* feat(cli): render --prores-profile picks ProRes 4444, hq, 422, lt or proxy for MOV
* fix(cli): drop a comment line that raised the comment share
* fix(cli): the render plan args type carries --prores-profile
* fix(producer): stop HDR video windows after the first from repeating one frame
A raw HDR window seeks to a whole second, then trims its lead-in frames.
trim keeps their timestamps, and the raw muxer writes none, so when the
input has an audio track FFmpeg's default CFR sync pads from pts 0 with
copies of the window's first frame and -frames:v cuts the real ones off.
Phone footage always has audio, so HLG clips rendered at ~half their frames.
Pass frames through as the filter graph emits them.
Fixes#6556
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(producer): hold the first frame until a delayed HDR video starts
Window slots counted from the first decoded frame, so a video stream that
starts after its audio played early by its lead. Slots now count from the
extraction start and show the first frame until the stream begins, as they
did before windowed extraction (#6304) and as Chrome does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(producer): add an HDR fixture whose clips carry audio and span extraction windows
Neither HDR fixture had an audio track, which is how #6556 shipped. Window A
checks frames after the first extraction window are not repeats; window B
checks a video stream that starts 0.5 s after its audio holds its first frame
until the picture starts. The baseline was generated in Docker and the fixture
fails against 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>
* fix(producer): a taken file-server port fails the render, and the late grading test runs in its lane
* test(producer): run the taken-port file-server test under Node, where listen errors are events
* test(producer): every *.integration.test.ts must sit in the integration lane
* fix(producer): drop the explanatory comment on the file-server error handler
* fix(core): text a script splits into letters shows once and carries the variable's value
* fix(core): Studio's mounted and swapped scenes read bound text, and instance values load first
* test(core): the scoped variables table is emitted compact in head
* perf(producer): a parallel chunk checks BeginFrame on its first worker, with no separate browser
* style(producer): format
* fix(producer): a chunk whose first worker dies before its check settles instead of hanging
* test(producer): the stall tests jump fake time to the next timer instead of a second at a time
A 400 s scenario took 400 steps of 50 real event-loop turns plus file IO, which crept
past the unit timeout on a loaded runner. Idle windows now cost one step.
* test(producer): stub the memory sampler so the stall tests' timer jumps skip the idle windows
The sampler is a faked 250 ms interval, so it was the next timer on every jump and the
jump was never longer than 250 ms.
* test(producer): probe a variable-bound clip under a host with values
* fix(producer): probe a variable-bound clip its mount or defaults rebind
* style(producer): variable media fixtures in small steps
* test(producer): list the real-render variable mount test in the integration lane
* style(producer): keep probeStage's comment share at the base
* fix(engine): a stalled BeginFrame capture resumes from the first unwritten frame
A wedged worker used to throw away every frame already written and restart the
whole render on a screenshot session. Fresh BeginFrame workers now continue from
the first unwritten frame (once; a second stall still takes the screenshot
fallback), and the stall message names each worker's in-flight frame and step.
Force-closing the wedged Chrome no longer crashes the CLI with an unhandled
pipe ECONNRESET.
* chore(engine): cut the stall-resume comments to the comment limit
The explanations live in the PR body.
* feat(engine): encoders cap peak bitrate and place keyframes on a set interval
* style(engine): shorten the rate-cap comment
* style(engine): drop the doc lines the comment ratchet flagged
* feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path
* style(producer): shorten the delivery encoder file headers
* style(producer): drop a comment line from the segment manifest
* style(producer): shorten the integration test header
* test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane
* feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap
* fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder
* chore(studio-server): drop a comment the helper's name already says
* feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
* fix(engine): a scratch folder Windows refuses to delete no longer fails a finished audio mix
* fix(engine): audio scratch files Windows refuses to delete no longer fail a render
* fix(engine): a rename Windows keeps refusing falls back to a copy so the audio mix finishes
* fix(engine): audio scratch left behind is counted in the render log
* style(engine): wrap the long rename fallback test call
* refactor(engine): split the rename fallback decision out of the retry loop
* fix(engine): the rename copy fallback is opt-in and scratch leftovers are counted per render
* style(engine): keep renameWithRetry's comment share at the base
* style(engine): drop the blank line left by the comment cut
* fix(producer): compile probes each unique media source once however many elements use it
* test(producer): run the probe-count test in the integration lane
The segmented route had no drawElement recovery: a suspect small frame, a
missing paint record, a renderer stall or a failed self-verification failed
the whole render, where the streaming and disk paths re-render via
screenshot. Catch those errors around the segmented stage, replan with
forceScreenshot (segmentation kept), close the probe and retry once.
The retry discards the segment directory and manifest, including segments
resumed from an earlier run: the plan hash does not cover the capture mode,
drawElement and screenshot segments are not byte-identical, and segmented
drawElement segments are never self-verified.
A screenshot retry whose first segment encoder cannot spawn now keeps
forceScreenshot when it drops to plain streaming.
— Via
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(producer): open a fresh segmented session when the probe has risk frames
Since #6318 the gate-check probe is planned with deFrameFallback: true, so
it can carry deRiskFrames. The segmented stage reuses that probe for its
first session and captures every frame with captureFrameToBuffer, which
cannot hand a risk frame to a screenshot session: the first one throws
"frame needs screenshot capture, which this capture path cannot hand over"
and the segmented route has no drawElement catch, so the render fails.
Close such a probe and open a fresh segmented session instead; those are
created with deFrameFallback: false and keep the whole-render screenshot
fallback.
— Via
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* refactor(producer): name the risk-frame probe condition instead of commenting it
The comment ratchet does not allow a file's comment share to rise.
— Via
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(render): a PNG's colour tags no longer change its pixels in preview, check, snapshot or render
* fix(render): the play and present server also serves a PNG without its colour tags
* fix(render): bundled pages inline a PNG without its colour tags too
* fix(producer): a composition with no timeline-registering script no longer waits 45 s at setup
The render waited the full timeout for every composition that never registers a timeline, such as one driven by canvas or requestAnimationFrame.
* fix(producer): module and importing scripts still count as able to register a timeline
* fix(producer): a failed script keeps the readiness wait, and the page scan runs once
* fix(producer): convert HEIC images to JPEG at compile time
Tiled HEIC photos probe as several video streams, so the media-type
preflight rejected them as image:video, and Chrome cannot decode HEIC.
Sniff the ftyp brands of every <img>, join and rotate the tile grid with
ffmpeg, verify the declared size, and swap in a cached JPEG.
* fix(producer): shorten the HEIC prep comments
* fix(producer): name host tool failures and keep a failed HEIC preview image as authored
Also: HEIC tests run in the integration lane; the primary grid is picked by default disposition.
* chore(producer): raise the package size budget for the HEIC prep
* feat(cli): check catches audio the render would lose or play as silence
check gains an Audio section, read from the media list the render mixes:
- error: a clip's source has no audio the render can read (its mix fails,
the film ships without sound)
- warning: a clip starts past the end of its file, or the part it plays
is silent
- info: the film has no sound at all
The bundle and media list are now built once and shared with the HDR
inspection; the audio pass runs while the browser pass does.
Fixes#2775
* fix(cli): check reports an unreadable audio source the way render handles it
A source with no audio track is refused by render's own media-type rule, now exported from the producer and called by check; a source that does not decode as audio fails the mix and stops the render.
* perf(cli): check samples a long clip at a few spots before reading all of it for silence
Sound at any spot ends the decode; a clip is only called silent after its whole played part is read.
* chore(cli): drop a comment the shared finding helper's name already says
* fix(core): a composition behind a wrapper takes its host's start
Fixes#4334.
* fix(core): a composition inside a timed wrapper starts with the wrapper
The nearest host or timed wrapper above a mounted root sets its start, so its clock starts when the wrapper shows it, matching how CSS animations already pick their clip.
* test(producer): re-pin the test classification digest
* fix(producer): deduplicate scene font faces before embedding
* fix(producer): address font deduplication review
* docs(help): document render font-face deduplication
The troubleshooting guide this section originally targeted was folded
into docs/help.mdx on main, so the section now lives under the render
troubleshooting entries there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: James <james.russo@heygen.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(producer): a media src that resolves to the page itself is no source
A src of '#', '#t=1', '?' or '?x=1' reads back from the browser as the page URL
plus a suffix. decodedUrlPath strips the suffix, so the probe opened index.html as
a media file and failed the render with 'media sources are text documents'.
Discovery now drops any src whose URL without query and hash equals the page URL,
which also covers the blank and whitespace forms from the earlier fix.
* fix(producer): name the page-itself check instead of commenting it
* fix(producer): a render whose capture stops advancing fails within minutes
* fix(producer): keep the bounded probe read the stall change dropped
* chore(producer): shorten a test comment
* fix(producer): every retry batch gets its own first-frame window; the stall test waits for the render to settle
A retry that runs as several batches opened one stall window for the whole attempt, so a later batch whose browsers take longer than the no-progress window to land a frame was killed where main would have finished. The window now opens per batch. The stall tests also waited a fixed number of turns for real file IO after the last frame, which failed about 3 runs in 10 on a loaded machine; they now wait for the render to settle.
* fix(producer): a test reads the stall error fields instead of suppressions; size budget follows
* test(producer): split the scripted capture attempt into small helpers
* test(producer): the stall test advances fake time only while the scripted capture waits
* fix(producer): chunk capture round-robins only for callers that bill wall time
* fix(engine): worker, extraction and encoder sizing read the CPU quota, not the host core count
* Revert "fix(engine): worker, extraction and encoder sizing read the CPU quota, not the host core count"
This reverts commit cdd4d858c8.
* chore(producer): drop the doc comments the comment ratchet rejects
* perf(producer): chunks capture frames round-robin so the encoder runs during capture
The overlapped encoder takes frames in order, but workers captured contiguous ranges, so it sat idle until worker 0 finished and then encoded the other workers' frames after capture. With the encoder on, workers now take frames round-robin and it keeps pace.
* chore(producer): drop a doc comment to keep the comment ratchet flat
* fix(producer): interleaved capture retries a dead worker as one stride task and keeps static-frame reuse
A worker that dies during round-robin capture leaves every Nth frame missing; the missing-frame retry turned each into its own one-frame batch. It now retries one stride task per dead worker. Screenshot-mode static reuse also required the previous frame to be exactly one back, which never holds at stride above 1; it now reuses when every frame in the gap is static, like the worker path.
* fix(engine): ffmpeg steps and downloads stop on no progress, not while still advancing
* test(cli): an ffmpeg step that stops advancing reports inactivity, not a deadline
* fix(producer): reject regression audio stream mismatches
* chore: prepare conflict resolution against current main
* chore: preserve reviewed fix on current main
* chore: prepare conflict resolution against current main
* fix(producer): retain audio regression checks with current main
* chore: prepare conflict resolution against current main
* fix(producer): preserve audio presence checks on current main
A root composition with no data-duration took its own GSAP timeline's length as its visibility
window, so when media made the composition longer, the whole frame went black from the end of
that timeline in preview and render. The root's window is now open-ended, bounded by the
composition's length. In a framed preview, the duration the runtime stamps on GSAP targets for
Studio's timeline no longer hides the clips inside them either: the ancestor chain check now
skips discovery stamps, as the clip's own visibility pass already does.
* fix(core): a self-closed audio group no longer hides the clips after it
HTML does not close a custom element on `/>`, so `<hf-audio-group id="sfx" />` takes in every
later sibling, and the runtime's inert style (`display:none`) hid them in preview, snapshot and
render. The style is now `display:contents`: an empty group still makes no box, and anything
inside one renders as written. A lint warning names the elements a group holds, and the docs and
mixing reference show the group closed.
* style(core): cut the audio group comments to the comment length limits
* fix(core): a muted audio group still hides what sits inside it
A muted bus (`data-hidden`) is hidden by an inline display:none that the bus rule outranked, so
clips inside a muted self-closed bus kept painting while their audio and video frames dropped.
* perf(core): skip rendering preview scenes far from the playhead
* refactor(core): split the far-scene pass into small helpers
* perf(core): a first render reveals only the clips it reaches
* fix(core): reveal the clips holding first-rendered tweens, wherever their window is
* feat(core): a scene may load a project helper and wait for fonts and still swap
* perf(core): the preview mounts only the scenes near the playhead
* perf(core): a seek waits only for the scene it lands in; neighbours mount one per turn
* feat(studio): selecting a clip in an unmounted scene mounts that scene first
* fix(core): a source a play and a seek both need is fetched and decoded once
* fix(core): a scene mounting during play joins the mix without restarting what plays
* fix(studio): selecting a clip waits on a scene mount only in a preview that unmounts scenes
* fix(core): far scenes still mount after an edit, and an edit to a parked scene keeps its styles
* perf(core): web audio schedules a clip only as the playhead comes near it
* chore(studio): trim the scene hold helper's comments to its package's share
* fix(core): a scene the preview unmounts is freed, not kept by audio, caches or old roots
* fix(studio): an unmounted scene's rows and their state come from the markup it mounts with
* perf(core): the preview mounts scenes ahead of play and keeps recent ones mounted
* refactor(core): keep the far-scene neighbour rule private to its module
* style(core): format the far-scene neighbour rule
* perf(core): the preview unmounts scenes the playhead leaves, as kept ones slowed each mount
* fix(core): scenes that measure text, call their own animate or wait on font loads can unmount
* fix(core): each preview scene carries the helpers it loads, so it mounts on its own
* fix(core): a mounted scene shows though its stand-in was hidden by display
* fix(core): a helper scenes share runs once for the film, and a remounted scene keeps showing
* fix(core): a scene that also publishes its timeline under another id stays mounted
* feat(core): scenes that draw in 2D or chain the timeline they register can unmount
* perf(core): a play frame that draws nothing new skips the hidden-clip lookup
* fix(core): a GSAP file copied into a project counts as the library for scene unmounting
* style(core): drop an unneeded escape in the registered-chain pattern
* chore: core and producer size budgets make room for the preview scene window
* fix(core): a scene that draws on a 2D canvas stays mounted
* fix(core): a scene the preview unmounts lets go of timelines it built after a font wait
* perf(core): a scene leaving the playhead keeps its last rendered size, no forced layout at the cut
* refactor(core): the far-scene skip keeps last-render boxes in their own helper
* fix(core): a mounting scene never records the runtime's root timeline as its own
* chore(producer): raise the size budget to the measured size with the preview runtime inlined
* docs(core): keep the far-scene and scene-memory comments within the comment limits
* perf(core): style each hidden clip visible once ahead of its cut, so the cut frame does not
* fix(core): a seek into a scene still mounting as a neighbour waits for that mount
* chore(core): keep comment share and size budgets to main plus the measured core growth
* perf(engine): fast capture stays on for frames a shadow, blur or clip effect does not touch
A single drop-shadow, blur, backdrop-filter or blend element, or one clip-path or mask
tween, sent the whole render to screenshot capture, 2-3x slower than drawElement. The two
init gates now name the frames the effect can show on. Only those frames capture by
screenshot, in a second browser opened on the first of them; every other frame keeps
drawElement.
* fix(engine): scan effects in a throwaway session; hand risk frames over only where captured
The effect scan seeked the render's own page to the end, leaving its timeline state ahead of
the render. It now runs in a throwaway session with the render's own seek. A hidden effect
element with a visible descendant counts as shown. Per-frame capture is opt-in per caller
(deFrameFallback), so disk, segmented, chunk and parallel-worker sessions keep the
whole-render fallback, and a route that cannot hand frames over drops drawElement up front.
* fix(engine): a timeline callback keeps the whole-render fallback; trim comment share
A tl.call or onComplete can add an effect's class to any element mid-render, so the elements
found at the start are not the only ones it can reach. Such a timeline is unbounded and keeps
the whole-render screenshot fallback.
* style(engine): shorten the live-frame scan comment
* perf(engine): after a timeline callback, not before, every frame may show the effect
A callback can add an effect's class to any element, so only the frames from the first
callback on lose their element bounds. Frames before it keep the scanned bounds, so a late
callback no longer sends the whole render to screenshot capture.
* perf(engine): open the second screenshot browser only when memory is available for it
Per-frame capture needs a second browser (479 MB measured at 1080p). When the render would
leave less than 1.5 GB available after it, the render keeps the whole-render screenshot
fallback instead. getAvailableMemoryMb moves from the CLI to the engine's system-memory
module, which already owns the host's memory readings; os.freemem() reads 367 MB on this
49 GB Mac, so the guard uses free, inactive, speculative and purgeable pages.
* fix(engine): budget the whole 1 GB per-frame capture adds, not just the second browser
The render's process tree goes from 1002 to 2001 MB at 1080p: the screenshot browser (479 MB)
plus a drawElement browser that is itself about 500 MB larger than a screenshot one. The guard
budgeted 500 MB. It now budgets the 1 GB measured, scaled by output pixels.