From aceb44a97ba8a04ef570e50d85bda6d3a9bf5971 Mon Sep 17 00:00:00 2001 From: BarriBarri20 Date: Fri, 21 Aug 2026 23:50:30 +0100 Subject: [PATCH 01/35] continueRender in finally: shader draw exceptions must not leak the delayRender handle --- .../src/html-in-canvas-presentation.tsx | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/packages/transitions/src/html-in-canvas-presentation.tsx b/packages/transitions/src/html-in-canvas-presentation.tsx index c977c0c8cd7..5784da61ffb 100644 --- a/packages/transitions/src/html-in-canvas-presentation.tsx +++ b/packages/transitions/src/html-in-canvas-presentation.tsx @@ -113,53 +113,54 @@ export const HtmlInCanvasPresentation = < } const handle = delayRender('onPaint'); - const clearOutput = () => { - const context = outputCanvas.getContext('2d'); - if (!context) { - throw new Error('Failed to create output canvas context'); + try { + const clearOutput = () => { + const context = outputCanvas.getContext('2d'); + if (!context) { + throw new Error('Failed to create output canvas context'); + } + + context.clearRect(0, 0, outputCanvas.width, outputCanvas.height); + }; + + if (!prevImage && !nextImage) { + instance.clear(); + clearOutput(); + return; } - context.clearRect(0, 0, outputCanvas.width, outputCanvas.height); - }; - - if (!prevImage && !nextImage) { - instance.clear(); - clearOutput(); - continueRender(handle); - return; - } + const width = prevImage?.width ?? nextImage?.width ?? 0; + const height = prevImage?.height ?? nextImage?.height ?? 0; - const width = prevImage?.width ?? nextImage?.width ?? 0; - const height = prevImage?.height ?? nextImage?.height ?? 0; + if (width === 0 || height === 0) { + instance.clear(); + clearOutput(); + return; + } - if (width === 0 || height === 0) { - instance.clear(); - clearOutput(); + shaderCanvas.width = width; + shaderCanvas.height = height; + + instance.draw({ + prevImage, + nextImage, + width, + height, + time: progress, + passedProps: passedPropsRef.current, + }); + + await Internals.runEffectChain({ + state: chainState.get(width, height)!, + source: shaderCanvas, + effects: effectsRef.current ?? [], + width, + height, + output: outputCanvas, + }); + } finally { continueRender(handle); - return; } - - shaderCanvas.width = width; - shaderCanvas.height = height; - - instance.draw({ - prevImage, - nextImage, - width, - height, - time: progress, - passedProps: passedPropsRef.current, - }); - - await Internals.runEffectChain({ - state: chainState.get(width, height)!, - source: shaderCanvas, - effects: effectsRef.current ?? [], - width, - height, - output: outputCanvas, - }); - continueRender(handle); }, [chainState, continueRender, delayRender, instance, shaderCanvas], ); From c18d16dda3b888fc398fe1da2fc885178b1ed6a2 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:20:48 -0400 Subject: [PATCH 02/35] `remotion`: Replace imperative playing ref with `playbackStore` and `usePlaying()` Move playing state into the existing runtime value store so Player, Studio, and media tags share one source of truth instead of separate React state and imperativePlaying refs. Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 61 +++++------- packages/core/src/freeze.tsx | 8 +- packages/core/src/index.ts | 1 + packages/core/src/internals.ts | 8 +- .../core/src/test/nested-sequences.test.tsx | 16 +--- packages/core/src/test/series.test.tsx | 6 +- .../core/src/test/wrap-sequence-context.tsx | 4 +- packages/core/src/timeline-position-state.ts | 21 ++++- packages/core/src/use-media-playback.ts | 4 +- packages/core/src/use-media-tag.ts | 6 +- packages/core/src/use-playing.ts | 11 +++ packages/core/src/wrap-remotion-context.tsx | 39 +++----- packages/gif/src/test/Gif.test.tsx | 3 +- .../src/test/resolve-cursor.test.ts | 3 +- .../media/src/audio/audio-for-preview.tsx | 4 +- .../media/src/video/video-for-preview.tsx | 4 +- packages/player/src/EmitterProvider.tsx | 9 +- packages/player/src/Player.tsx | 25 +++-- packages/player/src/PlayerUI.tsx | 2 +- packages/player/src/SharedPlayerContext.tsx | 78 +++++++-------- packages/player/src/Thumbnail.tsx | 4 +- packages/player/src/browser-mediasession.ts | 2 +- packages/player/src/test/index.test.ts | 38 -------- .../src/timeline-imperative-context.tsx | 72 -------------- packages/player/src/use-playback.ts | 2 +- packages/player/src/use-player-methods.ts | 94 +++++++------------ packages/studio/src/components/FpsCounter.tsx | 2 +- .../studio/src/components/FramePersistor.tsx | 2 +- .../Timeline/TimelinePlayCursorSyncer.tsx | 2 +- packages/studio/src/components/WebMcp.tsx | 6 +- .../studio/src/components/ZoomPersistor.tsx | 2 +- packages/test-utils/src/timeline-context.ts | 6 +- 32 files changed, 197 insertions(+), 348 deletions(-) create mode 100644 packages/core/src/use-playing.ts delete mode 100644 packages/player/src/timeline-imperative-context.tsx diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 303adb86f75..ac7d2f60c9b 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -6,6 +6,10 @@ import React, { useRef, useState, } from 'react'; +import { + createRuntimeValueStore, + type RuntimeValueStoreController, +} from './runtime-value-store.js'; import { getInitialFrameState, type PlayableMediaTag, @@ -14,14 +18,7 @@ import {useDelayRender} from './use-delay-render'; export type TimelineContextValue = { frame: Record; - playing: boolean; - imperativePlaying: RefObject; - audioAndVideoTags: RefObject; -}; - -export type TimelineImperativeContextValue = { - frameRef: RefObject>; - imperativePlaying: RefObject; + playbackStore: RuntimeValueStoreController; audioAndVideoTags: RefObject; }; @@ -46,9 +43,6 @@ export const SetTimelineContext = createContext({ export const TimelineContext = createContext(null); -export const TimelineImperativeContext = - createContext(null); - export const PlaybackRateContext = createContext(null); @@ -60,8 +54,10 @@ export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; }> = ({children, frameState}) => { - const [playing, setPlaying] = useState(false); - const imperativePlaying = useRef(false); + const playbackStore = useMemo( + () => createRuntimeValueStore({playing: false}), + [], + ); const [playbackRate, setPlaybackRate] = useState(1); const audioAndVideoTags = useRef([]); @@ -113,20 +109,10 @@ export const TimelineContextProvider: React.FC<{ const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playing, - imperativePlaying, + playbackStore, audioAndVideoTags, }; - }, [frame, playing]); - - const timelineImperativeContextValue = - useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying, - audioAndVideoTags, - }; - }, []); + }, [frame, playbackStore]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -138,22 +124,25 @@ export const TimelineContextProvider: React.FC<{ const setTimelineContextValue = useMemo((): SetTimelineContextValue => { return { setFrame, - setPlaying, + setPlaying: (updater) => { + if (typeof updater === 'function') { + const current = playbackStore.store.getSnapshot().playing as boolean; + playbackStore.setSnapshot({playing: updater(current)}); + } else { + playbackStore.setSnapshot({playing: updater}); + } + }, }; - }, []); + }, [playbackStore]); return ( - - - - {children} - - - + + + {children} + + ); diff --git a/packages/core/src/freeze.tsx b/packages/core/src/freeze.tsx index 2f911f6c464..48264e9b998 100644 --- a/packages/core/src/freeze.tsx +++ b/packages/core/src/freeze.tsx @@ -1,4 +1,5 @@ import React, {useContext, useMemo} from 'react'; +import {createRuntimeValueStore} from './runtime-value-store.js'; import type {SequenceContextType} from './SequenceContext.js'; import {SequenceContext} from './SequenceContext.js'; import {useTimelineContext} from './timeline-position-state.js'; @@ -69,12 +70,11 @@ export const Freeze: React.FC = ({ return timelineContext; } + const frozenStore = createRuntimeValueStore({playing: false}); + return { ...timelineContext, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: frozenStore, frame: { [videoConfig.id]: frameToFreeze + relativeFrom, }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7948e6e5fff..1105b8c1551 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -250,6 +250,7 @@ export * from './Still.js'; export type {PlayableMediaTag} from './timeline-position-state.js'; export {useBufferState} from './use-buffer-state'; export {useCurrentFrame} from './use-current-frame.js'; +export {usePlaying} from './use-playing.js'; export { CurrentScaleContextType, PreviewSize, diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index c6ead6ff781..eafd73f022a 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -157,6 +157,7 @@ import { resolveCompositionsRef, useResolvedVideoConfig, } from './ResolveCompositionConfig.js'; +import {createRuntimeValueStore} from './runtime-value-store.js'; import {resolveSequenceCrop} from './sequence-crop.js'; import type { OverrideIdToNodePaths, @@ -206,10 +207,8 @@ import { PlaybackRateContext, SetTimelineContext, TimelineContext, - TimelineImperativeContext, type PlaybackRateContextValue, type SetTimelineContextValue, - type TimelineImperativeContextValue, type TimelineContextValue, } from './TimelineContext.js'; import {truthy} from './truthy.js'; @@ -227,6 +226,7 @@ import { useMediaInTimeline, } from './use-media-in-timeline.js'; import {PixelDensityContext} from './use-pixel-density.js'; +import {usePlaying} from './use-playing.js'; import {usePremounting} from './use-premounting.js'; import type { CanUpdateSequencePropStatusFalse, @@ -309,6 +309,7 @@ export const Internals = { MEDIABUNNY_DURATION_VALUE_KEY, makeRenderResourceManager, RenderResourceManagerContext, + createRuntimeValueStore, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, @@ -439,7 +440,7 @@ export const Internals = { TimelinePosition, DelayRenderContextType, TimelineContext, - TimelineImperativeContext, + usePlaying, PlaybackRateContext, AbsoluteTimeContext, RenderAssetManagerProvider, @@ -528,7 +529,6 @@ export type { TCompMetadata, TComposition, TimelineContextValue, - TimelineImperativeContextValue, TRenderAsset, TSequence, VisibleFieldSchema, diff --git a/packages/core/src/test/nested-sequences.test.tsx b/packages/core/src/test/nested-sequences.test.tsx index d745c953ee5..32bc710194a 100644 --- a/packages/core/src/test/nested-sequences.test.tsx +++ b/packages/core/src/test/nested-sequences.test.tsx @@ -3,6 +3,7 @@ import {cleanup, render} from '@testing-library/react'; import {useEffect} from 'react'; import {AbsoluteFill} from '../AbsoluteFill.js'; import {getTimelineDuration} from '../get-timeline-duration.js'; +import {createRuntimeValueStore} from '../runtime-value-store.js'; import {Sequence} from '../Sequence.js'; import {TimelineContext} from '../TimelineContext.js'; import {useCurrentFrame} from '../use-current-frame.js'; @@ -18,10 +19,7 @@ const getForFrame = (frame: number, content: React.ReactNode) => { frame: { 'my-comp': frame, }, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: createRuntimeValueStore({playing: false}), audioAndVideoTags: { current: [], }, @@ -258,10 +256,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 15, }, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: createRuntimeValueStore({playing: false}), audioAndVideoTags: { current: [], }, @@ -282,10 +277,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 25, }, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: createRuntimeValueStore({playing: false}), audioAndVideoTags: { current: [], }, diff --git a/packages/core/src/test/series.test.tsx b/packages/core/src/test/series.test.tsx index 629c6a71874..991896089a7 100644 --- a/packages/core/src/test/series.test.tsx +++ b/packages/core/src/test/series.test.tsx @@ -4,6 +4,7 @@ import {renderToString} from 'react-dom/server'; import {AbsoluteFill} from '../AbsoluteFill.js'; import {CanUseRemotionHooksProvider} from '../CanUseRemotionHooks.js'; import {Internals} from '../internals.js'; +import {createRuntimeValueStore} from '../runtime-value-store.js'; import {Sequence} from '../Sequence.js'; import {Series} from '../series/index.js'; import type {TimelineContextValue} from '../TimelineContext.js'; @@ -50,10 +51,7 @@ const renderForFrame = (frame: number, markup: React.ReactNode) => { frame: { 'my-comp': frame, }, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: createRuntimeValueStore({playing: false}), audioAndVideoTags: {current: []}, }; diff --git a/packages/core/src/test/wrap-sequence-context.tsx b/packages/core/src/test/wrap-sequence-context.tsx index 09a2f7b6991..dd1480741cc 100644 --- a/packages/core/src/test/wrap-sequence-context.tsx +++ b/packages/core/src/test/wrap-sequence-context.tsx @@ -5,6 +5,7 @@ import type {CompositionManagerContext} from '../CompositionManagerContext.js'; import {CompositionManager} from '../CompositionManagerContext.js'; import type {LoggingContextValue} from '../log-level-context.js'; import {LogLevelContext} from '../log-level-context.js'; +import {createRuntimeValueStore} from '../runtime-value-store.js'; import {SequenceManagerProvider} from '../SequenceManager.js'; import type { PlaybackRateContextValue, @@ -115,8 +116,7 @@ export const WrapSequenceContext: React.FC<{ const timelineContext = useMemo( () => ({ frame: {'my-comp': currentFrame}, - playing: false, - imperativePlaying: {current: false}, + playbackStore: createRuntimeValueStore({playing: false}), audioAndVideoTags: {current: []}, }), [currentFrame], diff --git a/packages/core/src/timeline-position-state.ts b/packages/core/src/timeline-position-state.ts index d5ad0acb61a..c42d10b803e 100644 --- a/packages/core/src/timeline-position-state.ts +++ b/packages/core/src/timeline-position-state.ts @@ -8,6 +8,7 @@ import { type PlaybackRateContextValue, type TimelineContextValue, } from './TimelineContext.js'; +import {usePlaying} from './use-playing.js'; import {useRemotionEnvironment} from './use-remotion-environment.js'; import {useVideo} from './use-video.js'; @@ -125,11 +126,25 @@ type PlayingReturnType = readonly [ ]; export const usePlayingState = (): PlayingReturnType => { - const {playing, imperativePlaying} = useTimelineContext(); + const {playbackStore} = useTimelineContext(); + const playing = usePlaying(); const {setPlaying} = useContext(SetTimelineContext); return useMemo( - () => [playing, setPlaying, imperativePlaying], - [imperativePlaying, playing, setPlaying], + () => [ + playing, + setPlaying, + { + get current() { + return playbackStore.store.getSnapshot().playing as boolean; + }, + set current(val) { + playbackStore.setSnapshot({playing: val}); + }, + }, + ], + [playbackStore, playing, setPlaying], ); }; + +export {usePlaying} from './use-playing.js'; diff --git a/packages/core/src/use-media-playback.ts b/packages/core/src/use-media-playback.ts index 2c80e83cc8e..74d1ec6036f 100644 --- a/packages/core/src/use-media-playback.ts +++ b/packages/core/src/use-media-playback.ts @@ -17,7 +17,7 @@ import {playAndHandleNotAllowedError} from './play-and-handle-not-allowed-error. import {playbackLogging} from './playback-logging.js'; import {seek} from './seek.js'; import { - usePlayingState, + usePlaying, usePlaybackRate, useTimelinePosition, } from './timeline-position-state.js'; @@ -57,7 +57,7 @@ export const useMediaPlayback = ({ const {playbackRate: globalPlaybackRate} = usePlaybackRate(); const frame = useCurrentFrame(); const absoluteFrame = useTimelinePosition(); - const [playing] = usePlayingState(); + const playing = usePlaying(); const buffering = useContext(BufferingContextReact); const {fps} = useVideoConfig(); const mediaStartsAt = useMediaStartsAt(); diff --git a/packages/core/src/use-media-tag.ts b/packages/core/src/use-media-tag.ts index 2b507ba8807..792335cafc6 100644 --- a/packages/core/src/use-media-tag.ts +++ b/packages/core/src/use-media-tag.ts @@ -21,7 +21,7 @@ export const useMediaTag = ({ isPremounting: boolean; isPostmounting: boolean; }) => { - const {audioAndVideoTags, imperativePlaying} = useTimelineContext(); + const {audioAndVideoTags, playbackStore} = useTimelineContext(); const logLevel = useLogLevel(); const mountTime = useMountTime(); const env = useRemotionEnvironment(); @@ -30,7 +30,7 @@ export const useMediaTag = ({ const tag: PlayableMediaTag = { id, play: (reason) => { - if (!imperativePlaying.current) { + if (!playbackStore.store.getSnapshot().playing) { // Don't play if for example in a state. return; } @@ -63,7 +63,7 @@ export const useMediaTag = ({ mediaRef, mediaType, onAutoPlayError, - imperativePlaying, + playbackStore, isPremounting, isPostmounting, logLevel, diff --git a/packages/core/src/use-playing.ts b/packages/core/src/use-playing.ts new file mode 100644 index 00000000000..b2573d4b278 --- /dev/null +++ b/packages/core/src/use-playing.ts @@ -0,0 +1,11 @@ +import {useSyncExternalStore} from 'react'; +import {useTimelineContext} from './timeline-position-state.js'; + +export const usePlaying = () => { + const {playbackStore} = useTimelineContext(); + return useSyncExternalStore( + playbackStore.store.subscribe, + () => playbackStore.store.getSnapshot().playing as boolean, + () => playbackStore.store.getSnapshot().playing as boolean, + ); +}; diff --git a/packages/core/src/wrap-remotion-context.tsx b/packages/core/src/wrap-remotion-context.tsx index c2772fec896..5029853e21a 100644 --- a/packages/core/src/wrap-remotion-context.tsx +++ b/packages/core/src/wrap-remotion-context.tsx @@ -17,16 +17,11 @@ import { SequenceManagerRefContext, VisualModePropStatusesRefContext, } from './SequenceManager.js'; -import { - SetTimelineContext, - TimelineContext, - TimelineImperativeContext, -} from './TimelineContext.js'; +import {SetTimelineContext, TimelineContext} from './TimelineContext.js'; export function useRemotionContexts() { const compositionManagerCtx = React.useContext(CompositionManager); const timelineContext = React.useContext(TimelineContext); - const timelineImperativeContext = React.useContext(TimelineImperativeContext); const setTimelineContext = React.useContext(SetTimelineContext); const sequenceContext = React.useContext(SequenceContext); const nonceContext = React.useContext(NonceContext); @@ -46,7 +41,6 @@ export function useRemotionContexts() { () => ({ compositionManagerCtx, timelineContext, - timelineImperativeContext, setTimelineContext, sequenceContext, nonceContext, @@ -66,7 +60,6 @@ export function useRemotionContexts() { sequenceContext, setTimelineContext, timelineContext, - timelineImperativeContext, canUseRemotionHooksContext, preloadContext, resolveCompositionContext, @@ -110,27 +103,23 @@ export const RemotionContextProvider = ( - - - - - - {children} - - - - - + {children} + + + + diff --git a/packages/gif/src/test/Gif.test.tsx b/packages/gif/src/test/Gif.test.tsx index accf55b6c67..237216c1a42 100644 --- a/packages/gif/src/test/Gif.test.tsx +++ b/packages/gif/src/test/Gif.test.tsx @@ -107,8 +107,7 @@ const compositionContext = { const timelineContext = { frame: {}, - playing: false, - imperativePlaying: {current: false}, + playbackStore: Internals.createRuntimeValueStore({playing: false}), audioAndVideoTags: {current: []}, } as React.ContextType; diff --git a/packages/mac-cursors/src/test/resolve-cursor.test.ts b/packages/mac-cursors/src/test/resolve-cursor.test.ts index 361769e6a70..2af3628a9d6 100644 --- a/packages/mac-cursors/src/test/resolve-cursor.test.ts +++ b/packages/mac-cursors/src/test/resolve-cursor.test.ts @@ -106,8 +106,7 @@ test(' renders the default cursor when the cursor prop is omitted', } as React.ContextType; const timeline = { frame: {comp: 0}, - playing: false, - imperativePlaying: {current: false}, + playbackStore: Internals.createRuntimeValueStore({playing: false}), audioAndVideoTags: {current: []}, } as React.ContextType; const markup = renderToString( diff --git a/packages/media/src/audio/audio-for-preview.tsx b/packages/media/src/audio/audio-for-preview.tsx index df3b0da4e39..6a4742088a0 100644 --- a/packages/media/src/audio/audio-for-preview.tsx +++ b/packages/media/src/audio/audio-for-preview.tsx @@ -16,7 +16,6 @@ import type {FallbackHtml5AudioProps} from './props'; const { useUnsafeVideoConfig, - Timeline, SharedAudioContext, usePlayerMutedState, useMediaVolumeState, @@ -25,6 +24,7 @@ const { warnAboutTooHighVolume, usePreload, SequenceContext, + usePlaying, } = Internals; type NewAudioForPreviewProps = { @@ -85,7 +85,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({ const [shouldFallbackToNativeAudio, setShouldFallbackToNativeAudio] = useState(false); - const [playing] = Timeline.usePlayingState(); + const playing = usePlaying(); const {playbackRate: globalPlaybackRate} = Internals.usePlaybackRate(); const sharedAudioContext = useContext(SharedAudioContext); const buffer = useBufferState(); diff --git a/packages/media/src/video/video-for-preview.tsx b/packages/media/src/video/video-for-preview.tsx index a32b791ed10..1191ca0d429 100644 --- a/packages/media/src/video/video-for-preview.tsx +++ b/packages/media/src/video/video-for-preview.tsx @@ -37,7 +37,6 @@ import {warnAboutObjectFitInStyleOrClassName} from './warn-object-fit-css'; const { useUnsafeVideoConfig, - Timeline, SharedAudioContext, usePlayerMutedState, useMediaVolumeState, @@ -47,6 +46,7 @@ const { usePreload, SequenceContext, useEffectChainState, + usePlaying, } = Internals; type VideoForPreviewProps = NativeVideoProps & { @@ -128,7 +128,7 @@ const VideoForPreviewAssertedShowing: React.FC< const [shouldFallbackToNativeVideo, setShouldFallbackToNativeVideo] = useState(false); - const [playing] = Timeline.usePlayingState(); + const playing = usePlaying(); const {playbackRate: globalPlaybackRate} = Internals.usePlaybackRate(); const sharedAudioContext = useContext(SharedAudioContext); const buffer = useBufferState(); diff --git a/packages/player/src/EmitterProvider.tsx b/packages/player/src/EmitterProvider.tsx index 3951660f2ce..f0d10599191 100644 --- a/packages/player/src/EmitterProvider.tsx +++ b/packages/player/src/EmitterProvider.tsx @@ -2,7 +2,6 @@ import React, {useContext, useEffect, useState} from 'react'; import {Internals} from 'remotion'; import {PlayerEventEmitterContext} from './emitter-context.js'; import {PlayerEmitter} from './event-emitter.js'; -import {TimelineImperativeCompatibilityProvider} from './timeline-imperative-context.js'; import {useBufferStateEmitter} from './use-buffer-state-emitter.js'; export const PlayerEmitterProvider: React.FC<{ @@ -24,10 +23,8 @@ export const PlayerEmitterProvider: React.FC<{ useBufferStateEmitter(emitter); return ( - - - {children} - - + + {children} + ); }; diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 606885ba3a6..7f704374162 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -242,10 +242,12 @@ const PlayerFn = < const [frame, setFrame] = useState>(() => ({ [PLAYER_COMP_ID]: initialFrame ?? 0, })); - const [playing, setPlaying] = useState(false); const rootRef = useRef(null); const audioAndVideoTags = useRef([]); - const imperativePlaying = useRef(false); + const playbackStore = useMemo( + () => Internals.createRuntimeValueStore({playing: false}), + [], + ); const [currentPlaybackRate, setCurrentPlaybackRate] = useState(playbackRate); if (typeof compositionHeight !== 'number') { @@ -410,11 +412,10 @@ const PlayerFn = < const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playing, - imperativePlaying, + playbackStore, audioAndVideoTags, }; - }, [frame, playing]); + }, [frame, playbackStore]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -426,9 +427,19 @@ const PlayerFn = < const setTimelineContextValue = useMemo((): SetTimelineContextValue => { return { setFrame, - setPlaying, + setPlaying: (updater) => { + if (typeof updater === 'function') { + playbackStore.setSnapshot({ + playing: updater( + playbackStore.store.getSnapshot().playing as boolean, + ), + }); + } else { + playbackStore.setSnapshot({playing: updater}); + } + }, }; - }, [setFrame]); + }, [playbackStore, setFrame]); if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/player/src/PlayerUI.tsx b/packages/player/src/PlayerUI.tsx index 0e790aabb7c..ef75b1480c4 100644 --- a/packages/player/src/PlayerUI.tsx +++ b/packages/player/src/PlayerUI.tsx @@ -169,7 +169,7 @@ const PlayerUI: React.ForwardRefRenderFunction< }, []); const player = usePlayerMethods(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const frame = Internals.Timeline.useTimelinePosition(); const play = useCallback( diff --git a/packages/player/src/SharedPlayerContext.tsx b/packages/player/src/SharedPlayerContext.tsx index cf108730204..a759fdc0db1 100644 --- a/packages/player/src/SharedPlayerContext.tsx +++ b/packages/player/src/SharedPlayerContext.tsx @@ -10,11 +10,9 @@ import type { PlaybackRateContextValue, RemotionEnvironment, SetMediaVolumeContextValue, - TimelineImperativeContextValue, TimelineContextValue, } from 'remotion'; import {Internals} from 'remotion'; -import {CoreTimelineImperativeContextProvider} from './timeline-imperative-context.js'; import {getPreferredVolume, persistVolume} from './volume-persistence.js'; export const PLAYER_COMP_ID = 'player-comp'; @@ -162,14 +160,6 @@ export const SharedPlayerContexts: React.FC<{ const frameRef = useRef(timelineContext.frame); frameRef.current = timelineContext.frame; - const timelineImperativeContextValue = - useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying: timelineContext.imperativePlaying, - audioAndVideoTags: timelineContext.audioAndVideoTags, - }; - }, [timelineContext.audioAndVideoTags, timelineContext.imperativePlaying]); return ( @@ -177,44 +167,40 @@ export const SharedPlayerContexts: React.FC<{ - - - - - - + + + + + - - - + + - - {children} - - - - - - - - - - + {children} + + + + + + + + + diff --git a/packages/player/src/Thumbnail.tsx b/packages/player/src/Thumbnail.tsx index 849a48d215a..378e0656a88 100644 --- a/packages/player/src/Thumbnail.tsx +++ b/packages/player/src/Thumbnail.tsx @@ -83,16 +83,14 @@ const ThumbnailFn = < } const rootRef = useRef(null); - const imperativePlaying = useRef(false); const audioAndVideoTags = useRef([]); const timelineState: TimelineContextValue = useMemo(() => { const value: TimelineContextValue = { - playing: false, + playbackStore: Internals.createRuntimeValueStore({playing: false}), frame: { [PLAYER_COMP_ID]: frameToDisplay, }, - imperativePlaying, audioAndVideoTags, }; diff --git a/packages/player/src/browser-mediasession.ts b/packages/player/src/browser-mediasession.ts index b8290c032d7..6730cc9e678 100644 --- a/packages/player/src/browser-mediasession.ts +++ b/packages/player/src/browser-mediasession.ts @@ -23,7 +23,7 @@ export const useBrowserMediaSession = ({ videoConfig: VideoConfig | null; playbackRate: number; }) => { - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {pause, play, emitter, getCurrentFrame, seek} = usePlayerMethods(); const hasEverPlayed = useRef(false); diff --git a/packages/player/src/test/index.test.ts b/packages/player/src/test/index.test.ts index 6059ed025d3..5cad85e76f4 100644 --- a/packages/player/src/test/index.test.ts +++ b/packages/player/src/test/index.test.ts @@ -104,44 +104,6 @@ test('Imperative player methods do not rerender when the frame changes', () => { expect(methodRenders).toBe(rendersAfterMount); }); -test('Player methods fall back when core has no timeline imperative context', () => { - const internalsWithOptionalContext = Internals as { - TimelineImperativeContext?: typeof Internals.TimelineImperativeContext; - }; - const timelineImperativeContext = - internalsWithOptionalContext.TimelineImperativeContext; - delete internalsWithOptionalContext.TimelineImperativeContext; - - try { - const methodsRef: {current: UsePlayerMethods | null} = {current: null}; - const renderCustomControls = () => - React.createElement(PlayerMethodsProbe, { - onRender: (methods) => { - methodsRef.current = methods; - }, - }); - - render( - React.createElement(Player, { - component: () => null, - durationInFrames: 100, - compositionWidth: 1920, - compositionHeight: 1080, - fps: 30, - controls: true, - renderCustomControls, - }), - ); - - expect(methodsRef.current?.getCurrentFrame()).toBe(0); - act(() => methodsRef.current?.seek(20)); - expect(methodsRef.current?.getCurrentFrame()).toBe(20); - } finally { - internalsWithOptionalContext.TimelineImperativeContext = - timelineImperativeContext; - } -}); - test('Playing from the last frame resets playback and dismisses the unplayed poster', () => { const playerRef = createRef(); const Composition = () => null; diff --git a/packages/player/src/timeline-imperative-context.tsx b/packages/player/src/timeline-imperative-context.tsx deleted file mode 100644 index 339c0136398..00000000000 --- a/packages/player/src/timeline-imperative-context.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type {Context} from 'react'; -import React, {useMemo, useRef} from 'react'; -import type {TimelineImperativeContextValue} from 'remotion'; -import {Internals} from 'remotion'; - -const PlayerTimelineImperativeContext = - React.createContext(null); - -const getCoreTimelineImperativeContext = () => { - return ( - Internals as typeof Internals & { - TimelineImperativeContext?: Context; - } - ).TimelineImperativeContext; -}; - -export const getTimelineImperativeContext = () => { - return getCoreTimelineImperativeContext() ?? PlayerTimelineImperativeContext; -}; - -export const CoreTimelineImperativeContextProvider: React.FC<{ - readonly children: React.ReactNode; - readonly value: TimelineImperativeContextValue; -}> = ({children, value}) => { - const TimelineImperativeContext = getCoreTimelineImperativeContext(); - - if (!TimelineImperativeContext) { - return children; - } - - return ( - - {children} - - ); -}; - -const LegacyTimelineImperativeContextProvider: React.FC<{ - readonly children: React.ReactNode; -}> = ({children}) => { - const timelineContext = Internals.Timeline.useTimelineContext(); - const frameRef = useRef(timelineContext.frame); - frameRef.current = timelineContext.frame; - - const value = useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying: timelineContext.imperativePlaying, - audioAndVideoTags: timelineContext.audioAndVideoTags, - }; - }, [timelineContext.audioAndVideoTags, timelineContext.imperativePlaying]); - - return ( - - {children} - - ); -}; - -export const TimelineImperativeCompatibilityProvider: React.FC<{ - readonly children: React.ReactNode; -}> = ({children}) => { - if (getCoreTimelineImperativeContext()) { - return children; - } - - return ( - - {children} - - ); -}; diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 0e10fd075c9..f3de81a6d51 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -50,7 +50,7 @@ export const usePlayback = ({ }) => { const config = Internals.useUnsafeVideoConfig(); const frame = Internals.Timeline.useTimelinePosition(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {pause, emitter, isPlaying} = usePlayerMethods(); const setFrame = Internals.Timeline.useTimelineSetFrame(); const sharedAudioContext = useContext(Internals.SharedAudioContext); diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index e140726c08b..2accce5978e 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -3,7 +3,6 @@ import {useCallback, useContext, useMemo, useRef} from 'react'; import {Internals, useRemotionEnvironment} from 'remotion'; import {PlayerEventEmitterContext} from './emitter-context.js'; import type {PlayerEmitter} from './event-emitter.js'; -import {getTimelineImperativeContext} from './timeline-imperative-context.js'; export type UsePlayerMethods = { frameBack: (frames: number) => void; @@ -22,8 +21,7 @@ export type UsePlayerMethods = { export const usePlayerMethods = (): UsePlayerMethods => { const setFrame = Internals.Timeline.useTimelineSetFrame(); const setTimelinePosition = Internals.Timeline.useTimelineSetFrame(); - const {setPlaying} = useContext(Internals.SetTimelineContext); - const timelineImperativeContext = useContext(getTimelineImperativeContext()); + const timelineContext = useContext(Internals.TimelineContext); const audioContext = useContext(Internals.SharedAudioContext); const audioTagsContext = useContext(Internals.SharedAudioTagsContext); const environment = useRemotionEnvironment(); @@ -33,9 +31,9 @@ export const usePlayerMethods = (): UsePlayerMethods => { const playStart = useRef(0); const fallbackFrame = useRef(null); - if (!timelineImperativeContext) { + if (!timelineContext) { throw new Error( - 'Timeline imperative context is not available. This hook must be used inside a or the Remotion Studio.', + 'Timeline context is not available. This hook must be used inside a or the Remotion Studio.', ); } @@ -61,7 +59,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { } const unclamped = - timelineImperativeContext.frameRef.current[video.id] ?? + timelineContext.frame[video.id] ?? (environment.isPlayer ? 0 : Internals.Timeline.getFrameForComposition(video.id)); @@ -70,7 +68,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { unclamped, video.durationInFrames, ); - }, [environment.isPlayer, timelineImperativeContext, video]); + }, [environment.isPlayer, timelineContext, video]); const seek = useCallback( (newFrame: number) => { @@ -84,11 +82,9 @@ export const usePlayerMethods = (): UsePlayerMethods => { fallbackFrame.current = frameToSeekTo; if (video?.id) { - if ( - timelineImperativeContext.frameRef.current[video.id] !== frameToSeekTo - ) { - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + if (timelineContext.frame[video.id] !== frameToSeekTo) { + timelineContext.frame = { + ...timelineContext.frame, [video.id]: frameToSeekTo, }; } @@ -102,18 +98,12 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter.dispatchSeek(frameToSeekTo); }, - [ - config, - emitter, - setTimelinePosition, - timelineImperativeContext, - video?.id, - ], + [config, emitter, setTimelinePosition, timelineContext, video?.id], ); const play = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineImperativeContext.imperativePlaying.current) { + if (timelineContext.playbackStore.store.getSnapshot().playing) { return; } @@ -135,12 +125,11 @@ export const usePlayerMethods = (): UsePlayerMethods => { * Play audios and videos directly here so they can benefit from * being triggered by a click */ - timelineImperativeContext.audioAndVideoTags.current.forEach((tag) => + timelineContext.audioAndVideoTags.current.forEach((tag) => tag.play('player play() was called and playing audio from a click'), ); - timelineImperativeContext.imperativePlaying.current = true; - setPlaying(true); + timelineContext.playbackStore.setSnapshot({playing: true}); playStart.current = getCurrentFrame(); emitter.dispatchPlay(); }, @@ -151,45 +140,36 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter, getCurrentFrame, seek, - setPlaying, - timelineImperativeContext, + timelineContext, ], ); const pause = useCallback(() => { - if (timelineImperativeContext.imperativePlaying.current) { - timelineImperativeContext.imperativePlaying.current = false; + if (timelineContext.playbackStore.store.getSnapshot().playing) { + timelineContext.playbackStore.setSnapshot({playing: false}); - setPlaying(false); emitter.dispatchPause(); audioContext?.suspend(); } - }, [audioContext, emitter, setPlaying, timelineImperativeContext]); + }, [audioContext, emitter, timelineContext]); const pauseAndReturnToPlayStart = useCallback(() => { - if (timelineImperativeContext.imperativePlaying.current) { - timelineImperativeContext.imperativePlaying.current = false; + if (timelineContext.playbackStore.store.getSnapshot().playing) { + timelineContext.playbackStore.setSnapshot({playing: false}); fallbackFrame.current = playStart.current; if (config) { - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + timelineContext.frame = { + ...timelineContext.frame, [config.id]: playStart.current, }; setTimelinePosition((currentFrames) => ({ ...currentFrames, [config.id]: playStart.current, })); - setPlaying(false); emitter.dispatchPause(); } } - }, [ - config, - emitter, - setPlaying, - setTimelinePosition, - timelineImperativeContext, - ]); + }, [config, emitter, setTimelinePosition, timelineContext]); const videoId = video?.id; const lastFrame = (config?.durationInFrames ?? 1) - 1; @@ -200,21 +180,19 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineImperativeContext.imperativePlaying.current) { + if (timelineContext.playbackStore.store.getSnapshot().playing) { return; } const previousFrame = - timelineImperativeContext.frameRef.current[videoId] ?? - window.remotion_initialFrame ?? - 0; + timelineContext.frame[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.max(0, previousFrame - frames); if (previousFrame === newFrame) { return; } - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + timelineContext.frame = { + ...timelineContext.frame, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -223,7 +201,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [setFrame, timelineImperativeContext, videoId], + [setFrame, timelineContext, videoId], ); const frameForward = useCallback( @@ -232,21 +210,19 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineImperativeContext.imperativePlaying.current) { + if (timelineContext.playbackStore.store.getSnapshot().playing) { return; } const previousFrame = - timelineImperativeContext.frameRef.current[videoId] ?? - window.remotion_initialFrame ?? - 0; + timelineContext.frame[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.min(lastFrame, previousFrame + frames); if (previousFrame === newFrame) { return; } - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + timelineContext.frame = { + ...timelineContext.frame, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -255,23 +231,23 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [lastFrame, setFrame, timelineImperativeContext, videoId], + [lastFrame, setFrame, timelineContext, videoId], ); const toggle = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineImperativeContext.imperativePlaying.current) { + if (timelineContext.playbackStore.store.getSnapshot().playing) { pause(); } else { play(e); } }, - [pause, play, timelineImperativeContext], + [pause, play, timelineContext], ); const isPlaying = useCallback(() => { - return timelineImperativeContext.imperativePlaying.current; - }, [timelineImperativeContext]); + return timelineContext.playbackStore.store.getSnapshot().playing as boolean; + }, [timelineContext]); const isBuffering = useCallback(() => { return bufferingContext.buffering.current; diff --git a/packages/studio/src/components/FpsCounter.tsx b/packages/studio/src/components/FpsCounter.tsx index 8417440e4fd..268a73c09b3 100644 --- a/packages/studio/src/components/FpsCounter.tsx +++ b/packages/studio/src/components/FpsCounter.tsx @@ -28,7 +28,7 @@ export const FpsCounter: React.FC<{ readonly playbackSpeed: number; }> = ({playbackSpeed}) => { const videoConfig = Internals.useUnsafeVideoConfig(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const frame = Internals.Timeline.useTimelinePosition(); const [marker, rerender] = useState({}); diff --git a/packages/studio/src/components/FramePersistor.tsx b/packages/studio/src/components/FramePersistor.tsx index 331772595f8..942da006894 100644 --- a/packages/studio/src/components/FramePersistor.tsx +++ b/packages/studio/src/components/FramePersistor.tsx @@ -3,7 +3,7 @@ import {useEffect} from 'react'; import {Internals, useVideoConfig} from 'remotion'; export const FramePersistor: React.FC = () => { - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const config = useVideoConfig(); const frame = Internals.Timeline.useTimelinePosition(); const setFrame = Internals.useTimelineSetFrame(); diff --git a/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx b/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx index 1c66e14e0c4..6d50312f36f 100644 --- a/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx +++ b/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx @@ -31,7 +31,7 @@ let lastTimelinePositionWhileScrolling: TimelinePosition | null = null; export const TimelinePlayCursorSyncer: React.FC = () => { const video = Internals.useVideo(); const timelinePosition = Internals.Timeline.useTimelinePosition(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {playbackRate} = Internals.usePlaybackRate(); const {canvasContent} = useContext(Internals.CompositionManager); const {zoom: zoomMap} = useContext(TimelineZoomCtx); diff --git a/packages/studio/src/components/WebMcp.tsx b/packages/studio/src/components/WebMcp.tsx index 5132946e60f..45debd3beaf 100644 --- a/packages/studio/src/components/WebMcp.tsx +++ b/packages/studio/src/components/WebMcp.tsx @@ -129,7 +129,7 @@ export const WebMcp: FC = () => { useContext(Internals.CompositionManager); const {playbackRate: currentPlaybackRate, setPlaybackRate} = Internals.usePlaybackRate(); - const [, , imperativePlaying] = Internals.Timeline.usePlayingState(); + const {playbackStore} = Internals.Timeline.useTimelineContext(); const {mediaVolume, playerMuted} = useContext(Internals.MediaVolumeContext); const {setPlayerMuted} = useContext(Internals.SetMediaVolumeContext); const {setZoom: setTimelineZoom, zoom: timelineZoomMap} = @@ -688,7 +688,7 @@ export const WebMcp: FC = () => { return Promise.resolve({ currentComposition: compositionId, currentFrame: getCurrentFrame(), - playing: imperativePlaying.current, + playing: playbackStore.store.getSnapshot().playing as boolean, muted: playerMutedRef.current, volume: mediaVolumeRef.current, playbackRate: playbackRateRef.current, @@ -1218,7 +1218,7 @@ export const WebMcp: FC = () => { }; }, [ clearSelection, - imperativePlaying, + playbackStore, selectComposition, selectItems, setEditorShowGuides, diff --git a/packages/studio/src/components/ZoomPersistor.tsx b/packages/studio/src/components/ZoomPersistor.tsx index 523412594e6..89de1eeaa53 100644 --- a/packages/studio/src/components/ZoomPersistor.tsx +++ b/packages/studio/src/components/ZoomPersistor.tsx @@ -18,7 +18,7 @@ export const getZoomFromLocalStorage = (): Record => { }; export const ZoomPersistor: React.FC = () => { - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {zoom} = useContext(TimelineZoomCtx); const {canvasContent} = useContext(Internals.CompositionManager); diff --git a/packages/test-utils/src/timeline-context.ts b/packages/test-utils/src/timeline-context.ts index 87be200f7a1..07624817b63 100644 --- a/packages/test-utils/src/timeline-context.ts +++ b/packages/test-utils/src/timeline-context.ts @@ -1,4 +1,5 @@ import type {TimelineContextValue} from 'remotion'; +import {Internals} from 'remotion'; import {ID} from './id.js'; export const makeTimelineContext = (frame: number): TimelineContextValue => { @@ -6,10 +7,7 @@ export const makeTimelineContext = (frame: number): TimelineContextValue => { frame: { [ID]: frame, }, - playing: false, - imperativePlaying: { - current: false, - }, + playbackStore: Internals.createRuntimeValueStore({playing: false}), audioAndVideoTags: {current: []}, }; }; From 00120e9bb2eaac0b993e08c3d1f3a87d246c09e2 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:29:03 -0400 Subject: [PATCH 03/35] `remotion`: Expose `isPlaying()` on timeline context instead of `playbackStore` Keep playing subscription on SetTimelineContext for usePlaying(); sync reads go through isPlaying(). Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 28 ++++++----- packages/core/src/freeze.tsx | 5 +- packages/core/src/internals.ts | 2 + packages/core/src/playing-state.ts | 32 +++++++++++++ .../core/src/test/nested-sequences.test.tsx | 7 ++- packages/core/src/test/series.test.tsx | 3 +- .../core/src/test/wrap-sequence-context.tsx | 3 +- packages/core/src/timeline-position-state.ts | 8 ++-- packages/core/src/use-media-tag.ts | 6 +-- packages/core/src/use-playing.ts | 12 ++--- packages/gif/src/test/Gif.test.tsx | 2 +- .../src/test/resolve-cursor.test.ts | 2 +- packages/player/src/Player.tsx | 19 ++++---- packages/player/src/Thumbnail.tsx | 2 +- packages/player/src/use-player-methods.ts | 46 ++++++++++++------- packages/studio/src/components/WebMcp.tsx | 6 +-- packages/test-utils/src/timeline-context.ts | 3 +- 17 files changed, 109 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/playing-state.ts diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index ac7d2f60c9b..78a953aeac2 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -6,10 +6,7 @@ import React, { useRef, useState, } from 'react'; -import { - createRuntimeValueStore, - type RuntimeValueStoreController, -} from './runtime-value-store.js'; +import {createPlayingController} from './playing-state.js'; import { getInitialFrameState, type PlayableMediaTag, @@ -18,7 +15,7 @@ import {useDelayRender} from './use-delay-render'; export type TimelineContextValue = { frame: Record; - playbackStore: RuntimeValueStoreController; + isPlaying: () => boolean; audioAndVideoTags: RefObject; }; @@ -30,6 +27,7 @@ export type PlaybackRateContextValue = { export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; + subscribePlaying: (listener: () => void) => () => void; }; export const SetTimelineContext = createContext({ @@ -39,6 +37,9 @@ export const SetTimelineContext = createContext({ setPlaying: () => { throw new Error('default'); }, + subscribePlaying: () => { + throw new Error('default'); + }, }); export const TimelineContext = createContext(null); @@ -54,10 +55,7 @@ export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; }> = ({children, frameState}) => { - const playbackStore = useMemo( - () => createRuntimeValueStore({playing: false}), - [], - ); + const playingController = useMemo(() => createPlayingController(false), []); const [playbackRate, setPlaybackRate] = useState(1); const audioAndVideoTags = useRef([]); @@ -109,10 +107,10 @@ export const TimelineContextProvider: React.FC<{ const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playbackStore, + isPlaying: playingController.isPlaying, audioAndVideoTags, }; - }, [frame, playbackStore]); + }, [frame, playingController.isPlaying, audioAndVideoTags]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -126,14 +124,14 @@ export const TimelineContextProvider: React.FC<{ setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - const current = playbackStore.store.getSnapshot().playing as boolean; - playbackStore.setSnapshot({playing: updater(current)}); + playingController.setPlaying(updater(playingController.isPlaying())); } else { - playbackStore.setSnapshot({playing: updater}); + playingController.setPlaying(updater); } }, + subscribePlaying: playingController.subscribePlaying, }; - }, [playbackStore]); + }, [playingController]); return ( diff --git a/packages/core/src/freeze.tsx b/packages/core/src/freeze.tsx index 48264e9b998..e358c121bef 100644 --- a/packages/core/src/freeze.tsx +++ b/packages/core/src/freeze.tsx @@ -1,5 +1,4 @@ import React, {useContext, useMemo} from 'react'; -import {createRuntimeValueStore} from './runtime-value-store.js'; import type {SequenceContextType} from './SequenceContext.js'; import {SequenceContext} from './SequenceContext.js'; import {useTimelineContext} from './timeline-position-state.js'; @@ -70,11 +69,9 @@ export const Freeze: React.FC = ({ return timelineContext; } - const frozenStore = createRuntimeValueStore({playing: false}); - return { ...timelineContext, - playbackStore: frozenStore, + isPlaying: () => false, frame: { [videoConfig.id]: frameToFreeze + relativeFrom, }, diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index eafd73f022a..4af25d31117 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -131,6 +131,7 @@ import { import type {NonceHistory} from './nonce.js'; import {NonceContext} from './nonce.js'; import {playbackLogging} from './playback-logging.js'; +import {createPlayingController} from './playing-state.js'; import {portalNode, setPortalNodeCurrentScale} from './portal-node.js'; import {PrefetchProvider} from './prefetch-state.js'; import {usePreload} from './prefetch.js'; @@ -310,6 +311,7 @@ export const Internals = { makeRenderResourceManager, RenderResourceManagerContext, createRuntimeValueStore, + createPlayingController, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, diff --git a/packages/core/src/playing-state.ts b/packages/core/src/playing-state.ts new file mode 100644 index 00000000000..a010d15020c --- /dev/null +++ b/packages/core/src/playing-state.ts @@ -0,0 +1,32 @@ +export type PlayingController = { + readonly isPlaying: () => boolean; + readonly subscribePlaying: (listener: () => void) => () => void; + readonly setPlaying: (playing: boolean) => void; +}; + +export const createPlayingController = ( + initialPlaying = false, +): PlayingController => { + let playing = initialPlaying; + const listeners = new Set<() => void>(); + + return { + isPlaying: () => playing, + subscribePlaying: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + setPlaying: (newPlaying) => { + if (playing === newPlaying) { + return; + } + + playing = newPlaying; + for (const listener of listeners) { + listener(); + } + }, + }; +}; diff --git a/packages/core/src/test/nested-sequences.test.tsx b/packages/core/src/test/nested-sequences.test.tsx index 32bc710194a..00125e05784 100644 --- a/packages/core/src/test/nested-sequences.test.tsx +++ b/packages/core/src/test/nested-sequences.test.tsx @@ -3,7 +3,6 @@ import {cleanup, render} from '@testing-library/react'; import {useEffect} from 'react'; import {AbsoluteFill} from '../AbsoluteFill.js'; import {getTimelineDuration} from '../get-timeline-duration.js'; -import {createRuntimeValueStore} from '../runtime-value-store.js'; import {Sequence} from '../Sequence.js'; import {TimelineContext} from '../TimelineContext.js'; import {useCurrentFrame} from '../use-current-frame.js'; @@ -19,7 +18,7 @@ const getForFrame = (frame: number, content: React.ReactNode) => { frame: { 'my-comp': frame, }, - playbackStore: createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: { current: [], }, @@ -256,7 +255,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 15, }, - playbackStore: createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: { current: [], }, @@ -277,7 +276,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 25, }, - playbackStore: createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: { current: [], }, diff --git a/packages/core/src/test/series.test.tsx b/packages/core/src/test/series.test.tsx index 991896089a7..fb98be908fa 100644 --- a/packages/core/src/test/series.test.tsx +++ b/packages/core/src/test/series.test.tsx @@ -4,7 +4,6 @@ import {renderToString} from 'react-dom/server'; import {AbsoluteFill} from '../AbsoluteFill.js'; import {CanUseRemotionHooksProvider} from '../CanUseRemotionHooks.js'; import {Internals} from '../internals.js'; -import {createRuntimeValueStore} from '../runtime-value-store.js'; import {Sequence} from '../Sequence.js'; import {Series} from '../series/index.js'; import type {TimelineContextValue} from '../TimelineContext.js'; @@ -51,7 +50,7 @@ const renderForFrame = (frame: number, markup: React.ReactNode) => { frame: { 'my-comp': frame, }, - playbackStore: createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: {current: []}, }; diff --git a/packages/core/src/test/wrap-sequence-context.tsx b/packages/core/src/test/wrap-sequence-context.tsx index dd1480741cc..7ae2b4f6e04 100644 --- a/packages/core/src/test/wrap-sequence-context.tsx +++ b/packages/core/src/test/wrap-sequence-context.tsx @@ -5,7 +5,6 @@ import type {CompositionManagerContext} from '../CompositionManagerContext.js'; import {CompositionManager} from '../CompositionManagerContext.js'; import type {LoggingContextValue} from '../log-level-context.js'; import {LogLevelContext} from '../log-level-context.js'; -import {createRuntimeValueStore} from '../runtime-value-store.js'; import {SequenceManagerProvider} from '../SequenceManager.js'; import type { PlaybackRateContextValue, @@ -116,7 +115,7 @@ export const WrapSequenceContext: React.FC<{ const timelineContext = useMemo( () => ({ frame: {'my-comp': currentFrame}, - playbackStore: createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: {current: []}, }), [currentFrame], diff --git a/packages/core/src/timeline-position-state.ts b/packages/core/src/timeline-position-state.ts index c42d10b803e..18344e9e697 100644 --- a/packages/core/src/timeline-position-state.ts +++ b/packages/core/src/timeline-position-state.ts @@ -126,7 +126,7 @@ type PlayingReturnType = readonly [ ]; export const usePlayingState = (): PlayingReturnType => { - const {playbackStore} = useTimelineContext(); + const {isPlaying} = useTimelineContext(); const playing = usePlaying(); const {setPlaying} = useContext(SetTimelineContext); @@ -136,14 +136,14 @@ export const usePlayingState = (): PlayingReturnType => { setPlaying, { get current() { - return playbackStore.store.getSnapshot().playing as boolean; + return isPlaying(); }, set current(val) { - playbackStore.setSnapshot({playing: val}); + setPlaying(val); }, }, ], - [playbackStore, playing, setPlaying], + [isPlaying, playing, setPlaying], ); }; diff --git a/packages/core/src/use-media-tag.ts b/packages/core/src/use-media-tag.ts index 792335cafc6..217e4a7d3cd 100644 --- a/packages/core/src/use-media-tag.ts +++ b/packages/core/src/use-media-tag.ts @@ -21,7 +21,7 @@ export const useMediaTag = ({ isPremounting: boolean; isPostmounting: boolean; }) => { - const {audioAndVideoTags, playbackStore} = useTimelineContext(); + const {audioAndVideoTags, isPlaying} = useTimelineContext(); const logLevel = useLogLevel(); const mountTime = useMountTime(); const env = useRemotionEnvironment(); @@ -30,7 +30,7 @@ export const useMediaTag = ({ const tag: PlayableMediaTag = { id, play: (reason) => { - if (!playbackStore.store.getSnapshot().playing) { + if (!isPlaying()) { // Don't play if for example in a state. return; } @@ -63,7 +63,7 @@ export const useMediaTag = ({ mediaRef, mediaType, onAutoPlayError, - playbackStore, + isPlaying, isPremounting, isPostmounting, logLevel, diff --git a/packages/core/src/use-playing.ts b/packages/core/src/use-playing.ts index b2573d4b278..f13c9153000 100644 --- a/packages/core/src/use-playing.ts +++ b/packages/core/src/use-playing.ts @@ -1,11 +1,9 @@ -import {useSyncExternalStore} from 'react'; +import {useSyncExternalStore, useContext} from 'react'; import {useTimelineContext} from './timeline-position-state.js'; +import {SetTimelineContext} from './TimelineContext.js'; export const usePlaying = () => { - const {playbackStore} = useTimelineContext(); - return useSyncExternalStore( - playbackStore.store.subscribe, - () => playbackStore.store.getSnapshot().playing as boolean, - () => playbackStore.store.getSnapshot().playing as boolean, - ); + const {isPlaying} = useTimelineContext(); + const {subscribePlaying} = useContext(SetTimelineContext); + return useSyncExternalStore(subscribePlaying, isPlaying, isPlaying); }; diff --git a/packages/gif/src/test/Gif.test.tsx b/packages/gif/src/test/Gif.test.tsx index 237216c1a42..148e794020b 100644 --- a/packages/gif/src/test/Gif.test.tsx +++ b/packages/gif/src/test/Gif.test.tsx @@ -107,7 +107,7 @@ const compositionContext = { const timelineContext = { frame: {}, - playbackStore: Internals.createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: {current: []}, } as React.ContextType; diff --git a/packages/mac-cursors/src/test/resolve-cursor.test.ts b/packages/mac-cursors/src/test/resolve-cursor.test.ts index 2af3628a9d6..d040dfa5772 100644 --- a/packages/mac-cursors/src/test/resolve-cursor.test.ts +++ b/packages/mac-cursors/src/test/resolve-cursor.test.ts @@ -106,7 +106,7 @@ test(' renders the default cursor when the cursor prop is omitted', } as React.ContextType; const timeline = { frame: {comp: 0}, - playbackStore: Internals.createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: {current: []}, } as React.ContextType; const markup = renderToString( diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 7f704374162..6f1c2dad14c 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -244,8 +244,8 @@ const PlayerFn = < })); const rootRef = useRef(null); const audioAndVideoTags = useRef([]); - const playbackStore = useMemo( - () => Internals.createRuntimeValueStore({playing: false}), + const playingController = useMemo( + () => Internals.createPlayingController(false), [], ); const [currentPlaybackRate, setCurrentPlaybackRate] = useState(playbackRate); @@ -412,10 +412,10 @@ const PlayerFn = < const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playbackStore, + isPlaying: playingController.isPlaying, audioAndVideoTags, }; - }, [frame, playbackStore]); + }, [frame, playingController.isPlaying, audioAndVideoTags]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -429,17 +429,14 @@ const PlayerFn = < setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - playbackStore.setSnapshot({ - playing: updater( - playbackStore.store.getSnapshot().playing as boolean, - ), - }); + playingController.setPlaying(updater(playingController.isPlaying())); } else { - playbackStore.setSnapshot({playing: updater}); + playingController.setPlaying(updater); } }, + subscribePlaying: playingController.subscribePlaying, }; - }, [playbackStore, setFrame]); + }, [playingController, setFrame]); if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/player/src/Thumbnail.tsx b/packages/player/src/Thumbnail.tsx index 378e0656a88..8dfa6066b20 100644 --- a/packages/player/src/Thumbnail.tsx +++ b/packages/player/src/Thumbnail.tsx @@ -87,7 +87,7 @@ const ThumbnailFn = < const timelineState: TimelineContextValue = useMemo(() => { const value: TimelineContextValue = { - playbackStore: Internals.createRuntimeValueStore({playing: false}), + isPlaying: () => false, frame: { [PLAYER_COMP_ID]: frameToDisplay, }, diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index 2accce5978e..9795491318d 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -22,6 +22,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { const setFrame = Internals.Timeline.useTimelineSetFrame(); const setTimelinePosition = Internals.Timeline.useTimelineSetFrame(); const timelineContext = useContext(Internals.TimelineContext); + const {setPlaying} = useContext(Internals.SetTimelineContext); const audioContext = useContext(Internals.SharedAudioContext); const audioTagsContext = useContext(Internals.SharedAudioTagsContext); const environment = useRemotionEnvironment(); @@ -48,6 +49,8 @@ export const usePlayerMethods = (): UsePlayerMethods => { ); } + const {isPlaying: readIsPlaying} = timelineContext; + const getCurrentFrame = useCallback(() => { if (!video) { return ( @@ -103,7 +106,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { const play = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineContext.playbackStore.store.getSnapshot().playing) { + if (readIsPlaying()) { return; } @@ -129,7 +132,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { tag.play('player play() was called and playing audio from a click'), ); - timelineContext.playbackStore.setSnapshot({playing: true}); + setPlaying(true); playStart.current = getCurrentFrame(); emitter.dispatchPlay(); }, @@ -139,23 +142,25 @@ export const usePlayerMethods = (): UsePlayerMethods => { config?.durationInFrames, emitter, getCurrentFrame, + readIsPlaying, seek, - timelineContext, + setPlaying, + timelineContext.audioAndVideoTags, ], ); const pause = useCallback(() => { - if (timelineContext.playbackStore.store.getSnapshot().playing) { - timelineContext.playbackStore.setSnapshot({playing: false}); + if (readIsPlaying()) { + setPlaying(false); emitter.dispatchPause(); audioContext?.suspend(); } - }, [audioContext, emitter, timelineContext]); + }, [audioContext, emitter, readIsPlaying, setPlaying]); const pauseAndReturnToPlayStart = useCallback(() => { - if (timelineContext.playbackStore.store.getSnapshot().playing) { - timelineContext.playbackStore.setSnapshot({playing: false}); + if (readIsPlaying()) { + setPlaying(false); fallbackFrame.current = playStart.current; if (config) { timelineContext.frame = { @@ -169,7 +174,14 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter.dispatchPause(); } } - }, [config, emitter, setTimelinePosition, timelineContext]); + }, [ + config, + emitter, + readIsPlaying, + setPlaying, + setTimelinePosition, + timelineContext, + ]); const videoId = video?.id; const lastFrame = (config?.durationInFrames ?? 1) - 1; @@ -180,7 +192,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineContext.playbackStore.store.getSnapshot().playing) { + if (readIsPlaying()) { return; } @@ -201,7 +213,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [setFrame, timelineContext, videoId], + [readIsPlaying, setFrame, timelineContext, videoId], ); const frameForward = useCallback( @@ -210,7 +222,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineContext.playbackStore.store.getSnapshot().playing) { + if (readIsPlaying()) { return; } @@ -231,23 +243,23 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [lastFrame, setFrame, timelineContext, videoId], + [lastFrame, readIsPlaying, setFrame, timelineContext, videoId], ); const toggle = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineContext.playbackStore.store.getSnapshot().playing) { + if (readIsPlaying()) { pause(); } else { play(e); } }, - [pause, play, timelineContext], + [pause, play, readIsPlaying], ); const isPlaying = useCallback(() => { - return timelineContext.playbackStore.store.getSnapshot().playing as boolean; - }, [timelineContext]); + return readIsPlaying(); + }, [readIsPlaying]); const isBuffering = useCallback(() => { return bufferingContext.buffering.current; diff --git a/packages/studio/src/components/WebMcp.tsx b/packages/studio/src/components/WebMcp.tsx index 45debd3beaf..91b662914ec 100644 --- a/packages/studio/src/components/WebMcp.tsx +++ b/packages/studio/src/components/WebMcp.tsx @@ -129,7 +129,7 @@ export const WebMcp: FC = () => { useContext(Internals.CompositionManager); const {playbackRate: currentPlaybackRate, setPlaybackRate} = Internals.usePlaybackRate(); - const {playbackStore} = Internals.Timeline.useTimelineContext(); + const {isPlaying} = Internals.Timeline.useTimelineContext(); const {mediaVolume, playerMuted} = useContext(Internals.MediaVolumeContext); const {setPlayerMuted} = useContext(Internals.SetMediaVolumeContext); const {setZoom: setTimelineZoom, zoom: timelineZoomMap} = @@ -688,7 +688,7 @@ export const WebMcp: FC = () => { return Promise.resolve({ currentComposition: compositionId, currentFrame: getCurrentFrame(), - playing: playbackStore.store.getSnapshot().playing as boolean, + playing: isPlaying(), muted: playerMutedRef.current, volume: mediaVolumeRef.current, playbackRate: playbackRateRef.current, @@ -1218,7 +1218,7 @@ export const WebMcp: FC = () => { }; }, [ clearSelection, - playbackStore, + isPlaying, selectComposition, selectItems, setEditorShowGuides, diff --git a/packages/test-utils/src/timeline-context.ts b/packages/test-utils/src/timeline-context.ts index 07624817b63..a1ddcdfe5a1 100644 --- a/packages/test-utils/src/timeline-context.ts +++ b/packages/test-utils/src/timeline-context.ts @@ -1,5 +1,4 @@ import type {TimelineContextValue} from 'remotion'; -import {Internals} from 'remotion'; import {ID} from './id.js'; export const makeTimelineContext = (frame: number): TimelineContextValue => { @@ -7,7 +6,7 @@ export const makeTimelineContext = (frame: number): TimelineContextValue => { frame: { [ID]: frame, }, - playbackStore: Internals.createRuntimeValueStore({playing: false}), + isPlaying: () => false, audioAndVideoTags: {current: []}, }; }; From 2bdd21c1fc633f1de7cd17f48e23c8623113c322 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:31:30 -0400 Subject: [PATCH 04/35] `remotion`: Use runtime value store for playing and drop dead hooks Remove createPlayingController, playing-state.ts, and unused usePlayingState. Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 20 +++++++----- packages/core/src/internals.ts | 2 -- packages/core/src/playing-state.ts | 32 -------------------- packages/core/src/timeline-position-state.ts | 32 +------------------- packages/player/src/Player.tsx | 17 ++++++----- 5 files changed, 22 insertions(+), 81 deletions(-) delete mode 100644 packages/core/src/playing-state.ts diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 78a953aeac2..a04d64497f1 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -6,7 +6,7 @@ import React, { useRef, useState, } from 'react'; -import {createPlayingController} from './playing-state.js'; +import {createRuntimeValueStore} from './runtime-value-store.js'; import { getInitialFrameState, type PlayableMediaTag, @@ -55,7 +55,10 @@ export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; }> = ({children, frameState}) => { - const playingController = useMemo(() => createPlayingController(false), []); + const playingStore = useMemo( + () => createRuntimeValueStore({playing: false}), + [], + ); const [playbackRate, setPlaybackRate] = useState(1); const audioAndVideoTags = useRef([]); @@ -107,10 +110,10 @@ export const TimelineContextProvider: React.FC<{ const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - isPlaying: playingController.isPlaying, + isPlaying: () => playingStore.store.getSnapshot().playing as boolean, audioAndVideoTags, }; - }, [frame, playingController.isPlaying, audioAndVideoTags]); + }, [frame, playingStore, audioAndVideoTags]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -124,14 +127,15 @@ export const TimelineContextProvider: React.FC<{ setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - playingController.setPlaying(updater(playingController.isPlaying())); + const current = playingStore.store.getSnapshot().playing as boolean; + playingStore.setSnapshot({playing: updater(current)}); } else { - playingController.setPlaying(updater); + playingStore.setSnapshot({playing: updater}); } }, - subscribePlaying: playingController.subscribePlaying, + subscribePlaying: playingStore.store.subscribe, }; - }, [playingController]); + }, [playingStore]); return ( diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index 4af25d31117..eafd73f022a 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -131,7 +131,6 @@ import { import type {NonceHistory} from './nonce.js'; import {NonceContext} from './nonce.js'; import {playbackLogging} from './playback-logging.js'; -import {createPlayingController} from './playing-state.js'; import {portalNode, setPortalNodeCurrentScale} from './portal-node.js'; import {PrefetchProvider} from './prefetch-state.js'; import {usePreload} from './prefetch.js'; @@ -311,7 +310,6 @@ export const Internals = { makeRenderResourceManager, RenderResourceManagerContext, createRuntimeValueStore, - createPlayingController, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, diff --git a/packages/core/src/playing-state.ts b/packages/core/src/playing-state.ts deleted file mode 100644 index a010d15020c..00000000000 --- a/packages/core/src/playing-state.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type PlayingController = { - readonly isPlaying: () => boolean; - readonly subscribePlaying: (listener: () => void) => () => void; - readonly setPlaying: (playing: boolean) => void; -}; - -export const createPlayingController = ( - initialPlaying = false, -): PlayingController => { - let playing = initialPlaying; - const listeners = new Set<() => void>(); - - return { - isPlaying: () => playing, - subscribePlaying: (listener) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - setPlaying: (newPlaying) => { - if (playing === newPlaying) { - return; - } - - playing = newPlaying; - for (const listener of listeners) { - listener(); - } - }, - }; -}; diff --git a/packages/core/src/timeline-position-state.ts b/packages/core/src/timeline-position-state.ts index 18344e9e697..bd8c660036e 100644 --- a/packages/core/src/timeline-position-state.ts +++ b/packages/core/src/timeline-position-state.ts @@ -1,5 +1,4 @@ -import type {RefObject} from 'react'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import { AbsoluteTimeContext, PlaybackRateContext, @@ -8,7 +7,6 @@ import { type PlaybackRateContextValue, type TimelineContextValue, } from './TimelineContext.js'; -import {usePlaying} from './use-playing.js'; import {useRemotionEnvironment} from './use-remotion-environment.js'; import {useVideo} from './use-video.js'; @@ -119,32 +117,4 @@ export const useTimelineSetFrame = (): (( return setFrame; }; -type PlayingReturnType = readonly [ - boolean, - (u: React.SetStateAction) => void, - RefObject, -]; - -export const usePlayingState = (): PlayingReturnType => { - const {isPlaying} = useTimelineContext(); - const playing = usePlaying(); - const {setPlaying} = useContext(SetTimelineContext); - - return useMemo( - () => [ - playing, - setPlaying, - { - get current() { - return isPlaying(); - }, - set current(val) { - setPlaying(val); - }, - }, - ], - [isPlaying, playing, setPlaying], - ); -}; - export {usePlaying} from './use-playing.js'; diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 6f1c2dad14c..2683e4f2fc4 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -244,8 +244,8 @@ const PlayerFn = < })); const rootRef = useRef(null); const audioAndVideoTags = useRef([]); - const playingController = useMemo( - () => Internals.createPlayingController(false), + const playingStore = useMemo( + () => Internals.createRuntimeValueStore({playing: false}), [], ); const [currentPlaybackRate, setCurrentPlaybackRate] = useState(playbackRate); @@ -412,10 +412,10 @@ const PlayerFn = < const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - isPlaying: playingController.isPlaying, + isPlaying: () => playingStore.store.getSnapshot().playing as boolean, audioAndVideoTags, }; - }, [frame, playingController.isPlaying, audioAndVideoTags]); + }, [frame, playingStore, audioAndVideoTags]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -429,14 +429,15 @@ const PlayerFn = < setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - playingController.setPlaying(updater(playingController.isPlaying())); + const current = playingStore.store.getSnapshot().playing as boolean; + playingStore.setSnapshot({playing: updater(current)}); } else { - playingController.setPlaying(updater); + playingStore.setSnapshot({playing: updater}); } }, - subscribePlaying: playingController.subscribePlaying, + subscribePlaying: playingStore.store.subscribe, }; - }, [playingController, setFrame]); + }, [playingStore, setFrame]); if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks From 7b6c26803b7a58433b42a17df2c566a7bfdba8a9 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:39:47 -0400 Subject: [PATCH 05/35] `remotion`: Stabilize playing reads and fix imperative player re-renders Move frame/isPlaying/audio tag reads to SetTimelineContext so usePlayerMethods stays off the per-frame timeline context, keep isPlaying stable for media tags, and type the runtime value store generically. Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 29 ++++++++--- packages/core/src/runtime-value-store.ts | 22 +++++---- packages/core/src/use-media-tag.ts | 7 +-- packages/player/src/Player.tsx | 18 +++++-- packages/player/src/SharedPlayerContext.tsx | 3 -- packages/player/src/use-player-methods.ts | 54 ++++++++++----------- 6 files changed, 79 insertions(+), 54 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index a04d64497f1..434e7d2f9fd 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -1,6 +1,7 @@ import type {RefObject} from 'react'; import React, { createContext, + useCallback, useLayoutEffect, useMemo, useRef, @@ -28,6 +29,9 @@ export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; subscribePlaying: (listener: () => void) => () => void; + frameRef: RefObject>; + isPlaying: () => boolean; + audioAndVideoTags: RefObject; }; export const SetTimelineContext = createContext({ @@ -40,6 +44,11 @@ export const SetTimelineContext = createContext({ subscribePlaying: () => { throw new Error('default'); }, + frameRef: {current: {}}, + isPlaying: () => { + throw new Error('default'); + }, + audioAndVideoTags: {current: []}, }); export const TimelineContext = createContext(null); @@ -51,6 +60,8 @@ export const AbsoluteTimeContext = createContext( null, ); +const studioFrameRef: RefObject> = {current: {}}; + export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; @@ -67,8 +78,11 @@ export const TimelineContextProvider: React.FC<{ ); const frame = frameState ?? _frame; - const frameRef = useRef(frame); - frameRef.current = frame; + + const readIsPlaying = useCallback( + () => playingStore.store.getSnapshot().playing, + [playingStore], + ); const {delayRender, continueRender} = useDelayRender(); @@ -110,10 +124,10 @@ export const TimelineContextProvider: React.FC<{ const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - isPlaying: () => playingStore.store.getSnapshot().playing as boolean, + isPlaying: readIsPlaying, audioAndVideoTags, }; - }, [frame, playingStore, audioAndVideoTags]); + }, [frame, readIsPlaying]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -127,15 +141,18 @@ export const TimelineContextProvider: React.FC<{ setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - const current = playingStore.store.getSnapshot().playing as boolean; + const current = playingStore.store.getSnapshot().playing; playingStore.setSnapshot({playing: updater(current)}); } else { playingStore.setSnapshot({playing: updater}); } }, subscribePlaying: playingStore.store.subscribe, + frameRef: studioFrameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, }; - }, [playingStore]); + }, [playingStore, readIsPlaying]); return ( diff --git a/packages/core/src/runtime-value-store.ts b/packages/core/src/runtime-value-store.ts index 25fb97cd272..f3112581621 100644 --- a/packages/core/src/runtime-value-store.ts +++ b/packages/core/src/runtime-value-store.ts @@ -1,22 +1,26 @@ export type RuntimeValueSnapshot = Readonly>; -export type RuntimeValueStore = { - getSnapshot: () => RuntimeValueSnapshot; +export type RuntimeValueStore< + TSnapshot extends RuntimeValueSnapshot = RuntimeValueSnapshot, +> = { + getSnapshot: () => TSnapshot; subscribe: (listener: () => void) => () => void; }; -export type RuntimeValueStoreController = { - store: RuntimeValueStore; - setSnapshot: (newSnapshot: RuntimeValueSnapshot) => void; +export type RuntimeValueStoreController< + TSnapshot extends RuntimeValueSnapshot = RuntimeValueSnapshot, +> = { + store: RuntimeValueStore; + setSnapshot: (newSnapshot: TSnapshot) => void; }; -export const createRuntimeValueStore = ( - initialSnapshot: RuntimeValueSnapshot, -): RuntimeValueStoreController => { +export const createRuntimeValueStore = ( + initialSnapshot: TSnapshot, +): RuntimeValueStoreController => { let snapshot = initialSnapshot; const listeners = new Set<() => void>(); - const store: RuntimeValueStore = { + const store: RuntimeValueStore = { getSnapshot: () => snapshot, subscribe: (listener) => { listeners.add(listener); diff --git a/packages/core/src/use-media-tag.ts b/packages/core/src/use-media-tag.ts index 217e4a7d3cd..9292871b3f1 100644 --- a/packages/core/src/use-media-tag.ts +++ b/packages/core/src/use-media-tag.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import {useLogLevel, useMountTime} from './log-level-context.js'; import {playAndHandleNotAllowedError} from './play-and-handle-not-allowed-error.js'; import type {PlayableMediaTag} from './timeline-position-state.js'; @@ -22,6 +22,8 @@ export const useMediaTag = ({ isPostmounting: boolean; }) => { const {audioAndVideoTags, isPlaying} = useTimelineContext(); + const isPlayingRef = useRef(isPlaying); + isPlayingRef.current = isPlaying; const logLevel = useLogLevel(); const mountTime = useMountTime(); const env = useRemotionEnvironment(); @@ -30,7 +32,7 @@ export const useMediaTag = ({ const tag: PlayableMediaTag = { id, play: (reason) => { - if (!isPlaying()) { + if (!isPlayingRef.current()) { // Don't play if for example in a state. return; } @@ -63,7 +65,6 @@ export const useMediaTag = ({ mediaRef, mediaType, onAutoPlayError, - isPlaying, isPremounting, isPostmounting, logLevel, diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 2683e4f2fc4..100121b9e48 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -1,6 +1,7 @@ import type {ComponentType, LazyExoticComponent, RefObject} from 'react'; import React, { forwardRef, + useCallback, useEffect, useImperativeHandle, useLayoutEffect, @@ -242,12 +243,18 @@ const PlayerFn = < const [frame, setFrame] = useState>(() => ({ [PLAYER_COMP_ID]: initialFrame ?? 0, })); + const frameRef = useRef(frame); + frameRef.current = frame; const rootRef = useRef(null); const audioAndVideoTags = useRef([]); const playingStore = useMemo( () => Internals.createRuntimeValueStore({playing: false}), [], ); + const readIsPlaying = useCallback( + () => playingStore.store.getSnapshot().playing, + [playingStore], + ); const [currentPlaybackRate, setCurrentPlaybackRate] = useState(playbackRate); if (typeof compositionHeight !== 'number') { @@ -412,10 +419,10 @@ const PlayerFn = < const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - isPlaying: () => playingStore.store.getSnapshot().playing as boolean, + isPlaying: readIsPlaying, audioAndVideoTags, }; - }, [frame, playingStore, audioAndVideoTags]); + }, [frame, readIsPlaying]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -429,15 +436,18 @@ const PlayerFn = < setFrame, setPlaying: (updater) => { if (typeof updater === 'function') { - const current = playingStore.store.getSnapshot().playing as boolean; + const current = playingStore.store.getSnapshot().playing; playingStore.setSnapshot({playing: updater(current)}); } else { playingStore.setSnapshot({playing: updater}); } }, subscribePlaying: playingStore.store.subscribe, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, }; - }, [playingStore, setFrame]); + }, [playingStore, setFrame, frameRef, readIsPlaying]); if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/player/src/SharedPlayerContext.tsx b/packages/player/src/SharedPlayerContext.tsx index a759fdc0db1..891337b26e9 100644 --- a/packages/player/src/SharedPlayerContext.tsx +++ b/packages/player/src/SharedPlayerContext.tsx @@ -158,9 +158,6 @@ export const SharedPlayerContexts: React.FC<{ }; }, []); - const frameRef = useRef(timelineContext.frame); - frameRef.current = timelineContext.frame; - return ( diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index 9795491318d..1d3abf30e75 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -21,8 +21,12 @@ export type UsePlayerMethods = { export const usePlayerMethods = (): UsePlayerMethods => { const setFrame = Internals.Timeline.useTimelineSetFrame(); const setTimelinePosition = Internals.Timeline.useTimelineSetFrame(); - const timelineContext = useContext(Internals.TimelineContext); - const {setPlaying} = useContext(Internals.SetTimelineContext); + const { + setPlaying, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, + } = useContext(Internals.SetTimelineContext); const audioContext = useContext(Internals.SharedAudioContext); const audioTagsContext = useContext(Internals.SharedAudioTagsContext); const environment = useRemotionEnvironment(); @@ -32,12 +36,6 @@ export const usePlayerMethods = (): UsePlayerMethods => { const playStart = useRef(0); const fallbackFrame = useRef(null); - if (!timelineContext) { - throw new Error( - 'Timeline context is not available. This hook must be used inside a or the Remotion Studio.', - ); - } - if (!emitter) { throw new TypeError('Expected Player event emitter context'); } @@ -49,8 +47,6 @@ export const usePlayerMethods = (): UsePlayerMethods => { ); } - const {isPlaying: readIsPlaying} = timelineContext; - const getCurrentFrame = useCallback(() => { if (!video) { return ( @@ -62,7 +58,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { } const unclamped = - timelineContext.frame[video.id] ?? + frameRef.current[video.id] ?? (environment.isPlayer ? 0 : Internals.Timeline.getFrameForComposition(video.id)); @@ -71,7 +67,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { unclamped, video.durationInFrames, ); - }, [environment.isPlayer, timelineContext, video]); + }, [environment.isPlayer, frameRef, video]); const seek = useCallback( (newFrame: number) => { @@ -85,9 +81,9 @@ export const usePlayerMethods = (): UsePlayerMethods => { fallbackFrame.current = frameToSeekTo; if (video?.id) { - if (timelineContext.frame[video.id] !== frameToSeekTo) { - timelineContext.frame = { - ...timelineContext.frame, + if (frameRef.current[video.id] !== frameToSeekTo) { + frameRef.current = { + ...frameRef.current, [video.id]: frameToSeekTo, }; } @@ -101,7 +97,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter.dispatchSeek(frameToSeekTo); }, - [config, emitter, setTimelinePosition, timelineContext, video?.id], + [config, emitter, frameRef, setTimelinePosition, video?.id], ); const play = useCallback( @@ -128,7 +124,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { * Play audios and videos directly here so they can benefit from * being triggered by a click */ - timelineContext.audioAndVideoTags.current.forEach((tag) => + audioAndVideoTags.current.forEach((tag) => tag.play('player play() was called and playing audio from a click'), ); @@ -137,6 +133,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter.dispatchPlay(); }, [ + audioAndVideoTags, audioContext, audioTagsContext, config?.durationInFrames, @@ -145,7 +142,6 @@ export const usePlayerMethods = (): UsePlayerMethods => { readIsPlaying, seek, setPlaying, - timelineContext.audioAndVideoTags, ], ); @@ -163,8 +159,8 @@ export const usePlayerMethods = (): UsePlayerMethods => { setPlaying(false); fallbackFrame.current = playStart.current; if (config) { - timelineContext.frame = { - ...timelineContext.frame, + frameRef.current = { + ...frameRef.current, [config.id]: playStart.current, }; setTimelinePosition((currentFrames) => ({ @@ -177,10 +173,10 @@ export const usePlayerMethods = (): UsePlayerMethods => { }, [ config, emitter, + frameRef, readIsPlaying, setPlaying, setTimelinePosition, - timelineContext, ]); const videoId = video?.id; @@ -197,14 +193,14 @@ export const usePlayerMethods = (): UsePlayerMethods => { } const previousFrame = - timelineContext.frame[videoId] ?? window.remotion_initialFrame ?? 0; + frameRef.current[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.max(0, previousFrame - frames); if (previousFrame === newFrame) { return; } - timelineContext.frame = { - ...timelineContext.frame, + frameRef.current = { + ...frameRef.current, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -213,7 +209,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [readIsPlaying, setFrame, timelineContext, videoId], + [frameRef, readIsPlaying, setFrame, videoId], ); const frameForward = useCallback( @@ -227,14 +223,14 @@ export const usePlayerMethods = (): UsePlayerMethods => { } const previousFrame = - timelineContext.frame[videoId] ?? window.remotion_initialFrame ?? 0; + frameRef.current[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.min(lastFrame, previousFrame + frames); if (previousFrame === newFrame) { return; } - timelineContext.frame = { - ...timelineContext.frame, + frameRef.current = { + ...frameRef.current, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -243,7 +239,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [lastFrame, readIsPlaying, setFrame, timelineContext, videoId], + [frameRef, lastFrame, readIsPlaying, setFrame, videoId], ); const toggle = useCallback( From c36823815608741816c51a574356ef2660ee0c6d Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:42:12 -0400 Subject: [PATCH 06/35] `remotion`: Keep Studio timeline setters free of dummy frame refs Player-only imperative reads stay nullable on SetTimelineContext so Studio does not invent a global frameRef just to satisfy the type. Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 24 ++++++++++------------- packages/player/src/use-player-methods.ts | 6 ++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 434e7d2f9fd..6d95b24479b 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -29,9 +29,9 @@ export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; subscribePlaying: (listener: () => void) => () => void; - frameRef: RefObject>; - isPlaying: () => boolean; - audioAndVideoTags: RefObject; + frameRef: RefObject> | null; + isPlaying: (() => boolean) | null; + audioAndVideoTags: RefObject | null; }; export const SetTimelineContext = createContext({ @@ -44,11 +44,9 @@ export const SetTimelineContext = createContext({ subscribePlaying: () => { throw new Error('default'); }, - frameRef: {current: {}}, - isPlaying: () => { - throw new Error('default'); - }, - audioAndVideoTags: {current: []}, + frameRef: null, + isPlaying: null, + audioAndVideoTags: null, }); export const TimelineContext = createContext(null); @@ -60,8 +58,6 @@ export const AbsoluteTimeContext = createContext( null, ); -const studioFrameRef: RefObject> = {current: {}}; - export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; @@ -148,11 +144,11 @@ export const TimelineContextProvider: React.FC<{ } }, subscribePlaying: playingStore.store.subscribe, - frameRef: studioFrameRef, - isPlaying: readIsPlaying, - audioAndVideoTags, + frameRef: null, + isPlaying: null, + audioAndVideoTags: null, }; - }, [playingStore, readIsPlaying]); + }, [playingStore]); return ( diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index 1d3abf30e75..3f0b65e2ebf 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -40,6 +40,12 @@ export const usePlayerMethods = (): UsePlayerMethods => { throw new TypeError('Expected Player event emitter context'); } + if (!frameRef || !readIsPlaying || !audioAndVideoTags) { + throw new Error( + 'Player timeline context is not available. This hook must be used inside a .', + ); + } + const bufferingContext = useContext(Internals.BufferingContextReact); if (!bufferingContext) { throw new Error( From b3653383eefabd12237df2df06fc0b9a9d800f5f Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:43:45 -0400 Subject: [PATCH 07/35] `remotion`: Keep Studio frameRef in sync with the current timeline Restore a provider-local frame ref assigned each render so Studio play/seek/step reads the live frame instead of an empty or null source. Co-authored-by: Cursor --- packages/core/src/TimelineContext.tsx | 24 +++++++++++++---------- packages/player/src/use-player-methods.ts | 6 ------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 6d95b24479b..1328af4f6cf 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -29,9 +29,9 @@ export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; subscribePlaying: (listener: () => void) => () => void; - frameRef: RefObject> | null; - isPlaying: (() => boolean) | null; - audioAndVideoTags: RefObject | null; + frameRef: RefObject>; + isPlaying: () => boolean; + audioAndVideoTags: RefObject; }; export const SetTimelineContext = createContext({ @@ -44,9 +44,11 @@ export const SetTimelineContext = createContext({ subscribePlaying: () => { throw new Error('default'); }, - frameRef: null, - isPlaying: null, - audioAndVideoTags: null, + frameRef: {current: {}}, + isPlaying: () => { + throw new Error('default'); + }, + audioAndVideoTags: {current: []}, }); export const TimelineContext = createContext(null); @@ -74,6 +76,8 @@ export const TimelineContextProvider: React.FC<{ ); const frame = frameState ?? _frame; + const frameRef = useRef(frame); + frameRef.current = frame; const readIsPlaying = useCallback( () => playingStore.store.getSnapshot().playing, @@ -144,11 +148,11 @@ export const TimelineContextProvider: React.FC<{ } }, subscribePlaying: playingStore.store.subscribe, - frameRef: null, - isPlaying: null, - audioAndVideoTags: null, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, }; - }, [playingStore]); + }, [playingStore, readIsPlaying]); return ( diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index 3f0b65e2ebf..1d3abf30e75 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -40,12 +40,6 @@ export const usePlayerMethods = (): UsePlayerMethods => { throw new TypeError('Expected Player event emitter context'); } - if (!frameRef || !readIsPlaying || !audioAndVideoTags) { - throw new Error( - 'Player timeline context is not available. This hook must be used inside a .', - ); - } - const bufferingContext = useContext(Internals.BufferingContextReact); if (!bufferingContext) { throw new Error( From 172b4c6fb1c02d4000b00c1793989832b7b438db Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 29 Aug 2026 20:52:08 -0400 Subject: [PATCH 08/35] `remotion`: Provide SetTimelineContext in sequence test wrapper Preview media tests call usePlaying(), which now subscribes through SetTimelineContext. The wrapper only had TimelineContext, so OffthreadVideo preview renders threw the default context error. Co-authored-by: Cursor --- .../core/src/test/wrap-sequence-context.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/test/wrap-sequence-context.tsx b/packages/core/src/test/wrap-sequence-context.tsx index 7ae2b4f6e04..213d4cdac8e 100644 --- a/packages/core/src/test/wrap-sequence-context.tsx +++ b/packages/core/src/test/wrap-sequence-context.tsx @@ -8,11 +8,13 @@ import {LogLevelContext} from '../log-level-context.js'; import {SequenceManagerProvider} from '../SequenceManager.js'; import type { PlaybackRateContextValue, + SetTimelineContextValue, TimelineContextValue, } from '../TimelineContext.js'; import { AbsoluteTimeContext, PlaybackRateContext, + SetTimelineContext, TimelineContext, } from '../TimelineContext.js'; @@ -68,6 +70,15 @@ const mockPlaybackRateContext: PlaybackRateContextValue = { }, }; +const mockSetTimelineContext: SetTimelineContextValue = { + setFrame: () => undefined, + setPlaying: () => undefined, + subscribePlaying: () => () => undefined, + frameRef: {current: {}}, + isPlaying: () => false, + audioAndVideoTags: {current: []}, +}; + const MaybeTimelineProvider: React.FC<{ readonly children: React.ReactNode; readonly timelineContext: TimelineContextValue; @@ -126,13 +137,15 @@ export const WrapSequenceContext: React.FC<{ - - - - {children} - - - + + + + + {children} + + + + From 4eaf9d51254f582288f66a0864d01767fb3a631c Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 12:45:58 -0400 Subject: [PATCH 09/35] remotion: Avoid redundant playback store updates --- packages/core/src/TimelineContext.tsx | 10 +++++----- packages/player/src/Player.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 1328af4f6cf..8e2961fe481 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -140,11 +140,11 @@ export const TimelineContextProvider: React.FC<{ return { setFrame, setPlaying: (updater) => { - if (typeof updater === 'function') { - const current = playingStore.store.getSnapshot().playing; - playingStore.setSnapshot({playing: updater(current)}); - } else { - playingStore.setSnapshot({playing: updater}); + const current = playingStore.store.getSnapshot().playing; + const next = typeof updater === 'function' ? updater(current) : updater; + + if (current !== next) { + playingStore.setSnapshot({playing: next}); } }, subscribePlaying: playingStore.store.subscribe, diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 100121b9e48..366c70f14ce 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -435,11 +435,11 @@ const PlayerFn = < return { setFrame, setPlaying: (updater) => { - if (typeof updater === 'function') { - const current = playingStore.store.getSnapshot().playing; - playingStore.setSnapshot({playing: updater(current)}); - } else { - playingStore.setSnapshot({playing: updater}); + const current = playingStore.store.getSnapshot().playing; + const next = typeof updater === 'function' ? updater(current) : updater; + + if (current !== next) { + playingStore.setSnapshot({playing: next}); } }, subscribePlaying: playingStore.store.subscribe, From b0e25b7cc183a8e3fecaf88d54e04fc37a3fa471 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 12:47:38 -0400 Subject: [PATCH 10/35] remotion: Keep usePlaying internal --- packages/core/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1105b8c1551..7948e6e5fff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -250,7 +250,6 @@ export * from './Still.js'; export type {PlayableMediaTag} from './timeline-position-state.js'; export {useBufferState} from './use-buffer-state'; export {useCurrentFrame} from './use-current-frame.js'; -export {usePlaying} from './use-playing.js'; export { CurrentScaleContextType, PreviewSize, From 7ef0bf56798076219181d8524c462935af23e6b5 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 12:50:26 -0400 Subject: [PATCH 11/35] remotion: Provide playback context to thumbnails --- packages/player/src/Thumbnail.tsx | 42 ++++++++++++++++++-------- packages/player/src/test/index.test.ts | 21 +++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/player/src/Thumbnail.tsx b/packages/player/src/Thumbnail.tsx index 8dfa6066b20..139cdeec7b9 100644 --- a/packages/player/src/Thumbnail.tsx +++ b/packages/player/src/Thumbnail.tsx @@ -18,6 +18,7 @@ import type { LogLevel, PlayableMediaTag, PlaybackRateContextValue, + SetTimelineContextValue, TimelineContextValue, } from 'remotion'; import {Internals} from 'remotion'; @@ -96,6 +97,19 @@ const ThumbnailFn = < return value; }, [frameToDisplay]); + const frameRef = useRef(timelineState.frame); + frameRef.current = timelineState.frame; + + const setTimelineContext = useMemo((): SetTimelineContextValue => { + return { + setFrame: () => undefined, + setPlaying: () => undefined, + subscribePlaying: () => () => undefined, + frameRef, + isPlaying: () => false, + audioAndVideoTags, + }; + }, []); const playbackRateContext: PlaybackRateContextValue = useMemo(() => { return { @@ -139,19 +153,21 @@ const ThumbnailFn = < audioEnabled={false} _experimentalKeepAudioContextAlive={false} > - - - + + + + + ); diff --git a/packages/player/src/test/index.test.ts b/packages/player/src/test/index.test.ts index 5cad85e76f4..e54e0fe73e1 100644 --- a/packages/player/src/test/index.test.ts +++ b/packages/player/src/test/index.test.ts @@ -3,6 +3,7 @@ import React, {createRef, useRef} from 'react'; import {Html5Audio, Internals, useCurrentFrame} from 'remotion'; import type {PlayerRef} from '../player-methods.js'; import {Player} from '../Player.js'; +import {Thumbnail} from '../Thumbnail.js'; import type {UsePlayerMethods} from '../use-player-methods.js'; import {usePlayerMethods} from '../use-player-methods.js'; import {act, cleanup, fireEvent, render} from './test-utils.js'; @@ -17,6 +18,26 @@ test('It should throw an error if not being used inside a RemotionRoot', () => { }).toThrow(); }); +const ThumbnailPlayingProbe = () => { + const playing = Internals.usePlaying(); + return React.createElement('div', null, playing ? 'playing' : 'paused'); +}; + +test('Thumbnail provides playback context to its composition', () => { + const view = render( + React.createElement(Thumbnail, { + component: ThumbnailPlayingProbe, + durationInFrames: 100, + compositionWidth: 1920, + compositionHeight: 1080, + fps: 30, + frameToDisplay: 0, + }), + ); + + expect(view.getByText('paused')).toBeTruthy(); +}); + test('Seeking to the current frame does not rerender the composition', () => { let compositionRenders = 0; const playerRef = createRef(); From 51b51c926d028359efb4beb8e63787306aee8c7c Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 12:52:57 -0400 Subject: [PATCH 12/35] remotion: Simplify thumbnail playback fallback --- packages/core/src/TimelineContext.tsx | 4 +-- packages/player/src/Thumbnail.tsx | 42 ++++++++------------------ packages/player/src/test/index.test.ts | 2 +- 3 files changed, 15 insertions(+), 33 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 8e2961fe481..48d01992c73 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -41,9 +41,7 @@ export const SetTimelineContext = createContext({ setPlaying: () => { throw new Error('default'); }, - subscribePlaying: () => { - throw new Error('default'); - }, + subscribePlaying: () => () => undefined, frameRef: {current: {}}, isPlaying: () => { throw new Error('default'); diff --git a/packages/player/src/Thumbnail.tsx b/packages/player/src/Thumbnail.tsx index 139cdeec7b9..8dfa6066b20 100644 --- a/packages/player/src/Thumbnail.tsx +++ b/packages/player/src/Thumbnail.tsx @@ -18,7 +18,6 @@ import type { LogLevel, PlayableMediaTag, PlaybackRateContextValue, - SetTimelineContextValue, TimelineContextValue, } from 'remotion'; import {Internals} from 'remotion'; @@ -97,19 +96,6 @@ const ThumbnailFn = < return value; }, [frameToDisplay]); - const frameRef = useRef(timelineState.frame); - frameRef.current = timelineState.frame; - - const setTimelineContext = useMemo((): SetTimelineContextValue => { - return { - setFrame: () => undefined, - setPlaying: () => undefined, - subscribePlaying: () => () => undefined, - frameRef, - isPlaying: () => false, - audioAndVideoTags, - }; - }, []); const playbackRateContext: PlaybackRateContextValue = useMemo(() => { return { @@ -153,21 +139,19 @@ const ThumbnailFn = < audioEnabled={false} _experimentalKeepAudioContextAlive={false} > - - - - - + + + ); diff --git a/packages/player/src/test/index.test.ts b/packages/player/src/test/index.test.ts index e54e0fe73e1..b43855d7440 100644 --- a/packages/player/src/test/index.test.ts +++ b/packages/player/src/test/index.test.ts @@ -23,7 +23,7 @@ const ThumbnailPlayingProbe = () => { return React.createElement('div', null, playing ? 'playing' : 'paused'); }; -test('Thumbnail provides playback context to its composition', () => { +test('Thumbnail supports usePlaying in its composition', () => { const view = render( React.createElement(Thumbnail, { component: ThumbnailPlayingProbe, From f47c8bdd42f0ee3c57942b8377b534562b34a7d3 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 16:34:13 -0400 Subject: [PATCH 13/35] refactor(core): publish runtime value snapshots Co-authored-by: Codex --- packages/core/src/TimelineContext.tsx | 4 +++- packages/core/src/runtime-value-store.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 48d01992c73..483f54b8f57 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -28,7 +28,9 @@ export type PlaybackRateContextValue = { export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; - subscribePlaying: (listener: () => void) => () => void; + subscribePlaying: ( + listener: (state: Readonly<{playing: boolean}>) => void, + ) => () => void; frameRef: RefObject>; isPlaying: () => boolean; audioAndVideoTags: RefObject; diff --git a/packages/core/src/runtime-value-store.ts b/packages/core/src/runtime-value-store.ts index f3112581621..890e59ab5d0 100644 --- a/packages/core/src/runtime-value-store.ts +++ b/packages/core/src/runtime-value-store.ts @@ -4,7 +4,7 @@ export type RuntimeValueStore< TSnapshot extends RuntimeValueSnapshot = RuntimeValueSnapshot, > = { getSnapshot: () => TSnapshot; - subscribe: (listener: () => void) => () => void; + subscribe: (listener: (snapshot: TSnapshot) => void) => () => void; }; export type RuntimeValueStoreController< @@ -18,7 +18,7 @@ export const createRuntimeValueStore = ( initialSnapshot: TSnapshot, ): RuntimeValueStoreController => { let snapshot = initialSnapshot; - const listeners = new Set<() => void>(); + const listeners = new Set<(snapshot: TSnapshot) => void>(); const store: RuntimeValueStore = { getSnapshot: () => snapshot, @@ -39,7 +39,7 @@ export const createRuntimeValueStore = ( snapshot = newSnapshot; for (const listener of listeners) { - listener(); + listener(snapshot); } }, }; From e42ac516e15618e4287abfa99376a4aaeb8688c8 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sun, 30 Aug 2026 16:38:54 -0400 Subject: [PATCH 14/35] docs(core): explain playing subscription split Co-authored-by: Codex --- packages/core/src/use-playing.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/use-playing.ts b/packages/core/src/use-playing.ts index f13c9153000..5fae1838bae 100644 --- a/packages/core/src/use-playing.ts +++ b/packages/core/src/use-playing.ts @@ -5,5 +5,6 @@ import {SetTimelineContext} from './TimelineContext.js'; export const usePlaying = () => { const {isPlaying} = useTimelineContext(); const {subscribePlaying} = useContext(SetTimelineContext); + // Read the local Freeze override, but subscribe to the shared playing store. return useSyncExternalStore(subscribePlaying, isPlaying, isPlaying); }; From 9ec3e9f277ce13a7d69174b98961aa0591305edb Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 11:18:55 +0200 Subject: [PATCH 15/35] `@remotion/media`: Upgrade Mediabunny to 1.55.4 --- bun.lock | 38 +++++++++---------- package.json | 16 ++++---- packages/cli/src/extra-packages.ts | 14 +++---- packages/docs/docs/mediabunny/version.mdx | 3 +- packages/studio-shared/src/package-info.ts | 8 ++-- .../template-music-visualization/package.json | 2 +- packages/template-recorder/package.json | 2 +- packages/template-three/package.json | 2 +- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/bun.lock b/bun.lock index 18e175438ff..260aa426542 100644 --- a/bun.lock +++ b/bun.lock @@ -2119,7 +2119,7 @@ "@remotion/media": "workspace:*", "@remotion/media-utils": "workspace:*", "@remotion/zod-types": "workspace:*", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2441,7 +2441,7 @@ "connect": "3.7.0", "eslint": "9.19.0", "lucide-react": "0.516.0", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2577,7 +2577,7 @@ "@remotion/media": "workspace:*", "@remotion/three": "workspace:*", "@remotion/zod-types": "workspace:*", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2897,13 +2897,13 @@ "@aws-sdk/middleware-flexible-checksums": "3.972.5", "@aws-sdk/s3-request-presigner": "3.986.0", "@huggingface/transformers": "4.2.0", - "@mediabunny/aac-encoder": "1.55.1", - "@mediabunny/ac3": "1.55.1", - "@mediabunny/dts": "1.55.1", - "@mediabunny/flac-encoder": "1.55.1", - "@mediabunny/mp3-encoder": "1.55.1", - "@mediabunny/prores": "1.55.1", - "@mediabunny/server": "1.55.1", + "@mediabunny/aac-encoder": "1.55.4", + "@mediabunny/ac3": "1.55.4", + "@mediabunny/dts": "1.55.4", + "@mediabunny/flac-encoder": "1.55.4", + "@mediabunny/mp3-encoder": "1.55.4", + "@mediabunny/prores": "1.55.4", + "@mediabunny/server": "1.55.4", "@react-three/fiber": "9.2.0", "@types/bun": "1.3.3", "@types/dom-webcodecs": "0.1.11", @@ -2916,7 +2916,7 @@ "@vitejs/plugin-react": "4.3.4", "@vitest/browser-playwright": "4.0.9", "eslint": "9.19.0", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "next": "16.2.11", "openai": "4.67.1", "playwright": "1.55.1", @@ -3889,19 +3889,19 @@ "@mdx-js/react": ["@mdx-js/react@2.3.0", "", { "dependencies": { "@types/mdx": "2.0.5", "@types/react": "19.0.0" }, "peerDependencies": { "react": "19.0.0" } }, "sha512-zQH//gdOmuu7nt2oJR29vFhDv88oGPmVw6BggmrHeMI+xgEkp1B2dX9/bMBSYtK0dyLX/aOmesKS09g222K1/g=="], - "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-NkLRZzfZoCmMZpMosj+O9gpl1tgHgKR85G8QrtUSUGJO1HaI/NqZznwSEdGWZKT4rSiRRaYtYXecLCAIdKWDFQ=="], + "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-wxwuXnH+q0TGwFwvK42Xbld13RvogwSDe0ipeshfRhCNb1xTmLgqo0yzipmnPSZXn8d02TCWGFe8XDyNCtTU1w=="], - "@mediabunny/ac3": ["@mediabunny/ac3@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-6gNlm1qffxQLnK94iYsDIK+SM2LEW5vFUmLFFm3Xgz4c+o3oJ0ZNVeWaZiVMj3/Gw5BrJ4GwOTpPLsjzi+uZHA=="], + "@mediabunny/ac3": ["@mediabunny/ac3@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-yhUUPA0NlOPp6vb6/rpu8wDlaUbgrjZ3KHmNIYMaeZzrck3FAFQ0+RCGG8yA9dtCX/VpLbVaqxhLi+ZSzubeZA=="], - "@mediabunny/dts": ["@mediabunny/dts@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JQlgcf3d6l1/CIfTreZeTGB+dnwRr/cOdJEkGmNLXEsZPx8TQ+F/TmDQkWBjr4sJqYZd/l36uF6T5ymgWRYa5A=="], + "@mediabunny/dts": ["@mediabunny/dts@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-dZsjHlhWs4q/MxVC4Iz2FP8fSq1jGoXdMRmZ/Mu8sSYf39NNBK6QyU2e5DnbupzqyKkTZ4QkWE/KS74G5Du8xw=="], - "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-tWmL2Fv6MtAIkjSVebZ8YgSj7YR/xN6Jn37F+JpSA2WdZLDXLxxH0wyw/kqsKv9aWCCUcYhc/GpDo/KUr9SUHw=="], + "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-Rm5xATKLG2onP4a0YJG8hP/2m2grUjqtCumZNs7EBEXfuIPbjsuJdsnXQ6yGlvTp6/B3gASZI+4eJsgO0xw/3g=="], - "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-5l9o9R+Ptb0MzAdysZ7fG5GxCpq+MxtJxy+ywlm32HFhvifutKJTw1ORP6pz2Bu0m8aD5OPITgdANvmwkXYU/g=="], + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-Ox8WUlWH/MR2gcsVPvPzZwmJk2UkjrEBJQzUsyLJ27tEqjSnVO+BhEOol1y8UVkSoeGV7wpNrN7aznm91BTgoQ=="], - "@mediabunny/prores": ["@mediabunny/prores@1.55.1", "", { "dependencies": { "turbores": "^1.2.2" }, "peerDependencies": { "mediabunny": "^1.49.0" } }, "sha512-0/nmBgGHLr8uDvWvhcVGygQEN75MM18BpdJCZv9jDmw0xE+dWxDcgth8Gw/xxEsMs0xrtKrQhQU4U6ZV5rxx+w=="], + "@mediabunny/prores": ["@mediabunny/prores@1.55.4", "", { "dependencies": { "turbores": "^1.2.2" }, "peerDependencies": { "mediabunny": "^1.49.0" } }, "sha512-DOunATssb5NPAp44eNOsUAISuvMz2teuESpmtWhWxeYWvm99j+uLEWWpCkzcDTLKp/5mD18/6lJLiAHt4SppBA=="], - "@mediabunny/server": ["@mediabunny/server@1.55.1", "", { "dependencies": { "@mediabunny/prores": "^1.55.1", "node-av": "^6.0.0" }, "peerDependencies": { "mediabunny": "^1.45.0" } }, "sha512-S296TDzp2RaMu8o9I1v5TSbLCkbXj76RhTsNRWTClVOQkmkzj2QYTpddFbGb61jvB7JwfK1jyZ9DFBK+3cTRAA=="], + "@mediabunny/server": ["@mediabunny/server@1.55.4", "", { "dependencies": { "@mediabunny/prores": "^1.55.4", "node-av": "^6.0.0" }, "peerDependencies": { "mediabunny": "^1.45.0" } }, "sha512-PBzINwmRCOcKBDZ+hv5fYfdLB6NLg9BTdZkZGBMhcVr0nBTMLE8s+Erd5r00wK1JGMraXDXMem5++YGpwfOasg=="], "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], @@ -7281,7 +7281,7 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "mediabunny": ["mediabunny@1.55.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-JDdTEUOw9g6Ey8eWc2Ei63GmRGtGTJoE56SE7CHikwzS8i9xpsWcve4bEiUw47d4j49L3r7MXDFsH/5oZIhjBw=="], + "mediabunny": ["mediabunny@1.55.4", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-dJDE7ScjKlt21ZA3wS0xWQtgXjoUBEkWzjbnTVX5/BdN88C4QMdQu1u+K13vPHbuEbvjKbsXdYy38M3JTtPRHw=="], "mem": ["mem@4.3.0", "", { "dependencies": { "map-age-cleaner": "^0.1.1", "mimic-fn": "^2.0.0", "p-is-promise": "^2.0.0" } }, "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w=="], diff --git a/package.json b/package.json index 3cb30513d6a..3fd92bb6615 100644 --- a/package.json +++ b/package.json @@ -132,14 +132,14 @@ "@types/node": "20.12.14", "@types/web": "0.0.166", "@types/bun": "1.3.3", - "mediabunny": "1.55.1", - "@mediabunny/server": "1.55.1", - "@mediabunny/mp3-encoder": "1.55.1", - "@mediabunny/aac-encoder": "1.55.1", - "@mediabunny/flac-encoder": "1.55.1", - "@mediabunny/prores": "1.55.1", - "@mediabunny/ac3": "1.55.1", - "@mediabunny/dts": "1.55.1", + "mediabunny": "1.55.4", + "@mediabunny/server": "1.55.4", + "@mediabunny/mp3-encoder": "1.55.4", + "@mediabunny/aac-encoder": "1.55.4", + "@mediabunny/flac-encoder": "1.55.4", + "@mediabunny/prores": "1.55.4", + "@mediabunny/ac3": "1.55.4", + "@mediabunny/dts": "1.55.4", "next": "16.2.11", "three": "0.178.0", "sharp": "0.34.5", diff --git a/packages/cli/src/extra-packages.ts b/packages/cli/src/extra-packages.ts index abb0d4c43ef..d85c8ef4117 100644 --- a/packages/cli/src/extra-packages.ts +++ b/packages/cli/src/extra-packages.ts @@ -1,11 +1,11 @@ export const EXTRA_PACKAGES: Record = { - mediabunny: '1.55.1', - '@mediabunny/ac3': '1.55.1', - '@mediabunny/dts': '1.55.1', - '@mediabunny/mp3-encoder': '1.55.1', - '@mediabunny/aac-encoder': '1.55.1', - '@mediabunny/flac-encoder': '1.55.1', - '@mediabunny/prores': '1.55.1', + mediabunny: '1.55.4', + '@mediabunny/ac3': '1.55.4', + '@mediabunny/dts': '1.55.4', + '@mediabunny/mp3-encoder': '1.55.4', + '@mediabunny/aac-encoder': '1.55.4', + '@mediabunny/flac-encoder': '1.55.4', + '@mediabunny/prores': '1.55.4', zod: '4.4.3', }; diff --git a/packages/docs/docs/mediabunny/version.mdx b/packages/docs/docs/mediabunny/version.mdx index fab9482654e..79398c59428 100644 --- a/packages/docs/docs/mediabunny/version.mdx +++ b/packages/docs/docs/mediabunny/version.mdx @@ -10,7 +10,7 @@ The following packages of Remotion are using [Mediabunny](https://mediabunny.dev - [`@remotion/media`](/docs/media) - [`@remotion/media-utils`](/docs/media-utils) -The current version of Remotion uses version [`1.55.1`](https://github.com/Vanilagy/mediabunny/releases/tag/v1.55.1) of Mediabunny. +The current version of Remotion uses version [`1.55.4`](https://github.com/Vanilagy/mediabunny/releases/tag/v1.55.4) of Mediabunny. If you use Mediabunny as a direct dependency in your project, you can install it with the correct version using: @@ -41,6 +41,7 @@ Note that only from 4.0.355, Mediabunny is not bundled in with Remotion but load | Remotion Version | Mediabunny Version | | ---------------- | ------------------ | +| 4.0.520 | 1.55.4 | | 4.0.513 | 1.55.1 | | 4.0.488 | 1.50.8 | | 4.0.487 | 1.50.7 | diff --git a/packages/studio-shared/src/package-info.ts b/packages/studio-shared/src/package-info.ts index d0a1d15106c..6a74d410802 100644 --- a/packages/studio-shared/src/package-info.ts +++ b/packages/studio-shared/src/package-info.ts @@ -129,25 +129,25 @@ export type ExtraPackage = { export const extraPackages: ExtraPackage[] = [ { name: 'mediabunny', - version: '1.55.1', + version: '1.55.4', description: 'Multimedia library used by Remotion', docsUrl: 'https://www.remotion.dev/docs/mediabunny/version', }, { name: '@mediabunny/ac3', - version: '1.55.1', + version: '1.55.4', description: 'AC-3 and E-AC-3 audio codec support for Mediabunny', docsUrl: 'https://www.remotion.dev/docs/mediabunny/formats#ac-3-and-e-ac-3', }, { name: '@mediabunny/dts', - version: '1.55.1', + version: '1.55.4', description: 'DTS audio codec support for Mediabunny', docsUrl: 'https://mediabunny.dev/guide/extensions/dts', }, { name: '@mediabunny/prores', - version: '1.55.1', + version: '1.55.4', description: 'Apple ProRes decoder support for Mediabunny', docsUrl: 'https://www.remotion.dev/docs/mediabunny/formats', }, diff --git a/packages/template-music-visualization/package.json b/packages/template-music-visualization/package.json index 9407a128096..6cd256b5d73 100644 --- a/packages/template-music-visualization/package.json +++ b/packages/template-music-visualization/package.json @@ -17,7 +17,7 @@ "@remotion/media-utils": "workspace:*", "@remotion/google-fonts": "workspace:*", "@remotion/zod-types": "workspace:*", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", diff --git a/packages/template-recorder/package.json b/packages/template-recorder/package.json index df6470623e7..c540f94ec72 100644 --- a/packages/template-recorder/package.json +++ b/packages/template-recorder/package.json @@ -39,7 +39,7 @@ "connect": "3.7.0", "eslint": "9.19.0", "lucide-react": "0.516.0", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "1.14.0", diff --git a/packages/template-three/package.json b/packages/template-three/package.json index f13b091c210..1a387874ab5 100644 --- a/packages/template-three/package.json +++ b/packages/template-three/package.json @@ -21,7 +21,7 @@ "remotion": "workspace:*", "three": "0.178.0", "zod": "4.4.3", - "mediabunny": "1.55.1" + "mediabunny": "1.55.4" }, "devDependencies": { "@remotion/eslint-config-flat": "workspace:*", From 6a9bfc68b71611fcc1638bb5de31af01316ef579 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 11:28:47 +0200 Subject: [PATCH 16/35] Add current error to Studio WebMCP --- packages/example/e2e/error-overlay.test.mts | 60 +++++++++++++++++++ packages/studio/src/components/WebMcp.tsx | 37 ++++++++++++ .../studio/src/error-overlay/current-error.ts | 29 +++++++++ .../remotion-overlay/ErrorLoader.tsx | 7 ++- 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/error-overlay/current-error.ts diff --git a/packages/example/e2e/error-overlay.test.mts b/packages/example/e2e/error-overlay.test.mts index 2a1987d7665..b1c944cd900 100644 --- a/packages/example/e2e/error-overlay.test.mts +++ b/packages/example/e2e/error-overlay.test.mts @@ -119,7 +119,44 @@ test.describe('error overlay dismissal', () => { }); await page.addInitScript(() => { Object.defineProperty(window.navigator, 'platform', {value: 'Win32'}); + type RegisteredTool = { + readonly name: string; + readonly execute: (input: Record) => Promise; + }; + const tools = new Map(); + Object.defineProperty(window, '__remotion_webmcp_tools', { + value: tools, + }); + Object.defineProperty(document, 'modelContext', { + value: { + registerTool: async ( + tool: RegisteredTool, + options: {readonly signal: AbortSignal}, + ) => { + tools.set(tool.name, tool); + options.signal.addEventListener('abort', () => { + if (tools.get(tool.name) === tool) { + tools.delete(tool.name); + } + }); + }, + }, + }); }); + const getCurrentError = () => { + return page.evaluate(async () => { + const tools = ( + window as typeof window & { + readonly __remotion_webmcp_tools: Map< + string, + {readonly execute: () => Promise} + >; + } + ).__remotion_webmcp_tools; + const tool = tools.get('get_current_error'); + return tool ? tool.execute() : 'not-registered'; + }); + }; await page.goto(`${STUDIO_URL}/error-overlay-unsymbolicated-e2e`); await expect(page.getByText('Expected defaults').first()).toBeVisible({ timeout: 15_000, @@ -246,6 +283,14 @@ test.describe('error overlay dismissal', () => { (element) => element.scrollWidth > element.clientWidth, ), ).toBe(true); + await expect.poll(getCurrentError).toEqual({ + name: 'TypeError', + message: 'Expected defaults', + stack: expect.stringContaining( + 'webpack-internal:///cannot-symbolicate.js', + ), + symbolicatedStackFrames: null, + }); await page.getByRole('button', {name: 'Copy stack'}).click(); await expect(page.getByRole('button', {name: 'Copied!'})).toBeVisible(); @@ -258,6 +303,7 @@ test.describe('error overlay dismissal', () => { // Sanity check: no error visible initially. await expect(errorMessage).toHaveCount(0); + await expect.poll(getCurrentError).toBe(null); // 1. Introduce the bug: remove the `radius: 24` argument. await writeAndWaitForRebuild(buggyContent, 'introducing the bug'); @@ -265,6 +311,19 @@ test.describe('error overlay dismissal', () => { await expect( page.getByText('ErrorOverlayRepro', {exact: true}), ).toBeVisible(); + await expect.poll(getCurrentError).toEqual({ + name: 'TypeError', + message: '"radius" must be a finite number, but got undefined', + stack: expect.any(String), + symbolicatedStackFrames: expect.arrayContaining([ + expect.objectContaining({ + originalFunctionName: 'ErrorOverlayRepro', + originalFileName: expect.stringContaining( + 'src/ErrorOverlayE2e/ErrorOverlayRepro.tsx', + ), + }), + ]), + }); await expect( page.getByText('react_stack_bottom_frame', {exact: true}), ).toHaveCount(0); @@ -333,6 +392,7 @@ test.describe('error overlay dismissal', () => { // dismiss once HMR applies the fix. await writeAndWaitForRebuild(originalContent, 'fixing the bug'); await expect(errorMessage).toHaveCount(0, {timeout: 15_000}); + await expect.poll(getCurrentError).toBe(null); // 3. Re-introduce the bug: the error UI should come back. This guards // against the boundary getting permanently stuck in the success state diff --git a/packages/studio/src/components/WebMcp.tsx b/packages/studio/src/components/WebMcp.tsx index 5132946e60f..6e20498cf96 100644 --- a/packages/studio/src/components/WebMcp.tsx +++ b/packages/studio/src/components/WebMcp.tsx @@ -3,6 +3,7 @@ import {Internals} from 'remotion'; import {pause} from '../api/pause'; import {play} from '../api/play'; import {seek} from '../api/seek'; +import {getCurrentError} from '../error-overlay/current-error'; import {calculateTimeline} from '../helpers/calculate-timeline'; import {createFolderTree} from '../helpers/create-folder-tree'; import { @@ -296,6 +297,42 @@ export const WebMcp: FC = () => { }; Promise.all([ + modelContext.registerTool( + { + name: 'get_current_error', + title: 'Get current Studio error', + description: + 'Read the error currently shown in the Remotion Studio error overlay. Returns null when the overlay is not visible and includes symbolicated stack frames when symbolication succeeds.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: {readOnlyHint: true}, + execute: async () => { + const currentError = getCurrentError(); + if (currentError === null) { + return null; + } + + let symbolicatedStackFrames = null; + try { + const record = await currentError.symbolication; + symbolicatedStackFrames = record?.stackFrames ?? null; + } catch { + // Fall back to the original stack if symbolication fails. + } + + return { + name: currentError.error.name, + message: currentError.error.message, + stack: currentError.error.stack ?? null, + symbolicatedStackFrames, + }; + }, + }, + {signal: controller.signal}, + ), modelContext.registerTool( { name: 'get_compositions', diff --git a/packages/studio/src/error-overlay/current-error.ts b/packages/studio/src/error-overlay/current-error.ts new file mode 100644 index 00000000000..784b9b23ed9 --- /dev/null +++ b/packages/studio/src/error-overlay/current-error.ts @@ -0,0 +1,29 @@ +import type {ErrorRecord} from './react-overlay/listen-to-runtime-errors'; + +type CurrentError = { + readonly error: Error; + readonly symbolication: Promise; +}; + +declare global { + interface Window { + remotion_currentErrors: CurrentError[] | undefined; + } +} + +export const registerCurrentError = (error: CurrentError) => { + window.remotion_currentErrors = [ + ...(window.remotion_currentErrors ?? []), + error, + ]; + + return () => { + window.remotion_currentErrors = window.remotion_currentErrors?.filter( + (candidate) => candidate !== error, + ); + }; +}; + +export const getCurrentError = (): CurrentError | null => { + return window.remotion_currentErrors?.[0] ?? null; +}; diff --git a/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx b/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx index 1472d2c255c..c9a136c8382 100644 --- a/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx +++ b/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx @@ -1,5 +1,6 @@ import {getLocationFromBuildError} from '@remotion/studio-shared'; import React, {useEffect, useState} from 'react'; +import {registerCurrentError} from '../current-error'; import {wasErrorLoggedByServer} from '../error-origin'; import type {ErrorRecord} from '../react-overlay/listen-to-runtime-errors'; import {getErrorRecord} from '../react-overlay/listen-to-runtime-errors'; @@ -93,7 +94,9 @@ export const ErrorLoader: React.FC<{ }); useEffect(() => { - getErrorRecord(error) + const symbolication = getErrorRecord(error); + const unregister = registerCurrentError({error, symbolication}); + symbolication .then((record) => { if (record) { if (shouldLogError(error)) { @@ -124,6 +127,8 @@ export const ErrorLoader: React.FC<{ type: 'error', }); }); + + return unregister; }, [error]); if (state.type === 'loading') { From dd2ee8dc45e416401a51ad78a8b6e409548730da Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:25:17 +0200 Subject: [PATCH 17/35] `@remotion/media`: Update loop frame color snapshot --- ...me-91-loop-trim-after-step-back-chrome.png | Bin 21007 -> 29072 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png b/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png index b35edd4c95eb281bfd5ba582b9e8b3e6c7004de4..e8c9a3f723a6208f40e44cc0cebed6bcb4f3b434 100644 GIT binary patch literal 29072 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 literal 21007 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 N9o+9geqiO9@E>RTPqhF5 From a79dba5c2dde274f841da2f1ec0c05782004a9a7 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:33:04 +0200 Subject: [PATCH 18/35] Document get_current_error WebMCP tool --- packages/docs/docs/ai/webmcp.mdx | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/docs/docs/ai/webmcp.mdx b/packages/docs/docs/ai/webmcp.mdx index af6ba9ed784..1e5664d29cb 100644 --- a/packages/docs/docs/ai/webmcp.mdx +++ b/packages/docs/docs/ai/webmcp.mdx @@ -16,6 +16,7 @@ Agents can: - See which items you currently have selected - See selectable items and select them - See available compositions and navigate to them +- Read the error currently shown in the Studio error overlay - Get the current HTML of your composition - See current outlines of elements and their geometry - Get your current playhead position and playback state @@ -41,6 +42,37 @@ The shape of inputs and outputs is not currently stable. Tools that modify the timeline, playback or guides require a composition to be open. +### `get_current_error` + +Returns the error currently shown in the Studio error overlay. It waits for source map symbolication and includes symbolicated stack frames when available, while preserving the raw stack as a fallback. Returns `null` when the error overlay is not visible. + +```ts title="Input" +{} +``` + +```ts title="Output" +type ScriptLine = { + lineNumber: number; + content: string; + highlight: boolean; +}; + +type SymbolicatedStackFrame = { + originalFunctionName: string | null; + originalFileName: string | null; + originalLineNumber: number | null; + originalColumnNumber: number | null; + originalScriptCode: ScriptLine[] | null; +}; + +null | { + name: string; + message: string; + stack: string | null; + symbolicatedStackFrames: SymbolicatedStackFrame[] | null; +} +``` + ### `get_compositions` Returns the mounted compositions in the same nested folder hierarchy shown in the Studio sidebar. @@ -481,6 +513,7 @@ Seeks to a zero-based frame. Values beyond the duration are clamped to the final You can ask a browser agent: - "What is currently selected in the Studio?" +- "What is the current error?" - "List all mounted compositions and their folders." - "Open the `Shapes` composition." - "List the sequences and select the `Title` sequence." From e2d47177a1a0de7ae7b716d14f52b1b09fafa683 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:36:45 +0200 Subject: [PATCH 19/35] Update WebMCP error prompt --- packages/docs/docs/ai/webmcp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/ai/webmcp.mdx b/packages/docs/docs/ai/webmcp.mdx index 1e5664d29cb..7a56afa5ce7 100644 --- a/packages/docs/docs/ai/webmcp.mdx +++ b/packages/docs/docs/ai/webmcp.mdx @@ -513,7 +513,7 @@ Seeks to a zero-based frame. Values beyond the duration are clamped to the final You can ask a browser agent: - "What is currently selected in the Studio?" -- "What is the current error?" +- "Fix the error" - "List all mounted compositions and their folders." - "Open the `Shapes` composition." - "List the sequences and select the `Title` sequence." From 9faa0cc66b77318ffb703c6717e420d9bab425d1 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:37:24 +0200 Subject: [PATCH 20/35] Cancel render on HTML-in-canvas draw errors --- .../src/html-in-canvas-presentation.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/transitions/src/html-in-canvas-presentation.tsx b/packages/transitions/src/html-in-canvas-presentation.tsx index 5784da61ffb..fe94431c1ad 100644 --- a/packages/transitions/src/html-in-canvas-presentation.tsx +++ b/packages/transitions/src/html-in-canvas-presentation.tsx @@ -103,7 +103,7 @@ export const HtmlInCanvasPresentation = < }, [instance]); const chainState = Internals.useEffectChainState(); - const {delayRender, continueRender} = useDelayRender(); + const {delayRender, continueRender, cancelRender} = useDelayRender(); const draw: DrawFunction = useCallback( async (prevImage, nextImage, progress) => { @@ -126,6 +126,7 @@ export const HtmlInCanvasPresentation = < if (!prevImage && !nextImage) { instance.clear(); clearOutput(); + continueRender(handle); return; } @@ -135,6 +136,7 @@ export const HtmlInCanvasPresentation = < if (width === 0 || height === 0) { instance.clear(); clearOutput(); + continueRender(handle); return; } @@ -158,11 +160,19 @@ export const HtmlInCanvasPresentation = < height, output: outputCanvas, }); - } finally { continueRender(handle); + } catch (error) { + cancelRender(error); } }, - [chainState, continueRender, delayRender, instance, shaderCanvas], + [ + cancelRender, + chainState, + continueRender, + delayRender, + instance, + shaderCanvas, + ], ); const passThrough = From b91f119a4c09430f82ce4e1f2abe00d22cc37e35 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:46:51 +0200 Subject: [PATCH 21/35] Fix Browser Studio document title --- packages/browser-studio/e2e/browser-studio.test.ts | 12 ++++++++++++ packages/studio/src/helpers/document-title.ts | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 3a3bcc77d29..442c76daa06 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -195,6 +195,10 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli studio.locator('.remotion-studio-composition-container'), ).toBeVisible(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp'); + await expect(page).toHaveTitle( + 'MyComp / template-blank - Remotion Studio', + {timeout: 5000}, + ); await studio.locator('button:has(svg[viewBox="0 0 415 426"])').click(); const popupPromise = page.waitForEvent('popup'); await studio.getByText('About Remotion', {exact: true}).click(); @@ -329,8 +333,16 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli studio.getByTitle('/project').getByText('MyComp1'), ).toBeVisible(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp1'); + await expect(page).toHaveTitle( + 'MyComp1 / template-blank - Remotion Studio', + {timeout: 5000}, + ); await page.goBack(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp'); + await expect(page).toHaveTitle( + 'MyComp / template-blank - Remotion Studio', + {timeout: 5000}, + ); await studio.getByRole('button', {name: 'Render on web'}).click(); await expect( studio.getByText('Render MyComp', {exact: true}), diff --git a/packages/studio/src/helpers/document-title.ts b/packages/studio/src/helpers/document-title.ts index 633e9be7dad..1f7523bce6b 100644 --- a/packages/studio/src/helpers/document-title.ts +++ b/packages/studio/src/helpers/document-title.ts @@ -1,6 +1,7 @@ import {NoReactInternals} from 'remotion/no-react'; import type {AnyRenderJob} from '../components/RenderQueue/context'; import {isClientRenderJob} from '../components/RenderQueue/context'; +import {getNavigationWindow} from './url-state'; let currentItemName: string | null = null; let renderJobs: AnyRenderJob[] = []; @@ -27,13 +28,13 @@ const suffix = `- ${productName}`; const updateTitle = () => { if (!currentItemName) { - document.title = productName; + getNavigationWindow().document.title = productName; return; } const currentCompTitle = `${currentItemName} / ${window.remotion_projectName}`; - document.title = [ + getNavigationWindow().document.title = [ getProgressInBrackets(currentItemName, renderJobs), `${currentCompTitle} ${suffix}`, ] From c5cfac51ef79e3bd6a2dfbf2cc2c115489a3fcc9 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:47:44 +0200 Subject: [PATCH 22/35] Internal tests: Wait for installed Element file --- packages/example/e2e/studio-protocol.test.mts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts index 11327f2d9ce..517d9c3c5c4 100644 --- a/packages/example/e2e/studio-protocol.test.mts +++ b/packages/example/e2e/studio-protocol.test.mts @@ -82,11 +82,7 @@ export const MyComponent = () => { }; `, ); - const closeupDirectory = path.join( - temporaryProject, - 'src', - 'closeups', - ); + const closeupDirectory = path.join(temporaryProject, 'src', 'closeups'); fs.mkdirSync(closeupDirectory); fs.writeFileSync( path.join(closeupDirectory, 'Closeup.tsx'), @@ -443,16 +439,18 @@ const CloseupPlaceholder = () => { closeupDirectory, 'ProtocolElementScene.tsx', ); + const newCompositionElementFile = path.join( + closeupDirectory, + 'protocol-element.element.tsx', + ); await waitForFile(newCompositionFile); + await waitForFile(newCompositionElementFile); await expect .poll(() => fs.readFileSync(newCompositionFile, 'utf8')) .toContain('ProtocolElement'); - expect( - fs.readFileSync( - path.join(closeupDirectory, 'protocol-element.element.tsx'), - 'utf8', - ), - ).toContain('export const ProtocolElement'); + expect(fs.readFileSync(newCompositionElementFile, 'utf8')).toContain( + 'export const ProtocolElement', + ); const sourceWithNewComposition = fs.readFileSync( path.join(closeupDirectory, 'Closeup.tsx'), 'utf8', From dda7ea8949e6ed7a6316f5c96392543e08ca80c4 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 13:52:52 +0200 Subject: [PATCH 23/35] Vendor useSyncExternalStore shim --- packages/canvas/src/selection.ts | 5 +- packages/core/THIRD_PARTY_LICENSES.md | 28 ++++ packages/core/src/internals.ts | 2 + packages/core/src/use-playing.ts | 3 +- packages/core/src/use-sync-external-store.ts | 120 ++++++++++++++++++ packages/studio/src/api/visual-control.ts | 5 +- .../src/components/Timeline/Timeline.tsx | 3 +- packages/studio/src/helpers/open-in-editor.ts | 5 +- .../studio/src/helpers/use-runtime-values.ts | 20 ++- .../src/state/timeline-sequence-hover.ts | 17 ++- 10 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 packages/core/THIRD_PARTY_LICENSES.md create mode 100644 packages/core/src/use-sync-external-store.ts diff --git a/packages/canvas/src/selection.ts b/packages/canvas/src/selection.ts index f83c7347741..c89eb4763cf 100644 --- a/packages/canvas/src/selection.ts +++ b/packages/canvas/src/selection.ts @@ -1,4 +1,5 @@ -import {useState, useSyncExternalStore} from 'react'; +import {useState} from 'react'; +import {Internals} from 'remotion'; import type {SequenceNodePathInfo} from './get-timeline-sequence-sort-key'; export type CanvasSelectionItem = @@ -326,7 +327,7 @@ export const useCanvasSelectionController = (): CanvasSelectionController => { export const useCanvasSelection = ( controller: CanvasSelectionController, ): CanvasSelectionSnapshot => { - return useSyncExternalStore( + return Internals.useSyncExternalStore( controller.subscribe, controller.getSnapshot, controller.getSnapshot, diff --git a/packages/core/THIRD_PARTY_LICENSES.md b/packages/core/THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000000..c5bac94fc3e --- /dev/null +++ b/packages/core/THIRD_PARTY_LICENSES.md @@ -0,0 +1,28 @@ +# Third-party licenses + +## use-sync-external-store + +The vendored `useSyncExternalStore` compatibility implementation is adapted +from `use-sync-external-store` 1.5.0. + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index eafd73f022a..4c2bb031000 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -250,6 +250,7 @@ import { type EffectDragOverrides, type PropStatuses, } from './use-schema.js'; +import {useSyncExternalStore} from './use-sync-external-store.js'; import {useUnsafeVideoConfig} from './use-unsafe-video-config.js'; import {useVideo} from './use-video.js'; import {validateMediaProps} from './validate-media-props.js'; @@ -310,6 +311,7 @@ export const Internals = { makeRenderResourceManager, RenderResourceManagerContext, createRuntimeValueStore, + useSyncExternalStore, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, diff --git a/packages/core/src/use-playing.ts b/packages/core/src/use-playing.ts index 5fae1838bae..7617786179c 100644 --- a/packages/core/src/use-playing.ts +++ b/packages/core/src/use-playing.ts @@ -1,6 +1,7 @@ -import {useSyncExternalStore, useContext} from 'react'; +import {useContext} from 'react'; import {useTimelineContext} from './timeline-position-state.js'; import {SetTimelineContext} from './TimelineContext.js'; +import {useSyncExternalStore} from './use-sync-external-store.js'; export const usePlaying = () => { const {isPlaying} = useTimelineContext(); diff --git a/packages/core/src/use-sync-external-store.ts b/packages/core/src/use-sync-external-store.ts new file mode 100644 index 00000000000..665a64e1b28 --- /dev/null +++ b/packages/core/src/use-sync-external-store.ts @@ -0,0 +1,120 @@ +/** + * @license React + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * Adapted from use-sync-external-store 1.5.0. + */ + +import * as React from 'react'; + +type UseSyncExternalStore = ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => TSnapshot, + getServerSnapshot?: () => TSnapshot, +) => TSnapshot; + +type StoreInstance = { + value: TSnapshot; + getSnapshot: () => TSnapshot; +}; + +const objectIs = + typeof Object.is === 'function' + ? Object.is + : (first: unknown, second: unknown) => + (first === second && + (first !== 0 || 1 / (first as number) === 1 / (second as number))) || + (Number.isNaN(first) && Number.isNaN(second)); + +let didWarnAboutUncachedGetSnapshot = false; + +const checkIfSnapshotChanged = ( + instance: StoreInstance, +): boolean => { + try { + return !objectIs(instance.value, instance.getSnapshot()); + } catch { + return true; + } +}; + +const useSyncExternalStoreShimClient: UseSyncExternalStore = ( + subscribe, + getSnapshot, +) => { + const value = getSnapshot(); + + if ( + process.env.NODE_ENV !== 'production' && + !didWarnAboutUncachedGetSnapshot && + !objectIs(value, getSnapshot()) + ) { + // eslint-disable-next-line no-console + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnAboutUncachedGetSnapshot = true; + } + + const [{instance}, forceUpdate] = React.useState({ + instance: {value, getSnapshot}, + }); + + React.useLayoutEffect(() => { + instance.value = value; + instance.getSnapshot = getSnapshot; + + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + }, [getSnapshot, instance, subscribe, value]); + + React.useEffect(() => { + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + + return subscribe(() => { + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + }); + }, [instance, subscribe]); + + React.useDebugValue(value); + return value; +}; + +const useSyncExternalStoreShimServer: UseSyncExternalStore = ( + _subscribe, + getSnapshot, +) => getSnapshot(); + +const shim = + typeof window === 'undefined' || + typeof window.document === 'undefined' || + typeof window.document.createElement === 'undefined' + ? useSyncExternalStoreShimServer + : useSyncExternalStoreShimClient; + +export const useSyncExternalStore: UseSyncExternalStore = + React.useSyncExternalStore ?? shim; diff --git a/packages/studio/src/api/visual-control.ts b/packages/studio/src/api/visual-control.ts index 4c79ee04ec5..f093c5c6560 100644 --- a/packages/studio/src/api/visual-control.ts +++ b/packages/studio/src/api/visual-control.ts @@ -1,5 +1,4 @@ -import {useSyncExternalStore} from 'react'; -import {getRemotionEnvironment} from 'remotion'; +import {getRemotionEnvironment, Internals} from 'remotion'; import {visualControlStore} from '../visual-controls/visual-control-store'; import { visualControlRef, @@ -17,7 +16,7 @@ export const visualControl: VisualControlRef['globalVisualControl'] = ( // Subscribe to store changes so the calling component // re-renders when a visual control value is edited in the sidebar. // eslint-disable-next-line react-hooks/rules-of-hooks - useSyncExternalStore( + Internals.useSyncExternalStore( visualControlStore.subscribe, visualControlStore.getSnapshot, visualControlStore.getSnapshot, diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index 78d47c3e68b..c5b77839728 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -6,7 +6,6 @@ import React, { useMemo, useRef, useState, - useSyncExternalStore, } from 'react'; import {Internals} from 'remotion'; import {FastRefreshContext} from '../../fast-refresh-context'; @@ -348,7 +347,7 @@ const TimelineInner: React.FC = () => { overrideIdToNodePathMappings, compositions, ]); - const pendingInsertedElementSelection = useSyncExternalStore( + const pendingInsertedElementSelection = Internals.useSyncExternalStore( subscribeToInsertedElementSelection, getInsertedElementSelection, getInsertedElementSelection, diff --git a/packages/studio/src/helpers/open-in-editor.ts b/packages/studio/src/helpers/open-in-editor.ts index 0b1d4f20ea6..6191e45f72f 100644 --- a/packages/studio/src/helpers/open-in-editor.ts +++ b/packages/studio/src/helpers/open-in-editor.ts @@ -6,7 +6,8 @@ import type { OpenInTerminalResponse, SymbolicatedStackFrame, } from '@remotion/studio-shared'; -import {useEffect, useSyncExternalStore} from 'react'; +import {useEffect} from 'react'; +import {Internals} from 'remotion'; import {callApi} from '../components/call-api'; import {getSourceMapFilesForSource} from '../components/Timeline/TimelineStack/get-stack'; import type { @@ -166,7 +167,7 @@ export const useCachedCompositionComponentInfo = ({ compositionFile: string | null; compositionId: string | null; }) => { - const result = useSyncExternalStore( + const result = Internals.useSyncExternalStore( subscribeToCompositionComponentInfo, () => { if (compositionFile === null || compositionId === null) { diff --git a/packages/studio/src/helpers/use-runtime-values.ts b/packages/studio/src/helpers/use-runtime-values.ts index 956a550bd41..1fabed87800 100644 --- a/packages/studio/src/helpers/use-runtime-values.ts +++ b/packages/studio/src/helpers/use-runtime-values.ts @@ -1,5 +1,9 @@ -import {useCallback, useMemo, useSyncExternalStore} from 'react'; -import type {RuntimeValueStore, SequenceRegistrationControls} from 'remotion'; +import {useCallback, useMemo} from 'react'; +import { + Internals, + type RuntimeValueStore, + type SequenceRegistrationControls, +} from 'remotion'; const EMPTY_RUNTIME_VALUES: Readonly> = {}; const EMPTY_RUNTIME_VALUE_STORE = { @@ -11,7 +15,7 @@ export const useRuntimeValues = ( controls: SequenceRegistrationControls | null, ): Readonly> => { const store = controls?.runtimeValues ?? EMPTY_RUNTIME_VALUE_STORE; - return useSyncExternalStore( + return Internals.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, @@ -32,7 +36,11 @@ export const useRuntimeStoreValue = ( const store = storeOrNull ?? EMPTY_RUNTIME_VALUE_STORE; const getSnapshot = useCallback(() => store.getSnapshot()[key], [key, store]); - return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + return Internals.useSyncExternalStore( + store.subscribe, + getSnapshot, + getSnapshot, + ); }; export const useRuntimeValueSelector = ({ @@ -70,7 +78,7 @@ export const useRuntimeValueSelector = ({ }; }, [isEqual, selector, store]); - return useSyncExternalStore( + return Internals.useSyncExternalStore( selectedStore.subscribe, selectedStore.getSnapshot, selectedStore.getSnapshot, @@ -114,7 +122,7 @@ export const useRuntimeValueSnapshots = ( }; }, [stores]); - return useSyncExternalStore( + return Internals.useSyncExternalStore( aggregateStore.subscribe, aggregateStore.getSnapshot, aggregateStore.getSnapshot, diff --git a/packages/studio/src/state/timeline-sequence-hover.ts b/packages/studio/src/state/timeline-sequence-hover.ts index f71140d8546..f6f0e49fbcf 100644 --- a/packages/studio/src/state/timeline-sequence-hover.ts +++ b/packages/studio/src/state/timeline-sequence-hover.ts @@ -1,11 +1,6 @@ import type {Dispatch, SetStateAction} from 'react'; -import { - createContext, - useCallback, - useContext, - useMemo, - useSyncExternalStore, -} from 'react'; +import {createContext, useCallback, useContext, useMemo} from 'react'; +import {Internals} from 'remotion'; import type {SequenceNodePathInfo} from '../helpers/get-timeline-sequence-sort-key'; import { timelineNodePathInfoToKey, @@ -57,7 +52,7 @@ export const TimelineSequenceHoverContext = export const useTimelineSequenceHoverState = () => { const store = useContext(TimelineSequenceHoverContext); - return useSyncExternalStore( + return Internals.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, @@ -76,7 +71,11 @@ export const useIsTimelineSequenceHovered = (nodePathKey: string | null) => { [nodePathKey, store], ); - return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + return Internals.useSyncExternalStore( + store.subscribe, + getSnapshot, + getSnapshot, + ); }; export const useTimelineSequenceHover = ( From c043e5f619da006e1a772378cd38b1dc87cf272e Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 14:09:06 +0200 Subject: [PATCH 24/35] Keep sync store shim internal --- packages/canvas/src/selection.ts | 4 ++-- packages/canvas/src/use-sync-external-store.ts | 11 +++++++++++ packages/core/src/internals.ts | 5 ++++- packages/studio/src/api/visual-control.ts | 5 +++-- .../studio/src/components/Timeline/Timeline.tsx | 3 ++- packages/studio/src/helpers/open-in-editor.ts | 4 ++-- packages/studio/src/helpers/use-runtime-values.ts | 14 +++++--------- .../studio/src/helpers/use-sync-external-store.ts | 11 +++++++++++ .../studio/src/state/timeline-sequence-hover.ts | 10 +++------- 9 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 packages/canvas/src/use-sync-external-store.ts create mode 100644 packages/studio/src/helpers/use-sync-external-store.ts diff --git a/packages/canvas/src/selection.ts b/packages/canvas/src/selection.ts index c89eb4763cf..633168feabf 100644 --- a/packages/canvas/src/selection.ts +++ b/packages/canvas/src/selection.ts @@ -1,6 +1,6 @@ import {useState} from 'react'; -import {Internals} from 'remotion'; import type {SequenceNodePathInfo} from './get-timeline-sequence-sort-key'; +import {useSyncExternalStore} from './use-sync-external-store'; export type CanvasSelectionItem = | { @@ -327,7 +327,7 @@ export const useCanvasSelectionController = (): CanvasSelectionController => { export const useCanvasSelection = ( controller: CanvasSelectionController, ): CanvasSelectionSnapshot => { - return Internals.useSyncExternalStore( + return useSyncExternalStore( controller.subscribe, controller.getSnapshot, controller.getSnapshot, diff --git a/packages/canvas/src/use-sync-external-store.ts b/packages/canvas/src/use-sync-external-store.ts new file mode 100644 index 00000000000..7f15652aebe --- /dev/null +++ b/packages/canvas/src/use-sync-external-store.ts @@ -0,0 +1,11 @@ +import {Internals} from 'remotion'; + +type UseSyncExternalStore = ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => TSnapshot, + getServerSnapshot?: () => TSnapshot, +) => TSnapshot; + +export const {useSyncExternalStore} = Internals as typeof Internals & { + useSyncExternalStore: UseSyncExternalStore; +}; diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index 4c2bb031000..60c7a6f7164 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -311,7 +311,6 @@ export const Internals = { makeRenderResourceManager, RenderResourceManagerContext, createRuntimeValueStore, - useSyncExternalStore, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, @@ -477,6 +476,10 @@ export const Internals = { useCropStyle, } as const; +// Keep this available to other Remotion packages without adding it to the +// public type of Internals. +Object.assign(Internals, {useSyncExternalStore}); + export type { ArrayFieldSchema, AssetFieldSchema, diff --git a/packages/studio/src/api/visual-control.ts b/packages/studio/src/api/visual-control.ts index f093c5c6560..68eb1df7979 100644 --- a/packages/studio/src/api/visual-control.ts +++ b/packages/studio/src/api/visual-control.ts @@ -1,4 +1,5 @@ -import {getRemotionEnvironment, Internals} from 'remotion'; +import {getRemotionEnvironment} from 'remotion'; +import {useSyncExternalStore} from '../helpers/use-sync-external-store'; import {visualControlStore} from '../visual-controls/visual-control-store'; import { visualControlRef, @@ -16,7 +17,7 @@ export const visualControl: VisualControlRef['globalVisualControl'] = ( // Subscribe to store changes so the calling component // re-renders when a visual control value is edited in the sidebar. // eslint-disable-next-line react-hooks/rules-of-hooks - Internals.useSyncExternalStore( + useSyncExternalStore( visualControlStore.subscribe, visualControlStore.getSnapshot, visualControlStore.getSnapshot, diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index c5b77839728..26928cf183d 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -24,6 +24,7 @@ import {useIsStill} from '../../helpers/is-current-selected-still'; import {useCachedCompositionComponentInfo} from '../../helpers/open-in-editor'; import {getStudioMaxTimelineTracks} from '../../helpers/studio-runtime-config'; import {timelineSequenceNodePathToKey} from '../../helpers/timeline-node-path-key'; +import {useSyncExternalStore} from '../../helpers/use-sync-external-store'; import {callApi} from '../call-api'; import {ContextMenu} from '../ContextMenu'; import {importAssets, pickFilesToImport} from '../import-assets'; @@ -347,7 +348,7 @@ const TimelineInner: React.FC = () => { overrideIdToNodePathMappings, compositions, ]); - const pendingInsertedElementSelection = Internals.useSyncExternalStore( + const pendingInsertedElementSelection = useSyncExternalStore( subscribeToInsertedElementSelection, getInsertedElementSelection, getInsertedElementSelection, diff --git a/packages/studio/src/helpers/open-in-editor.ts b/packages/studio/src/helpers/open-in-editor.ts index 6191e45f72f..51dc3cc68d0 100644 --- a/packages/studio/src/helpers/open-in-editor.ts +++ b/packages/studio/src/helpers/open-in-editor.ts @@ -7,7 +7,6 @@ import type { SymbolicatedStackFrame, } from '@remotion/studio-shared'; import {useEffect} from 'react'; -import {Internals} from 'remotion'; import {callApi} from '../components/call-api'; import {getSourceMapFilesForSource} from '../components/Timeline/TimelineStack/get-stack'; import type { @@ -15,6 +14,7 @@ import type { OriginalPosition, } from '../error-overlay/react-overlay/utils/get-source-map'; import {getBrowserStudioOperations} from './browser-studio-operations'; +import {useSyncExternalStore} from './use-sync-external-store'; export const openInEditor = ( stack: SymbolicatedStackFrame, @@ -167,7 +167,7 @@ export const useCachedCompositionComponentInfo = ({ compositionFile: string | null; compositionId: string | null; }) => { - const result = Internals.useSyncExternalStore( + const result = useSyncExternalStore( subscribeToCompositionComponentInfo, () => { if (compositionFile === null || compositionId === null) { diff --git a/packages/studio/src/helpers/use-runtime-values.ts b/packages/studio/src/helpers/use-runtime-values.ts index 1fabed87800..3ce91f2d95a 100644 --- a/packages/studio/src/helpers/use-runtime-values.ts +++ b/packages/studio/src/helpers/use-runtime-values.ts @@ -1,9 +1,9 @@ import {useCallback, useMemo} from 'react'; import { - Internals, type RuntimeValueStore, type SequenceRegistrationControls, } from 'remotion'; +import {useSyncExternalStore} from './use-sync-external-store'; const EMPTY_RUNTIME_VALUES: Readonly> = {}; const EMPTY_RUNTIME_VALUE_STORE = { @@ -15,7 +15,7 @@ export const useRuntimeValues = ( controls: SequenceRegistrationControls | null, ): Readonly> => { const store = controls?.runtimeValues ?? EMPTY_RUNTIME_VALUE_STORE; - return Internals.useSyncExternalStore( + return useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, @@ -36,11 +36,7 @@ export const useRuntimeStoreValue = ( const store = storeOrNull ?? EMPTY_RUNTIME_VALUE_STORE; const getSnapshot = useCallback(() => store.getSnapshot()[key], [key, store]); - return Internals.useSyncExternalStore( - store.subscribe, - getSnapshot, - getSnapshot, - ); + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); }; export const useRuntimeValueSelector = ({ @@ -78,7 +74,7 @@ export const useRuntimeValueSelector = ({ }; }, [isEqual, selector, store]); - return Internals.useSyncExternalStore( + return useSyncExternalStore( selectedStore.subscribe, selectedStore.getSnapshot, selectedStore.getSnapshot, @@ -122,7 +118,7 @@ export const useRuntimeValueSnapshots = ( }; }, [stores]); - return Internals.useSyncExternalStore( + return useSyncExternalStore( aggregateStore.subscribe, aggregateStore.getSnapshot, aggregateStore.getSnapshot, diff --git a/packages/studio/src/helpers/use-sync-external-store.ts b/packages/studio/src/helpers/use-sync-external-store.ts new file mode 100644 index 00000000000..7f15652aebe --- /dev/null +++ b/packages/studio/src/helpers/use-sync-external-store.ts @@ -0,0 +1,11 @@ +import {Internals} from 'remotion'; + +type UseSyncExternalStore = ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => TSnapshot, + getServerSnapshot?: () => TSnapshot, +) => TSnapshot; + +export const {useSyncExternalStore} = Internals as typeof Internals & { + useSyncExternalStore: UseSyncExternalStore; +}; diff --git a/packages/studio/src/state/timeline-sequence-hover.ts b/packages/studio/src/state/timeline-sequence-hover.ts index f6f0e49fbcf..9d9cd57d638 100644 --- a/packages/studio/src/state/timeline-sequence-hover.ts +++ b/packages/studio/src/state/timeline-sequence-hover.ts @@ -1,11 +1,11 @@ import type {Dispatch, SetStateAction} from 'react'; import {createContext, useCallback, useContext, useMemo} from 'react'; -import {Internals} from 'remotion'; import type {SequenceNodePathInfo} from '../helpers/get-timeline-sequence-sort-key'; import { timelineNodePathInfoToKey, timelineSequenceNodePathToKey, } from '../helpers/timeline-node-path-key'; +import {useSyncExternalStore} from '../helpers/use-sync-external-store'; export type TimelineSequenceHover = { readonly key: string; @@ -52,7 +52,7 @@ export const TimelineSequenceHoverContext = export const useTimelineSequenceHoverState = () => { const store = useContext(TimelineSequenceHoverContext); - return Internals.useSyncExternalStore( + return useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, @@ -71,11 +71,7 @@ export const useIsTimelineSequenceHovered = (nodePathKey: string | null) => { [nodePathKey, store], ); - return Internals.useSyncExternalStore( - store.subscribe, - getSnapshot, - getSnapshot, - ); + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); }; export const useTimelineSequenceHover = ( From 6e5d5d7ebd2867366e8b3ef979e2aca756b96b45 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 14:18:34 +0200 Subject: [PATCH 25/35] Derive Element install title from modal state --- .../browser-studio/e2e/browser-studio.test.ts | 7 ++ packages/studio/src/components/Canvas.tsx | 92 +++---------------- .../CurrentCompositionSideEffects.tsx | 7 ++ .../components/ElementInstallConfirmation.tsx | 73 +++++++-------- packages/studio/src/components/Modals.tsx | 8 +- packages/studio/src/helpers/document-title.ts | 12 +++ packages/studio/src/state/modals.ts | 26 +++++- 7 files changed, 106 insertions(+), 119 deletions(-) diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 442c76daa06..7c8070bfe8d 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -1101,6 +1101,10 @@ export const LinkedElement = () => ; await expect( studio.getByText('Install Element', {exact: true}), ).toBeVisible(); + await expect(page).toHaveTitle( + '📦 Install Linked Element - Remotion Studio', + {timeout: 5000}, + ); await expect( studio.getByText('Unverified Browser Studio link'), ).toBeVisible(); @@ -1158,6 +1162,9 @@ export const LinkedElement = () => ; ).__browserStudioInstallPreservedIframe, ), ).toBe(true); + await expect(page).toHaveTitle('MyComp / template-blank - Remotion Studio', { + timeout: 5000, + }); }); test('reports inline SVG imports as unsupported without changing the project', async ({ diff --git a/packages/studio/src/components/Canvas.tsx b/packages/studio/src/components/Canvas.tsx index 60082a31d10..9191f00e969 100644 --- a/packages/studio/src/components/Canvas.tsx +++ b/packages/studio/src/components/Canvas.tsx @@ -1,9 +1,6 @@ import type {Size} from '@remotion/player'; import {StudioProtocolInternals} from '@remotion/studio-protocol'; -import type { - ElementInstallExpectedFileState, - ElementInstallRequest, -} from '@remotion/studio-shared'; +import type {ElementInstallRequest} from '@remotion/studio-shared'; import React, { useCallback, useContext, @@ -71,7 +68,6 @@ import { enqueueElementInstallRequest, subscribeToElementInstallRequests, } from './element-install-request'; -import {ElementInstallConfirmation} from './ElementInstallConfirmation'; import {handleDrop} from './handle-drop'; import { getFromForDrop, @@ -91,21 +87,6 @@ import {useResolvedStack} from './Timeline/use-resolved-stack'; const elementInstallDependencyIgnoreList = ['react', 'react-dom', 'remotion']; -type ElementInstallPlan = { - readonly compositionFile: string; - readonly filePath: string; - readonly expectedFileState: ElementInstallExpectedFileState; -}; - -type ElementInstallReview = { - readonly currentPlan: ElementInstallPlan | null; - readonly dependenciesToReview: string[]; - readonly missingPackages: string[]; - readonly newPlan: ElementInstallPlan; - readonly sourceIsUnverified: boolean; - readonly sourceLabel: string; -}; - const getContainerStyle = ( editorZoomGestures: boolean, ): React.CSSProperties => ({ @@ -256,15 +237,10 @@ export const Canvas: React.FC<{ const [isAddingAsset, setIsAddingAsset] = useState(false); const [compositionDropPreview, setCompositionDropPreview] = useState(null); - const [installingElementName, setInstallingElementName] = useState< - string | null - >(null); const [pendingElementInstallRequests, setPendingElementInstallRequests] = useState([]); const [activeElementInstallRequest, setActiveElementInstallRequest] = useState(null); - const [elementInstallReview, setElementInstallReview] = - useState(null); const lastFocusedAtRef = useRef( typeof document === 'undefined' || document.hasFocus() ? Date.now() : null, ); @@ -832,19 +808,6 @@ export const Canvas: React.FC<{ }); }, [subscribeToEvent, updateElementInstallTarget]); - useEffect(() => { - if (installingElementName === null) { - return; - } - - const previousTitle = document.title; - document.title = `📦 Install ${installingElementName} - Remotion Studio`; - - return () => { - document.title = previousTitle; - }; - }, [installingElementName]); - useEffect(() => { if (previewServerClientId === null) { return; @@ -930,6 +893,11 @@ export const Canvas: React.FC<{ setPendingElementInstallRequests(remainingRequests); }, [activeElementInstallRequest, pendingElementInstallRequests]); + const closeElementInstallDialog = useCallback(() => { + setSelectedModal(null); + setActiveElementInstallRequest(null); + }, [setSelectedModal]); + useEffect(() => { if (activeElementInstallRequest === null) { return; @@ -937,7 +905,6 @@ export const Canvas: React.FC<{ let canceled = false; const handleInstallRequest = async () => { - setInstallingElementName(activeElementInstallRequest.element.displayName); try { const [newPreflight, currentPreflight] = await Promise.all([ prepareElementInstall({ @@ -965,7 +932,6 @@ export const Canvas: React.FC<{ `Could not review Element installation: ${newPreflight.reason}`, 4000, ); - setInstallingElementName(null); setActiveElementInstallRequest(null); return; } @@ -1005,12 +971,14 @@ export const Canvas: React.FC<{ const currentPlan = currentPreflight.success ? currentPreflight.plan : null; - setSelectedModal(null); - setElementInstallReview({ + setSelectedModal({ + type: 'element-install', currentPlan, dependenciesToReview, missingPackages, newPlan: newPreflight.plan, + onClose: closeElementInstallDialog, + request: activeElementInstallRequest, sourceIsUnverified, sourceLabel, }); @@ -1025,7 +993,6 @@ export const Canvas: React.FC<{ }`, 4000, ); - setInstallingElementName(null); setActiveElementInstallRequest(null); } }; @@ -1034,13 +1001,11 @@ export const Canvas: React.FC<{ return () => { canceled = true; }; - }, [activeElementInstallRequest, setSelectedModal]); - - const closeElementInstallDialog = useCallback(() => { - setElementInstallReview(null); - setInstallingElementName(null); - setActiveElementInstallRequest(null); - }, []); + }, [ + activeElementInstallRequest, + closeElementInstallDialog, + setSelectedModal, + ]); const onDragOver = useCallback( (event: DragEvent) => { @@ -1515,33 +1480,6 @@ export const Canvas: React.FC<{ containerRef={canvasRef} /> )} - {activeElementInstallRequest === null || - elementInstallReview === null ? null : ( - - )} ); }; diff --git a/packages/studio/src/components/CurrentCompositionSideEffects.tsx b/packages/studio/src/components/CurrentCompositionSideEffects.tsx index ed51fe9a2c6..f686a4052fc 100644 --- a/packages/studio/src/components/CurrentCompositionSideEffects.tsx +++ b/packages/studio/src/components/CurrentCompositionSideEffects.tsx @@ -3,13 +3,16 @@ import {useContext, useEffect} from 'react'; import {Internals} from 'remotion'; import { setCurrentCanvasContentId, + setCurrentModal, setRenderJobs, } from '../helpers/document-title'; +import {SelectedModalContext} from '../state/modals'; import {RenderQueueContext} from './RenderQueue/context'; export const TitleUpdater: React.FC = () => { const renderQueue = useContext(RenderQueueContext); const {canvasContent} = useContext(Internals.CompositionManager); + const selectedModal = useContext(SelectedModalContext); const {jobs} = renderQueue; useEffect(() => { @@ -40,5 +43,9 @@ export const TitleUpdater: React.FC = () => { setRenderJobs(jobs); }, [jobs]); + useEffect(() => { + setCurrentModal(selectedModal); + }, [selectedModal]); + return null; }; diff --git a/packages/studio/src/components/ElementInstallConfirmation.tsx b/packages/studio/src/components/ElementInstallConfirmation.tsx index 082f2407f3e..10d9957aa69 100644 --- a/packages/studio/src/components/ElementInstallConfirmation.tsx +++ b/packages/studio/src/components/ElementInstallConfirmation.tsx @@ -1,8 +1,4 @@ import {StudioProtocolInternals} from '@remotion/studio-protocol'; -import type { - ElementInstallExpectedFileState, - ElementInstallRequest, -} from '@remotion/studio-shared'; import React, { useCallback, useContext, @@ -14,6 +10,7 @@ import React, { import {createPortal} from 'react-dom'; import {Internals} from 'remotion'; import {ShortcutHint} from '../error-overlay/remotion-overlay/ShortcutHint'; +import {getBrowserStudioOperations} from '../helpers/browser-studio-operations'; import { INPUT_BACKGROUND, LIGHT_TEXT, @@ -30,6 +27,10 @@ import { import {resolvedStackToSymbolicated} from '../helpers/resolved-stack-to-symbolicated'; import {useCreateComposition} from '../helpers/use-create-composition'; import {validateCompositionName} from '../helpers/validate-new-comp-data'; +import type { + ElementInstallModalState, + ElementInstallPlan, +} from '../state/modals'; import {useZIndex} from '../state/z-index'; import {Button} from './Button'; import {prepareElementInstall} from './element-install-api'; @@ -351,12 +352,6 @@ export const ElementLibraryAddConfirmation: React.FC<{ ); }; -type ElementInstallPlan = { - readonly compositionFile: string; - readonly filePath: string; - readonly expectedFileState: ElementInstallExpectedFileState; -}; - type NewCompositionPlanState = | { readonly type: 'loading'; @@ -373,37 +368,35 @@ type NewCompositionPlanState = readonly reason: string; }; -type CurrentCompositionMetadata = { - readonly durationInFrames: number; - readonly fps: number; - readonly height: number; - readonly width: number; -}; - export const ElementInstallConfirmation: React.FC<{ - readonly currentCompositionMetadata: CurrentCompositionMetadata | null; - readonly currentPlan: ElementInstallPlan | null; - readonly dependenciesToReview: string[]; - readonly missingPackages: string[]; - readonly newPlan: ElementInstallPlan; - readonly onClose: () => void; - readonly request: ElementInstallRequest; - readonly sourceIsUnverified: boolean; - readonly sourceLabel: string; - readonly usesBrowserDependencyResolution: boolean; -}> = ({ - currentCompositionMetadata, - currentPlan, - dependenciesToReview, - missingPackages, - newPlan, - onClose, - request, - sourceIsUnverified, - sourceLabel, - usesBrowserDependencyResolution, -}) => { - const {compositions} = useContext(Internals.CompositionManager); + readonly state: ElementInstallModalState; +}> = ({state}) => { + const { + currentPlan, + dependenciesToReview, + missingPackages, + newPlan, + onClose, + request, + sourceIsUnverified, + sourceLabel, + } = state; + const config = Internals.useUnsafeVideoConfig(); + const {canvasContent, compositions} = useContext( + Internals.CompositionManager, + ); + const currentCompositionMetadata = + config === null || + canvasContent?.type !== 'composition' || + canvasContent.compositionId !== request.compositionId + ? null + : { + durationInFrames: config.durationInFrames, + fps: config.fps, + height: config.height, + width: config.width, + }; + const usesBrowserDependencyResolution = getBrowserStudioOperations() !== null; const {currentZIndex} = useZIndex(); const [mode, setMode] = useState<'current-composition' | 'new-composition'>( currentPlan === null ? 'new-composition' : 'current-composition', diff --git a/packages/studio/src/components/Modals.tsx b/packages/studio/src/components/Modals.tsx index bbde745bc3d..fc31627bba4 100644 --- a/packages/studio/src/components/Modals.tsx +++ b/packages/studio/src/components/Modals.tsx @@ -7,7 +7,10 @@ import {AskAiModal} from './AskAiModal'; import {callApi} from './call-api'; import {ConfirmationDialog, useConfirmationDialog} from './ConfirmationDialog'; import {EffectPickerModal} from './EffectPickerModal'; -import {ElementLibraryAddConfirmation} from './ElementInstallConfirmation'; +import { + ElementInstallConfirmation, + ElementLibraryAddConfirmation, +} from './ElementInstallConfirmation'; import {ElementLibraryModal} from './ElementLibraryModal'; import {FixComputedValueModal} from './FixComputedValueModal'; import {DeleteComposition} from './NewComposition/DeleteComposition'; @@ -275,6 +278,9 @@ export const Modals: React.FC<{ url={modalContextType.url} /> )} + {modalContextType && modalContextType.type === 'element-install' && ( + + )} {modalContextType && modalContextType.type === 'add-effect' && ( )} diff --git a/packages/studio/src/helpers/document-title.ts b/packages/studio/src/helpers/document-title.ts index 1f7523bce6b..6385f6f20fc 100644 --- a/packages/studio/src/helpers/document-title.ts +++ b/packages/studio/src/helpers/document-title.ts @@ -1,9 +1,11 @@ import {NoReactInternals} from 'remotion/no-react'; import type {AnyRenderJob} from '../components/RenderQueue/context'; import {isClientRenderJob} from '../components/RenderQueue/context'; +import type {ModalState} from '../state/modals'; import {getNavigationWindow} from './url-state'; let currentItemName: string | null = null; +let currentModal: ModalState | null = null; let renderJobs: AnyRenderJob[] = []; export const setCurrentCanvasContentId = (id: string | null) => { @@ -23,10 +25,20 @@ export const setRenderJobs = (jobs: AnyRenderJob[]) => { updateTitle(); }; +export const setCurrentModal = (modal: ModalState | null) => { + currentModal = modal; + updateTitle(); +}; + const productName = 'Remotion Studio'; const suffix = `- ${productName}`; const updateTitle = () => { + if (currentModal?.type === 'element-install') { + getNavigationWindow().document.title = `📦 Install ${currentModal.request.element.displayName} - ${productName}`; + return; + } + if (!currentItemName) { getNavigationWindow().document.title = productName; return; diff --git a/packages/studio/src/state/modals.ts b/packages/studio/src/state/modals.ts index 0204642e573..3ed3f2eb570 100644 --- a/packages/studio/src/state/modals.ts +++ b/packages/studio/src/state/modals.ts @@ -11,7 +11,12 @@ import type { X264Preset, } from '@remotion/renderer'; import type {HardwareAccelerationOption} from '@remotion/renderer/client'; -import type {CanvasCaptureData, RenderDefaults} from '@remotion/studio-shared'; +import type { + CanvasCaptureData, + ElementInstallExpectedFileState, + ElementInstallRequest, + RenderDefaults, +} from '@remotion/studio-shared'; import type { RenderStillOnWebImageFormat, WebRendererAudioCodec, @@ -143,6 +148,24 @@ export type CanvasCaptureImport = { readonly width: number; }; +export type ElementInstallPlan = { + readonly compositionFile: string; + readonly filePath: string; + readonly expectedFileState: ElementInstallExpectedFileState; +}; + +export type ElementInstallModalState = { + readonly type: 'element-install'; + readonly currentPlan: ElementInstallPlan | null; + readonly dependenciesToReview: string[]; + readonly missingPackages: string[]; + readonly newPlan: ElementInstallPlan; + readonly onClose: () => void; + readonly request: ElementInstallRequest; + readonly sourceIsUnverified: boolean; + readonly sourceLabel: string; +}; + export type ModalState = | { type: 'new-comp'; @@ -232,6 +255,7 @@ export type ModalState = name: string; url: string; } + | ElementInstallModalState | AddEffectModalState | ConfirmationDialogState | SvgImportDialogState; From a399c49195e4ff657d68e2f5eb5b2dd9f557fb24 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Mon, 31 Aug 2026 14:41:13 +0200 Subject: [PATCH 26/35] `@remotion/studio-codemods`: Stop wrapping self-closing roots in `` --- .../test/browser-studio-operations.test.ts | 9 +-- packages/studio-codemods/src/index.ts | 24 +------ .../studio-codemods/src/insert-jsx-element.ts | 71 ++----------------- .../resolve-composition-component.test.ts | 35 +++++---- 4 files changed, 30 insertions(+), 109 deletions(-) diff --git a/packages/browser-studio/src/test/browser-studio-operations.test.ts b/packages/browser-studio/src/test/browser-studio-operations.test.ts index 7b6eac8ada6..4055645dc0f 100644 --- a/packages/browser-studio/src/test/browser-studio-operations.test.ts +++ b/packages/browser-studio/src/test/browser-studio-operations.test.ts @@ -179,7 +179,7 @@ export const MyComponent = () => Existing; ); }); -test('wraps a self-closing root, aliases bindings, and remaps the root', () => { +test('inserts beside a self-closing root, aliases bindings, and remaps the root', () => { const project: VirtualProject = { rootDir: '/project', entryPoint: '/project/src/index.tsx', @@ -212,11 +212,12 @@ registerRoot(Root); const output = updated.files['/project/src/index.tsx']; expect(output).toContain( - "import {AbsoluteFill, Composition, registerRoot, Solid as RemotionSolid, Sequence as RemotionSequence} from 'remotion';", + "import {AbsoluteFill, Composition, registerRoot, Solid as RemotionSolid} from 'remotion';", ); - expect(output).toContain(''); + expect(output).not.toContain(''); + expect(output).toContain(''); expect(output).toContain(' { const start = getPosition(root, 'start'); const indent = lineIndentAt(source, start); const original = source.slice(start, getPosition(root, 'end')); - if (wrapRootInSequence && sequenceLocalName === null) { - throw new Error('Expected a Sequence import for a self-closing root'); - } - - const existingRoot = wrapRootInSequence - ? [ - `${indent}${unit}<${sequenceLocalName}>`, - `${indent}${unit}${unit}${original}`, - `${indent}${unit}`, - ] - : [`${indent}${unit}${original}`]; return { start, end: getPosition(root, 'end'), text: [ '<>', - ...existingRoot, + `${indent}${unit}${original}`, `${indent}${unit}${element}`, `${indent}`, ].join('\n'), @@ -882,11 +867,8 @@ export const insertSolidIntoSource = ({ candidates: ['Solid', 'RemotionSolid'], importedName: 'Solid', }); - const openingElement = - root.type === 'JSXElement' ? getNode(root, 'openingElement') : null; - const isSelfClosing = openingElement?.selfClosing === true; const sequenceImport = - isSelfClosing || from !== null + from !== null ? chooseLocalName({ ast, candidates: ['Sequence', 'RemotionSequence'], @@ -922,10 +904,8 @@ export const insertSolidIntoSource = ({ ? replaceJsxElementRoot({ element, root, - sequenceLocalName: sequenceImport?.localName ?? null, source, unit, - wrapRootInSequence: isSelfClosing, }) : appendToJsxRoot({ element, diff --git a/packages/studio-codemods/src/insert-jsx-element.ts b/packages/studio-codemods/src/insert-jsx-element.ts index e15f465c472..4899ccdde7a 100644 --- a/packages/studio-codemods/src/insert-jsx-element.ts +++ b/packages/studio-codemods/src/insert-jsx-element.ts @@ -898,25 +898,6 @@ const createSequenceElement = (): namedTypes.JSXElement => { ); }; -const createSequenceWithChild = ({ - child, - sequenceLocalName, -}: { - child: namedTypes.JSXElement; - sequenceLocalName: string; -}): namedTypes.JSXElement => { - return recast.types.builders.jsxElement( - recast.types.builders.jsxOpeningElement( - recast.types.builders.jsxIdentifier(sequenceLocalName), - [], - ), - recast.types.builders.jsxClosingElement( - recast.types.builders.jsxIdentifier(sequenceLocalName), - ), - [child], - ); -}; - const createNumberAttribute = ( name: string, value: number, @@ -2007,12 +1988,7 @@ const addElementToComponentRoot = ({ } if (rootNode.type === 'JSXElement') { - const existingRoot = rootNode.openingElement.selfClosing - ? createSequenceWithChild({ - child: stripParenthesizedExtra(rootNode), - sequenceLocalName: ensureSequenceImport(ast), - }) - : stripParenthesizedExtra(rootNode); + const existingRoot = stripParenthesizedExtra(rootNode); const fragment = recast.types.builders.jsxFragment( recast.types.builders.jsxOpeningFragment(), recast.types.builders.jsxClosingFragment(), @@ -2734,14 +2710,12 @@ const getInsertionRootSourceEdit = ({ nullRoot, prettierConfigOverride, root, - sequenceLocalName, }: { input: string; insertion: string; nullRoot: NullLiteral | null; prettierConfigOverride: Record | null; root: namedTypes.JSXElement | namedTypes.JSXFragment | null; - sequenceLocalName: string | null; }): SourceEdit => { const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; const unit = getIndentationUnit(input, prettierConfigOverride); @@ -2813,33 +2787,17 @@ const getInsertionRootSourceEdit = ({ const end = recastLocToOffset(input, root.loc.end); const indent = getLineIndent(input, start); const original = input.slice(start, end); - const existingRoot = root.openingElement.selfClosing - ? [ - `${indent}${unit}<${sequenceLocalName}>`, - indentExistingJsx({ - indent: `${indent}${unit}${unit}`, - original, - originalIndent: indent, - }), - `${indent}${unit}`, - ] - : [ - indentExistingJsx({ - indent: `${indent}${unit}`, - original, - originalIndent: indent, - }), - ]; - - if (root.openingElement.selfClosing && sequenceLocalName === null) { - throw new Error('Expected a Sequence import for a self-closing root'); - } + const existingRoot = indentExistingJsx({ + indent: `${indent}${unit}`, + original, + originalIndent: indent, + }); return { end, replacement: [ '<>', - ...existingRoot, + existingRoot, indentInsertedJsx({indent: `${indent}${unit}`, insertion}), `${indent}`, ].join(endOfLine), @@ -3447,20 +3405,6 @@ export const insertJsxElementIntoComposition = async ({ exportName: location.exportName, element: finalElementToInsert, }); - const finalRoot = componentDeclaration - ? getComponentRootNode(componentDeclaration) - : null; - const firstFinalRootChild = - finalRoot?.type === 'JSXFragment' - ? (finalRoot.children?.[0] ?? null) - : null; - const sequenceLocalName = - rootBeforeInsertion?.type === 'JSXElement' && - rootBeforeInsertion.openingElement.selfClosing && - firstFinalRootChild?.type === 'JSXElement' && - firstFinalRootChild.openingElement.name.type === 'JSXIdentifier' - ? firstFinalRootChild.openingElement.name.name - : null; const output = applySourceEdits({ edits: [ ...getInsertImportSourceEdits({ @@ -3482,7 +3426,6 @@ export const insertJsxElementIntoComposition = async ({ nullRoot: nullRootBeforeInsertion, prettierConfigOverride, root: rootBeforeInsertion, - sequenceLocalName, }), ], input, diff --git a/packages/studio-server/src/test/resolve-composition-component.test.ts b/packages/studio-server/src/test/resolve-composition-component.test.ts index 7f01bd12513..347f9c96c72 100644 --- a/packages/studio-server/src/test/resolve-composition-component.test.ts +++ b/packages/studio-server/src/test/resolve-composition-component.test.ts @@ -422,7 +422,7 @@ test('canAddSequence=true for self-closing root JSX return', async () => { } }); -test('wraps a self-closing root in a Sequence before inserting', async () => { +test('inserts a Solid next to a self-closing root without wrapping it', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remotion-resolve-')); try { await fs.writeFile( @@ -452,12 +452,9 @@ test('wraps a self-closing root in a Sequence before inserting', async () => { compositionFile: 'Root.tsx', compositionId: 'test', element: { - type: 'asset', - assetType: 'audio', - src: 'music.mp3', - srcType: 'static', - dimensions: null, - durationInFrames: null, + type: 'solid', + width: 1920, + height: 1080, position: null, }, from: null, @@ -465,14 +462,14 @@ test('wraps a self-closing root in a Sequence before inserting', async () => { }); expect(result.output).toContain( - "import {staticFile, Sequence} from 'remotion';", + "import {staticFile, Solid} from 'remotion';", ); - expect(result.output).toContain(''); + expect(result.output).not.toContain('', ); - expect(result.output).toContain(''); - expect(result.output).toContain("