Skip to content

fix(studio): keep dense keyframes readable - #2925

Merged
miguel-heygen merged 15 commits into
mainfrom
fix/timeline-keyframe-readability-zoom
Jul 31, 2026
Merged

fix(studio): keep dense keyframes readable#2925
miguel-heygen merged 15 commits into
mainfrom
fix/timeline-keyframe-readability-zoom

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • keep timeline keyframe diamonds at their full readable size when timestamps are dense; glyphs may overlap while hit geometry remains bounded
  • replace the fixed 2000% timeline zoom ceiling with a frame-level limit derived from 30fps at 48px per frame
  • mark only the nearest keyframe within half an output frame as under the playhead, using binary search instead of scanning the lane on every tick
  • distinguish the playhead cursor (neutral diamond with accent ring) from explicit keyframe selection (solid accent), including accessible state
  • record at most one gesture keyframe per 30fps output frame, retaining the latest pointer value in that frame
  • keep toolbar, pinch zoom, timeline geometry, and store clamping on the same zoom contract

Validation

  • Studio test suite: 293 files, 3213 tests passed
  • Studio typecheck, oxlint, oxfmt, and diff checks passed
  • live Studio QA at 8554% zoom with 59 dense recorded keyframes: exactly one playhead marker per lane and explicit selection remains visually separate
  • timeline virtualization enabled during live QA

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.
The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.
@miguel-heygen
miguel-heygen force-pushed the fix/timeline-keyframe-readability-zoom branch from 01a377c to 22c812e Compare July 31, 2026 15:15
@miguel-heygen
miguel-heygen changed the base branch from ci/studio-timeline-viewport-gate to main July 31, 2026 15:22
@miguel-heygen
miguel-heygen merged commit 723d338 into main Jul 31, 2026
75 of 76 checks passed
@miguel-heygen
miguel-heygen deleted the fix/timeline-keyframe-readability-zoom branch July 31, 2026 16:05
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.

1 participant