Commit Graph
589 Commits
Author SHA1 Message Date
Miguel Ángel 7cc5dd382c perf(producer): cloud render chunks hand Chrome each video near the size it is shown (#6001)
* perf(producer): cloud render chunks hand Chrome each video near the size it is shown

A chunk now measures the largest box each video is drawn in over its own frames,
in device pixels, before extracting that video's frames, and halves the frames
while they still cover that box. Graded videos, pages with a canvas, and
object-fit none/scale-down keep full-size frames.

* docs(engine): shorten the shown-box measurement comment

* refactor(engine): name the halving filter instead of commenting it

* test(producer): end ffprobe options before the frame path in the shown-size tests
2026-10-21 05:32:19 +00:00
Miguel Ángel 0260c40e56 fix(engine): renders use the browser the CLI picked; Linux system Chrome is not beginframe (#5987)
* fix(engine): renders use the browser the CLI picked; Linux system Chrome is not beginframe

* fix(cli): state the browser env rule without its history

* fix(engine): renders from the CLI launch the browser it picked, not the engine config's
2026-10-21 01:56:34 +00:00
Miguel Ángel 8942aa23bd fix(producer): a render no longer publishes a short or wrong-size video silently (#5978)
* fix(producer): a render no longer ends early on a frame it cannot read

Frame-sequence encodes pass -xerror so an unreadable frame fails the encode
at that frame with the OS error, instead of ffmpeg ending the video there and
exiting 0. macOS keeps render scratch in the system temp folder, as Windows
does, so a synced project folder never sees the captured frames.

* fix(producer): a render refuses to publish a video smaller than it asked for

The final output check compares the encoded width and height with the
requested size. A non-native --resolution with HDR footage is refused before
calibration launches Chrome, and the refusal names --sdr or the project size.

* fix(engine): a frame read failure reports the OS error on every platform

ffmpeg prints the unreadable file and the OS reason on separate lines; on
Windows the file line comes first, so reporting only the first match dropped
the reason.

* test(engine): stream start offset fixture cleanup gets the 30 s budget

Its afterAll removed the fixture media within vitest's 10 s default and timed
out on a Windows runner after all 16 tests passed. Real-ffmpeg fixture cleanup
gets 30 s everywhere else (#5368).
2026-10-21 01:49:36 +00:00
Miguel Ángel f440db9019 fix(cli): check waits for late images and runs its browser pass in unnamed macOS sandboxes (#5977)
* fix(cli): snapshots, compare and check wait for late images the way render does

* fix(cli): check runs its browser pass inside a macOS sandbox that does not name itself

Every CLI Chrome launch has used a pipe since the warm-check change. A pipe
launch that a seatbelt kills fails with only "Protocol error
(Target.setDiscoverTargets): Target closed". The one-process fallback keyed
on Chrome's bootstrap_check_in stderr, which a pipe launch failure never
carries, so it never fired. On macOS a pipe launch that fails at the
protocol level now relaunches as one process over the pipe.

* fix(cli): retry only a pipe launch whose connection closed, and keep puppeteer lazy
2026-10-20 22:54:45 +00:00
Miguel Ángel a125dbc19f perf(producer): a cloud plan corrects its audio peak from the mix itself, in parallel (#5981)
* perf(producer): the true-peak correction of long audio encodes in parallel segments

* perf(producer): a cloud plan corrects its audio peak from the mix itself, one lossy pass

* fix(producer): only the PCM-sourced peak correction splits; AAC input stays one encoder
2026-10-20 22:24:52 +00:00
Miguel Ángel a8a80323f6 perf(engine): cloud renders cut a video's audio from its URL instead of downloading the file (#5972)
* perf(engine): cloud renders cut a video's audio from its URL instead of downloading the file

* refactor(engine): keep the audio mixer's comment share at the base

* fix(engine): a cloud-held file's audio probe retries once, then fails instead of mixing unprobed

* fix(engine): a cancelled probe is not retried, and the encode test noise is seeded so it repeats
2026-10-20 20:52:10 +00:00
Miguel Ángel 87c6e50f5e fix(engine): a sharp sound in a film's first moments no longer grows louder with each audio pass (#5956)
* fix(engine): a sharp sound in a film's first moments no longer grows louder with each audio pass

Every AAC encode now starts in digital silence that is dropped afterwards. Opening cold on quiet sound, the native encoder coded the first loud frame three scalefactor steps hot (4.5 dB), and each re-encode (pad, true-peak correction) did it again.

* fix(engine): AAC encodes stay in step with audio that is not 48 kHz before the encoder

* test(producer): regression goldens for audio that now opens without the encoder's cold start
2026-10-20 16:32:03 +00:00
Miguel Ángel 5583ae4293 perf(cli): commands run without esbuild, puppeteer-core or sharp until they need them (#5930)
* 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
2026-10-20 09:12:47 +00:00
Miguel Ángel f98ef09be4 test: queue runs on slow windows runners stop failing four timing tests (#5949)
* test: queue runs on slow windows runners stop failing four timing tests

* test(cli): the keepalive process test budgets for a cold start on a slow runner
2026-10-20 09:12:43 +00:00
James Russo e44f456b7c fix(core): recover refused audio automation with ramps (#5927)
* fix(core): clear overlapping curves before fallback ramps

* fix(core): rebuild refused automation as ramps

* test(engine): use shared Chrome launcher for audio regression
2026-10-20 04:13:29 +00:00
Miguel Ángel c77061e3af perf(producer): the plan's true-peak check runs in parallel segments (#5925)
* perf(producer): the plan's true-peak check runs in parallel segments

The plan's -1 dBTP check ran one single-threaded ebur128 pass over the whole
mix. It now scans fixed segments in parallel and reports the same printed
value: each later segment starts one 100 ms block early and drops that block
(the oversampler's mirrored start can invent a peak, and a scan never measures
its last 16 samples); the last segment falls back to one whole-file pass when
its dropped block alone could be the loudest. Segments decode from the file
start, since the AAC decoder carries noise-substitution state.

* fix(producer): a true-peak scan with no summary fails instead of reading 0 dBFS

A run whose stderr has no Peak line threw before the parser moved; it
parsed as 0 dBFS after, which would lower and re-encode audio that was
never measured. It throws again. When one segment scan fails, the others
are stopped, and only 48 kHz audio is split into segments, since ebur128
resamples any other rate.

* docs(producer): shorter true-peak doc comment
2026-10-20 02:19:20 +00:00
Miguel Ángel 472ee43c3a fix(engine): long phone-video exports neither time out nor stall before the first frame (#5921)
* fix(engine): long 4K video exports no longer time out while frames are still coming

* test(engine): the paced extraction test keeps a wide margin on a loaded runner

* fix(engine): long phone-video exports probe each source once instead of once per cut

* fix(engine): a failed source probe is retried, and probes share the extraction slots

* test(engine): synthesize the tone-mapped concurrency fixture with known input colour
2026-10-20 01:14:42 +00:00
Miguel Ángel 50623ee60a revert(engine): render workers keep the media of FFmpeg-fed videos loaded again (#5919)
* revert(engine): render workers keep the media of FFmpeg-fed videos loaded again

This reverts the behavior change of #5795 (6ed89a1e4). Releasing the media of
videos whose frames come from FFmpeg leaves some worker pages that never paint,
so parallel renders fail intermittently with "Invalid JPEG input at frame N:
empty frame". The shared loopback test fixture it added stays: later tests use it.

* chore(ci): agree the deletion of the reverted release test
2026-10-20 00:10:40 +00:00
Miguel Ángel 50e7a59aef fix: check, snapshot and renders use the pinned Chrome, not an older one another tool cached (#5900)
* fix(cli): check and snapshot use the pinned Chrome, not an older one another tool cached

* fix(cli): keep the browser-order comment within the line limit

* fix(engine): renders that resolve their own browser follow the same pinned-first rule

* fix(engine): keep the browser rule's reasoning in the PR, not new comments

* fix: a shared-cache shell is used only if it starts, after the pinned build

* fix(engine): import the shared-shell pick, and probe shells with a blank page

* fix(skills): caption scripts launch the browser the CLI chooses

* chore(skills): regenerate the skills manifest for the caption scripts
2026-10-19 20:00:25 +00:00
Miguel Ángel 992df8b203 perf(cloud): move project bytes once through shared content storage (#5772)
* 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
2026-10-19 19:23:51 +00:00
Miguel Ángel 0ce14d6443 test(engine): browser tests kill a Chrome whose close hangs instead of timing out (#5898) 2026-10-19 18:12:50 +00:00
Miguel Ángel d796846d83 fix(engine): SDR content in HLG exports sits at reference white, not peak (#5749)
* fix(engine): SDR content in HLG exports sits at reference white, not peak

The sRGB to HLG conversion for DOM layers fed SDR-relative light straight into the HLG OETF, whose input is light relative to the HLG peak, so SDR white landed at 100% signal. Graded HLG footage (graded as SDR, then converted back) and every white overlay were pushed to peak and blew out. SDR white now sits at 75% HLG signal (BT.2408), the same model the PQ branch uses with 203 nits.

* docs(engine): shorten the SDR-white note in the HDR LUT

* docs(engine): cut the LUT generator's notes to its usage and drift rule
2026-10-19 14:30:48 +00:00
Miguel Ángel 70fbe32f8d perf(cloud): assemble chunk output with a single faststart index (#5812)
* perf(producer): assemble chunks that carry audio under one faststart index

Reads each chunk's moov, merges the sample tables into one moov at the front, and writes
the chunks' mdat payloads after it unchanged: the same bytes storage can copy into place.
The assembler no longer needs the plan audio for such plans.

* perf(aws-lambda): build the final mp4 inside S3 with its index up front

The Lambda plan asks chunks to carry their audio. Assemble writes the faststart header as
part 1 of a multipart upload and copies each chunk's mdat payload with UploadPartCopy, so
the video bytes never pass through the function; any failure aborts the upload.

* fix(engine): a chunk's first frame shows its own time, not the composition's opening frame

* fix(producer): stitch silent AAC tails and clean failed outputs

* fix(producer): open stitched chunks read-only with an explicit mode

* chore(aws-lambda): raise the package size budget for S3-side MP4 assembly

handler.js and index.js each bundle the new S3 stitch code (+5,550 bytes apiece) and s3Stitch.d.ts adds 970: +3,479 packed and +12,070 unpacked over main, measured with the CI pack. The budget rises by exactly that delta.

* fix(aws-lambda): v1 plans give chunks their audio too, so both protocols match

* perf(producer): plans whose chunks carry audio encode it once, at the frames' exact length

* test(producer): terminate the plan audio ffprobe argv like every other probe
2026-10-19 00:13:02 +00:00
Miguel Ángel ea742c5018 fix(engine): exports carry full colour tags with every encoder, SDR and HDR (#5742)
* fix(engine): SDR exports carry BT.709 transfer and primaries with every encoder

The SDR conversion filter set the BT.709 matrix on its frames but not the
transfer or primaries. Encoders that take colour from the frames rather than
from -color_* options (VideoToolbox on macOS) wrote color_space=bt709 with
color_transfer and color_primaries unknown. The capture and raw-input SDR
filters now end in the tagged BT.709 filter ProRes already uses.

* fix(engine): HDR exports tag BT.2020 with PQ or HLG on the frames too

VideoToolbox HDR encodes had the same gap as SDR: given only -color_* options
it wrote bt2020nc with transfer and primaries unknown, so players read the
export as SDR. Every HDR branch of the streamed and disk encoders now tags the
frames through one hdrFrameTagsFilter built from the encoder colour params.

* test(engine): colour-tag probes pass -- before the output path
2026-10-18 22:15:31 +00:00
Miguel Ángel b98a1ac6e0 test(engine): process tracker tests spawn node, not shell tools, so Windows runs stop timing out (#5832) 2026-10-18 22:13:31 +00:00
Miguel Ángel b46ebedeae fix(engine): a 29.97 fps camera clip exports smoothly at 30 fps (#5849)
* fix(engine): a 29.97 fps camera clip exports smoothly at 30 fps

Extraction samples each output frame from the last source frame starting at
or before it. Millisecond container ticks move each frame by up to half a
millisecond, while 29.97 fps drifts against 30 fps by 0.033 ms per frame, so
the rounding decided the slot and the export alternated repeats and skips.
Constant-rate frames are now re-spaced on the stream's own frame grid from
the first decoded frame before sampling. The cache schema moves to v8 so
exports stop reusing frames extracted the old way.

* fix(engine): every extraction process snaps frames to one absolute grid

The grid was anchored on each process's first decoded frame, which carries its
own millisecond rounding, so a segment or distributed chunk could place the
29.97 -> 30 repeat elsewhere and skip a frame at its seam. Frames are now
snapped to the stream's own grid in file time (this process's seek plus the
video's lead), the same in every process.
2026-10-18 16:37:20 +00:00
Miguel Ángel 18fc3901c7 fix(cli): check and snapshot wait for a slow CDN script as long as render does (#5847)
* fix(cli): check and snapshot wait for a slow CDN script as long as render does

check, snapshot, layout, motion-shot and validate gave the page 10 s to reach
DOMContentLoaded while render gives it the engine's page-navigation timeout
(60 s), so a parser-blocking script from a slow CDN failed the diagnostics
with "Navigation timeout of 10000 ms exceeded" on projects that render fine.
The separate 10 s budgets are gone: every caller takes the engine's value
(and its env override), raised only by a command's own longer --timeout.
The cold-Chrome allowance for sending the first request is unchanged.

* fix(cli): check and snapshot read only the page-load budget from the engine

resolvePageNavigationTimeout() owns the page-navigation env read for render
and the diagnostics, so check and snapshot no longer resolve the whole render
config, which printed render's Windows streaming-encode warning. validate's
comment and timeout hint now say render shares the budget.
2026-10-18 16:23:47 +00:00
Miguel Ángel 8e4ce74118 perf(engine): long renders encode their audio in parallel segments (#5788)
* perf(engine): long renders encode their audio in parallel segments

* fix(engine): parallel audio encode counts packets from whole samples

The expected packet count came from a floating-point duration, so frame-aligned lengths such as 147.52 s (one ulp above 7080960 samples) expected one packet more than the encoder writes, the last segment check threw, and the render lost its audio. Round to whole samples first; a 147.52 s test fails without the fix.
2026-10-17 22:51:25 +00:00
Miguel Ángel 6ed89a1e4c fix(engine): render workers stop loading the videos whose frames come from FFmpeg (#5795)
* fix(engine): render workers stop loading the videos whose frames come from FFmpeg

* fix(engine): keep the media of a skipped video no poster sizes
2026-10-17 18:03:34 +00:00
Miguel Ángel b18b3e958a fix(gcp-cloud-run): the BeginFrame contract image resolves the engine's core import (#5785)
* 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
2026-10-17 11:13:06 +00:00
Miguel Ángel fc41fa277a fix(engine): local renders lay out width-only videos like the browser (#5771)
* fix(engine): local renders lay out width-only videos like the browser

* chore(engine): name the poster instead of commenting it
2026-10-17 08:45:59 +00:00
Miguel Ángel a498cf2032 perf(producer): assemble in one pass and keep its disk use under one output copy (#5768)
* 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.
2026-10-17 08:45:53 +00:00
Miguel Ángel d5162beb50 refactor(core): one owner for the distributed fps set (#5756)
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.
2026-10-17 08:45:41 +00:00
Miguel Ángel 09c0307c3f feat(producer): render at 720p and 480p from a larger composition (#5724)
## 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.
2026-10-16 20:58:15 -07:00
Miguel Ángel 4e6ee6d230 fix(engine): a WebGPU block mounted as a sub-composition gets WebGPU like a root one (#5735)
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.
2026-10-17 00:52:55 +00:00
Miguel Ángel c03532b285 fix(producer): an ffmpeg failure names how ffmpeg ended and keeps its last line (#5678)
* 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
2026-10-16 01:38:20 +00:00
Miguel Ángel 8e17ee9300 fix(engine): preserve authored audio mix order (#5672) 2026-10-16 00:13:04 +00:00
James Russo 9c11715971 fix(audio): preserve subprocess causes through CLI diagnostics (#5620)
* fix(audio): preserve subprocess evidence through CLI diagnostics

* test(producer): retain real engine exports in assembly mock

* fix(audio): retain triggering interruption within diagnostic cap

* fix(producer): sanitize audio evidence before encoder error construction

* test(cli): isolate diagnostic render temporary project
2026-10-14 20:26:59 +00:00
Miguel Ángel 14e2ccf3bb test(engine): the warm extraction cache test checks ffmpeg did not run, not the clock (#5613) 2026-10-14 17:51:02 +00:00
Miguel Ángel 7744692a46 test(engine): audio effect tests that start Chrome get the same time as the others (#5608) 2026-10-14 16:49:13 +00:00
Miguel Ángel 38cfcdd56c fix(engine): renders under WSL reach the GPU on Chrome 150 and later (#5591)
* fix(engine): renders under WSL reach the GPU on Chrome 150 and later

Part of #5548.

* fix(engine): shorten the WSL browser env comment

* fix(engine): cite the Chromium bug in one line and allow the fallback test's removal

* fix(engine): drop WAYLAND_DISPLAY under WSL only when that is what reaches the GPU

* fix(engine): restore the WSL note from before the full-Chrome fallback

* test(engine): give the second worker's page load room on a busy Windows runner

* fix(engine): keep the Chromium bug comment under the line limit
2026-10-14 06:22:39 +00:00
Miguel Ángel 9b996059af fix(engine): renders under WSL use the GPU through full Chrome when the headless shell cannot (#5574)
* fix(engine): renders under WSL use the GPU through full Chrome when the headless shell cannot

Part of #5548.

* fix(engine): a missing or unreadable full Chrome cache never stops a WSL render
2026-10-14 02:49:08 +00:00
James RussoandClaude Opus 5.5 6afcb48395 fix(engine): sample VFR video by timestamp so it does not freeze on FFmpeg 8 (#5572)
* fix(engine): sample VFR video by timestamp so it does not freeze on FFmpeg 8

VFR sources were resampled with `-fps_mode cfr -r`, which on FFmpeg 8 honours
each frame's duration. In mp4 files with B-frames those durations are
decode-order gaps, so a frame can claim 0.4s while the next one starts 17ms
later, and the extracted video froze on it. VFR now uses the same fps filter
sampling as CFR (the frame on screen at each output tick), bounded by
-frames:v so a window that ends on a still keeps its rounded-up frame count.

The two-process raw pipe that worked around FFmpeg 5.1 dropping durations
under -vf is no longer needed and is removed. The vfr-screen-recording golden
is regenerated: FFmpeg 5.1's cfr mapping it was rendered with ran one to two
source frames late.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(engine): move the extraction cache to v7 so v6 VFR frames are not reused

v6 entries for VFR sources hold frames sampled by packet duration, which
FFmpeg 8 freezes and FFmpeg 5.1 lags. The key is unchanged by the fix, so
a warm cache served them after the upgrade.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:45:43 +00:00
Ayodele Ayoola 3de4763d53 fix(engine): avoid padding every audio mix input (#5563)
* test(engine): cover unpadded audio mixing and legacy fallback

* fix(engine): avoid padding every audio mix input
2026-10-13 23:37:28 +00:00
Miguel Ángel 102c14a6c4 fix(engine): stop multi-worker renders hanging on media-heavy pages on Windows and macOS (#5565)
* fix(engine): stop multi-worker renders hanging on media-heavy pages on Windows and macOS

Off Linux, parallel workers shared one pooled browser. Pages in one browser
share Chrome's six connections per host to the file server, so a page with
many videos and audio clips held all six and the next worker's page.goto
waited until it timed out. Every parallel worker now gets its own browser.

Part of #5548

* refactor(engine): shorten the worker browser comment
2026-10-13 22:48:57 +00:00
Miguel Ángel 96e2154d7c fix(engine): say why WSL renders fall back to software and how to get the GPU (#5560)
* fix(engine): say why WSL renders fall back to software and how to get the GPU

Under WSL the GPU check often falls back to SwiftShader and the only output
was the probe line, with Docker passthrough advice for --browser-gpu. Print
one note that says what was checked (can this user open the render node),
the exact fix, and the native Windows alternative. The CLI's WSL detector
moves to the engine so both use one.

Part of #5548

* fix(engine): keep WSL GPU notes true to the probe result and render mode

The WSL note now prints only when auto mode really selects software, and
says whether the GPU check found no GPU or could not finish. Explicit
--browser-gpu keeps its unverified and Chrome-install advice and adds the
WSL checks. The render-node check verifies group membership before
suggesting usermod.
2026-10-13 22:18:59 +00:00
James RussoandClaude Opus 5.5 401354cd8f fix(engine): keep the rightmost 8 columns of SDR MP4 at widths like 1080 (#5559)
#4564 routes SDR colour through `format=gbrp`. Without accurate_rnd,
swscale's yuv-to-gbrp conversion leaves the last 8 columns black when the
width is 16n + 8 (1000, 1080, 1112...), so every 1080x1920 portrait MP4
since 0.8.81 has had an 8 px black strip on the right. The same step in
SDR jpg frame extraction blanked the right edge of footage at those widths.

Set accurate_rnd on the yuv-to-gbrp scale in both chains. It is also the
more accurate rounding: on a testsrc2 frame, worst luma error against a
float reference drops from 1.85 to 0.90 codes.

Fixes #5551

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:50:09 +00:00
Miguel Ángel c96099d39a fix(cli): bound checks of non-finishing compositions (#5537)
* fix(cli): bound checks of non-finishing compositions

* fix(cli): account for cold startup in deadline witness

* fix(cli): wait for profile owners before test cleanup

* test(cli): separate portable deadline and Linux process witnesses

* test(cli): preserve per-launch browser profile ownership

* fix(cli): start check deadline after browser readiness
2026-10-13 13:49:05 +00:00
Diego López c8e105dc92 fix(engine): encode and tag ProRes mov output as BT.709 (#5382)
* fix(engine): encode and tag ProRes mov output as BT.709

* test(engine): read the ProRes colour tags from the first frame

* test(engine): end the ProRes tag probe's options with --
2026-10-13 00:07:44 +00:00
Miguel Ángel fbbc4e88c5 fix(engine): bound injected video frame resource retention (#5494)
* fix(engine): bound injected video frame resource retention

* perf(engine): bound capture inspection response buffers

* chore(engine): condense capture buffer explanation

* test(engine): cover cross-origin frame GPU uploads
2026-10-12 12:52:44 +00:00
Jerrai 8f7906b337 fix(audio): keep the pitch shifter's tail; no silence after a bitcrush seek (#5478)
The offline render now pads the source with silence for the whole render
(clip + chain tail + latency) instead of only the true-peak lookahead, so the
pitch shifter's ring drains instead of cutting off about 90 ms early.

A bitcrush node built part-way into a clip seeded its hold counter but started
its held value at 0, playing up to samples-1 frames of silence on a preview
seek. The held value is now primed from the first input sample; render windows
still line up with a whole render.
2026-10-12 01:29:50 +00:00
Jerrai 30359f8038 fix(audio): master bus follow-ups: a scene renders with its own chain; long mixes render in windows (#5474)
* fix(core): a scene rendered on its own takes its master chain from its own root

The standalone wrapper for a scene file reused the project index's root
attributes, so the index's data-fx-chain became the master bus. The wrapper
now carries the scene root's own chain, or none when the scene declares none,
as the audio-effects reference describes.

— Jerrai

* fix(engine): run long audio chains window by window instead of reading the file whole

applyAudioFxChain read the whole input WAV into memory, which Node refuses past
2 GiB (a stereo float mix beyond about 93 minutes), and the browser page then
held the track twice. Inputs above 512 MiB of samples are now read, rendered
and written in 5 minute windows, each with 10 s of preceding and 1 s of
following audio rendered and discarded so stateful effects arrive warm. The
output header is patched at the end. Inputs below the threshold keep the
existing whole-file path.

Parameter automation is clocked from the file start and is not windowed. An
input past 2 GiB that carries automation now fails with an AudioFxRenderError
naming that, instead of Node's raw file-size error.

— Jerrai

* chore(engine): trim the browser-lease comment to its live invariant

— Jerrai

* fix(core): a scene mounted as the index's own root takes its own master chain

When the index's top-level composition is itself the scene's mount, the standalone
extraction returned before the scene's chain was applied, so the index's chain still won.

— Jerrai

* fix(engine): windowed audio renders follow the file's clock and warm up for the whole chain

- Pass each window's start time into the page and on to buildFxChain, and seed the
  pitch-shift worklet's tap phase from it, so chorus, phaser and pitch shift continue
  across a window seam instead of restarting from time zero.
- Size the pre-roll from the chain (delay repeats, reverb room, dynamics release, to -60 dB),
  never below the configured floor and never above a cap.
- Read a WAV data size of 0 or 0xFFFFFFFF as running to the end of the file, so audio past
  4 GiB is no longer dropped.
- Close the input descriptor when the output cannot be opened, and name the real cause when
  an input over 2 GiB cannot be streamed.
- Cover reverb, a slow-release compressor, chorus and pitch shift in the windowed-vs-whole
  test, with an output-length check.

— Jerrai

* fix(engine): start each audio window on a render quantum and test the seams against a hard chain

Windows that begin off a 128-frame block see worklet blocks shifted against a whole render, which
left spikes in the tail after the last input sample. Align the window start, and make the
windowed-vs-whole test cover modulation, delay, reverb and a slow-release compressor on windows
whose start is not zero, failing above 0.001 where it previously tolerated 0.02.

— Jerrai

* fix(engine): keep a limiter's lookahead, seed bitcrush holds, close-check without /proc

- Pad the render source with the limiter's lookahead of silence so the last samples of a clip
  leave the lookahead line instead of being dropped.
- Seed the bitcrush hold counter from the window's start so holds that do not divide the
  render quantum stay in step across seams, and cover bitcrush in the windowed-vs-whole test.
- Check that the input descriptor is closed through the fs mock instead of /proc/self/fd,
  which only exists on Linux.

— Jerrai

* test(engine): pick a bitcrush hold the window starts do not divide

The earlier hold of 3 divided both window starts, so an unseeded counter passed.

— Jerrai

* fix(engine): warn at the pre-roll cap, share tail formulas, read a zero data size alike

- Warn when a chain's memory exceeds the pre-roll cap, so cut repeats at window seams
  are not silent.
- Export the delay and reverb tail formulas from audio-fx-tail and use them for the
  window warm-up too.
- Both WAV readers now treat a declared data size of 0 or 0xFFFFFFFF as the rest of
  the file.

— Jerrai
2026-10-11 23:56:09 +00:00
Jerrai e1925d4317 feat(audio): composition master bus (data-fx-chain on the root) (#5420)
* feat(audio): composition master bus (data-fx-chain on the root)

A clip can belong to only one audio group, so there was no way to put one
effect, such as a limiter, across the whole mix. The data-fx-chain on the
top-level composition root now runs once over the sum of every clip and every
group, after each group's own chain and fader. No new attribute: a sub-composition
root's data-fx-chain was never applied to anything and still is not.

Render: when a master chain exists, mixAudioTracks writes a float WAV instead
of AAC, applyAudioFxChain runs the chain over it, and ffmpeg encodes AAC last,
so the chain reads unclipped samples. An unreadable chain is a mix failure, as
for a group. Preview: the transport splices the chain between the clips' sum and
the monitor, re-anchored per play generation and watched for edits.

Only the top-level root is read. No master automation: the root's data-automation
is not read as the bus's. No lint rule: data-fx-chain is valid on clips, groups
and roots, and the one place it does nothing (a sub-composition root) has always
been silent.

— Jerrai

* fix(core): preview routes every audible video through the master bus

A root data-fx-chain is attached only inside the Web Audio graph, but a plainly
audible <video> joined that graph only for its own chain, group, automation or a
volume over 1. So preview limited the music and left the talking head dry, and a
composition whose only audio is video got no master chain at all, while export
sums every audible element into the master. A master chain on the top-level
composition root is now a reason to join, the same as a video's own chain.
Elements under a root with no chain keep native output. Sub-composition roots
are still ignored.

— Jerrai

* fix(audio): master bus review round 2 (temp dir, tests, docs, Studio meter)

- audioMixer: a fatal FX error in the master pass or a group bus left the
  work directory (the full float sum, about 1.4 GB an hour) on disk. The
  final mix now runs in try/finally and the group rethrow removes it first.
- Tests: the "once over the sum" case now uses a limiter then a gain, so a
  per-clip chain (-10.4) and a doubled one (-18) both fail it; a new case
  proves the sum stays float until the chain reads it; the fader case uses
  a group volume of 0.5 so the fader's position is exercised; the work-dir
  removal is covered for both paths.
- Docs: "Three rules exist" (the master-bus lint rule is gone), a bad master
  chain stops the render with audio_processing_failed, a sub-composition
  file opened on its own has its root chain as the master bus, and a seek
  clears a group or master limiter.
- Studio: TimelineElement.masterChain carries the root's data-fx-chain, so
  the program meter shows for a video-only composition whose only audio
  routing is the master chain, as preview now plays it.
- Comment blocks over four lines cut; webAudioTransport.setRate split so
  its CRAP score is under the Fallow threshold.

— Jerrai

* fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy

- Master chain: it was carried on each parsed AudioElement, so a track the
  producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered
  audio in probeStage) never had it, and a composition whose only audio was
  such a track exported with no master chain, no ceiling and no warning. It is
  now a composition property: parseMasterFxChain(html) at compile, carried on
  CompiledComposition and CompositionMetadata, passed to processCompositionAudio
  by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain
  is removed so there is one source. Each forward (compile stage, local
  orchestrator, distributed plan) has a test that fails when it is dropped.
- Plain limiter: it follows the level with no lookahead and Level after is
  applied past the ceiling, so it cannot promise "nothing gets past" or "never
  clips". Studio copy (description, panel text, knob label, summary) and the
  hyperframes-audio skill now say a fast or inter-sample peak can pass it and
  point to the true-peak limiter for a delivery limit. A copy test guards it.

— Jerrai

* fix(producer): keep master bus comments within the ratchet; run the plan test with the ffmpeg tests

Drop three one-line field comments that raised their files' comment share,
and list plan.masterBus.test.ts with the integration tests because plan()
reads the ffmpeg version.

— Jerrai

* chore(ci): repin the producer test-classification source in the reachability map

The reachability map pins the hash of test-classification.mjs, which now
lists one more integration test. The map's producer lane command already
covers every test under src, in both the unit and integration matrix lanes.

— Jerrai
2026-10-11 19:07:00 +00:00
Miguel Ángel b211fc7cb8 fix(engine): MP4, MOV and TS exports keep sound and picture in sync when streams start apart (#5455)
* fix(engine): exports place sound and picture right when a source's audio and video start apart

* fix(engine): a late video stream plays to its end and distributed and HDR renders hide its lead

* test(engine): pin a late video stream's trimmed start, held tail and HDR trim
2026-10-11 13:54:33 +00:00
Miguel Ángel bb34e301ec fix(engine): a render stall names the step Chrome hung in and says what to do (#5450)
* fix(engine): a capture stall names the step Chrome stopped answering in

* fix(engine): a stall on a motion-blurred frame names the seek or injection it hung in
2026-10-11 07:51:45 +00:00