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