Open time is one number at one instant. It cannot see the CPU Studio burns
while idle, the full-document media scan every scrub frame, the same media file
fetched eight times on open, or the heap kept after a project switch. This adds
a browser gate for all four and the baseline they read today, before any fix.
The idle window samples consecutive ten-second windows until the style-recalc
count settles rather than sleeping: the first window on a media project drains
the thumbnail-extraction backlog and reads 56% of a core against a flat steady
state, so a fixed sleep would gate on a transient. Animation-frame scheduling is
attributed by call site in every frame, which is what makes a future regression
debuggable rather than merely red.
The scrub window counts full-document video/audio queries during a synthetic
playhead drag, patched in every frame — all of those queries happen inside the
preview iframe, not the Studio document.
The Chrome resolver both e2e gates need moves to a shared puppeteerEnv module.
* perf(studio): define timeline viewport budgets and fixtures
* test(studio): gate timeline viewport performance in Chromium
* refactor(studio): isolate clip drag lifecycle
* refactor(studio): extract timeline render contracts
* perf(studio): centralize timeline viewport geometry
* perf(studio): follow playhead across virtualized rows
* perf(studio): add timeline clip-window index primitive
* perf(studio): virtualize timeline clip windows
* perf(studio): stop timeline scroll work when row virtualization is off
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.
* ci(studio): run the timeline viewport gate on studio changes
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`.
* fix(ci): preserve both timeline gate evidence arms
* ci(studio): report timeline gate arm statuses
* ci(studio): require timeline gate evidence artifacts
* fix(studio): keep dense keyframes readable
* fix(ci): resolve timeline stack audit findings