* fix(studio): dropping an asset between or above timeline lanes opens a new track there
* refactor(studio): reuse the source patcher and shared drop-placement type for track inserts
* fix(studio): the dropped clip must carry its peers' sourceFile, not the resolved path
planDropTrackInsert stamped the synthetic dropped element with the resolved
target path string, but real elements on the main document carry no
sourceFile at all. sameSourceFile compared the two directly, so the dropped
element always read as belonging to a different file and every peer was
skipped as unwritable, silently landing the drop on an existing track
instead of opening one.
* refactor(studio): trim drop-track comments and share one can-insert-track predicate
Drop the TimelinePlacement alias, compute the file-or-asset check once for
dragover and drop, and shorten two comments that narrated history.
* fix(studio): plan a dropped-in track against the visible rows, not clips the timeline hides
The insert row counts the rows the timeline shows, but the plan was built
from every same-file clip. A clip the timeline does not show as its own row
shifted the boundary and renumbered lanes it should have left alone.
* test(studio): assert the dropped clip lands on the opened lane
* fix(studio): a row drop measures its boundary on the same row order a clip drag does
The drop path indexed the insert row against the rows the timeline shows,
which include audio-group anchor rows, but planned it against a plain sorted
track list. An audio group above the boundary shifted the new row by one.
The placement now carries the displayed row order and the planner uses it,
as the clip drag already does.
* fix(cli): install onnxruntime-node and google-genai on first use
Both carry deprecated transitive dependencies (boolean, node-domexception)
that showed up as npm warnings on every npx hyperframes install. They move
to devDependencies (types and the Intel-binding check still need them at
build time; the bundler externalizes them) and load through a new
on-demand loader that installs into the CLI's own cache dir the first
time a feature needs them, with one stderr line, no prompt, and a
manual-install command named in the error if the network is unavailable.
hyperframes doctor lists each as installed (with version) or
not-installed-yet.
* fix(cli): sweep stale install staging and bound network retries
* fix(cli): optional-package install only sweeps staging dirs whose process is dead
Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir
whose pid is alive, so concurrent first runs no longer delete each other's install.
The install error suggests the network only for network failures, and the manifest
path has one owner. install() takes an injectable npm runner so tests drive the real code.
* fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir
The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign
staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no
longer aborts a viable install.
* fix(cli): optional-package sweep names a stale dir it could not remove
A locked leftover is still skipped so the install proceeds, but the warning makes it visible.
* docs(cli): shorten the install docblock to the comment limit
Comment-only change.
* feat(studio): move every design value into one theme file
Studio's design values lived in three places: a JS Tailwind preset, an
inline @theme block, and raw hex and pixel literals in markup and CSS.
theme.css is now the single owner. Tailwind v4 turns it into both the
utility classes and the CSS custom properties, so markup, plain CSS and
canvas code all read the same number.
- semantic colour palette (background levels, surfaces, borders, text
levels, accent, selection, playhead, danger, media, container), radii,
control heights, the micro type scale, font stacks, shadows, easings
and motion durations
- the stock Tailwind palette is cleared with --color-*: initial, so a
colour that is not a Studio token cannot be reached from markup; the
entries Studio's markup still names are re-declared as deprecated
aliases and removed once the sweep converts them
- motion durations ship as @utility rules that zero themselves under
prefers-reduced-motion, since Tailwind has no duration namespace
- Phosphor icons get one default size and weight, read off the theme at
the app root; icons that pass their own still win
- published as @hyperframes/studio/theme.css; the JS preset export stays
as a deprecated shim for v3 consumers, with a test that fails if the
two drift apart
* docs(studio): trim theme token comments to pass the comment-length gate
* docs(studio): shorten theme test docblock to fit the comment gate
* refactor(studio): collapse the duration utilities into one functional rule
* refactor(studio): trim theme.css comments to three lines
* test(studio): fail when a legacy palette entry is not a Tailwind default
* fix(studio): keep the palette reset out of the exported theme.css
* style(studio): format theme.css with the repo formatter
* fix(studio): declare theme.css browser-only so the packed-consumer smoke does not import it in node
* feat(studio): the Lint badge pulses while an error finding exists
* refactor(studio): drop needless memo around the lint error flag
* fix(studio): the Lint badge pulse reads the same findings as its count
* fix(studio): name the badge severity for screen readers and cover a mixed finding list
* fix(studio): the error badge uses the panel danger token
* feat(cli): hyperframes timeline prints a project's tracks and clips
* docs(cli): list the timeline command
* feat(cli): timeline command reports unreadable lanes; add skill docs
* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes
* fix(cli): timeline resolves symlinks before the project containment check
* fix(cli): timeline accepts project folders whose name starts with two dots
* refactor(cli): timeline and compositions share the engine's start resolver
* docs(skills): point agents at the timeline command where they decide how to read a project
* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips
Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.
* fix(cli): timeline places nested media at the runtime start and tests its query one-liners
A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0
The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
* refactor(core): one media-start rule shared by the runtime and the timeline command
The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
* test(cli): timeline fixture for a media start written as an expression
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
* fix(producer): retry a dead browser tab once on the single-worker capture path
* fix(producer): keep the caller-owned dedup perf list untouched on sequential retry
* test(producer): extend the streaming-stage orchestrator mock with the retry exports
Children now render directly as flex items of the button (which
already carries gap-1.5/gap-1/gap-2) instead of a wrapping span, so a
label plus a kbd shortcut as two children get real spacing instead of
0px.
* chore(studio): upgrade Tailwind CSS to v4 through the Vite plugin
Studio moves from Tailwind 3.4 to 4.3.3, loaded by @tailwindcss/vite instead
of PostCSS. The JS config becomes an @theme block in studio.css carrying the
same studio.* and panel.* colors, with explicit @source globs so the gitignored
data/projects tree is never scanned.
The official upgrade tool renamed the utilities, but it rewrites bare words
inside any string, so its edits to event names, CSS property names, filter
function names, status enum values and prose were reverted by hand.
Nothing in Studio relied on the v3 default border color, ring width or ring
color, so the tool's compatibility shim was dropped rather than kept.
* chore(studio): note the duplicated palette between theme css and preset
* fix(studio): keep the pointer cursor on buttons after the Tailwind v4 upgrade
v4 preflight dropped the pointer cursor on enabled buttons, so add it back in
one base rule. Also migrate the last outline-none to outline-hidden.
* fix(studio): keep the Renders Export button at its shipped 14px size
* chore(studio): allow the tailwind v4 config deletions in the deletion guard
* test(parsers): pin unroll order and side-effect soundness
* fix(parsers): resolve helper tweens in source order and unroll only pure tween helpers
* fix(parsers): unroll keeps a helper whose tween chain ends in a non-tween call
A helper like tl.to(...).call(fn) was treated as adding only tweens, so unroll
dropped the callback. Only chains of set/to/from/fromTo count now.
Also reuses isFunctionNode and names the unroll scope.
* fix(parsers): keep chain order, label positions and chain callbacks when unrolling
Review found three ways unroll still changed a timeline:
- a helper call positioned by a label was read at 0 (label order used raw loc)
- a tween chain added by a helper ran in reverse (outer call stamped first)
- an inner chain link with a callback was unrolled and lost the callback
* fix(parsers): resolve a label defined inside an inlined helper at its call site
addLabel was never tagged during helper expansion, so a cloned label kept
its declaration-body offset while its sibling tweens were remapped to the
call site. Comparing the two put every such label before every tween.
addLabel now gets the same site/order stamp, with order as the tie-break
when a label and its own tween share a call site.
* test(parsers): assert the sibling-label unroll rewrite matches its parsed start
The only literal-rewrite path this fix actually exercises (the label-defining
helper itself is never unrolled, since onlyAddsTweens rejects addLabel).
* refactor(parsers): unroll's effect scan reuses acorn-walk like its sibling
containsEffect hand-rolled a generic object walk; hasUnsafeLoopIndexUse in
gsapWriterAcorn.ts already solves the same shape with acorn-walk.full.
attachMotionBlur(el, tl) has an ordering contract: it has to run after every
tween is defined and before the composition registers its timeline. Getting it
wrong makes the blur silently do nothing, which is the worst shape a failure can
take in a renderer nobody watches. An attribute has no order to get wrong.
data-hf-motion-blur on any element is now the contract. Empty means defaults;
anything else is a JSON object of the same options. The element finds its own
timeline: its nearest [data-composition-id] ancestor names the key, and the
snippet reads window.__timelines[key]. attachMotionBlur stays exported for a
target built after the page has settled.
claim(el) is the only thing that attaches, and two triggers call it. An accessor
on window.__timelines wraps the registry in a Proxy whose set trap sweeps on
every per-key registration, which is what attaches before the renderer captures
frame zero. A bounded poll, 32 ms by 250 ticks, reads the registry directly and
needs no cooperation from whoever wrote it. The poll is not belt and braces: a
registry reference captured before the snippet installs bypasses the trap
outright, and a composition that mounts its DOM after registering its timeline
has no marked element to sweep at trap time. It runs its whole budget every time
rather than stopping the first tick nothing is pending, because on an ordinary
page the top-level target is claimed on the registration write, so an early exit
kills the poll one tick in, before any late composition arrives.
Every refusal is by name, once, and never silent: a value that is not JSON, a
value that parses but is not an options object, an option name that is not one
of the four, an option value that is not a finite number, a target inside
another target, and a second timeline registered under one key. Parsing runs
before the timeline lookup, so a malformed value is reported as malformed rather
than as a composition that never registered.
Two properties of the primitive this change made load-bearing:
- Frame rate is resolved from the target's own composition root, not from the
first root in the document. The document-wide lookup was survivable while
every caller could pass fps explicitly; the declarative form has no per-call
argument. Two compositions on one page at different rates now each get their
own shutter window, and one call naming both says it cannot.
- An attach is all-or-nothing. The mark, the copies and the resize observer go
in together and come back out together, so a throw partway through leaves the
element untouched rather than marked blurred with copies in the page and no
tracker driving them. The sweep is also bounded per element, because the write
trap runs it inside the author's own registration statement.
Copies are stripped of data-hf-motion-blur, data-composition-id and data-fps,
on the clone and every descendant: the first would make a copy a target of its
own, and the other two would let a copy win the fps lookup, since copies are
inserted before the original.
skills/hyperframes-animation/references/motion-blur.md is the agent-facing half,
mirroring how colour grading is surfaced. It leads with when NOT to blur,
because the attribute makes blurring everything trivial and a smear on motion
that was never fast enough reads as a soft, cheap render.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
* docs(skills): the agent presents a tailor-made storyboard page instead of a Studio board
Adds the storyboard recipe (decisions first, beat fields, a self-contained
storyboard.html contact sheet, a review round) distilled from the launch
films, and rewrites the review loop, brief contract, storyboard format and
workflow steps that still described the deleted board and comments file.
* docs(skills): reconcile the sketch pass with the beautiful storyboard sheet
Review found: the recipe's storyboard.html cell spec (real fonts, real
colors) contradicted review-loop's own sketch definition (wireframe, no
brand treatment). The sketch is static, not unstyled; only motion is
deferred to the build pass. Also fixes a frame-packet boundary note, a
craft-device citation, and a provenance overclaim.
* feat(parsers): mark tweens whose duration is not a static number as unresolved
* feat(parsers): detect timing mismatches between attributes and the gsap timeline
* refactor(parsers): simplify unresolved-duration handling
* fix(parsers): treat every unreadable duration shape as unknown
* fix(parsers): keep helpers that unrolled-away calls still run through
* fix(parsers): unroll keeps every helper a remaining call still reaches
* fix(parsers): unroll matches helper names by identifier and skips unresolved selectors
* test(parsers): pin the identifier boundary used to keep unroll helpers
The aggregator runs with always(), so a run cancelled by concurrency posted a red
required check. A cancelled lane now passes only when a newer run of this workflow
exists for the same branch; a manual cancel of the latest run, or any API error,
still fails.
Review follow-ups on the R3 hardening PR:
- A resume that finds every segment complete no longer opens a browser: the
stage closes the probe it was handed, concatenates the plan's segment list
and returns success. A stage test asserts the session factory is never
called on that path.
- retryAfterTargetLoss notes that its recycle also resets the worker's
cadence counter (intended: the fresh browser is as new as a cadence restart).
- The long-form README gains a Known cost section on the narrower retry
surface of segmented capture versus plain streaming, and an operational
note that renders/.hf-segments/<planHash>/ persists after failure by design
and that the plan hash now covers GPU encoder, output resolution and motion
blur.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four renders on the 60 s fixture, one injected failure each: ffmpeg killed
mid-stream, one worker's Chrome killed, the same under the manual
HF_DE_PARALLEL_STREAM opt-in, and one worker frozen with SIGSTOP under a 5 s
stall window. All four retried on one worker and delivered all 1800 frames;
the worker death surfaced its own error in 0.9 s instead of a 60 s stall
relabel, and the opt-in cohort retried at one worker instead of N. Records
the one caveat: a frozen renderer cannot observe the pool abort, so that
retry waits for the pool to settle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>