fix(core): stop transport re-seek from clobbering Studio drag drafts#1464
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Verified at HEAD 8d42005a. HF runtime-interop / SDK shape / player semantics deferred to Vai's parallel pass; this review covers canonical-rubric angles (correctness, race-safety, regression risk, test coverage, hot-path cost).
Verdict: approve-with-concerns. Fix is surgically scoped to the right call site, gesture lifecycle is solid, programmatic seeks (player.seek, scrub, end-of-clip, init) correctly stay un-gated. Concerns below are non-blocking — one test gap and a minor hot-path cost note worth a follow-up.
Concerns
-
packages/core/src/runtime/init.ts:2107— test gap: the new test verifies (a) skip while paused+gesture and (b) resume on marker clear, but does NOT verify the playing branch ofclock.isPlaying() || !hasActiveStudioManualEditGesture(). That short-circuit is what guarantees playback is never affected when a gesture marker is somehow still present (e.g. drag-during-play, or a stale marker from a teardown race). Suggest adding a 3-line case:// While playing, the re-seek must fire even with the gesture marker present. clock.play(); // or however the test scaffold exposes playing state const beforePlay = seekTimes.length; raf.step(16); expect(seekTimes.length).toBeGreaterThan(beforePlay);Without it, a regression that flips the
||to&&would slip through the unit suite. -
packages/core/src/runtime/init.ts:2002—document.querySelector('[data-hf-studio-manual-edit-gesture]')runs on everytransportTick(60fps) regardless of whether a drag is active. Attribute-selector querySelector on a sandbox DOM is fast (~µs on small trees), but on large compositions with hundreds of elements it's a per-frame walk. Negligible today; could become a hot-path nuisance if composition DOMs grow. A boolean ref toggled bybegin/endStudioManualEditGesturewould be zero-cost, but adds cross-package coupling — fine to defer until a profile flags it. Flagging for awareness, not a blocker. -
packages/core/src/runtime/init.ts:1999—hasActiveStudioManualEditGestureis global (document.querySelector). If Studio ever hosts multiple compositions side-by-side and a drag is active in composition A, composition B's transport tick also yields. Not a problem in current Studio (single composition at a time), but worth a comment noting the global scope so a future multi-composition refactor doesn't trip over it.
Nits
packages/core/src/runtime/init.ts:2001— try/catch arounddocument.querySelectoris defensive, butquerySelectoronly throws on invalid selector syntax; the selector here is a static constant. The catch is cheap (zero-cost in V8 if no throw fires) so leaving it is fine, just noting it's belt-and-suspenders.
What looks right
- Gating scope: only the per-frame
transportTickre-seek (line 2107) is gated.player.seek(2142),player.renderSeek(2161), end-of-clip clamp (2030),play()-reset (1672), and init seeds are all un-gated — scrub-during-drag and play-during-drag both still work as expected. clock.isPlaying() || !hasActiveStudioManualEditGesture()— playback always wins. Only paused + active gesture skips. Correct precedence.- Gesture marker lifecycle (audited in
packages/studio/src/components/editor/useDomEditOverlayGestures.ts+manualOffsetDrag.ts): commit, cancel, blur, escape, and.finally()paths all callendStudioManualEditGesture. Token-based clear prevents stale-token races. No marker-leak risk that would silently freeze playback. - Skipping the re-seek is genuinely a no-op for non-gestured elements while paused, since
clock.now()doesn't advance — the comment block at 2100-2106 captures this correctly. - HF#1439 (keyframe drag) and HF#1448 (shift-on-drag) drag paths route through the same
beginStudioManualEditGestureAPI, so the fix benefits them too without code changes.
What I didn't verify
- HF runtime-interop / Player API contract semantics — deferred to Vai's parallel pass.
- The browser end-to-end claim in the PR body (mid-drag
gsapX-202 → +296). The unit test demonstrates the gating; trust the manual repro for the visual outcome. - Whether any non-Studio consumer of
@hyperframes/core/runtimewrites thedata-hf-studio-manual-edit-gestureattribute for a different purpose — quick grep showed only Studio's drag paths set it.
Review by Rames D Jusso
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.
8d42005 to
f4ef56d
Compare
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.
8e1ad2a to
69c29af
Compare
What
Dragging a GSAP
x/y-controlled element in the Studio preview froze the element at its current animated position while only the selection box tracked the cursor; the element then snapped to the correct spot on drop. The committed result was always correct — only the in-drag visual was wrong.Before vs. after
Same +240px drag of the
x + scalebox at a mid-animation playhead:Root cause
The composition runtime's per-frame
transportTick(packages/core/src/runtime/init.ts) callsseekTimelineAndAdapters(clock.now())on every animation frame, regardless of play/pause state. During a drag the playhead is paused, but the tick keeps re-seeking the paused timeline to the current frame — re-applying the GSAP-animated value over the draftgsap.setthat owns the element during the gesture. The element is rewritten back to its animated position every frame, so it appears frozen; only the selection-box overlay (a separate writer) follows the cursor. On drop, the commit path re-authors the position and lands it correctly.This is not scale- or matrix-related — confirmed identical on a pure
x/yelement and anx + scaleelement; the commit path uses the same offset math and is exactly correct.Fix
In
transportTick, skip the per-frame re-seek when the clock is paused and an element carries the manual-edit gesture marker (data-hf-studio-manual-edit-gesture), so the draft writer stays the last writer for the gestured element:Verification
@hyperframes/coresuite green: 1603 passed, 0 failed; lint + format + typecheck + fallow clean.init.test.tsthat fails without the gate (re-seek fires every frame) and passes with it.gsapXnow tracks the pointer (−202 → +296 during the drag) instead of freezing; drop result unchanged.Manual test plan
x/yis animated by a registeredwindow.__timelinestimeline).Notes
packages/core/src/generated/runtime-inline.ts) is gitignored and rebuilt by CI.pr-assets/gsap-drag-preview-freezebranch (kept out of this PR's diff); safe to delete after merge.