Files
hyperframes/plans/preview-loads-during-shell-first-layout.md
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00

251 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Preview loads during the shell's first layout
**Path:** `plans/preview-loads-during-shell-first-layout.md`
**Repo:** heygen-com/hyperframes · measured against v0.8.35
**Status:** proposal, needs owner decisions (see Open questions)
Every claim below is tagged **[fact]** (read from the code or the trace), **[inference]** (reasoning from those facts, falsifiable), or **[recommendation]**.
---
## Problem
**[fact — measured, not re-derived here]** On a cold open of a project, the Studio shell's first text layout is a single synchronous **3.04 s** task (`FontFaceSet::HandlePendingEventsAndPromisesSoon` → forced style+layout, 9 dirty objects, `preFCP`). Measured at 3.8 s idle, 11.9 s under machine load, ~20 s as experienced. It reproduces on a bare `<p>hi</p>` page in a fresh renderer: **it is Chrome plus machine load, not our CSS.**
The preview iframe is created by React _after_ the shell renders, so the composition's own load (~1.5 s) is serialised behind the stall:
```
+0.00s studio shell commits
+0.21s Layout 3.04s (shell, 9 objects) ← stall
+3.36s preview iframe element created
+4.10s preview document commits (0.74s of server + network)
+4.53s preview layout 0.03s (1,602 objects)
```
Nine dirty objects taking 3.04 s is the tell: the cost is not proportional to our DOM. We cannot shrink it. We can stop paying it _and then_ paying for the preview.
---
## Goal and non-goals
**Goal.** Time-to-first-preview-frame is bounded by `max(stall, composition load)` rather than `stall + composition load`, by starting the composition's load before the stall begins.
**Non-goals.**
- Shrinking the 3.04 s stall. See "Why fonts is a dead end" — there is nothing of ours in it.
- Any change to the `<hyperframes-player>` public API, its attributes, events, or lifecycle. **[fact]** None of the recommended units touch `packages/player/**` at all.
- Any change to deterministic render or export. The preview route (`/api/projects/:id/preview`) is already the studio-only path; the render path goes through the producer.
- Making the preview _itself_ faster (its 30 ms layout is not the problem).
---
## Current flow (cited)
### Where the element and the iframe are created
1. `packages/studio/src/main.tsx:96` — `createRoot(#root).render(<StudioApp/>)`. The entry is `<script type="module" src="/src/main.tsx">` (`packages/studio/index.html:11`), i.e. **deferred**: it runs after HTML parsing.
2. `packages/studio/src/App.tsx:453` — the entire shell is gated: `if (resolving || waitingForServer || !projectId) return <StudioSplash/>`. **[fact]** `useServerConnection` (`packages/studio/src/hooks/useServerConnection.ts:26-66`) contacts `/api/projects` **even when the hash already names a project** — the hash id is used, but only after the fetch settles.
3. `App.tsx:497` → `EditorShell` → `packages/studio/src/components/EditorShell.tsx` → `nle/PreviewPane.tsx:125` → `nle/NLEPreview.tsx:467` → `player/components/Player.tsx`.
4. `packages/studio/src/player/components/Player.tsx:158-282` is where the DOM work happens, inside `useMountEffect` (a plain `useEffect`, `hooks/useMountEffect.ts:15`):
- `:163` `previewSource = directUrl || buildProjectApiPath(projectId, "/preview")`
- `:170` `await import("@hyperframes/player")` — a **dynamic import**, deliberately deferred to the browser (comment at `:6-8`: a module-scope import registers a custom element and throws under SSR)
- `:174` `document.createElement("hyperframes-player")`
- `:273-274` `shader-capture-scale="1"`, `shader-loading="player"` — **hard-coded constants**
- `:281-282` `setAttribute("src", src)` then `container.appendChild(player)`
5. `packages/player/src/hyperframes-player.ts:220-236` — `attributeChangedCallback` for `src` **bails when `!isConnected`** (`:227`). So the navigation actually starts in `connectedCallback` (`:177-189`), on the `appendChild` at `Player.tsx:282`, via `prepareSrcForElement`.
6. `packages/player/src/iframe-dom.ts:39-55` — `createCompositionIframe()` builds the iframe in the element's **constructor** (`hyperframes-player.ts:121`), inside the shadow root. There is no seam to hand the element a pre-existing iframe.
**So the serial chain today is:** parse index.html → deferred module bundle → React mount → `/api/projects` round trip → shell commit → **3.04 s layout stall** → passive mount effects → dynamic import of the player chunk → `createElement` + `appendChild` → _now_ the preview request is sent.
### What the iframe `src` depends on, and when each input is known
| Input | Source | Known at HTML-parse time? |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
| project id | `location.hash` → `parseProjectIdFromHash` (`utils/projectRouting.ts:60-72`); the CLI always emits `#project/<name>` (`packages/cli/src/commands/preview.ts:1200`) | **Yes** |
| `__hf_shader_capture_scale=1`, `__hf_shader_loading=player` | hard-coded at `Player.tsx:273-274`, appended by `shader-options.ts:88-104` (`prepareSrcForElement`) in that order | **Yes** (constants) |
| `variables=` | `hooks/previewVariablesStore.ts:17-19`, applied at `Player.tsx:176`. **[fact]** The only setter is the Variables panel (`components/panels/VariablesPanel.tsx:271`); nothing hydrates it at boot | **Yes** (always absent on a cold open) |
| `directUrl` (drill-down / non-master comp) | `nle/useCompositionStack.ts:33,93,110`, chosen from `activeCompPath`, which is hydrated from the URL only after the **file tree** loads (`hooks/useHydrateActiveCompPathFromUrl.ts`) | **No** — needs React state + a second fetch |
| `_t=` cache-buster | `player/hooks/useTimelinePlayer.ts:457-476` (`refreshPlayer`) | N/A (reload only) |
| `_hfStudioRetry` | `Player.tsx:180-188` | N/A (retry only) |
| **`t=` (playhead restore)** | `utils/studioUrlState.ts:154` → applied as a **seek after load** (`hooks/useStudioUrlState.ts:272-313`), **never part of the iframe URL** | N/A |
**[fact]** The exact cold-open URL is therefore fully derivable from `location.hash` alone:
```
/api/projects/<encodeURIComponent(id)>/preview?__hf_shader_capture_scale=1&__hf_shader_loading=player
```
### `refreshKey` does _not_ remount the player
**[fact]** `nle/NLEContext.tsx:113-118` — a `refreshKey` bump calls `refreshPlayer()`, which mutates `iframe.src` in place (`useTimelinePlayer.ts:457-476`). The React element is not remounted. **[inference]** Any element that exists at the right place in the DOM survives every reload path; only a `directUrl` change remounts, because `activeKey = directUrl ?? projectId` keys the `<Player>` (`NLEPreview.tsx:130,468`).
### Why fonts is a dead end (question 2)
**[fact]** Search performed, not sampled: `grep -rn "@font-face|woff|fonts.googleapis|fonts.gstatic|document.fonts|FontFace" packages/studio/src packages/studio/index.html packages/studio/public`.
- The shell's body font is a **pure system stack**: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` (`packages/studio/src/styles/studio.css:19`); mono stacks at `:293,331,426` are also local-only.
- `packages/studio/index.html` has **no** stylesheet link, no preconnect, no font at all — one favicon and one module script.
- `packages/studio/tailwind.config.js` adds no `fontFamily` override, so Tailwind preflight's `ui-sans-serif, system-ui, …` applies. No `transformIndexHtml` plugin injects anything (`packages/studio/vite.config.ts:235`, plugins are `react()` and `devProjectApi()` only).
- The only remote fonts in Studio are **on-demand and post-mount**: `components/editor/propertyPanelFont.tsx:88-108` injects a Google Fonts `<link>` when a `FontFamilyField` renders with a family (`:211`) or the user picks one (`:376`); `fontCatalog.ts:131` builds those URLs with `&display=swap` already.
**Conclusion.** There is no web font in the shell's cold path, so `font-display`, `<link rel="preload">` for fonts, and deferring the "font-dependent subtree" have nothing to act on — the subtree is _all_ text, and the trace's `<p>hi</p>` repro says a single paragraph is enough. **This plan pulls the "stop serialising behind it" lever, not the "shrink it" lever**, because the stall is Chrome's `FontFaceSet` machinery on a loaded machine and we have no input to it.
---
## Options and tradeoffs
### The constraint that decides everything
**[fact]** The preview iframe is same-origin and must stay so: Studio reads `contentDocument` everywhere — `NLEPreview.tsx:66-82` (`readPreviewCompositionSize`), `Player.tsx:79-100` (`hasUnloadedAssets`), the DOM-edit session, the timeline adapters, `previewMessageRouter.ts:99-105`.
**[inference, load-bearing]** A same-origin iframe lives in the parent's renderer process and on the parent's main thread. **The warm frame's HTML parsing, JS and layout cannot run while the parent is inside a 3.04 s synchronous task.** What _can_ overlap is everything off-thread: the HTTP request, the server's work, and subresource fetches. Hence the realistic prize is the **0.74 s** between "iframe created" and "document commits", plus the composition's asset fetches — not the composition's own script/layout time. Cross-origin (which would give true parallelism via site isolation) is off the table: it breaks every `contentDocument` reader listed above.
Say the honest bound up front: **`max(stall, network+server part of composition load) + composition main-thread part`**, not `max(stall, composition load)`.
### (a) Static `<hyperframes-player>` in `index.html`, adopted by React
Two sub-variants, both **rejected**:
- **Real adoption (React re-parents the pre-made element into the preview pane).** **[fact]** Removing and re-inserting an iframe discards its nested browsing context; the document reloads. Re-parenting throws away exactly the work we started early. Dead on arrival.
- **Never re-parent: the player lives in a fixed-position layer that tracks the preview pane's rect.** Preserves the load, but breaks: preview-only fullscreen (`PreviewPane.tsx:88-96` calls `requestFullscreen()` on the pane container — a fixed overlay outside it would not be included), the zoom/pan stage transform (`NLEPreview.tsx:190-197,437-447`), the drag-drop hit testing (`usePreviewBlockDrop`), and the overlay z-stack. Large diff, permanent complexity, for a win we can get another way.
Also **[fact]**: a `<hyperframes-player src>` in `index.html` does nothing until the custom element is _defined_ and upgraded (`hyperframes-player.ts:227` bails when `!isConnected`… and an undefined element has no callbacks at all). That means shipping the player bundle as a second head script — more startup bytes on the main thread, for a start no earlier than a plain `<iframe>`.
### (a′) Throwaway warm subframe in `index.html` — **the one that works**
A ~15-line inline script at the top of `<body>` that reads `location.hash`, and if it names a project, appends a hidden `<iframe>` to the same URL the player will use. It is never moved and never adopted; it is removed once the real preview fires `load`.
- **[inference]** Chrome's HTTP cache is keyed by `(top-frame site, frame site, url)` **plus a "subframe document resource" boolean**. A warm _subframe navigation_ shares that key with the real subframe navigation; a top-frame `fetch()` does **not**. This is why (a′) beats (b) in the browser, and why it also warms the composition's **subresources** (runtime script, the gsap CDN tags injected at `packages/studio-server/src/routes/preview.ts:45-48`, images, media) — not just the HTML.
- **[fact]** The response is `Cache-Control: private, no-cache` + `ETag` (`routes/preview.ts:310-312`), so it _is_ stored and the real navigation revalidates. **[fact]** The 304 short-circuit (`:332-339`) happens **before** any bundling or disk write, so the second request is cheap.
- **[fact]** Message safety: a phantom frame cannot corrupt state. Studio filters by sender (`previewMessageRouter.ts:57` `isForeignSource`) and the player filters by `event.source !== frameWindow` (`packages/player/src/runtime-message-handler.ts:64`).
**Failure modes.** No project in the hash → script no-ops, `useServerConnection` picks `projects[0]` (`useServerConnection.ts:47-52`) and nothing is warmed (correct: warming the wrong project is worse than not warming). `refreshKey` bump → irrelevant; `refreshPlayer` mutates `iframe.src` with a `_t` buster and never remounts (`useTimelinePlayer.ts:470-475`). Shader flags change → they are constants at `Player.tsx:273-274`; if that ever stops being true the warm URL silently misses the cache — guarded by the parity test in U3. Non-master `activeCompPath` restored from the URL → the `<Player>` remounts on the new `directUrl` key and the warm work is wasted (no breakage, no win). `?view=storyboard` (`packages/cli/src/commands/preview.ts:1183`) → `EditorShell` is only `hidden`, still mounted, so the warm still pays. HMR → the warm frame is one-shot and already gone; nothing to re-adopt.
**Costs.** The composition executes twice (memory, double media decode) unless the warm frame is `display:none` — which suppresses rAF and rendering, so it parses and fetches but does not run the animation. Concurrent cold requests can double-bundle and race `persistHfIdsIfNeeded` (`routes/preview.ts:341-344`) — a pre-existing hazard for any double request, but this makes it routine.
### (b) `<link rel="preload">` / early `fetch()`
Cheap and risk-free, but **[inference]** likely does not warm the browser cache for the subframe navigation (cache-key boolean above), and does not warm subresources at all. What it _does_ warm is the server, which (d) does better and earlier.
### (c) Mount the preview column before the font-dependent panels
**[fact]** The stall is one layout of the whole document, and passive effects run **after** layout — `Player.tsx:158` is `useMountEffect` = `useEffect` (`useMountEffect.ts:15`). Reordering React children cannot get an effect in front of the first layout. Making it work would need a static import of `@hyperframes/player` (explicitly avoided, `Player.tsx:6-8`) _plus_ `useLayoutEffect`, and would still only recover the **0.11 s** between stall-end and iframe-creation in the trace. Not worth the SSR/registration risk on its own.
**[recommendation]** Keep one crumb of it: kick the chunk fetch at module scope (U4), so the dynamic import is already resolved when the effect runs.
### (d) Prewarm the server — found in the code, and probably the biggest single win
**[fact]** The first preview request pays a **cold module load of the compiler**, on both server paths:
- Vite dev: `packages/studio/vite.adapter.ts:127-138` — `server.ssrLoadModule("@hyperframes/core/compiler")`, memoised in `_bundler` after the first call.
- CLI bundle: `packages/cli/src/server/studioServer.ts:395-402` — `await import("@hyperframes/core/compiler")`.
Plus `transformPreviewHtml` dynamically importing `producer/services/deterministicFonts.js` (`vite.adapter.ts:249`), the project signature cache, and the media-codec probe cache (`routes/preview.ts:318`).
**[inference — the single most testable claim in this document]** Most of the measured **0.74 s** between iframe creation and document commit is that first-request server cost, not the network. If so, one `fetch()` from the CLI _before it spawns the browser_ removes it from the user's critical path entirely, with **zero** browser-side risk.
**[fact]** There is exactly one funnel for it: `openStudioBrowser` (`packages/cli/src/commands/preview.ts:1261-1273`), called from all four sites (`:546, :1399, :1624, :1646`). Warming _before_ the `noOpen` early-return also helps users who paste the URL.
---
## Recommendation and why
**Ship (d) first, measure, then ship (a′) only if the trace still shows a gap.**
1. **U1 — server prewarm.** ~5 lines in one function, no client change, no flag, no API surface. If the inference holds it deletes most of the 0.74 s for _every_ entry path including the project picker, and it does so before the browser process even exists — the most parallel possible placement.
2. **Re-trace.** If the browser's first preview request still takes > 300 ms to commit, or the composition's subresources dominate, ship **U2 (a′)**: the throwaway warm subframe. It is the only option that starts the real subframe request before React exists and warms the same cache partition the real frame will read.
3. **U4** (module-scope chunk kick) is a one-liner that shaves the remaining 0.11 s tail; take it whenever.
Rejected for this plan: (a) real adoption (re-parenting reloads the frame; the fixed-overlay variant breaks fullscreen and zoom), (b) top-frame `fetch` (weaker than both alternatives), (c) React reordering (cannot precede the first layout).
**[recommendation]** Do not chase `max(stall, composition load)` as literally stated — the same-thread constraint makes the composition's own script/layout time unrecoverable without a cross-origin frame, which the DOM-editing contract forbids. Target: **the preview document is committed before the stall ends**, so only the composition's ~0.45 s main-thread tail remains after it.
---
## Units of work
### U1 — Prewarm the preview route before the browser opens
**DoD:** After the CLI prints the Studio URL, the browser's first `GET /api/projects/<id>/preview` is served with the compiler already imported and the bundle already computed — verified by two consecutive `curl -w '%{time_total}'` calls where the _second_ is < 100 ms on the demo project, and by the first being the one that pays.
**Files:** `packages/cli/src/commands/preview.ts` (inside `openStudioBrowser`, `:1261`, before the `noOpen` return); optionally `packages/studio/vite.config.ts` (`configureServer`, `:67`) if the owner's cold-open path is the Vite dev server.
**Notes:** fire-and-forget, `.catch(() => {})`, no await, no output on failure. URL must be `/api/projects/${encodeURIComponent(projectName)}/preview` (server-side warming does not need the shader params — those do not change the bundle, only the query).
### U2 — Measure where the 0.74 s actually goes _(gate for U3)_
**DoD:** A one-run answer, written into this document, splitting the 0.74 s into server handler time vs. transfer vs. renderer dispatch — from `curl` timings against a cold server plus the `ResourceSendRequest` → `ResourceReceiveResponse` deltas already in the trace.
**Files:** none (measurement only).
### U3 — Parse-time warm subframe _(only if U2 leaves > 300 ms, or subresources dominate)_
**DoD:** On a cold load whose URL carries `#project/<id>`, the preview document request is in flight **before** the shell's first layout event starts, and the warm iframe is removed from the DOM within 1 s of the real preview's `load`.
**Files:** `packages/studio/index.html` (inline script, `display:none` iframe, `sandbox="allow-scripts allow-same-origin"` + `referrerpolicy="no-referrer"` to match `iframe-dom.ts:47-51`, opt-out on `?hfNoWarm`); `packages/studio/src/player/components/Player.tsx` (remove the warm node in `handleLoad`, `:196`); one new test.
**Test (drift guard, required):** a vitest that recomputes the expected URL from the real helpers — `prepareSrcForElement` (`shader-options.ts:139`) over an element carrying `shader-capture-scale="1"` / `shader-loading="player"` and `buildProjectApiPath(id, "/preview")` — and asserts the string embedded in `index.html` matches. If anyone changes the shader attributes, the warm URL stops matching the cache key and this test fails instead of the win silently evaporating.
### U4 — Start the player chunk fetch at module scope
**DoD:** In a cold trace, the request for the `@hyperframes/player` chunk appears before the shell's first layout, and `Player.test.ts` still passes unchanged.
**Files:** `packages/studio/src/player/components/Player.tsx:170` — hoist to a module-scope `const playerModule = typeof window === "undefined" ? null : import("@hyperframes/player")`, awaited in the effect. The `typeof window` guard preserves the SSR contract documented at `:6-8`.
---
## Verification
**[fact]** The rig already exists: `hyperframes preview --browser-path <chrome> --user-data-dir <fresh dir> --remote-debugging-port 9222` (`packages/cli/src/utils/openBrowser.ts:44-66`, validated at `packages/cli/src/commands/preview.ts:437-441`).
Procedure, per run (baseline and candidate, 5 runs each, same machine, same load, report the median and note idle vs. loaded):
1. Kill any running preview server. a **fresh** `--user-data-dir` per run; a warm profile has the preview HTML cached and hides the whole effect.
2. Start with `--no-open` so the browser launch is under the harness's control, and note the URL.
3. Throwaway node script (puppeteer-core is already a dependency of `@hyperframes/engine`): launch Chrome with the fresh profile, `page.tracing.start({ path: "<out-dir>/run-N.json", categories: ["devtools.timeline", "blink.user_timing", "disabled-by-default-devtools.timeline.frame"] })`, `page.goto(<deep link>)`, wait 15 s, `tracing.stop()`. Do not commit the script.
4. Extract four timestamps from the JSON: (i) `navigationStart` of the top frame; (ii) `ts` of the ≥ 1 s `Layout` event in the top frame (the stall) and its `dur`; (iii) `ts` of the `ResourceSendRequest` whose `args.data.url` ends in `/preview?__hf_shader_capture_scale=1&__hf_shader_loading=player`; (iv) `ts` of the `Layout` in the preview frame with ~1,602 objects.
**Pass criterion (all three):**
- **P1 — ordering:** (iii) < start of (ii). The preview request is sent before the stall begins.
- **P2 — overlap realised:** the preview frame's `commitLoad` occurs within 150 ms of the stall's end (today: ~850 ms after it).
- **P3 — user-visible:** median (iv) − (i) improves by **≥ 0.6 s** versus baseline on an idle machine, and by ≥ 0.6 s under the same synthetic load used for the 11.9 s run.
**Falsifier.** If **P1 holds but P2 and P3 do not**, the inference about what can overlap a long parent task is wrong for this case: the renderer serialised the subframe commit behind the parent anyway, the change buys nothing, and U3 must be reverted (keep U1, which is measured independently by its own `curl` DoD). If P3 improves by less than the U2-measured server cost, U1 did not land where we thought — re-open U2.
A prose argument that "it must be faster now" does not close this. Only the trace does.
---
## Invariant
> **On a cold Studio open whose URL names a project, the request for that project's preview document is already in flight before the shell's first layout task begins — the preview's load never waits on React.**
---
## Risks and rollback
| Risk | Where | Mitigation |
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Double bundling / `persistHfIdsIfNeeded` write race when the warm request and the real navigation overlap while cold | `routes/preview.ts:341-344` | The 304 path (`:332-339`) precedes all writes, so only the _fully concurrent cold_ case doubles up. Pre-existing (any double refresh does this). Flag to the owner; do not fix here. |
| Warm URL drifts from the real URL, silently losing the cache hit | `Player.tsx:273-274` vs `index.html` | The U3 parity test fails loudly. |
| Phantom frame confuses timeline/player state | — | **[fact]** Both listeners filter by sender: `previewMessageRouter.ts:57`, `runtime-message-handler.ts:64`. |
| Extra memory / media decode from the warm frame | — | `display:none` (no rAF, no rendering); removed on the real `load`. |
| Warm request for a project that then changes (`activeCompPath`, project picker) | `useCompositionStack.ts:110`, `useServerConnection.ts:47` | Wasted work only; no user-visible breakage. |
| Public web-component behaviour changes | — | **[fact]** No unit touches `packages/player/**`. |
**Flag & rollback.** U1: no flag; revert = delete the `void fetch(...)` line. U3: kill switch `?hfNoWarm=1` in the URL (read by the inline script itself, so it works even if the bundle is broken); revert = delete the `<script>` block from `index.html` and the removal hook in `Player.tsx`. U4: revert one line. No migrations, no persisted state, nothing to un-ship on the server.
**Existing tests that cover this ground and will need attention:**
- `packages/studio/src/player/components/Player.test.ts` — mounts the real `Player` against a fake custom element and asserts **listener-before-`src` ordering** (`:117-129`) and clean unmount when detached (`:106-115`). U3's removal hook and U4's hoisted import both run through this file.
- `packages/studio/src/components/nle/NLEPreview.test.ts` — mocks `Player`; covers `getPreviewPlayerKey` / stage sizing. Unaffected unless the mount contract changes.
- `packages/studio-server/src/routes/preview.test.ts` — the route's ETag/304 behaviour, which U1 leans on. Add a case only if the warm changes route behaviour (it should not).
- `packages/cli/src/commands/preview.test.ts` — where U1's prewarm assertion belongs (`openStudioBrowser` issues the request before spawning, and does so even under `--no-open`).
---
## Open questions for the owner
1. **Which server is on the measured path** — `hyperframes preview` (CLI bundle, `studioServer.ts:395`) or the Vite dev server (`vite.adapter.ts:129`)? U1 lands in one place or two. _No default taken._
2. **Is a phantom double-execution of the composition acceptable** (memory, duplicate media fetch, agent-observable second request in logs)? Yes → U3 as specified. No → fall back to the weaker top-frame `fetch` warm.
3. **Should the warm follow a restored non-master `activeCompPath`?** It cannot be known at parse time (needs the file-tree fetch). Master-only, or add a URL param carrying the comp path so the warm can be exact?
4. **May the shell mount before `/api/projects` answers when the hash names a project** (`App.tsx:453`, `useServerConnection.ts:26`)? That moves the whole timeline earlier but weakens the dead-server detection this gate exists for. Out of scope here; it is the next-largest structural lever.
5. **What is the target number?** "First preview frame in under X s on the reference machine under load." P3 above is a delta, not an absolute. The absolute belongs to the owner.
6. **Is the 3.04 s stall itself worth escalating to Chrome / to the machine's owner?** It reproduces on `<p>hi</p>`; everything in this plan is a workaround for someone else's bug.
---