Skip to content

fix(studio): a snap the saved clip would miss no longer moves it - #5006

Merged
miguel-heygen merged 5 commits into
mainfrom
fix/studio-snap-refuses-unsaved-edge
Oct 4, 2026
Merged

miguel-heygen merged 5 commits into
mainfrom
fix/studio-snap-refuses-unsaved-edge

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

Two review follow-ups from #4986:

  1. A snap the saved clip would miss no longer moves the clip. Clip files store times to the centisecond, so at high zoom a move or tail trim could snap flush to a target, then jump a few pixels on release when the file rounded it. Now, when the saved edge would land a pixel or more off the target, the snap is not applied and the clip stays where the pointer put it.
  2. A test pins that the paused preview-frame view never applies while playing.

Why

#4986 dropped the guide for such snaps but still applied them, so the clip sat flush during the drag and jumped on release (reported in both reviews of #4986). Without the snap it previews and saves at the same place.

Related work

Refs #4986.

How

  • computeDragPreview checks the move's snap against guideIfSaved right after snapMoveToTargets; a missed target falls back to the pointer's start. The guide is then whatever snap survived, so the check runs once.
  • computeResizePreview builds the snapped trim (onTarget) and applies it only when guideIfSaved keeps its guide, for both edges.
  • The trimTargets.length > 0 || gridStep > 0 guard is gone: with the magnet on the playhead is always a target, and with it off snapTimelineTime returns no target.
  • Host note: a host that renders Timeline over its own player (not useTimelinePlayer) gets no trim edge-frame preview; previewFrameStore is not exported. In-repo hosts all use useTimelinePlayer.
  • Walk: tests/e2e/timeline-snap-release.mjs (new clips card-c and card-d in the shared fixture) zooms to the deepest level, moves a 31/30 s clip until its end is 4.8 px past card-d's start, and compares where the drag shows it landing with where it saved, both relative to card-d. The landing slot gets data-testid="timeline-drag-landing", like its sibling drop preview. CI runs it after the trim-snap walk on a fresh copy of the fixture.

Before

Deepest zoom, close-up at card-d's start (4x). While dragging, the landing slot ends flush with card-d; after release the saved clip ends past it: a 4.3 px jump.

Before: the landing slot is flush with the edge, the saved clip is not

After

The landing slot already shows where the clip will save, and release does not move it.

After: the landing slot and the saved clip agree

What I measured

  • Walk timeline-snap-release.mjs, headless Chrome on Linux, 1440x900, repo fixture: on main the clip jumps 4.3 px on release (exit 1); with this branch 3 of 3 runs show 0.0 px (saved start 0.32 s either way). With saves held back 2 s (request interception), main still fails and this branch passes, so the walk reads the saved start, not the drag's own. timeline-trim-snap.mjs still passes 3 of 3 with the larger fixture.
  • Unit tests (vitest), each file 3 runs, all exit 0: timelineClipDragPreview.test.ts 50, useTimelineClipDrag.resize.test.tsx 27, timelineSnapping.test.ts 20, useTimelinePlayer.seek.test.ts 20, Timeline.test.ts 83. Full packages/studio suite: 664 files, 7325 tests passed, exit 0. tsc --noEmit, oxlint, oxfmt, comment ratchet clean.
  • Each new test fails when its code is broken: applying a missed move snap (2 tests red), applying a missed tail snap (1 red), letting the paused view apply while playing (1 red).

What I did NOT exercise

  • macOS or a GPU: the walks ran headless on Linux, so the captures prove behaviour, not pixels.
  • HyperFrames Desktop: it picks this up on its next Studio version bump.
  • A start-trim refusal in a browser: start trims save their start rounded to the centisecond already, so at normal zoom the refusal only changes the guide; it is unit-tested.

Test plan

  • Unit tests added/updated
  • Manual testing performed (browser walk above)
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 1556 (base branch 1556), smooth 1458 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • rotate-none-px-r0-root-z200: error / tracking 0.02, pressJump 0, drop 0, reload 0.08, render 0.04, renderKey -, undo true, teleport true / tracking 0.02, pressJump 0, drop 0, reload 0.08, render 0.04, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 4, 2026 12:20

@jrusso1020 jrusso1020 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.

Approving. A snap is now applied only when the clip's edge will save onto the target, so what the drag shows is where the clip saves.

Move

  • The fallback is safe: nextMove.start already comes out of resolveTimelineMove rounded to the centisecond. So dropping a missed snap leaves a start that saves exactly where it is drawn.
  • guideIfSaved checks both saved edges against the target. A snap that put the clip's end on a target is judged by the end, as it should be.
  • The guide is now just whatever snap survived, and only when placement.start === snap.start. That matches the old target gate, and the check runs once.

Trim

  • Both edges build onTarget and only take it when guideIfSaved keeps the guide. An edge already on the target still keeps its guide, as before.
  • Dropping the trimTargets.length > 0 || gridStep > 0 guard changes nothing. With no targets the loop never runs, and nearestGridLine returns null when gridStep is not above 0, so snapTimelineTime returns { time, target: null }.

Walk: in this head's Studio: timeline viewport gate job, timeline-snap-release.mjs reports savedStart: 0.32 and jumpPx: -0.015 with no failures. timeline-trim-snap.mjs also passes on the larger fixture.

Tests: the six touched or neighbouring files pass (204 tests). Of 6 mutations, 5 were caught:

  • Applying a missed move snap: 2 failures.
  • Keeping the guide on a missed move: 3.
  • Applying a missed tail snap: 1.
  • Drawing the guide whatever the placement: 1.
  • Widening guideIfSaved to 5 px: 5.

The survivor is applying a missed start-trim snap. For a top-level clip it changes nothing, because applyClipStartTrimDelta already rounds the start with roundTimelineTime. That is what the body says ("only changes the guide").

Non-blocking

  • On a nested clip whose host starts off the centisecond grid (the parentCompositionStart: 1/30 case), savedClipEdges rounds start - offset, not start. So the pointer's own start can still save a few px from where it is drawn at deep zoom, with or without a snap. That predates this PR, and the same goes for a start trim on such a clip. It might be worth a follow-up so the fallback rounds in the clip's own time.
  • When the nearest target misses, the move does not try the next target. That seems right to me, since the next target is a different snap than the one the guide would have shown.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 48bfe61 Oct 4, 2026
82 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-snap-refuses-unsaved-edge branch October 4, 2026 14:37
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.

2 participants