fix(core): stop transport re-seek from clobbering Studio drag drafts (#1464)

Gate the runtime's per-frame transport re-seek to yield to an active Studio manual-edit drag, so GSAP x/y-controlled elements track the cursor instead of freezing until drop. Also adds the missing sdk-playground workspace member to Dockerfile.test, which unblocks the render regression suite for any runtime-touching PR.
This commit is contained in:
Miguel Ángel
2026-06-15 16:26:01 -04:00
committed by GitHub
parent 3b3ece81d1
commit 1ab7dcfe47
3 changed files with 85 additions and 2 deletions
+5 -1
View File
@@ -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
+50
View File
@@ -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 = `
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080">
<div id="dragged" data-hf-studio-manual-edit-gesture="tok-1"></div>
</div>
`;
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);
});
});
+30 -1
View File
@@ -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 (<hyperframes-player>),
// not the runtime. The clock pauses at duration; GSAP's repeat:-1