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=="], 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:*" 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]); 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(); + } +}); diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index 914761f4b9d..1833d3729b4 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, @@ -411,8 +412,12 @@ export const CompositionSelectorItem: React.FC<{ ); if (item.type === 'composition') { + const browserStudioOperations = getBrowserStudioOperations(); const compositionDragData = makeCompositionDragData({ - compositionFile: resolvedLocation?.source ?? null, + compositionFile: + browserStudioOperations === null + ? (resolvedLocation?.source ?? null) + : browserStudioOperations.getCompositionFile(item.composition.id), compositionId: item.composition.id, width: item.composition.width ?? null, height: item.composition.height ?? null, 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..e8ac5bb5c63 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'; @@ -60,6 +64,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, @@ -461,14 +466,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,68 +500,81 @@ const TimelineInner: React.FC = () => { ); })} {isStudioInteractivityEnabled() ? : null} - - - - {isStudioInteractivityEnabled() ? ( - - ) : null} - - {isStill ? ( - - ) : ( - - - - } - > - - - - - - - - } + + + + + {isStudioInteractivityEnabled() ? ( + + ) : null} + + {isStill ? ( + + ) : ( + + + - - - - - - {isStudioInteractivityEnabled() ? ( - - ) : null} - - - - - )} - - - - + } + > + + + + + + + + } + > + + + + + + {isStudioInteractivityEnabled() ? ( + + ) : null} + + + + + )} + + + + + ); }; -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/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/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/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} diff --git a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx index 756c362d450..d701ab8b2a9 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceRightEdgeDragHandle.tsx @@ -46,14 +46,16 @@ import { type TimelineSelection, } from './TimelineSelection'; -const HANDLE_WIDTH = 6; +const HANDLE_INSET = 6; +const HANDLE_OUTSET = 8; export const timelineSequenceFromDragSnapThresholdPx = 10; const baseStyle: React.CSSProperties = { position: 'absolute', top: 0, bottom: 0, - width: HANDLE_WIDTH, + // 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', @@ -1303,6 +1305,8 @@ const TimelineSequenceLeftEdgeDragHandleInner: React.FC<{ return; } + // Include the release position even if the final pointermove was skipped. + onMove(e); finishDrag(true); }; @@ -1326,7 +1330,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); @@ -1341,7 +1347,7 @@ const TimelineSequenceLeftEdgeDragHandleInner: React.FC<{ const style: React.CSSProperties = { ...baseStyle, - left: 0, + left: -HANDLE_OUTSET, background: TRANSPARENT, }; @@ -1613,7 +1619,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 +1865,8 @@ const TimelineSequenceRightEdgeDragHandleInner: React.FC<{ return; } + // Include the release position even if the final pointermove was skipped. + onMove(e); finishDrag(true); }; @@ -1880,7 +1890,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); @@ -1895,7 +1907,7 @@ const TimelineSequenceRightEdgeDragHandleInner: React.FC<{ const style: React.CSSProperties = { ...baseStyle, - right: 0, + right: -HANDLE_OUTSET, background: TRANSPARENT, }; 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 {