Commit Graph
1093 Commits
Author SHA1 Message Date
Vance Ingalls c797f54542 chore: release v0.8.58 (#4242) 2026-09-20 20:14:03 -07:00
Vance Ingalls f09ea7af3b chore: release v0.8.57 (#4231) 2026-09-20 14:13:17 -07:00
Miguel Ángel 0c158be165 feat(studio): edit a clip's speed ramp with a rate lane and presets (#4153)
* feat(studio): edit a clip's speed ramp with a rate lane and presets

* fix(studio): keep the live preview attribute in sync with what gets persisted

* fix(cli): snapshot picks the source frame through a rate lane and up to 10x

hyperframes snapshot clamped the constant playback rate at 5 and ignored a
rate lane in data-automation, so a sped-up or ramped clip showed the wrong
frame. It now reads the rate through the runtime's own reader and maps time
with the shared source-time function.

* docs(skills): teach speed ramps as a rate lane in one reference page

Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10
and the 'speed ramps are not supported' lines in the skills, and regenerates
the skills manifest.

* docs(skills): say what fixes a ramped clip's length and how the render approximates pitch

* fix(studio): hide the speed preset until the clip has a known duration

A preset on a clip with no data-duration was stretched over a made-up one
second. The panel now offers presets only when the duration is known.

* docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording
2026-09-20 12:21:47 -04:00
Miguel Ángel e45483aa0a chore: release v0.8.56 (#4218) 2026-09-20 10:57:46 -04:00
Vance Ingalls 96369c8acd chore: release v0.8.55 (#4216) 2026-09-20 00:45:18 -07:00
Miguel Ángel c204b3c757 chore: release v0.8.54 (#4212) 2026-09-20 00:22:52 -04:00
Vance Ingalls d11907c325 chore: release v0.8.53 (#4210) 2026-09-19 20:30:29 -07:00
Miguel Ángel e778100b48 Merge branch 'main' into release/v0.8.52 2026-09-19 18:33:29 -04:00
Miguel Ángel 038851c7a8 feat(core): add an inlineAssets option to skip base64 asset inlining (#4198)
* feat(core): add an inlineAssets option to skip base64 asset inlining

A same-origin caller can now keep every font/image src as its authored path, not a data: URL.

Default true, unchanged for every existing caller.

* fix(core): also stop inlineAssets: false from inlining LUTs

The same bug (a path that reads back as data: URL) applied to LUT references too.

Composes with the pre-existing inlineColorGradingLuts flag; default is unchanged.
2026-09-19 17:57:17 -04:00
Vance Ingalls 4d62e80921 chore: release v0.8.52 2026-09-19 14:38:56 -07:00
Vance Ingalls 1dce615c56 chore: release v0.8.51 (#4178) 2026-09-19 08:51:27 -07:00
Miguel Ángel fa44d7621d feat(core): a composition with no authored length takes it from its clips (#4174)
* feat(core): a composition with no authored length takes it from its clips

A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.

Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.

* refactor(core): resolve the runtime duration floor in one call

The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.

* fix(core): a derived composition length waits for every clip length and reports honestly

Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.

* fix(core): a sub-composition with no known length counts as pending

A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.

* fix(core): a loaded Lottie with no registered animation keeps the length pending

Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.

* test(core): a registered Lottie supplies the length and the clips length is not used

Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.

* test(core): the declared and DotLottie pending branches are covered
2026-09-19 11:27:33 -04:00
Miguel Ángel f4e00ce2b5 fix(runtime): a finished film rests on its last frame instead of a blank one (#4158)
* fix(runtime): a finished film rests on its last frame instead of a blank one

The clip-visibility check was half-open (start <= t < end), so a clip whose
authored end reached the composition's total duration hid at exactly that
instant. The player parks there when playback ends, so a finished film
went blank instead of holding its last frame, in both the published
player and Studio's scrub-to-end.

Extracts the shared half-open comparison used in seven places into
isInClipWindow (packages/core/src/runtime/clipWindow.ts) and adds
isClipVisibleAt, which additionally treats a clip reaching the
composition's terminal instant as visible. Only the DOM-visibility gate
uses the terminal rule; playback and media-activation windows keep the
plain half-open check, so two back-to-back clips still never both show
at their shared boundary and audio never re-activates past the end.

* fix(runtime): hold a terminal video on its last frame at the composition end

* refactor(player): route the proxy clip-window checks through the shared predicate

Makes the composition duration required on syncRuntimeMedia so a caller
cannot drop the terminal rule, exposes the predicate as a core subpath,
and adds a nested-timeline float-noise test.

* build(core): declare the runtime/clip-window subpath in the package-subpaths descriptor

* test(runtime): a speed-ramped video that runs to the composition end holds its last frame

Covers the composition of the terminal hold with a rate lane: at a time
past the clip end the video sits at the source time of its own end.
2026-09-19 10:35:26 -04:00
Miguel Ángel 09aadd86a5 feat(core): video, audio and image length come from one shared resolver (#4166)
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.

The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.

The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
2026-09-19 08:01:28 -04:00
Miguel Ángel bdecae066a feat(engine): render speed ramps with the same source-time mapping as the preview (#4143)
* feat(engine): render speed ramps with the same source-time mapping as the preview

The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer
read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame
the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean
rate, keeping pitch. The compile-time attribute reader now handles single-quoted and
entity-encoded JSON attributes. Adds a speed-ramp regression fixture.

* test(producer): regenerate the speed-ramp golden audio with the current engine

The stored baseline predates the per-slice audio fix, so its audio drifted 1 to 3 percent in
pitch from the expected source-time track and the CI render correlated at 0.61. Rendered in the
CI test image, the new baseline tracks the expected pitch to 0.1 percent median, and the compare
now reports visual 0 failed frames and audio correlation 1.000.

* refactor(core): drop data-preserve-pitch, pitch is preserved by construction

The engine always time-stretches audio with atempo, so an opt-out read only by
the preview made preview and render disagree. Remove the attribute and its
reader so both paths keep pitch.
2026-09-19 08:01:17 -04:00
Miguel Ángel 7f452ad276 feat(core): speed ramps as a rate lane in data-automation (#4129)
* feat(core): speed ramps as a rate lane in data-automation

A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.

* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests

* test(engine): read the playback-rate clamp from core instead of a literal
2026-09-19 06:27:42 -04:00
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* feat(cli): hyperframes timeline prints a project's tracks and clips

* docs(cli): list the timeline command

* feat(cli): timeline command reports unreadable lanes; add skill docs

* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes

* fix(cli): timeline resolves symlinks before the project containment check

* fix(cli): timeline accepts project folders whose name starts with two dots

* refactor(cli): timeline and compositions share the engine's start resolver

* docs(skills): point agents at the timeline command where they decide how to read a project

* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips

Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.

* fix(cli): timeline places nested media at the runtime start and tests its query one-liners

A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.

* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0

The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.

* refactor(core): one media-start rule shared by the runtime and the timeline command

The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.

* test(cli): timeline fixture for a media start written as an expression

parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
2026-09-19 04:27:45 -04:00
Miguel Ángel 1a9668b6d6 docs(skills): the agent presents a tailor-made storyboard instead of a Studio board (#4150)
* docs(skills): the agent presents a tailor-made storyboard page instead of a Studio board

Adds the storyboard recipe (decisions first, beat fields, a self-contained
storyboard.html contact sheet, a review round) distilled from the launch
films, and rewrites the review loop, brief contract, storyboard format and
workflow steps that still described the deleted board and comments file.

* docs(skills): reconcile the sketch pass with the beautiful storyboard sheet

Review found: the recipe's storyboard.html cell spec (real fonts, real
colors) contradicted review-loop's own sketch definition (wireframe, no
brand treatment). The sketch is static, not unstyled; only motion is
deferred to the build pass. Also fixes a frame-packet boundary note, a
craft-device citation, and a provenance overclaim.
2026-09-19 01:07:24 -04:00
Miguel Ángel a25da94b77 refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch

* refactor(studio): fold the thumbnail output branches left after the storyboard mode went

* docs(studio): drop leftover storyboard references after the view was removed

* ci: allow the storyboard view deletions in the no-deletions guard

* docs(studio): wrap an over-long comment line in the caption overlay helper

* style(studio): reindent App.tsx after the view-mode wrapper was removed
2026-09-18 23:47:12 -04:00
Vance Ingalls 42f4afa367 chore: release v0.8.50 2026-09-18 19:41:06 -07:00
Miguel Ángel 14e5f57670 fix(core): script coalescing no longer moves an inline script past a src script (#4133)
* fix(core): script coalescing no longer moves an inline script past a src script

* test(core): pin how the script coalescers group inline runs

* test(core): simplify the pinned-script grouping test
2026-09-18 19:35:08 -04:00
Miguel Ángel cb9ca4f0af chore: release v0.8.49 (#4122) 2026-09-18 17:44:18 -04:00
Miguel Ángel a7282dcd0e fix(player): wait for a cross-origin composition's assets before playing (#4103)
* fix(player): wait for a cross-origin composition's assets before playing

The player's readiness gate scans the iframe document, which is null when the
composition is embedded from another origin, so it released playback at once
and the first frames could play with images still loading.

The runtime now runs the same readiness scan inside the iframe and posts
assets-ready; its timeline message reports whether assets have settled so an
older runtime, which never posts it, is not waited on. Also declares the
animation-frame abort handler before a host whose requestAnimationFrame calls
back synchronously can reach it.

* fix(player): keep a ready cross-origin player ready across a late iframe load

The iframe load event reset readiness for any document the player cannot
read. The runtime posts its timeline before load and a paused runtime never
posts it again, so a load arriving after the assets settled left the player
not ready. A ready cross-origin player already holds this document's
handshake, because every navigation clears readiness first.

* fix(core): stop RuntimeReadinessWindow widening Window.__hf so the runtime typecheck passes

* fix(core): a torn-down runtime no longer posts assets-ready
2026-09-18 17:27:16 -04:00
Miguel Ángel 15460ec541 fix(core): resolve already-errored media immediately in the asset-readiness gate (#4096)
The shared composition-readiness gate only listened for future canplay/error
events on pending media, so an asset whose error already fired before the
gate started listening was never seen -- a DOM error event doesn't refire.
That left the gate burning its full 8s timeout on every load. Measured on a
production composition with 85 duplicate small audio elements pointing at
missing files: 8.68s to 0.83s with this fix, no timeout warning.
2026-09-18 13:19:46 -04:00
heygengenesis[bot]andmiguel.sierra 27303658c1 fix(core): scope selector arrays within compositions (#4035)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-18 11:12:52 -04:00
Miguel Ángel e36a53d6b6 fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration

Adds the compute and paint-and-idle inputs to the composition readiness gate,
plus window.__hf.buildReady: a promise registry a piece registers when it has
setup work no adapter can see (mesh building, shader compiles). The runtime
holds render-ready on it and the player shows its loading state instead of a
blank frame while it resolves.

* refactor(core): dedupe the paint-and-idle readiness test setup

Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput
tests as a 12-line clone; extracted the shared doc/tracking setup.

* fix(core): stop the readiness settled-tracker hanging on 2+ pending promises

Promise.all allocates a new promise every call, so comparing ITS identity
across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never
observes settled once 2+ promises are combined: every poll rebuilds a new
combined promise, re-arms, and republishes forever without the caller ever
seeing true. Reproduced with two already-resolved window.__hf.buildReady
entries, which is the exact multi-piece usage the new authoring doc describes.

Fixes both trackers at once with a shared createSettledTracker that compares
the source promise array by element instead of the Promise.all wrapper.

* fix(core): cancel readiness inputs once settleCompositionReadiness settles

computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept
running after the shared timeout won the race, since nothing told the losing
inputs to stop. One AbortSignal now threads through all three inputs and is
aborted the instant the race settles.

* docs(core): fix a stale sync-callback claim in settleCompositionReadiness

paintAndIdleReadinessInput always has a frame to wait on for any real
document, so the sync fast path this docstring described is no longer
reachable with the default inputs except for a viewless document.
Found by the simplify pass on this branch.

* fix(core): clear a stale buildReady entry on runtime teardown

A never-resolved window.__hf.buildReady promise from a torn-down
composition survived into the next init in the same window (window.__hf
is never reset), permanently blocking render-ready for whatever loaded
next -- the exact black-screen class this branch fixes, reproduced
through its own new mechanism. Found by the independent review.

Also fixes a stale hyperframes-player.ts docstring (media/images/fonts
had not been updated to compute + paint-and-idle) and documents the
buildReady key-collision hazard in three.md.

* fix(core): skip the compute readiness wait when no runtime ever ran

computeReadinessInput polled window.__renderReady for the full shared
timeout on any same-origin document with no HyperFrames runtime
injected (a probe-only composition using native video/duration, or a
blank test iframe) — nothing was ever going to set that flag. Checking
window.__hf first (the same signal composition-probe.ts already treats
as "has a runtime") lets it return null instead of waiting.

* fix(player): debounce the loading overlay and diagnose compute timeouts

paintAndIdleReadinessInput always has a frame to wait on for an
attached iframe, so settleCompositionReadiness's synchronous-settle
path is unreachable in practice now — the loading overlay showed on
every single Play, even ones with nothing genuinely pending. Debounce
the overlay itself instead: a wait that resolves fast never shows it.

Also names compute (window.__renderReady) in the stuck-assets timeout
diagnostic, which previously only re-scanned media, images and fonts.

* fix(engine): name a stuck buildReady key in the zero-duration diagnostic

A build promise a composition registers on window.__hf.buildReady that
never resolves left the export path's zero-duration timeout naming
nothing useful — hasHf/hasSeek/duration all looked fine, since the
runtime was genuinely present and just waiting on that one promise.
Probes buildReady's keys for pending ones and names them in the hint.

* fix(engine): stop the buildReady diagnostic from crashing on a rejection

A microtask race against a bare resolved promise rejected the whole
diagnostic expression on any rejected buildReady entry (the runtime's
own createSettledTracker rejects deliberately) and mis-sorted a settled
non-native thenable as pending, since its extra adoption tick loses the
race. Both measured directly, not inferred.

Settle each key onto its own record past one macrotask instead: no
throw, and pending/resolved/rejected are distinguished correctly. A
rejected key gets its own hint, separate from a merely pending one.

* fix(player): report a backgrounded document instead of overclaiming a paint bound

MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one
that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames
ever fire) still rides the full shared 8s timeout, same as before this
input existed. Corrected the comments that claimed otherwise.

That gap is exactly why the stuck-assets diagnostic left paint-and-idle
unreported: there was no accurate way to say it might be the cause. Added
documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded
tab is the reachable, directly observable case.

* refactor(engine): simplify the buildReady diagnostic rewrite

Skip the macrotask wait entirely when a composition registers no
buildReady keys — the common case, since it's opt-in. Nothing to settle
means nothing to wait on.

Collapse buildZeroDurationDiagnostic's inline parameter type into the
existing HfDiagnostic interface instead of maintaining two copies of
the same shape.

* docs(engine): note the setTimeout(0)-thenable gap the review found

An independent adversarial review, executing hostile inputs rather than
tracing, found the macrotask wait can still misreport a thenable that
resolves via its own internal setTimeout(0), since the diagnostic's own
timer is registered before the thenable's. Bounded and self-correcting
(only feeds the message text, and the diagnostic re-runs every ~1s), so
documenting the boundary rather than chasing an unbounded fix.
2026-09-18 11:12:44 -04:00
Miguel Ángel 7b4f3a22b0 chore: release v0.8.48 (#4085) 2026-09-18 11:11:40 -04:00
Vance Ingalls 415967befa Merge pull request #4063 from heygen-com/release/v0.8.47
chore: release v0.8.47
2026-09-18 01:12:02 -07:00
Miguel Ángel 72b223d57f fix(player): gate play on real composition readiness, not just duration (#4054)
* feat(player): wait for a composition's media, images and fonts before playing

The player used to fire ready/autoplay the moment the timeline resolved,
even if the composition's video, images or fonts hadn't loaded yet -
visible as a flash of missing content or a frozen first frame. It now
waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded
images, document.fonts.ready) before play() proceeds, showing its own
loading overlay (reusing the shader-transition loading panel) while it
waits, bounded by an 8s timeout that plays anyway and logs which assets
were still stuck.

Settles synchronously with no overlay when nothing needs waiting (already
loaded, or a cross-origin composition the player has no DOM access to
inspect), so today's immediate-autoplay behavior is unchanged for the
common case. A new assetsReady getter and assetsready event mirror the
gate for hosts that want to observe it.

* chore(player): drop internal decision-ref from a public-repo comment

An internal tracking id leaked into a code comment and a test name in the
previous commit. This repo is public; internal references don't belong here.

* fix(player): stop a stale asset-wait from settling a newer composition

An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.

Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.

Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.

* fix(player): invalidate the asset wait from every navigation path, not two

The generation-counter fix only bumped at two of the six places that set
_ready = false ahead of a navigation, reload, or disconnect - src/srcdoc
attribute changes, a sandbox-origin policy reload, and a shader-options
reload all started a new navigation without invalidating the previous
wait, leaving the same race open through those paths.

One _invalidateAssetsWait() helper now owns the reset (assetsReady,
pendingPlay, generation, the loading attribute, hiding the overlay) and
every one of the six sites calls it before starting whatever comes next.

* fix(player): gate play on real composition readiness, not just duration

Add a shared composition-readiness gate in packages/core, exported as the
@hyperframes/core/composition-readiness subpath. It resolves once every
declared input settles (media for now: video/audio/image/font loads), or
after a timeout, and calls back synchronously when nothing is pending so a
caller that plays immediately after sees the decision already applied.

The player element now queues an early play() call instead of dropping it
when called before the runtime is ready, and both of Studio's readiness
call sites (the direct-adapter path and the message-based fallback) go
through one store action, requestTimelineReady, instead of duplicating a
generation-counter-and-settle pattern across two hook files.

* fix(player): stop a queued early play() and a project switch from double-firing readiness

An independent review of the readiness gate found two real races:

play() queued before the runtime was ready re-ran its full body once ready,
dispatching the public "play" event twice for one user action. It now
dispatches "play" only on the call that actually starts playback.

Studio's requestTimelineReady generation guard was never invalidated by a
project switch or a full timeline reset, so a slow-loading project's
in-flight wait could resolve after the switch and mark the new, unchecked
project ready. Resetting the timeline now bumps the generation, and setting
timelineReady directly does too.

* fix(player): cancel a queued play on pause or seek during the asset wait

A play() queued while assets buffer (_pendingPlay) used to fire once they
settled even after an explicit pause or seek during the wait. pause() and
seek() now clear the flag, so a user pause/seek wins.
2026-09-18 03:23:21 -04:00
Vance Ingalls 5b7ff519a5 chore: release v0.8.47 2026-09-17 23:55:00 -07:00
Miguel Ángel 2de3ca5f86 fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it (#4053)
* fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it

TransportClock's monotonic time is a bare wall-clock read with no
protection against a long gap between reads. On a composition whose
first real paint blocks the main thread for several seconds, nothing
reads the clock during that time; the next read reported the whole
gap as elapsed playback, so the composition jumped forward instead of
continuing from where it froze.

A gap over 500ms is now treated as a stall and corrected to a 33ms
advance instead of reported whole, matching GSAP's own ticker default.
The correction lives inside now() itself so every reader benefits,
since this clock is read every real frame by more than one driver.
Render/export capture is unaffected: it always pauses and seeks to an
exact time rather than playing, so the correction never engages there.

* chore(player): drop internal tracker references from stall-policy comments

* fix(player): stop the stall correction from corrupting the clock while audio is authoritative

An independent review found that the previous commit's correction ran
unconditionally on every read, including while an attached audio
source was providing the time. Audio keeps playing through a
JS-thread stall, so a gap between reads there is not a stall — but the
correction still shifted the clock's internal reference point, and
that corruption only surfaced later, on an unrelated read, once the
clock fell back to monotonic timing (audio pausing or buffering).

The correction now runs only in the monotonic branch, after audio has
had its chance to answer. Added a regression test that fails against
the previous placement and passes against this one.

* fix(player): keep the stall correction's read timestamp fresh through an audio-authoritative stretch

A second independent review found the previous commit's fix was
incomplete: it stopped a stall correction from firing while audio was
answering a read, but the internal timestamp it compares against only
moved on monotonic reads. Any monotonic read from before audio took
over stayed the comparison point for as long as audio kept answering,
so the whole audio-authoritative stretch read as one giant stall the
moment audio dropped out again — reproducing the exact corruption the
previous commit set out to fix, through a different door. The same
gap also meant a real, non-stalled audio outage got smoothed away to
almost nothing instead of advancing normally.

The read timestamp now moves on every read, audio-answered or not;
only the correction itself stays scoped to the monotonic branch. Two
regression tests reproduce both failure modes and pass here; both were
confirmed to fail against the prior placement.

* fix(player): stop comparing a monotonic read against a timestamp audio made meaningless

A third review found the previous commit's fix was still incomplete:
keeping the read timestamp fresh during audio-authoritative reads
stops it going stale, but a real gap with no reads at all — the main
thread genuinely frozen while an attached audio element kept playing
on its own, unaffected by JS scheduling — still read as a stall the
moment audio next failed to answer, discarding real elapsed time that
audio had been correctly tracking the whole way.

An audio-answered read now clears the timestamp instead of stamping
it, the same as a seek. The next monotonic read then starts from
`_playStartMs`, which nothing but a genuine discontinuity (play, seek,
rate change, detach) ever touches — an anchor that already reconstructs
real elapsed time correctly on its own, without needing the correction
to reason about a comparison point it has no way to make meaningful
once audio was involved. A stall between two purely monotonic reads,
with no audio in between, is unaffected and still corrected exactly as
before.

Two regression tests cover the transition: a real unread gap while
audio was tracking correctly must not be discarded, and a sustained
audio outage must still advance at real speed rather than being
smoothed to nothing. Both were confirmed to fail against the prior
commit's placement.

* chore(core): trim clock comments
2026-09-18 01:50:05 -04:00
Miguel Ángel a8a9fdb0fe chore: prepare v0.8.46 (#4033)
* chore: prepare v0.8.46

Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag.
Changelog rewritten in plain words for the one change since v0.8.45
(#4031, adaptive per-frame motion-blur sample count). Cutting the
release is a separate step on Miguel's word.

* docs(release): fix false render-time savings claim in v0.8.46 notes

Adaptive sampling never drops below the old fixed-16 default; it only
adds cost on frames that measure real spatial motion. Per Miga's review
on #4033.
2026-09-17 01:48:54 -04:00
miga-heygenandMiguel Ángel 484a06fb65 chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 22:56:40 -04:00
miga-heygenandMiguel Ángel 0a1c6f5a45 feat(engine): sub-frame multi-sample motion blur, capture-level accumulation (#4019)
Deliverable one of issue #4010: average K sub-frame captures into each output
frame so rotation, scale and opacity blur the way After Effects does, which the
translation-only catalog component (#4008) cannot do. Opt in per render with
CaptureOptions.motionBlur; absent means the capture path is unchanged.

The blocker was quantizeTimeToFrame flooring every seek onto the output frame
grid, which collapsed all K samples onto one instant and made the averaged frame
byte-identical to the unblurred one. quantizeSeekTime now owns that decision at
all three quantize sites (runtime player, its no-timeline fallback, and the init
transport) and refines the grid by an integer subdivision count, which can only
contain the output grid, never move a frame-time instant off it. The finer grid
rounds rather than floors: a sample time round-trips through the page bridge as
a float, and past a few million ticks that error exceeds the floor epsilon.

Accumulation sits inside captureFrameCore AFTER the static-dedup early return,
so a static frame still costs one reused buffer and only moving frames pay K. It
is not a before-capture hook, which would have disabled dedup outright.

Callback invariant: one eventful seek per output frame at the frame time, then K
suppressed sample seeks, then a suppressed restore to the frame time. The
restore matters because a suppressed seek still moves the playhead, so without
it the next frame's eventful seek would swallow the callbacks inside the shutter
window.

Scope of this deliverable: screenshot capture mode and PNG output. beginframe,
drawelement and JPEG encoding are rejected with a named error rather than
silently rendering unblurred. Video content is rejected for the same reason it
is a documented limitation: injected frames are extracted per output frame and
cannot follow a sub-frame seek.

Blend defaults to sRGB, matching AE's non-linearised 8-bpc working space and
the catalog component. Linear light is an explicit opt-in.

Samples are folded into a running average as they are captured rather than held
until the end, so peak memory is one accumulator plus one decoded sample instead
of all K: at 1080p that is a constant 41 MB rather than 141 MB at 16 samples and
540 MB at the 64 maximum, on every frame of the render.

PNG chunk CRC32 now has one owner in utils/crc32.ts, shared by the new encoder
and ffprobe's metadata reader. The encoder previously imported zlib's crc32 by
name, which throws at module evaluation on Node 22.0 and 22.1; ffprobe already
carried the guarded version and the comment explaining why. The sRGB transfer
function is likewise exported once and shared.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 20:10:06 -04:00
miga-heygenandMiguel Ángel 3c9462feea chore: release v0.8.44 (#4016)
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
  false positive v0.8.43 shipped with; a top-level mismatch is still reported
  and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
  same arguments as the disk encoder. Off by default, so an existing render
  produces an unchanged FFmpeg arg list (#3973)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 19:14:51 -04:00
miga-heygenandMiguel Ángel d6fe66707f chore: release v0.8.43 (#4007)
- Portrait compositions render the full frame when the project was
  scaffolded at a different size: html/body are sized to the composition
  root at runtime; new lint warning root_dimensions_mismatch (#4005,
  fixes #4001)
- render_complete telemetry adds composition_element_tags,
  aroll_video_count, heygen_video_count; render_error adds error_name and
  failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
  search no longer shows [object Object] (#4006)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 14:05:29 -04:00
miga-heygenandMiguel Ángel eb0957a497 fix(core,lint): keep html/body sized to the composition root, warn when a scaffold's copies drift (#4005)
A composition whose root declares portrait data-width/data-height renders
at the correct output size, but a project scaffolded landscape (init
without --resolution) still ships html/body CSS and a viewport meta at the
old landscape size. The stale body's overflow:hidden then clips the
correctly-sized root at the old height, so everything past it renders as
page background instead of real content.

applyCompositionSizing now mirrors the SAME forced width/height it already
computes for the root onto documentElement/body, reusing rather than
recomputing them. overflow:hidden is left untouched (a deliberate
anti-white-bar guard set earlier in the same init pass): once body's own
size agrees with the root it contains, it clips nothing that matters.
Runs in both preview and render, since both load the one bundled runtime
artifact this file builds into.

Adds a warning-level root_dimensions_mismatch lint rule alongside
root_missing_dimensions, comparing the root's dims against the html/body
CSS block and the viewport meta - the two other places a scaffold's
resolution lives besides the root itself. The read-only regexes live in
@hyperframes/parsers (packages/lint cannot depend on packages/cli, which
owns the closest existing pattern in applyResolutionPreset) so a future
caller has one place to update if the scaffold's shape changes, rather
than a rule silently comparing against a guess.

Closes #4001.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 12:37:11 -04:00
miga-heygenandMiguel Ángel 71b274bbfd chore: release v0.8.42 (#3988)
* chore: release v0.8.42

- Studio preview works for projects whose path has non-Latin-1 characters (#3982)
- Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984)
- check --json reports HDR auto-promotion; render prints one warning (#3985)
- Forced --sdr renders tone-map HDR sources before extraction (#3981)
- Render/preview workers no longer survive as orphans after Ctrl-C or parent exit;
  cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for
  intentionally detached renders (#3974)
- HDR preflight test coverage for held tails (#3963)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom and scope

- #3982 entry: a Studio preview server crash for compositions whose file path
  has non-Latin-1 characters (the project directory is not involved)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom

- #3982 entry: the uncaught ETag TypeError took the preview server down, so
  call it a Studio preview server crash rather than a preview failure

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 09:40:06 -04:00
terencecho edf6e4b373 chore: release v0.8.41 (#3966) 2026-09-15 17:24:09 -04:00
James Russo 8886743f44 fix(core): tell an opaque document how to actually get its Web Audio back (#3940)
The opaque-origin fix in #3937 routed correctly but sent the diagnostic down
a dead end. An opaque document reached the same `cross_origin_no_cors` verdict
a genuinely foreign asset gets, so the message told the author to "proxy or
download the asset to a same-origin URL". That is impossible advice, because
nothing is same-origin with an opaque origin and the asset is usually
same-origin by URL already.

Split the verdict into two reasons and give each its own note and remedy. The
opaque case now names the two fixes that work: `allow-same-origin` on the
sandbox, or `crossorigin` plus an `Access-Control-Allow-Origin` of `null` or
`*`. Opacity wins when both conditions hold, since it is the blocking one and
the one an author cannot fix by moving the asset.

The CLI scraper matches on the code prefix only (checkBrowser.ts:309), so the
new reason value does not touch that contract.

- Rames
2026-09-14 11:56:45 -07:00
Miguel Ángel cfe5dcfad3 chore: release v0.8.40 (#3938) 2026-09-14 12:41:47 -04:00
Miguel Ángel 293829a2c1 fix(core): route media through Web Audio only when the document can actually read it (#3937)
isCorsSilenced() compared a media URL's origin against window.location.origin,
which stays URL-shaped even inside a sandboxed iframe without
allow-same-origin. Inside such a frame the document's actual security origin
(window.origin) is the string "null", so same-URL-origin media without a
crossorigin attribute was waved through createMediaElementSource, and per the
Web Audio spec's MediaElementAudioSourceNode security section that node
outputs silence without throwing. The composition kept playing with the
timeline advancing and no audio at all.

Compare against window.origin instead, treating "null" as never same-origin,
so an opaque document falls back to the existing decode-only path unless the
media element opts in with crossorigin.
2026-09-14 12:32:38 -04:00
Miguel Ángel d13a89b670 chore: release v0.8.39 (#3936) 2026-09-14 11:13:53 -04:00
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) 2026-09-13 22:02:19 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángel cd975d8c28 fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)
`bundleToSingleHtml` documented itself as producing "a single
self-contained HTML file", but `INLINE_MIME` covered only `.svg`,
`.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed
a live project-relative reference.

That is invisible to every consumer in this repo, because each one
serves the bundled string from a server rooted at the project directory,
so the relative paths resolve. It breaks the moment the bundle is stored
on its own, with no sibling asset directory: the font 404s and the page
silently reflows in a fallback face, which is worse than a visible
failure.

Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images
(png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is
measured against this repo's own assets rather than guessed: the largest
of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked
raster images is 2.00 MB, so everything in-tree inlines while a
video-sized file cannot. Oversized assets keep their relative URL and
warn, reusing the existing "may not be self-contained" wording.

Audio and video stay external on purpose: they are large, streamed
rather than laid out, and their absence is obvious rather than silent.
Scripts already had a better path (`script[src]` is folded in as source),
so they are deliberately not added to the MIME table.

The five rebasing tests that asserted a relative path survived now
assert the data URL's decoded content instead. That is a stronger check:
resolving from the wrong base directory finds no file, so nothing
inlines and the assertion fails.
2026-09-13 12:47:01 -04:00
miga-heygenandMiguel Ángel 1acc65dbf8 fix(core): un-hide a later root-level clip instead of leaving it display:none forever (#3893)
A root-level `[data-start]` clip with no authored `position` starts out
`position: static` until the runtime forces it to `position: absolute`. A
visibility pass over the clip while it's still inactive can observe the
pre-forcing `static` value and cache it as in-flow, which correctly hides
the clip with `display: none`. But by the time the clip later becomes
active, that same cache has been invalidated and recomputed against the
now-forced `absolute` position — so the un-hide check, which re-derived the
same cached fact instead of tracking what it had actually done, saw
"not in-flow" and skipped the removal. The clip stayed `display: none` (and
therefore zero-sized) for the rest of the render.

Tracks whether this code applied `display: none` to a given element in its
own WeakSet instead of re-deriving the in-flow cache, so the un-hide check
can no longer disagree with the hide check that set it.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-13 03:58:12 +00:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) 2026-09-12 13:03:01 -04:00
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
Vance IngallsandClaude Fable 5.1 4ba8396c27 chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:30:23 -07:00