* 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.
29 KiB
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 touchpackages/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
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.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/projectseven when the hash already names a project — the hash id is used, but only after the fetch settles.App.tsx:497→EditorShell→packages/studio/src/components/EditorShell.tsx→nle/PreviewPane.tsx:125→nle/NLEPreview.tsx:467→player/components/Player.tsx.packages/studio/src/player/components/Player.tsx:158-282is where the DOM work happens, insideuseMountEffect(a plainuseEffect,hooks/useMountEffect.ts:15)::163previewSource = directUrl || buildProjectApiPath(projectId, "/preview"):170await 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):174document.createElement("hyperframes-player"):273-274shader-capture-scale="1",shader-loading="player"— hard-coded constants:281-282setAttribute("src", src)thencontainer.appendChild(player)
packages/player/src/hyperframes-player.ts:220-236—attributeChangedCallbackforsrcbails when!isConnected(:227). So the navigation actually starts inconnectedCallback(:177-189), on theappendChildatPlayer.tsx:282, viaprepareSrcForElement.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,426are also local-only. packages/studio/index.htmlhas no stylesheet link, no preconnect, no font at all — one favicon and one module script.packages/studio/tailwind.config.jsadds nofontFamilyoverride, so Tailwind preflight'sui-sans-serif, system-ui, …applies. NotransformIndexHtmlplugin injects anything (packages/studio/vite.config.ts:235, plugins arereact()anddevProjectApi()only).- The only remote fonts in Studio are on-demand and post-mount:
components/editor/propertyPanelFont.tsx:88-108injects a Google Fonts<link>when aFontFamilyFieldrenders with a family (:211) or the user picks one (:376);fontCatalog.ts:131builds those URLs with&display=swapalready.
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-96callsrequestFullscreen()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-framefetch()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 atpackages/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:57isForeignSource) and the player filters byevent.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_bundlerafter the first call. - CLI bundle:
packages/cli/src/server/studioServer.ts:395-402—await import("@hyperframes/core/compiler"). PlustransformPreviewHtmldynamically importingproducer/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.
- 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.
- 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.
- 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):
- Kill any running preview server. a fresh
--user-data-dirper run; a warm profile has the preview HTML cached and hides the whole effect. - Start with
--no-openso the browser launch is under the harness's control, and note the URL. - 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. - Extract four timestamps from the JSON: (i)
navigationStartof the top frame; (ii)tsof the ≥ 1 sLayoutevent in the top frame (the stall) and itsdur; (iii)tsof theResourceSendRequestwhoseargs.data.urlends in/preview?__hf_shader_capture_scale=1&__hf_shader_loading=player; (iv)tsof theLayoutin 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
commitLoadoccurs 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 realPlayeragainst a fake custom element and asserts listener-before-srcordering (: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— mocksPlayer; coversgetPreviewPlayerKey/ 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 (openStudioBrowserissues the request before spawning, and does so even under--no-open).
Open questions for the owner
- 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. - 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
fetchwarm. - 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? - May the shell mount before
/api/projectsanswers 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. - 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.
- 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.