Commit Graph
4484 Commits
Author SHA1 Message Date
Vance IngallsandClaude Fable 5.1 ee3d6418f7 docs(long-form): record the adversarial review outcome and the retry target per routing
- HANDOFF section 4a: the single-worker fallback applies on default routing
  (non-DE router and the manual opt-in); a DE-router plan keeps its
  precomputed fallback. Adds the review's verdicts: three confirmed defects
  fixed, one latent hardened, one split accepted, one low left open (a worker
  wedged before its first capture is bounded only by player-ready and CDP
  timeouts and is not retry-eligible).
- cli.mdx: the parallel-stream paragraph no longer promises a single-worker
  retry for every case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:13 -07:00
Vance IngallsandClaude Fable 5.1 bcb2544e7a fix(producer): record the manual interleave opt-in on the plan so its retry drops to one worker
The streaming stage interleaves on forceParallelStream OR
HF_DE_PARALLEL_STREAM=true, but the plan only carried the two routers'
flags. A manual opt-in below the DE router's frame floor therefore retried
at N workers with forceParallelStream false, which the env var re-interleaved
on the second attempt, contradicting the single-worker guarantee the retry
comment and log line made. isParallelStreamForced folds the env var into the
plan flag at creation, so the plan, the retry target and the
deParallelStreamForced telemetry agree with the distribution the stage
picks. Found by the adversarial review of this branch.

Also from that review: the parallel-stall log and checkpoint no longer claim
a "single-worker" retry, which router-pinned plans do not make (they take
their precomputed routing fallback; the post-replan log carries the real
count); the fallbackReason doc comments list encoder_death and
parallel_stall; the peer-death test names the engine test that pins the
hook contract it replays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:12 -07:00
Vance IngallsandClaude Fable 5.1 439bdbd774 fix(engine): pin the pool's worker-failure contract and survive a throwing hook
The streaming stage's peer-death test replays the contract "hook first, with
the original CaptureFailure, then the peers abort and the pool rejects with
its own summary" inside its mock, and nothing on the engine side pinned it.
The gate now lives in createPoolFailureHandler, tested without a browser:
the hook sees the original failure before the peer signal flips, a hook
that throws can no longer skip the abort (the pool still reports the
classified failure, not the hook's error), failures after the first are
ignored, and a cancellation leaves the peers running. Mutation-checked:
removing the try/finally, swapping the order, or dropping the first-failure
guard each fails one test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:12 -07:00
Vance IngallsandClaude Fable 5.1 77dd1da8da docs(long-form): gate the R3 route on the PRINFRA-694 fixture, record the hardening
- tests/long-form/README: the two-clip in-flow fixture, the per-frame luma
  check over clip B, the 2026-09-18 matrix (branch -w 1 / -w 2 disk / -w 2
  streaming / absolute CSS / published 0.8.44 all 0 of 100 black; 0.8.35 and
  0.8.36 99 of 100 as the positive control; fixed in 0.8.37 by #3893), and
  why the multi-worker trace's captureMode was not the engine's mode.
- HANDOFF: R3 row now reads "hardening built"; new section 4a lists the
  guards, the 694 gate result and what the flip still needs; counts refreshed.
- cli.mdx: the parallel-stream paragraph says the opt-in path now retries a
  stalled pool or a mid-stream encoder exit on a fresh single-worker session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:12 -07:00
Vance IngallsandClaude Fable 5.1 0d4560748b fix(producer): label a non-Linux multi-worker trace as screenshot, not beginframe
The capture-stage trace checkpoints read captureMode from the probe session
and, when none is open, assumed "beginframe". The multi-worker path closes
its probe before capture starts, so every macOS and Windows multi-worker
render logged captureMode:"beginframe" while each worker's own
[initSession:screenshot] prefix said otherwise. Found while gating the
PRINFRA-694 fixture on the R3 route. The fallback now goes through the
platform rule (resolveObservedCaptureMode) via a pure helper with tests;
the telemetry field was already derived correctly and is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:11 -07:00
Vance IngallsandClaude Fable 5.1 b05072342a fix(producer): retry parallel stalls and mid-stream encoder death on default routing
shouldRetryViaPinnedFallback is the routing-independent retry allowlist;
until now a parallel-capture stall and an encoder dying mid-stream both
fell through it and hard-failed the render on default routing. Two
predicates join the allowlist:

- isParallelCaptureStallError matches the stage's typed stall by name or by
  its "[Render] Parallel <label> capture stalled" message.
- isRetryableEncoderDeath matches "Streaming encoder exited before frame N
  was written" for N >= 2, so a session that streamed at least one frame is
  retried on a fresh encoder, while a death before any frame (an ffmpeg
  argument or codec problem that reproduces) and a host-interrupted encoder
  (EncoderInterruptedError, which has its own path) are not.

The fallback reason, log line and checkpoint gain matching parallel_stall
and encoder_death branches, and the stage receives the capture label
(screenshot / drawElement / BeginFrame) so the stall names the engine.

Mutation-checked: removing the allowlist line, the frame threshold, or the
EncoderInterruptedError exclusion each fails one of the new tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:11 -07:00
Vance IngallsandClaude Fable 5.1 9d72e7fa23 fix(producer): retry an interleaved parallel-stream plan at one worker, never N contiguous
replanAfterFailure's default-routing fallback kept plan.workerCount for
every non-memory failure. For the parallel-stream router's plan that meant
retrying at N workers with forceParallelStream off, which is contiguous-chunk
streaming: worker k+1's first frame waits for all of worker k's, so
throughput serialises to roughly one worker while N Chrome processes run
and the no-progress watchdog can re-trip on the same render. The retry now
targets a single worker for interleaved plans, the fully hardened path
(typed stall, init-excluded clock, routing-independent retry).

Mutation-checked: dropping the forceParallelStream term fails the new test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:11 -07:00
Vance IngallsandClaude Fable 5.1 9acf96d036 fix(producer): type the parallel stall, arm its watchdog after init, propagate worker death
Three of the 5.5 % screenshot-cohort failure modes lived in the parallel
branch of the streaming stage:

- The no-progress watchdog started its clock when the pool was created, so
  a fleet whose browsers took longer than HF_DE_STALL_MS to launch was
  reported as stalled before any worker had a chance to capture. The timer
  now arms on the first frame_capture phase (or first captured frame).
- The stall was a plain Error whose message said "drawElement" regardless
  of the capture engine in use. It is now ParallelCaptureStallError with a
  variant (drain | pool) and the caller-supplied capture label, so the
  orchestrator can route it by name and the log names the engine that
  stalled.
- A worker dying mid-render left the reorder buffer parked until the
  watchdog fired, which relabelled the real cause as a stall. The stage now
  passes an onWorkerFailure hook to executeParallelCapture and aborts the
  writer with the worker's own failure the moment the pool declares it
  fatal.

Each guard has a test that fails when it is removed (init-hang, hard-coded
label, no-op hook), checked by mutation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:10 -07:00
Vance IngallsandClaude Fable 5.1 5273554516 fix(engine): a worker death is pool-fatal on the streaming path
On the disk path a transient worker death is deliberately non-fatal: the
orchestrator's adaptive retry re-captures that worker's missing frames. The
streaming path has no per-worker retry and the dead worker's frames are
gone, yet it inherited the same gating — so its peers parked in the ordered
writer waiting for a frame that would never come, and sixty seconds later
the no-progress watchdog relabelled the death as a stall. That is the
`stuck at k/N` field signature.

isPoolFatalWorkerFailure splits the two: every non-cancelled failure ends a
streaming pool, and a new onWorkerFailure hook delivers the ORIGINAL failure
to the caller before peers are aborted, so the ordered writer can be released
with it rather than with a stall. Mutation-checked in both directions,
including that `cancelled` never propagates (the pool is already aborting).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:10 -07:00
Vance IngallsandClaude Fable 5.1 95cbf56634 fix(engine): classify a broken encoder pipe as io, not authoring
EPIPE (darwin/linux), EOF (win32's UV_EOF for a closed named pipe) and
ECONNRESET all mean the process reading our stdin has gone. Bucketed as
authoring, telemetry blamed the composition for a host event. Matched on
the errno code and on the message, since the code is lost when a worker
pool flattens the error to a string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:09 -07:00
Vance IngallsandClaude Fable 5.1 69024e17d1 fix(engine): keep ffmpeg's exit reason when it dies under a parked write
The field "write EPIPE" / "write EOF" render errors are this path:
writeFrame waits on once(stdin, "drain") for back-pressure to clear, ffmpeg
dies first, and Node rejects the drain wait with the stream's own error.
That rejection escaped writeFrame as the render error, so ffmpeg's exit code
and stderr — captured, but read only from the `false`-return path — were
discarded. With N workers feeding one encoder the writer is parked most of
the time, which is why the parallel cohort reads EPIPE where a single-worker
render reads "Streaming encoder exited before frame N: FFmpeg exited with
code …".

Wait for the exit to settle, then report it as the close race does. Both
halves are mutation-checked: rethrowing again fails the test, and returning
"exit" without awaiting settlement fails the synchronous getExitStatus read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:09 -07:00
Vance IngallsandClaude Fable 5.1 80d12f8c9e refactor(producer): drop the unused worker count from the segmented router predicate
Review notes on the Phase 2d PR: shouldSegmentCapture took workerCount and
never read it (any count segments), so the parameter documented an intent
the body did not have. Removed from the signature, call site and tests, and
the doc comment now says worker count is not a gate. The stage input's
sessionFactory doc says it is shared across workers when no per-worker
factory is given and must return a distinct session per create().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:08 -07:00
Vance IngallsandClaude Opus 5 c4ccd9d6a2 docs(cli): document segmented capture and record the Phase 2d gate
Three workers produced a byte-identical file to the single-worker segmented
render at 1.6x the speed, which is the property the gate exists to check:
segments finish out of order, so a mismatch would mean the concat list had
picked up completion order instead of the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:08 -07:00
Vance IngallsandClaude Opus 5 f78c3cb8d4 feat(producer): one router predicate for segmented capture, multi-worker allowed
shouldSegmentCapture replaces the 2a single-worker gate: the kill switch and
the forced opt-in keep working, and a duration threshold decides the rest.

The threshold ships disabled (the default is Infinity) rather than at the
spec's 600 s. Flipping the route for every long render in the same release
that introduces the route is exactly what the soak gate exists to prevent;
SEGMENTED_MIN_SECONDS_AFTER_SOAK records the intended value, and a fleet can
opt in today with HF_SEGMENTED_MIN_SECONDS=600.

createCapturePlan no longer requires useStreamingEncode for a segmented
plan. That flag answers "one encoder for the whole render", which goes false
for multi-worker — requiring it would have silently dropped every
multi-worker segmented render onto the disk path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:07 -07:00
Vance IngallsandClaude Opus 5 4e4cfe7ee5 feat(producer): capture segments with N parallel workers off a shared queue
Each worker owns a session (recycled and retried per 2c) and pulls whole
segments in index order. Segments finish out of order, so the concat list is
built from the plan rather than from completion order — ordering never
reaches the encoders.

Three guards, each mutation-checked: every worker settles before any session
is closed (closing one out from under a live worker orphans its ffmpeg and
races the CDP connection); a multi-worker render never takes the
single-encoder fallback, because other workers are already encoding work it
would discard; and the queue is shared, so no frame is captured twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:07 -07:00
Vance IngallsandClaude Fable 5.1 763ea8f97f fix(producer): call closeCaptureSession through its live binding in segmented defaults
The default deps object captured the engine's closeCaptureSession at module
load, so a test that swaps the engine module after this file is evaluated
never saw the close. The Phase 2a test that checks the probe is closed after
a first-encoder spawn failure failed from this layer upward for that reason.
Calling through the import binding restores the check; production behaviour
is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:06 -07:00
Vance IngallsandClaude Opus 5 79a4b02d78 test(producer): record the Phase 2c gate result
Recycling the browser on every segment produced a byte-identical file to
the single-session render, which is what makes the target-loss retry safe:
a segment re-captured on a fresh session is indistinguishable from one that
never failed. Five launches cost ~5 s, and each recycle reported a renderer
RSS peak of ~1.4 GB being returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:06 -07:00
Vance IngallsandClaude Opus 5 c87b4d1b47 feat(producer): wire the segment browser-recycle cadence and retry telemetry
HF_SEGMENT_BROWSER_RECYCLE defaults to 3 segments, which keeps a session
under the 10k-frame band where field crash rates pass 4%. segmentRetries
lands on live capture observability, so a render that retried still reports
it if it later dies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:06 -07:00
Vance IngallsandClaude Opus 5 7d214e96ec feat(producer): recycle the browser between segments and retry target loss once
A segment boundary is the only safe point to hand capture a fresh browser,
which is what makes the long-render failure class survivable: the session
restarts every N segments, and a Chrome target loss mid-segment re-captures
that segment once on a new session instead of failing the render.

Retry is deliberately narrow. Encoder and authoring errors reproduce on a
fresh browser, so retrying them doubles the time to the same failure — all
three guards (retry exists, retry is classified, cadence boundary) are
mutation-checked. The partial segment file is deleted before the retry so a
later resume cannot see it, and onSegmentComplete still fires only after a
successful close, so the manifest never records a partial segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:05 -07:00
Vance IngallsandClaude Opus 5 61223223d4 feat(producer): target-loss classifier and browser recycle cadence
Only the Chrome target-loss class is worth retrying a segment for: encoder
and authoring failures reproduce on a fresh browser, so a retry just doubles
the time to the same failure. Both mutation-checked, including that a
non-Error is never classified as retryable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:05 -07:00
Vance IngallsandClaude Fable 5.1 e7c4eb52b9 fix(producer,cli): hash every byte-affecting segment input; pin the resume-flag plumbing
Review findings on the Phase 2b PR. Three inputs that change segment bytes
were missing from computeSegmentPlanHash, so --resume could splice
mismatched segments: the hardware encoder (h264_nvenc and libx264 both
report codec "h264"), the device-scaled capture size (the hash used the
composition's CSS size), and the motion-blur options (sample count changes
every averaged frame). All three are hashed now, each with a test that
fails if it is dropped. The hash and the encoder read the image format
from one binding.

The plan -> options -> request -> config chain that once dropped --resume
and --keep-segments had no test. renderOptionsFromPlan is now a pure,
exported hop with a test, renderConfigFromRequest has one for the same
flags, and the batch path, which was still dropping both flags, carries
them too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:04 -07:00
Vance IngallsandClaude Opus 5 ae6aedf1ca fix(cli): pass the segmented resume flags from the plan into render options
execute.ts is where the plan becomes RenderOptions, and it dropped both new
flags, so --keep-segments deleted the segment directory anyway and --resume
never resumed. Nothing failed: the render just silently re-captured
everything. Caught by the Phase 2b gate, which is the only check that spans
plan -> options -> request -> config; the gate doc now says so.

Gate result recorded: killed at 2 of 6 segments, resume skipped both,
finished in 3m32 against 5m13, and the output was byte-identical to the
uninterrupted render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:04 -07:00
Vance IngallsandClaude Opus 5 a560f498cd feat(render): --resume and --keep-segments for segmented capture
Segments and their manifest live under renders/.hf-segments/<planHash>. A
resumed run re-validates every recorded segment before skipping it; a run
without --resume deletes the directory first, because a previous run's
segments must never be spliced into this output. The directory survives a
failure on purpose — that is the resume point — and is removed after a
successful render unless --keep-segments.

The frame-count check derives from probed duration x fps: the engine has no
exact-count reader and -count_frames decodes the whole file, while the only
thing this must catch is a segment missing frames, which shortens duration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:04 -07:00
Vance IngallsandClaude Opus 5 8c14169efd feat(producer): segmented stage skips validated segments and reports completions
segmentOutputPath now takes the segment directory, so segments can live in
a stable resume directory instead of the disposable work dir. Skipped
segments are still concatenated in order, and the fallback-on-spawn-failure
rule moves from "segment 0" to "the first segment this run still has to
capture" — on a resume that is not index 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:03 -07:00
Vance IngallsandClaude Opus 5 5daaae8498 feat(producer): segment manifest with content-addressed plan hash
Resume reuses encoded bytes, so a wrong reuse corrupts the output silently
instead of failing. Three guards, each mutation-checked: the plan hash
covers every input that determines segment bytes and is separator-joined so
adjacent fields cannot alias; a reused segment must still exist at its
recorded size; and its probed frame count must equal the slice length.

Type guards use `in` + Reflect.get rather than the plan's
`as Record<string, unknown>`, which the repo's no-cast rule forbids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:03 -07:00
Vance IngallsandClaude Fable 5.1 cb28942110 fix(producer): drop the closed probe reference when segmented capture falls back to streaming
Review finding on the Phase 2a PR: when the first segment's encoder failed
to spawn, the stage's finally had already closed the session it opened,
including a reused probe, but the orchestrator kept its probeSession
reference and handed the closed session to the streaming branch, which
would fail a stage later with a confusing "page closed". The fallback
branch now nulls the reference; the stage test pins that the probe is
closed on that path. Also covers the negative HF_SEGMENT_FRAMES clamp the
review noted was untested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:02 -07:00
Vance IngallsandClaude Opus 5 7000e32942 test(producer): record the Phase 2a gate result and its real pass criteria
The spec's "PSNR >= 45 dB vs the single-encoder output" reads as a failure
on a healthy render: the measured average is 44.4 dB because segmented
capture forces a closed GOP with scene-cut detection off, and two CRF
encodes of identical frames differ regardless. The boundary frames are the
HIGHEST-PSNR frames in the series (48-62 dB forced IDRs), which is what the
gate should actually assert.

Also records the cost this buys nothing back on: the segmented stage runs
the plain capture loop rather than the depth-2 worker-encode pipeline, so
the same fixture takes 5m16 against streaming's 2m43.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:02 -07:00
Vance IngallsandClaude Opus 5 4b54b5d26f feat(producer): route long renders to segmented capture behind HF_SEGMENTED_CAPTURE
Opt-in, single-worker, mp4/mov only, and not for HDR or shader-transition
routes — those run their own compositor and webm's VP9 concat-copy is
fragile across ffmpeg versions.

A first-segment encoder spawn failure replans onto the plain streaming
path and falls through to the branch below, so the opt-in cannot make a
render fail where the default would have succeeded. A failure after that
propagates: per-segment retry is Phase 2c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:01 -07:00
Vance IngallsandClaude Opus 5 8391abd672 feat(producer): sequential segmented capture stage
One closed-GOP encoder per segment writing segments/segment_NNNNN.mp4,
then a concat-copy into the video-only file the assemble stage muxes. The
GOP is each segment's own frame count, so every concat boundary is an IDR;
the tail segment is shorter and says so.

A spawn failure on the first segment returns success:false so the caller
can still replan onto the single-encoder streaming path. A later one
throws: earlier segments are already encoded, and silently discarding them
would drop frames from the output.

Reuses the sequential stall watchdog from the streaming stage rather than
running unguarded; both helpers are exported for that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:01 -07:00
Vance IngallsandClaude Opus 5 d7398517bd feat(producer): sdr_segmented capture plan kind
Inert until the orchestrator passes useSegmentedCapture. replanAfterFailure
handles the kind for every failure, including capture_failure, which would
otherwise reach the "cannot apply" throw guarding the non-streaming kinds:
a drawElement failure keeps segmenting on the screenshot engine, anything
else drops to a plain streaming render.

CapturePlanTarget deliberately does not gain the kind — routing fallbacks
are streaming/disk only, and adding it there would make
`useStreamingEncode: fallback.kind === "sdr_streaming"` silently false for
a segmented target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:00 -07:00
Vance IngallsandClaude Opus 5 46d852cc93 refactor(engine): extract concatVideoFiles from the chunked encoder
Segmented capture needs the same concat-demuxer stream copy the chunked
encoder already does. Behaviour preserved, including the
external_interruption classification the caller maps to a retryable
failure: the helper reports it rather than swallowing it. The concat list
keeps living beside its inputs, because concurrent encodes share an output
directory but not a chunk directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:53:00 -07:00
Vance IngallsandClaude Opus 5 9c3cff4960 feat(producer): segment planner for long-form capture
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:59 -07:00
Vance IngallsandClaude Fable 5.1 5f373894d0 fix(producer): disk preflight names only causes that reach it; refresh the router field's docstring
Review follow-ups on the Phase 0 and Phase 1a PRs:

- The disk-headroom message blamed HDR and shader-transition output for a
  throw those renders can never reach (they run hdr_layered, a separate
  loop). It now lists png-sequence/gif, multi-worker screenshot capture,
  and the three ways streaming is switched off, including the duration cap
  and its threshold.
- RenderCaptureObservability.captureParallelStream still described the
  pre-split semantics ("the kill switch was on"). It now says BeginFrame
  routes by default, screenshot only with the env, that eligible_off sizes
  the held-back screenshot cohort, and that the label can disagree with the
  platform-only captureMode on Linux with system Chrome or DPR > 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:59 -07:00
Vance IngallsandClaude Opus 5 bc2a8b5e0d docs(cli): document the parallel-stream router and its capture-mode split
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:58 -07:00
Vance IngallsandClaude Opus 5 1ae359ca00 feat(producer): stream multi-worker BeginFrame renders by default
Splits the non-DE parallel-stream router default by the capture mode the
render will actually use. BeginFrame (Linux headless-shell) defaults on:
its opt-in cohort fails at 0.05% over 11.5k renders. Screenshot capture
(macOS/Windows) stays behind HF_CAPTURE_PARALLEL_STREAM=true, because its
opt-in cohort fails at 5.5% against a ~1.1% baseline in two classes this
path owns — the parallel stall watchdog hard-fails with no retry, and a
dying encoder surfaces as a bare write EPIPE. Phase 1b flips it once those
are fixed.

resolveParallelCaptureMode mirrors the engine's preMode rather than reusing
resolveObservedCaptureMode, which is platform-only and would have
default-enabled Linux-with-system-Chrome and Linux-at-DPR>1, whose real
capture path is the screenshot one. The routed telemetry value now comes
from the same helper, so the cohort cannot disagree with the predicate that
routed it, and eligible-but-held-back renders keep emitting eligible_off.

The preflight message named --workers 1 as the fix for every disk-path
render; with the duration cap enabled a --workers 1 render lands there too.
It now names each override that can cause it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:58 -07:00
Vance IngallsandClaude Opus 5 ddbc9a8a98 feat(cli): emit Chrome memory, capture path and segment fields on render events
Both events carry the live observability values, so a render_error that
never built a perf summary still reports the last known Chrome memory.
render_complete prefers the aggregate from RenderPerfSummary.chromeMemory,
which covers every worker rather than whichever session sampled last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:25 -07:00
Vance IngallsandClaude Opus 5 2cf8024a7c fix(engine): read Chrome process info from the browser target, not the page
SystemInfo.getProcessInfo is served only by the browser target; a
page-target session rejects with "is only supported on the browser
target". The sampler swallows probe failures by design, so this shipped as
a permanently empty reading: a 10 s debug render reported chromeMemory
null with capturePath already correct. Against the browser target the same
render reports browser 105 MB / renderer 359 MB over 3 samples.

readChromePids is extracted so the target choice is pinned by a test whose
fake rejects a page session the way Chrome does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:25 -07:00
Vance IngallsandClaude Opus 5 119dafc005 feat(producer): carry Chrome memory and capture path on observability and perf summary
Live route through updateCaptureObservability survives a crash, which is
the case this telemetry exists for: the failure path never builds a
RenderPerfSummary. The aggregate route through CapturePerfSummary (max of
per-session peaks, sum of samples) feeds render_complete.

capturePath is recorded inside syncCapturePlan rather than beside the
streaming-encode gate log, so a render that replans onto disk reports the
path it actually captured on; capturePathForPlanKind is exhaustive over
CapturePlan["kind"] so a new plan kind cannot silently inherit the wrong
label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:25 -07:00
Vance IngallsandClaude Opus 5 428922162d feat(engine): sample Chrome process memory per capture session
Start a 2 s unref'd sampler when a session finishes initializing, stop it
in closeCaptureSession, and report peaks on CapturePerfSummary. Pids come
from puppeteer (browser) and CDP SystemInfo.getProcessInfo (renderer, GPU),
so the long-render "Target closed" class can be correlated with Chrome
memory instead of guessed at. Disable with HF_CHROME_MEMORY_SAMPLER=false;
cadence HF_CHROME_MEMORY_SAMPLE_MS.

The start hook is finalizeSessionInit rather than the tail of
initializeSession: that helper is already the single owner of "session is
ready" across both of initializeSession's exits, so the early screenshot
return cannot silently skip sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:52:03 -07:00
Vance IngallsandClaude Fable 5.1 a35168eddd fix(engine): sampleOnce() joins an in-flight sample instead of skipping it
With a boolean reentrancy flag, the Task 3 close sequence
(`stop(); await sampleOnce()`) returned immediately whenever an interval
tick was still awaiting CDP/ps, so getCapturePerfSummary read stats from up
to one interval earlier and onSample fired once more after the session had
closed. Reproduced with a 350 ms getPids on a 100 ms interval: close
resolved with samples=0 and the callback landed afterwards.

The guard now keeps the in-flight promise and returns it to any caller, so
an interval tick still never stacks a second round-trip (the existing
reentrancy test holds) while a close-time call resolves only after the
running tick has merged. Deviates from the plan's reference body, which
used `let inFlight = false`; the ChromeMemorySampler interface and all
names are unchanged, only the sampleOnce() contract is stronger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 7cf57fa42e test(engine): pin the unref'd sampler interval via a cast-free timer seam
Three independent reviewers found that deleting timer.unref() in
createChromeMemorySampler left the suite green: the plan's Global Constraint
(fire-and-forget on an unref'd interval) had no failing test. The new test
captures the real NodeJS.Timeout through the setIntervalFn seam and asserts
hasRef() is false after start(); it goes red when the unref() call is
removed.

The seam could not be used without a cast as typed in the plan
(`typeof setInterval`): with the DOM lib in scope that type also carries the
`(handler, timeout) => number` overload, which a capturing wrapper cannot
satisfy. setIntervalFn / clearIntervalFn keep their names but are now typed
to the two-argument shape the sampler actually calls; the global timers are
assignable to those shapes, so the defaults are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 53403a5896 feat(engine): chrome memory sampler with live onMemorySample capture option
Pure service for Phase -1 of the long-form render plan: merges per-pid RSS
rows into browser/renderer peaks, last-sample total and GPU presence on an
unref'd interval with a reentrancy guard, so a CDP or ps failure can never
reach the capture loop. CaptureOptions.onMemorySample is the live route the
producer will wire so a mid-render crash still reports memory state. Nothing
instantiates the sampler yet (Task 3); no behaviour change.

Beyond the plan's tests, pins the double-start, empty-sample and catch
guards directly, since the interval-based error test stays green when the
catch is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00
Vance IngallsandClaude Fable 5.1 1862c34399 test(engine): fix readonly array type in the pid-filter test
`readonly string[][]` parses as `ReadonlyArray<string[]>`, so `calls.push`
was a TS2339 in 5214552cd. It slipped through because the engine tsconfig
excludes `src/**/*.test.ts` and vitest strips types; caught by running tsc
on the test file explicitly. `Array<readonly string[]>` is what the
neighbouring "one ps call" test already uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 3cee3b94f7 fix(engine): read /proc/<pid>/status for RSS on linux instead of shelling out to ps
Every render image (packages/cli/src/docker/Dockerfile.render,
packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example,
Dockerfile.test) is node:22-bookworm-slim with no procps, so `ps` is ENOENT
in containers and sampleProcessRss would return [] on every tick: the whole
cloud fleet would report chrome_memory_samples: 0, indistinguishable from
"no Chrome pids". Linux now reads VmRSS from /proc/<pid>/status with a
per-pid try/catch, so one renderer that exited between discovery and
sampling omits only itself (with `ps` a wholly-dead batch fails the call).
No child process per tick on the primary server platform. Same precedent as
the CLI's orphanCleanup.ts / processTree.ts. `ps` stays for darwin and
other POSIX; `tasklist` for win32. The reader is an optional fourth
parameter with a default, so Task 2/3 callers are unaffected.

Scope note: spec section 2.5 records the Target-closed population as
docker=false throughout, so this closes a render_complete completeness gap
on cloud, not a coverage gap on the crash class this phase targets.

Verification: 15/15 vitest; four linux-branch mutants (per-pid catch
rethrows, regex loses /m, kb <= 0 weakened, path /status -> /stat) each
fail at least one test, restored green. Real-process smoke check on darwin:
sampleProcessRss([process.pid, <exited pid>]) returns one sample for the
live pid (BSD ps exits 0 with partial output when a valid-range pid is
gone). The linux path is unit-tested against a /proc/<pid>/status fixture
only; no Linux host or VM was available in this session to execute it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 645cbdce4e test(engine): pin the pid filter, dedup and tasklist memory guard in sampleProcessRss
Review of 76d8e2682 found three surviving mutants: removing the
`Number.isInteger(p) && p > 0` filter, dropping the `new Set` dedup, and
weakening `kb <= 0` to `kb < 0` all passed the plan's 8 tests. The filter is
the guard that matters most: Task 2 feeds `browser.process()?.pid`, and a
single NaN or undefined reaching `ps -p` makes ps exit non-zero, which the
never-rejects catch turns into `[]` for every pid in the sample.

Mutation check: each of the three mutants now fails exactly one test
("drops invalid pids and duplicates before calling ps" for the filter and
dedup, "returns [] when the memory column is not a positive number" for the
tasklist guard); restored source is 10/10 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:01 -07:00
Vance IngallsandClaude Fable 5.1 bc70156f00 feat(engine): zero-dependency per-pid RSS sampler (ps / tasklist)
First piece of the long-form render Phase -1 telemetry: the Chrome memory
sampler needs resident set size per pid without adding pidusage or
systeminformation. `ps -o pid=,rss=` covers POSIX in one call for every pid;
`tasklist /FO CSV` covers win32 one pid at a time. The function never rejects
and omits pids that exited between discovery and sampling, so a failed sample
can never reach the capture loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:00 -07:00
Vance Ingalls 2b2b999e62 Merge pull request #4109 from heygen-com/lfr/02-phase0-duration-cap
feat(producer): stream long single-worker renders by default; duration cap behind a flag (Phase 0)
2026-09-18 20:50:36 -07:00
Miguel Ángel a25da94b77 refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch

* refactor(studio): fold the thumbnail output branches left after the storyboard mode went

* docs(studio): drop leftover storyboard references after the view was removed

* ci: allow the storyboard view deletions in the no-deletions guard

* docs(studio): wrap an over-long comment line in the caption overlay helper

* style(studio): reindent App.tsx after the view-mode wrapper was removed
2026-09-18 23:47:12 -04:00
Miguel Ángel 2db126d4c8 docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)
* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill

Agents got contradictory advice on volume fades and were blocked from splits
and copies by the Ask-agent guardrails.

- creator-editing-recipes.md is the single owner: adds align-to-event,
  copy-a-group and swap-media recipes and the one volume rule (use the lane)
- variables-and-media, data-attributes (skill and CLI copy), the gsap adapter
  and tracks-and-clips now defer to it
- Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap
  requires and point at the owner doc
- new hyperframes-studio skill: sub-compositions for every scene, one caption
  track, one element kind per track, safe zones
- drift test lints every html example in the owner doc with the real linter
  and fails on any doc teaching a volume tween

* refactor(skills): drop unused tween allowlist from recipes drift test

* docs(skills): name the track kind attribute in the studio skill

* docs(skills): stop claiming lint rules that do not exist yet and fix track numbering

Review fixes:

- studio skill: drop the severity claim and the preview-overlay claim,
  group tracks by data-track-kind instead of numeric ranges that clashed
  with the recipes
- recipes: audio data-start is root time only in the root composition;
  scene-local inside a sub-composition file
- guardrails: timeline edits are exempt from the element-only and timing
  lines; the pointer names the skill reference, not a repo path
- montage doc defers to the volume lane; skill count is 21 everywhere
- drift test also scans the montage doc

* fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones

- audio-duck.mjs prints a data-automation lane (clip-local times from the
  target data-start) instead of a timeline volume tween; --json adds the lane
- operations.md shows the generated lane; the drift test scans it
- embedded-captions safe-zone lines point at the studio skill, which owns
  the values

* chore(skills): regenerate the skills manifest after rebase

* docs(skills): remotion translation docs point volume ramps at the automation lane

* docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes

* docs(skills): caption docs defer to the studio safe boxes

* chore(skills): regenerate the skills manifest after rebase

* fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording
2026-09-18 23:19:40 -04:00
Vance Ingalls 34644a1993 Merge pull request #4148 from heygen-com/release/v0.8.50
chore: release v0.8.50
v0.8.50
2026-09-18 19:56:18 -07:00