From 54f41b41b6ffd63eb0eb862517178f3f8868007b Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Mon, 13 Jul 2026 10:52:37 -0700 Subject: [PATCH 1/6] fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped or regressed several final-wave behaviors from the reviewed studio-dnd stack, and never repaired the stale timelineZones.ts that #2279 introduced. Restores: - TimelineRuler: sticky under vertical scroll, full-height gridlines removed (beat lines only), frame-number tick labels via a persisted timeDisplayMode store preference (PlayerControls toggle now store-backed) - timelineZones: stable track lanes — lane = authored data-track-index ascending; z is paint order only (replaces the stale z-driven lane pack, which broke track insert-band commits that contractually depend on it) - persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes already at target values, e.g. in a track-insert renumber) is skipped instead of aborting and rolling back the whole batch — this alone made new-track creation (incl. the top insert band) fail silently - useTimelineStackingSync: unresolvable clips read as NaN again so timelineStackingSync's Number.isFinite exclusion contract holds (z=0 fabrications skewed stacking boundaries) - timelineAssetDrop: drops land on the drop track (no overlap bump to max-track+1), data-hf-id stamped, audio gets data-volume - timing edits: soft-reload the server's rewritten GSAP script instead of a full iframe remount (no all-clips flash on move/resize); full reload only when no scriptText or the soft path can't apply, and one full reload when a group edit touches non-active files (new hooks/timelineTimingSync.ts) - duration: content-driven grow-AND-shrink on move/resize/delete, synced optimistically to the store and the live root data-duration at release (was a grow-only ratchet; shrink never updated the readout) New UX: sidebar asset click opens a compact non-modal preview over the canvas (dismiss on outside click, Escape, playback, or seek), and clicking an already-added asset reveals its clip in the timeline (smooth minimal scroll to its time and lane; vertical-only in fit zoom). Verified by pointer-driving a real project: sticky ruler + gridline removal, no iframe remount on move/resize (marker survives, GSAP tween positions rewritten in place), duration readout 40->37->40 on shrink/stretch, and top-insert-band track creation renumbering lanes correctly on disk. --- .../components/nle/AssetPreviewOverlay.tsx | 86 ++-- .../src/components/sidebar/AssetCard.tsx | 8 +- .../src/components/sidebar/AudioRow.tsx | 8 +- .../src/hooks/timelineEditingHelpers.test.ts | 88 ++++ .../src/hooks/timelineEditingHelpers.ts | 264 +---------- .../studio/src/hooks/timelineTimingSync.ts | 377 +++++++++++++++ .../src/hooks/useTimelineEditing.test.tsx | 343 +++++--------- .../studio/src/hooks/useTimelineEditing.ts | 150 +++--- .../src/hooks/useTimelineGroupEditing.ts | 267 +++++------ .../src/player/components/PlayerControls.tsx | 10 +- .../src/player/components/TimelineCanvas.tsx | 3 + .../src/player/components/TimelineRuler.tsx | 102 +++-- .../components/timelineClipDragCommit.test.ts | 17 +- .../components/timelineRevealScroll.test.ts | 94 ++++ .../player/components/timelineRevealScroll.ts | 88 ++++ .../player/components/timelineZones.test.ts | 432 +++++------------- .../src/player/components/timelineZones.ts | 206 +++------ .../components/useTimelineRevealClip.ts | 56 +++ .../components/useTimelineStackingSync.ts | 12 +- .../src/player/store/playerStore.test.ts | 51 ++- .../studio/src/player/store/playerStore.ts | 26 ++ .../src/utils/assetPreviewDismiss.test.ts | 27 ++ .../studio/src/utils/assetPreviewDismiss.ts | 29 ++ .../src/utils/timelineAssetDrop.test.ts | 196 +++++--- .../studio/src/utils/timelineAssetDrop.ts | 148 +++--- 25 files changed, 1723 insertions(+), 1365 deletions(-) create mode 100644 packages/studio/src/hooks/timelineTimingSync.ts create mode 100644 packages/studio/src/player/components/timelineRevealScroll.test.ts create mode 100644 packages/studio/src/player/components/timelineRevealScroll.ts create mode 100644 packages/studio/src/player/components/useTimelineRevealClip.ts create mode 100644 packages/studio/src/utils/assetPreviewDismiss.test.ts create mode 100644 packages/studio/src/utils/assetPreviewDismiss.ts diff --git a/packages/studio/src/components/nle/AssetPreviewOverlay.tsx b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx index 6245c4985e..63721f7c55 100644 --- a/packages/studio/src/components/nle/AssetPreviewOverlay.tsx +++ b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx @@ -2,15 +2,20 @@ * CapCut-style asset preview overlay rendered inside PreviewPane. * * Shown when the user clicks an asset card that has NOT yet been added to the - * timeline. Displays the media (image / video / audio) without modifying the - * composition — no undo entry, no file mutation. + * timeline. Displays the media (image / video / audio) as a compact floating + * card over the canvas — the canvas stays visible behind a barely-tinted + * click-catcher — without modifying the composition (no undo entry, no file + * mutation). * - * Dismiss: X button, Escape key, or click on the scrim. + * Dismiss: X button, Escape key, click outside the card, or any playhead + * activity (starting playback / seeking) — the canvas refocuses. * Switching to another not-added asset replaces the current preview. */ import { useEffect, useCallback } from "react"; import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes"; import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss"; import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils"; function basename(path: string): string { @@ -37,11 +42,7 @@ function AssetPreviewMedia({ }) { if (kind === "image") { return ( - {name} + {name} ); } if (kind === "video") { @@ -52,12 +53,12 @@ function AssetPreviewMedia({ autoPlay muted playsInline - className="max-w-full max-h-[70vh] rounded-md shadow-2xl" + className="max-w-full max-h-[40vh] rounded" /> ); } return ( -
+
window.removeEventListener("keydown", handleKeyDown); }, [previewAsset, handleKeyDown]); + // The canvas refocuses on any playhead activity: starting playback or a + // seek/scrub away from where the playhead sat when the preview opened + // dismisses it. openedTime is captured per preview open (previewAsset dep), + // so a stale render can never dismiss against the wrong reference time. + useEffect(() => { + if (!previewAsset) return; + const openedTime = usePlayerStore.getState().currentTime; + return usePlayerStore.subscribe((state) => { + if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset(); + }); + }, [previewAsset, clearPreviewAsset]); + if (!previewAsset || !previewProjectId) return null; const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId); @@ -101,40 +114,39 @@ export function AssetPreviewOverlay() { return (
- {/* Close button */} - - - {/* Media */} + {/* Floating preview card — compact, canvas stays visible around it */}
e.stopPropagation()} > + {/* Close button */} + + {/* Filename label */} diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx index a083d34d25..f67e43a87e 100644 --- a/packages/studio/src/components/sidebar/AssetCard.tsx +++ b/packages/studio/src/components/sidebar/AssetCard.tsx @@ -133,6 +133,7 @@ export function AssetCard({ const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const requestClipReveal = usePlayerStore((s) => s.requestClipReveal); const elements = usePlayerStore((s) => s.elements); const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); @@ -150,14 +151,17 @@ export function AssetCard({ if (used) { const clip = findClipForAsset(elements, asset); if (clip) { - setSelectedElementId(clip.key ?? clip.id); + const clipKey = clip.key ?? clip.id; + setSelectedElementId(clipKey); + // Scroll the timeline so the selected clip is actually visible. + requestClipReveal(clipKey); return; } } // Not added (or no matching clip found) → preview overlay setPreviewAsset(asset, projectId); }, - [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset], + [used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset], ); return ( diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx index 445ea6732d..2dc26f8c12 100644 --- a/packages/studio/src/components/sidebar/AudioRow.tsx +++ b/packages/studio/src/components/sidebar/AudioRow.tsx @@ -43,6 +43,7 @@ export function AudioRow({ // CapCut-style click behavior: drag-threshold gate. const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const requestClipReveal = usePlayerStore((s) => s.requestClipReveal); const elements = usePlayerStore((s) => s.elements); const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); @@ -59,14 +60,17 @@ export function AudioRow({ if (used) { const clip = findClipForAsset(elements, asset); if (clip) { - setSelectedElementId(clip.key ?? clip.id); + const clipKey = clip.key ?? clip.id; + setSelectedElementId(clipKey); + // Scroll the timeline so the selected clip is actually visible. + requestClipReveal(clipKey); return; } } // Not added → preview overlay (audio player) setPreviewAsset(asset, projectId); }, - [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset], + [used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset], ); useEffect(() => { diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index 238f30f2cb..d76f4898e6 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyTimelineStackingReorder, + buildTimelineMoveTimingPatch, deleteSelectedKeyframes, extendRootDurationIfNeeded, + persistTimelineBatchEdit, + type PersistTimelineBatchChange, } from "./timelineEditingHelpers"; import type { TimelineElement } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore"; @@ -108,6 +111,91 @@ describe("extendRootDurationIfNeeded", () => { }); }); +describe("persistTimelineBatchEdit", () => { + const SOURCE = `
`; + + function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) { + return { + projectId: "p1", + activeCompPath: "index.html", + label: "Move timeline clips", + changes, + writeProjectFile: async (path: string, content: string) => { + writes.push([path, content]); + }, + recordEdit: async () => {}, + domEditSaveTimestampRef: { current: 0 }, + pendingTimelineEditPathRef: { current: new Set() }, + }; + } + + function stubReadFileContent(content: string) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ content }), + })), + ); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => { + // A track-insert renumber can include a member whose attributes already + // hold the target values — its patch is string-identical. The batch must + // skip it and still persist the members that DID change. + stubReadFileContent(SOURCE); + const writes: Array<[string, string]> = []; + + await persistTimelineBatchEdit( + batchInput( + [ + { + // no-op: data-start already "1", track already 0 + element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }), + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, 1, 5, 0), + }, + { + // real change: track 1 -> 2 + element: el({ id: "b", tag: "video", domId: "b", start: 2, track: 1 }), + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, 2, 5, 2), + }, + ], + writes, + ), + ); + + expect(writes).toHaveLength(1); + expect(writes[0]![0]).toBe("index.html"); + expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"'); + }); + + it("saves nothing when every member is a no-op", async () => { + stubReadFileContent(SOURCE); + const writes: Array<[string, string]> = []; + + await persistTimelineBatchEdit( + batchInput( + [ + { + element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }), + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, 1, 5, 0), + }, + ], + writes, + ), + ); + + expect(writes).toHaveLength(0); + }); +}); + describe("deleteSelectedKeyframes", () => { it("coalesces all removals and reloads only after the last one", () => { usePlayerStore.setState({ diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 7609d8a9f5..a99b33cbad 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -5,13 +5,16 @@ import { type TimelineStackingReorderIntent, } from "../player/components/timelineEditing"; import { getElementZIndex } from "../player/lib/layerOrdering"; -import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers"; +import { + furthestClipEndFromSource, + getTimelineElementIdentity, +} from "../player/lib/timelineElementHelpers"; import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory"; import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; -import { extendRootDurationInSource } from "../utils/rootDuration"; -import { postRuntimeControlMessage } from "../player/lib/runtimeProtocol"; - +import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; +import { readFileContent } from "./timelineTimingSync"; export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes"; +export { readFileContent }; function isHTMLElement(element: Element | null): element is HTMLElement { if (!element) return false; // Use the element's OWN realm's HTMLElement: timeline clips live in the preview @@ -149,16 +152,6 @@ export function patchIframeDomTiming( // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. } } -function postRootDurationToPreview( - iframe: HTMLIFrameElement | null, - durationSeconds: number, -): void { - const duration = Number(durationSeconds); - if (!Number.isFinite(duration) || duration <= 0) return; - postRuntimeControlMessage(iframe?.contentWindow, "set-root-duration", { - durationSeconds: duration, - }); -} // fallow-ignore-next-line complexity function resolveResizePlaybackStart( original: string, @@ -211,7 +204,12 @@ export function buildTimelineMoveTimingPatch( value: formatTimelineAttributeNumber(track), }); } - return extendRootDurationInSource(patched, start + duration); + // Content-driven duration: sync data-duration to the furthest clip end read + // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved + // past the end and shrinks if the furthest clip moved left. Measured from the + // source, NOT the store — store durations are runtime-truncated to the current + // comp length, which would ratchet the duration down every move. + return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched)); } export function buildTimelineResizeTimingPatch( @@ -238,7 +236,10 @@ export function buildTimelineResizeTimingPatch( value: formatTimelineAttributeNumber(pbs.value), }); } - return extendRootDurationInSource(patched, updates.start + updates.duration); + // Content-driven duration from the PATCHED SOURCE (raw data-duration) — + // grows/shrinks to the furthest clip end. Not from the store, whose + // durations are runtime-truncated. + return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched)); } export interface PersistTimelineEditInput { @@ -319,12 +320,16 @@ export async function persistTimelineBatchEdit( const current = patchedByPath.get(targetPath) ?? original; const patched = change.buildPatches(current, patchTarget); - if (patched === current) { - throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`); - } + // A member whose attributes already hold the target values patches to the + // identical string — e.g. a track-insert renumber where one clip's lane is + // already correct. That is a legitimate no-op, not a targeting failure: + // skip it instead of aborting (and rolling back) the whole batch. + if (patched === current) continue; patchedByPath.set(targetPath, patched); } + if (patchedByPath.size === 0) return; + const files = Object.fromEntries(patchedByPath); for (const targetPath of Object.keys(files)) { input.pendingTimelineEditPathRef.current.add(targetPath); @@ -343,227 +348,6 @@ export async function persistTimelineBatchEdit( input.domEditSaveTimestampRef.current = Date.now(); } -export async function readFileContent(projectId: string, targetPath: string): Promise { - if (targetPath.includes("\0") || targetPath.includes("..")) { - throw new Error(`Unsafe path: ${targetPath}`); - } - const response = await fetch( - `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, - ); - if (!response.ok) { - throw new Error(`Failed to read ${targetPath}`); - } - const data = (await response.json()) as { content?: string }; - if (typeof data.content !== "string") { - throw new Error(`Missing file contents for ${targetPath}`); - } - return data.content; -} - -export type GsapMutationStatus = { mutated: boolean }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readMutationStatus(value: unknown): GsapMutationStatus { - if (!isRecord(value)) return { mutated: false }; - return { mutated: value.mutated === true || value.changed === true }; -} - -function readMutationError(value: unknown, fallback: string): string { - if (isRecord(value) && typeof value.error === "string") return value.error; - return fallback; -} - -export async function finishTimelineTimingFallback(input: { - iframe: HTMLIFrameElement | null; - needsExtension: boolean; - rootDurationSeconds: number; - reloadPreview: () => void; - gsapMutation?: () => Promise; - onGsapError: (error: unknown) => void; -}): Promise { - let gsapMutated = false; - if (input.gsapMutation) { - try { - gsapMutated = (await input.gsapMutation()).mutated; - } catch (error) { - input.onGsapError(error); - return; - } - } - if (input.needsExtension) { - postRootDurationToPreview(input.iframe, input.rootDurationSeconds); - if (gsapMutated) input.reloadPreview(); - return; - } - input.reloadPreview(); -} - -// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to -// outlast one GSAP server round-trip, never a real second edit. -const GSAP_HISTORY_COALESCE_MS = 10_000; - -/** - * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the - * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash - * conflict. This snapshots every touched file, runs the mutation, then records a follow-up - * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip, - * folding both writes into one undo step. Returns the mutation status for caller reloads. - */ -export async function foldGsapMutationIntoHistory(input: { - projectId: string; - paths: string[]; - label: string; - coalesceKey?: string; - recordEdit: (edit: RecordEditInput) => Promise; - gsapMutation: () => Promise; -}): Promise { - const uniquePaths = [...new Set(input.paths)]; - const before = new Map(); - for (const path of uniquePaths) { - before.set(path, await readFileContent(input.projectId, path)); - } - const status = await input.gsapMutation(); - if (status.mutated) { - const files: Record = {}; - for (const path of uniquePaths) { - const priorContent = before.get(path); - const finalContent = await readFileContent(input.projectId, path); - if (priorContent !== undefined && finalContent !== priorContent) { - files[path] = { before: priorContent, after: finalContent }; - } - } - if (Object.keys(files).length > 0) { - await input.recordEdit({ - label: input.label, - kind: "timeline", - coalesceKey: input.coalesceKey, - coalesceMs: GSAP_HISTORY_COALESCE_MS, - files, - }); - } - } - return status; -} - -/** - * Shift all GSAP animation positions targeting a given element by a time delta. - * Calls the server-side GSAP mutation endpoint which uses the AST-based parser. - */ -export async function shiftGsapPositions( - projectId: string, - filePath: string, - elementId: string, - delta: number, -): Promise { - if (delta === 0 || !elementId) return { mutated: false }; - const res = await fetch( - `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "shift-positions", - targetSelector: `#${elementId}`, - delta, - }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => null); - throw new Error(readMutationError(err, "shift-positions failed")); - } - return readMutationStatus(await res.json().catch(() => null)); -} - -export async function scaleGsapPositions( - projectId: string, - filePath: string, - elementId: string, - oldStart: number, - oldDuration: number, - newStart: number, - newDuration: number, -): Promise { - if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false }; - if (oldStart === newStart && oldDuration === newDuration) return { mutated: false }; - const res = await fetch( - `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "scale-positions", - targetSelector: `#${elementId}`, - oldStart, - oldDuration, - newStart, - newDuration, - }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => null); - throw new Error(readMutationError(err, "scale-positions failed")); - } - return readMutationStatus(await res.json().catch(() => null)); -} - -/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */ -export function foldedShiftGsapMutation(input: { - projectId: string; - targetPath: string; - domId: string; - delta: number; - label: string; - coalesceKey?: string; - recordEdit: (edit: RecordEditInput) => Promise; -}): () => Promise { - return () => - foldGsapMutationIntoHistory({ - projectId: input.projectId, - paths: [input.targetPath], - label: input.label, - coalesceKey: input.coalesceKey, - recordEdit: input.recordEdit, - gsapMutation: () => - shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta), - }); -} - -/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */ -export function foldedScaleGsapMutation(input: { - projectId: string; - targetPath: string; - domId: string; - from: { start: number; duration: number }; - to: { start: number; duration: number }; - label: string; - coalesceKey?: string; - recordEdit: (edit: RecordEditInput) => Promise; -}): () => Promise { - return () => - foldGsapMutationIntoHistory({ - projectId: input.projectId, - paths: [input.targetPath], - label: input.label, - coalesceKey: input.coalesceKey, - recordEdit: input.recordEdit, - gsapMutation: () => - scaleGsapPositions( - input.projectId, - input.targetPath, - input.domId, - input.from.start, - input.from.duration, - input.to.start, - input.to.duration, - ), - }); -} - export { applyPatchByTarget, formatTimelineAttributeNumber }; export { patchDocumentRootDuration } from "./timelineEditingGsap"; diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts new file mode 100644 index 0000000000..4840d3b708 --- /dev/null +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -0,0 +1,377 @@ +// Soft-reload-first preview sync for timeline timing edits: server GSAP +// position mutations (shift / scale), folding those rewrites into the timing +// edit's undo history, and swapping the rewritten script into the live preview +// without a full iframe reload when possible. +import { type TimelineElement, usePlayerStore } from "../player/store/playerStore"; +import { applySoftReload } from "../utils/gsapSoftReload"; +import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; +import type { RecordEditInput } from "../utils/studioFileHistory"; +import { patchDocumentRootDuration } from "./timelineEditingGsap"; + +export async function readFileContent(projectId: string, targetPath: string): Promise { + if (targetPath.includes("\0") || targetPath.includes("..")) { + throw new Error(`Unsafe path: ${targetPath}`); + } + const response = await fetch( + `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) { + throw new Error(`Failed to read ${targetPath}`); + } + const data = (await response.json()) as { content?: string }; + if (typeof data.content !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + return data.content; +} + +/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */ +function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void { + try { + patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd); + } catch { + // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. + } +} + +/** + * Optimistically push the composition's content-driven length into the player + * store right after the live DOM patch, so the duration readout + seek bar + * update immediately. The readout binds to store.duration (PlayerControls); + * edits only patched store.elements, so the number stayed frozen (esp. on + * shrink) until a manual refresh. Read from the just-patched preview DOM (raw + * data-duration) so it's immune to the runtime's truncated live durations. + * + * Also writes the content end into the live root's `data-duration`. Timing + * edits take the soft-reload path (no full iframe reload), which lets the + * runtime recompute the length from the root's declared duration and post it + * back — reading the STALE root would revert this optimistic set. + */ +export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void { + const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null); + if (end > 0) { + usePlayerStore.getState().setDuration(end); + patchIframeRootDuration(iframe, end); + } +} + +/** + * The bits of the server GSAP-mutation response the timeline edit path needs. + * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload` + * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null + * when the endpoint didn't return one (older server, or a multi-script comp the + * soft path can't scope), in which case the caller full-reloads as before. + */ +export type GsapMutationStatus = { mutated: boolean; scriptText: string | null }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readMutationStatus(value: unknown): GsapMutationStatus { + if (!isRecord(value)) return { mutated: false, scriptText: null }; + return { + mutated: value.mutated === true || value.changed === true, + scriptText: typeof value.scriptText === "string" ? value.scriptText : null, + }; +} + +function readMutationError(value: unknown, fallback: string): string { + if (isRecord(value) && typeof value.error === "string") return value.error; + return fallback; +} + +/** + * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a + * soft reload over the full iframe reload that flashes every clip. + * + * Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has + * already (a) patched the live DOM timing attributes, (b) updated the store's + * elements optimistically (the drag commit calls `updateElement` before the + * persist), and (c) had the server rewrite the GSAP tween positions — which is the + * `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE + * document (no navigation), re-seeks to the current playhead, and rebinds the + * timeline, so the runtime matches the already-correct store. Nothing structural + * changed (no clip added/removed), so `processTimelineMessage` would re-derive the + * identical element set — skipping it just avoids the flash. + * + * Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload` + * result (no gsap runtime / rebind hook / scopable key / script element, or the + * re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run + * already applied the shift; a remount would re-flash for nothing. When the server + * returned no `scriptText` (older server, multi-script comp), we also full-reload. + */ +function syncTimingEditPreview( + iframe: HTMLIFrameElement | null, + outcome: Pick, + currentTime: number, + reloadPreview: () => void, +): void { + if (!iframe || !outcome.scriptText) { + reloadPreview(); + return; + } + const result = applySoftReload(iframe, outcome.scriptText, { + onAsyncFailure: reloadPreview, + currentTimeOverride: currentTime, + }); + if (result === "cannot-soft-reload") reloadPreview(); +} + +async function finishTimelineTimingFallback(input: { + iframe: HTMLIFrameElement | null; + reloadPreview: () => void; + gsapMutation?: () => Promise; + onGsapError: (error: unknown) => void; +}): Promise { + let outcome: GsapMutationStatus = { mutated: false, scriptText: null }; + if (input.gsapMutation) { + try { + outcome = await input.gsapMutation(); + } catch (error) { + input.onGsapError(error); + return; + } + } + syncTimingEditPreview( + input.iframe, + outcome, + usePlayerStore.getState().currentTime, + input.reloadPreview, + ); +} + +// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to +// outlast one GSAP server round-trip, never a real second edit. +const GSAP_HISTORY_COALESCE_MS = 10_000; + +/** + * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the + * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash + * conflict. This snapshots every touched file, runs the mutation, then records a follow-up + * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip, + * folding both writes into one undo step. Returns the mutation status for caller reloads. + */ +async function foldGsapMutationIntoHistory(input: { + projectId: string; + paths: string[]; + label: string; + coalesceKey?: string; + recordEdit: (edit: RecordEditInput) => Promise; + gsapMutation: () => Promise; +}): Promise { + const uniquePaths = [...new Set(input.paths)]; + const before = new Map(); + for (const path of uniquePaths) { + before.set(path, await readFileContent(input.projectId, path)); + } + const status = await input.gsapMutation(); + if (status.mutated) { + const files: Record = {}; + for (const path of uniquePaths) { + const priorContent = before.get(path); + const finalContent = await readFileContent(input.projectId, path); + if (priorContent !== undefined && finalContent !== priorContent) { + files[path] = { before: priorContent, after: finalContent }; + } + } + if (Object.keys(files).length > 0) { + await input.recordEdit({ + label: input.label, + kind: "timeline", + coalesceKey: input.coalesceKey, + coalesceMs: GSAP_HISTORY_COALESCE_MS, + files, + }); + } + } + return status; +} + +/** + * Shift all GSAP animation positions targeting a given element by a time delta. + * Calls the server-side GSAP mutation endpoint which uses the AST-based parser. + * Returns the rewritten script so the caller can soft-reload instead of full-reload. + */ +export async function shiftGsapPositions( + projectId: string, + filePath: string, + elementId: string, + delta: number, +): Promise { + if (delta === 0 || !elementId) return { mutated: false, scriptText: null }; + const res = await fetch( + `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "shift-positions", + targetSelector: `#${elementId}`, + delta, + }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(readMutationError(err, "shift-positions failed")); + } + return readMutationStatus(await res.json().catch(() => null)); +} + +export async function scaleGsapPositions( + projectId: string, + filePath: string, + elementId: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): Promise { + if (!elementId || oldDuration <= 0 || newDuration <= 0) + return { mutated: false, scriptText: null }; + if (oldStart === newStart && oldDuration === newDuration) + return { mutated: false, scriptText: null }; + const res = await fetch( + `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "scale-positions", + targetSelector: `#${elementId}`, + oldStart, + oldDuration, + newStart, + newDuration, + }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(readMutationError(err, "scale-positions failed")); + } + return readMutationStatus(await res.json().catch(() => null)); +} + +/** Timing delta a single-clip edit applies to its GSAP tweens. */ +export type SingleClipGsapEdit = + | { kind: "shift"; delta: number } + | { + kind: "scale"; + from: { start: number; duration: number }; + to: { start: number; duration: number }; + }; + +/** + * Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the + * server shift/scale mutation, folds the rewrite into the timing edit's history + * entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with + * the rewritten script — full reload when the mutation is skipped, failed, or + * returned no script. + */ +export function finishClipTimingFallback(input: { + iframe: HTMLIFrameElement | null; + reloadPreview: () => void; + projectId: string | null; + targetPath: string; + domId: string | undefined; + label: string; + coalesceKey?: string; + recordEdit: (edit: RecordEditInput) => Promise; + edit: SingleClipGsapEdit; +}): Promise { + const { projectId, targetPath, domId, edit } = input; + const timingChanged = + edit.kind === "shift" + ? edit.delta !== 0 + : edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration; + const runMutation = (pid: string, id: string): Promise => + edit.kind === "shift" + ? shiftGsapPositions(pid, targetPath, id, edit.delta) + : scaleGsapPositions( + pid, + targetPath, + id, + edit.from.start, + edit.from.duration, + edit.to.start, + edit.to.duration, + ); + return finishTimelineTimingFallback({ + iframe: input.iframe, + reloadPreview: input.reloadPreview, + gsapMutation: + timingChanged && domId && projectId + ? () => + foldGsapMutationIntoHistory({ + projectId, + paths: [targetPath], + label: input.label, + coalesceKey: input.coalesceKey, + recordEdit: input.recordEdit, + gsapMutation: () => runMutation(projectId, domId), + }) + : undefined, + onGsapError: (err) => console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err), + }); +} + +/** + * Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the + * per-change server mutation for every changed clip, folds the rewrites into the + * timing edit's history entry, and soft-reloads the preview when possible. + * + * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only + * the active comp's rewritten script can be soft-reloaded (swapped in place, no + * all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a + * multi-file move — no scriptText is passed, so the fallback does ONE full + * reload that reflects every changed file. + */ +export async function finishGroupTimingGsapFallback(input: { + projectId: string; + iframe: HTMLIFrameElement | null; + reloadPreview: () => void; + label: string; + errorLabel: string; + coalesceKey?: string; + recordEdit: (edit: RecordEditInput) => Promise; + activeCompPath: string | null; + changes: readonly C[]; + resolveChangePath: (element: TimelineElement) => string; + /** Per-change GSAP mutation; return null to skip a change with no timing delta. */ + mutateChange: (change: C, changePath: string) => Promise | null; +}): Promise { + const activePath = input.activeCompPath || "index.html"; + const otherFileChanged = input.changes.some( + (change) => input.resolveChangePath(change.element) !== activePath, + ); + await finishTimelineTimingFallback({ + iframe: input.iframe, + reloadPreview: input.reloadPreview, + gsapMutation: () => + foldGsapMutationIntoHistory({ + projectId: input.projectId, + paths: input.changes.map((change) => input.resolveChangePath(change.element)), + label: input.label, + coalesceKey: input.coalesceKey, + recordEdit: input.recordEdit, + gsapMutation: async () => { + let mutated = false; + let scriptText: GsapMutationStatus["scriptText"] = null; + for (const change of input.changes) { + const changePath = input.resolveChangePath(change.element); + const pending = input.mutateChange(change, changePath); + if (!pending) continue; + const status = await pending; + mutated = mutated || status.mutated; + // The LAST mutation against the active comp carries the cumulative + // rewritten script for that file. + if (changePath === activePath) scriptText = status.scriptText; + } + return { mutated, scriptText: otherFileChanged ? null : scriptText }; + }, + }), + onGsapError: (err) => console.error(`[Timeline] ${input.errorLabel}`, err), + }); +} diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index 3cc74fbfd7..9a652d83e9 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -77,6 +77,21 @@ function timelineElement(input: { }; } +/** Mount a harness component under act() and return its unmount hook. */ +function mountHarness(node: React.ReactElement): { unmount: () => void } { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { + unmount: () => { + act(() => root.unmount()); + }, + }; +} + function renderTimelineEditingHook(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; @@ -130,26 +145,12 @@ function renderTimelineEditingHook(input: { return null; } - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - + const { unmount } = mountHarness(); if (!move) throw new Error("Expected hook to expose move handler"); if (!resize) throw new Error("Expected hook to expose resize handler"); if (!groupMove) throw new Error("Expected hook to expose group move handler"); if (!groupResize) throw new Error("Expected hook to expose group resize handler"); - return { - move, - resize, - groupMove, - groupResize, - unmount: () => { - act(() => root.unmount()); - }, - }; + return { move, resize, groupMove, groupResize, unmount }; } type TimelineRecordEdit = NonNullable< @@ -198,20 +199,9 @@ function renderTimelineEditingHookWithLifecycle(input: { return null; } - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - + const { unmount } = mountHarness(); if (!move) throw new Error("Expected hook to expose move handler"); - return { - move, - unmount: () => { - act(() => root.unmount()); - }, - }; + return { move, unmount }; } function jsonResponse(body: unknown): Response { @@ -233,139 +223,118 @@ async function flushAsyncWork(): Promise { } } +/** + * Stub global fetch for project "p1": serves file contents (a single source + * string, or a path → content map) and answers the GSAP-mutation endpoint + * with `gsapBody`. Returns the mock for call inspection. + */ +function stubProjectFetch( + files: string | Record, + gsapBody: unknown = { ok: true }, +) { + const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) { + if (typeof files === "string") return jsonResponse({ content: files }); + const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); + return jsonResponse({ content: files[path] }); + } + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody); + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const ROOT_DURATION_FALLBACK_SOURCE = [ + `
`, + `
`, + `
`, +].join("\n"); + +/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */ +async function setupRootDurationFallback() { + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE); + const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const forceReloadSdkSession = vi.fn(); + const reloadPreview = vi.fn(); + const iframeWindow = iframe.contentWindow; + if (!iframeWindow) throw new Error("Expected iframe window"); + const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); + stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false }); + usePlayerStore.getState().setDuration(4); + const hook = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit, + sdkSession, + forceReloadSdkSession, + reloadPreview, + }); + return { + hook, + clip, + setTimingSpy, + writeProjectFile, + forceReloadSdkSession, + reloadPreview, + postMessageSpy, + }; +} + +/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */ +function expectRootDurationExtendedViaFallback( + ctx: Awaited>, +): void { + expect(ctx.setTimingSpy).not.toHaveBeenCalled(); + expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain( + 'data-composition-id="main" data-duration="5"', + ); + expect(usePlayerStore.getState().duration).toBe(5); + expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1); + // The GSAP endpoint returned no rewritten scriptText, so the timing sync + // escalates from the flash-free soft reload to ONE full reload. The root + // duration travels via the persisted content-driven `data-duration` (above), + // not a `set-root-duration` postMessage. + expect(ctx.reloadPreview).toHaveBeenCalledTimes(1); + expect(ctx.postMessageSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "set-root-duration" }), + "*", + ); +} + describe("useTimelineEditing timeline z-index reorder", () => { it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => { - const source = [ - `
`, - `
`, - `
`, - ].join("\n"); - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const sdkSession = await openComposition(source); - const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); - const forceReloadSdkSession = vi.fn(); - const reloadPreview = vi.fn(); - const iframeWindow = iframe.contentWindow; - if (!iframeWindow) throw new Error("Expected iframe window"); - const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); - if (url.includes("/api/projects/p1/gsap-mutations/")) { - return jsonResponse({ ok: true, mutated: false }); - } - throw new Error(`Unexpected fetch: ${url}`); - }), - ); - usePlayerStore.getState().setDuration(4); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: vi.fn().mockResolvedValue(undefined), - projectId: "p1", - writeProjectFile, - recordEdit, - sdkSession, - forceReloadSdkSession, - reloadPreview, - }); + const ctx = await setupRootDurationFallback(); await act(async () => { - await move(clip, { start: 3, track: clip.track }); - }); - - expect(setTimingSpy).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain( - 'data-composition-id="main" data-duration="5"', - ); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); - expect(usePlayerStore.getState().duration).toBe(5); - expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); - expect(reloadPreview).not.toHaveBeenCalled(); - expect(postMessageSpy).toHaveBeenCalledWith( - expect.objectContaining({ - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: 5, - protocolVersion: 1, - }), - "*", - ); + await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }); + }); - unmount(); + expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); + expectRootDurationExtendedViaFallback(ctx); + + ctx.hook.unmount(); }); it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => { - const source = [ - `
`, - `
`, - `
`, - ].join("\n"); - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const sdkSession = await openComposition(source); - const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); - const forceReloadSdkSession = vi.fn(); - const reloadPreview = vi.fn(); - const iframeWindow = iframe.contentWindow; - if (!iframeWindow) throw new Error("Expected iframe window"); - const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); - if (url.includes("/api/projects/p1/gsap-mutations/")) { - return jsonResponse({ ok: true, mutated: false }); - } - throw new Error(`Unexpected fetch: ${url}`); - }), - ); - usePlayerStore.getState().setDuration(4); - const { resize, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: vi.fn().mockResolvedValue(undefined), - projectId: "p1", - writeProjectFile, - recordEdit, - sdkSession, - forceReloadSdkSession, - reloadPreview, - }); + const ctx = await setupRootDurationFallback(); await act(async () => { - await resize(clip, { start: 0, duration: 5, playbackStart: undefined }); - }); - - expect(setTimingSpy).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain( - 'data-composition-id="main" data-duration="5"', - ); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">
'); - expect(usePlayerStore.getState().duration).toBe(5); - expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); - expect(reloadPreview).not.toHaveBeenCalled(); - expect(postMessageSpy).toHaveBeenCalledWith( - expect.objectContaining({ - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: 5, - protocolVersion: 1, - }), - "*", - ); + await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined }); + }); - unmount(); + expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">
'); + expectRootDurationExtendedViaFallback(ctx); + + ctx.hook.unmount(); }); it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { @@ -640,24 +609,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); const reloadPreview = vi.fn(); - const fetchMock = vi.fn( - async ( - input: Parameters[0], - _init?: Parameters[1], - ): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) { - return jsonResponse({ - content: '
', - }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) { - return jsonResponse({ ok: true }); - } - throw new Error(`Unexpected fetch: ${url}`); - }, - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubProjectFetch('
'); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, @@ -699,17 +651,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { }); const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockReturnValue(commitGate); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) { - return jsonResponse({ - content: '
', - }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); + stubProjectFetch('
'); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, @@ -768,15 +710,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { ]; const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }), - ); + stubProjectFetch(source); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: clips, iframe, @@ -825,18 +759,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { }); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) { - const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); - return jsonResponse({ content: files[path] }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }), - ); + stubProjectFetch(files); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [a, b], iframe, @@ -877,15 +800,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { releaseCommit = resolve; }); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }), - ); + stubProjectFetch(source); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, @@ -915,13 +830,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { it("matches the single-clip move output when a group move contains one clip", async () => { const source = '
'; const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); + stubProjectFetch(source); const singleWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const single = renderTimelineEditingHook({ diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index b873f85178..5cf07b0fe5 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -7,12 +7,16 @@ import { buildTimelineAssetId, buildTimelineAssetInsertHtml, buildTimelineFileDropPlacements, + fitTimelineAssetGeometry, getTimelineAssetKind, insertTimelineAssetIntoSource, - resolveTimelineAssetInitialGeometry, + resolveTimelineAssetCompositionSize, resolveTimelineAssetSrc, } from "../utils/timelineAssetDrop"; +import { generateId } from "../utils/generateId"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; +import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers"; import { getTimelineElementLabel, collectHtmlIds, @@ -23,15 +27,16 @@ import { buildPatchTarget, patchIframeDomTiming, persistTimelineEdit, - readFileContent, - foldedShiftGsapMutation, - foldedScaleGsapMutation, formatTimelineAttributeNumber, - finishTimelineTimingFallback, extendRootDurationIfNeeded, buildTimelineMoveTimingPatch, buildTimelineResizeTimingPatch, } from "./timelineEditingHelpers"; +import { + finishClipTimingFallback, + readFileContent, + syncPreviewContentDuration, +} from "./timelineTimingSync"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; import { @@ -155,36 +160,35 @@ export function useTimelineEditing({ if (!startChanged) return reorderDone; + // needsExtension gates the SDK path (setTiming can't grow the root duration), + // so read the store BEFORE the readout sync below optimistically updates it. + const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); + // Optimistic duration readout: content-driven (grow AND shrink), read from + // the just-patched live DOM. See syncPreviewContentDuration. + syncPreviewContentDuration(previewIframeRef.current); + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); }; const coalesceKey = `timeline-move:${element.hfId ?? element.id}`; const moveFallback = () => - enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => { - const pid = projectIdRef.current; - const delta = updates.start - element.start; - const domId = element.domId; - return finishTimelineTimingFallback({ + enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => + // Soft-reload with the server's rewritten GSAP script instead of a full + // iframe reload — a timing-only move already patched the DOM + store, so + // swapping the script in place avoids the all-clips flash. Falls back to + // reloadPreview() when the soft path can't apply. (See timelineTimingSync.) + finishClipTimingFallback({ iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: updates.start + element.duration, reloadPreview, - gsapMutation: - delta !== 0 && domId && pid - ? foldedShiftGsapMutation({ - projectId: pid, - targetPath, - domId, - delta, - label: "Move timeline clip", - coalesceKey, - recordEdit, - }) - : undefined, - onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err), - }); - }); - const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); + projectId: projectIdRef.current, + targetPath, + domId: element.domId, + label: "Move timeline clip", + coalesceKey, + recordEdit, + edit: { kind: "shift", delta: updates.start - element.start }, + }), + ); return reorderDone.then(() => { if (sdkSession && element.hfId && !needsExtension) { return sdkTimingPersist( @@ -245,6 +249,12 @@ export function useTimelineEditing({ liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); } patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); + // needsExtension gates the SDK path (setTiming can't grow the root duration), + // so read the store BEFORE the readout sync below optimistically updates it. + const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration); + // Optimistic duration readout: content-driven (grow AND shrink), read from + // the just-patched live DOM. See syncPreviewContentDuration. + syncPreviewContentDuration(previewIframeRef.current); const targetPath = element.sourceFile || activeCompPath || "index.html"; const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { return buildTimelineResizeTimingPatch(original, target, element, updates); @@ -253,37 +263,28 @@ export function useTimelineEditing({ updates.playbackStart != null || (updates.start !== element.start && element.playbackStart != null); // Server-path fallback: after persisting the attr patch, scale GSAP tween - // positions/durations on the server. Extending edits can keep the iframe - // live unless a GSAP source rewrite needs a fresh run. + // positions/durations on the server, then soft-reload with the rewritten + // script (timing-only resize) — same no-flash path as move; full reload is + // the fallback. const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; - const timingChanged = - updates.start !== element.start || updates.duration !== element.duration; - const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration); const resizeFallback = () => - enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => { - const pid = projectIdRef.current; - const domId = element.domId; - return finishTimelineTimingFallback({ + enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => + finishClipTimingFallback({ iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: updates.start + updates.duration, reloadPreview, - gsapMutation: - timingChanged && domId && pid - ? foldedScaleGsapMutation({ - projectId: pid, - targetPath, - domId, - from: { start: element.start, duration: element.duration }, - to: { start: updates.start, duration: updates.duration }, - label: "Resize timeline clip", - coalesceKey, - recordEdit, - }) - : undefined, - onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err), - }); - }); + projectId: projectIdRef.current, + targetPath, + domId: element.domId, + label: "Resize timeline clip", + coalesceKey, + recordEdit, + edit: { + kind: "scale", + from: { start: element.start, duration: element.duration }, + to: { start: updates.start, duration: updates.duration }, + }, + }), + ); if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) { return sdkTimingPersist( element.hfId, @@ -384,8 +385,19 @@ export function useTimelineEditing({ changed?: boolean; content?: string; }; - const patchedContent = + const removedContent = typeof removeData.content === "string" ? removeData.content : originalContent; + // Content-driven duration: shrink the composition to the furthest + // remaining clip end, read from the post-removal SOURCE (raw + // data-duration), so deleting the last/longest clip removes trailing + // empty space. Measured from the source, not the store, whose + // durations are runtime-truncated. + const deleteContentEnd = furthestClipEndFromSource(removedContent); + const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd); + // Optimistically reflect the shrunk length in the readout/seek bar. + if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) { + usePlayerStore.getState().setDuration(deleteContentEnd); + } domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ @@ -469,13 +481,17 @@ export function useTimelineEditing({ originalContent, buildTimelineAssetInsertHtml({ id: newId, + hfId: `hf-${generateId()}`, assetPath: resolvedAssetSrc, kind, start: normalizedStart, duration: normalizedDuration, track: placement.track, zIndex: newElementZIndex, - geometry: resolveTimelineAssetInitialGeometry(originalContent), + geometry: fitTimelineAssetGeometry( + null, + resolveTimelineAssetCompositionSize(originalContent), + ), }), ); @@ -532,17 +548,6 @@ export function useTimelineEditing({ const placements = buildTimelineFileDropPlacements( placement ?? { start: 0, track: 0 }, durations, - timelineElements - .filter( - (te) => - (te.sourceFile || activeCompPath || "index.html") === - (activeCompPath || "index.html"), - ) - .map((te) => ({ - start: te.start, - duration: te.duration, - track: te.track, - })), ); for (const [index, assetPath] of uploaded.entries()) { await handleTimelineAssetDrop( @@ -552,14 +557,7 @@ export function useTimelineEditing({ ); } }, - [ - activeCompPath, - handleTimelineAssetDrop, - timelineElements, - uploadProjectFiles, - isRecordingRef, - showToast, - ], + [handleTimelineAssetDrop, uploadProjectFiles, isRecordingRef, showToast], ); const handleBlockedTimelineEdit = useCallback( diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 7225b50d59..2dc4179ff2 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -6,17 +6,19 @@ import { buildTimelineMoveTimingPatch, buildTimelineResizeTimingPatch, extendRootDurationIfNeeded, - finishTimelineTimingFallback, - foldGsapMutationIntoHistory, formatTimelineAttributeNumber, patchIframeDomTiming, persistTimelineBatchEdit, - readFileContent, - scaleGsapPositions, - shiftGsapPositions, type PersistTimelineBatchChange, type RecordEditInput, } from "./timelineEditingHelpers"; +import { + finishGroupTimingGsapFallback, + readFileContent, + scaleGsapPositions, + shiftGsapPositions, + syncPreviewContentDuration, +} from "./timelineTimingSync"; export interface TimelineGroupMoveChange { element: TimelineElement; @@ -149,6 +151,55 @@ export function useTimelineGroupEditing({ ], ); + // Shared SDK fast path for group move/resize: eligible when nothing needs the + // server (no root-duration growth, one shared file, every change SDK-addressable + // and `eligible` per the caller's own gate). Returns whether the SDK handled it; + // false → caller falls through to the server batch persist. + const trySdkBatchPersist = useCallback( + async (input: { + changes: readonly { element: TimelineElement }[]; + sdkChanges: Array<{ + hfId: string; + timingUpdate: { start: number; duration?: number }; + } | null>; + eligible: boolean; + needsExtension: boolean; + label: string; + coalesceKey: string; + }): Promise => { + const sharedPath = allChangesSharePath(input.changes, activeCompPath); + const canUseSdk = + !input.needsExtension && + sharedPath !== null && + input.eligible && + input.sdkChanges.every((change) => change !== null); + if (!canUseSdk) return false; + return sdkTimingBatchPersist( + input.sdkChanges.filter((change): change is NonNullable => change !== null), + sharedPath, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: input.label, coalesceKey: input.coalesceKey }, + ); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + const handleTimelineGroupMove = useCallback( (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => { if (changes.length === 0) return Promise.resolve(); @@ -163,38 +214,28 @@ export function useTimelineGroupEditing({ } const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration)); + // needsExtension gates the SDK path (setTiming can't grow the root duration), + // so read the store BEFORE the readout sync below optimistically updates it. const needsExtension = extendRootDurationIfNeeded(maxEnd); + // Optimistic duration readout: content-driven (grow AND shrink), read from + // the just-patched live DOM. See syncPreviewContentDuration. + syncPreviewContentDuration(previewIframeRef.current); const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes); return enqueueGroupOperation("Move timeline clips", async (projectId) => { await options?.beforeTiming; - const sharedPath = allChangesSharePath(changes, activeCompPath); - const sdkChanges = changes.map((change) => - change.element.hfId - ? { hfId: change.element.hfId, timingUpdate: { start: change.start } } - : null, - ); - const canUseSdk = - !needsExtension && - sharedPath !== null && - changes.every((change) => change.track == null) && - sdkChanges.every((change) => change !== null); - if (canUseSdk) { - const handled = await sdkTimingBatchPersist( - sdkChanges.filter((change): change is NonNullable => change !== null), - sharedPath, - sdkSession, - { - editHistory: { recordEdit }, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - compositionPath: activeCompPath, - readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), - }, - { label: "Move timeline clips", coalesceKey }, - ); - if (handled) return; - } + const handledBySdk = await trySdkBatchPersist({ + changes, + sdkChanges: changes.map((change) => + change.element.hfId + ? { hfId: change.element.hfId, timingUpdate: { start: change.start } } + : null, + ), + eligible: changes.every((change) => change.track == null), + needsExtension, + label: "Move timeline clips", + coalesceKey, + }); + if (handledBySdk) return; await persistServerBatch( projectId, @@ -212,50 +253,34 @@ export function useTimelineGroupEditing({ })), coalesceKey, ); - await finishTimelineTimingFallback({ + await finishGroupTimingGsapFallback({ + projectId, iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: maxEnd, reloadPreview, - gsapMutation: () => - foldGsapMutationIntoHistory({ - projectId, - paths: changes.map((change) => targetPathFor(change.element, activeCompPath)), - label: "Move timeline clips", - coalesceKey, - recordEdit, - gsapMutation: async () => { - let mutated = false; - for (const change of changes) { - const delta = change.start - change.element.start; - const domId = change.element.domId; - if (delta === 0 || !domId) continue; - const status = await shiftGsapPositions( - projectId, - targetPathFor(change.element, activeCompPath), - domId, - delta, - ); - mutated = mutated || status.mutated; - } - return { mutated }; - }, - }), - onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err), + label: "Move timeline clips", + errorLabel: "Failed to shift GSAP positions", + coalesceKey, + recordEdit, + activeCompPath, + changes, + resolveChangePath: (element) => targetPathFor(element, activeCompPath), + mutateChange: (change, changePath) => { + const delta = change.start - change.element.start; + const domId = change.element.domId; + if (delta === 0 || !domId) return null; + return shiftGsapPositions(projectId, changePath, domId, delta); + }, }); }); }, [ activeCompPath, - domEditSaveTimestampRef, enqueueGroupOperation, persistServerBatch, previewIframeRef, - projectIdRef, recordEdit, reloadPreview, - sdkSession, - writeProjectFile, + trySdkBatchPersist, ], ); @@ -278,41 +303,31 @@ export function useTimelineGroupEditing({ } const maxEnd = Math.max(...changes.map((change) => change.start + change.duration)); + // needsExtension gates the SDK path (setTiming can't grow the root duration), + // so read the store BEFORE the readout sync below optimistically updates it. const needsExtension = extendRootDurationIfNeeded(maxEnd); + // Optimistic duration readout: content-driven (grow AND shrink), read from + // the just-patched live DOM. See syncPreviewContentDuration. + syncPreviewContentDuration(previewIframeRef.current); const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes); return enqueueGroupOperation("Resize timeline clips", async (projectId) => { await options?.beforeTiming; - const sharedPath = allChangesSharePath(changes, activeCompPath); - const sdkChanges = changes.map((change) => - change.element.hfId - ? { - hfId: change.element.hfId, - timingUpdate: { start: change.start, duration: change.duration }, - } - : null, - ); - const canUseSdk = - !needsExtension && - sharedPath !== null && - changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) && - sdkChanges.every((change) => change !== null); - if (canUseSdk) { - const handled = await sdkTimingBatchPersist( - sdkChanges.filter((change): change is NonNullable => change !== null), - sharedPath, - sdkSession, - { - editHistory: { recordEdit }, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - compositionPath: activeCompPath, - readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), - }, - { label: "Resize timeline clips", coalesceKey }, - ); - if (handled) return; - } + const handledBySdk = await trySdkBatchPersist({ + changes, + sdkChanges: changes.map((change) => + change.element.hfId + ? { + hfId: change.element.hfId, + timingUpdate: { start: change.start, duration: change.duration }, + } + : null, + ), + eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)), + needsExtension, + label: "Resize timeline clips", + coalesceKey, + }); + if (handledBySdk) return; await persistServerBatch( projectId, @@ -328,55 +343,43 @@ export function useTimelineGroupEditing({ })), coalesceKey, ); - await finishTimelineTimingFallback({ + await finishGroupTimingGsapFallback({ + projectId, iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: maxEnd, reloadPreview, - gsapMutation: () => - foldGsapMutationIntoHistory({ + label: "Resize timeline clips", + errorLabel: "Failed to scale GSAP positions", + coalesceKey, + recordEdit, + activeCompPath, + changes, + resolveChangePath: (element) => targetPathFor(element, activeCompPath), + mutateChange: (change, changePath) => { + const domId = change.element.domId; + const timingChanged = + change.start !== change.element.start || change.duration !== change.element.duration; + if (!timingChanged || !domId) return null; + return scaleGsapPositions( projectId, - paths: changes.map((change) => targetPathFor(change.element, activeCompPath)), - label: "Resize timeline clips", - coalesceKey, - recordEdit, - gsapMutation: async () => { - let mutated = false; - for (const change of changes) { - const domId = change.element.domId; - const timingChanged = - change.start !== change.element.start || - change.duration !== change.element.duration; - if (!timingChanged || !domId) continue; - const status = await scaleGsapPositions( - projectId, - targetPathFor(change.element, activeCompPath), - domId, - change.element.start, - change.element.duration, - change.start, - change.duration, - ); - mutated = mutated || status.mutated; - } - return { mutated }; - }, - }), - onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err), + changePath, + domId, + change.element.start, + change.element.duration, + change.start, + change.duration, + ); + }, }); }); }, [ activeCompPath, - domEditSaveTimestampRef, enqueueGroupOperation, persistServerBatch, previewIframeRef, - projectIdRef, recordEdit, reloadPreview, - sdkSession, - writeProjectFile, + trySdkBatchPersist, ], ); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 5df14ed537..dd32c1342c 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag"; -import { useState } from "react"; export { resolveSeekPercent }; -type TimeDisplayMode = "time" | "frame"; /* ── Icon sub-components ─────────────────────────────────────────── */ @@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({ const outPoint = usePlayerStore((s) => s.outPoint); const setInPoint = usePlayerStore.getState().setInPoint; const setOutPoint = usePlayerStore.getState().setOutPoint; - const [timeDisplayMode, setTimeDisplayMode] = useState("time"); + const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); + const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode; const progressFillRef = useRef(null); const progressThumbRef = useRef(null); @@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({ return (
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({ >