diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 4ab0f9053c..7e58cdfcec 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 6430cdeb9a..44597243a5 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 a55984df76..2002c31023 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/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index f4946afbe6..f1d08dfa71 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -57,9 +57,76 @@ describe("collectRuntimeTimelinePayload", () => { expect(result.clips[0].id).toBe("text-1"); expect(result.clips[0].start).toBe(1); expect(result.clips[0].duration).toBe(3); + expect(result.clips[0].track).toBe(0); expect(result.clips[0].kind).toBe("element"); }); + it("parses inline z-index for timeline clips", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const clip = document.createElement("div"); + clip.id = "layered"; + clip.style.zIndex = "11"; + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", "4"); + root.appendChild(clip); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips[0].zIndex).toBe(11); + }); + + it("uses zero z-index sentinel when a timeline clip has no inline z-index", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const clip = document.createElement("div"); + clip.id = "auto-layer"; + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", "4"); + root.appendChild(clip); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips[0].zIndex).toBe(0); + }); + + it("assigns stacking context ids from root and nearest sub-composition", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const rootClip = document.createElement("div"); + rootClip.id = "root-layer"; + rootClip.setAttribute("data-start", "0"); + rootClip.setAttribute("data-duration", "5"); + root.appendChild(rootClip); + + const scene = document.createElement("div"); + scene.id = "scene-host"; + scene.setAttribute("data-composition-id", "scene"); + scene.setAttribute("data-start", "0"); + scene.setAttribute("data-duration", "5"); + root.appendChild(scene); + + const nestedClip = document.createElement("div"); + nestedClip.id = "nested-layer"; + nestedClip.setAttribute("data-start", "0"); + nestedClip.setAttribute("data-duration", "2"); + scene.appendChild(nestedClip); + + const result = collectRuntimeTimelinePayload(defaultParams); + const rootLayer = result.clips.find((clip) => clip.id === "root-layer"); + const nestedLayer = result.clips.find((clip) => clip.id === "nested-layer"); + + expect(rootLayer?.stackingContextId).toBe("main"); + expect(nestedLayer?.stackingContextId).toBe("scene"); + }); + it("identifies video clips by tag", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 550690c554..d7ea927f7c 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -32,6 +32,19 @@ function parseElementEndAttr(element: Element): number | null { ); } +function readInlineZIndex(element: Element): number { + try { + const inline = (element as HTMLElement).style?.zIndex; + if (inline && inline !== "auto") { + const parsed = parseInt(inline, 10); + if (Number.isFinite(parsed)) return parsed; + } + return 0; + } catch { + return 0; + } +} + function maxDefinedNumber(...values: Array): number | null { const finite = values.filter((value): value is number => Number.isFinite(value ?? null)); if (finite.length === 0) return null; @@ -434,6 +447,8 @@ export function collectRuntimeTimelinePayload(params: { node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), 10, ) || 0, + zIndex: readInlineZIndex(node), + stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId, kind, tagName: tag, compositionId: node.getAttribute("data-composition-id"), @@ -545,6 +560,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || gsapTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: el.tagName.toLowerCase(), compositionId: el.getAttribute("data-composition-id"), @@ -602,6 +619,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || overlayTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: tag, compositionId: el.getAttribute("data-composition-id"), diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index a50efae9cf..b2b6648149 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; @@ -51,6 +53,8 @@ export type RuntimeTimelineClip = { start: number; duration: number; track: number; + zIndex: number; + stackingContextId: string | null; kind: "video" | "audio" | "image" | "element" | "composition"; tagName: string | null; compositionId: string | null; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 766c15b147..dfdcdcb9f8 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 = `