diff --git a/packages/docs/docs/media/audio.mdx b/packages/docs/docs/media/audio.mdx
index a1fb71898ac..cff1e9bc82c 100644
--- a/packages/docs/docs/media/audio.mdx
+++ b/packages/docs/docs/media/audio.mdx
@@ -170,6 +170,23 @@ export const MyComposition = () => {
};
```
+### `crossOrigin?`
+
+Controls the CORS mode when the audio is fetched. Set to `'anonymous'` or `'use-credentials'` to pass the corresponding [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#credentials) options.
+
+```tsx twoslash title="Loading a remote audio file with CORS enabled"
+import {AbsoluteFill} from 'remotion';
+import {Audio} from '@remotion/media';
+// ---cut---
+export const MyComposition = () => {
+ return (
+
+
+
+ );
+};
+```
+
### `showInTimeline?`
If set to `false`, no layer will be shown in the timeline of the Remotion Studio. The default is `true`.
diff --git a/packages/docs/docs/media/video.mdx b/packages/docs/docs/media/video.mdx
index 94e7fc9c30d..c083e473541 100644
--- a/packages/docs/docs/media/video.mdx
+++ b/packages/docs/docs/media/video.mdx
@@ -158,6 +158,23 @@ export const MyComposition = () => {
};
```
+### `crossOrigin?`
+
+Controls the CORS mode when the video is fetched. Set to `'anonymous'` or `'use-credentials'` to pass the corresponding [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#credentials) options.
+
+```tsx twoslash title="Loading a remote video with CORS enabled"
+import {AbsoluteFill} from 'remotion';
+import {Video} from '@remotion/media';
+// ---cut---
+export const MyComposition = () => {
+ return (
+
+
+
+ );
+};
+```
+
### `style?`
You can pass any style you can pass to a native HTML `` element.
diff --git a/packages/media/src/audio-extraction/extract-audio.ts b/packages/media/src/audio-extraction/extract-audio.ts
index 583a990c70e..c3c5e641b3b 100644
--- a/packages/media/src/audio-extraction/extract-audio.ts
+++ b/packages/media/src/audio-extraction/extract-audio.ts
@@ -27,6 +27,7 @@ type ExtractAudioParams = {
trimAfter: number | undefined;
fps: number;
maxCacheSize: number;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
};
const extractAudioInternal = async ({
@@ -41,6 +42,7 @@ const extractAudioInternal = async ({
trimAfter,
fps,
maxCacheSize,
+ crossOrigin,
}: ExtractAudioParams): Promise<
| {
data: PcmS16AudioData | null;
@@ -51,7 +53,7 @@ const extractAudioInternal = async ({
| 'network-error'
> => {
const {getAudio, actualMatroskaTimestamps, isMatroska, getDuration} =
- await getSink(src, logLevel);
+ await getSink({src, logLevel, crossOrigin});
let mediaDurationInSeconds: number | null = null;
if (loop) {
diff --git a/packages/media/src/audio/audio-for-preview.tsx b/packages/media/src/audio/audio-for-preview.tsx
index 10ef6472634..9c4ba8d1be8 100644
--- a/packages/media/src/audio/audio-for-preview.tsx
+++ b/packages/media/src/audio/audio-for-preview.tsx
@@ -50,6 +50,7 @@ type NewAudioForPreviewProps = {
readonly toneFrequency: number | undefined;
readonly audioStreamIndex: number | undefined;
readonly fallbackHtml5AudioProps: FallbackHtml5AudioProps | undefined;
+ readonly crossOrigin?: '' | 'anonymous' | 'use-credentials';
};
const AudioForPreviewAssertedShowing: React.FC = ({
@@ -69,6 +70,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({
toneFrequency,
audioStreamIndex,
fallbackHtml5AudioProps,
+ crossOrigin,
}) => {
const videoConfig = useUnsafeVideoConfig();
const frame = useCurrentFrame();
@@ -176,6 +178,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({
isPostmounting,
isPremounting,
globalPlaybackRate,
+ crossOrigin,
});
mediaPlayerRef.current = player;
@@ -303,6 +306,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({
isPremounting,
isPostmounting,
globalPlaybackRate,
+ crossOrigin,
]);
useEffect(() => {
@@ -436,6 +440,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({
toneFrequency={toneFrequency}
audioStreamIndex={audioStreamIndex}
pauseWhenBuffering={fallbackHtml5AudioProps?.pauseWhenBuffering}
+ crossOrigin={crossOrigin}
{...fallbackHtml5AudioProps}
/>
);
@@ -469,6 +474,7 @@ type InnerAudioProps = {
readonly toneFrequency?: number;
readonly audioStreamIndex?: number;
readonly fallbackHtml5AudioProps?: FallbackHtml5AudioProps;
+ readonly crossOrigin?: '' | 'anonymous' | 'use-credentials';
};
export const AudioForPreview: React.FC = ({
@@ -488,6 +494,7 @@ export const AudioForPreview: React.FC = ({
toneFrequency,
audioStreamIndex,
fallbackHtml5AudioProps,
+ crossOrigin,
}) => {
const preloadedSrc = usePreload(src);
@@ -546,6 +553,7 @@ export const AudioForPreview: React.FC = ({
disallowFallbackToHtml5Audio={disallowFallbackToHtml5Audio ?? false}
toneFrequency={toneFrequency}
fallbackHtml5AudioProps={fallbackHtml5AudioProps}
+ crossOrigin={crossOrigin}
/>
);
};
diff --git a/packages/media/src/audio/audio-for-rendering.tsx b/packages/media/src/audio/audio-for-rendering.tsx
index 27910c482fc..b90e5868b21 100644
--- a/packages/media/src/audio/audio-for-rendering.tsx
+++ b/packages/media/src/audio/audio-for-rendering.tsx
@@ -21,6 +21,7 @@ export const AudioForRendering: React.FC = ({
playbackRate,
src,
muted,
+ crossOrigin,
loopVolumeCurveBehavior,
delayRenderRetries,
delayRenderTimeoutInMilliseconds,
@@ -121,6 +122,7 @@ export const AudioForRendering: React.FC = ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
})
.then((result) => {
if (result.type === 'unknown-container-format') {
@@ -257,6 +259,7 @@ export const AudioForRendering: React.FC = ({
trimBefore,
replaceWithHtml5Audio,
maxCacheSize,
+ crossOrigin,
]);
if (replaceWithHtml5Audio) {
@@ -272,6 +275,7 @@ export const AudioForRendering: React.FC = ({
style={style}
loopVolumeCurveBehavior={loopVolumeCurveBehavior}
audioStreamIndex={audioStreamIndex}
+ crossOrigin={crossOrigin}
useWebAudioApi={fallbackHtml5AudioProps?.useWebAudioApi}
onError={fallbackHtml5AudioProps?.onError}
toneFrequency={toneFrequency}
diff --git a/packages/media/src/audio/props.ts b/packages/media/src/audio/props.ts
index 4b3b88337d3..829b8e758ca 100644
--- a/packages/media/src/audio/props.ts
+++ b/packages/media/src/audio/props.ts
@@ -17,6 +17,7 @@ export type AudioProps = {
showInTimeline?: boolean;
playbackRate?: number;
muted?: boolean;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
style?: React.CSSProperties;
/**
* @deprecated For internal use only
diff --git a/packages/media/src/extract-frame-and-audio.ts b/packages/media/src/extract-frame-and-audio.ts
index ccbe81fd832..ad5244666cd 100644
--- a/packages/media/src/extract-frame-and-audio.ts
+++ b/packages/media/src/extract-frame-and-audio.ts
@@ -19,6 +19,7 @@ export const extractFrameAndAudio = async ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
}: {
src: string;
timeInSeconds: number;
@@ -33,6 +34,7 @@ export const extractFrameAndAudio = async ({
trimBefore: number | undefined;
fps: number;
maxCacheSize: number;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
}): Promise => {
try {
const [frame, audio] = await Promise.all([
@@ -47,6 +49,7 @@ export const extractFrameAndAudio = async ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
})
: null,
includeAudio
@@ -62,6 +65,7 @@ export const extractFrameAndAudio = async ({
fps,
trimBefore,
maxCacheSize,
+ crossOrigin,
})
: null,
]);
diff --git a/packages/media/src/get-sink.ts b/packages/media/src/get-sink.ts
index f6a0d6e6578..a72097fdf06 100644
--- a/packages/media/src/get-sink.ts
+++ b/packages/media/src/get-sink.ts
@@ -5,8 +5,24 @@ import {getSinks} from './video-extraction/get-frames-since-keyframe';
export const sinkPromises: Record> = {};
-export const getSink = (src: string, logLevel: LogLevel) => {
- let promise = sinkPromises[src];
+const getSinkKey = (
+ src: string,
+ crossOrigin?: '' | 'anonymous' | 'use-credentials',
+) => {
+ return JSON.stringify({src, crossOrigin: crossOrigin ?? null});
+};
+
+export const getSink = ({
+ src,
+ logLevel,
+ crossOrigin,
+}: {
+ src: string;
+ logLevel: LogLevel;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
+}) => {
+ const key = getSinkKey(src, crossOrigin);
+ let promise = sinkPromises[key];
if (!promise) {
Internals.Log.verbose(
{
@@ -15,8 +31,8 @@ export const getSink = (src: string, logLevel: LogLevel) => {
},
`Sink for ${src} was not found, creating new sink`,
);
- promise = getSinks(src);
- sinkPromises[src] = promise;
+ promise = getSinks(src, crossOrigin);
+ sinkPromises[key] = promise;
}
return promise;
diff --git a/packages/media/src/helpers/get-request-init.ts b/packages/media/src/helpers/get-request-init.ts
new file mode 100644
index 00000000000..774c86d2634
--- /dev/null
+++ b/packages/media/src/helpers/get-request-init.ts
@@ -0,0 +1,21 @@
+export const getRequestInit = ({
+ crossOrigin,
+}: {
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
+}): RequestInit | undefined => {
+ if (crossOrigin === '' || crossOrigin === undefined) {
+ return undefined;
+ }
+
+ if (crossOrigin === 'use-credentials') {
+ return {
+ mode: 'cors',
+ credentials: 'include',
+ };
+ }
+
+ return {
+ mode: 'cors',
+ credentials: 'omit',
+ };
+};
diff --git a/packages/media/src/media-player.ts b/packages/media/src/media-player.ts
index 0ed2c501993..e49edb6a26e 100644
--- a/packages/media/src/media-player.ts
+++ b/packages/media/src/media-player.ts
@@ -8,6 +8,7 @@ import {
import {calculatePlaybackTime} from './calculate-playbacktime';
import {drawPreviewOverlay} from './debug-overlay/preview-overlay';
import {getTimeInSeconds} from './get-time-in-seconds';
+import {getRequestInit} from './helpers/get-request-init';
import {isNetworkError} from './is-network-error';
import type {Nonce, NonceManager} from './nonce-manager';
import {makeNonceManager} from './nonce-manager';
@@ -71,6 +72,7 @@ export class MediaPlayer {
constructor({
canvas,
src,
+ crossOrigin,
logLevel,
sharedAudioContext,
loop,
@@ -92,6 +94,7 @@ export class MediaPlayer {
loop: boolean;
trimBefore: number | undefined;
trimAfter: number | undefined;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
playbackRate: number;
globalPlaybackRate: number;
audioStreamIndex: number;
@@ -118,8 +121,10 @@ export class MediaPlayer {
this.isPostmounting = isPostmounting;
this.nonceManager = makeNonceManager();
+ const requestInit = getRequestInit({crossOrigin});
+
this.input = new Input({
- source: new UrlSource(this.src),
+ source: new UrlSource(this.src, requestInit ? {requestInit} : undefined),
formats: ALL_FORMATS,
});
diff --git a/packages/media/src/test/cross-origin.test.ts b/packages/media/src/test/cross-origin.test.ts
new file mode 100644
index 00000000000..4072aaa9906
--- /dev/null
+++ b/packages/media/src/test/cross-origin.test.ts
@@ -0,0 +1,79 @@
+import {afterEach, beforeEach, expect, test, vi} from 'vitest';
+import {getSink, sinkPromises} from '../get-sink';
+import {getRequestInit} from '../helpers/get-request-init';
+import {getSinks} from '../video-extraction/get-frames-since-keyframe';
+
+vi.mock('../video-extraction/get-frames-since-keyframe', () => ({
+ getSinks: vi.fn(),
+}));
+
+type MockedGetSinks = ReturnType>;
+const mockedGetSinks = getSinks as unknown as MockedGetSinks;
+
+const clearSinkPromises = () => {
+ for (const key of Object.keys(sinkPromises)) {
+ delete sinkPromises[key];
+ }
+};
+
+beforeEach(() => {
+ mockedGetSinks.mockReset();
+ clearSinkPromises();
+});
+
+afterEach(() => {
+ clearSinkPromises();
+});
+
+test('getRequestInit returns expected RequestInit values', () => {
+ expect(getRequestInit({crossOrigin: undefined})).toBeUndefined();
+ expect(getRequestInit({crossOrigin: ''})).toBeUndefined();
+ expect(getRequestInit({crossOrigin: 'anonymous'})).toEqual({
+ mode: 'cors',
+ credentials: 'omit',
+ });
+ expect(getRequestInit({crossOrigin: 'use-credentials'})).toEqual({
+ mode: 'cors',
+ credentials: 'include',
+ });
+});
+
+test('getSink caches sinks separately by crossOrigin value', async () => {
+ mockedGetSinks.mockImplementation((src, crossOrigin) =>
+ Promise.resolve({src, crossOrigin} as never),
+ );
+
+ const defaultSink = getSink({
+ src: 'video.mp4',
+ logLevel: 'info',
+ crossOrigin: undefined,
+ });
+ const sameDefaultSink = getSink({
+ src: 'video.mp4',
+ logLevel: 'info',
+ crossOrigin: undefined,
+ });
+
+ expect(mockedGetSinks).toHaveBeenCalledTimes(1);
+ expect(defaultSink).toBe(sameDefaultSink);
+
+ const anonymousSink = getSink({
+ src: 'video.mp4',
+ logLevel: 'info',
+ crossOrigin: 'anonymous',
+ });
+
+ expect(mockedGetSinks).toHaveBeenCalledTimes(2);
+ expect(anonymousSink).not.toBe(defaultSink);
+
+ const credentialedSink = getSink({
+ src: 'video.mp4',
+ logLevel: 'info',
+ crossOrigin: 'use-credentials',
+ });
+
+ expect(mockedGetSinks).toHaveBeenCalledTimes(3);
+ expect(credentialedSink).not.toBe(anonymousSink);
+
+ expect(Object.keys(sinkPromises).length).toBe(3);
+});
diff --git a/packages/media/src/video-extraction/extract-frame-via-broadcast-channel.ts b/packages/media/src/video-extraction/extract-frame-via-broadcast-channel.ts
index 96bdbf3f541..1be8353dc1d 100644
--- a/packages/media/src/video-extraction/extract-frame-via-broadcast-channel.ts
+++ b/packages/media/src/video-extraction/extract-frame-via-broadcast-channel.ts
@@ -18,6 +18,7 @@ type ExtractFrameRequest = {
trimBefore: number | undefined;
fps: number;
maxCacheSize: number;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
};
type ExtractFrameResponse =
@@ -78,6 +79,7 @@ if (
trimBefore: data.trimBefore,
fps: data.fps,
maxCacheSize: data.maxCacheSize,
+ crossOrigin: data.crossOrigin,
});
if (result.type === 'cannot-decode') {
@@ -185,6 +187,7 @@ export const extractFrameViaBroadcastChannel = ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
}: {
src: string;
timeInSeconds: number;
@@ -200,6 +203,7 @@ export const extractFrameViaBroadcastChannel = ({
trimBefore: number | undefined;
fps: number;
maxCacheSize: number;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
}): Promise => {
if (isClientSideRendering || window.remotion_isMainTab) {
return extractFrameAndAudio({
@@ -216,6 +220,7 @@ export const extractFrameViaBroadcastChannel = ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
});
}
@@ -335,6 +340,7 @@ export const extractFrameViaBroadcastChannel = ({
trimBefore,
fps,
maxCacheSize,
+ crossOrigin,
};
window.remotion_broadcastChannel!.postMessage(request);
diff --git a/packages/media/src/video-extraction/extract-frame.ts b/packages/media/src/video-extraction/extract-frame.ts
index 51e363e4a61..a154adf621c 100644
--- a/packages/media/src/video-extraction/extract-frame.ts
+++ b/packages/media/src/video-extraction/extract-frame.ts
@@ -25,6 +25,7 @@ type ExtractFrameParams = {
playbackRate: number;
fps: number;
maxCacheSize: number;
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
};
const extractFrameInternal = async ({
@@ -37,8 +38,9 @@ const extractFrameInternal = async ({
playbackRate,
fps,
maxCacheSize,
+ crossOrigin,
}: ExtractFrameParams): Promise => {
- const sink = await getSink(src, logLevel);
+ const sink = await getSink({src, logLevel, crossOrigin});
const video = await sink.getVideo();
diff --git a/packages/media/src/video-extraction/get-frames-since-keyframe.ts b/packages/media/src/video-extraction/get-frames-since-keyframe.ts
index 3ada3825819..d71eb5a6095 100644
--- a/packages/media/src/video-extraction/get-frames-since-keyframe.ts
+++ b/packages/media/src/video-extraction/get-frames-since-keyframe.ts
@@ -10,6 +10,7 @@ import {
WEBM,
} from 'mediabunny';
import type {LogLevel} from 'remotion';
+import {getRequestInit} from '../helpers/get-request-init';
import {isNetworkError} from '../is-network-error';
import {makeKeyframeBank} from './keyframe-bank';
import {rememberActualMatroskaTimestamps} from './remember-actual-matroska-timestamps';
@@ -54,11 +55,16 @@ const getFormatOrNullOrNetworkError = async (
}
};
-export const getSinks = async (src: string) => {
+export const getSinks = async (
+ src: string,
+ crossOrigin?: '' | 'anonymous' | 'use-credentials',
+) => {
+ const requestInit = getRequestInit({crossOrigin});
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay,
+ ...(requestInit ? {requestInit} : {}),
}),
});
diff --git a/packages/media/src/video/props.ts b/packages/media/src/video/props.ts
index e8fde292f1b..8f6ad270652 100644
--- a/packages/media/src/video/props.ts
+++ b/packages/media/src/video/props.ts
@@ -32,6 +32,7 @@ type OptionalVideoProps = {
delayRenderRetries: number | null;
delayRenderTimeoutInMilliseconds: number | null;
style: React.CSSProperties;
+ crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined;
/**
* @deprecated For internal use only
*/
diff --git a/packages/media/src/video/video-for-preview.tsx b/packages/media/src/video/video-for-preview.tsx
index 551f7c7adf0..c5e5a47f945 100644
--- a/packages/media/src/video/video-for-preview.tsx
+++ b/packages/media/src/video/video-for-preview.tsx
@@ -54,6 +54,7 @@ type VideoForPreviewProps = {
readonly fallbackOffthreadVideoProps: FallbackOffthreadVideoProps;
readonly audioStreamIndex: number;
readonly debugOverlay: boolean;
+ readonly crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined;
};
const VideoForPreviewAssertedShowing: React.FC = ({
@@ -76,6 +77,7 @@ const VideoForPreviewAssertedShowing: React.FC = ({
fallbackOffthreadVideoProps,
audioStreamIndex,
debugOverlay,
+ crossOrigin,
}) => {
const src = usePreload(unpreloadedSrc);
@@ -184,6 +186,7 @@ const VideoForPreviewAssertedShowing: React.FC = ({
isPremounting,
isPostmounting,
globalPlaybackRate,
+ crossOrigin,
});
mediaPlayerRef.current = player;
@@ -305,6 +308,7 @@ const VideoForPreviewAssertedShowing: React.FC = ({
isPremounting,
isPostmounting,
globalPlaybackRate,
+ crossOrigin,
]);
const classNameValue = useMemo(() => {
@@ -471,6 +475,7 @@ const VideoForPreviewAssertedShowing: React.FC = ({
loop={loop}
showInTimeline={showInTimeline}
stack={stack ?? undefined}
+ crossOrigin={crossOrigin}
{...fallbackOffthreadVideoProps}
/>
);
diff --git a/packages/media/src/video/video-for-rendering.tsx b/packages/media/src/video/video-for-rendering.tsx
index 6574e9f42a8..4086150229b 100644
--- a/packages/media/src/video/video-for-rendering.tsx
+++ b/packages/media/src/video/video-for-rendering.tsx
@@ -48,6 +48,7 @@ type InnerVideoProps = {
readonly toneFrequency: number;
readonly trimBeforeValue: number | undefined;
readonly trimAfterValue: number | undefined;
+ readonly crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined;
};
type FallbackToOffthreadVideo = {
@@ -75,6 +76,7 @@ export const VideoForRendering: React.FC = ({
toneFrequency,
trimAfterValue,
trimBeforeValue,
+ crossOrigin,
}) => {
if (!src) {
throw new TypeError('No `src` was passed to .');
@@ -176,6 +178,7 @@ export const VideoForRendering: React.FC = ({
trimBefore: trimBeforeValue,
fps,
maxCacheSize,
+ crossOrigin,
})
.then((result) => {
if (result.type === 'unknown-container-format') {
@@ -371,6 +374,7 @@ export const VideoForRendering: React.FC = ({
videoEnabled,
maxCacheSize,
cancelRender,
+ crossOrigin,
]);
const classNameValue = useMemo(() => {
@@ -407,7 +411,7 @@ export const VideoForRendering: React.FC = ({
toneFrequency={toneFrequency}
// these shouldn't matter during rendering / should not appear at all
showInTimeline={false}
- crossOrigin={undefined}
+ crossOrigin={crossOrigin}
onAutoPlayError={() => undefined}
pauseWhenBuffering={false}
trimAfter={trimAfterValue}
diff --git a/packages/media/src/video/video.tsx b/packages/media/src/video/video.tsx
index 3f94a802b20..fa081bbe4b8 100644
--- a/packages/media/src/video/video.tsx
+++ b/packages/media/src/video/video.tsx
@@ -23,6 +23,7 @@ const InnerVideo: React.FC = ({
onVideoFrame,
playbackRate,
style,
+ crossOrigin,
trimAfter,
trimBefore,
volume,
@@ -80,6 +81,7 @@ const InnerVideo: React.FC = ({
src={src}
stack={stack}
style={style}
+ crossOrigin={crossOrigin}
volume={volume}
toneFrequency={toneFrequency}
trimAfterValue={trimAfterValue}
@@ -101,6 +103,7 @@ const InnerVideo: React.FC = ({
playbackRate={playbackRate}
src={src}
style={style}
+ crossOrigin={crossOrigin}
volume={volume}
showInTimeline={showInTimeline}
trimAfter={trimAfterValue}
@@ -130,6 +133,7 @@ export const Video: React.FC = ({
playbackRate,
showInTimeline,
style,
+ crossOrigin,
trimAfter,
trimBefore,
volume,
@@ -164,6 +168,7 @@ export const Video: React.FC = ({
showInTimeline={showInTimeline ?? true}
src={src}
style={style ?? {}}
+ crossOrigin={crossOrigin}
trimAfter={trimAfter}
trimBefore={trimBefore}
volume={volume ?? 1}