From 302f45f35f9267c9f844cd448e13724d382f59d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:25:34 +0000 Subject: [PATCH 01/12] Bump fflate from 0.8.2 to 0.8.3 in /packages/browser-studio Bumps [fflate](https://github.com/101arrowz/fflate) from 0.8.2 to 0.8.3. - [Release notes](https://github.com/101arrowz/fflate/releases) - [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md) - [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3) --- updated-dependencies: - dependency-name: fflate dependency-version: 0.8.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- packages/browser-studio/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-studio/package.json b/packages/browser-studio/package.json index 432860b2cde..08653b6006c 100644 --- a/packages/browser-studio/package.json +++ b/packages/browser-studio/package.json @@ -31,7 +31,7 @@ "@remotion/studio-protocol": "workspace:*", "@remotion/studio-shared": "workspace:*", "@rspack/browser": "2.1.1", - "fflate": "0.8.2", + "fflate": "0.8.3", "prettier": "catalog:", "react-refresh": "0.18.0", "remotion": "workspace:*" From 5c80e256f2c20fcbf8ed2c424c12476cf3ce1e47 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:22:43 +0200 Subject: [PATCH 02/12] Update fflate to 0.8.3 across all monorepo packages --- bun.lock | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 64416b86791..cc3103e4ab9 100644 --- a/bun.lock +++ b/bun.lock @@ -153,7 +153,7 @@ "@remotion/studio-protocol": "workspace:*", "@remotion/studio-shared": "workspace:*", "@rspack/browser": "2.1.1", - "fflate": "0.8.2", + "fflate": "0.8.3", "prettier": "catalog:", "react-refresh": "0.18.0", "remotion": "workspace:*", @@ -6557,7 +6557,7 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "3.3.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "4.0.1" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -11269,6 +11269,8 @@ "@types/styled-components/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@6.21.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.7.0", "@types/json-schema": "7.0.15", "@types/semver": "7.5.3", "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", "@typescript-eslint/typescript-estree": "6.21.0", "semver": "7.6.3" }, "peerDependencies": { "eslint": "9.19.0" } }, "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ=="], "@typescript-eslint/eslint-plugin/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], @@ -18177,6 +18179,8 @@ "template-prompt-to-motion-graphics/@react-three/fiber/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + "template-prompt-to-motion-graphics/@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "template-prompt-to-motion-graphics/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0" } }, "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw=="], "template-prompt-to-motion-graphics/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0", "@typescript-eslint/utils": "8.46.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg=="], From 1e173740f9ddebd5e55147add429a4e151b2ee86 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:36:40 +0200 Subject: [PATCH 03/12] Expand timeline layer edge hit areas --- .../components/Timeline/TimelineSequence.tsx | 67 ++++++++++++------- .../TimelineSequenceRightEdgeDragHandle.tsx | 9 +-- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineSequence.tsx b/packages/studio/src/components/Timeline/TimelineSequence.tsx index e567b8cf000..e6ec3eb2710 100644 --- a/packages/studio/src/components/Timeline/TimelineSequence.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequence.tsx @@ -176,6 +176,7 @@ const TimelineSequenceCurrentFrame: React.FC<{ readonly negativeStartClipped: boolean; readonly style: React.CSSProperties; readonly children: React.ReactNode; + readonly edgeDragHandles: React.ReactNode; readonly nodePathInfo: SequenceNodePathInfo | null; readonly sequenceFrameOffset: number; readonly fromCanUpdate: boolean; @@ -195,6 +196,7 @@ const TimelineSequenceCurrentFrame: React.FC<{ negativeStartClipped, style, children, + edgeDragHandles, nodePathInfo, sequenceFrameOffset, fromCanUpdate, @@ -379,8 +381,18 @@ const TimelineSequenceCurrentFrame: React.FC<{ ) : ( - content +
+ {content} +
)} + {edgeDragHandles} ); }; @@ -824,7 +836,8 @@ const TimelineSequenceInner: React.FC<{ marginLeft: visibleLayout?.marginLeft ?? 0, width: visibleLayout?.width ?? 0, color: WHITE, - overflow: 'hidden', + // Edge handles extend outside the layer; media is clipped separately. + overflow: 'visible', }; }, [negativeStartClipped, s.type, showLeftBorderRadius, visibleLayout]); @@ -870,6 +883,33 @@ const TimelineSequenceInner: React.FC<{ frozenFrame={frozenFrame} onMoveDragPointerDown={onMoveDragPointerDown} onPointerDownCapture={dragAwareDoubleClick.beginPointerGesture} + edgeDragHandles={ + <> + {showLeftEdgeDragHandle && + visibleLayout.leftEdgeVisible && + negativeStartWidth === 0 && + nodePathInfo && + validatedLocation ? ( + + ) : null} + {showRightEdgeDragHandle && + visibleLayout.rightEdgeVisible && + nodePathInfo && + validatedLocation ? ( + + ) : null} + + } onDoubleClick={ canHandleSequenceDoubleClick ? onSequenceDoubleClick : undefined } @@ -927,29 +967,6 @@ const TimelineSequenceInner: React.FC<{ visibleWidth={visibleLayout.width} /> )} - {showLeftEdgeDragHandle && - visibleLayout.leftEdgeVisible && - negativeStartWidth === 0 && - nodePathInfo && - validatedLocation ? ( - - ) : null} - {showRightEdgeDragHandle && - visibleLayout.rightEdgeVisible && - nodePathInfo && - validatedLocation ? ( - - ) : null} ); diff --git a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx index 756c362d450..561e780aa8f 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx @@ -46,14 +46,15 @@ import { type TimelineSelection, } from './TimelineSelection'; -const HANDLE_WIDTH = 6; +const HANDLE_INSET = 6; +const HANDLE_OUTSET = 4; export const timelineSequenceFromDragSnapThresholdPx = 10; const baseStyle: React.CSSProperties = { position: 'absolute', top: 0, bottom: 0, - width: HANDLE_WIDTH, + width: HANDLE_INSET + HANDLE_OUTSET, cursor: 'ew-resize', zIndex: 1, touchAction: 'none', @@ -1341,7 +1342,7 @@ const TimelineSequenceLeftEdgeDragHandleInner: React.FC<{ const style: React.CSSProperties = { ...baseStyle, - left: 0, + left: -HANDLE_OUTSET, background: TRANSPARENT, }; @@ -1895,7 +1896,7 @@ const TimelineSequenceRightEdgeDragHandleInner: React.FC<{ const style: React.CSSProperties = { ...baseStyle, - right: 0, + right: -HANDLE_OUTSET, background: TRANSPARENT, }; From f49e8e589c5ce57705778c60d0711fa3de218f11 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:44:28 +0200 Subject: [PATCH 04/12] Expand outer edge handles and avoid overlap on narrow layers --- .../Timeline/TimelineSequenceRightEdgeDragHandle.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx index 561e780aa8f..f7d6236005b 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx @@ -47,14 +47,15 @@ import { } from './TimelineSelection'; const HANDLE_INSET = 6; -const HANDLE_OUTSET = 4; +const HANDLE_OUTSET = 8; export const timelineSequenceFromDragSnapThresholdPx = 10; const baseStyle: React.CSSProperties = { position: 'absolute', top: 0, bottom: 0, - width: HANDLE_INSET + HANDLE_OUTSET, + // Keep the middle half of narrow layers available for moving. + width: `calc(${HANDLE_OUTSET}px + min(${HANDLE_INSET}px, 25%))`, cursor: 'ew-resize', zIndex: 1, touchAction: 'none', From 64fa477395bb3c42d2e86f82c113aa74b9e7e6d3 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:49:41 +0200 Subject: [PATCH 05/12] Add persistent frame format for Studio timeline ticks --- packages/studio/src/components/TimeValue.tsx | 93 ++++++++++++++----- .../src/components/Timeline/Timeline.tsx | 11 ++- .../Timeline/TimelineTickFormatProvider.tsx | 26 ++++++ .../Timeline/TimelineTimeIndicators.tsx | 11 ++- 4 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 packages/studio/src/components/Timeline/TimelineTickFormatProvider.tsx diff --git a/packages/studio/src/components/TimeValue.tsx b/packages/studio/src/components/TimeValue.tsx index 2a58bb2033b..81cd8ce990b 100644 --- a/packages/studio/src/components/TimeValue.tsx +++ b/packages/studio/src/components/TimeValue.tsx @@ -1,17 +1,24 @@ import {PlayerInternals} from '@remotion/player'; import React, { useCallback, + useContext, useEffect, useImperativeHandle, useRef, } from 'react'; import {Internals, useCurrentFrame} from 'remotion'; -import {LIGHT_TEXT, WHITE} from '../helpers/colors'; +import {LIGHT_TEXT, TRANSPARENT, WHITE} from '../helpers/colors'; +import { + FOCUS_VISIBLE_ONLY_CLASS_NAME, + HOVERABLE_CLASS_NAME, + hoverableStyle, +} from '../helpers/hoverable'; import {useIsStill} from '../helpers/is-current-selected-still'; import {useKeybinding} from '../helpers/use-keybinding'; import {renderFrame} from '../state/render-frame'; import {Flex, Spacing} from './layout'; import {InputDragger} from './NewComposition/InputDragger'; +import {TimelineTickFormatContext} from './Timeline/TimelineTickFormatProvider'; import {TimelineZoomControls} from './Timeline/TimelineZoomControls'; const text: React.CSSProperties = { @@ -25,7 +32,7 @@ const text: React.CSSProperties = { }; const currentTimeTypography: React.CSSProperties = { - color: WHITE, + color: LIGHT_TEXT, display: 'inline-block', fontSize: 14, fontVariantNumeric: 'tabular-nums', @@ -36,16 +43,37 @@ const currentTimeTypography: React.CSSProperties = { const currentTimeInputStyle: React.CSSProperties = { ...currentTimeTypography, - padding: '4px 6px 4px 0', + padding: 0, }; -const currentTimeButtonStyle: React.CSSProperties = { - paddingLeft: 0, +const currentTimeStack: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + lineHeight: 1, + padding: '5px 7px 5px 1px', transform: 'translateY(-1px)', }; +const currentTimeButtonStyle = { + display: 'block', + padding: 0, + border: 'none', + lineHeight: '21px', + '--remotion-cli-internals-blue-hovered': WHITE, +} as React.CSSProperties; + const currentTimeSubtitle: React.CSSProperties = { - color: LIGHT_TEXT, + ...hoverableStyle({ + idleBackground: TRANSPARENT, + hoverBackground: TRANSPARENT, + idleColor: LIGHT_TEXT, + hoverColor: WHITE, + }), + background: TRANSPARENT, + border: 'none', + padding: 0, + cursor: 'default', display: 'block', fontFamily: 'monospace', fontSize: 10, @@ -57,6 +85,10 @@ const currentTimeSubtitle: React.CSSProperties = { export const TimeValue: React.FC = () => { const frame = useCurrentFrame(); + const {showFrames, setShowFrames} = useContext(TimelineTickFormatContext); + const toggleTickFormat = useCallback(() => { + setShowFrames((previous) => !previous); + }, [setShowFrames]); const config = Internals.useUnsafeVideoConfig(); const isStill = useIsStill(); const {seek, play, pause, toggle} = PlayerInternals.usePlayerMethods(); @@ -81,10 +113,6 @@ export const TimeValue: React.FC = () => { }, [config], ); - const formatterSubtitle = useCallback( - (value: string | number) => String(value), - [], - ); useImperativeHandle( Internals.timeValueRef, () => ({ @@ -127,21 +155,36 @@ export const TimeValue: React.FC = () => { return (
- +
+ + +
diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index 13b4948b579..db674dbec0f 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -60,6 +60,7 @@ import { } from './TimelineSelection'; import {SEQUENCE_REORDER_MIME_TYPE} from './TimelineSequenceItem'; import {TimelineSlider} from './TimelineSlider'; +import {TimelineTickFormatProvider} from './TimelineTickFormatProvider'; import { TIMELINE_TIME_INDICATOR_HEIGHT, TimelineTimeIndicators, @@ -553,4 +554,12 @@ const TimelineInner: React.FC = () => { ); }; -export const Timeline = React.memo(TimelineInner); +const MemoizedTimelineInner = React.memo(TimelineInner); + +export const Timeline: React.FC = () => { + return ( + + + + ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineTickFormatProvider.tsx b/packages/studio/src/components/Timeline/TimelineTickFormatProvider.tsx new file mode 100644 index 00000000000..097ae90bf3b --- /dev/null +++ b/packages/studio/src/components/Timeline/TimelineTickFormatProvider.tsx @@ -0,0 +1,26 @@ +import React, {createContext, useEffect, useMemo, useState} from 'react'; + +const STORAGE_KEY = 'remotion.timelineShowFrames'; + +export const TimelineTickFormatContext = createContext<{ + showFrames: boolean; + setShowFrames: React.Dispatch>; +}>({showFrames: false, setShowFrames: () => undefined}); + +export const TimelineTickFormatProvider: React.FC<{ + readonly children: React.ReactNode; +}> = ({children}) => { + const [showFrames, setShowFrames] = useState( + () => localStorage.getItem(STORAGE_KEY) === 'true', + ); + useEffect(() => { + localStorage.setItem(STORAGE_KEY, String(showFrames)); + }, [showFrames]); + + const value = useMemo(() => ({showFrames, setShowFrames}), [showFrames]); + return ( + + {children} + + ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx b/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx index 26672ca5351..a60015d1dbc 100644 --- a/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx +++ b/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx @@ -15,6 +15,7 @@ import {TimeValue} from '../TimeValue'; import {scrollableRef} from './timeline-refs'; import {getFrameIncrementFromWidth} from './timeline-scroll-logic'; import {TIMELINE_TICKS_BACKGROUND} from './TimelineSelection'; +import {TimelineTickFormatContext} from './TimelineTickFormatProvider'; import {TimelineWidthContext} from './TimelineWidthProvider'; export const TIMELINE_TIME_INDICATOR_HEIGHT = 39; @@ -242,6 +243,7 @@ const TimelineTimeIndicatorsInner = React.memo<{ readonly durationInFrames: number; }>(({windowWidth, durationInFrames, fps}) => { const canvasRef = useRef(null); + const {showFrames} = useContext(TimelineTickFormatContext); useLayoutEffect(() => { const canvas = canvasRef.current; @@ -260,7 +262,10 @@ const TimelineTimeIndicatorsInner = React.memo<{ windowWidth, ); const maxTickLabelWidth = - renderFrame(durationInFrames - 1, fps).length * + (showFrames + ? `${durationInFrames - 1}f` + : renderFrame(durationInFrames - 1, fps) + ).length * TICK_LABEL_FONT_SIZE * 0.6; const tickScale = getTimelineTickScale({ @@ -320,7 +325,7 @@ const TimelineTimeIndicatorsInner = React.memo<{ context.font = `${TICK_LABEL_FONT_SIZE}px ${getComputedStyle(canvas).fontFamily}`; context.textBaseline = 'top'; context.fillText( - renderFrame(frame, fps), + showFrames ? `${Math.round(frame)}f` : renderFrame(frame, fps), xForFrame(frame) + TICK_LABEL_MARGIN_LEFT, 7, ); @@ -390,7 +395,7 @@ const TimelineTimeIndicatorsInner = React.memo<{ scrollable.removeEventListener('scroll', onScroll); resizeObserver.disconnect(); }; - }, [durationInFrames, fps, windowWidth]); + }, [durationInFrames, fps, showFrames, windowWidth]); const style: React.CSSProperties = useMemo(() => { return { From f772efe14ff1ac1886fed7d3751d16633f538a84 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:54:53 +0200 Subject: [PATCH 06/12] Add Browser Studio leave confirmation --- packages/browser-studio/src/BrowserStudio.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/browser-studio/src/BrowserStudio.tsx b/packages/browser-studio/src/BrowserStudio.tsx index ecddac0e5bc..f3bcee05653 100644 --- a/packages/browser-studio/src/BrowserStudio.tsx +++ b/packages/browser-studio/src/BrowserStudio.tsx @@ -181,6 +181,18 @@ export const BrowserStudio: React.FC = ({ [], ); + useEffect(() => { + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = true; + }; + + window.addEventListener('beforeunload', onBeforeUnload); + return () => { + window.removeEventListener('beforeunload', onBeforeUnload); + }; + }, []); + useEffect(() => { return () => publicFileManager.dispose(); }, [publicFileManager]); From 2c68166259faa187123c04dbd9ad2cb97febe7fe Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:57:36 +0200 Subject: [PATCH 07/12] fix media playback rate reset on resource reload --- packages/core/src/use-media-playback.ts | 8 ++++ .../src/test/html5-playback-rate.test.tsx | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 packages/media/src/test/html5-playback-rate.test.tsx diff --git a/packages/core/src/use-media-playback.ts b/packages/core/src/use-media-playback.ts index 29f4f1a1e05..654194207c0 100644 --- a/packages/core/src/use-media-playback.ts +++ b/packages/core/src/use-media-playback.ts @@ -164,6 +164,14 @@ export const useMediaPlayback = ({ // and it is also in a useLayoutEffect. useLayoutEffect(() => { const playbackRateToSet = Math.max(0, playbackRate); + // Loading a resource resets playbackRate to defaultPlaybackRate. + if ( + mediaRef.current && + mediaRef.current.defaultPlaybackRate !== playbackRateToSet + ) { + mediaRef.current.defaultPlaybackRate = playbackRateToSet; + } + if ( mediaRef.current && mediaRef.current.playbackRate !== playbackRateToSet diff --git a/packages/media/src/test/html5-playback-rate.test.tsx b/packages/media/src/test/html5-playback-rate.test.tsx new file mode 100644 index 00000000000..4c4d5cca959 --- /dev/null +++ b/packages/media/src/test/html5-playback-rate.test.tsx @@ -0,0 +1,46 @@ +import {Player} from '@remotion/player'; +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Html5Video} from 'remotion'; +import {expect, test} from 'vitest'; + +test('preserves the Player media playback rate across source changes and reloads', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const Composition: React.FC<{readonly src: string}> = ({src}) => ( + + ); + + try { + for (const [index, globalRate] of [1, 0.5].entries()) { + root.render( + , + ); + await expect + .poll(() => container.querySelector('video')?.readyState) + .toBe(4); + const video = container.querySelector('video')!; + await expect.poll(() => video.playbackRate).toBe(0.6 * globalRate); + + // Loading a new resource resets playbackRate to defaultPlaybackRate. + video.src = `/bigbuckbunny.mp4?reload=${index}`; + video.load(); + await expect.poll(() => video.readyState).toBe(4); + expect(video.playbackRate).toBe(0.6 * globalRate); + } + } finally { + root.unmount(); + container.remove(); + } +}); From e6f4dd321f56516fd87f34eacc20e13f66ddcab2 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 11:59:25 +0200 Subject: [PATCH 08/12] Fix timeline drags snapping back on mouse release --- .../TimelineSequenceRightEdgeDragHandle.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx index 756c362d450..6f5cf8139c6 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx @@ -1303,6 +1303,8 @@ const TimelineSequenceLeftEdgeDragHandleInner: React.FC<{ return; } + // Include the release position even if the final pointermove was skipped. + onMove(e); finishDrag(true); }; @@ -1326,7 +1328,9 @@ const TimelineSequenceLeftEdgeDragHandleInner: React.FC<{ onMove, onEnd: (reason, endEvent) => { if ( - (reason === 'pointerup' || reason === 'buttons-released') && + (reason === 'pointerup' || + reason === 'buttons-released' || + (reason === 'lostpointercapture' && endEvent?.buttons === 0)) && endEvent ) { onUp(endEvent); @@ -1613,7 +1617,9 @@ export const useTimelineSequenceFromDrag = ({ onEnd: (reason, endEvent) => { stopPointerSessionRef.current = null; finishDrag( - (reason === 'pointerup' || reason === 'buttons-released') && + (reason === 'pointerup' || + reason === 'buttons-released' || + (reason === 'lostpointercapture' && endEvent?.buttons === 0)) && endEvent !== null, ); }, @@ -1857,6 +1863,8 @@ const TimelineSequenceRightEdgeDragHandleInner: React.FC<{ return; } + // Include the release position even if the final pointermove was skipped. + onMove(e); finishDrag(true); }; @@ -1880,7 +1888,9 @@ const TimelineSequenceRightEdgeDragHandleInner: React.FC<{ onMove, onEnd: (reason, endEvent) => { if ( - (reason === 'pointerup' || reason === 'buttons-released') && + (reason === 'pointerup' || + reason === 'buttons-released' || + (reason === 'lostpointercapture' && endEvent?.buttons === 0)) && endEvent ) { onUp(endEvent); From e9b8c4311bded160de962ab56ed48fecefe7cbbb Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 12:03:35 +0200 Subject: [PATCH 09/12] Fix Browser Studio composition drag source paths --- packages/browser-studio/e2e/app.tsx | 19 ++++ .../browser-studio/e2e/browser-studio.test.ts | 103 ++++++++++++++++++ .../components/CompositionSelectorItem.tsx | 8 +- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/browser-studio/e2e/app.tsx b/packages/browser-studio/e2e/app.tsx index defaf8cf60f..1b21d48fdca 100644 --- a/packages/browser-studio/e2e/app.tsx +++ b/packages/browser-studio/e2e/app.tsx @@ -37,6 +37,25 @@ registerRoot(RemotionRoot); `; } + if (new URLSearchParams(window.location.search).has('composition-drop')) { + project.files['/project/src/Root.tsx'] = + `import {Composition} from 'remotion'; +import {Parent} from './Parent'; +import {Child} from './Child'; +export const RemotionRoot = () => <> + + +; +`; + project.files['/project/src/Parent.tsx'] = + `import {AbsoluteFill} from 'remotion'; +export const Parent = () => ; +`; + project.files['/project/src/Child.tsx'] = + `export const Child = ({text}: {text: string}) =>
{text}
; +`; + } + const root = document.getElementById('root'); if (!root) { throw new Error('Could not find root element'); diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 5bf8f58be39..345edf69da5 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -1346,3 +1346,106 @@ test('clears hover backgrounds even if pointer leave events are lost', async ({ .poll(() => getBackgroundColor(fileMenu)) .toBe('rgba(0, 0, 0, 0)'); }); + +for (const target of ['canvas', 'timeline'] as const) { + test(`drops a sidebar composition onto the ${target} with undo and redo`, async ({ + page, + }) => { + const studioApiRequests: string[] = []; + page.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/api/')) { + studioApiRequests.push(request.url()); + } + }); + await page.goto('/?composition-drop'); + const studio = page.frameLocator('iframe'); + await studio.locator('[data-compname="Parent"]').click(); + await waitForBrowserStudioOperations(studio); + const canvas = studio.locator('.remotion-studio-composition-container'); + await expect(canvas).toBeVisible(); + const before = await page.evaluate( + () => window.__browserStudioProject.files['/project/src/Parent.tsx'], + ); + const child = studio.locator('[data-compname="Child"]'); + const dataTransfer = await child.evaluateHandle(() => new DataTransfer()); + await child.dispatchEvent('dragstart', {dataTransfer}); + const dropTarget = + target === 'canvas' + ? canvas + : studio.locator('[data-timeline-scrollable="true"]'); + const coordinates = await dropTarget.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + }; + }); + await dropTarget.dispatchEvent('dragover', {...coordinates, dataTransfer}); + const indicator = + target === 'canvas' + ? studio.getByTestId('composition-drop-preview') + : studio.locator('[data-timeline-asset-drop-indicator="true"]'); + await expect(indicator).toBeVisible(); + const placementPreview = await indicator.boundingBox(); + await dropTarget.dispatchEvent('drop', {...coordinates, dataTransfer}); + await child.dispatchEvent('dragend', {dataTransfer}); + await expect + .poll(() => + page.evaluate( + () => window.__browserStudioProject.files['/project/src/Parent.tsx'], + ), + ) + .toContain(' window.__browserStudioProject.files['/project/src/Parent.tsx'], + ); + expect(source).toContain('durationInFrames={30}'); + expect(source).toContain('Nested composition'); + expect(source).toContain('width={320}'); + expect(source).toContain('height={180}'); + if (target === 'timeline') { + expect(Number(source.match(/from=\{(\d+)\}/)?.[1])).toBeGreaterThan(0); + await studio.locator('[data-timeline-scrollable="true"]').click({ + position: { + x: await dropTarget.evaluate((element) => element.clientWidth / 2), + y: 5, + }, + }); + } + + await expect(canvas.getByText('Nested composition')).toBeVisible(); + if (target === 'canvas') { + const rendered = await canvas + .getByText('Nested composition') + .boundingBox(); + expect(rendered).not.toBeNull(); + expect(placementPreview).not.toBeNull(); + for (const dimension of ['x', 'y', 'width', 'height'] as const) { + expect(rendered![dimension]).toBeCloseTo( + placementPreview![dimension], + 0, + ); + } + } + + await studio.getByTitle(/^Undo/).click(); + await expect + .poll(() => + page.evaluate( + () => window.__browserStudioProject.files['/project/src/Parent.tsx'], + ), + ) + .toBe(before); + await expect(canvas.getByText('Nested composition')).toBeHidden(); + await studio.getByTitle(/^Redo/).click(); + await expect + .poll(() => + page.evaluate( + () => window.__browserStudioProject.files['/project/src/Parent.tsx'], + ), + ) + .toBe(source); + await expect(canvas.getByText('Nested composition')).toBeVisible(); + expect(studioApiRequests).toEqual([]); + }); +} diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index 914761f4b9d..7197f664c27 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -15,6 +15,7 @@ import React, { useState, } from 'react'; import {type _InternalTypes} from 'remotion'; +import {getBrowserStudioOperations} from '../helpers/browser-studio-operations'; import {StudioServerConnectionCtx} from '../helpers/client-id'; import { CURRENT_COLOR, @@ -412,7 +413,12 @@ export const CompositionSelectorItem: React.FC<{ if (item.type === 'composition') { const compositionDragData = makeCompositionDragData({ - compositionFile: resolvedLocation?.source ?? null, + compositionFile: + getBrowserStudioOperations()?.getCompositionFile( + item.composition.id, + ) ?? + resolvedLocation?.source ?? + null, compositionId: item.composition.id, width: item.composition.width ?? null, height: item.composition.height ?? null, From e99543758252ccb007c52a083c65be9a81bedc37 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 13:29:19 +0200 Subject: [PATCH 10/12] Handle unresolved Browser Studio composition drag sources --- packages/browser-studio/e2e/app.tsx | 2 ++ .../browser-studio/e2e/browser-studio.test.ts | 27 +++++++++++++++++++ .../components/CompositionSelectorItem.tsx | 9 +++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/browser-studio/e2e/app.tsx b/packages/browser-studio/e2e/app.tsx index 1b21d48fdca..daf21a42b96 100644 --- a/packages/browser-studio/e2e/app.tsx +++ b/packages/browser-studio/e2e/app.tsx @@ -42,9 +42,11 @@ registerRoot(RemotionRoot); `import {Composition} from 'remotion'; import {Parent} from './Parent'; import {Child} from './Child'; +const computedId = 'ComputedChild'; export const RemotionRoot = () => <> + ; `; project.files['/project/src/Parent.tsx'] = diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 345edf69da5..81c2e24b8bf 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -1446,6 +1446,33 @@ for (const target of ['canvas', 'timeline'] as const) { ) .toBe(source); await expect(canvas.getByText('Nested composition')).toBeVisible(); + + const computedChild = studio.locator('[data-compname="ComputedChild"]'); + const unresolvedTransfer = await computedChild.evaluateHandle( + () => new DataTransfer(), + ); + await computedChild.dispatchEvent('dragstart', { + dataTransfer: unresolvedTransfer, + }); + await dropTarget.dispatchEvent('dragover', { + ...coordinates, + dataTransfer: unresolvedTransfer, + }); + await dropTarget.dispatchEvent('drop', { + ...coordinates, + dataTransfer: unresolvedTransfer, + }); + await computedChild.dispatchEvent('dragend', { + dataTransfer: unresolvedTransfer, + }); + await expect( + studio.getByText('Could not find composition source file', {exact: true}), + ).toBeVisible({timeout: 5000}); + expect( + await page.evaluate( + () => window.__browserStudioProject.files['/project/src/Parent.tsx'], + ), + ).toBe(source); expect(studioApiRequests).toEqual([]); }); } diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index 7197f664c27..1833d3729b4 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -412,13 +412,12 @@ export const CompositionSelectorItem: React.FC<{ ); if (item.type === 'composition') { + const browserStudioOperations = getBrowserStudioOperations(); const compositionDragData = makeCompositionDragData({ compositionFile: - getBrowserStudioOperations()?.getCompositionFile( - item.composition.id, - ) ?? - resolvedLocation?.source ?? - null, + browserStudioOperations === null + ? (resolvedLocation?.source ?? null) + : browserStudioOperations.getCompositionFile(item.composition.id), compositionId: item.composition.id, width: item.composition.width ?? null, height: item.composition.height ?? null, From df74f5fa3ecfd607aad284c1ddc27e08e2f23d3b Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 13:29:33 +0200 Subject: [PATCH 11/12] Remove composition drag test fixtures and E2E coverage --- packages/browser-studio/e2e/app.tsx | 21 --- .../browser-studio/e2e/browser-studio.test.ts | 130 ------------------ 2 files changed, 151 deletions(-) diff --git a/packages/browser-studio/e2e/app.tsx b/packages/browser-studio/e2e/app.tsx index daf21a42b96..defaf8cf60f 100644 --- a/packages/browser-studio/e2e/app.tsx +++ b/packages/browser-studio/e2e/app.tsx @@ -37,27 +37,6 @@ registerRoot(RemotionRoot); `; } - if (new URLSearchParams(window.location.search).has('composition-drop')) { - project.files['/project/src/Root.tsx'] = - `import {Composition} from 'remotion'; -import {Parent} from './Parent'; -import {Child} from './Child'; -const computedId = 'ComputedChild'; -export const RemotionRoot = () => <> - - - -; -`; - project.files['/project/src/Parent.tsx'] = - `import {AbsoluteFill} from 'remotion'; -export const Parent = () => ; -`; - project.files['/project/src/Child.tsx'] = - `export const Child = ({text}: {text: string}) =>
{text}
; -`; - } - const root = document.getElementById('root'); if (!root) { throw new Error('Could not find root element'); diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 81c2e24b8bf..5bf8f58be39 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -1346,133 +1346,3 @@ test('clears hover backgrounds even if pointer leave events are lost', async ({ .poll(() => getBackgroundColor(fileMenu)) .toBe('rgba(0, 0, 0, 0)'); }); - -for (const target of ['canvas', 'timeline'] as const) { - test(`drops a sidebar composition onto the ${target} with undo and redo`, async ({ - page, - }) => { - const studioApiRequests: string[] = []; - page.on('request', (request) => { - if (new URL(request.url()).pathname.startsWith('/api/')) { - studioApiRequests.push(request.url()); - } - }); - await page.goto('/?composition-drop'); - const studio = page.frameLocator('iframe'); - await studio.locator('[data-compname="Parent"]').click(); - await waitForBrowserStudioOperations(studio); - const canvas = studio.locator('.remotion-studio-composition-container'); - await expect(canvas).toBeVisible(); - const before = await page.evaluate( - () => window.__browserStudioProject.files['/project/src/Parent.tsx'], - ); - const child = studio.locator('[data-compname="Child"]'); - const dataTransfer = await child.evaluateHandle(() => new DataTransfer()); - await child.dispatchEvent('dragstart', {dataTransfer}); - const dropTarget = - target === 'canvas' - ? canvas - : studio.locator('[data-timeline-scrollable="true"]'); - const coordinates = await dropTarget.evaluate((element) => { - const rect = element.getBoundingClientRect(); - return { - clientX: rect.x + rect.width / 2, - clientY: rect.y + rect.height / 2, - }; - }); - await dropTarget.dispatchEvent('dragover', {...coordinates, dataTransfer}); - const indicator = - target === 'canvas' - ? studio.getByTestId('composition-drop-preview') - : studio.locator('[data-timeline-asset-drop-indicator="true"]'); - await expect(indicator).toBeVisible(); - const placementPreview = await indicator.boundingBox(); - await dropTarget.dispatchEvent('drop', {...coordinates, dataTransfer}); - await child.dispatchEvent('dragend', {dataTransfer}); - await expect - .poll(() => - page.evaluate( - () => window.__browserStudioProject.files['/project/src/Parent.tsx'], - ), - ) - .toContain(' window.__browserStudioProject.files['/project/src/Parent.tsx'], - ); - expect(source).toContain('durationInFrames={30}'); - expect(source).toContain('Nested composition'); - expect(source).toContain('width={320}'); - expect(source).toContain('height={180}'); - if (target === 'timeline') { - expect(Number(source.match(/from=\{(\d+)\}/)?.[1])).toBeGreaterThan(0); - await studio.locator('[data-timeline-scrollable="true"]').click({ - position: { - x: await dropTarget.evaluate((element) => element.clientWidth / 2), - y: 5, - }, - }); - } - - await expect(canvas.getByText('Nested composition')).toBeVisible(); - if (target === 'canvas') { - const rendered = await canvas - .getByText('Nested composition') - .boundingBox(); - expect(rendered).not.toBeNull(); - expect(placementPreview).not.toBeNull(); - for (const dimension of ['x', 'y', 'width', 'height'] as const) { - expect(rendered![dimension]).toBeCloseTo( - placementPreview![dimension], - 0, - ); - } - } - - await studio.getByTitle(/^Undo/).click(); - await expect - .poll(() => - page.evaluate( - () => window.__browserStudioProject.files['/project/src/Parent.tsx'], - ), - ) - .toBe(before); - await expect(canvas.getByText('Nested composition')).toBeHidden(); - await studio.getByTitle(/^Redo/).click(); - await expect - .poll(() => - page.evaluate( - () => window.__browserStudioProject.files['/project/src/Parent.tsx'], - ), - ) - .toBe(source); - await expect(canvas.getByText('Nested composition')).toBeVisible(); - - const computedChild = studio.locator('[data-compname="ComputedChild"]'); - const unresolvedTransfer = await computedChild.evaluateHandle( - () => new DataTransfer(), - ); - await computedChild.dispatchEvent('dragstart', { - dataTransfer: unresolvedTransfer, - }); - await dropTarget.dispatchEvent('dragover', { - ...coordinates, - dataTransfer: unresolvedTransfer, - }); - await dropTarget.dispatchEvent('drop', { - ...coordinates, - dataTransfer: unresolvedTransfer, - }); - await computedChild.dispatchEvent('dragend', { - dataTransfer: unresolvedTransfer, - }); - await expect( - studio.getByText('Could not find composition source file', {exact: true}), - ).toBeVisible({timeout: 5000}); - expect( - await page.evaluate( - () => window.__browserStudioProject.files['/project/src/Parent.tsx'], - ), - ).toBe(source); - expect(studioApiRequests).toEqual([]); - }); -} From 1a449a3c87bba45d59a5b909ced4e15732f543e8 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Sat, 5 Sep 2026 13:51:47 +0200 Subject: [PATCH 12/12] Add persistent timeline layer child collapsing --- .../src/components/Timeline/Timeline.tsx | 143 ++++++++------ .../Timeline/TimelineCollapseToggle.tsx | 2 +- .../Timeline/TimelineLayerChildren.tsx | 187 ++++++++++++++++++ .../Timeline/TimelineSequenceItem.tsx | 3 +- 4 files changed, 269 insertions(+), 66 deletions(-) create mode 100644 packages/studio/src/components/Timeline/TimelineLayerChildren.tsx diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index 13b4948b579..0260a623995 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -49,6 +49,10 @@ import {TimelineHeightContainer} from './TimelineHeightContainer'; import {TimelineInOutDragHandler} from './TimelineInOutDragHandler'; import {TimelineInOutPointer} from './TimelineInOutPointer'; import {TimelineKeyframeTracksProvider} from './TimelineKeyframeTracksContext'; +import { + TimelineLayerChildrenProvider, + useTimelineLayerChildren, +} from './TimelineLayerChildren'; import {TimelineList} from './TimelineList'; import {TimelinePinchZoom} from './TimelinePinchZoom'; import {TimelinePlayCursorSyncer} from './TimelinePlayCursorSyncer'; @@ -461,14 +465,20 @@ const TimelineInner: React.FC = () => { }); }, [filtered]); + const {visibleTracks, value: layerChildrenValue} = useTimelineLayerChildren( + collapsed, + sequences, + canvasContent?.type === 'composition' ? canvasContent.compositionId : null, + ); const maxTimelineTracks = getStudioMaxTimelineTracks(); const shown = useMemo(() => { - return maxTimelineTracks !== null && collapsed.length > maxTimelineTracks - ? collapsed.slice(0, maxTimelineTracks) - : collapsed; - }, [collapsed, maxTimelineTracks]); + return maxTimelineTracks !== null && + visibleTracks.length > maxTimelineTracks + ? visibleTracks.slice(0, maxTimelineTracks) + : visibleTracks; + }, [visibleTracks, maxTimelineTracks]); - const hasBeenCut = collapsed.length > shown.length; + const hasBeenCut = visibleTracks.length > shown.length; return ( @@ -489,66 +499,71 @@ const TimelineInner: React.FC = () => { ); })} {isStudioInteractivityEnabled() ? : null} - - - - {isStudioInteractivityEnabled() ? ( - - ) : null} - - {isStill ? ( - - ) : ( - - - - } - > - - - - - - - - } + + + + + {isStudioInteractivityEnabled() ? ( + + ) : null} + + {isStill ? ( + + ) : ( + + + - - - - - - {isStudioInteractivityEnabled() ? ( - - ) : null} - - - - - )} - - - - + } + > + + + + + + + + } + > + + + + + + {isStudioInteractivityEnabled() ? ( + + ) : null} + + + + + )} + + + + + ); }; diff --git a/packages/studio/src/components/Timeline/TimelineCollapseToggle.tsx b/packages/studio/src/components/Timeline/TimelineCollapseToggle.tsx index 87e4491af55..f672911e583 100644 --- a/packages/studio/src/components/Timeline/TimelineCollapseToggle.tsx +++ b/packages/studio/src/components/Timeline/TimelineCollapseToggle.tsx @@ -15,7 +15,7 @@ const Icon: React.FC< } > = ({color, ...props}) => { return ( - + ); diff --git a/packages/studio/src/components/Timeline/TimelineLayerChildren.tsx b/packages/studio/src/components/Timeline/TimelineLayerChildren.tsx new file mode 100644 index 00000000000..c094b0c90f7 --- /dev/null +++ b/packages/studio/src/components/Timeline/TimelineLayerChildren.tsx @@ -0,0 +1,187 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import type {TSequence} from 'remotion'; +import {LIGHT_TEXT, TRANSPARENT, WHITE} from '../../helpers/colors'; +import type {TimelineTrackData} from '../../helpers/get-timeline-sequence-sort-key'; +import { + FOCUS_VISIBLE_ONLY_CLASS_NAME, + HOVERABLE_CLASS_NAME, + hoverableStyle, +} from '../../helpers/hoverable'; +import {toggleBooleanMapKey} from '../../helpers/persist-boolean-map'; +import {timelineNodePathInfoToKey} from '../../helpers/timeline-node-path-key'; +import {TimelineCollapseToggle} from './TimelineCollapseToggle'; +import {TimelineExpandArrowSpacer} from './TimelineExpandArrowButton'; + +const STORAGE_KEY = 'remotion.editor.collapsedLayerChildren.v1'; + +const TimelineLayerChildrenContext = createContext<{ + readonly collapsed: Record; + readonly keys: Map; + readonly parents: Set; + readonly toggle: (key: string) => void; +} | null>(null); + +export const TimelineLayerChildrenProvider = + TimelineLayerChildrenContext.Provider; + +export const useTimelineLayerChildren = ( + tracks: TimelineTrackData[], + sequences: TSequence[], + compositionId: string | null, +) => { + const [collapsed, setCollapsed] = useState>(() => { + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) ?? '{}', + ); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + ([, storedValue]) => storedValue === true, + ), + ); + } catch { + return {}; + } + }); + const toggle = useCallback((key: string) => { + setCollapsed((previous) => { + const next = toggleBooleanMapKey(previous, key); + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Keep the control usable when storage is unavailable. + } + + return next; + }); + }, []); + const hierarchy = useMemo(() => { + const byId = new Map(sequences.map((sequence) => [sequence.id, sequence])); + const siblingIndices = new Map(); + const siblingCounts = new Map(); + for (const sequence of sequences) { + const index = siblingCounts.get(sequence.parent) ?? 0; + siblingIndices.set(sequence.id, index); + siblingCounts.set(sequence.parent, index + 1); + } + + const keys = new Map(); + const parents = new Set(); + const ancestors = new Map(); + for (const track of tracks) { + const parentIds: string[] = []; + let {parent} = track.sequence; + while (parent !== null && !parentIds.includes(parent)) { + parentIds.push(parent); + parents.add(parent); + parent = byId.get(parent)?.parent ?? null; + } + + ancestors.set(track.sequence.id, parentIds); + // Source identity survives remounts. For layers without source metadata, + // use the structural position instead of the ephemeral sequence ID. + const identity = track.nodePathInfo + ? ['source', timelineNodePathInfoToKey(track.nodePathInfo)] + : [ + 'position', + ...[...parentIds] + .reverse() + .concat(track.sequence.id) + .map((id) => siblingIndices.get(id)), + ]; + keys.set(track.sequence.id, JSON.stringify([compositionId, identity])); + } + + return {keys, parents, ancestors}; + }, [compositionId, sequences, tracks]); + const visibleTracks = useMemo( + () => + tracks.filter((track) => { + return !(hierarchy.ancestors.get(track.sequence.id) ?? []).some( + (id) => { + const key = hierarchy.keys.get(id); + return key !== undefined && collapsed[key]; + }, + ); + }), + [hierarchy, collapsed, tracks], + ); + const value = useMemo( + () => ({ + collapsed, + keys: hierarchy.keys, + parents: hierarchy.parents, + toggle, + }), + [collapsed, hierarchy, toggle], + ); + return {visibleTracks, value}; +}; + +export const TimelineLayerChildrenToggle: React.FC<{ + readonly sequence: TSequence; +}> = ({sequence}) => { + const context = useContext(TimelineLayerChildrenContext); + const key = context?.keys.get(sequence.id); + const isCollapsed = key !== undefined && Boolean(context?.collapsed[key]); + const onClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (key !== undefined) { + context?.toggle(key); + } + }, + [context, key], + ); + const stopPropagation = useCallback( + (event: React.SyntheticEvent) => event.stopPropagation(), + [], + ); + if (!context?.parents.has(sequence.id) || key === undefined) { + return ; + } + + const label = `${isCollapsed ? 'Expand' : 'Collapse'} children of ${sequence.displayName}`; + return ( + + ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx index 19f56333782..b99077a0479 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx @@ -76,6 +76,7 @@ import { TimelineExpandArrowSpacer, } from './TimelineExpandArrowButton'; import {TimelineExpandedSection} from './TimelineExpandedSection'; +import {TimelineLayerChildrenToggle} from './TimelineLayerChildren'; import {TimelineLayerEye, TimelineLayerEyeSpacer} from './TimelineLayerEye'; import {TimelineMediaInfo} from './TimelineMediaInfo'; import {TimelineRowChrome} from './TimelineRowChrome'; @@ -1266,7 +1267,7 @@ const TimelineSequenceItemInner: React.FC<{ ) } - arrow={} + arrow={} style={rowStyle} selected={selected} selectable={selectable}