* 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
lambda and cloudrun render at 30 fps when data-fps is outside the cloud set (for example 50 or 30000/1001) and --fps is not passed. They now say so and point at --fps. The 30 fps default has one owner, defaultCloudFps.
* 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
* perf(producer): assemble in one pass and keep its disk use under one output copy
The distributed assembler joined the chunks into a full-size file, muxed the
audio into a second one and rewrote that again for faststart, so local disk
peaked near four copies of the video and a 30-minute 1080p render came close
to Lambda's 10 GB /tmp. The join, audio mux and faststart now run as one
ffmpeg pass over the concat demuxer; the audio pad/trim takes its frame count
from the chunks. Video and audio packets are unchanged. Assemble and the
Lambda assemble handler log per-stage timings.
* refactor(producer): split assemble into plan check, cfr re-encode and final write
Keeps assemble under the complexity gate and drops an orphaned doc comment in chunkEncoder. No behaviour change.
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.
* fix(cli): check's dense overlap pass takes at most 240 samples, at clip windows first
* fix(cli): a held overlap on a long film is still an error under the 240-sample bound
Lambda and Cloud Run renders refused 25 fps although it is an integer
rate like 24, 30 and 60, and PAL footage is 25 fps. The distributed fps
set is now {24, 25, 30, 60} in the producer's config validation, the
plan v2 manifest, the render request mapping, the Lambda and Cloud Run
event types, the CLI's lambda and cloudrun commands and the regression
harnesses.
Co-authored-by: Cursor <cursoragent@cursor.com>
## 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.
skills update (bare or named) and bare skills now exit 0 with a one-line
notice instead of installing or pruning when HYPERFRAMES_SKIP_SKILLS=1, so a
pinned skills install survives the router's trigger-time update. Closes#5695.
* fix(cli): timeline lists clips nested in a timed element and lint flags ones that never show
* docs(lint): wrap the nested-clip entries
* docs(skills): format the nested-clip note and refresh the skills manifest
* fix(cli): keep timeline edits from colliding with a clip's own wrapper
* fix(cli): cut clips nested in a wrapper at their sub-composition's in-point
* refactor(lint): split the nested-clip visibility checks to keep the audit green
* style(cli): restore the timeline test's formatting
compositionRequiresWebGpu read only the first data-composition-id tag, the
page root. In bundled HTML a mounted composition's root keeps
data-requires-webgpu but carries data-hf-inner-root instead of a composition
id, so a pasted-in WebGPU block launched without WebGPU and failed with
'WebGPU unavailable' even with PRODUCER_ALLOW_SOFTWARE_WEBGPU set.
* 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
* fix(cli): page loads stop timing out while a busy machine's Chrome warms up
* fix(cli): keep the page-load helper comment-free and its files in the bench filter
* perf(core): probe media volume with one timeline walk per pass, not one per element
Each bind, rebind and scene-swap pass probes every media element for volume
automation, and each probe flattened the whole captured timeline
(getChildren(true, true, false)) to ask whether any tween could move that one
element's volume. A pass cost elements x tweens. Elements with no automation are
never cached, so the cost came back on every rebind pass.
On a two-hour podcast episode (2,354 media clips, ~100k caption tweens) that was
several seconds of preview main thread per pass (CPU profile of the project's
open in Desktop: IE/PE -> getChildren, ~6 s).
volumeMoversOf() now walks the tweens once and lists the elements a tween names
volume on, plus whether any non-DOM setter is tweened. Each pass builds it lazily
and shares it with every probe; a probe without one walks as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): find the master bus from the first composition root
The top-level root is the first [data-composition-id] in document order, so
one match replaces a scan of every composition node, read once per clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): lay out a clip again only when its layout inputs change
applyClipLayout ran on every timeline post and, for every root clip, lifted
display:none, read computed style and restored it. A clip whose inline
layout, class and data-width/height are as last laid out gets the same
answer, so it is skipped. On a 2,354-clip episode that was 4 of every 10 s
of playback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(core): a clip is laid out again only when its layout inputs change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): play a long film without re-walking it every frame
- The visibility pass skips root-level media far from the playhead during
steady playback: hidden last pass, not loading or armed, and its window
(widened by every look-ahead) clear of the span played and the loop start.
Seeks, forced syncs and renders visit every node.
- The GSAP re-render saves and restores only the landing marks near the
playhead, placed once per timeline structure; the zero-duration-callback
check is cached the same way. A caption track of 38k sets was walked twice
a frame.
- Registered timeline hosts, the root composition and the per-frame
whole-film queries ([data-start], audio groups, timed audio) are kept for
one timing revision; a render reads fresh.
- The runtime's own media release (src reset) and load events of clips with
an authored length no longer count as timing changes, which re-posted the
timeline about twice a second.
- The timeline payload resolves a stacking context once per parent.
2,354-clip episode in Desktop: playback 15 -> 67-89 fps (unminified runtime).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): check each element once per batch of raster-hint records, and cache the root from init
- A script building a large tree (a caption track) made each attribute record
re-walk its subtree with getComputedStyle; a batch now checks each element
once, an element whose own attributes changed still reading its own hints.
- The root composition cache installs the timing observer itself: a root
with a declared length never reads the duration floors that installed it,
so init looked the root up once per media clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): type the playback visibility skip for any element, and cache clip layout only once loaded
The skip's predicate took an HTMLElement where the pass hands it a
StylableElement (tsconfig.runtime.json). The layout cache records a clip only
once the document has loaded, so a pass before stylesheets apply is redone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): realm-safe element checks in the timing caches; cut added comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a failed preview reload says which step it was still waiting for
* fix(studio): a failed reload of a cross-origin preview still cleans up and reports
Naming the step the reload waited for read the hidden frame's player, which throws
for a cross-origin frame and skipped the cleanup and the failure report.
* refactor(studio): name the reachable-player check instead of commenting it
Each case left the CLI's background imports in flight. Under load they resolved
vitest's pending mock queue at the same time as the next case's import, which
then loaded the real command module. Wait for dynamic imports to settle before
resetting the mocks.
* fix(core): exported scenes keep cards that fly in, instead of one frame of each
Under the producer's GSAP stub every timeline is a stand-in. GSAP's bundle sets
window.gsap twice, so the stub wrapped twice; and the runtime compared those
stand-ins against GSAP's real children. Nesting a scene into the root never
read back as nested, so the scene ended up parked under an abandoned composite
at time 0, and each render seek's play() snapped it back to 0. Tweens whose
from-values set opacity lost them after their first frame.
The stub now wraps a GSAP instance once, and the runtime compares the timeline
behind a stand-in.
* fix(core): keep the stand-in note to one line
* fix(core): a caption line shows again after the playhead jumps back over it
The runtime hides an off-screen clip with an inline visibility:hidden that its
children inherit. A seek that jumps over a clip renders that clip's GSAP tweens
for the first time while it is hidden, so a set or autoAlpha tween records the
inherited "hidden" as the value to restore. Seeking back then writes it back,
and the element stays hidden for the rest of the session.
Interactive seeks now render GSAP with hidden clips shown, so first reads see
the authored style. Play ticks and render captures move forward and are
unchanged.
* fix(core): keep the files' comment share where it was
* fix(core): show hidden clips for any seek that jumps past what has rendered
Clicks and scrubs were covered, but render seeks jump too (hover frames,
thumbnails, stills at several times). The decision now lives in the seek
itself: a seek more than a frame and a half from the last one, to a time the
timelines have not rendered through yet, renders GSAP with hidden clips shown.
Frame-by-frame renders, play ticks and scrubs over already-rendered time pay
nothing.
* test: ffmpeg failures name how ffmpeg ended and keep its last line
* fix(producer): an ffmpeg failure names how ffmpeg ended and keeps its last line
* refactor(producer): say why in names, not comments
* fix(core): timed svg clips hide outside their window
* fix(core): type the clip visibility path for SVG as well as HTML elements
* chore(core): regenerate the position edits render script