Commit Graph
4509 Commits
Author SHA1 Message Date
Miguel Ángel c2ca2291ca fix(studio): dropping an asset between or above timeline rows opens a new row (#4160)
* 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.
2026-09-19 05:28:06 -04:00
Miguel Ángel e8fdfd0d65 fix(cli): install onnxruntime-node and google-genai on first use (#4156)
* 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.
2026-09-19 05:27:35 -04:00
Miguel Ángel 339dc76026 feat(studio): move every design value into one theme file (#3621)
* 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
2026-09-19 04:48:05 -04:00
Miguel Ángel f50df37671 feat(studio): the Lint badge pulses while an error finding exists (#4152)
* 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
2026-09-19 04:47:53 -04:00
Miguel Ángel 17b0c26896 fix(lint): video and audio take their length from the file, only an img needs data-duration (#4154) 2026-09-19 04:47:39 -04:00
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* 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.
2026-09-19 04:27:45 -04:00
Miguel Ángel 73f39862f2 fix(producer): retry a dead browser tab once on the single-worker capture path (#4162)
* 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
2026-09-19 04:27:34 -04:00
Miguel Ángel 0915affe97 fix(studio): button children lay out with the consumer's gap (#4159)
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.
2026-09-19 03:23:51 -04:00
Miguel Ángel 607775bb3a fix(engine): make urlDownloader retry tests independent of wall-clock time (#4155) 2026-09-19 03:07:27 -04:00
Miguel Ángel 68b61675ed feat(studio): upgrade Studio to Tailwind CSS v4 (#3618)
* 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
2026-09-19 03:07:19 -04:00
Miguel Ángel 559e57d295 fix(parsers): unroll keeps tween order and label positions the parser read (#4157)
* 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.
2026-09-19 02:47:20 -04:00
752b552789 feat(registry): blur an element by marking it, not by calling a function (#4045)
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>
2026-09-18 22:55:15 -07:00
Miguel Ángel 1a9668b6d6 docs(skills): the agent presents a tailor-made storyboard instead of a Studio board (#4150)
* 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.
2026-09-19 01:07:24 -04:00
Vance Ingalls 9d71223042 Merge pull request #4116 from heygen-com/lfr/09-r3-hardening
fix(engine,producer): harden the parallel streaming path for the screenshot cohort (R3)
2026-09-18 21:52:27 -07:00
Vance Ingalls 85537e1c7d Merge pull request #4115 from heygen-com/lfr/08-phase2d-multi-worker-segments
feat(producer): multi-worker segmented capture and the single router predicate (Phase 2d)
2026-09-18 21:25:15 -07:00
Vance Ingalls 10b2f4caf7 Merge pull request #4114 from heygen-com/lfr/07-phase2c-recycle-retry
feat(producer): browser recycle between segments and one-shot target-loss retry (Phase 2c)
2026-09-18 21:24:29 -07:00
Vance Ingalls 30b2b8f82d Merge pull request #4113 from heygen-com/lfr/06-phase2b-manifest-resume
feat(producer,cli): segment manifest, --resume and --keep-segments (Phase 2b)
2026-09-18 21:23:04 -07:00
Vance Ingalls 9e6bb9579d Merge pull request #4112 from heygen-com/lfr/05-phase2a-segmented-capture
feat(producer): segmented capture for long renders behind HF_SEGMENTED_CAPTURE (Phase 2a)
2026-09-18 21:22:41 -07:00
Vance Ingalls e3ecff0e0d Merge pull request #4111 from heygen-com/lfr/04-phase1-parallel-stream-default
feat(producer): parallel-stream router default-on for BeginFrame capture (Phase 1a)
2026-09-18 21:22:12 -07:00
Vance Ingalls 97e0569d98 Merge pull request #4110 from heygen-com/lfr/03-phase-minus1-chrome-memory
feat(engine,producer,cli): Chrome memory telemetry per capture session (Phase -1)
2026-09-18 21:21:33 -07:00
Miguel Ángel 144e8023c6 feat(parsers): detect where the gsap timeline disagrees with timing attributes (#4147)
* 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
2026-09-19 00:09:21 -04:00
Miguel Ángel e3bf76af67 ci(windows): a run superseded by a newer push no longer reports a failed aggregator (#4149)
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.
2026-09-19 00:07:22 -04:00
Vance IngallsandClaude Fable 5.1 7b65cb98e7 fix(producer): concat directly on a full segmented resume and document the retry surface
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>
2026-09-18 20:53:13 -07:00
Vance IngallsandClaude Fable 5.1 072750168d docs(long-form): point the fixture README at the ticket, not a plans path
Spec and planning docs are not committed to this repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:53:13 -07:00
Vance IngallsandClaude Fable 5.1 17cf030b93 docs(long-form): fault-injection validation of the R3 retry paths
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>
2026-09-18 20:53:13 -07:00
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