Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
31 changes: 30 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading