mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> }
|
||||
|
||||
Reference in New Issue
Block a user