diff --git a/Dockerfile.test b/Dockerfile.test index 3c3d0f0d9..36b5fea3c 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -69,7 +69,10 @@ WORKDIR /app RUN curl -fsSL https://bun.sh/install | BUN_INSTALL="/root/.bun" bash -s "bun-v1.3.13" ENV PATH="/root/.bun/bin:$PATH" -# Install dependencies (full, including devDependencies for tsx + test harness) +# Install dependencies (full, including devDependencies for tsx + test harness). +# Every workspace member (packages/*) must be COPYed here — `bun install +# --frozen-lockfile` treats any member missing from the build context as a +# lockfile change and fails. COPY package.json bun.lock ./ COPY packages/core/package.json packages/core/package.json COPY packages/engine/package.json packages/engine/package.json @@ -81,6 +84,7 @@ COPY packages/shader-transitions/package.json packages/shader-transitions/packag COPY packages/aws-lambda/package.json packages/aws-lambda/package.json COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json COPY packages/sdk/package.json packages/sdk/package.json +COPY packages/sdk-playground/package.json packages/sdk-playground/package.json RUN bun install --frozen-lockfile # Copy source diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8df56e23a..2d219f7db 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1095,4 +1095,54 @@ describe("initSandboxRuntimeModular", () => { expect(video.muted).toBe(true); expect(audio.muted).toBe(false); }); + + it("skips the per-frame transport re-seek while a Studio manual-edit gesture is active", () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + + const seekTimes: number[] = []; + const tl = createMockTimeline(5); + const origTotalTime = tl.totalTime; + tl.totalTime = ((time: number, ...rest: unknown[]) => { + seekTimes.push(time); + (origTotalTime as Function).call(tl, time, ...rest); + }) as RuntimeTimelineLike["totalTime"]; + + document.body.innerHTML = ` +
+
+
+ `; + window.__timelines = { root: tl }; + initSandboxRuntimeModular(); + + // (1) Paused + gesture active → the per-frame transport tick must NOT + // re-seek the timeline, otherwise it re-applies the animated value and + // clobbers the draft writer (gsap.set) that owns the dragged element, + // freezing it mid-drag. + const afterInit = seekTimes.length; + raf.step(16); + raf.step(16); + raf.step(16); + expect(seekTimes.length).toBe(afterInit); + + // (2) Playback always wins: with the SAME gesture marker still present, a + // playing clock must keep re-seeking (the gate must never freeze playback). + // Guards the clock.isPlaying() short-circuit — a regression flipping `||` + // to `&&` would skip the seek here and this assertion would catch it. + const player = window.__player; + const beforePlaying = seekTimes.length; + player?.play(); + raf.step(16); + expect(seekTimes.length).toBeGreaterThan(beforePlaying); + player?.pause(); + + // (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes. + document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture"); + const beforeResume = seekTimes.length; + raf.step(16); + expect(seekTimes.length).toBeGreaterThan(beforeResume); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 163754543..d7cd75d4d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -25,6 +25,7 @@ import { applyCaptionOverrides } from "./captionOverrides"; import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; +import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers"; import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types"; import type { PlayerAPI } from "../core.types"; import { swallow } from "./diagnostics"; @@ -1994,6 +1995,24 @@ export function initSandboxRuntimeModular(): void { } }; + // True while the Studio is mid-drag on an element (the gesture marker is + // stamped on the gestured element for the duration of the drag). During a + // paused gesture the draft writer owns the element's transform, so the + // per-frame transport re-seek must yield to it (see transportTick). + // + // The query is document-global (fine for today's single-composition Studio; + // revisit if a multi-composition editor needs to scope this to one root). + // It only runs while the clock is paused — transportTick short-circuits on + // isPlaying() — so it is off the playback hot path; one attribute selector + // per paused frame is negligible. + const hasActiveStudioManualEditGesture = (): boolean => { + try { + return document.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) != null; + } catch { + return false; + } + }; + const transportTick = () => { if (state.tornDown || inTransportTick) return; inTransportTick = true; @@ -2084,7 +2103,17 @@ export function initSandboxRuntimeModular(): void { const t = clock.now(); state.currentTime = t; - seekTimelineAndAdapters(t); + // During a paused Studio manual-edit drag, the draft writer owns the + // gestured element's transform (e.g. gsap.set for x/y). Re-seeking the + // timeline every frame re-applies the animated value and clobbers the + // draft, freezing the element while only the selection box tracks the + // cursor. The playhead does not advance during a paused gesture, so + // skipping the re-seek is a no-op for every other element; it resumes + // the frame the gesture marker clears (drop/cancel). Playback is never + // affected — the seek runs whenever the clock is playing. + if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) { + seekTimelineAndAdapters(t); + } // Looping is handled at the player layer (), // not the runtime. The clock pauses at duration; GSAP's repeat:-1