* perf(core): a project with many composition variables builds without a style scan per variable
* perf(core): a build checks each project path once, however often a scene is mounted
* perf(core): collecting a page's characters for fonts no longer copies the page into arrays
* perf(core): a warm font cache builds the page without asking Google Fonts again
The woff2 files were cached on disk, but the CSS lookup that names them was
only cached in memory. Each new process asked fonts.googleapis.com first, and
offline the lookup failed before the cached woff2 files were ever reached.
The lookup is now kept on disk beside them, keyed by its request URL. Only a
lookup that named faces is kept, written aside and renamed into place.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(core): a build resolves and reads each inlined asset once, however often a scene is mounted
Inlining a relative asset as a data: URL ran isSafePath on it, which realpaths
the path and every ancestor, then statted, read and base64-encoded the file.
It did so for every url() of every mount, so a scene mounted 160 times paid
for the same few files 160 times. The answers are now kept for the build: a
path's safety is shared with the entry-path checks, and an asset's data: URL
(or its too-large warning) is kept by file path.
The bundler also tells onRead about each path once per build. The preview's
read tracker realpaths and readlinks every path it is told about, and it was
told again for every mount.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(core): a scene mounted many times is read, parsed and scoped once per build
Every mount of a sub-composition re-read its file, re-stamped its hf ids,
parsed it four times (twice to check it is usable, twice to mount it) and
re-ran postcss over each of its stylesheets, though only the scope selector,
ids and variables differ between mounts.
- The bundler reads and stamps each scene file once per build.
- inlineSubCompositions parses each file once, answers the usability check
from that parse, and gives each mount its own clone to edit.
- createCompositionCssScoper scopes a sheet once around a placeholder scope
shaped like the real ones, then substitutes each mount's scope. It falls
back to scopeCssToComposition for any scope or id outside [\w.:-], a scope
equal to the authored id, a sheet holding the placeholder, or a selector
kept raw for a comment.
A golden test pins the preview and render pages of a representative project
(goldens written by the build before this change), and a fuzz test compares
the scoper with scopeCssToComposition over random sheets and scopes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(core): a project with many composition variables builds without a style scan per variable
* perf(core): a build checks each project path once, however often a scene is mounted
* perf(core): collecting a page's characters for fonts no longer copies the page into arrays
Add the three pure modules capture motion blur is built from: pose (an
element's box as an affine in composition space, and the poses between two
times), discover (find the elements that move fast enough for a smear to
show) and composite (redraw each mover along its path into one frame).
Nothing calls them yet; the runtime that wires them up lands in the next
change.
The pose reads the individual translate, rotate and scale properties in CSS
order, adds the offsetParent border back and subtracts ancestor scroll, so it
matches getBoundingClientRect for a box centered with translate: -50% -50%.
Folded review fixes: R3 (pose half; the scan's 3D skip for individual
transforms lands with the mover scan).
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(core): grade an image that appears after its scene starts hidden
A graded <img> was only picked up if it was visible when grading scanned the page. An image in a
layer revealed later by a timeline, or in a sub-composition whose host starts later, stayed
ungraded in snapshot and render. Each draw pass now adopts graded media that has come into view.
* fix(core): keep the grading file's comment share flat
* fix(core): keep a grade switched off live from coming back on the next frame
* chore: release v0.8.182
* chore: list #5953 in the v0.8.182 notes
* chore: add #5843, #5932 and #5945 to the v0.8.182 notes
* chore: add #5923, #5943, #5956, #5960, #5961, #5963, #5964 and #5966 to the v0.8.182 notes
* chore: add #5972, #5977, #5981 and #5982 to the v0.8.182 notes
* chore: add the 13 PRs merged since the last refresh to the v0.8.182 notes
* chore: add #6003, #6005 and #5579 to the v0.8.182 notes
* chore: add #6006 to the v0.8.182 notes
* fix(lint): place each scene's audio at its host's start in the audio overlap check
* fix(lint): drop internal tags from two comments
* fix(lint): check audio in scenes chained by a reference start
* test(player): measure heavy films in real Safari and Chrome on macOS
* test(player): count runtime media seeks and rate writes during the Safari bench
* test(player): compare the Safari bench against native playback of the same media
* test(player): split Safari control playback into per-frame media writes and per-frame transforms
* perf(core): write media volume and mute only when they change
* test(player): presented frame rate and compositor-animation controls in the Safari bench
* perf(core): keep the media volume guard without a new comment line
* test(player): derive presented frames from playback counters and snapshot video state
* test(player): control page plays only listed media, and anchor the Safari process match
* test(player): send the bench as code, and write only validated numbers from the browser
* fix(player): perf host page gives the player a real box, so films are on screen when measured
* fix(player): a player with no height set takes its film's shape instead of collapsing to nothing
The player box had no intrinsic height, so an embed that set only a width (or nothing) was 0 px tall
and showed no picture. An in-flow sizer now carries the composition's aspect ratio; a height the
embed sets still wins, and a height-only embed keeps its full width.
* test(player): label each embed on the player box page
* perf(cli): commands run without esbuild, puppeteer-core or sharp until they need them
A host that bundles the CLI can now leave these packages out: each command
loads them only where it uses them, and sharp installs once on first use when
it is missing. The build check fails on any static import of them again.
* fix(cli): use a host's sharp from NODE_PATH and let hosts turn first-use installs off
* fix(cli): capture fails when sharp is missing, and one install serves every caller
A missing sharp now stops the asset download up front instead of counting every
image as unavailable. Concurrent first uses share one install, a failed install
is not retried in the same run, and preview thumbnails report why. Hosts can turn
off installs for a list of packages, e.g. HYPERFRAMES_NO_OPTIONAL_INSTALL=sharp.
* fix(cli): snapshot --describe still works without sharp, resizing only when it is installed
* style(cli): format the sharp loader and its tests
* build(cli): sharp is an optional dependency, so a host can ship the CLI without it
* build(studio-server): keep sharp out of the bundle now that it is optional
* chore: release v0.8.180
* docs(release): describe chunk sub-ranges as groundwork for split retries
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore: release v0.8.180 notes list the empty-frame fix
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(core): a paused preview puts a reloaded video back at the playhead
When a video's authoring proxy (or that copy's larger size) loads while the preview is paused, the element restarts at 0 and the paused transport never seeks it again, so a clip stacked with its own cutout showed two moments of the same person. Any media element's loadedmetadata now re-syncs a paused transport.
* fix(core): the reload listener's name carries its reason
* fix(core): keep the paused-video playhead re-sync out of renders
A render drives every video's frames itself. The re-sync now asks the
runtime's one render check (the render config or an injected frame)
instead of only the capture-started pair.
* fix(studio): a lost preview server or a superseded edit no longer files as a crash
* fix(studio-server): a render's scratch files no longer stream changes or list as compositions
* perf(producer): distributed chunks download only the videos they show
A project video under compiled/ that chunks extract their own frames from
now goes only to the chunks that show it, like video-sources/ files. A
chunk skips the readiness wait for a deferred video it does not show, so
it no longer logs media_load_failed for a file it was never given.
* fix(producer): cloud site uploads pack the files their id hashes, symlinks included
* perf(aws-lambda): chunks read only the byte ranges of a video they need
A chunk that extracts its own frames from a deferred project video now
reads it from S3 by byte range through a short-lived pre-signed URL,
instead of downloading and hashing the whole object first. S3 attests the
object's stored SHA-256 before the URL is signed; every other artifact is
still downloaded and verified as before.
* perf(aws-lambda): upload project files once to a shared content-addressed area
* chore(producer): keep planV2 comment share at the base level
* fix(engine): verify the TLS certificate of https video inputs and never surface their signed query
* fix(aws-lambda): renders re-upload a site's expired files instead of failing
* perf(aws-lambda): the plan step probes project videos in place instead of downloading them
* fix(engine): https video reads reconnect and time out, and redaction covers wrapped causes
Signed-URL reads now pass -rw_timeout, -reconnect and -reconnect_delay_max (both Lambda builds accept them) and ffmpeg also -reconnect_on_network_error, so a transient S3 error or a stalled read fails fast or recovers instead of waiting out the process timeout. Query redaction now descends into error.cause.
* fix(aws-lambda): share entry bundles and ratchet package budget
* fix(aws-lambda): site ids uploaded before files.json still render
A site id from an earlier sites create has only sites/<id>/project.tar.gz.
renderToLambda now falls back to that tarball, which the plan step still
reads, and when neither key exists it fails before starting with both keys
named and the fix (run sites create again).
* fix(aws-lambda): renders fail before upload on a stack too old to read sites
A stack deployed before files.json sites untars the site file in its plan
step and fails after four retries. renderToLambda now reads the state
machine definition first and stops with STACK_TOO_OLD and the redeploy
command when the plan task does not list SITE_MANIFEST_INVALID, which new
SAM and CDK stacks now treat as terminal. The README's upgrade section
drops the planProtocol v1 fallback, which hits the same failure.
* docs(aws-lambda): document shared content storage and the upgrade path
The deploy guide still described the tarball HeadObject short-circuit. It
now covers the cas/sha256/ store and its 30-day lifecycle rule, the
STACK_TOO_OLD, SITE_FILES_MISSING and SITE_NOT_FOUND errors, that old site
ids keep working, and that every project uploads once more after the
upgrade. A test keeps a troubleshooting entry for each pre-submit error.
* fix(producer): chunks log each video they read by URL, without its query
A chunk reading a video straight from the bucket left no trace in its log.
It now logs one line per remote source with the path and the URL minus its
query string, so signatures never reach the log.
* fix(engine): ffmpeg errors never keep part of a signed URL's query
A process's stderr tail was trimmed before its URLs were redacted, so a
cut inside a signed URL left a query fragment the URL pattern no longer
matched, and the fragment reached the error. The stderr tail is now
redacted before every trim, and the redactor also strips a signing
parameter cut off from its URL.
* fix(aws-lambda): comments name the site manifest instead of a file path
* fix(engine): stderr redaction survives chunk splits, and every ffmpeg spawn uses it
The stderr tail was redacted on each chunk and then cut, so once a cut
severed a long URL's scheme, the rest of its signature in the next chunk
matched neither pattern. The tail is now kept raw with 2x slack and
redacted only when the text is produced, then cut to size. The unused
producer audioExtractor (its own spawn, unredacted stderr) is deleted and
the parity harness's PCM decode goes through ManagedChildProcess; a test
fails on any ffmpeg/ffprobe spawn in engine, producer or aws-lambda that
does not.
* chore(producer): record the audioExtractor deletion as intended
* fix(engine): drop a signature fragment the stderr tail cut from its URL
The raw tail is kept at twice the cap, but that cut can itself land inside a
signature, and when redaction shrinks the rest below the cap the fragment
survived. After a cut, the severed token is dropped before redaction.
* refactor(engine): name the severed-token drop instead of commenting it
* test(engine): a cut-off URL leaves nothing behind; aws-lambda size budget to measured
* fix(producer): chunk URL-read tests use main's frame list; producer size budget to measured
* chore(producer): size budget at the measured size, no headroom
* chore(producer): size budget back to measured x1.05, the check's own rule
* perf(core): playback work follows what is on screen, so long films keep their frame rate
* fix(core): a timing change during playback re-decides every clip on that frame
* style(core): format the scene clock's rest rule
* fix(core): narrow playback media before armed lookups
* perf(core): a jump starts its video first and rebuilds the clip list after the frame lands
* fix(core): the preview transport keeps ticking in a hidden tab
A hidden document runs no animation frames, but its media keeps playing.
The transport ticked on requestAnimationFrame only, so syncRuntimeMedia
stopped running: a clip was never paused at the end of its window and the
next clip never started until the tab was shown again.
While document.visibilityState is "hidden", the transport now schedules
its tick on a 16 ms timer instead. A visibilitychange listener moves a
pending tick to whichever scheduler the new state calls for, and teardown
removes it. A paused, hidden transport still parks as before.
Refs #5710 (cause 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* docs(core): note the hidden-tab timer clamp for silent media
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* docs(core): shorten the hidden-tab transport comments to hold the comment share
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* perf(producer): renders embed local fonts as-is instead of re-compressing them
Local ttf/otf fonts in a render were woff2-compressed on every cold font
cache, which costs seconds per font. The compiled HTML is a render-time
artifact, so embed them raw. fontToDataUri gains a compress: false
option; its default is unchanged.
* refactor(producer): name the raw local font option instead of explaining it in comments
* fix(core): color grading applies to canvas compositions
data-color-grading on a <canvas> was ignored by the runtime and refused by
lint, the Studio panel and the CLI writer. One source-tag list in the grading
contract now feeds all of them. A canvas is sized from its drawing buffer and
re-uploaded on every seek and transport tick.
* fix(core): keep the comment share of grading files at the base
duplicate_audio_track compared raw float ends, so 21.03 + 0.1 (21.130000000000003)
overlapped a clip starting at 21.13. The one-instant span helpers move from core's
clipFacts to parsers, which lint and core both depend on; core re-exports them and
the rule uses spansOverlap.
The capability catalog listed correction controls as "adjustments" and
finishing controls as "finishing", while the grading payload calls them
"adjust" and "details", and no capability showed a payload. Agents wrapped a
correction in "adjustments" and the validator rejected it with a hint that
named no key. The schema now owns a correction example; the catalog names its
sections after the payload keys and carries that example, correction and
finishing show an apply payload from it, the unknown-key hint lists the real
top-level keys, and a test validates every grading payload in docs and skills.
* fix(core): a scene paints no SVG filter grain before its start or after its end
* fix(core): a scene's filter tween starts from its stylesheet filter when frames step in
The hidden-clip rule's filter:none was still on when a tween first rendered on
the frame-sized step into its scene, so it cached none as its start value. A
seek that first renders a time now shows the hidden clips due at that time
(all of them on a jump) around the GSAP render.
* fix(core): hidden scenes block only SVG filter output, leaving HTML filters to tween
Only SVG reference filters paint without input, so the filter:none rule now
covers SVG content inside a hidden clip and nothing else. A stylesheet filter
on an HTML element stays readable whenever GSAP first renders it, including a
tween that starts a frame before its scene, and the per-step show of due clips
is no longer needed.
* perf(core): a preview loads each web font once, not once per scene
* test(core): count the bundle's font rules in its styles, not in the inlined runtime
* perf(core): scenes stop animating after their clip ends, so late playback keeps its frame rate
A scene timeline registered on its own was re-seeked every frame up to its own
length, not its clip's end. A scene whose animation outlasts its clip kept
re-rendering while hidden; the runtime now rests it at the clip's end.
* perf(core): name the clip end instead of commenting it
* fix(core): rest only timed scenes in preview, leave render seeks as before
* fix(core): a Studio-stamped scene keeps animating after the root grows past its stamp
* fix(gcp-cloud-run): the BeginFrame contract image resolves the engine's core import
The engine config imports the distributed fps set from @hyperframes/core, and the contract stage of the GCP image copies only parsers and engine sources, so the import failed at build time. The set now lives in a small core subpath (@hyperframes/core/distributed-fps) that the engine imports like its other light core imports, and the contract stage copies core like parsers. The core root export is unchanged.
* style(core): format package.json
* fix(core): scrubbing back across a scene no longer leaves its content hidden
* fix(core): apply a scene's same-time steps in authored order when scrubbing back onto them
* fix(core): type the steps found on a landing time
The integer rates Lambda and Cloud Run accept were written out by hand in
about 25 places across core, engine, producer, the adapters and the CLI.
They now come from DISTRIBUTED_FPS / DistributedFps / isDistributedFps in
@hyperframes/core. Engine config snapshots accept 25 like the rest.
## What a user can do now
Render a video at 720p or 480p: `hyperframes render --resolution 720p` (or `480p`, or the presets `landscape-720p`, `portrait-480p`, `square-720p` and so on) gives a 1280×720 or 854×480 file from a 1080p composition, and the same presets work through the studio-server render route. Before, any preset smaller than the composition was refused ("Downsampling via --resolution is not supported").
## How
- `parsers/src/types.ts`: six presets, `{landscape,portrait,square}-{720p,480p}`, and the `720p` / `480p` aliases (aspect-agnostic, like `1080p` and `4k`). 480p at 16:9 is 854×480: 480 × 16/9 is 853.33, and 4:2:0 encoders need even sides.
- `checkOutputResolutionCompatibility` now allows a preset smaller than the composition. A smaller preset may be off by under one pixel on its long side (the 854); a larger one must still match exactly and be a whole multiple, as before. The aspect remap that `--resolution 1080p` / `4k` apply on their own (`suggestMatchingPreset`, used by the CLI preflight and the compile stage) returns only a preset in the asked tier, never another: with the looser match for smaller presets, a 1922×1080 composition asked for `4k` would otherwise have been remapped to 480p. It is refused instead, as before.
- `resolveDeviceScaleFactor` returns the long side's ratio, so a smaller preset renders at a device scale factor under 1: Chrome lays the page out at composition size and rasterizes each frame straight at 1280×720 or 854×480, the way 4K already renders at 2. For 480p that is 854/1920: the width lands on 854 exactly and the height's 480.375 rounds to 480, so no pad column is added. Output sizes round as Chrome rounds the screenshot.
- The streaming encoder was given the composition's size instead of the output size on both streaming paths (`renderOrchestrator.ts`). 4K never noticed, as both sizes are even; at 480p the encoder skipped its even-size pad and libx264 refused an odd frame (`width not divisible by 2 (853x480)`). It now gets the output size, as the disk-frame encode already did.
- The paths that refused DPR > 1 (BeginFrame, drawElement, the Linux fast-capture gate) now refuse any DPR other than 1, since none of them honor a scale; scaled renders take the screenshot path, as 4K does.
- `render/outputScaleRoute.ts`: the layered compositor (HDR content, and shader transitions in webm and mov) sizes its layers and encoder from the composition and never scales, so a scaled output on that route now stops with a named error, checked where every route and replan is settled (`syncCapturePlan`, beside motion blur's route check). Before, a 1080p request from a 4K composition with shader transitions as mov would have encoded 3840×2160. HDR with `--resolution` was already refused in preflight; 4K on this route had the same gap on main and gets the same error.
## What I measured
- Real renders from source on a Linux box (`bun src/cli.ts render registry/examples/nyt-graph --resolution 720p|480p|1080p`), checked with ffprobe: 720p `h264, 1280x720, yuv420p, 15.0 s`; 480p `h264, 854x480, yuv420p, 15.0 s`; 1080p unchanged at 1920x1080. A frame of each, looked at: same chart and layout, crisp text, and the 480p right-edge column is the page's own color (250,248,242), not a pad.
- Before the encoder-size fix the 480p render failed with `[libx264] width not divisible by 2 (853x480)`; after it, it renders.
- `outputScaleRoute.test.ts`: the real route choice for a 4K composition with shader transitions as mov (`outputSupportsPageSideShaderCompositing`, `shouldUseLayeredComposite`, `createCapturePlan`, `resolveDeviceScaleFactor`) lands on `hdr_layered` at scale 0.5 and is refused. CLI preflight and the compile stage both refuse `4k` on a 1922×1080 composition.
- fails without the fix: without the same-tier rule, the preflight and compile tests for 1922×1080 at `4k` pass the render through at 854×480; without the route check, the mov test does not throw.
- At d33c4458: producer `outputScaleRoute` + `compileStage` 18/18 three runs in a row, `shared` (bun) 9/9, cli `render` 104/104 once and its new test three runs in a row; tsc clean in producer and cli. Earlier at e72f6507: parsers `outputResolutionCompatibility` 20/20, core `index` 26/26, producer `resolveDeviceScaleFactor` 14/14, cli `render` + `parseOutputResolution` 122/122, studio-server `render` 53/53. The new cases fail on main (the presets don't exist; smaller presets are refused). tsc clean in parsers, engine, producer and cli; oxlint and oxfmt clean.
## What I did NOT exercise
- Distributed (Lambda / Cloud Run) renders at 720p/480p: the chunk worker's sizes round the same way, but I did not run a distributed render.
- Studio's render queue keeps its own copy of the rule (`RenderQueue.tsx` `scaleApplies`) and offers no 720p/480p option; unchanged here.
* fix(cli): check's dense geometry seeks stop waiting for video frames they never read
* test(cli): race the dense seek against the barrier ask instead of a timer
* fix(cli): keep the geometry seek's comment share at its base
* fix(cli): dense geometry seeks still wait for frame sources, only video frames are skipped