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