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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 1d858f004d
commit 1cf601202d
7 changed files with 714 additions and 22 deletions
@@ -32,6 +32,8 @@ export function TimelineEditProvider({
[
value.onMoveElement,
value.onResizeElement,
value.onMoveElements,
value.onResizeElements,
value.onToggleTrackHidden,
value.onToggleElementHidden,
value.onBlockedEditAttempt,
@@ -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<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
coalesceKey?: string;
}
export async function persistTimelineBatchEdit(
input: PersistTimelineBatchEditInput,
): Promise<void> {
const originals = new Map<string, string>();
const patchedByPath = new Map<string, string>();
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<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
@@ -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<typeof useTimelineEditing>["handleTimelineElementMove"];
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
unmount: () => void;
} {
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
let groupResize: ReturnType<typeof useTimelineEditing>["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<typeof renderTimelineEditingHook>[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<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(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<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(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<void>>().mockResolvedValue(undefined);
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const recordEdit = vi.fn(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(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 = [
'<div id="a" data-start="0" data-duration="1"></div>',
'<div id="b" data-start="1" data-duration="1"></div>',
'<div id="c" data-start="2" data-duration="1"></div>',
].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<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
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<string, string> = {
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
"scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
};
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<void>>(async () => {});
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
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 = '<div id="clip" data-start="0" data-duration="1"></div>';
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<void>((resolve) => {
releaseCommit = resolve;
});
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
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<unknown>;
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 = '<div id="clip" data-start="0" data-duration="1"></div>';
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
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<void>>(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<void>>(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();
});
});
+17 -16
View File
@@ -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<TimelineElement, "start" | "track"> & {
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,
};
}
@@ -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<void>;
coalesceKey?: string;
}
interface UseTimelineGroupEditingOptions {
activeCompPath: string | null;
domEditSaveTimestampRef: MutableRefObject<number>;
editQueueRef: MutableRefObject<Promise<unknown>>;
forceReloadSdkSession?: () => void;
isRecordingRef?: RefObject<boolean>;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
previewIframeRef: RefObject<HTMLIFrameElement | null>;
projectIdRef: MutableRefObject<string | null>;
recordEdit: (input: RecordEditInput) => Promise<void>;
reloadPreview: () => void;
sdkSession?: Composition | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
}
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<void>): Promise<void> => {
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<typeof change> => 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<typeof change> => 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 };
}
@@ -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<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onMoveElements?: (
changes: TimelineGroupMoveChange[],
options?: TimelineGroupCommitOptions,
) => Promise<void> | void;
onResizeElements?: (
changes: TimelineGroupResizeChange[],
options?: TimelineGroupCommitOptions,
) => Promise<void> | void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
+45
View File
@@ -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<boolean> {
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<GsapTweenSpec> }