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
@@ -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,