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