perf(studio): grow composition duration live on extend, no preview remount

Extending a clip past the video end used to force the server-fallback
path that fully remounts the preview iframe (the SDK fast path can't
express the root composition's data-duration, and the runtime bakes+drops
data-duration at load so it can't be patched live). On a large comp that
remount is a visible hitch.

Add a runtime control-bridge action set-root-duration -> clock.setDuration,
so the studio can grow the transport length in place. On an extend the
studio now posts it (and patches the clip's own timing live) instead of
reloading; it only reloads when a GSAP source rewrite actually happened
(the gsap-mutation endpoints now report a mutated flag). Non-animated
extends — the common case — commit as fast as a normal edit.

Verified: bridge dispatch + studio no-reload/post-message paths unit-
tested; core/studio/studio-server typecheck + suites green; the built
runtime artifact carries the handler; E2E confirms the extend no longer
remounts the preview and still persists.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:28 -04:00
parent 580676bbf4
commit 9f6c20e482
9 changed files with 221 additions and 38 deletions
+8
View File
@@ -16,6 +16,7 @@ function createMockDeps() {
onSetPlaybackRate: vi.fn(),
onSetColorGrading: vi.fn(),
onSetColorGradingCompare: vi.fn(),
onSetRootDuration: vi.fn(),
onEnablePickMode: vi.fn(),
onDisablePickMode: vi.fn(),
};
@@ -155,6 +156,13 @@ describe("installRuntimeControlBridge", () => {
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
});
it("dispatches set-root-duration command with numeric seconds", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-root-duration", { durationSeconds: "18.5" }));
expect(deps.onSetRootDuration).toHaveBeenCalledWith(18.5);
});
it("dispatches set-color-grading command with target and grading payload", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
+2
View File
@@ -14,6 +14,7 @@ type BridgeDeps = {
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
onSetPlaybackRate: (rate: number) => void;
onSetRootDuration: (durationSeconds: number) => void;
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
onSetColorGradingCompare: (
target: HfColorGradingTarget | string | null,
@@ -53,6 +54,7 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
"set-web-audio-media-disabled": (data, deps) =>
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
"set-color-grading": (data, deps) =>
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
"set-color-grading-compare": (data, deps) =>
+26
View File
@@ -1852,6 +1852,7 @@ export function initSandboxRuntimeModular(): void {
// transport tick. A plain count misses same-count swaps (one sub-comp unloads
// as another loads), so the signature keys on id+tag in document order.
let clipTreeSignature = "";
let liveRootDurationOverrideSeconds = 0;
const computeClipTreeSignature = (): string => {
let sig = "";
for (const el of document.querySelectorAll("[data-start]")) {
@@ -1904,6 +1905,30 @@ export function initSandboxRuntimeModular(): void {
scheduleRootStageLayoutDiagnostics();
};
const finitePositiveDuration = (value: number): number =>
Number.isFinite(value) && value > 0 ? value : 0;
const growRootDurationLive = (durationSeconds: number) => {
const nextDuration = finitePositiveDuration(Number(durationSeconds));
if (nextDuration <= 0) return;
const rootEl = resolveRootCompositionElement();
const rootAttrDuration = finitePositiveDuration(
Number.parseFloat(rootEl?.getAttribute("data-duration") ?? ""),
);
const currentDuration = Math.max(
liveRootDurationOverrideSeconds,
finitePositiveDuration(clock.getDuration()),
rootAttrDuration,
);
if (nextDuration <= currentDuration) return;
liveRootDurationOverrideSeconds = nextDuration;
rootEl?.setAttribute("data-duration", String(nextDuration));
clock.setDuration(nextDuration);
postTimeline();
postState(true);
};
const runAdapters = (method: "discover" | "pause" | "play", timeSeconds = 0) => {
for (const adapter of state.deterministicAdapters) {
try {
@@ -2193,6 +2218,7 @@ export function initSandboxRuntimeModular(): void {
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
applyWebAudioRate();
},
onSetRootDuration: growRootDurationLive,
onSetColorGrading: (target, grading) => {
colorGrading.setGrading(target, grading);
},
+2
View File
@@ -18,6 +18,7 @@ export type RuntimeBridgeControlAction =
| "set-media-output-muted"
| "set-native-media-sync-disabled"
| "set-web-audio-media-disabled"
| "set-root-duration"
| "stop-media"
| "flash-elements";
@@ -28,6 +29,7 @@ export type RuntimeBridgeControlMessage = {
frame?: number;
muted?: boolean;
volume?: number;
durationSeconds?: number;
disabled?: boolean;
playbackRate?: number;
target?: HfColorGradingTarget | string | null;
@@ -227,18 +227,46 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
});
const result = (await res.json()) as {
ok: boolean;
mutated?: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.mutated).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
});
it("reports no GSAP mutation when shifting positions in a file with no GSAP script", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: "#box",
delta: 1,
}),
});
const result = (await res.json()) as {
ok?: boolean;
changed?: boolean;
mutated?: boolean;
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.changed).toBe(false);
expect(result.mutated).toBe(false);
});
it("consolidate-position-writes leaves exactly one position write per selector", async () => {
const projectDir = createProjectDir();
const CORRUPTED = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
@@ -2046,6 +2046,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
block = extractGsapScriptBlock(html);
}
if (!block && (body.type === "shift-positions" || body.type === "scale-positions")) {
return c.json({
ok: true,
changed: false,
mutated: false,
parsed: { animations: [], timelineVar: "tl", preamble: "", postamble: "" },
before: html,
after: html,
scriptText: "",
path: res.filePath,
backupPath: null,
});
}
if (!block) {
return c.json({ error: "no GSAP script found in file" }, 400);
}
@@ -2081,6 +2094,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const responsePayload: Record<string, unknown> = {
ok: true,
changed,
mutated: changed,
parsed: freshParsed,
before: html,
after: newHtml,
@@ -190,6 +190,23 @@ export function patchIframeDomTiming(
}
}
export function postRootDurationToPreview(
iframe: HTMLIFrameElement | null,
durationSeconds: number,
): void {
const duration = Number(durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) return;
iframe?.contentWindow?.postMessage(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: duration,
},
"*",
);
}
// fallow-ignore-next-line complexity
function resolveResizePlaybackStart(
original: string,
@@ -317,6 +334,47 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}
export type GsapMutationStatus = { mutated: boolean };
function isRecord(value: unknown): value is Record<string, unknown> {
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<GsapMutationStatus>;
onGsapError: (error: unknown) => void;
}): Promise<void> {
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();
}
/**
* 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.
@@ -326,8 +384,8 @@ export async function shiftGsapPositions(
filePath: string,
elementId: string,
delta: number,
): Promise<void> {
if (delta === 0 || !elementId) return;
): Promise<GsapMutationStatus> {
if (delta === 0 || !elementId) return { mutated: false };
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
@@ -342,8 +400,9 @@ export async function shiftGsapPositions(
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
throw new Error(readMutationError(err, "shift-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
export async function scaleGsapPositions(
@@ -354,9 +413,9 @@ export async function scaleGsapPositions(
oldDuration: number,
newStart: number,
newDuration: number,
): Promise<void> {
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
if (oldStart === newStart && oldDuration === newDuration) return;
): Promise<GsapMutationStatus> {
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)}`,
{
@@ -374,8 +433,9 @@ export async function scaleGsapPositions(
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
throw new Error(readMutationError(err, "scale-positions failed"));
}
return readMutationStatus(await res.json().catch(() => null));
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
@@ -230,12 +230,18 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(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<typeof fetch>[0]): Promise<Response> => {
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 });
if (url.includes("/api/projects/p1/gsap-mutations/")) {
return jsonResponse({ ok: true, mutated: false });
}
throw new Error(`Unexpected fetch: ${url}`);
}),
);
@@ -249,6 +255,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
recordEdit,
sdkSession,
forceReloadSdkSession,
reloadPreview,
});
await act(async () => {
@@ -262,6 +269,16 @@ describe("useTimelineEditing timeline z-index reorder", () => {
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(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
},
"*",
);
unmount();
});
@@ -279,12 +296,18 @@ describe("useTimelineEditing timeline z-index reorder", () => {
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(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<typeof fetch>[0]): Promise<Response> => {
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 });
if (url.includes("/api/projects/p1/gsap-mutations/")) {
return jsonResponse({ ok: true, mutated: false });
}
throw new Error(`Unexpected fetch: ${url}`);
}),
);
@@ -298,6 +321,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
recordEdit,
sdkSession,
forceReloadSdkSession,
reloadPreview,
});
await act(async () => {
@@ -311,6 +335,16 @@ describe("useTimelineEditing timeline z-index reorder", () => {
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
expect(usePlayerStore.getState().duration).toBe(5);
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
expect(reloadPreview).not.toHaveBeenCalled();
expect(postMessageSpy).toHaveBeenCalledWith(
{
source: "hf-parent",
type: "control",
action: "set-root-duration",
durationSeconds: 5,
},
"*",
);
unmount();
});
+38 -29
View File
@@ -29,6 +29,7 @@ import {
formatTimelineAttributeNumber,
shiftGsapPositions,
scaleGsapPositions,
finishTimelineTimingFallback,
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
@@ -149,22 +150,25 @@ export function useTimelineEditing({
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
};
// Server-path fallback (no SDK session): persist the attr patch, then
// shift GSAP tween positions on the server and reload the preview — the
// SDK path folds both into setTiming, but the fallback must do them
// explicitly or the clip moves while its GSAP tweens stay put + the
// preview never refreshes. coalesceKey mirrors the SDK branch so undo
// granularity is identical on either path.
// shift GSAP tween positions on the server. Extending edits can keep the
// iframe live unless a GSAP source rewrite needs a fresh run.
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
const delta = updates.start - element.start;
if (delta !== 0 && element.domId && pid) {
return shiftGsapPositions(pid, targetPath, element.domId, delta)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
}
return reloadPreview();
const domId = element.domId;
return finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + element.duration,
reloadPreview,
gsapMutation:
delta !== 0 && domId && pid
? () => shiftGsapPositions(pid, targetPath, domId, delta)
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
});
});
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
if (sdkSession && element.hfId && !needsExtension) {
@@ -238,10 +242,8 @@ export function useTimelineEditing({
updates.playbackStart != null ||
(updates.start !== element.start && element.playbackStart != null);
// Server-path fallback: after persisting the attr patch, scale GSAP tween
// positions/durations on the server and reload the preview. The SDK path
// folds both into setTiming; the fallback must do them explicitly or the
// clip resizes while its GSAP tweens keep their old timing + the preview
// never refreshes. coalesceKey mirrors the SDK branch for undo parity.
// positions/durations on the server. Extending edits can keep the iframe
// live unless a GSAP source rewrite needs a fresh run.
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
const timingChanged =
updates.start !== element.start || updates.duration !== element.duration;
@@ -249,20 +251,27 @@ export function useTimelineEditing({
const resizeFallback = () =>
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
const pid = projectIdRef.current;
if (timingChanged && element.domId && pid) {
return scaleGsapPositions(
pid,
targetPath,
element.domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
}
return reloadPreview();
const domId = element.domId;
return finishTimelineTimingFallback({
iframe: previewIframeRef.current,
needsExtension,
rootDurationSeconds: updates.start + updates.duration,
reloadPreview,
gsapMutation:
timingChanged && domId && pid
? () =>
scaleGsapPositions(
pid,
targetPath,
domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
: undefined,
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
});
});
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
return sdkTimingPersist(