-
- {/* Media */}
+ {/* Floating preview card — compact, canvas stays visible around it */}
e.stopPropagation()}
>
+ {/* Close button */}
+
{
+ e.stopPropagation();
+ clearPreviewAsset();
+ }}
+ aria-label="Close preview"
+ >
+
+
+
+
+
+
{/* Filename label */}
diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx
index a083d34d2..f67e43a87 100644
--- a/packages/studio/src/components/sidebar/AssetCard.tsx
+++ b/packages/studio/src/components/sidebar/AssetCard.tsx
@@ -133,6 +133,7 @@ export function AssetCard({
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
@@ -150,14 +151,17 @@ export function AssetCard({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
- setSelectedElementId(clip.key ?? clip.id);
+ const clipKey = clip.key ?? clip.id;
+ setSelectedElementId(clipKey);
+ // Scroll the timeline so the selected clip is actually visible.
+ requestClipReveal(clipKey);
return;
}
}
// Not added (or no matching clip found) → preview overlay
setPreviewAsset(asset, projectId);
},
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
+ [used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
);
return (
diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx
index 445ea6732..2dc26f8c1 100644
--- a/packages/studio/src/components/sidebar/AudioRow.tsx
+++ b/packages/studio/src/components/sidebar/AudioRow.tsx
@@ -43,6 +43,7 @@ export function AudioRow({
// CapCut-style click behavior: drag-threshold gate.
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
@@ -59,14 +60,17 @@ export function AudioRow({
if (used) {
const clip = findClipForAsset(elements, asset);
if (clip) {
- setSelectedElementId(clip.key ?? clip.id);
+ const clipKey = clip.key ?? clip.id;
+ setSelectedElementId(clipKey);
+ // Scroll the timeline so the selected clip is actually visible.
+ requestClipReveal(clipKey);
return;
}
}
// Not added → preview overlay (audio player)
setPreviewAsset(asset, projectId);
},
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
+ [used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
);
useEffect(() => {
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
index 238f30f2c..d76f4898e 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
@@ -2,8 +2,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyTimelineStackingReorder,
+ buildTimelineMoveTimingPatch,
deleteSelectedKeyframes,
extendRootDurationIfNeeded,
+ persistTimelineBatchEdit,
+ type PersistTimelineBatchChange,
} from "./timelineEditingHelpers";
import type { TimelineElement } from "../player/store/playerStore";
import { usePlayerStore } from "../player/store/playerStore";
@@ -108,6 +111,91 @@ describe("extendRootDurationIfNeeded", () => {
});
});
+describe("persistTimelineBatchEdit", () => {
+ const SOURCE = `
`;
+
+ function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
+ return {
+ projectId: "p1",
+ activeCompPath: "index.html",
+ label: "Move timeline clips",
+ changes,
+ writeProjectFile: async (path: string, content: string) => {
+ writes.push([path, content]);
+ },
+ recordEdit: async () => {},
+ domEditSaveTimestampRef: { current: 0 },
+ pendingTimelineEditPathRef: { current: new Set
() },
+ };
+ }
+
+ function stubReadFileContent(content: string) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ content }),
+ })),
+ );
+ }
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
+ // A track-insert renumber can include a member whose attributes already
+ // hold the target values — its patch is string-identical. The batch must
+ // skip it and still persist the members that DID change.
+ stubReadFileContent(SOURCE);
+ const writes: Array<[string, string]> = [];
+
+ await persistTimelineBatchEdit(
+ batchInput(
+ [
+ {
+ // no-op: data-start already "1", track already 0
+ element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
+ buildPatches: (original, target) =>
+ buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
+ },
+ {
+ // real change: track 1 -> 2
+ element: el({ id: "b", tag: "video", domId: "b", start: 2, track: 1 }),
+ buildPatches: (original, target) =>
+ buildTimelineMoveTimingPatch(original, target, 2, 5, 2),
+ },
+ ],
+ writes,
+ ),
+ );
+
+ expect(writes).toHaveLength(1);
+ expect(writes[0]![0]).toBe("index.html");
+ expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
+ });
+
+ it("saves nothing when every member is a no-op", async () => {
+ stubReadFileContent(SOURCE);
+ const writes: Array<[string, string]> = [];
+
+ await persistTimelineBatchEdit(
+ batchInput(
+ [
+ {
+ element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
+ buildPatches: (original, target) =>
+ buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
+ },
+ ],
+ writes,
+ ),
+ );
+
+ expect(writes).toHaveLength(0);
+ });
+});
+
describe("deleteSelectedKeyframes", () => {
it("coalesces all removals and reloads only after the last one", () => {
usePlayerStore.setState({
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index 7609d8a9f..a99b33cba 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -5,13 +5,16 @@ import {
type TimelineStackingReorderIntent,
} from "../player/components/timelineEditing";
import { getElementZIndex } from "../player/lib/layerOrdering";
-import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
+import {
+ furthestClipEndFromSource,
+ getTimelineElementIdentity,
+} from "../player/lib/timelineElementHelpers";
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
-import { extendRootDurationInSource } from "../utils/rootDuration";
-import { postRuntimeControlMessage } from "../player/lib/runtimeProtocol";
-
+import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
+import { readFileContent } from "./timelineTimingSync";
export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
+export { readFileContent };
function isHTMLElement(element: Element | null): element is HTMLElement {
if (!element) return false;
// Use the element's OWN realm's HTMLElement: timeline clips live in the preview
@@ -149,16 +152,6 @@ export function patchIframeDomTiming(
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
-function postRootDurationToPreview(
- iframe: HTMLIFrameElement | null,
- durationSeconds: number,
-): void {
- const duration = Number(durationSeconds);
- if (!Number.isFinite(duration) || duration <= 0) return;
- postRuntimeControlMessage(iframe?.contentWindow, "set-root-duration", {
- durationSeconds: duration,
- });
-}
// fallow-ignore-next-line complexity
function resolveResizePlaybackStart(
original: string,
@@ -211,7 +204,12 @@ export function buildTimelineMoveTimingPatch(
value: formatTimelineAttributeNumber(track),
});
}
- return extendRootDurationInSource(patched, start + duration);
+ // Content-driven duration: sync data-duration to the furthest clip end read
+ // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
+ // past the end and shrinks if the furthest clip moved left. Measured from the
+ // source, NOT the store — store durations are runtime-truncated to the current
+ // comp length, which would ratchet the duration down every move.
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
}
export function buildTimelineResizeTimingPatch(
@@ -238,7 +236,10 @@ export function buildTimelineResizeTimingPatch(
value: formatTimelineAttributeNumber(pbs.value),
});
}
- return extendRootDurationInSource(patched, updates.start + updates.duration);
+ // Content-driven duration from the PATCHED SOURCE (raw data-duration) —
+ // grows/shrinks to the furthest clip end. Not from the store, whose
+ // durations are runtime-truncated.
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
}
export interface PersistTimelineEditInput {
@@ -319,12 +320,16 @@ export async function persistTimelineBatchEdit(
const current = patchedByPath.get(targetPath) ?? original;
const patched = change.buildPatches(current, patchTarget);
- if (patched === current) {
- throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
- }
+ // 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:
+ // skip it instead of aborting (and rolling back) the whole batch.
+ if (patched === current) continue;
patchedByPath.set(targetPath, patched);
}
+ if (patchedByPath.size === 0) return;
+
const files = Object.fromEntries(patchedByPath);
for (const targetPath of Object.keys(files)) {
input.pendingTimelineEditPathRef.current.add(targetPath);
@@ -343,227 +348,6 @@ export async function persistTimelineBatchEdit(
input.domEditSaveTimestampRef.current = Date.now();
}
-export async function readFileContent(projectId: string, targetPath: string): Promise {
- if (targetPath.includes("\0") || targetPath.includes("..")) {
- throw new Error(`Unsafe path: ${targetPath}`);
- }
- const response = await fetch(
- `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
- );
- if (!response.ok) {
- throw new Error(`Failed to read ${targetPath}`);
- }
- const data = (await response.json()) as { content?: string };
- if (typeof data.content !== "string") {
- throw new Error(`Missing file contents for ${targetPath}`);
- }
- return data.content;
-}
-
-export type GsapMutationStatus = { mutated: boolean };
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null;
-}
-
-function readMutationStatus(value: unknown): GsapMutationStatus {
- if (!isRecord(value)) return { mutated: false };
- return { mutated: value.mutated === true || value.changed === true };
-}
-
-function readMutationError(value: unknown, fallback: string): string {
- if (isRecord(value) && typeof value.error === "string") return value.error;
- return fallback;
-}
-
-export async function finishTimelineTimingFallback(input: {
- iframe: HTMLIFrameElement | null;
- needsExtension: boolean;
- rootDurationSeconds: number;
- reloadPreview: () => void;
- gsapMutation?: () => Promise;
- onGsapError: (error: unknown) => void;
-}): Promise {
- let gsapMutated = false;
- if (input.gsapMutation) {
- try {
- gsapMutated = (await input.gsapMutation()).mutated;
- } catch (error) {
- input.onGsapError(error);
- return;
- }
- }
- if (input.needsExtension) {
- postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
- if (gsapMutated) input.reloadPreview();
- return;
- }
- input.reloadPreview();
-}
-
-// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
-// outlast one GSAP server round-trip, never a real second edit.
-const GSAP_HISTORY_COALESCE_MS = 10_000;
-
-/**
- * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
- * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
- * 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.
- */
-export async function foldGsapMutationIntoHistory(input: {
- projectId: string;
- paths: string[];
- label: string;
- coalesceKey?: string;
- recordEdit: (edit: RecordEditInput) => Promise;
- gsapMutation: () => Promise;
-}): Promise {
- const uniquePaths = [...new Set(input.paths)];
- const before = new Map();
- for (const path of uniquePaths) {
- before.set(path, await readFileContent(input.projectId, path));
- }
- const status = await input.gsapMutation();
- if (status.mutated) {
- const files: Record = {};
- 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,
- });
- }
- }
- return status;
-}
-
-/**
- * Shift all GSAP animation positions targeting a given element by a time delta.
- * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
- */
-export async function shiftGsapPositions(
- projectId: string,
- filePath: string,
- elementId: string,
- delta: number,
-): Promise {
- if (delta === 0 || !elementId) return { mutated: false };
- const res = await fetch(
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- type: "shift-positions",
- targetSelector: `#${elementId}`,
- delta,
- }),
- },
- );
- if (!res.ok) {
- const err = await res.json().catch(() => null);
- throw new Error(readMutationError(err, "shift-positions failed"));
- }
- return readMutationStatus(await res.json().catch(() => null));
-}
-
-export async function scaleGsapPositions(
- projectId: string,
- filePath: string,
- elementId: string,
- oldStart: number,
- oldDuration: number,
- newStart: number,
- newDuration: number,
-): Promise {
- if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
- if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
- const res = await fetch(
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- type: "scale-positions",
- targetSelector: `#${elementId}`,
- oldStart,
- oldDuration,
- newStart,
- newDuration,
- }),
- },
- );
- if (!res.ok) {
- const err = await res.json().catch(() => null);
- throw new Error(readMutationError(err, "scale-positions failed"));
- }
- return readMutationStatus(await res.json().catch(() => null));
-}
-
-/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
-export function foldedShiftGsapMutation(input: {
- projectId: string;
- targetPath: string;
- domId: string;
- delta: number;
- label: string;
- coalesceKey?: string;
- recordEdit: (edit: RecordEditInput) => Promise;
-}): () => Promise {
- return () =>
- foldGsapMutationIntoHistory({
- projectId: input.projectId,
- paths: [input.targetPath],
- label: input.label,
- coalesceKey: input.coalesceKey,
- recordEdit: input.recordEdit,
- gsapMutation: () =>
- shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
- });
-}
-
-/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
-export function foldedScaleGsapMutation(input: {
- projectId: string;
- targetPath: string;
- domId: string;
- from: { start: number; duration: number };
- to: { start: number; duration: number };
- label: string;
- coalesceKey?: string;
- recordEdit: (edit: RecordEditInput) => Promise;
-}): () => Promise {
- return () =>
- foldGsapMutationIntoHistory({
- projectId: input.projectId,
- paths: [input.targetPath],
- label: input.label,
- coalesceKey: input.coalesceKey,
- recordEdit: input.recordEdit,
- gsapMutation: () =>
- scaleGsapPositions(
- input.projectId,
- input.targetPath,
- input.domId,
- input.from.start,
- input.from.duration,
- input.to.start,
- input.to.duration,
- ),
- });
-}
-
export { applyPatchByTarget, formatTimelineAttributeNumber };
export { patchDocumentRootDuration } from "./timelineEditingGsap";
diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts
new file mode 100644
index 000000000..4840d3b70
--- /dev/null
+++ b/packages/studio/src/hooks/timelineTimingSync.ts
@@ -0,0 +1,377 @@
+// Soft-reload-first preview sync for timeline timing edits: server GSAP
+// position mutations (shift / scale), folding those rewrites into the timing
+// edit's undo history, and swapping the rewritten script into the live preview
+// without a full iframe reload when possible.
+import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
+import { applySoftReload } from "../utils/gsapSoftReload";
+import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
+import type { RecordEditInput } from "../utils/studioFileHistory";
+import { patchDocumentRootDuration } from "./timelineEditingGsap";
+
+export async function readFileContent(projectId: string, targetPath: string): Promise {
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
+ throw new Error(`Unsafe path: ${targetPath}`);
+ }
+ const response = await fetch(
+ `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to read ${targetPath}`);
+ }
+ const data = (await response.json()) as { content?: string };
+ if (typeof data.content !== "string") {
+ throw new Error(`Missing file contents for ${targetPath}`);
+ }
+ return data.content;
+}
+
+/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
+function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
+ try {
+ patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
+ } catch {
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
+ }
+}
+
+/**
+ * Optimistically push the composition's content-driven length into the player
+ * store right after the live DOM patch, so the duration readout + seek bar
+ * update immediately. The readout binds to store.duration (PlayerControls);
+ * edits only patched store.elements, so the number stayed frozen (esp. on
+ * shrink) until a manual refresh. Read from the just-patched preview DOM (raw
+ * data-duration) so it's immune to the runtime's truncated live durations.
+ *
+ * Also writes the content end into the live root's `data-duration`. Timing
+ * edits take the soft-reload path (no full iframe reload), which lets the
+ * runtime recompute the length from the root's declared duration and post it
+ * back — reading the STALE root would revert this optimistic set.
+ */
+export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
+ const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
+ if (end > 0) {
+ usePlayerStore.getState().setDuration(end);
+ patchIframeRootDuration(iframe, end);
+ }
+}
+
+/**
+ * The bits of the server GSAP-mutation response the timeline edit path needs.
+ * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
+ * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
+ * when the endpoint didn't return one (older server, or a multi-script comp the
+ * soft path can't scope), in which case the caller full-reloads as before.
+ */
+export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function readMutationStatus(value: unknown): GsapMutationStatus {
+ if (!isRecord(value)) return { mutated: false, scriptText: null };
+ return {
+ mutated: value.mutated === true || value.changed === true,
+ scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
+ };
+}
+
+function readMutationError(value: unknown, fallback: string): string {
+ if (isRecord(value) && typeof value.error === "string") return value.error;
+ return fallback;
+}
+
+/**
+ * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
+ * soft reload over the full iframe reload that flashes every clip.
+ *
+ * Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
+ * already (a) patched the live DOM timing attributes, (b) updated the store's
+ * elements optimistically (the drag commit calls `updateElement` before the
+ * persist), and (c) had the server rewrite the GSAP tween positions — which is the
+ * `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
+ * document (no navigation), re-seeks to the current playhead, and rebinds the
+ * timeline, so the runtime matches the already-correct store. Nothing structural
+ * changed (no clip added/removed), so `processTimelineMessage` would re-derive the
+ * identical element set — skipping it just avoids the flash.
+ *
+ * Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
+ * result (no gsap runtime / rebind hook / scopable key / script element, or the
+ * re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
+ * already applied the shift; a remount would re-flash for nothing. When the server
+ * returned no `scriptText` (older server, multi-script comp), we also full-reload.
+ */
+function syncTimingEditPreview(
+ iframe: HTMLIFrameElement | null,
+ outcome: Pick,
+ currentTime: number,
+ reloadPreview: () => void,
+): void {
+ if (!iframe || !outcome.scriptText) {
+ reloadPreview();
+ return;
+ }
+ const result = applySoftReload(iframe, outcome.scriptText, {
+ onAsyncFailure: reloadPreview,
+ currentTimeOverride: currentTime,
+ });
+ if (result === "cannot-soft-reload") reloadPreview();
+}
+
+async function finishTimelineTimingFallback(input: {
+ iframe: HTMLIFrameElement | null;
+ reloadPreview: () => void;
+ gsapMutation?: () => Promise;
+ onGsapError: (error: unknown) => void;
+}): Promise {
+ let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
+ if (input.gsapMutation) {
+ try {
+ outcome = await input.gsapMutation();
+ } catch (error) {
+ input.onGsapError(error);
+ return;
+ }
+ }
+ syncTimingEditPreview(
+ input.iframe,
+ outcome,
+ usePlayerStore.getState().currentTime,
+ input.reloadPreview,
+ );
+}
+
+// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
+// outlast one GSAP server round-trip, never a real second edit.
+const GSAP_HISTORY_COALESCE_MS = 10_000;
+
+/**
+ * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
+ * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
+ * 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.
+ */
+async function foldGsapMutationIntoHistory(input: {
+ projectId: string;
+ paths: string[];
+ label: string;
+ coalesceKey?: string;
+ recordEdit: (edit: RecordEditInput) => Promise;
+ gsapMutation: () => Promise;
+}): Promise {
+ const uniquePaths = [...new Set(input.paths)];
+ const before = new Map();
+ for (const path of uniquePaths) {
+ before.set(path, await readFileContent(input.projectId, path));
+ }
+ const status = await input.gsapMutation();
+ if (status.mutated) {
+ const files: Record = {};
+ 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,
+ });
+ }
+ }
+ return status;
+}
+
+/**
+ * Shift all GSAP animation positions targeting a given element by a time delta.
+ * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
+ * Returns the rewritten script so the caller can soft-reload instead of full-reload.
+ */
+export async function shiftGsapPositions(
+ projectId: string,
+ filePath: string,
+ elementId: string,
+ delta: number,
+): Promise {
+ if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
+ const res = await fetch(
+ `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "shift-positions",
+ targetSelector: `#${elementId}`,
+ delta,
+ }),
+ },
+ );
+ if (!res.ok) {
+ const err = await res.json().catch(() => null);
+ throw new Error(readMutationError(err, "shift-positions failed"));
+ }
+ return readMutationStatus(await res.json().catch(() => null));
+}
+
+export async function scaleGsapPositions(
+ projectId: string,
+ filePath: string,
+ elementId: string,
+ oldStart: number,
+ oldDuration: number,
+ newStart: number,
+ newDuration: number,
+): Promise {
+ if (!elementId || oldDuration <= 0 || newDuration <= 0)
+ return { mutated: false, scriptText: null };
+ if (oldStart === newStart && oldDuration === newDuration)
+ return { mutated: false, scriptText: null };
+ const res = await fetch(
+ `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "scale-positions",
+ targetSelector: `#${elementId}`,
+ oldStart,
+ oldDuration,
+ newStart,
+ newDuration,
+ }),
+ },
+ );
+ if (!res.ok) {
+ const err = await res.json().catch(() => null);
+ throw new Error(readMutationError(err, "scale-positions failed"));
+ }
+ return readMutationStatus(await res.json().catch(() => null));
+}
+
+/** Timing delta a single-clip edit applies to its GSAP tweens. */
+export type SingleClipGsapEdit =
+ | { kind: "shift"; delta: number }
+ | {
+ kind: "scale";
+ from: { start: number; duration: number };
+ to: { start: number; duration: number };
+ };
+
+/**
+ * Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
+ * server shift/scale mutation, folds the rewrite into the timing edit's history
+ * entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
+ * the rewritten script — full reload when the mutation is skipped, failed, or
+ * returned no script.
+ */
+export function finishClipTimingFallback(input: {
+ iframe: HTMLIFrameElement | null;
+ reloadPreview: () => void;
+ projectId: string | null;
+ targetPath: string;
+ domId: string | undefined;
+ label: string;
+ coalesceKey?: string;
+ recordEdit: (edit: RecordEditInput) => Promise;
+ edit: SingleClipGsapEdit;
+}): Promise {
+ const { projectId, targetPath, domId, edit } = input;
+ const timingChanged =
+ edit.kind === "shift"
+ ? edit.delta !== 0
+ : edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
+ const runMutation = (pid: string, id: string): Promise =>
+ edit.kind === "shift"
+ ? shiftGsapPositions(pid, targetPath, id, edit.delta)
+ : scaleGsapPositions(
+ pid,
+ targetPath,
+ id,
+ edit.from.start,
+ edit.from.duration,
+ edit.to.start,
+ edit.to.duration,
+ );
+ return finishTimelineTimingFallback({
+ iframe: input.iframe,
+ reloadPreview: input.reloadPreview,
+ gsapMutation:
+ timingChanged && domId && projectId
+ ? () =>
+ foldGsapMutationIntoHistory({
+ projectId,
+ paths: [targetPath],
+ label: input.label,
+ coalesceKey: input.coalesceKey,
+ recordEdit: input.recordEdit,
+ gsapMutation: () => runMutation(projectId, domId),
+ })
+ : undefined,
+ onGsapError: (err) => console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err),
+ });
+}
+
+/**
+ * Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
+ * per-change server mutation for every changed clip, folds the rewrites into the
+ * timing edit's history entry, and soft-reloads the preview when possible.
+ *
+ * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
+ * the active comp's rewritten script can be soft-reloaded (swapped in place, no
+ * all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
+ * multi-file move — no scriptText is passed, so the fallback does ONE full
+ * reload that reflects every changed file.
+ */
+export async function finishGroupTimingGsapFallback(input: {
+ projectId: string;
+ iframe: HTMLIFrameElement | null;
+ reloadPreview: () => void;
+ label: string;
+ errorLabel: string;
+ coalesceKey?: string;
+ recordEdit: (edit: RecordEditInput) => Promise;
+ activeCompPath: string | null;
+ changes: readonly C[];
+ resolveChangePath: (element: TimelineElement) => string;
+ /** Per-change GSAP mutation; return null to skip a change with no timing delta. */
+ mutateChange: (change: C, changePath: string) => Promise | null;
+}): Promise {
+ const activePath = input.activeCompPath || "index.html";
+ const otherFileChanged = input.changes.some(
+ (change) => input.resolveChangePath(change.element) !== activePath,
+ );
+ await finishTimelineTimingFallback({
+ iframe: input.iframe,
+ reloadPreview: input.reloadPreview,
+ gsapMutation: () =>
+ foldGsapMutationIntoHistory({
+ projectId: input.projectId,
+ paths: input.changes.map((change) => input.resolveChangePath(change.element)),
+ label: input.label,
+ coalesceKey: input.coalesceKey,
+ recordEdit: input.recordEdit,
+ gsapMutation: async () => {
+ let mutated = false;
+ let scriptText: GsapMutationStatus["scriptText"] = null;
+ for (const change of input.changes) {
+ const changePath = input.resolveChangePath(change.element);
+ const pending = input.mutateChange(change, changePath);
+ if (!pending) continue;
+ const status = await pending;
+ mutated = mutated || status.mutated;
+ // The LAST mutation against the active comp carries the cumulative
+ // rewritten script for that file.
+ if (changePath === activePath) scriptText = status.scriptText;
+ }
+ return { mutated, scriptText: otherFileChanged ? null : scriptText };
+ },
+ }),
+ onGsapError: (err) => console.error(`[Timeline] ${input.errorLabel}`, err),
+ });
+}
diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx
index 3cc74fbfd..9a652d83e 100644
--- a/packages/studio/src/hooks/useTimelineEditing.test.tsx
+++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx
@@ -77,6 +77,21 @@ function timelineElement(input: {
};
}
+/** Mount a harness component under act() and return its unmount hook. */
+function mountHarness(node: React.ReactElement): { unmount: () => void } {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(node);
+ });
+ return {
+ unmount: () => {
+ act(() => root.unmount());
+ },
+ };
+}
+
function renderTimelineEditingHook(input: {
timelineElements: TimelineElement[];
iframe: HTMLIFrameElement;
@@ -130,26 +145,12 @@ function renderTimelineEditingHook(input: {
return null;
}
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render( );
- });
-
+ const { unmount } = mountHarness( );
if (!move) throw new Error("Expected hook to expose move handler");
if (!resize) throw new Error("Expected hook to expose resize handler");
if (!groupMove) throw new Error("Expected hook to expose group move handler");
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
- return {
- move,
- resize,
- groupMove,
- groupResize,
- unmount: () => {
- act(() => root.unmount());
- },
- };
+ return { move, resize, groupMove, groupResize, unmount };
}
type TimelineRecordEdit = NonNullable<
@@ -198,20 +199,9 @@ function renderTimelineEditingHookWithLifecycle(input: {
return null;
}
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render( );
- });
-
+ const { unmount } = mountHarness( );
if (!move) throw new Error("Expected hook to expose move handler");
- return {
- move,
- unmount: () => {
- act(() => root.unmount());
- },
- };
+ return { move, unmount };
}
function jsonResponse(body: unknown): Response {
@@ -233,139 +223,118 @@ async function flushAsyncWork(): Promise {
}
}
+/**
+ * Stub global fetch for project "p1": serves file contents (a single source
+ * string, or a path → content map) and answers the GSAP-mutation endpoint
+ * with `gsapBody`. Returns the mock for call inspection.
+ */
+function stubProjectFetch(
+ files: string | Record,
+ gsapBody: unknown = { ok: true },
+) {
+ const fetchMock = vi.fn(async (input: Parameters[0]): Promise => {
+ const url = requestUrl(input);
+ if (url.includes("/api/projects/p1/files/")) {
+ if (typeof files === "string") return jsonResponse({ content: files });
+ const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
+ return jsonResponse({ content: files[path] });
+ }
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+const ROOT_DURATION_FALLBACK_SOURCE = [
+ ``,
+].join("\n");
+
+/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
+async function setupRootDurationFallback() {
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
+ const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
+ const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
+ const recordEdit = vi.fn(async () => {});
+ const forceReloadSdkSession = vi.fn();
+ const reloadPreview = vi.fn();
+ const iframeWindow = iframe.contentWindow;
+ if (!iframeWindow) throw new Error("Expected iframe window");
+ const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
+ stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
+ usePlayerStore.getState().setDuration(4);
+ const hook = renderTimelineEditingHook({
+ timelineElements: [clip],
+ iframe,
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
+ projectId: "p1",
+ writeProjectFile,
+ recordEdit,
+ sdkSession,
+ forceReloadSdkSession,
+ reloadPreview,
+ });
+ return {
+ hook,
+ clip,
+ setTimingSpy,
+ writeProjectFile,
+ forceReloadSdkSession,
+ reloadPreview,
+ postMessageSpy,
+ };
+}
+
+/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
+function expectRootDurationExtendedViaFallback(
+ ctx: Awaited>,
+): void {
+ expect(ctx.setTimingSpy).not.toHaveBeenCalled();
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
+ 'data-composition-id="main" data-duration="5"',
+ );
+ expect(usePlayerStore.getState().duration).toBe(5);
+ expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
+ // The GSAP endpoint returned no rewritten scriptText, so the timing sync
+ // escalates from the flash-free soft reload to ONE full reload. The root
+ // duration travels via the persisted content-driven `data-duration` (above),
+ // not a `set-root-duration` postMessage.
+ expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
+ expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ action: "set-root-duration" }),
+ "*",
+ );
+}
+
describe("useTimelineEditing timeline z-index reorder", () => {
it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
- const source = [
- ``,
- ].join("\n");
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
- const sdkSession = await openComposition(source);
- const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const recordEdit = vi.fn(async () => {});
- const forceReloadSdkSession = vi.fn();
- const reloadPreview = vi.fn();
- const iframeWindow = iframe.contentWindow;
- if (!iframeWindow) throw new Error("Expected iframe window");
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
- return jsonResponse({ ok: true, mutated: false });
- }
- throw new Error(`Unexpected fetch: ${url}`);
- }),
- );
- usePlayerStore.getState().setDuration(4);
- const { move, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- sdkSession,
- forceReloadSdkSession,
- reloadPreview,
- });
+ const ctx = await setupRootDurationFallback();
await act(async () => {
- await move(clip, { start: 3, track: clip.track });
+ await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
});
- expect(setTimingSpy).not.toHaveBeenCalled();
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
- 'data-composition-id="main" data-duration="5"',
- );
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
- expect(usePlayerStore.getState().duration).toBe(5);
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
- expect(reloadPreview).not.toHaveBeenCalled();
- expect(postMessageSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- source: "hf-parent",
- type: "control",
- action: "set-root-duration",
- durationSeconds: 5,
- protocolVersion: 1,
- }),
- "*",
- );
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
+ expectRootDurationExtendedViaFallback(ctx);
- unmount();
+ ctx.hook.unmount();
});
it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
- const source = [
- ``,
- ].join("\n");
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
- const sdkSession = await openComposition(source);
- const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {});
- const recordEdit = vi.fn(async () => {});
- const forceReloadSdkSession = vi.fn();
- const reloadPreview = vi.fn();
- const iframeWindow = iframe.contentWindow;
- if (!iframeWindow) throw new Error("Expected iframe window");
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
- vi.stubGlobal(
- "fetch",
- vi.fn(async (input: Parameters[0]): Promise => {
- const url = requestUrl(input);
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
- return jsonResponse({ ok: true, mutated: false });
- }
- throw new Error(`Unexpected fetch: ${url}`);
- }),
- );
- usePlayerStore.getState().setDuration(4);
- const { resize, unmount } = renderTimelineEditingHook({
- timelineElements: [clip],
- iframe,
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
- projectId: "p1",
- writeProjectFile,
- recordEdit,
- sdkSession,
- forceReloadSdkSession,
- reloadPreview,
- });
+ const ctx = await setupRootDurationFallback();
await act(async () => {
- await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
+ await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
});
- expect(setTimingSpy).not.toHaveBeenCalled();
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
- 'data-composition-id="main" data-duration="5"',
- );
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"> ');
- expect(usePlayerStore.getState().duration).toBe(5);
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
- expect(reloadPreview).not.toHaveBeenCalled();
- expect(postMessageSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- source: "hf-parent",
- type: "control",
- action: "set-root-duration",
- durationSeconds: 5,
- protocolVersion: 1,
- }),
- "*",
- );
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">