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;