Skip to content

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

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/gsap-drag-preview-freeze
Jun 15, 2026
Merged

fix(core): stop transport re-seek from clobbering Studio drag drafts#1464
miguel-heygen merged 2 commits into
mainfrom
fix/gsap-drag-preview-freeze

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

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 + scale box at a mid-animation playhead:

Before (frozen — box separates from element) After (element tracks the cursor)
before after

Root cause

The composition runtime's per-frame transportTick (packages/core/src/runtime/init.ts) calls seekTimelineAndAdapters(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 draft gsap.set that 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/y element and an x + scale element; 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:

  • Playback is unaffected — the seek always runs while the clock is playing.
  • Other elements are unaffected — the playhead does not advance during a paused gesture, so skipping is a no-op for them.
  • Resumes automatically the frame the gesture marker clears (drop/cancel).

Verification

  • Full @hyperframes/core suite green: 1603 passed, 0 failed; lint + format + typecheck + fallow clean.
  • New runtime unit test in init.test.ts that fails without the gate (re-seek fires every frame) and passes with it.
  • Browser end-to-end on a minimal repro: mid-drag gsapX now tracks the pointer (−202 → +296 during the drag) instead of freezing; drop result unchanged.

Manual test plan

  1. Open a GSAP composition in Studio (a clip whose x/y is animated by a registered window.__timelines timeline).
  2. Scrub the playhead to mid-animation (so GSAP holds a live transform).
  3. Select the element and drag it slowly.
    • Before: element frozen; only the selection box follows the cursor; jumps to the box on release.
    • After: element tracks the cursor in lockstep with the box; no jump on release.
  4. Press play — the element animates normally (playback unaffected).

Notes

  • Runtime-only change. The shipped artifact (packages/core/src/generated/runtime-inline.ts) is gitignored and rebuilt by CI.
  • Before/after screenshots are hosted on the pr-assets/gsap-drag-preview-freeze branch (kept out of this PR's diff); safe to delete after merge.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of clock.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:2002document.querySelector('[data-hf-studio-manual-edit-gesture]') runs on every transportTick (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 by begin/endStudioManualEditGesture would 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:1999hasActiveStudioManualEditGesture is 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 around document.querySelector is defensive, but querySelector only 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 transportTick re-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 call endStudioManualEditGesture. 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 beginStudioManualEditGesture API, 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/runtime writes the data-hf-studio-manual-edit-gesture attribute for a different purpose — quick grep showed only Studio's drag paths set it.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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.
@miguel-heygen miguel-heygen force-pushed the fix/gsap-drag-preview-freeze branch from 8d42005 to f4ef56d Compare June 15, 2026 19:22
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.
@miguel-heygen miguel-heygen force-pushed the fix/gsap-drag-preview-freeze branch from 8e1ad2a to 69c29af Compare June 15, 2026 19:52
@miguel-heygen miguel-heygen merged commit 1ab7dcf into main Jun 15, 2026
47 checks passed
@miguel-heygen miguel-heygen deleted the fix/gsap-drag-preview-freeze branch June 15, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants