Commit Graph
589 Commits
Author SHA1 Message Date
Miguel Ángel 81f60dbc98 fix(producer): exports no longer fail on a video clip outside the composition's length (#5431)
* fix(engine): exports no longer fail on a video clip outside the composition's length

A clip that starts at or after the composition's end (or ends before 0) is
never on screen, so extraction skips it. Its timeline window stayed as
authored, so the frame-coverage gate still expected its full length and
aborted the render with "captured 0 of expected N frames". Such a clip now
leaves extraction with an empty window, the same way a partly visible clip
is already trimmed to its visible part, so it owes no frames.

* fix(producer): drop clips outside the timeline before extraction, cloud renders included

The previous commit marked such a clip with end = start, but the window
resolver reads a zero-length slot as "use the source's natural length", and
the distributed plan still required an extracted entry for every declared
clip. Instead, the extract stage (shared by in-process and distributed
renders) removes clips that lie entirely outside [0, duration) from the
composition before probing and extraction, using one engine predicate that
the extractor's own early skip also uses, so a clip ending before 0 is no
longer downloaded or probed either.

* fix(engine): keep natural-length clips when dropping clips outside the timeline

A clip with no authored end carries end 0 (script-created media, or a src
set from a script), which the window resolver reads as "natural source
length". The outside-the-timeline check treated it as ending at 0 and dropped
it, so the clip froze on its first frame. Only an authored end (after the
start) at or before 0 now counts as before the timeline.

* fix(producer): only drop clips that start at or after the composition's end

A clip with no authored length carries end 0, the same value as an authored
end at 0, so "ends at or before 0" cannot be told apart from "natural
length" and a script-created clip with a negative start was dropped and
froze. Clips entirely before 0 keep main's behaviour; the filter now only
drops clips starting at or after the end, which never reads the end.

* test(producer): pin that a clip starting just before the end is still extracted
2026-10-11 00:02:51 +00:00
Miguel Ángel 3aba29ce05 fix(core): a runtime that fails to start names its real error instead of a half-built page (#5406)
* fix(core): a sub-composition timeline with a non-function duration no longer aborts the runtime

* fix(core): every registered-timeline duration read survives a partial or throwing entry

* fix(core): a runtime that fails to start reports its real error instead of a half-built page

* fix(core): a timeline post that throws after start-up is named too, and a later good post clears it

* fix(cli): check skips a custom clock whose time() returns null instead of reading it as stuck at 0

* fix(core): a recorded runtime start-up error stays set instead of being cleared by a later post

* fix(core): the runtime failure message says what to fix

* fix(core): the runtime failure is recorded even when the thrown value cannot be printed

* chore(core): format and regenerate the position-edits bundle for the startup error recorder

* fix(cli): keyframes and validate close the browser when the runtime fails to start
2026-10-10 22:56:35 +00:00
Miguel Ángel 9825614d91 fix(studio): Studio's own text reads without em or en dashes (#5414)
* fix(studio): text in Studio reads without em or en dashes

* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable

* test(studio): the dash check test covers every entity form

* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too

* fix(producer): the gate check stops early on the engine's new fallback wording
2026-10-10 19:08:30 +00:00
Jerrai 3746703ea7 feat(core): truepeak, a lookahead true-peak limiter FX (#5419)
* feat(core): add a lookahead true-peak limiter FX (truepeak)

The existing limiter is an envelope follower with no lookahead and no
inter-sample detection, so a -14 dB ceiling can leave the true peak several dB
over. truepeak estimates the true peak on a 4x polyphase interpolator, takes the
minimum required gain over a lookahead window and smooths it with a boxcar of
the same length, so the gain is already down when the peak leaves the delay
line. Release is exponential and can only lower the gain further; channels
share one gain.

The offline render lengthens the OfflineAudioContext by the chain's latency and
drops the lead-in, so a limited clip stays sample-aligned with its source.
Preview plays the lookahead (about 3.3 ms at the default).

— Jerrai

* docs(audio): truepeak ceiling is a 4x estimate; measure it with an ideal interpolator

The unit test's meter was a 96-tap Kaiser sinc, which rolls off near Nyquist and
reads full-band noise about 0.5 dB low, so the PR reported a 0.3 dB broadband
overshoot. Against an ideal 16x interpolation (zero-padded FFT, cross-checked with
a 16x soxr resample) hot full-band white noise ends 1.2 to 1.7 dB over the ceiling
(median 1.4 over 28 runs of 10 to 30 s). ffmpeg's ebur128 true peak is also a 4x
meter and reads the same files 0.1 dB over.

- The test meter is now the FFT interpolation. Tolerances are what it measures on
  each fixed signal: tones 0.01 dB (they land on the ceiling), the dense program
  0.5 dB (reads 0.445, the old 0.4 passed only because the meter read low), and a
  new full-band noise case at -1, -6 and -14 dBTP with 1.35 dB (reads 1.09 to 1.31).
- audio-effects.mdx and the hyperframes-audio skill say the ceiling is a 4x
  estimate, give the measured overshoot and recommend a 2 dB margin under a hard
  delivery limit.
- skills-manifest.json regenerated.

— Jerrai

* fix(core): truepeak UI copy no longer promises a hard ceiling

The registry description and the rack summary said the true peak is never above
the ceiling. The limiter holds a 4x estimate, and full-band noise can land up to
about 1.7 dB above it, as the docs now say. The summary reads "held near", the
description names the estimate and the overshoot, and the rack blurb says "holds
... to a ceiling". No skill file changed, so the skills manifest is unchanged.

— Jerrai

* fix(core): Studio truepeak copy states the 4x-estimate margin

The ceiling control was labelled "Never exceed" and the module said the mix
"must not clip", so a Studio user never saw that dense full-band content can end
up to about 1.7 dB above the ceiling. The label is now "Target ceiling" with a
hint that names the 4x estimate and the headroom to leave, the blurb says
"toward a target ceiling", the rack summary says peaks are pulled down toward the
value, and the registry hint no longer says "highest true peak allowed". The
audio skill's "hard dBTP ceiling" wording is gone and the skills manifest is
regenerated. A copy test keeps the promise out.

— Jerrai

* fix(core): truepeak clears on a seek, and the review nits (fallow, comments)

- Unexport TRUE_PEAK_OVERSAMPLE, TRUE_PEAK_DETECTOR_DELAY and
  truePeakLookaheadSamples: nothing imports them (Fallow audit).
- A persistent group bus keeps its chain across a seek, so the limiter's
  delay line and held gain carried over. The worklet takes __hfReset, the
  graph exposes FxChainHandle.reset(), and reanchor() calls it.
- The processor-loading harness is shared by the truepeak and worklet tests
  (it was a 12-line clone); the test FFT no longer repeats audioCarve's
  bit-reversal; two test bodies are under the CRAP threshold.
- Comment blocks cut to four lines or fewer (Comments check).
- Docs state the preview delay in ms.

— Jerrai
2026-10-10 18:28:21 +00:00
miga-heygenandMiguel Ángel e23b3a3e31 fix(engine): copy border-width/style/color onto the replacement video frame (#3993)
* fix(engine): copy border-width/style/color onto the replacement video frame

injectVideoFramesBatch substitutes each <video> with a sibling <img> holding
an extracted still frame, copying an allow-listed set of CSS properties from
the video's computed style onto the img. border-width/border-style/
border-color were absent from that allow-list, so any border authored
directly on a <video> never reached the replacement image and disappeared
from render/snapshot output. border-radius and clip-path were already on the
list and already clip a replaced element's content correctly without
needing overflow:hidden, confirmed by a real-Chromium test that exercises
the actual capture path and reads real screenshot pixels.

Also corrected a stale comment in frameCapture.ts claiming
detectCssEffectRisk can return "clip-path" as a risk value -- it can't; that
string only ever comes from a separate, animation-only at-risk-props gate
further down the same file.

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

* test(producer): regenerate style-9-prod golden for the border fix

style-9-prod's video has an authored 8px border + 16px border-radius
(design_review.md calls out the "floating" look) that never rendered before
this fix, so its committed golden baked in the pre-fix (borderless) output.
Regenerated via `tsx src/regression-harness.ts style-9-prod --update`;
verified the diff is exactly the border/radius becoming visible (extracted
frames before/after) and the fixture passes its own visual/audio checks
again (100/100 checkpoints, correlation 1.000).

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

* fix(engine): measure the video's box before styling its replacement frame

injectVideoFramesBatch measured the video's used box (offsetLeft/Top/
Width/Height) after creating, inserting, and styling the replacement
<img> sibling -- once that copy loop started carrying border-width
(added for the border-visibility fix), the freshly-inserted, still
in-flow, now-bordered <img> competed for space in a flex row before
the video's own box was ever read. In a flex-centered layout (video
width:100%, sharing a row with the img sibling), that shrank the
measured video box by the exact width of the border, so the img ended
up sized from the video's post-shrink box instead of its authored one.

Moved the measurement to before the <img> is created/inserted/styled
at all, and set img.style.boxSizing = "border-box" explicitly after
the style-copy loop, since offsetWidth/offsetHeight (and the
getBoundingClientRect fallback) are always a border-box measurement
regardless of what box-sizing value that loop copies from the video's
own computed style.

Added a flex-centered regression test asserting the replacement img's
box stays identical to the video's box; confirmed it fails with the
old measurement order (a 16px border shrinks a 500px box to 484px) and
passes with the fix. Regenerated the one fixture (style-9-prod) whose
committed golden had baked in the shrunk-frame geometry.

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

* refactor(engine): consolidate video-box measurement into one object

Follow-up to c88890357: group the four measurement locals into a
single videoBox object instead of four loose consts, and trim the
surrounding comments down to their load-bearing content. No behavior
change -- same measurement order, same fallback conditions, same
applied style values; re-verified red (reverting the measurement order
still reproduces the 500px -> 484px shrink) and green.

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

* refactor(engine): trim the border-fix comments to the invariant

* test(producer): keep main's style-9-prod compiled.html; timings are unchanged

* test(producer): repin the classification source hash for test reachability

* fix(core): a color-graded bordered source keeps its canvas inside its border box

* fix(core): a graded source with an empty content box still shows its border

* fix(core): a border-only graded canvas stays hidden until the source has drawn

* fix(core): border-only graded canvas shows only while grading hides the source

* test(core): pin the border-only canvas hide after a context loss

* fix(core): grading canvas carries no border, so a graded source never paints it twice

* fix(core): graded injected frame drops its border once grading draws over it

* fix(core): a released grading entry gives the injected frame its border back

* fix(core): a released injected frame takes the video's current border style

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-10 18:17:23 +00:00
Miguel Ángel b6467d9131 perf(engine): re-renders reuse the extracted frames of remote videos (#5408)
* perf(engine): re-renders reuse the extracted frames of remote videos

A remote video is downloaded into the render's own work dir, so its path and
mtime are new on every render. The extraction cache keyed on both, so a
re-render of a composition with a remote video never hit the cache and ran
ffmpeg over the whole clip again. Key a downloaded source on the SHA-256 of
its bytes instead. Keys of local sources are unchanged.

* fix(engine): key the video copies a render writes itself by content

The producer downloads a remote video at compile time and copies it into the
render's compiled dir, so the extractor never sees the https src. Key any
source inside the compiled dir or the extractor's own output dir by content,
and skip the cache for one that cannot be read.

* perf(engine): hash a render-written video only when its cache lookup runs

Distributed planning copies the whole project into the compiled dir and defers
long video extractions, which never look up the cache. Hash render-written
sources just before the lookup, once per file, so deferred ranges and repeated
sources are not hashed.

* fix(engine): cache only downloaded media by content, not distributed project copies

A distributed plan copies the whole project into the compiled dir, so keying
everything there by content would hash every project video on the planner.
Content-key the extractor's own downloads and the dirs the caller names; the
producer names its remote media dir. Hashing stops on abort, and a source
with no hash skips the cache.

* style(engine): keep the extraction cache's comment share at its base

* test(engine): pin no hashing with the cache off and the producer's remote-media dir

* test(engine): run the cache-off case through the shared render helper
2026-10-10 08:09:39 +00:00
Miguel Ángel 3d6de0581e fix(engine): audio duration probe reads to the end on Windows; cli tests skip bun's disk cache (#5395) 2026-10-10 03:05:46 +00:00
Miguel Ángel 4b68f73fe7 fix(engine): long exports with many clips no longer stall at 90% or hide a timeout (#5375)
* fix(engine): long exports with many clips no longer stall at 90% or hide a timeout

* fix(engine): stop queued frame extractions on cancel and time out distributed assembly

* refactor(engine): simplify the extraction slot check and its concurrency test

* test(engine): prove a cancelled extraction leaves the queue, skip the shell shim on Windows

* test(engine): wait for the held extraction's own record before cancelling the queued one
2026-10-10 02:43:44 +00:00
Miguel Ángel f85614ecb2 fix(cli): WebGPU compositions work in snapshot, check and layout like render (#5354)
* fix(cli): snapshot, check, layout and thumbnails honour software WebGPU like render

* test(cli): stub the local WebGPU helper in the studio server tests

* fix(engine): webgpu compositions are recognised after the bundled runtime script

* fix(engine): a hung GPU probe falls back to software in 15 s instead of 120 s

* fix(engine): the GPU probe keeps the configured launch timeout and bounds only its page

* build(parsers): declare the html-attribute-spans subpath in its descriptor

* build(gcp-cloud-run): the BeginFrame contract stage copies parsers, which the engine now imports

* test(cli): the screenshot launch pin follows the shared local WebGPU helper

* fix(engine): the GPU probe's wait for Chrome's first page falls inside its 15 s limit
2026-10-10 00:08:07 +00:00
Miguel Ángel 6a83959b0f test(engine): real-ffmpeg tests and fixture cleanup get a 30 s budget (#5368)
* test(engine): real-ffmpeg HLS tests get the 30 s budget other real-ffmpeg tests use

* test(engine): real-ffmpeg fixture cleanup gets the 30 s its setup already has
2026-10-09 23:09:10 +00:00
miga-heygenandMiguel Ángel 9460c983e0 fix(engine): recover audio duration by decoding the final frame (#3986)
* fix(engine): recover audio duration from packet timestamps before clamping

An authored `data-duration` on an <audio> element could be silently
shortened when a source file's container-summary duration (mvhd/tkhd/mdhd)
undercounts the real audio length while the packet stream itself is
intact. Add `probeAudioDurationFromPackets()`, which re-derives the true
duration from the last packet's own pts_time + duration_time — figures
ffprobe computes from the stream's own timestamps, not the container's
summary header — and wire it into the compiler's existing clamp check as a
rescue attempt before accepting a shorter value. Works regardless of
codec/profile; verified against real HE-AAC/SBR fixtures and a
binary-patched "lying container" MP4 built for the regression test.

The rescue only fires for candidates the existing clamp predicate already
flagged (no added cost on the common path), runs in parallel across
candidates rather than serially, and requires the recovered duration to
exceed the existing clamp epsilon (absorbing the few milliseconds of
priming-delay drift raw packet timestamps carry relative to an
edit-list-corrected container duration) before overriding the shorter
value.

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

* fix(engine): move audio duration correction into extractAudioMetadata

The previous approach compared the compiler's own packet-scan probe
against the already-corrected duration returned by extractAudioMetadata,
which meant the correction only applied to one caller and the regression
fixture happened to exercise a pre-existing AAC-LC-specific refinement
instead of the new code path.

Move the fix to the single canonical place duration is derived: replace
the old AAC-LC-only packet-count refinement (a profile allowlist assuming
a fixed 1024-samples-per-frame constant) with a codec-agnostic mechanism
that decodes the final audio frame and reads its own timestamp plus real
sample count. This has no per-codec assumption, so it applies uniformly
regardless of codec or profile, and every caller of extractAudioMetadata
benefits automatically. The compiler's own clamp logic is now unchanged
from its pre-existing form.

Verified against a real, previously-untouched case: a FLAC-in-MP4 file
(a codec the old AAC-LC-only path never corrected) with its mvhd/tkhd/mdhd
duration atoms binary-patched to half their true value while every real
frame stays intact. Confirmed this reproduces on the prior code (by
literally substituting its actual content) and is fixed by the new code.

The decode probe reads CSV output (each line stands alone) rather than
JSON (a single top-level array), and retains only the output tail under a
small cap — a lying container's true duration can be arbitrarily larger
than its declared one, and JSON truncated from the front isn't valid JSON,
so an unbounded scan of a long file silently fell back to the wrong
duration once ffprobe's stdout cap was hit.

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

* test(producer): update style-7-prod golden for corrected AAC duration probe

The deleted AAC-LC packet-count refinement assumed every demuxed packet
decodes to exactly 1024 samples. For this suite's real audio fixture
(a 48kHz stereo AAC-LC MP4), that assumption overcounts: ffprobe reports
753 demuxed packets, but only 752 of them actually decode into a real
audio frame (verified via raw PCM sample count: 770048 samples / 48000Hz
= 16.0426666...s). The old mechanism's 753 * 1024 / 48000 = 16.064s was
therefore wrong; the new decode-based probe correctly derives ~16.043s
from the real final frame.

Regression-shards caught this as a compilation mismatch on the previous
commit. Verified the corrected golden against the real source: full
compilation, visual (0 failed frames), and audio (correlation 0.966)
checks all pass locally with the actual LFS-hosted golden video, and a
standalone compileForRender() check confirms all other audio-bearing
fixture suites in the repo are unaffected.

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

* perf(engine): seek the audio decode probe instead of scanning the whole file

probeDecodedAudioDuration ran a full, single-threaded decode of the
entire audio stream on every extractAudioMetadata call (once per audio
element in the HTML compiler, plus audio mixing/padding/encoding
steps). On real 30-minute files this took over a second, and could
approach ffprobe's 30s deadline on multi-hour files while holding a
shared probe slot the whole time.

Add -read_intervals so the probe seeks to ~1 second before the
container's already-known claimed duration and decodes only that
tail. This is safe because the correction only ever raises the
duration: an honest or over-claiming container's seek lands at or past
the real end and finds nothing extra, while an under-claiming
container (the only case this exists to fix) still has real frames
past the seek point. If the tail read finds nothing at all, fall back
to a full unseeked decode before giving up, protecting files whose
seek index is unreliable (an index-less or non-seekably-sourced
container) from silently missing a real correction — mirroring the
existing seek-then-fallback pattern already used by the sibling
final-video-frame probe in this file.

Also replaces the real-file regression test's runtime ffmpeg-spawning
fixture generation with two small, committed binary fixtures (an
honest FLAC-in-MP4 file and a copy with its container duration atoms
halved) plus the ffprobe-static package, so this test's real-file
proof actually runs in CI instead of being silently skipped when no
system ffmpeg is on PATH.

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

* refactor(engine): trim the audio duration probe comments to the invariant

* test(engine): cache tests queue the decode probe and count metadata probes only

* test(engine): probe for ffprobe with a literal argv so the argv contract classifies it

* test(producer): regenerate the speed-ramp golden for the single-encode mix

The audio probe now reads the mixed AAC as exactly 4.000 s instead of the
packet-count 4.032 s (which counted the priming frame), so the final mix
is stream-copied instead of re-encoded once more. Same waveform (sample
correlation 0.9996 vs the old golden), 3.1 dB less true-peak attenuation;
video frames are byte-identical.

* fix(engine): measure decoded audio from the stream start and trust it for raw ADTS

The decoded last-frame end is an absolute timestamp, so subtract the audio stream's start time
(format start as fallback) before comparing it with the start-relative container duration, and
start the tail read at start + duration - 1 s. MPEG-TS audio (1.4 s default start) and offset
MP4s were over-extended by their start time.

Raw ADTS (format aac) has no duration field; ffprobe estimates it from the opening bitrate and
overclaims after a quiet opening. Its decoded timestamps are sample-counted, so for raw ADTS the
decoded end now wins in both directions beyond the margin. Other containers stay upward-only so
an MP4 edit list is never extended.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-09 21:58:32 +00:00
Miguel Ángel 0241078560 fix(cli): name the unzip errors when every Windows unzip tool fails (#5355)
* fix(cli): name the unzip errors when every Windows unzip tool fails

* fix(cli): a cached empty or folder Chrome counts as missing, not found

* fix(engine): skip an empty cached Chrome the same way the CLI does

* fix(engine): keep scanning the Chrome cache past a build it cannot read

* test(engine): a folder at the cached Chrome path is skipped
2026-10-09 19:18:27 +00:00
Miguel Ángel e25394d38e perf(engine): wait for a missing composition timeline once per render, not once per browser (#5227)
* perf(engine): wait for a missing composition timeline once per render, not once per browser

A composition host that never registers a timeline (CSS, canvas or rAF animation
without data-no-timeline) made every capture session of a render wait the full
player-ready timeout: calibration, then the workers, then each static-frame check
page. The first session that times out now records the host ids in a memo shared
by the render's sessions; later sessions report the same timeout warning without
waiting again.

* perf(engine): the static-frame check page stops on a failed script and chunks share one memo

The verification page now gets the session's failed-script list, so it bails after
the short grace like the main page instead of waiting the full timeout. Distributed
chunks share one timeline-wait memo across their sessions. The poll takes an
options object instead of eight positional parameters.

* fix(engine): replay only a clean timeline timeout so a script failure still fails the render

A session that skipped the wait classified it at once, before a late page error
could arrive, so a render that used to stop on a script failure could ship. The
memo is now written only by a wait that ran its full length with no page errors
and no failed script loads; a replaying session that already sees a failed script
load reports a script failure.
2026-10-09 16:34:12 +00:00
James RussoandClaude Opus 5.5 04f2bcd67a perf(producer): extract distributed video frames in each chunk (#5290)
* perf(producer): extract distributed video frames in each chunk

Plan v2 no longer extracts long constant-frame-rate videos. It ships
each source once and every chunk extracts only the frames it shows, so
Plan time stops growing with video length (#5260).

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

* docs(aws-lambda): plan no longer bounds video length

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

* test(producer): pin deferred compiled sources to every chunk

Each chunk's page loads every <video> src during session init and a
missing file fails the chunk, so a deferred source under compiled/ must
keep reaching every chunk, as it does on main.

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-08 21:50:01 +00:00
Abdullah M. Nazif 2295ed5ea6 fix(cli): encode background-removal WebM colors as BT.709 (#5278) 2026-10-08 20:13:46 +00:00
Abdullah M. Nazif 730a330800 fix(engine): invalidate cached media metadata after file changes (#5258) 2026-10-08 20:04:48 +00:00
Abdullah M. Nazif 95d5494baa fix(engine): preserve out-of-range HDR object positions (#5266) 2026-10-08 20:00:37 +00:00
Abdullah M. Nazif 971e1373bd fix(engine): restore discarded warmup timing samples (#5204) 2026-10-08 20:00:27 +00:00
James RussoandClaude Opus 5.5 de1ef5a05a fix(engine): extract long video ranges in segments and keep plan uploads flowing (#5280)
* fix(engine): extract long video ranges in segments and keep plan uploads flowing

A distributed render's plan step extracted a whole video in one ffmpeg process,
so a long clip hit ffmpegProcessTimeout (300 s) however much of Lambda's
15-minute limit was left, and every frame then uploaded in batches of 16 that
each waited for their slowest blob.

Ranges longer than two minutes now extract as two-minute segments that run side
by side, each numbered after the last. They sample the same absolute times as a
single pass, so the frames are byte-identical (checked on a 26-minute, 37,440
frame clip). Plan v2 publishing keeps 32 uploads in flight instead of batching.
On a 10 GB Lambda the 26-minute clip's plan step fell from 395 s to 200 s.

The Lambda docs now state the plan-step length bound.

Fixes #5260

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

* fix(engine): split extraction on an integer frame count

The split compared and subtracted float seconds, so 200.3 - 80.3 became
120.00000000000001 s: a second segment got `-t 1.42e-14`, which ffmpeg
rejects, failing an ordinary two-minute range. The frame count now comes from
extractionFrameCountForDuration, the rule a single pass follows, and every
segment takes an exact frame count.

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-08 19:07:46 +00:00
Monster-GM 202d5b5300 fix(engine): tag GPU H.265 streams as hvc1 (#5029) 2026-10-08 15:02:33 +00:00
Miguel Ángel 9e0d05d450 fix(engine): auto worker count stays within the heap budget (#5149)
* fix(engine): automatic worker count never exceeds what the V8 heap can feed

A 24 GB Mac exporting a 43 s portrait project with 22 videos picked 5 capture workers against a 4192 MB heap that feeds about 4, and the render died at 62% with JavaScript heap out of memory. The heap budget was only advisory; it now caps the automatic count (bound 'heap'). An explicit --workers N is untouched.

* test(engine): heap-cap test restores its mocks even when an assertion fails

Review findings on #5149: the os and v8 mocks were restored only if every assertion passed, and two comments still called the heap cap advisory.

* docs(engine): heap budget comments say it caps auto sizing
2026-10-07 06:11:30 +00:00
Miguel Ángel 3b89bfe8a7 fix(engine): motion blur works over video by holding each video frame across samples (#5150)
* fix(engine): motion blur works over video by holding each video frame across samples

Each output frame's injected video frame is now held for every sub-frame
sample and probe of that frame, so footage stays as shot and only the
layers over it blur. The refusal for sessions with a before-capture hook
is removed.

Fixes #5144

* fix(engine): trim held-video comments, pin the new test-classification hash

* fix(engine): a blurred frame at a sub-composition cut keeps its video

Each sample now decides which videos show at its own time, like every other
layer, and only a video on screen at the output frame's time keeps that
frame's picture. Before, the visible set was taken at the frame's time while
sub-composition hosts were hidden at the sample's time, so the samples just
before a cut showed no video and the cut frame dipped toward black. The
before-capture hook takes the held time as an optional third argument, and
the early injection is gone: the GPU re-render now always runs at the page's
own time.

* style(engine): one doc comment for the before-capture hook

* fix(engine): a video animating its own style stays in step when blurred

The injector copies a video's own style only when its frame changes, so the
first injection of each blurred frame now runs at the frame's own time
before the samples. A video sliding by its own transform no longer sits a
quarter frame behind with blur on.

* test(producer): a two-line header for the motion blur over video test

* test(producer): reach the cut fixture by path, not a URL fallow reads as an import
2026-10-07 05:46:10 +00:00
Miguel Ángel cffc3aa4e1 feat(render): report encoding and assembling progress (#5137)
* feat(render): report encoding and assembling progress

* fix(render): close every progress stage and fail a render whose progress callback throws

* fix(render): send a repeated progress update once

* fix(render): keep Docker terminal output as before and drop the unneeded callback ending

* fix(render): keep one failure stage code per stage whatever its frame counts
2026-10-07 04:29:35 +00:00
James RussoandClaude Opus 5.5 a17d0bfc00 feat(core): support deterministic async frame sources (#5130)
* feat(core): support deterministic async frame sources

* fix(runtime): retain queued draws and disable unsafe dedup

* docs(core): mark frame-source hosts with data-no-timeline

A composition that follows the frame-source contract registers no GSAP
timeline, so lint reported missing_timeline_registry and every render
waited 45 s for timeline registration (17.6 s -> 1 min 47.9 s for the
14 s imported film sample). Document the existing data-no-timeline
opt-out for the host and a timeline-less root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(runtime): avoid transport redraws after render seeks

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:59:55 +00:00
Miguel Ángel 4cf5cf9b53 feat(engine): render WebGPU compositions on SwiftShader behind an opt-in (#5133)
* feat(engine): render WebGPU compositions on SwiftShader behind an opt-in

PRODUCER_ALLOW_SOFTWARE_WEBGPU=true lets a host without a GPU render a
data-requires-webgpu composition instead of refusing it. SwiftShader can
draw canvas WebGPU only through its Vulkan backend with GPU compositing
on, so an opted-in software launch adds the Vulkan feature and
--use-vulkan=swiftshader, keeps compositing on, and the adapter check
accepts the fallback adapter. Off by default; every other launch is
unchanged.

* test(engine): compare the opted-out launch without a cast

* test(engine): pin which adapter a capture session accepts for WebGPU

* test(engine): assert the adapter check's arguments with the call matcher

* docs(engine): fit the software WebGPU note in three lines
2026-10-07 01:25:10 +00:00
Miguel Ángel 39a693c0a4 fix(engine): a render fails when a composition's own script throws before its timeline binds (#5033)
* fix(engine): a render fails when a composition script throws before its timeline binds

* fix(engine): an unrelated page error no longer fails a render whose timeline registers late

* fix(engine): only the composition's own scripts fail a render, local or distributed

* style(engine): format the script failure change

* fix(engine): a third-party browser rejection no longer fails a render

* refactor(engine): one page URL for navigation and error attribution

* fix(engine): a rejection after a hash change or in an iframe no longer fails a render

* fix(engine): a widget's error no longer fails a render once its script is inlined

* fix(core): name injected and inlined scripts without building code from library input

* fix(engine): a third-party embed's eval or inserted script no longer fails a render

* fix(producer): a widget's inline handler or a sub-composition no longer misreports a render

* refactor(engine): read each script response once and keep comments within 120 characters

* fix(engine): a scene that throws after registering its timeline no longer cuts a render short

* fix(engine): a composition script failure names the error and its real cause

* fix(engine): a failed VFX chain fails the render at once instead of shipping a missing layer

* fix(core): a VFX layer that is empty on one frame renders empty instead of failing or going stale

* fix(core): a VFX layer that grows from zero size renders instead of staying missing

* fix(core): a VFX layer hidden or late while empty renders once it has a size

* test(producer): cover a VFX layer that is 0×0 under a host that starts later

* test(producer): pin both VFX 0×0 marks, with a GSAP-shaped wipe and a real late host

* test(producer): a late VFX host with a GSAP wipe catches a walk that skips hidden hosts
2026-10-05 16:45:18 +00:00
Miguel Ángel 8e457d4224 fix(engine): fail the render when an injected video frame cannot decode (#4974)
* fix(engine): fail the render when an injected video frame cannot decode

* fix(engine): report BeginFrame capture errors at once, not after the stall watchdog

* fix(engine): skip the diagnostic screenshot for any session launched for BeginFrame
2026-10-03 23:27:39 +00:00
miga-heygenandMiguel Ángel d7285e414b fix(engine): preserve composition root background in alpha exports (#3976)
* fix(engine): preserve composition root background in alpha exports

initTransparentBackground() forced html, body, AND any
[data-composition-id] element (the composition root, or a nested
sub-composition root) to transparent for every alpha-carrying capture
session. That's correct for the HDR two-pass layered DOM pass, where
the HDR video itself is the backdrop — but for a plain alpha export
(MOV ProRes4444, webm+alpha) of a normal composition, an author's own
background painted on the root is real, intentional content, and it
was being silently dropped.

Add CaptureOptions.clearCompositionRootBackground, threaded through
session.options into every general-session call site (default false —
preserve the root's authored background). The two HDR-layered-DOM-
session call sites set it to true, keeping their existing (correct)
behavior. initTransparentBackground's injected stylesheet is now
idempotently rewritten rather than insert-once/skip, so a caller's
choice is never silently ignored by an earlier call on the same page.

Add a producer test fixture + transparency-test.ts check proving a
composition root's authored background survives into alpha output,
plus unit tests for both flag values and the rewrite-on-repeat-call
behavior directly.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): register the new transparency fixture with the shard planner

transparency-regression-root-bg is exercised by transparency-test.ts
directly (asserts a real alpha channel), not by the sharded regression
harness, same as its sibling transparency-regression. The shard
planner hard-fails on any fixture directory it doesn't recognize as
either scheduled or explicitly excluded, so register it in the
excluded list with the same reason as its sibling.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(engine): restore explicit transparent-background call for HDR layered sessions

The previous commit removed the two HDR-layered-DOM-session call
sites' own explicit initTransparentBackground() call, relying on
initializeSession()'s internal call (gated behind
session.options.format === "png") plus a new
clearCompositionRootBackground session option.

That was wrong: a plain HDR render's own DOM session captures
"jpeg", not "png" — the final HDR MP4/HEVC output doesn't itself
carry an alpha channel, only the layered-compositing step does. So
initializeSession()'s internal call never fired at all for this
common case, and the HDR video layer never got a transparent DOM
backdrop to composite into.

Reproduced directly against the hdr-regression fixture (part of the
regression-shards CI suite): a real local render showed a severe
PSNR collapse (~8-56dB) in the "Window B: z-order sandwich" segment,
where a DOM element behind the HDR video, the video itself, and a
translucent DOM overlay in front all need correct z-ordering — with
the composition root's own background no longer cleared, it painted
solid over the video slot instead of letting the video composite
through. Confirmed via bisection: reverting to the prior commit's
files reproduces 0 failed frames (passing); the removed-call version
reproduces the exact failure; restoring the explicit calls fixes it
again (100/100 checkpoints, 0 failed frames).

Restore the explicit, unconditional call in both HDR call sites,
right after initializeSession(). clearCompositionRootBackground still
does its job for the general/plain-alpha-export path (frameCapture.ts),
which is unaffected by this fix.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(engine): let the HDR layered pass own composition-root clearing

Drop the CaptureOptions.clearCompositionRootBackground field: every HDR layered
DOM session already calls initTransparentBackground right after init, so that
call is the one owner. Engine paths use the default, which keeps root backgrounds.

* chore(producer): drop comment additions the comment ratchet rejects

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 06:51:31 -07:00
HEOJUNFO 8aefd91bf8 fix(cli): capture snapshot --at at the exact requested instant (#4827)
snapshot --at captures the frame at the exact requested instant instead of the nearest seek step.
2026-10-02 05:48:06 -07:00
Santhi PrakashandMiguel Ángel cad7dbed0d fix(engine): warn when a video input declares alpha but decodes opaque (#3771)
* fix(engine): warn when a video input declares alpha but decodes opaque

- Problem: a video input whose alpha_mode=1 tag outlives its alpha plane (a remux drops the BlockAdditional sidecar while keeping the tag) composites as a solid rectangle, and nothing tells the user their file, not the renderer, is the problem (#3220 / #3226).
- Fix: during extraction preflight, when an input declares alpha (hasAlpha) and its codec can carry alpha, sample the decoded alpha plane (up to 3 frames at 8x8 rgba, bounded 30s, ~768-byte ceiling). If uniformly opaque, emit a non-blocking stderr warning naming the file with the re-export remedy. Inconclusive probes stay silent; opaque full-frame backgrounds are legitimate and never fail the render.
- Verification: 8 new unit tests (byte logic + message); engine suite 1483/1487 with 4 pre-existing failures on clean main (missing hdr-regression PNG fixture in this clone); typecheck/lint/format clean; real-fixture checks: lying-tag WebM warns, genuine-alpha WebM stays silent, both agree with ffmpeg alphaextract.

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>

* fix(engine): probe input alpha with the decoder that matches its codec

* style(engine): format the alpha probe test

* fix(engine): keep the alpha probe from failing or racing a render

---------

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 04:59:16 -07:00
9d6ca45857 fix: unbreak the regression suite on Windows (PSNR filtergraph escaping + console window popups) (#3839)
* fix: hide console windows spawned by ffmpeg and nvidia-smi on Windows

Node's `windowsHide` defaults to false, so every child process that is a
console application pops a window on the user's desktop. During a render
or a regression run that is a stream of black windows stealing focus.

The repo already sets `windowsHide: true` across cli, studio-server and
most of engine; these were the remaining gaps, all on paths that run
during a normal render:

- core/mediaGradeAnalyzer: ffprobe + ffmpeg, once per analyzed media file
- engine/browserManager: `nvidia-smi` VRAM probe. This one goes through a
  shell (`execSync`), so it flashes a cmd.exe window even on machines with
  no NVIDIA GPU, where the command exists only to fail
- producer: the regression harness ffmpeg runner, PSNR/audio comparison
  helpers, fixture synthesis, and the ffprobe call in audioPadTrim

No behavior change on POSIX, where the flag is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(producer): escape PSNR stats_file for ffmpeg's two-pass filtergraph parse

An ffmpeg filtergraph argument is unescaped twice: once when the graph is
split into filters and their options, then again when the option value is
read. `psnrAtFrames` escaped `\` and `:` a single time, which only survives
the first pass.

On POSIX that is invisible, because mkdtemp under tmpdir() produces neither
character. On Windows the stats path is always `C:\Users\...`, so it reaches
the option parser with a bare `:`, which starts a new option and fails the
whole graph:

    [AVFilterGraph] No option name near '\Users\...\psnr.log'
    Error parsing filterchain '[rv][gv]psnr=...:stats_file=C\:\Users\...'

Every suite that got as far as quality validation died there, so a Windows
regression run reported 0 passes with only 2 genuine compilation failures.
Escaping for both passes fixes it and stays a no-op on POSIX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(producer): pin PSNR stats path escaping with a colon and backslash path

* fix(producer): keep the PSNR stats path out of the filtergraph

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 04:14:36 -07:00
6632d99c9c fix(engine): pin capture surface scale to 1 device pixel per CSS pixel (#3782)
* fix(engine): pin capture surface scale to 1 device pixel per CSS pixel

Capture reads the browser's compositor surface (`Page.captureScreenshot`
with `fromSurface: true`, and `HeadlessExperimental.beginFrame` likewise),
and that surface is rasterized at the host display's backing scale.
`page.setViewport({ deviceScaleFactor })` goes through
`Emulation.setDeviceMetricsOverride`, which only moves
`window.devicePixelRatio` on the page side — it does not resize the
surface.

On a HiDPI host every render therefore came out at twice the composition:
a 1920x1080 composition encoded to a 3840x2160 file, and no flag could
bring it down, because the pipeline's own deviceScaleFactor was already 1.
Supersampling stacked on top of it, so `--resolution landscape-4k`
produced 7680x4320 rather than 3840x2160.

Pinning the surface with `--force-device-scale-factor=1` leaves the
deliberate supersampling path (the capture clip's `scale`, from
`resolveDeviceScaleFactor`) as the only thing that multiplies output
pixels. The flag is a no-op on Linux, where the surface is already 1x, so
CI and cloud output are unchanged.

* fix(engine): drop the capture-scale comments the ratchet rejects

---------

Co-authored-by: Leo Xu <xuliyou@bytedance.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 03:30:44 -07:00
Vance IngallsandClaude Opus 5.5 3c299acc7f feat(core): audible videos in preview web audio, audio groups, meters and carve (2/8) (#4814)
* feat(core): add isAudibleVideoElement predicate and audible media selector

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(runtime): route processed audible videos through web audio in preview

An audible <video> joins the Web Audio schedule, hidden-toggle and
group-capture passes when it needs a gain el.volume cannot express: a
boost above unity, an fx chain, automation, or an audio group. A plain
audible video at unity stays on native output. The clock master stays
audio-only; a video whose capture fails stays on native output (never
decoded, never failed closed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(audio): let audible videos join audio groups in preview and render

resolveAudioGroups, audioGroupOf, group render ids, the engine mixer and
the audio_group_no_members lint rule accept an audible <video>. A hidden
group drops its video members from the mix; muted videos stay inert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(editing): offer the audio fx section on audible videos

EditableElementFacts gains hasAudio; studio and sdk derive it from muted and data-has-audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): show audio meters when a project has an audible video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): offer audible videos as carve voices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): let audible video clips join audio groups

Drops the video refusal; grouping and group audition count audible videos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(engine): honor hidden audio groups for auto-detected video audio

Muted videos that declare data-has-audio are no longer mixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): keep authored src, loop and honest status when keeping cutout sound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(core): treat only data-has-audio=true as audible in runtime and lint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(core): trim the web audio route and group-key comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(runtime): capture ramped clips so a group's bus carries them in preview

A rate lane skipped Web Audio entirely, so a ramped video or audio clip in a
group played native output without the group's fader, mute or FX while the
export applied them. Only the decoded-buffer fallback cannot follow a rate
curve; the media-element capture can, so the skip now guards that fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): show meters only for videos Web Audio carries

A plain audible video at unity stays on native output, which the meters
cannot read, so it no longer advertises them. The runtime's join rule and the
meter gate now share audibleVideoNeedsWebAudio (core/audible-video).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:28:30 +00:00
37f30b1d06 feat: keep a video's sound on the clip by default (1/8) (#4813)
* feat(cli): one owner for init media placeholders, audio stays on the video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): init keeps a video's sound on the clip; silent files scaffold muted

Supersedes the patchVideoSrc changes in #3977 and #3942 and carries their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: miga-heygen <miga-heygen@users.noreply.github.com>
Co-Authored-By: kapelame <kapelame@users.noreply.github.com>

* feat(registry): example templates keep the a-roll's sound on the video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lint): video audio rules recommend keeping sound on the video

Update video_missing_muted and video_audio_double_source message and
fixHint text to recommend the new default: keep audio on the video with
data-has-audio="true" (not muted). Tests verify the new wording is in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parsers): a video is either audible (data-has-audio) or muted, never both

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): follow the compiler's audio rule in the audio-track toggle

Confirm before background removal drops a video's sound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): hyperframes-core teaches sound on the video by default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): workflows and scaffold guidance keep a video's sound on the clip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: footage with sound keeps it on the video clip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): init from-file a-roll is timed, muted strip is attribute-exact

The from-file a-roll video gets data-start="0" so a scaffold lints clean, and
the orphaned audio-only slot comment is dropped. setVideoAudio now walks the
tag attribute by attribute, so a class value containing the word muted
survives. The ffprobe-missing warning says the video is assumed to have sound
and how to switch to muted, and DEFAULT_META notes the failure surfaces at
render until lint support lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): keep has-audio in step with the muted toggle, test cutout confirm

Muting a video now clears has-audio and unmuting sets it to true, in both the
Design and flat media panels, matching the Has audio track toggle. Audio
elements are unchanged. Adds coverage for both panels plus the cutout
window.confirm guard: declined leaves src alone, accepted swaps src and mutes,
a silent video never prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parsers): updateElementInHtml hasAudio only applies to videos, true or false

An audio element or an undefined hasAudio no longer picks up a muted attribute;
only an explicit false on a video writes muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: video_missing_muted rule text, silent-footage notes, consistent audible examples

lint.mdx and cli.mdx describe the current video_missing_muted rule instead of
the retired unmuted-video wording. The editing recipes say how to adapt for
silent footage and require the outgoing clip's sound to end at a J-cut. The
Remotion media example shows the audible mapping consistently, data-attributes
states that has-audio is required unless muted, and the hairline frame note no
longer claims the video is muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): encode init media filenames and probe --audio duration

Function replacers stop dollar-sign pattern expansion in filenames; src is percent-encoded.
--audio now passes the probed track length as the composition duration in both init paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lint): audio-state-aware media rules and time-aware double-source check

video_missing_muted honors any explicit data-has-audio.
video_audio_double_source only considers audible videos and only fires
when the same-src clips overlap in time.
duplicate_audio_track now covers audible videos.
audio_file_without_element no longer claims silence when an audible video
carries the sound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): audio-stream check for remotion ports, followable J-cut recipe

Remotion Video maps to data-has-audio only when the file has an audio stream.
The J/L-cut recipe now shows both shots with muted pictures and separate audio.
embedded-captions removes the audio.mp3 scratch file it extracts.
skills-manifest.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(lint): trim comments to satisfy the comment ratchet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): atomic muted edits, cutout keeps sound on a linked audio

Muted and Has audio track commit one batched write (one persist, one
undo entry) and remove data-has-audio instead of writing an empty
marker. Unmuting a video probes its src: audible -> true, silent ->
false, probe failed -> absent. Remove background on an audible video
inserts a linked sibling <audio> with the original sound in the same
write, replacing the drop-the-sound confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(producer): skip hidden videos when auto-adding video audio

A video hidden in Studio is dropped by the mixer, so its src never reached
existingAudioSrcs and the render re-added it at full volume. The video parser
now records the shared hidden predicate and the auto-detect step skips hidden
videos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): keep useDomEditSession under the 600-line cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: miga-heygen <miga-heygen@users.noreply.github.com>
Co-authored-by: kapelame <kapelame@users.noreply.github.com>
2026-10-01 23:56:36 +00:00
Miguel Ángel 663b02297d feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk (#4701)
* feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk

`hyperframes clean` lists leftover render work dirs, debug renders, the history of projects
that no longer exist, idle video proxies and extracted video frames, with sizes, and removes
them. `--dry-run` removes nothing, `--json` is for agents, `--snapshots` also removes
snapshot folders (otherwise listed as reclaimable). It never touches outputs, sources or
anything a running render or preview still owns.

* docs(cli): say that clean does not search a project's subfolders

* fix(cli): clean test compares folder names on Windows and the skills manifest is regenerated

* fix(studio-server): the proxy cache sweep removes only files the transcoder named

It removed any idle .mp4 or .webm in .transcode-cache, so a user file placed there, or reached through
a symlinked cache folder, could go. Proxies are always <sha256>.<ext> and their temp files
.tmp-<uuid>-<proxy name>; nothing else is swept.

* style(producer): keep a renderDirOwner doc comment within 120 columns
2026-09-29 00:43:42 +00:00
Miguel Ángel f7ef18dca8 fix(producer): renders leave no captured frames behind when interrupted (#4694)
Ctrl+C during a render left its whole work dir of captured frames (and a
staging dir) in the renders folder. Puppeteer's default SIGINT handler kills
Chrome and exits the process before the render's own cancel path can remove
them. The engine keeps that default unless the host owns Ctrl+C; the CLI's
render cancellation scope claims it while a render runs, so the render aborts
and cleans up after itself.

A render killed outright (SIGKILL, crash) cannot clean up, so each work dir and
staging dir now records its owner process (pid, hostname, boot, and on Linux
the pid namespace), and the next render into the same folder removes
the ones whose owner has exited.

Cancelling a render while its browsers start no longer leaves anything
running that kept a batch render alive after Ctrl+C: the BeginFrame warm-up
loop, the probe browser and file server, or a capture browser whose launch
finished after the cancel (now closed before the worker settles, since
Puppeteer's exit hook skips every other live browser).
2026-09-28 21:58:06 +00:00
Miguel Ángel 5257dd581f fix(studio): tone-map HDR preview proxies with the render tone map (#4606) 2026-09-28 15:48:58 +00:00
Miguel ÁngelandKenneth Shortrede 3a64ea867c fix(engine): forced-SDR renders show tone-mapped HDR footage as Studio does (#4600)
* fix(engine): forced-SDR renders show tone-mapped HDR footage as Studio does

Forced-SDR extraction tone-maps HDR frames with zscale, but the frames kept the
BT.709 transfer tag, so Chrome re-curved them (a tone-mapped 388d96 painted as
2d8790), and JPEG frames kept the BT.709 matrix every JPEG reader decodes as
BT.601. Tone-mapped frames now get the same sRGB tag and BT.601 JPEG conversion
as other SDR frames.

macOS used VideoToolbox, which does not tone-map: its frames stay PQ and Chrome
tone-maps them itself, darker than the hable curve Linux and Studio use. macOS
now takes the zscale path whenever ffmpeg has zscale, found through the shared
ffmpeg filter probe; without it the old path stays and the render warns once.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): forced-SDR renders tone-map HDR footage that lacks matrix or primaries tags

zscale finds no path between colourspaces without them, so the extraction
failed. Untagged HDR footage is BT.2020 in practice, and now gets those tags.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): forced-SDR tone map also tags the transfer ffprobe read from the container

From ffmpeg 8.1 ffprobe reports a container-only PQ or HLG tag; the frames
still lack it, so zscale failed where macOS VideoToolbox used to render.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* test(engine): gate the container-only transfer case on the ffprobe that reads it

* docs(engine): shorten the filter-probe comment to the comment limit

* fix(engine): keep valid per-frame colour tags when tone-mapping HDR footage

* test(engine): build the mixed HLG and PQ fixture without B-frames so its timestamps are valid

* test(engine): build the trimmed mixed fixture without B-frames too

---------

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
2026-09-28 08:21:13 +00:00
Miguel Ángel e8313b2b39 fix(engine): renders show the video frame the preview shows at each output frame (#4596)
* fix(engine): renders show the video frame the preview shows below the source frame rate

* refactor(engine): one condition owns CFR sampling at the output rate

* fix(engine): webm and mkv clips keep every frame when rendered at their own rate

* fix(engine): only fine timebases get the half-tick sampling shift, so coarse ones stay exact

* docs(engine): the sampling comment states the half-tick bound it keeps

* fix(engine): move the extraction cache to v6 so main v5 entries are not reused

* docs(engine): keep extraction comments within their share

* test(producer): regenerate four 25-to-30 fps baselines for the exact frame pick

* test(engine): name the v6 schema prefix in its test
2026-09-28 01:38:09 +00:00
Miguel ÁngelandKenneth Shortrede c9b3d9c962 fix(engine): SDR renders keep footage and page colours as in the source (#4564)
* fix(engine): declare SDR extracted frames as sRGB so Chrome does not darken footage

* fix(engine): encode SDR mp4 frames with the BT.709 matrix they are tagged with

Chrome hands the SDR encoder BT.601 full-range JPEG captures (or RGB PNGs).
The encoders only converted the range, so swscale kept the BT.601 matrix under
the BT.709 tag (ffmpeg 5.1 to 7.0: CSS #C83C28 delivered as #D44824) or
converted YUV to YUV through a low-precision step (ffmpeg 8+: #101010 delivered
as #0B0D0D). Decode to RGB with the capture matrix, then encode with BT.709.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): tag SDR frames plain sRGB so every ffmpeg writes the sRGB chunk

ffmpeg writes a PNG sRGB chunk only for BT.709 primaries and writes cICP only
from 6.1. On ffmpeg 5.1 (Debian bookworm) a BT.601-tagged source therefore got
cHRM plus gAMA 1/2.2, which Chrome decodes as a pure 2.2 power: 0x10 shadows
rendered as 0x07. Declaring BT.709 primaries too gives the sRGB chunk on every
version, so Chrome shows the frame code values for every SDR source. The test
now reads the PNG chunks instead of ffprobe, which reports no transfer for an
sRGB chunk on ffmpeg 5.1.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): extract SDR jpg frames as the BT.601 full range JPEG readers assume

The default frame format for opaque SDR sources is JPEG. ffmpeg converted the
BT.709 source to full-range YUV without changing the matrix, and every JPEG
reader (Chrome included) decodes BT.601, so footage shifted: 0xC83C28 showed as
0xBB2D29. The old mp4 encode made the inverse mistake and hid it; with the
encode fixed, it would reach the output. Decode through RGB with the source
matrix, then encode BT.601 full range, keeping 4:2:0.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): round the SDR mp4 colour conversion accurately

swscale's default rounding moved saturated colours up to 3 codes (pure red 254
delivered as 251 on ffmpeg 5.1 to 7.0); accurate_rnd brings that to 1. The
chunk encoder now decides the HDR-or-SDR filter once, and the 10-bit GPU pad
test sets hdr, which its name always claimed.

* style(engine): keep the colour filter reasons in names and tests, not source comments

The comment ratchet caps a file's comment share: the new filter module and the
extractor carry their intent in constant names, and the encode test states why
the conversion goes through RGB.

* fix(engine): keep colour edges sharp through the SDR colour conversions

Going through RGB upsampled chroma with swscale's default filter, so ffmpeg 4.4
to 7.x smeared colour across edges by about 2 px (red 254 next to blue came out
8 inside the blue). Nearest-neighbour resampling maps the half-resolution chroma
back one to one, keeps the flat-colour results, and makes accurate_rnd redundant.
The pixel tests now also check a red to blue edge.

* test(producer): regenerate goldens whose colours the old mp4 matrix bent

These four fixtures compare at 40 to 45 dB, and their goldens were rendered
with the BT.601-under-BT.709 encode, so the corrected colours score 34 to 37 dB.
Regenerated in the Dockerfile.test image. Each new golden is closer to the
colour its page declares, e.g. #00d4aa was 00bcab and is now 00d3aa, and
#087443 was 006643 and is now 087241.

* fix(engine): keep VFR frame timing when extraction applies colour filters

ffmpeg before 6.1 ignores frame durations in CFR resampling once any -vf is
set, so a VFR window ending on a still lost frames. Filtered VFR extraction
now resamples in one process exactly as before and filters in a second.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): encode raw SDR layered frames in BT.709

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* test(engine): keep the new colour tests inside the format, comment and argv checks

* fix(engine): keep HDR and unusual pixel formats out of the two-process VFR path

The raw handoff dropped HDR10 light levels and misread semi-planar formats, and
decoder options rejected some ffprobe colour names. Only SDR sources in formats
nut carries unchanged now take it, their colour tags come back through setparams,
and a process that cannot start releases the other.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): read untagged footage with the matrix Chrome plays it with

Chrome decodes an untagged matrix as BT.709 from 720 coded lines up and as
BT.601 below; extraction assumed BT.601 at every size, so HD footage without
colour tags shifted hue against Studio's own preview.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* fix(engine): untagged VP9 and AV1 footage keeps BT.601, as Chrome plays it

Chrome applies the 720-line rule only to codecs it decodes through ffmpeg
(H.264, VP8); untagged VP9, VP9 alpha and AV1 play as BT.601 at every size.
Also widens the raw-pipe pixel formats to all verified ones, and reports the
pipeline process that failed first by settle order, not by per-process clocks.

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>

* test(producer): regenerate goldens for corrected page colours and untagged SD footage

* docs(engine): say which untagged codecs the matrix rule was measured on

---------

Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
2026-09-27 11:16:35 +00:00
Miguel Ángel 3def3514d1 fix(engine): export draws a video in exactly the frames it is shown in (#4565)
* fix(engine): export draws a video in exactly the frames export visibility shows it

* test(producer): guard that chunk frame planning snaps clip windows like export

* refactor(engine): set the render fps once per frame table and name its clip window

* chore(engine): trim frame table comments
2026-09-27 03:05:57 +00:00
Miguel Ángel 984ad28c7c fix(core): clips that meet within float rounding count as meeting in edits and playback (#4535)
* fix(core): clips that meet within float rounding count as meeting in edits and playback

* fix(engine): hand a shared clip boundary to the incoming clip in export, as preview does
2026-09-26 22:14:14 +00:00
Miguel Ángel fba943ea3a fix(cli): render --quiet stops printing producer info and trace lines (#4478) 2026-09-25 10:39:01 +00:00
Miguel Ángel f421851bdd fix(producer): give each parallel segmented-capture worker its own browser (#4475)
* fix(producer): give each parallel segmented-capture worker its own browser

Segmented capture with two or more workers failed at the first frame with
"Invalid JPEG input at frame 0: empty frame". Every worker opened its page in
one pooled Chrome, and beginFrame capture does not survive a shared browser:
a worker's first beginFrame returned no screenshot. Fresh worker sessions now
follow shouldDisableBrowserPoolForParallelWorker, the rule the parallel disk
path already uses.

The default session factory was also built once per worker, so with a probe
session every worker reused that same page. It is now built once per run, so
only the first worker takes the probe.

Workers are now created inside the try, so when a later worker fails to
launch, the finally still closes the browsers opened before it.

* fix(producer): close a segmented worker session whose initialization fails

The default segmented session factory opened a fresh browser and then
initialized it with nothing owning it on failure, so a page-load or init
timeout leaked that Chrome. It now closes the session and rethrows, as the
streaming stage does.
2026-09-25 06:42:59 +00:00
WaterrrForeverandClaude Opus 5 9d4836902b fix(engine): name the data-no-timeline opt-out in the timeline-timeout warning (#4410)
* fix(engine): name the data-no-timeline opt-out in the timeline-timeout warning

When the sub-composition timeline wait times out, two things are emitted:
the stderr line inside pollSubCompositionTimelines, which names the pending
composition ids and the data-no-timeline opt-out, and the structured warning
on the session, which said only "Sub-composition timelines did not become
ready within 45000ms".

The structured one is what a programmatic caller reads: render re-prints it
in the completed_with_warnings summary and returns it in the result's
`warnings` array, so a CI consumer or an SDK caller sees the symptom with no
remedy attached. It also reads as a fault to diagnose, when the commonest
cause is a composition that legitimately has no GSAP timeline -- CSS
animations or rAF -- and is paying the full playerReadyTimeout on every
render because nobody told the author the wait is theirs to switch off.

The message now says the wait can be intentional, names data-no-timeline as
the opt-out, and keeps the async-setup case as the other branch. The pending
composition ids were already computed for the stderr line and thrown away;
an optional onPending callback carries them to the session so the structured
warning and its details can name them too, matching the script-failure
branch, which already names its failed sources. The stderr text is unchanged.

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

* fix(engine): normalise the pending-id result instead of casting it

The enumerate step cast `page.evaluate`'s result with `as string[]`, which is a
claim the call site cannot enforce: `evaluate` is loosely typed, and anything
that returns a non-array turned the cast into a TypeError thrown on the very
path that exists to REPORT a problem. Normalise the result instead.

`pendingCompositionIds` was also never declared on `CaptureWarning.details`,
whose type is closed, so the engine build failed and every downstream package
failed with it.

Adds coverage for the poll's own `onPending` handoff, which the existing tests
in this PR could not see: they exercised `recordSubTimelineWarning` (the
message text) and never `pollSubCompositionTimelines` (where the throw was).
The non-array case is the one that discriminates this fix from the tempting
alternative of leaving the cast and adjusting the stubs.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 02:29:35 +08:00
WaterrrForeverandClaude Opus 5 250c9bd509 fix(engine): resolve the version by walking to our own package root (#4409)
readEngineVersion resolved ../../package.json relative to import.meta.url.
That path is correct from packages/engine/src/utils/ in the repo and
overshoots the package root from <pkg>/dist/ in the published CLI, which
bundles the engine. The require threw MODULE_NOT_FOUND, a bare catch
swallowed it, and every render stamped the 0.0.0-dev fallback.

No single relative literal is correct in both layouts, so resolve by walking
up to our own package root instead of by a fixed depth.

The name check is the fix, not a refinement. A bare "first package.json
found while walking up" resolves to whatever sits above in a hoisted install
and returns a confident wrong version - the same class of defect as the
sentinel, and harder to notice because the answer looks plausible. Only a
package.json whose own name is hyperframes or @hyperframes/* may answer.
A test pins this: removing the name check fails that case and only that case.

Also replaces the sentinel. 0.0.0-dev parses as a version, compares against
one, and sorts below every release, so a total resolution failure was
indistinguishable from a dev build - which is why it shipped unnoticed. The
fallback is now "unresolved", deliberately not semver-shaped, and every
failure path warns with the reason. A failed lookup still never breaks a
render.

The old comment claimed the engine ships as raw TS and exports
./package.json. The published package has no exports field and bundles the
engine, so that premise does not hold where the code runs; it is replaced
with the two layouts and why they conflict.

Refs #4365

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 02:29:30 +08:00
Miguel Ángel cbaddec51a fix(engine): download tests stop racing real deadlines, and failures name their cause (#4419)
* test(engine): hold the download attempt deadline so slow runners cannot trip it

urlDownloader tests passed a real 1s deadline that a slow Windows attempt outlasted.

The deadline timer is now held; the one test that needs it fires it by hand.

* fix(engine): name the underlying error when a download fails, without its URL

* test(engine): fire the stalled-body deadlines by hand instead of racing a real 20 ms timer
2026-09-24 10:58:53 -04:00
xiayewang-heygen 7d1d9ddf70 Merge pull request #4368 from heygen-com/fix/ffprobe-nb-frames-duration-crosscheck
fix(engine): cross-check nb_frames when video stream duration is absent
2026-09-23 21:30:11 -07:00
xiayewang-heygenandClaude Opus 4.6 b021cb77be fix(engine): reconcile metadata duration after extraction delivers fewer frames (#4371)
When the probed video stream duration is inflated (container duration
fallback includes a longer audio track), FFmpeg delivers far fewer
frames than expected — typically exactly 50% for stock clips whose
audio is 2x the video length. The coverage gate then aborts the
render because it sees 50% coverage against the inflated expectation.

After extraction, if FFmpeg delivered <=60% of the predicted frames,
correct `metadata.videoStreamDurationSeconds` to the actual video
extent. This reconciliation feeds through to the coverage check via
the same `metadata` reference, so `expectedFramesForVideo` sees the
true duration without any interface changes.

This is a corrective complement to the preventive nb_frames
cross-check in extractMediaMetadata — it catches cases where
nb_frames is unavailable (MKV, live streams, fragmented MP4).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-23 23:06:37 +00:00
xiayeandClaude Opus 4.6 ee4c00db6a fix(engine): cross-check nb_frames when video stream duration is absent
When ffprobe omits the video stream's own `duration` field,
`extractMediaMetadata` falls back to the container duration
(`format.duration`), which includes the longest stream in the
container. Stock and looping clips commonly have an audio track
2x the video length, inflating `videoStreamDurationSeconds` and
causing the downstream coverage gate to expect twice the frames
FFmpeg can actually produce — the "50% cluster" failure pattern.

Cross-check with `nb_frames / fps` when the stream duration is
absent. If the frame-derived duration is >10% shorter than the
container, use it instead. This corrects the inflation at probe
time without requiring a second ffprobe pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-23 15:37:35 -07:00