* fix(engine): extraction waits out the decode up to a deep seek in a long-GOP video
* chore(engine): shorten the keyframe gap doc comment
* fix(engine): cancelling an extraction during the keyframe probe reports a cancel
* fix(engine): a segment stopped by another's failure reads as stopped, not cancelled
* 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
A first page of a fresh Chrome stalled past vitest's 10 s hook limit on a contended Windows runner and failed the first test of its file. The first page is now opened inside beforeAll, with the launch and first-page times logged, and the browser project's hooks get the 30 s the CLI's Chrome project uses.
Audio effects launched Chrome with its own two flags and no retry, so inside
an agent's macOS seatbelt Chrome died at startup with "Target closed" while
the CLI's own browser launch recovered. The sandbox launch policy now lives
in the engine; the engine's Chrome launch retries once in one process over a
pipe when the sandbox refuses it, and audio effects use the shared Chrome
flags in software GPU mode. The CLI imports the same policy from the engine.
* fix: phone videos with an undecodable first audio track check, preview and render
* fix(engine): a rate-lane clip bakes its ramp from the decodable audio track
* test: phone-clip tests end ffprobe options before the input
The start probe waited for the shell to exit after SIGTERM, so a shell that stalled under load blocked the whole process. Kill it outright at the timeout.
The GPU probe's answer is kept for the whole process and the first caller's probe is the
one every later caller waits on. When that first caller cancelled it (a prelaunched
browser dropped, a stopping check), the probe died with "This operation was aborted",
and that cancellation was kept as the answer: every browser the process launched after
it ran in software mode, with a log line blaming the GPU probe. A cancelled probe is now
forgotten and the next caller probes again.
* 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
* fix(cli): a Chrome that fails to start says how it ended, and a slow start says so
A failed launch only reported "Protocol error (Target.setDiscoverTargets): Target closed". It now
runs the browser once more on its own with the same flags and reports its exit code (Windows
codes named: missing runtime, out of memory, security software), its last stderr lines, or that
it stayed up. The CLI and the engine's render launch both use it. A start slower than 5 s prints
"Chrome is still starting...", and a --timeout that looks like seconds says it is milliseconds.
* chore(cli): cut the Chrome launch diagnosis comments to the comment limit
* fix(cli): the Chrome startup check runs Chrome as puppeteer does and can be cancelled
The check started the browser with only the caller's flags, so a full Chrome ran headful and
was blamed for a missing X server. It now uses puppeteer's default arguments (headless
included). The check is stopped when the CLI is stopped, and the GPU probe, whose failure
falls back to software anyway, skips it.
* fix(cli): a Chrome launch error is never replaced by a failure to explain it
The startup diagnosis asks puppeteer for its default arguments before re-running
Chrome. If that call fails, its error used to replace the launch error the caller
needed. The launch error is now reported as it was. The core package size budget
rises to cover the new chromeStartup module (about 4.7 KB unpacked).
* refactor(engine): the Chrome startup diagnosis lives in the engine, not core
Both callers launch Chrome through the engine (the CLI already imports it), so the
engine owns the diagnosis. Core's package size budget is back to its old value.
* fix(engine): renders on a Mac decode a clip in software when the media engine refuses it
* refactor(engine): name the software decode retry condition instead of commenting it
* 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(engine): a failed drawElement worker encode takes the fresh-session recovery
The encode result is awaited after its frame was produced.
Its failure reached the stage as a plain error, so the render ended.
It now surfaces as a drawElement capture failure, like a failed produce.
* fix(engine): drop the comments from the encode failure wrapper
* feat(core): one function gives the media time that shows the frame starting at an instant
* fix(core): paused preview and render show the same, correct video frame
* fix(core): register the media-clock subpath in its descriptor and keep the reason beside the test
* fix(core): media seeks lead by 10 us so Chrome's timestamp rounding never shows the prior frame
* fix(core): generated pages lead media seeks by a literal, on every generator branch
* 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(snapshot): video frames keep the colours render gives the same footage
* fix(snapshot): say so once when video colours cannot be matched to render
* test(snapshot): cut the HEVC fixture's encode time so the flake gate stays inside its timeout
* style(snapshot): format the HEVC fixture line
* 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
* fix(engine): name where a root composition script threw and the element it looked up
* style(engine): format throw-site helper
* fix(engine): shorten the throw-site comment
* fix(engine): name a lookup only when the throwing line holds exactly one
The beginFrame probe now tells a timeout from a real rejection.
A timeout relaunches Chrome and probes once more with a longer budget.
An unsupported answer still falls back at once.
* 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
Frame extraction decoded every source in software, so a long 4K60 HEVC clip
spent much of its CPU on decoding. On macOS, extraction now asks ffmpeg for
VideoToolbox when this ffmpeg has it and the source cannot carry alpha; ffmpeg
falls back to software for a stream VideoToolbox cannot decode, and the frames
written are byte-identical.
* 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(core): a scene callback that throws no longer stops the main timeline in render
Render seeks now leave each scene paused in the root's pass and seek it on its own clock first,
as preview does, so a throwing onUpdate cannot cut the root's tweens short. The error is logged
once per scene as a composition script error, and the render log shows it once per render.
* fix(core): a scene registered without a timeline is not reported as a script error
A placeholder registration ({}) has nothing to seek. seekGsapTimeline now only calls seek()
when the object has one, as player.ts already does, so the new scene-error report stays for
real callback errors.
* fix(core): report a scene callback error as a warning, keeping render failure classes
Logged as a composition script error, a scene callback that threw became a page error, which
turns a timed-out timeline wait into a script failure and cuts the wait short. It is now a
runtime warning the engine forwards to the render log once per render, outside page errors.
* chore(core): regenerate the position-edits render bundle for the new diagnostics export
* fix(engine): the drawElement self-check reads video comps' truth on a separate page too
The video-frame injector now hands out a fresh hook with state of its own, so the check can paint the frames on its disposable page without the render page's injector losing track of what it has painted.
* fix(engine): the truth page gets the media render-id bridge the render page has
Videos that share a plain id (a bare video in each of two scenes) are told apart by a render id the bridge resolves; without it the truth page painted the wrong element.
* 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.
* fix(engine): the drawElement self-check no longer rejects count-up text it should accept
The check's ground-truth screenshot was taken after a seek that suppressed timeline callbacks, so text a callback writes (a count-up) was stale in the truth while the real render, which runs callbacks, showed it correctly. The check failed and the whole render restarted on the slower screenshot path.
* docs(engine): shorten a comment to the line limit
* fix(engine): the drawElement self-check reads its truth on a page the render does not use
Callbacks now run in the truth seek (a count-up or class swap no longer looks stale), and they run on a disposable page so a callback that changes the page cumulatively cannot change what the render draws. Compositions with videos keep the in-page path, because the video injector is tied to the render page.
* docs(engine): cut a comment to three lines
* fix(engine): a truth page that cannot open falls back to the render page
On macOS the extractor checks once per process whether ffmpeg has zscale. The test ran that
check through its counting shim, so it saw 11 ffmpeg runs instead of 8 cuts plus 2 probes and
failed on every Mac with zscale. Take the check with the real ffmpeg before the shim counts.