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
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
document.body.innerHTML = "";
usePlayerStore.getState().reset();
useAssetPreviewStore.getState().clearPreviewAsset();
});
function mountOverlay(): void {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<AssetPreviewOverlay />);
});
}
describe("AssetPreviewOverlay playback dismissal", () => {
it("dismisses immediately when opened while playback is ALREADY running", () => {
// The RAF playback loop bypasses the store, so no post-open store change
// will arrive — the dismiss check must be level-triggered, not edge-triggered.
usePlayerStore.setState({ isPlaying: true });
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("stays open when the playhead is idle", () => {
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
it("still dismisses when playback starts AFTER the preview opened", () => {
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
});
it("still dismisses when the playhead is scrubbed after opening", () => {
usePlayerStore.setState({ currentTime: 2 });
mountOverlay();
act(() => {
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
});
act(() => {
usePlayerStore.setState({ currentTime: 4.5 });
});
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
});
});
@@ -101,7 +101,15 @@ export function AssetPreviewOverlay() {
// so a stale render can never dismiss against the wrong reference time.
useEffect(() => {
if (!previewAsset) return;
const openedTime = usePlayerStore.getState().currentTime;
const opened = usePlayerStore.getState();
// Level-triggered, not edge-triggered: a preview opened while playback is
// ALREADY running gets no store change to react to (the RAF loop bypasses
// the store), so evaluate the current state once before subscribing.
if (opened.isPlaying) {
clearPreviewAsset();
return;
}
const openedTime = opened.currentTime;
return usePlayerStore.subscribe((state) => {
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
});
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { AssetCard } from "./AssetCard";
import { AudioRow } from "./AudioRow";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
document.body.innerHTML = "";
usePlayerStore.getState().reset();
useAssetPreviewStore.getState().clearPreviewAsset();
vi.restoreAllMocks();
});
function mount(node: React.ReactElement): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(node);
});
return host;
}
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
}
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
function clickCard(host: HTMLElement): void {
const card = host.querySelector('[draggable="true"]');
if (!card) throw new Error("Expected a draggable card root");
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
act(() => {
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
});
}
describe("AssetCard click behavior", () => {
const cardProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
// or it stays stuck over the canvas while the timeline reveals the clip.
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
});
it("opens the preview overlay for a not-yet-added asset", () => {
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
});
});
describe("AudioRow click behavior", () => {
const rowProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
usePlayerStore
.getState()
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
});
it("opens the preview overlay for a not-yet-added audio asset", () => {
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
});
});
@@ -136,6 +136,7 @@ export function AssetCard({
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -151,6 +152,9 @@ export function AssetCard({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
// Dismiss any open preview overlay (from another asset) — the reveal
// must not leave a stale preview card floating over the canvas.
clearPreviewAsset();
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
@@ -161,7 +165,16 @@ export function AssetCard({
// Not added (or no matching clip found) → preview overlay
setPreviewAsset(asset, projectId);
},
[used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
[
used,
elements,
asset,
projectId,
setSelectedElementId,
requestClipReveal,
setPreviewAsset,
clearPreviewAsset,
],
);
return (
@@ -46,6 +46,7 @@ export function AudioRow({
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -60,6 +61,9 @@ export function AudioRow({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
// Dismiss any open preview overlay (from another asset) — the reveal
// must not leave a stale preview card floating over the canvas.
clearPreviewAsset();
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
@@ -70,7 +74,16 @@ export function AudioRow({
// Not added → preview overlay (audio player)
setPreviewAsset(asset, projectId);
},
[used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
[
used,
elements,
asset,
projectId,
setSelectedElementId,
requestClipReveal,
setPreviewAsset,
clearPreviewAsset,
],
);
useEffect(() => {
@@ -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;
});
},
[
@@ -232,13 +232,75 @@ describe("commitDraggedClipMove", () => {
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
// Store stays in display-lane space...
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1 });
// Store stays in display-lane space, but authoredTrack is refreshed to the
// value just written to the file so a SECOND drag before any reload resolves
// authored tracks from current data, not the stale pre-edit value.
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
// ...while the persist is translated to the target lane's authored track.
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 20, track: 2 });
});
it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
// Expanded sub-comp children live on synthetic display lanes next to host
// clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
// occupied by a clip from a DIFFERENT file must never lend its authored
// value. Here lane 1 holds both a host-file clip (authored 12) and a
// same-file sibling (authored 7): the sibling answers.
const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, hostClip];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3).toEqual({ start: 0, track: 7 });
});
it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
// Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
// and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
// only another file's clip. Persisting 6 (the display integer) or 12 (the
// foreign authored value) would corrupt the sparse file; the fallback
// offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
// one lane further down → 8.
const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, foreign];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
{ elements, trackOrder: [4, 5, 6] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3.track).not.toBe(6); // not the display-lane integer
expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
expect(map.c3).toEqual({ start: 0, track: 8 });
});
it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
// Authored track 2 holds two time-overlapping clips, which packTrackLanes
// spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
// 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
// persisted value is the shared authored track (2), even though the clip
// may re-pack onto a different sub-lane on the next normalize.
const elements = normalizeToZones([
{ ...el("a", 0, 30, 3), authoredTrack: 1 },
{ ...el("b1", 2, 0, 5), authoredTrack: 2 },
{ ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
]);
expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
const spillLane = elements.find((e) => e.id === "b2")!.track;
const { onMoveElements } = runClipMove(
drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
{ elements, trackOrder: [0, 1, 2] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 30, track: 2 });
});
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
@@ -121,12 +121,25 @@ function persistMoveEdits(
key: keyOf(e.element),
start: e.element.start,
track: e.element.track,
authoredTrack: e.element.authoredTrack,
}));
const revision = beginTimelineOptimisticGesture(
updateElement,
edits.map((edit) => keyOf(edit.element)),
);
for (const e of edits) updateElement(keyOf(e.element), e.updates);
// The file write below targets `persistTrack` (authored space) when supplied,
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
// that written value into the store's `authoredTrack` so a SECOND drag before
// any reload resolves authored tracks from what the file now says, not stale
// pre-edit data. Pure time-moves leave authoredTrack untouched.
for (const e of edits) {
const writtenTrack =
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
updateElement(
keyOf(e.element),
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
);
}
// The store above gets DISPLAY lanes; the file below gets the authored-space
// track when one was resolved (see TimelineMoveEdit.persistTrack).
const persistEdits = edits.map((e) =>
@@ -142,7 +155,7 @@ function persistMoveEdits(
(error) => {
for (const p of prev) {
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
updateElement(p.key, { start: p.start, track: p.track });
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
}
}
console.error("[Timeline] Failed to persist clip edits", error);
@@ -157,22 +170,54 @@ function persistMoveEdits(
* then compacts it to a distinct integer lane between its neighbours, and the
* clips at/below the insert shift down by one the sanctioned index-renumber.
*/
/** Same-source-file predicate: authored track numbers only compare within ONE
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
* file, not the host timeline's). `undefined` means the active composition. */
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/**
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist.
* The lane's occupants all share one authored track by construction (lane =
* authored track after normalizeToZones; overlap sub-lane spills are display-only
* and never a lane-move target), so any occupant answers. A lane with no other
* occupant falls back to the lane value itself for already-contiguous files the
* two spaces coincide, and edge-created lanes (min-1 / max+1) route through the
* insert path, never here.
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
* file an occupant from a different file (e.g. an expanded sub-comp child, or
* a host clip next to expanded rows) carries authored values in a different
* coordinate space, and borrowing them would write a foreign file's numbering.
*
* Lane semantics after normalizeToZones: each distinct authored track owns one
* base lane, and time-overlapping same-track clips spill onto adjacent display
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
* trackOrder lists it): its occupants share the base lane's authored track by
* construction, so the same-file occupant lookup returns that authored track and
* the drop persists as a same-track join. The clip may then DISPLAY on a
* different sub-lane than it was dropped on the spill re-packs
* deterministically by stable id, first-fit but the persisted track is
* correct.
*
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
* dropped on a lane holding only other files' clips the display-lane integer
* must NOT be persisted into a sparse file):
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
* preserving "one lane up = one authored track up" in the clip's own file.
* 2. No same-file peers at all the lane value itself (single-clip files:
* display and authored spaces coincide for want of any other anchor).
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
* never here.
*/
function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
excludeKey: string,
dragged: TimelineElement,
): number {
const occupant = elements.find((e) => e.track === lane && keyOf(e) !== excludeKey);
return occupant ? (occupant.authoredTrack ?? occupant.track) : lane;
const dragKey = keyOf(dragged);
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
const occupant = peers.find((e) => e.track === lane);
if (occupant) return occupant.authoredTrack ?? occupant.track;
let nearest: TimelineElement | null = null;
for (const p of peers) {
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
}
if (!nearest) return lane;
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
@@ -283,7 +328,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
const dragEdit: TimelineMoveEdit = {
element: drag.element,
updates: { start: drag.previewStart, track: drag.previewTrack },
persistTrack: authoredTrackForLane(drag.previewTrack, elements, dragKey),
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
};
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
@@ -91,4 +91,43 @@ describe("computeRevealScroll", () => {
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
expect(result.left).toBeNull();
});
it("never scrolls on an axis whose visible window is degenerate", () => {
// Horizontal window collapses: 40px viewport minus the 32px gutter and
// 2x12px padding is negative; vertical is intact and still reveals.
const result = computeRevealScroll(
makeInput({
viewportWidth: 40,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(result.left).toBeNull();
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
// Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
const zero = computeRevealScroll(
makeInput({
viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
clipLeft: 1500,
clipRight: 1600,
}),
);
expect(zero.left).toBeNull();
// Both axes degenerate: no scroll at all.
const both = computeRevealScroll(
makeInput({
viewportWidth: 10,
viewportHeight: 10,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(both).toEqual({ left: null, top: null });
});
});
@@ -56,8 +56,11 @@ function revealAxis(
): number | null {
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
if (start >= windowStart && end <= windowEnd) return null;
const windowSize = windowEnd - windowStart;
// Degenerate viewport (container smaller than the sticky chrome + padding):
// there is no visible window to reveal into, so never scroll on this axis.
if (windowSize <= 0) return null;
if (start >= windowStart && end <= windowEnd) return null;
// Oversized range (or start hidden): align the start edge to the window start.
if (end - start > windowSize || start < windowStart) {
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
@@ -69,6 +69,14 @@ export interface StackingPatch {
const EPS = 1e-6;
/**
* Canonical stacking-context key: null/undefined both mean the root context.
* The ONLY place the normalization lives context partitioning, membership
* checks, and pairwise equality must all go through it.
*/
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
el.stackingContextId ?? null;
/**
* Two clips overlap in time when their half-open [start, end) intervals intersect.
*
@@ -297,10 +305,8 @@ export function computeStackingPatches(
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
// values across contexts is nonsense. Restrict the computation to the edited
// clips' own context(s); cross-context lane relations are out of scope.
const editedContexts = new Set(
allResolved.filter((e) => editedSet.has(e.key)).map((e) => e.stackingContextId ?? null),
);
const resolved = allResolved.filter((e) => editedContexts.has(e.stackingContextId ?? null));
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
@@ -320,8 +326,7 @@ export function computeStackingPatches(
// The full live set, so the transitive cascade can reach clips that overlap a
// LIFTED neighbour without overlapping the edited clip itself (#2198).
const all = [...byKey.values()];
const sameContext = (a: MutZ, b: MutZ) =>
(a.stackingContextId ?? null) === (b.stackingContextId ?? null);
const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter(
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
import { normalizeToZones } from "./timelineZones";
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
import type { DraggedClipState } from "./useTimelineClipDrag";
/**
* Pipeline test across the REAL manifestelement boundary: no hand-injected
* `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
* SPARSE authored tracks flows through createTimelineElementFromManifestClip
* (normalizeToZones | buildChildElements) commitDraggedClipMove, and the
* persisted data-track-index must be the AUTHORED target, never the display lane.
*/
const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
id: "x",
label: "x",
start: 0,
duration: 2,
track: 0,
stackingContextId: "root",
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...over,
});
function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
return clips.map((clip, index) =>
createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
);
}
function drag(
element: TimelineElement,
opts: { previewStart: number; previewTrack: number },
): DraggedClipState {
return {
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: opts.previewStart,
previewTrack: opts.previewTrack,
desiredTrack: opts.previewTrack,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
}
/** Commit a lane-change drag and return the single persisted edit batch. */
function commitLaneChange(
element: TimelineElement,
previewTrack: number,
elements: TimelineElement[],
trackOrder: number[],
): TimelineMoveEdit[] {
const onMoveElements = vi.fn();
commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
elements,
trackOrder,
updateElement: vi.fn(),
onMoveElements,
});
expect(onMoveElements).toHaveBeenCalledTimes(1);
return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
}
describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
// Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
// runtime manifest would ship them (clip.track is the verbatim data-track-index).
const sparseManifest = [
manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
];
it("factory records the authored track and stacking context from the manifest clip", () => {
const [v, g, m] = fromManifest(sparseManifest);
expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
expect(v.stackingContextId).toBe("root");
});
it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
// normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
// the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
const elements = normalizeToZones(fromManifest(sparseManifest));
const byId = new Map(elements.map((e) => [e.id, e]));
expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
// Drag the video (lane 0) onto the div's lane (display 1, authored 7).
const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
expect(down).toHaveLength(1);
expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
expect(down[0].updates.track).not.toBe(1);
// And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
});
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
// Host timeline: the sub-comp host plus a root clip, discovered through the
// factory and lane-normalized like the store does.
const hostManifest = [
manifestClip({
id: "scene",
kind: "composition",
tagName: "div",
track: 0,
start: 0,
duration: 10,
compositionId: "scene",
compositionSrc: "scene.html",
}),
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
];
// scene.html has SPARSE authored tracks 3 and 7.
const childClips = [
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
];
const storeElements = normalizeToZones(fromManifest(hostManifest));
const parentMap = new Map([
["c3", "scene"],
["c7", "scene"],
]);
const expanded = buildExpandedElements(
storeElements,
[...hostManifest, ...childClips],
parentMap,
"scene",
"scene",
);
// The children replaced the host row: synthetic display lanes, but the
// authored track (in scene.html's coordinate space) survived the expansion.
const c3 = expanded.find((e) => e.domId === "c3")!;
const c7 = expanded.find((e) => e.domId === "c7")!;
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
expect(c3.stackingContextId).toBe("root");
expect(c3.track).not.toBe(3); // display row is synthetic
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
// the written track must be c7's authored 7 — not the display-lane integer.
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
expect(edits).toHaveLength(1);
expect(edits[0].updates.track).toBe(7);
expect(edits[0].updates.track).not.toBe(c7.track);
});
});
@@ -42,6 +42,14 @@ function byStableId(a: TimelineElement, b: TimelineElement): number {
* The editor enforces no per-track time overlap, so the spill only fires on legacy
* files. It is DISPLAY-ONLY a drag commit persists just the dragged clip, never
* this re-lane so it never rewrites the source.
*
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
* display lane). Because every occupant of a sub-lane shares the base lane's
* authored track by construction, dropping a clip onto one persists that shared
* authored track a legitimate same-track join. On the next normalize the
* joined track re-packs (stable-id first-fit), so the clip may display on a
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
* the persisted source value is correct either way.
*/
function packTrackLanes(
clips: TimelineElement[],
@@ -163,7 +163,14 @@ function buildChildElements(
key,
start: clamped.start,
duration: clamped.duration,
// `track` becomes a synthetic display row under the expanded host, but the
// factory-set `authoredTrack` (the child's data-track-index in ITS OWN
// file's coordinate space) and the runtime-computed `stackingContextId`
// must survive verbatim — lane persists and z-sync read them, they are
// never reconstructed from display lanes.
track: display.track + result.length,
authoredTrack: base.authoredTrack,
stackingContextId: base.stackingContextId,
expandedParentStart: editBasis.start,
domId,
selector,
@@ -113,6 +113,15 @@ export function createTimelineElementFromManifestClip(params: {
start: clip.start,
duration: clip.duration,
track: clip.track,
// clip.track IS the authored data-track-index verbatim (the runtime honors
// it; see parseAuthoredTrack in core/runtime/timeline.ts). Record it at this
// translation boundary so later display-lane remaps (normalizeToZones,
// expanded-child rows) can persist in AUTHORED space instead of
// reconstructing it from lane occupants.
authoredTrack: clip.track,
// Runtime-computed stacking context — authoritative; helpers read it, never
// re-derive it.
stackingContextId: clip.stackingContextId ?? null,
domId,
hfId,
selector,
@@ -30,10 +30,14 @@ export interface TimelineElement {
duration: number;
track: number;
/**
* The data-track-index as written in the source file, when it differs from
* the display lane in `track` (normalizeToZones packs sparse authored tracks
* onto contiguous display lanes). Lane edits must persist THIS space writing
* a display-lane number into a sparse file re-targets the wrong track.
* The data-track-index as written in the source file. Set at the manifest
* translation boundary (createTimelineElementFromManifestClip) from the
* runtime clip's verbatim track, and preserved through display-lane remaps
* (normalizeToZones packs sparse authored tracks onto contiguous display
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
* must persist THIS space writing a display-lane number into a sparse file
* re-targets the wrong track. For an expanded child the value is in its OWN
* source file's coordinate space, not the host timeline's.
*/
authoredTrack?: number;
/** Resolved z-index for stacking-aware timeline ordering. */