diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 4ab0f9053..7e58cdfce 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -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); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 6430cdeb9..44597243a 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -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 = { "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) => diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a55984df7..2002c3102 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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); }, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index c2745d215..b2b664814 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -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; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 766c15b14..dfdcdcb9f 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -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 }> }; }; 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 = `