From 1cf601202d6c7bd032cf56adef2a9b7d357fb3ed Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 01:11:55 -0400 Subject: [PATCH] feat(studio): batch timeline timing commits Persist group timing edits through one coalesced write per source file. Keep batch timing queued behind any z-index commit for the same gesture. --- .../src/contexts/TimelineEditContext.tsx | 2 + .../src/hooks/timelineEditingHelpers.ts | 60 +++ .../src/hooks/useTimelineEditing.test.tsx | 233 +++++++++++- .../studio/src/hooks/useTimelineEditing.ts | 33 +- .../src/hooks/useTimelineGroupEditing.ts | 350 ++++++++++++++++++ .../player/components/timelineCallbacks.ts | 13 + packages/studio/src/utils/sdkCutover.ts | 45 +++ 7 files changed, 714 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/hooks/useTimelineGroupEditing.ts diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 7f075d783..7b523cbeb 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -32,6 +32,8 @@ export function TimelineEditProvider({ [ value.onMoveElement, value.onResizeElement, + value.onMoveElements, + value.onResizeElements, value.onToggleTrackHidden, value.onToggleElementHidden, value.onBlockedEditAttempt, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index d2414dd45..d266012a2 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -312,6 +312,66 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom input.domEditSaveTimestampRef.current = Date.now(); } +export interface PersistTimelineBatchChange { + element: TimelineElement; + buildPatches: (original: string, target: PatchTarget) => string; +} + +export interface PersistTimelineBatchEditInput { + projectId: string; + activeCompPath: string | null; + label: string; + changes: PersistTimelineBatchChange[]; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + pendingTimelineEditPathRef: React.MutableRefObject>; + coalesceKey?: string; +} + +export async function persistTimelineBatchEdit( + input: PersistTimelineBatchEditInput, +): Promise { + const originals = new Map(); + const patchedByPath = new Map(); + + for (const change of input.changes) { + const targetPath = change.element.sourceFile || input.activeCompPath || "index.html"; + const original = + originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath)); + originals.set(targetPath, original); + + const patchTarget = buildPatchTarget(change.element); + if (!patchTarget) { + throw new Error(`Timeline element ${change.element.id} is missing a patchable target`); + } + + 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}`); + } + patchedByPath.set(targetPath, patched); + } + + const files = Object.fromEntries(patchedByPath); + for (const targetPath of Object.keys(files)) { + input.pendingTimelineEditPathRef.current.add(targetPath); + } + input.domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: input.projectId, + label: input.label, + kind: "timeline", + coalesceKey: input.coalesceKey, + files, + readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path), + writeFile: input.writeProjectFile, + recordEdit: input.recordEdit, + }); + 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}`); diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index daa256a04..cd3f88fdc 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -56,20 +56,23 @@ function timelineElement(input: { track: number; zIndex: number; tag?: string; + start?: number; + duration?: number; + sourceFile?: string; }): TimelineElement { return { id: input.id, domId: input.id, hfId: `hf-${input.id}`, tag: input.tag ?? "div", - start: 0, - duration: 2, + start: input.start ?? 0, + duration: input.duration ?? 2, track: input.track, zIndex: input.zIndex, stackingContextId: "root", parentCompositionId: null, compositionAncestors: ["root"], - sourceFile: "index.html", + sourceFile: input.sourceFile ?? "index.html", timingSource: "authored", }; } @@ -92,10 +95,14 @@ function renderTimelineEditingHook(input: { }): { move: ReturnType["handleTimelineElementMove"]; resize: ReturnType["handleTimelineElementResize"]; + groupMove: ReturnType["handleTimelineGroupMove"]; + groupResize: ReturnType["handleTimelineGroupResize"]; unmount: () => void; } { let move: ReturnType["handleTimelineElementMove"] | null = null; let resize: ReturnType["handleTimelineElementResize"] | null = null; + let groupMove: ReturnType["handleTimelineGroupMove"] | null = null; + let groupResize: ReturnType["handleTimelineGroupResize"] | null = null; function Harness() { const commitRef = useRef(input.onZIndexCommit); @@ -118,6 +125,8 @@ function renderTimelineEditingHook(input: { }); move = hook.handleTimelineElementMove; resize = hook.handleTimelineElementResize; + groupMove = hook.handleTimelineGroupMove; + groupResize = hook.handleTimelineGroupResize; return null; } @@ -130,15 +139,23 @@ function renderTimelineEditingHook(input: { 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()); }, }; } +type TimelineRecordEdit = NonNullable< + Parameters[0]["recordEdit"] +>; + function renderTimelineEditingHookWithLifecycle(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; @@ -228,7 +245,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { 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 recordEdit = vi.fn(async () => {}); const forceReloadSdkSession = vi.fn(); const reloadPreview = vi.fn(); const iframeWindow = iframe.contentWindow; @@ -294,7 +311,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { 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 recordEdit = vi.fn(async () => {}); const forceReloadSdkSession = vi.fn(); const reloadPreview = vi.fn(); const iframeWindow = iframe.contentWindow; @@ -628,7 +645,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); + const recordEdit = vi.fn(async (_entry) => {}); const reloadPreview = vi.fn(); const fetchMock = vi.fn( async ( @@ -739,4 +756,208 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + + it("persists a same-file group move with one write containing every clip timing", async () => { + const source = [ + '
', + '
', + '
', + ].join("\n"); + const iframe = createPreviewIframe([ + { id: "a", track: 0 }, + { id: "b", track: 1 }, + { id: "c", track: 2 }, + ]); + const clips = [ + timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }), + timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }), + timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }), + ]; + 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}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: clips, + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit, + }); + + await act(async () => { + await groupMove([ + { element: clips[0], start: 0.5 }, + { element: clips[1], start: 1.5 }, + { element: clips[2], start: 2.5 }, + ]); + }); + + expect(writeProjectFile).toHaveBeenCalledTimes(1); + const written = writeProjectFile.mock.calls[0]![1] as string; + expect(written).toContain('id="a" data-start="0.5"'); + expect(written).toContain('id="b" data-start="1.5"'); + expect(written).toContain('id="c" data-start="2.5"'); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]); + + unmount(); + }); + + it("partitions a group move by source file while keeping one undo entry", async () => { + const files: Record = { + "index.html": '
', + "scene.html": '
', + }; + const iframe = createPreviewIframe([ + { id: "a", track: 0 }, + { id: "b", track: 1 }, + ]); + const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }); + const b = timelineElement({ + id: "b", + track: 1, + zIndex: 0, + start: 1, + duration: 1, + sourceFile: "scene.html", + }); + 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}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [a, b], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit, + }); + + await act(async () => { + await groupMove([ + { element: a, start: 0.25 }, + { element: b, start: 1.25 }, + ]); + }); + + expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([ + "index.html", + "scene.html", + ]); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"'); + expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"'); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([ + "index.html", + "scene.html", + ]); + + unmount(); + }); + + it("waits for a z-index commit before the group timing write", async () => { + const source = '
'; + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); + let releaseCommit!: () => void; + const zIndexCommit = new Promise((resolve) => { + 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}`); + }), + ); + const { groupMove, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + }); + + let movePromise!: Promise; + await act(async () => { + movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit }); + await flushAsyncWork(); + }); + expect(writeProjectFile).not.toHaveBeenCalled(); + + await act(async () => { + releaseCommit(); + await movePromise; + await flushAsyncWork(); + }); + expect(writeProjectFile).toHaveBeenCalledTimes(1); + + unmount(); + }); + + 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); + + const singleWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const single = renderTimelineEditingHook({ + timelineElements: [clip], + iframe: createPreviewIframe([{ id: "clip", track: 0 }]), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile: singleWrite, + recordEdit: vi.fn(async () => {}), + }); + await act(async () => { + await single.move(clip, { start: 0.5, track: clip.track }); + }); + single.unmount(); + + const groupWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const group = renderTimelineEditingHook({ + timelineElements: [clip], + iframe: createPreviewIframe([{ id: "clip", track: 0 }]), + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile: groupWrite, + recordEdit: vi.fn(async () => {}), + }); + await act(async () => { + await group.groupMove([{ element: clip, start: 0.5 }]); + }); + + expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]); + group.unmount(); + }); }); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index b5ca68e0c..0f78896bb 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,5 +1,3 @@ -// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + -// playback-start resolution). // fallow-ignore-file complexity import { useCallback, useRef } from "react"; import type { TimelineElement } from "../player"; @@ -40,6 +38,7 @@ import { useTimelineElementVisibilityEditing, useTimelineTrackVisibilityEditing, } from "./timelineTrackVisibility"; +import { useTimelineGroupEditing } from "./useTimelineGroupEditing"; import { sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; @@ -47,8 +46,6 @@ type TimelineMoveUpdates = Pick & { stackingReorder?: TimelineStackingReorderIntent | null; }; -// ── Hook ── - export function useTimelineEditing({ projectId, activeCompPath, @@ -101,7 +98,6 @@ export function useTimelineEditing({ }), ) .then(() => { - // Server wrote the file; resync the stale in-memory SDK doc. forceReloadSdkSession?.(); }); editQueueRef.current = queued.catch((error) => { @@ -120,6 +116,21 @@ export function useTimelineEditing({ forceReloadSdkSession, ], ); + const groupEditing = useTimelineGroupEditing({ + activeCompPath, + domEditSaveTimestampRef, + editQueueRef, + forceReloadSdkSession, + isRecordingRef, + pendingTimelineEditPathRef, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + showToast, + writeProjectFile, + }); // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( @@ -148,9 +159,6 @@ export function useTimelineEditing({ const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); }; - // Server-path fallback (no SDK session): persist the attr patch, then - // shift GSAP tween positions on the server. Extending edits can keep the - // iframe live unless a GSAP source rewrite needs a fresh run. const coalesceKey = `timeline-move:${element.hfId ?? element.id}`; const moveFallback = () => enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => { @@ -170,10 +178,6 @@ export function useTimelineEditing({ }); }); const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); - // The z-index reorder above and this timing write target the same file on - // separate save queues, and the timing write is a full-file overwrite. Order - // it after the reorder so it reads disk with the z-index already applied and - // can't clobber it — one ordered writer per gesture (diagonal move+restack). return reorderDone.then(() => { if (sdkSession && element.hfId && !needsExtension) { return sdkTimingPersist( @@ -239,10 +243,6 @@ export function useTimelineEditing({ const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { return buildTimelineResizeTimingPatch(original, target, element, updates); }; - // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). - // The second clause fires because trimming the start of a clip that has a - // playback-start attribute implicitly shifts that in-point — which the SDK - // setTiming op can't express — so those resizes must take the server path. const hasPbsAdjustment = updates.playbackStart != null || (updates.start !== element.start && element.playbackStart != null); @@ -589,5 +589,6 @@ export function useTimelineEditing({ handleTimelineAssetDrop, handleTimelineFileDrop, handleBlockedTimelineEdit, + ...groupEditing, }; } diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts new file mode 100644 index 000000000..77a6e814f --- /dev/null +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -0,0 +1,350 @@ +import { useCallback, type MutableRefObject, type RefObject } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import type { TimelineElement } from "../player"; +import { sdkTimingBatchPersist } from "../utils/sdkCutover"; +import { + buildTimelineMoveTimingPatch, + buildTimelineResizeTimingPatch, + extendRootDurationIfNeeded, + finishTimelineTimingFallback, + formatTimelineAttributeNumber, + patchIframeDomTiming, + persistTimelineBatchEdit, + readFileContent, + scaleGsapPositions, + shiftGsapPositions, + type PersistTimelineBatchChange, + type RecordEditInput, +} from "./timelineEditingHelpers"; + +export interface TimelineGroupMoveChange { + element: TimelineElement; + start: number; +} + +export interface TimelineGroupResizeChange { + element: TimelineElement; + start: number; + duration: number; + playbackStart?: number; +} + +export interface TimelineGroupCommitOptions { + beforeTiming?: Promise; + coalesceKey?: string; +} + +interface UseTimelineGroupEditingOptions { + activeCompPath: string | null; + domEditSaveTimestampRef: MutableRefObject; + editQueueRef: MutableRefObject>; + forceReloadSdkSession?: () => void; + isRecordingRef?: RefObject; + pendingTimelineEditPathRef: MutableRefObject>; + previewIframeRef: RefObject; + projectIdRef: MutableRefObject; + recordEdit: (input: RecordEditInput) => Promise; + reloadPreview: () => void; + sdkSession?: Composition | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; +} + +function targetPathFor(element: TimelineElement, activeCompPath: string | null): string { + return element.sourceFile || activeCompPath || "index.html"; +} + +function allChangesSharePath( + changes: readonly { element: TimelineElement }[], + activeCompPath: string | null, +): string | null { + const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null; + if (!firstPath) return null; + return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath) + ? firstPath + : null; +} + +function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string { + return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string { + return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`; +} + +function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean { + return ( + change.playbackStart != null || + (change.start !== change.element.start && change.element.playbackStart != null) + ); +} + +export function useTimelineGroupEditing({ + activeCompPath, + domEditSaveTimestampRef, + editQueueRef, + forceReloadSdkSession, + isRecordingRef, + pendingTimelineEditPathRef, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + showToast, + writeProjectFile, +}: UseTimelineGroupEditingOptions) { + const enqueueGroupOperation = useCallback( + (label: string, operation: (projectId: string) => Promise): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.resolve(); + } + const projectId = projectIdRef.current; + if (!projectId) return Promise.resolve(); + const queued = editQueueRef.current + .then(() => operation(projectId)) + .catch((error) => { + console.error(`[Timeline] Failed to persist: ${label}`, error); + }); + editQueueRef.current = queued; + return queued; + }, + [editQueueRef, isRecordingRef, projectIdRef, showToast], + ); + + const persistServerBatch = useCallback( + async ( + projectId: string, + label: string, + batchChanges: PersistTimelineBatchChange[], + coalesceKey: string, + ) => { + await persistTimelineBatchEdit({ + projectId, + activeCompPath, + label, + changes: batchChanges, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + coalesceKey, + }); + forceReloadSdkSession?.(); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + forceReloadSdkSession, + pendingTimelineEditPathRef, + recordEdit, + writeProjectFile, + ], + ); + + const handleTimelineGroupMove = useCallback( + (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + patchIframeDomTiming(previewIframeRef.current, change.element, [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ]); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + 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 && 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; + } + + await persistServerBatch( + projectId, + "Move timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + 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), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + const handleTimelineGroupResize = useCallback( + (changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => { + if (changes.length === 0) return Promise.resolve(); + for (const change of changes) { + const liveAttrs: Array<[string, string]> = [ + ["data-start", formatTimelineAttributeNumber(change.start)], + ["data-duration", formatTimelineAttributeNumber(change.duration)], + ]; + if (change.playbackStart != null) { + const liveAttr = + change.element.playbackStartAttr === "playback-start" + ? "data-playback-start" + : "data-media-start"; + liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]); + } + patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs); + } + + const maxEnd = Math.max(...changes.map((change) => change.start + change.duration)); + const needsExtension = extendRootDurationIfNeeded(maxEnd); + 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; + } + + await persistServerBatch( + projectId, + "Resize timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineResizeTimingPatch(original, target, change.element, { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }), + })), + coalesceKey, + ); + await finishTimelineTimingFallback({ + iframe: previewIframeRef.current, + needsExtension, + rootDurationSeconds: maxEnd, + reloadPreview, + 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), + }); + }); + }, + [ + activeCompPath, + domEditSaveTimestampRef, + enqueueGroupOperation, + persistServerBatch, + previewIframeRef, + projectIdRef, + recordEdit, + reloadPreview, + sdkSession, + writeProjectFile, + ], + ); + + return { handleTimelineGroupMove, handleTimelineGroupResize }; +} diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 345c7602c..6b8602e39 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -2,6 +2,11 @@ // fallow-ignore-file dead-code import type { TimelineElement } from "../store/playerStore"; import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing"; +import type { + TimelineGroupCommitOptions, + TimelineGroupMoveChange, + TimelineGroupResizeChange, +} from "../../hooks/useTimelineGroupEditing"; /** * Shared callback signatures for timeline editing operations. @@ -34,6 +39,14 @@ export interface TimelineEditCallbacks { element: TimelineElement, updates: Pick, ) => Promise | void; + onMoveElements?: ( + changes: TimelineGroupMoveChange[], + options?: TimelineGroupCommitOptions, + ) => Promise | void; + onResizeElements?: ( + changes: TimelineGroupResizeChange[], + options?: TimelineGroupCommitOptions, + ) => Promise | void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void; onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 7039bf420..c13ca519e 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -196,6 +196,51 @@ export async function sdkTimingPersist( } } +export async function sdkTimingBatchPersist( + changes: Array<{ + hfId: string; + timingUpdate: { start?: number; duration?: number; trackIndex?: number }; + }>, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + const timingSrc = deps.readProjectFile; + for (const change of changes) { + void recordResolverParity( + sdkSession, + change.hfId, + "setTiming", + timingSrc ? () => timingSrc(targetPath) : undefined, + ); + } + if (!STUDIO_SDK_CUTOVER_ENABLED) return false; + if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false; + if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false; + try { + const serializedBefore = sdkSession.serialize(); + sdkSession.batch(() => { + for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate); + }); + const after = sdkSession.serialize(); + if (after === serializedBefore) return false; + const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore); + await persistSdkSerialize(after, targetPath, undoBefore, deps, options); + trackStudioEvent("sdk_cutover_success", { + hfId: changes[0]?.hfId ?? null, + opCount: changes.length, + }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: changes[0]?.hfId ?? null, + error: String(err), + }); + return false; + } +} + type SdkGsapTweenOp = | { kind: "add"; target: string; spec: GsapTweenSpec } | { kind: "set"; animationId: string; properties: Partial }