mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
+5
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user