From b6e9501bbbbcddb2f0724354886797f9b4ea98c5 Mon Sep 17 00:00:00 2001 From: JonnyBurger Date: Mon, 31 Aug 2026 17:11:19 +0200 Subject: [PATCH 01/28] `@remotion/studio`: Preserve timeline markup order during Fast Refresh --- packages/canvas/src/calculate-timeline.ts | 22 ++- .../src/test/calculate-timeline.test.ts | 25 +++ packages/core/src/CompositionManager.tsx | 1 + packages/core/src/Sequence.tsx | 16 +- packages/core/src/SequenceManager.tsx | 101 ++++++++++++- packages/core/src/audio/AudioForPreview.tsx | 15 +- packages/core/src/internals.ts | 2 + packages/core/src/sequence-order-marker.tsx | 42 +++++ packages/core/src/use-media-in-timeline.ts | 1 + packages/core/src/video/VideoForPreview.tsx | 11 +- .../install-fiber-sequence-order-observer.ts | 138 +++++++++++++++++ packages/studio/src/previewEntry.tsx | 3 + .../fiber-sequence-order-observer.test.ts | 143 ++++++++++++++++++ .../src/test/sequenced-timeline.test.ts | 14 ++ .../src/test/timeline-keyframes.test.ts | 1 + packages/studio/src/test/timeline.test.ts | 13 ++ 16 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/sequence-order-marker.tsx create mode 100644 packages/studio/src/helpers/install-fiber-sequence-order-observer.ts create mode 100644 packages/studio/src/test/fiber-sequence-order-observer.test.ts diff --git a/packages/canvas/src/calculate-timeline.ts b/packages/canvas/src/calculate-timeline.ts index 95460488f00..e698fc0ed1e 100644 --- a/packages/canvas/src/calculate-timeline.ts +++ b/packages/canvas/src/calculate-timeline.ts @@ -49,7 +49,27 @@ export const calculateTimeline = ({ overrideIdsToNodePaths: OverrideIdToNodePaths; compositions?: readonly _InternalTypes['AnyComposition'][]; }): TimelineTrackData[] => { - const sortedSequences = sortItemsByNonceHistory(sequences); + const nonceSortedSequences = sortItemsByNonceHistory(sequences); + const hasCommittedSequenceOrder = nonceSortedSequences.some( + (sequence) => sequence.timelineOrder !== null, + ); + const sortedSequences = hasCommittedSequenceOrder + ? nonceSortedSequences.slice().sort((a, b) => { + if (a.timelineOrder === null && b.timelineOrder === null) { + return 0; + } + + if (a.timelineOrder === null) { + return 1; + } + + if (b.timelineOrder === null) { + return -1; + } + + return a.timelineOrder - b.timelineOrder; + }) + : nonceSortedSequences; const tracks: TimelineTrackWithOriginalTimings[] = []; if (sortedSequences.length === 0) { diff --git a/packages/canvas/src/test/calculate-timeline.test.ts b/packages/canvas/src/test/calculate-timeline.test.ts index d711cd3b8e7..e1db2ef47dd 100644 --- a/packages/canvas/src/test/calculate-timeline.test.ts +++ b/packages/canvas/src/test/calculate-timeline.test.ts @@ -31,6 +31,7 @@ const makeSequence = ({ premountDisplay: null, refForOutline: null, showInTimeline: true, + timelineOrder: null, trimBefore: null, type: 'sequence', }); @@ -62,3 +63,27 @@ test('normalizes nesting and visible starts', () => { {id: 'child', depth: 1, from: 20, sequenceFrameOffset: 10}, ]); }); + +test('committed Fiber order takes precedence over nonce order', () => { + const right = makeSequence({ + id: 'right', + parent: null, + from: 10, + nonce: 0, + }); + const left = makeSequence({ + id: 'left', + parent: null, + from: 0, + nonce: 1, + }); + right.timelineOrder = 1; + left.timelineOrder = 0; + + const timeline = calculateTimeline({ + overrideIdsToNodePaths: {}, + sequences: [right, left], + }); + + expect(timeline.map((track) => track.sequence.id)).toEqual(['left', 'right']); +}); diff --git a/packages/core/src/CompositionManager.tsx b/packages/core/src/CompositionManager.tsx index af3ab24bc21..730feb6461b 100644 --- a/packages/core/src/CompositionManager.tsx +++ b/packages/core/src/CompositionManager.tsx @@ -130,6 +130,7 @@ export type TSequence = { documentationLink: string | null; parent: string | null; showInTimeline: boolean; + timelineOrder: number | null; nonce: NonceHistory; loopDisplay: LoopDisplay | undefined; getStack: () => string | null; diff --git a/packages/core/src/Sequence.tsx b/packages/core/src/Sequence.tsx index 5f24fe06d04..7e9f4b61591 100644 --- a/packages/core/src/Sequence.tsx +++ b/packages/core/src/Sequence.tsx @@ -28,6 +28,7 @@ import { resolveSequenceCrop, validateSequenceCrop, } from './sequence-crop.js'; +import {SequenceOrderMarker} from './sequence-order-marker.js'; import type {SequenceContextType} from './SequenceContext.js'; import {SequenceContext} from './SequenceContext.js'; import {SequenceRegistrationContext} from './SequenceManager.js'; @@ -477,6 +478,7 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< postmountDisplay: postmountDisplay ?? null, premountDisplay: premountDisplay ?? null, showInTimeline, + timelineOrder: null, src: isMedia.src, getStack: () => stackRef.current, refForOutline: refForOutline ?? null, @@ -505,6 +507,7 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< postmountDisplay: postmountDisplay ?? null, premountDisplay: premountDisplay ?? null, showInTimeline, + timelineOrder: null, src: isMedia.data.src, getStack: () => stackRef.current, startMediaFrom: startMediaFrom ?? isMedia.data.startMediaFrom, @@ -529,6 +532,7 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< parent: parentSequence?.id ?? null, type: 'sequence', showInTimeline, + timelineOrder: null, nonce: nonce.get(), loopDisplay, getStack: () => stackRef.current, @@ -634,10 +638,12 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< } if (hidden) { - return null; + return env.isStudio ? ( + {null} + ) : null; } - return ( + const sequence = ( {frozenContent === null ? null : other.layout === 'none' ? ( frozenContent @@ -652,6 +658,12 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< )} ); + + return env.isStudio ? ( + {sequence} + ) : ( + sequence + ); }; const RegularSequence = forwardRef(RegularSequenceRefForwardingFunction); diff --git a/packages/core/src/SequenceManager.tsx b/packages/core/src/SequenceManager.tsx index 21fa2edb54e..20bb66b47cd 100644 --- a/packages/core/src/SequenceManager.tsx +++ b/packages/core/src/SequenceManager.tsx @@ -1,5 +1,11 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {TSequence} from './CompositionManager.js'; +import { + SEQUENCE_ORDER_EVENT, + SequenceManagerOrderMarker, + type SequenceOrderEventDetail, +} from './sequence-order-marker.js'; +import {useRemotionEnvironment} from './use-remotion-environment.js'; import type { CanUpdateSequencePropStatus, DragOverrideValue, @@ -10,6 +16,9 @@ import type { PropStatuses, } from './use-schema.js'; +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; + export type SequenceManagerContext = { registerSequence: (seq: TSequence) => void; updateSequence: ((seq: TSequence) => void) | null; @@ -199,6 +208,10 @@ const effectDragOverridesKey = ( export const SequenceManagerProvider: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { + const {isStudio} = useRemotionEnvironment(); + const [sequenceManagerId] = useState(() => String(Math.random())); + const committedOrderRef = useRef | null>(null); + const committedOrderIdsRef = useRef(null); const [sequences, setSequences] = useState([]); const sequencesRef = useRef(sequences); sequencesRef.current = sequences; @@ -326,20 +339,92 @@ export const SequenceManagerProvider: React.FC<{ [], ); + useIsomorphicLayoutEffect(() => { + if (!isStudio) { + return; + } + + let unmounted = false; + const onSequenceOrder = (event: Event) => { + const {detail} = event as CustomEvent; + const managerOrder = detail.find( + (item) => item.managerId === sequenceManagerId, + ); + if (!managerOrder) { + return; + } + + const previousOrder = committedOrderIdsRef.current; + if ( + previousOrder !== null && + previousOrder.length === managerOrder.sequenceIds.length && + previousOrder.every( + (sequenceId, index) => sequenceId === managerOrder.sequenceIds[index], + ) + ) { + return; + } + + const order = new Map( + managerOrder.sequenceIds.map((sequenceId, index) => [ + sequenceId, + index, + ]), + ); + committedOrderIdsRef.current = managerOrder.sequenceIds; + committedOrderRef.current = order; + queueMicrotask(() => { + if (unmounted) { + return; + } + + setSequences((currentSequences) => { + let changed = false; + const nextSequences = currentSequences.map((sequence) => { + const timelineOrder = order.get(sequence.id) ?? null; + if (sequence.timelineOrder === timelineOrder) { + return sequence; + } + + changed = true; + return {...sequence, timelineOrder}; + }); + + return changed ? nextSequences : currentSequences; + }); + }); + }; + + window.addEventListener(SEQUENCE_ORDER_EVENT, onSequenceOrder); + return () => { + unmounted = true; + window.removeEventListener(SEQUENCE_ORDER_EVENT, onSequenceOrder); + }; + }, [isStudio, sequenceManagerId]); + const registerSequence = useCallback((seq: TSequence) => { setSequences((seqs) => { - return [...seqs, seq]; + return [ + ...seqs, + { + ...seq, + timelineOrder: committedOrderRef.current?.get(seq.id) ?? null, + }, + ]; }); }, []); const updateSequence = useCallback((seq: TSequence) => { setSequences((seqs) => { const index = seqs.findIndex((item) => item.id === seq.id); - if (index === -1 || seqs[index] === seq) { + if (index === -1) { return seqs; } const next = [...seqs]; - next[index] = seq; + next[index] = { + ...seq, + timelineOrder: committedOrderRef.current?.get(seq.id) ?? null, + }; return next; }); }, []); @@ -406,7 +491,7 @@ export const SequenceManagerProvider: React.FC<{ remapPropStatuses, ]); - return ( + const providers = ( @@ -423,4 +508,12 @@ export const SequenceManagerProvider: React.FC<{ ); + + return isStudio ? ( + + {providers} + + ) : ( + providers + ); }; diff --git a/packages/core/src/audio/AudioForPreview.tsx b/packages/core/src/audio/AudioForPreview.tsx index 0fe15b55a81..1af9b0cc270 100644 --- a/packages/core/src/audio/AudioForPreview.tsx +++ b/packages/core/src/audio/AudioForPreview.tsx @@ -13,11 +13,13 @@ import {getCrossOriginValue} from '../get-cross-origin-value.js'; import {useLogLevel} from '../log-level-context.js'; import {usePreload} from '../prefetch.js'; import {random} from '../random.js'; +import {SequenceOrderMarker} from '../sequence-order-marker.js'; import {SequenceContext} from '../SequenceContext.js'; import {useVolume} from '../use-amplification.js'; import {useMediaInTimeline} from '../use-media-in-timeline.js'; import {useMediaPlayback} from '../use-media-playback.js'; import {useMediaTag} from '../use-media-tag.js'; +import {useRemotionEnvironment} from '../use-remotion-environment.js'; import { usePlayerMutedState, useMediaVolumeState, @@ -105,6 +107,7 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< const preloadedSrc = usePreload(src); const sequenceContext = useContext(SequenceContext); + const {isStudio} = useRemotionEnvironment(); const [timelineId] = useState(() => String(Math.random())); @@ -275,10 +278,12 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< }, [audioRef, src]); if (initialShouldPreMountAudioElements) { - return null; + return isStudio ? ( + {null} + ) : null; } - return ( + const audio = (