From f4ef56d569dde4211c90a1462aa44bb9574917b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 15 Jun 2026 14:17:33 -0400 Subject: [PATCH 1/2] fix(core): stop transport re-seek from clobbering Studio drag drafts Dragging a GSAP x/y-controlled element in the Studio preview froze the element at its animated position while only the selection box tracked the cursor; it snapped to the correct spot on drop. The runtime's per-frame transport tick re-seeked the paused timeline every frame, re-applying the animated value over the draft gsap.set that owns the element during the drag. Skip the per-frame transport re-seek while a manual-edit gesture marker is present and the clock is paused, so the draft writer stays the last writer for the gestured element. Playback is unaffected (the seek always runs while playing) and other elements are unaffected (the playhead does not advance during a paused gesture). Adds a runtime test asserting the re-seek is skipped while the gesture marker is present and resumes when it clears. --- packages/core/src/runtime/init.test.ts | 50 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 31 +++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8df56e23a7..2d219f7dbc 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 163754543c..d7cd75d4df 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 From 69c29af46b01ce4055a2f91b57be90d134544ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 15 Jun 2026 15:44:28 -0400 Subject: [PATCH 2/2] build: copy sdk-playground workspace member in regression test image Dockerfile.test copies each workspace member's package.json before bun install --frozen-lockfile, but packages/sdk-playground (added to the packages/* workspaces and to bun.lock) was never added to the list. bun then sees a member referenced by the lockfile but absent from the build context and fails frozen install with a lockfile-changed error, breaking every regression shard on any PR that exercises the render suite. Add the missing copy. The bun pin (1.3.13) is unrelated -- it validates the committed lockfile clean once all members are present. --- Dockerfile.test | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile.test b/Dockerfile.test index 3c3d0f0d97..36b5fea3c4 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