mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 manifest→element 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. */
|
||||
|
||||
Reference in New Issue
Block a user