fix(studio): address PR #2347 review findings (rounds 1-2)

Review 1 (restore commit):
- asset reveal now clears any open preview overlay (stuck-overlay repro:
  preview on A, click already-added B — A stayed open over the reveal)
- duration readout rolls back on failed persist: captureDurationRollback
  snapshots store + live root data-duration before the optimistic sync and
  restores both in every move/resize/delete/group catch (golden's
  previousDuration pattern)
- asset preview opened during running playback dismisses immediately (the
  RAF loop bypasses the store, so the subscription alone never fired)
- persistTimelineBatchEdit resolves the target (findTagByTarget) before
  treating identical output as a no-op — a mistargeted member now throws
  like the single-element path instead of being silently dropped
- a post-mutation history-fold failure no longer suppresses the preview
  sync: fold errors are surfaced separately and the rewritten script still
  syncs (previously the preview kept stale GSAP positions with no recovery)
- timelineRevealScroll guards degenerate viewports (windowSize <= 0)
- CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches

Review 2 (single-source-of-truth pass):
- createTimelineElementFromManifestClip — the one manifest->element
  boundary — now carries authoredTrack and stackingContextId; expanded
  sub-comp children preserve both (authoredTrack in their OWN file's space)
- authoredTrackForLane scopes occupants to the dragged clip's sourceFile
  (a foreign file's authored values are a different coordinate space);
  nearest-same-file-lane offset fallback
- optimistic store updates mirror the persisted track into authoredTrack
  (and roll it back on failure), so consecutive drags before a reload
  resolve from fresh data
- spill sub-lanes: documented decision — dropping onto a spill lane is a
  legitimate same-track join (occupants share the authored track by
  construction); false 'never a lane-move target' docstring rewritten
- single-element fallback persists vertical-only moves (early return now
  requires neither start nor track changed; live DOM patch includes
  data-track-index)
- canonical contextKey helper for stacking-context normalization
- new pipeline test crosses the REAL factory boundary (sparse authored
  tracks -> factory -> expansion -> normalize -> drag commit -> persisted
  attribute), no injected fields
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 760b88a6f3
commit a33b3f35e1
23 changed files with 1326 additions and 253 deletions
@@ -184,6 +184,19 @@ describe("persistTimelineBatchEdit", () => {
expect(writes).toHaveLength(0);
});
it("throws on a mistargeted member instead of silently dropping it", async () => {
// A member whose target does not resolve in the source (stale id) patches
// to the identical string too — but that is a targeting FAILURE, not an
// already-at-target no-op, and must abort the batch like the single path.
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await expect(
persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
).rejects.toThrow("Unable to patch timeline element ghost in index.html");
expect(writes).toHaveLength(0);
});
});
describe("deleteSelectedKeyframes", () => {
@@ -1,5 +1,5 @@
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import {
formatTimelineAttributeNumber,
type TimelineStackingReorderIntent,
@@ -319,10 +319,17 @@ export async function persistTimelineBatchEdit(
}
const current = patchedByPath.get(targetPath) ?? original;
// Resolve the target FIRST: byte-identical output below is only a legit
// no-op when the member actually resolved in the source. A mistargeted
// member (stale id/selector) must fail loudly like the single-edit path,
// not be silently dropped as "already at target".
if (!findTagByTarget(current, patchTarget)) {
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
}
const patched = change.buildPatches(current, patchTarget);
// A member whose attributes already hold the target values patches to the
// identical string — e.g. a track-insert renumber where one clip's lane is
// already correct. That is a legitimate no-op, not a targeting failure:
// The target resolved, so a member whose attributes already hold the target
// values patches to the identical string — e.g. a track-insert renumber
// where one clip's lane is already correct. That is a legitimate no-op:
// skip it instead of aborting (and rolling back) the whole batch.
if (patched === current) continue;
patchedByPath.set(targetPath, patched);
@@ -0,0 +1,150 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import {
captureDurationRollback,
finishClipTimingFallback,
readFileContent,
shiftGsapPositions,
} from "./timelineTimingSync";
afterEach(() => {
usePlayerStore.getState().reset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function requestUrl(input: Parameters<typeof fetch>[0]): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
/**
* Stub fetch: `/files/` reads return contents from the queue (repeating the
* last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
* Error rejects the call with a non-ok response).
*/
function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
let readIndex = 0;
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/files/")) {
const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
readIndex += 1;
return jsonResponse({ content });
}
if (url.includes("/gsap-mutations/")) {
if (gsapBody instanceof Error) {
return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
}
return jsonResponse(gsapBody);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function clipFallbackInput(overrides: {
reloadPreview: () => void;
recordEdit: (edit: unknown) => Promise<void>;
}) {
return {
iframe: null,
reloadPreview: overrides.reloadPreview,
projectId: "p1",
targetPath: "index.html",
domId: "clip",
label: "Move timeline clip",
recordEdit: overrides.recordEdit as never,
edit: { kind: "shift", delta: 1 } as const,
};
}
describe("finishClipTimingFallback failure domains", () => {
it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
// Mutation succeeds (server rewrite already on disk), but recordEdit (the
// fold step) throws. The preview MUST still be synced — otherwise stale
// GSAP positions stay on screen. iframe=null makes the sync observable as
// one reloadPreview() call.
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
const reloadPreview = vi.fn();
const foldError = new Error("history fold failed");
const recordEdit = vi.fn(async () => {
throw foldError;
});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).toHaveBeenCalledTimes(1);
// The fold error is surfaced, not swallowed silently.
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
});
it("skips the preview sync when the MUTATION itself fails", async () => {
stubFetch(["<before>"], new Error("mutation blew up"));
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).not.toHaveBeenCalled();
expect(reloadPreview).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledTimes(1);
});
it("records the fold and syncs on the happy path", async () => {
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
const reloadPreview = vi.fn();
const recordEdit = vi.fn(async () => {});
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});
describe("captureDurationRollback", () => {
it("restores the pre-sync duration only when it changed", () => {
usePlayerStore.getState().setDuration(4);
const rollback = captureDurationRollback(null);
// No change → rollback is a no-op (no spurious set).
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
usePlayerStore.getState().setDuration(9);
rollback();
expect(usePlayerStore.getState().duration).toBe(4);
});
});
describe("fetch URL encoding (user-influenced segments)", () => {
it("URI-encodes the projectId in file reads", async () => {
const fetchMock = stubFetch(["<html>"], {});
await readFileContent("p/../evil", "index.html");
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%2F..%2Fevil/files/index.html",
);
});
it("URI-encodes the projectId in GSAP mutation calls", async () => {
const fetchMock = stubFetch([], { mutated: false, scriptText: null });
await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
"/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
);
});
});
+55 -20
View File
@@ -13,7 +13,7 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
@@ -55,6 +55,23 @@ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): vo
}
}
/**
* Snapshot the store duration BEFORE an optimistic duration update
* (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
* rollback closure for the persist-failure path. The rollback restores BOTH
* the store duration and the live root's `data-duration` — otherwise a failed
* write leaves the readout/seek bar and the live root advertising a duration
* the saved source never got. No-op when the duration never changed.
*/
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
const previousDuration = usePlayerStore.getState().duration;
return () => {
if (usePlayerStore.getState().duration === previousDuration) return;
usePlayerStore.getState().setDuration(previousDuration);
patchIframeRootDuration(iframe, previousDuration);
};
}
/**
* The bits of the server GSAP-mutation response the timeline edit path needs.
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
@@ -151,6 +168,12 @@ const GSAP_HISTORY_COALESCE_MS = 10_000;
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
* folding both writes into one undo step. Returns the mutation status for caller reloads.
*
* Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
* the caller must skip the preview sync), but a failure in the history-FOLD step
* (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
* the mutation status is still returned — the server rewrite already landed on disk, so
* the caller must still sync the preview or it shows stale GSAP positions.
*/
async function foldGsapMutationIntoHistory(input: {
projectId: string;
@@ -159,30 +182,37 @@ async function foldGsapMutationIntoHistory(input: {
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
gsapMutation: () => Promise<GsapMutationStatus>;
onFoldError: (error: unknown) => void;
}): Promise<GsapMutationStatus> {
const uniquePaths = [...new Set(input.paths)];
const before = new Map<string, string>();
// A `before`-snapshot failure propagates like a mutation failure: the mutation
// has not run yet, so nothing landed on disk and skipping the sync is correct.
for (const path of uniquePaths) {
before.set(path, await readFileContent(input.projectId, path));
}
const status = await input.gsapMutation();
if (status.mutated) {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
try {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
}
}
}
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
}
} catch (error) {
input.onFoldError(error);
}
}
return status;
@@ -201,7 +231,7 @@ export async function shiftGsapPositions(
): Promise<GsapMutationStatus> {
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -233,7 +263,7 @@ export async function scaleGsapPositions(
if (oldStart === newStart && oldDuration === newDuration)
return { mutated: false, scriptText: null };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -298,6 +328,8 @@ export function finishClipTimingFallback(input: {
edit.to.start,
edit.to.duration,
);
const onGsapError = (err: unknown) =>
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
return finishTimelineTimingFallback({
iframe: input.iframe,
reloadPreview: input.reloadPreview,
@@ -311,9 +343,10 @@ export function finishClipTimingFallback(input: {
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () => runMutation(projectId, domId),
onFoldError: onGsapError,
})
: undefined,
onGsapError: (err) => console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err),
onGsapError,
});
}
@@ -346,6 +379,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
const otherFileChanged = input.changes.some(
(change) => input.resolveChangePath(change.element) !== activePath,
);
const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
await finishTimelineTimingFallback({
iframe: input.iframe,
reloadPreview: input.reloadPreview,
@@ -371,7 +405,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
}
return { mutated, scriptText: otherFileChanged ? null : scriptText };
},
onFoldError: onGsapError,
}),
onGsapError: (err) => console.error(`[Timeline] ${input.errorLabel}`, err),
onGsapError,
});
}
@@ -0,0 +1,175 @@
// Asset-drop handlers for the timeline: drop an existing project asset at a
// placement, or upload dragged-in OS files and place them sequentially.
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
// 600-line cap.
import { useCallback, type MutableRefObject, type RefObject } from "react";
import type { TimelineElement } from "../player";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
fitTimelineAssetGeometry,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetCompositionSize,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { generateId } from "../utils/generateId";
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
import { readFileContent } from "./timelineTimingSync";
interface UseTimelineAssetDropOpsOptions {
projectIdRef: MutableRefObject<string | null>;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: RefObject<boolean>;
forceReloadSdkSession?: () => void;
}
export function useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineAssetDropOpsOptions) {
// fallow-ignore-next-line complexity
const handleTimelineAssetDrop = useCallback(
// fallow-ignore-next-line complexity
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
return;
}
const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
const duration =
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
? durationOverride
: await resolveDroppedAssetDuration(pid, assetPath, kind);
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const newElementZIndex = Math.max(1, relevantElements.length + 1);
const patchedContent = insertTimelineAssetIntoSource(
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
hfId: `hf-${generateId()}`,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: fitTimelineAssetGeometry(
null,
resolveTimelineAssetCompositionSize(originalContent),
),
}),
);
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Add timeline asset",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
forceReloadSdkSession?.();
reloadPreview();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
showToast(message);
}
},
[
projectIdRef,
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
// fallow-ignore-next-line complexity
const handleTimelineFileDrop = useCallback(
// fallow-ignore-next-line complexity
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
if (uploaded.length === 0) return;
const durations: number[] = [];
for (const assetPath of uploaded) {
const kind = getTimelineAssetKind(assetPath);
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
durations.push(Number(formatTimelineAttributeNumber(duration)));
}
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
assetPath,
placements[index] ?? placements[0],
durations[index],
);
}
},
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
);
return { handleTimelineAssetDrop, handleTimelineFileDrop };
}
@@ -639,6 +639,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
unmount();
});
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
// pure lane change routed through onMoveElement (no onMoveElements wired)
// wrote NOTHING — the lane snapped back on the next reload.
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
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 () => {});
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
const { move, unmount } = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: commit,
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
});
await act(async () => {
// Vertical-only: same start, new track (already authored-space on this path).
await move(clip, { start: clip.start, track: 2 });
});
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
// Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
// ...and the file write carries the new data-track-index with start intact.
expect(writeProjectFile).toHaveBeenCalled();
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
unmount();
});
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
const iframe = createPreviewIframe([
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
@@ -863,3 +898,150 @@ describe("useTimelineEditing timeline z-index reorder", () => {
group.unmount();
});
});
describe("useTimelineEditing duration rollback on failed persist", () => {
const ROLLBACK_SOURCE = [
`<div data-composition-id="main" data-duration="4">`,
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
`</div>`,
].join("\n");
/** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
function createRootedIframe(): HTMLIFrameElement {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
doc.body.innerHTML = ROLLBACK_SOURCE;
return iframe;
}
function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
return iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-duration");
}
function setupFailedPersist() {
const iframe = createRootedIframe();
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
const writeError = new Error("write failed");
const writeProjectFile = vi
.fn<(...args: unknown[]) => Promise<void>>()
.mockRejectedValue(writeError);
stubProjectFetch(ROLLBACK_SOURCE);
usePlayerStore.getState().setDuration(4);
vi.spyOn(console, "error").mockImplementation(() => {});
const hook = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
reloadPreview: vi.fn(),
});
return { iframe, clip, hook, writeError };
}
it("rolls back the store duration and live root when a move persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
// Move past the end: the optimistic sync grows the readout to 5s.
await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a resize persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook
.resize(clip, { start: 0, duration: 6, playbackStart: undefined })
.catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a group move persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("rolls back the store duration and live root when a group resize persist fails", async () => {
const { iframe, clip, hook, writeError } = setupFailedPersist();
let rejection: unknown;
await act(async () => {
await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
rejection = error;
});
await flushAsyncWork();
});
expect(rejection).toBe(writeError);
expect(usePlayerStore.getState().duration).toBe(4);
expect(rootDurationAttr(iframe)).toBe("4");
hook.unmount();
});
it("keeps the grown duration when the persist succeeds", async () => {
const { iframe, clip, hook } = setupFailedPersist();
// Same harness, but with a write that succeeds this time.
hook.unmount();
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const succeeding = renderTimelineEditingHook({
timelineElements: [clip],
iframe,
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
projectId: "p1",
writeProjectFile,
recordEdit: vi.fn(async () => {}),
reloadPreview: vi.fn(),
});
await act(async () => {
await succeeding.move(clip, { start: 3, track: clip.track });
await flushAsyncWork();
});
expect(usePlayerStore.getState().duration).toBe(5);
expect(rootDurationAttr(iframe)).toBe("5");
succeeding.unmount();
});
});
+116 -200
View File
@@ -3,25 +3,11 @@ import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
buildTimelineFileDropPlacements,
fitTimelineAssetGeometry,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetCompositionSize,
resolveTimelineAssetSrc,
} from "../utils/timelineAssetDrop";
import { generateId } from "../utils/generateId";
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
import {
getTimelineElementLabel,
collectHtmlIds,
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import { getTimelineElementLabel } from "../utils/studioHelpers";
import {
applyTimelineStackingReorder,
buildPatchTarget,
@@ -33,6 +19,7 @@ import {
buildTimelineResizeTimingPatch,
} from "./timelineEditingHelpers";
import {
captureDurationRollback,
finishClipTimingFallback,
readFileContent,
syncPreviewContentDuration,
@@ -142,11 +129,22 @@ export function useTimelineEditing({
(element: TimelineElement, updates: TimelineMoveUpdates) => {
const targetPath = element.sourceFile || activeCompPath || "index.html";
const startChanged = updates.start !== element.start;
// A vertical-only lane move arrives with start unchanged but track changed
// (on this single-element path the drag commit has already folded the
// AUTHORED persist track into updates.track). It must persist like any
// other move — early-returning on !startChanged alone silently dropped
// the file write, so the lane snapped back on reload.
const trackChanged = updates.track !== element.track;
if (startChanged) {
patchIframeDomTiming(previewIframeRef.current, element, [
["data-start", formatTimelineAttributeNumber(updates.start)],
]);
if (startChanged || trackChanged) {
const liveAttrs: Array<[string, string]> = [];
if (startChanged) {
liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
}
if (trackChanged) {
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
}
const reorderDone = applyTimelineStackingReorder({
@@ -158,8 +156,11 @@ export function useTimelineEditing({
commit: handleDomZIndexReorderCommitRef?.current,
});
if (!startChanged) return reorderDone;
if (!startChanged && !trackChanged) return reorderDone;
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
@@ -167,7 +168,7 @@ export function useTimelineEditing({
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
// Persist lane changes too — data-start-only writes let reload snap the lane back.
const track = updates.track !== element.track ? updates.track : undefined;
const track = trackChanged ? updates.track : undefined;
return buildTimelineMoveTimingPatch(
original,
target,
@@ -193,30 +194,38 @@ export function useTimelineEditing({
edit: { kind: "shift", delta: updates.start - element.start },
}),
);
return reorderDone.then(() => {
if (sdkSession && element.hfId && !needsExtension) {
return sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing move
// restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return moveFallback();
});
}
return moveFallback();
});
return reorderDone
.then(() => {
// The SDK setTiming path writes start only — a lane change must take
// the fallback, whose patch builder writes data-track-index too.
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
return sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing move
// restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Move timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return moveFallback();
});
}
return moveFallback();
})
.catch((error) => {
// Failed persist: revert the optimistic duration readout + live root.
rollbackDuration();
throw error;
});
},
[
previewIframeRef,
@@ -253,6 +262,9 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
@@ -287,28 +299,33 @@ export function useTimelineEditing({
},
}),
);
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
return sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start, duration: updates.duration },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing
// resize restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return resizeFallback();
});
}
return resizeFallback();
const persistDone =
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
? sdkTimingPersist(
element.hfId,
targetPath,
{ start: updates.start, duration: updates.duration },
sdkSession,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
// Capture on-disk bytes as the undo `before` so undoing a timing
// resize restores the file verbatim, not a normalized full-DOM re-emit.
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
},
{ label: "Resize timeline clip", coalesceKey },
).then((handled) => {
if (!handled) return resizeFallback();
})
: resizeFallback();
return persistDone.catch((error) => {
// Failed persist: revert the optimistic duration readout + live root.
rollbackDuration();
throw error;
});
},
[
previewIframeRef,
@@ -396,21 +413,28 @@ export function useTimelineEditing({
// durations are runtime-truncated.
const deleteContentEnd = furthestClipEndFromSource(removedContent);
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
// Optimistically reflect the shrunk length in the readout/seek bar.
// Optimistically reflect the shrunk length in the readout/seek bar,
// rolling it back if the persist below fails (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(deleteContentEnd);
}
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
try {
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
} catch (error) {
rollbackDuration();
throw error;
}
usePlayerStore
.getState()
@@ -436,131 +460,23 @@ export function useTimelineEditing({
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
previewIframeRef,
],
);
// fallow-ignore-next-line complexity
const handleTimelineAssetDrop = useCallback(
// fallow-ignore-next-line complexity
async (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
return;
}
const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
const duration =
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
? durationOverride
: await resolveDroppedAssetDuration(pid, assetPath, kind);
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const newElementZIndex = Math.max(1, relevantElements.length + 1);
const patchedContent = insertTimelineAssetIntoSource(
originalContent,
buildTimelineAssetInsertHtml({
id: newId,
hfId: `hf-${generateId()}`,
assetPath: resolvedAssetSrc,
kind,
start: normalizedStart,
duration: normalizedDuration,
track: placement.track,
zIndex: newElementZIndex,
geometry: fitTimelineAssetGeometry(
null,
resolveTimelineAssetCompositionSize(originalContent),
),
}),
);
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Add timeline asset",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
forceReloadSdkSession?.();
reloadPreview();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
showToast(message);
}
},
[
activeCompPath,
recordEdit,
showToast,
timelineElements,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
// fallow-ignore-next-line complexity
const handleTimelineFileDrop = useCallback(
// fallow-ignore-next-line complexity
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
if (uploaded.length === 0) return;
const durations: number[] = [];
for (const assetPath of uploaded) {
const kind = getTimelineAssetKind(assetPath);
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
durations.push(Number(formatTimelineAttributeNumber(duration)));
}
const placements = buildTimelineFileDropPlacements(
placement ?? { start: 0, track: 0 },
durations,
);
for (const [index, assetPath] of uploaded.entries()) {
await handleTimelineAssetDrop(
assetPath,
placements[index] ?? placements[0],
durations[index],
);
}
},
[handleTimelineAssetDrop, uploadProjectFiles, isRecordingRef, showToast],
);
const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
});
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
@@ -13,6 +13,7 @@ import {
type RecordEditInput,
} from "./timelineEditingHelpers";
import {
captureDurationRollback,
finishGroupTimingGsapFallback,
readFileContent,
scaleGsapPositions,
@@ -223,6 +224,9 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
@@ -276,6 +280,11 @@ export function useTimelineGroupEditing({
return shiftGsapPositions(projectId, changePath, domId, delta);
},
});
}).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback.
rollbackDuration();
throw error;
});
},
[
@@ -308,6 +317,9 @@ export function useTimelineGroupEditing({
}
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
// Snapshot the duration BEFORE the optimistic updates below so a failed
// persist can roll the readout + live root back (see captureDurationRollback).
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(maxEnd);
@@ -371,6 +383,11 @@ export function useTimelineGroupEditing({
);
},
});
}).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback.
rollbackDuration();
throw error;
});
},
[