Commit Graph
1070 Commits
Author SHA1 Message Date
Miguel Ángel a7ec66b124 fix(producer): cache the google fonts css lookup per process (#4097)
* fix(producer): cache the google fonts css lookup per process

Studio compiles every composition in a project separately for its
sidebar thumbnails and for gsap-animation extraction. Each compile
called injectDeterministicFontFaces, which re-fetched the same font
family's CSS metadata from fonts.googleapis.com over the network every
single time -- only the woff2 binary files were cached to disk, not
the CSS lookup itself. A project with a dozen small compositions and
three declared font families fired dozens of redundant network round
trips serially, holding the editor's asset-readiness overlay well past
its own 8-second timeout.

Adds an in-process cache for the CSS lookup keyed by the exact request
URL, self-evicting on failure so a later call still retries. Measured
on a real project: gsap-animation extraction across 12 compositions
dropped from ~16.4s to ~2.9s wall time, and per-composition thumbnail
generation dropped from 9-11s each to 10-14ms each once the cache is
warm.

* fix(producer): isolate the google fonts css cache from any one caller's abort

An adversarial review of the process-wide CSS-lookup cache found two real
bugs before it shipped. First, the cached promise carried whichever caller
reached it first's AbortSignal, so one render's cancellation could fail a
completely unrelated concurrent render sharing the same font family. Second,
the cache has no reset between test files running in one process, so two
sibling test suites asserting per-call fetch counts against the same
Google Fonts URL silently saw each other's cached result.

Strips the caller-specific AbortSignal from the shared fetch and races each
caller's own signal against it independently, so a caller can still bail out
on its own cancellation without affecting anyone else awaiting the same
lookup. Clears the cache in the two sibling test files' own beforeEach.

* fix(producer): key the google fonts css cache by fail-closed mode and drop transient statuses
2026-09-18 13:47:24 -04:00
Miguel Ángel 7b4f3a22b0 chore: release v0.8.48 (#4085) 2026-09-18 11:11:40 -04:00
Vance Ingalls 5b7ff519a5 chore: release v0.8.47 2026-09-17 23:55:00 -07:00
Miguel Ángel a8a9fdb0fe chore: prepare v0.8.46 (#4033)
* chore: prepare v0.8.46

Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag.
Changelog rewritten in plain words for the one change since v0.8.45
(#4031, adaptive per-frame motion-blur sample count). Cutting the
release is a separate step on Miguel's word.

* docs(release): fix false render-time savings claim in v0.8.46 notes

Adaptive sampling never drops below the old fixed-16 default; it only
adds cost on frames that measure real spatial motion. Per Miga's review
on #4033.
2026-09-17 01:48:54 -04:00
miga-heygenandMiguel Ángel 484a06fb65 chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 22:56:40 -04:00
miga-heygenandMiguel Ángel 77a45f6080 fix(producer): fail loudly when a capture route cannot blur (#4026)
A render on the layered HDR route accepted `motionBlur` and returned frames
with no blur in them. The HDR compositor runs its own capture loop, seeking
and blitting each layer itself, so a frame never reaches the sub-frame
accumulation branch. The engine's own guard cannot catch it, because from
inside the session everything looks supported: screenshot capture mode, PNG
frames, and no video injector on a composition with no video.

This is the third time the question "does this route support motion blur"
has been answered implicitly by whichever code path happened to run, and the
third time it came out wrong and silent. `assertMotionBlurSupported` gives
that question one owner and one answer. The route it takes is the capture
plan's own `kind`, already the single value the capture stages consume, so
it cannot drift from the route that actually runs, and a new plan kind with
no entry in its table fails the typecheck rather than inheriting silence.

It sits inside `syncCapturePlan`, which runs on the initial plan and after
every replan, so a route a render falls back into is checked too.

Distributed renders are out of scope on purpose and recorded as such:
`DistributedRenderConfig` has no `motionBlur` field and is not built from
`RenderConfig`, so motion blur cannot be requested on one. There is nothing
to reject and no dead call site.

The explicit unsupported-mode error for layered HDR is the design in #4013
by Dante-dan.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 02:13:55 +00:00
miga-heygenandMiguel Ángel 71bb88719e feat(telemetry): render output-shape, adapter, count, and environment properties (#4017)
* feat(telemetry): add output-shape request facts to render events

output_resolution_preset, output_format, hdr_mode, video_frame_format,
and gif_fps_capped join render_complete/render_error, all threaded
from already-resolved RenderOptions/RenderPlan fields rather than
recomputed: createRenderPlan already computes gifFpsCapped (clamping a
requested --fps above 30 to 30 for --format gif) but only used it for
a console warning; this threads that same decision into RenderOptions
and telemetry. All five are known before the pipeline starts, so both
events carry them even when a render fails before a perfSummary
exists.

Also wires output_format/output_resolution_preset into Studio's
separate emitStudioRenderComplete/emitStudioRenderError path, since
Studio's request genuinely varies those two per-request. hdr_mode,
video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes
no HDR-mode, video-frame-format, or GIF output control to users at
all, so there is no per-request value to report for those three.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add adapters_used to render events

adapters_used lists the runtime deterministic adapters
(packages/core/src/runtime/adapters/) a composition exercises.
Investigated the actual codebase rather than the ticket's guessed
7-adapter list and found 12 real registered adapters (gsap, css,
waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre,
google-maps) — the map/data-source adapters share the exact same
window.__hf<Name> registration mechanism and are equally real
"adapters used" facts, so all 12 are included.

Detection unions a live page.evaluate probe (piggybacked on the same
probeSession resolveCompositionElementCount already uses) with a
static regex fallback over the compiled HTML, since this is purely
observational with no gating role (unlike compositionElementCount) —
union strictly increases recall rather than needing one source of
truth. Threaded through the same plumbing chain as the already-merged
composition_element_tags/aroll_video_count/heygen_video_count.

Studio's emitStudioRenderComplete/emitStudioRenderError get this field
automatically, with no Studio-side code change, via the same shared
capture-observability spread heygen_video_count already uses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add audio/image/sub-composition/color-grading counts

audio_count, image_count, sub_composition_count, audio_group_count,
color_grading_count, and has_lut join composition_element_tags in the
same single scanElementTags pass — audio/image/audio-group counts read
from the scan's uncapped tag map so a 50+-distinct-tag composition
can't push a named count into the "other" bucket. sub_composition_count
counts data-composition-src mounts directly rather than reusing
collectSubCompositionSrcs, which dedupes by src and drops
placeholder/remote mounts (a different, smaller fact than the mount
count this field reports).

has_lut decodes the HTML-entity-escaped form of data-color-grading
before parsing, since linkedom's serializer re-emits it
&quot;-escaped on every compile-pipeline round-trip — without the
decode this silently reported false for every LUT-graded composition.
The LUT-presence check now mirrors normalizeLut's own definition of
"has a LUT" exactly, closing a gap where a blank or empty lut value
would have reported true.

Threaded through the same plumbing chain as the already-merged
per-tag/provenance/adapter fields; Studio's emitter picks these up
automatically via the shared capture-observability spread.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add browser_version_major and ffmpeg_version_major

Both are already-run subprocess calls whose output was discarded:
readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg
-version`, and chromeLaunchOutcome already runs `--version` as a
Chrome launch sanity probe on every render. A shared
extractMajorVersion helper pulls the first X.Y-shaped number from
either banner, threaded onto EnvironmentCheckOutcome/
EnvironmentCheckResult, then RenderOptions right after
runEnvironmentChecks resolves in the local render path only.

Docker renders never populate these two RenderOptions fields (the
containerized producer runs its own preflight the host CLI never
sees), so both properties come back absent there rather than
reporting the host's own toolchain versions for a render that didn't
use them.

Studio does not call runEnvironmentChecks or this ffmpeg/Chrome
preflight at all before creating a render job, so its render_complete/
render_error events will not carry these two properties unless
Studio's own startup path is separately instrumented — a genuine
parity gap, not an oversight in this change.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add authoring_skill_source and authoring_skill_invalid

Both derive from resolution logic createRenderPlan already runs:
authoring_skill_source names whichever step actually won attribution
(an explicit --skill flag, or the skill persisted in hyperframes.json)
using the same flagSkill/projectConfigSkill values already computed
for authoringSkill itself, not a second lookup. authoring_skill_invalid
surfaces the raw --skill value when it fails slug-shape validation
(normalizeSkillSlug rejects malformed input, not unregistered skill
names) — this value already exists as RenderPlan.invalidAuthoringSkill
and already drives a CLI warning in present.ts; this change threads
the same field into telemetry rather than adding a parallel one.

Threaded through both RenderOptions builders in execute.ts (single and
batch render) and all three telemetry call sites in render.ts,
including the Docker path — skill attribution is a request-level fact
independent of which backend executes the render.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket

Measures the composition root's authored data-width/data-height
against the scaffold's own html/body CSS size, straight from the
compiled document string scanElementTags already scans — the
source-level fact, not runtime DOM state. init.ts's applyCompositionSizing
(the #4001 fix) only patches the live DOM after the browser loads this
same HTML, so this measurement stays meaningful whether or not that fix
ran for a given render.

Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same
ones packages/lint's root_dimensions_mismatch rule reads) for the CSS
side. Root-tag detection here is a lighter first-data-composition-id-match
heuristic, not lint's more thorough findRootTag (which also handles a
leading decorative <svg> defs block) — that helper isn't part of
@hyperframes/lint's public API, and expanding it is out of scope here.

The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported
raw, and both fields come back absent — not a false/"0" default — when
the root or the CSS block can't be read at all.

Threaded through the same plumbing as every other static-scan field
this week, including the live-capture-observability fallback used on a
crash that never reaches perfSummary.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add hf_env_overrides (names only, capped at 20)

Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when
createRenderPlan resolves — never their values, since several of these
carry filesystem paths. Snapshotting at plan-resolution time (the very
first line of the render command, before any pipeline code runs)
matters: this render's own preflight later injects
HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own
process.env, which would otherwise make those two always read back as
"operator overrides" whether or not the operator ever set them.

Reports an empty array, never an absent key, when nothing is set,
matching every other capped/bucketed field added this week. Capped at
20 names, documented alongside the cap constant.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(cli): stop clobbering studio render_complete fields, fix an env leak

trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/
adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever
populated on the CLI's own render.ts path, always won the object spread over
the observability-capture fallback those same keys share with trackRenderError,
even when the direct value was undefined. studioRenderTelemetry.ts's
emitStudioRenderComplete never populates drawElement, only the capture
fallback, so 24 fields silently came back missing from every studio-triggered
render_complete event despite Studio having computed and sent the capture
value. Each field now falls back to its captureXxx counterpart via a new
directOrCapture() helper when the direct field is absent, with regression
tests proving both the fallback and that the direct value still wins when
both are present.

Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this
on every invocation to point the bundled shader worker pool at its own file,
sharing the HF_ prefix by coincidence rather than because an operator set it.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style: remove em and en dashes from added code comments

No behavior change. Rewrites 22 comment lines this PR's own diff introduced,
using a comma or a period where the dash separated two clauses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure

Not a real fix. The rootBodyMismatch test group fails deterministically in
CI (confirmed on two runs of the same head) but will not reproduce locally
by any method tried, including an isolated file run, the exact combined
producer:test:unit command, a worker-count-constrained run, and a fresh
clone with a clean install and build matching CI's own steps.

This dumps the intermediate regex-match state for the simplest failing
fixture to stderr so the next CI run's log says what actually differs.
To be removed once that answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): widen the rootBodyMismatch diagnostic to compare import paths

Still not a real fix. The previous diagnostic (ca4e73983) proved the
imported HTML_BODY_CSS_WIDTH_FIRST_RE has 4 capture groups in the failing
CI run instead of the 2 the source and production code expect, but it used
a dynamic import(), which can resolve through a different codepath than
renderOrchestrator.ts's static import. This adds a static import of the
same binding, a require.resolve() of the resolved file path, and a direct
read of that file's own regex line, so the next run can rule in or out a
static/dynamic resolution split versus a genuinely different file on disk.

Locally both imports agree (2 groups, same object, correct file). To be
removed once the CI answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(producer): read the merged 4-group scaffold-size regex correctly

main merged #4012 while this branch was in flight, consolidating
HTML_BODY_CSS_WIDTH_FIRST_RE/HEIGHT_FIRST_RE from 2 capture groups (digits
only) to 4 (prefix text, digits, suffix text, digits) so cli's
applyResolutionPreset could reuse the same regex for its in-place replace.
detectRootBodySizeMismatch still read groups 1/2 as the digits, now the
prefix/suffix text, so Number() on them was NaN and every render's
rootBodyMismatch/rootBodyDeltaPxBucket silently came back undefined. Fixed
to read groups 2/4, matching lint's core.ts readHtmlBodyCssSize exactly.

Removes the temporary diagnostic test (ca4e73983, e2e384c8e) now that its
CI log gave the real answer: both a static and dynamic import of the
regex resolved to the same file, and that file's own content had 4 groups
because this branch's merge-base predates #4012, not because of any
resolution or caching difference. A fresh CI run confirmed no CI-only
failure remains after merging main and fixing the indices.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: nudge CI (no workflow run triggered on previous push)

* chore: nudge CI again now that #4017 is out of draft

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 02:10:27 +00:00
miga-heygenandMiguel Ángel b64a7a3a67 feat(producer): make sub-frame motion blur reachable from a render (#4020)
* feat(producer): make sub-frame motion blur reachable from a render

Second deliverable for issue #4010. The engine gained the accumulation path, but
nothing set CaptureOptions.motionBlur, so the feature could not be switched on
from a real render. RenderConfig now carries it and the producer threads it into
the capture options.

Doing that surfaced the defect that would have made it fail silently. "Does this
render capture PNG frames" was spelled four times, every one of them re-derived
from needsAlpha, which describes the OUTPUT container rather than the capture:

  probeStage        picked the probe capture format
  renderOrchestrator picked the render capture format
  encodeStage       built the encoder's frame_%06d input pattern, twice
  captureStage      built the disk worker's frame extension

Motion blur is a second, independent reason to capture PNG: the sub-frame samples
are averaged pixel by pixel, so JPEG samples would be averaged after a lossy
quantization. Add that reason to only one of those four and the encoder goes
looking for frame_%06d.jpg against files written as .png, and the render finds no
frames at all rather than reporting an error.

So the rule now has one owner, resolveCaptureImageFormat, which names both
reasons. Everyone downstream reads the resolved capture format instead of
re-deriving it: the encoder and the disk worker share frameFileExtension with
writeCapturedFrame, which is what actually names the files. needsAlpha keeps only
the question it really answers, whether the output preserves alpha.

captureStage no longer needs needsAlpha at all, which is the tell that the
re-derivation was its only reason to know.

No JPEG encoder is involved. The PNG capture path is already load-bearing, since
every transparent-output render uses it and the streaming encoder already selects
png as its input codec.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(engine): resolve the motion-blur plan on every initialization exit

A real render produced no blur at all. The plan was correct, the producer
wiring was correct, and the sub-frame sample times were genuinely distinct, but
K=16 and K=64 rendered identically to no blur because neither ever ran.

initializeSession has two exits: screenshot mode finishes its own block and
returns, every other capture mode falls through to the end. The plan was
resolved at the fall-through exit only, and screenshot is the one capture mode
motion blur supports, so on exactly the path the feature runs on
session.motionBlur was never assigned. captureFrameCore then read it as
undefined and took the single-capture branch: one capture per output frame, no
accumulation, no error.

finalizeSessionInit is now the single owner of what finishing initialization
means. It sets the plan and isInitialized together and is called at both exits,
so a third exit cannot mark a session ready while forgetting the plan.

Why the existing tests passed, which is the part worth keeping: the accumulation
test set session.motionBlur directly on a stub session. It exercised the
mechanism while bypassing the only code that decides whether the mechanism runs
at all. A test that proves something works and never asks whether it is
reachable is not end to end, however much behaviour it drives.

Two tests. A structural guard that session.isInitialized = true appears exactly
once and that the same place resolves the plan, which fails if the defect is
reintroduced at either exit. And a sample-distinctness assertion that hashes the
K buffers captured within one output frame and requires K distinct ones, which
covers the different failure of every sub-frame seek landing on one instant.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 01:16:52 +00:00
Aditya Poudyal e4b1832fad feat(cli): add hls output format (#4022) 2026-09-16 21:09:14 -04:00
Aditya PoudyalandClaude Opus 5 462f9db73d feat(producer): add hls output format (#4014)
* feat(engine): add packageHls for HLS VOD packaging

Adds `packageHls` next to `muxVideoWithAudio`: stream-copies an
already-encoded H.264 video and optional AAC sidecar into an HLS VOD
directory (`master.m3u8` + split video/audio renditions, MPEG-TS
segments) with `-c copy`, so no re-encode happens at package time.

Depends on the locked GOP from the previous commit. `-hls_time` can only
cut at existing keyframes, so `gopSize = segmentSeconds x fps` is what
makes every segment exactly `segmentSeconds` long and IDR-aligned. A
test packages an unlocked encode to pin that: the same call yields one
4s segment instead of four 1s ones.

Deliberately omits the mp4 mux's provenance tags, which MPEG-TS drops
and whose `-movflags` is invalid for `-f hls`, and `-avoid_negative_ts`,
which would discard the AAC priming and shift audio against video (see
issue 3487). A real-ffmpeg test asserts the first presentable audio
sample lands on the first video frame.

No caller yet; `format: "hls"` in the producer follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(engine): zero HLS mux base and reject % in packageHls outputDir

- Pass -muxdelay 0 -muxpreload 0 so the first segment starts at PTS 0
  instead of the mpegts muxer's 1.4 s base; A/V alignment through the
  AAC priming packet is unchanged
- Throw on an outputDir containing %, which ffmpeg expands as a printf
  conversion in -hls_segment_filename and fails with a misleading
  "No such file or directory"
- Assert alignment on relative spacing rather than on how one ffmpeg
  build clamps the priming packet's negative PTS
- Encode the test fixture with the full software GOP lock
  (lockedGopCodecParams), not just appendLockedGopArgs
- Trim comments to the non-obvious rationale

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(engine): satisfy the ffprobe argv contract in packageHls test

`producer/src/utils/ffprobeArgvContract.test.ts` sweeps every package at
the source level and requires `--` to be the argv entry immediately before
an ffprobe input path. The `probe()` helper here passed
`join(outputDir, HLS_VIDEO_PLAYLIST)` in that slot, which the contract's
comma-splitting parser reads as two entries, so the producer suite was red
on this branch while the engine suite stayed green.

`probe()` now takes the input path as a parameter and appends the
terminator itself. The sibling ffmpeg `run()` helper switches from a
spread-in-array-literal to `.concat`, because a verbosity-flag-then-spread
literal is indistinguishable from the ffprobe wrapper shape the contract
demands a terminator on. The matcher reads raw source including comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(engine): drop the audio-only variant from the HLS master playlist

With `a:0,agroup:aud` in `-var_stream_map`, ffmpeg lists the audio
rendition twice in `master.m3u8`: as the `#EXT-X-MEDIA:TYPE=AUDIO` group
the video variant references, and again as a standalone `#EXT-X-STREAM-INF`
variant with no `RESOLUTION`. That is valid HLS, but a player choosing by
bandwidth can select it and play sound with no picture, and the VOD
consumer asked for a single rendition.

`stripAudioOnlyVariants` removes any variant tag without a `RESOLUTION`
attribute together with its URI line; `packageHls` applies it to the
master after a successful audio+video run. The rendition group and the
audio playlist are untouched, so `audio.m3u8` is still referenced from the
master. Pinned by a unit test on the playlist text and by the real-ffmpeg
test, which now asserts exactly one `#EXT-X-STREAM-INF`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(producer): extract hdrMode warning and decision-log helpers

`resolveEffectiveHdrMode` was already over the fallow complexity gate on
`main` (21 cyclomatic / 25 cognitive / CRAP 462) and only surfaces now
because the HLS format work needs to touch its downgrade warning. Pull the
downgrade message into `formatDowngradeWarning` and the "which mode and
why" info line into `logHdrDecision`, which puts the function under the
threshold.

No behavior change: every log string is byte-identical and `hdrMode.test.ts`
asserts them unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(producer): add hls output format

Adds `format: "hls"` to `@hyperframes/producer`. An HLS render is the same
opaque H.264 + AAC encode as `mp4`, stream-copied at assemble time (no
re-encode) into a directory: `master.m3u8`, `video.m3u8` + `video_%05d.ts`,
and `audio.m3u8` + `audio_%05d.ts` when the composition has audio.

The load-bearing part is that `-c copy -f hls` can only cut a segment at an
existing keyframe, so the encoder's GOP is locked to
`hlsSegmentSeconds x fps` frames. `resolveHlsEncoderGopLock` returns that
pair as one value spread into both encoder inputs, so the streaming and
disk/chunked paths cannot disagree — a render reaching only one of them
still succeeds, it just emits variable-length segments silently.

- `renderFormat.ts`: `hls` is opaque (`outputNeedsAlpha` is now
  `!== "mp4" && !== "hls"` — missing this flips HLS to RGBA screenshot
  capture with nothing failing), supports page-side compositing, and
  `outputUsesH264Pipeline` replaces the three `outputFormat === "mp4"`
  router gates.
- `hlsConfig.ts`: `hlsSegmentSeconds` (default 4, positive integer), the
  GOP derivation, and the fail-fast gates. `force-hdr` is rejected (v1 is
  SDR-only; HDR10 needs HEVC in fMP4), and so is `useGpu` — the GOP lock is
  software-only, so a GPU HLS render segments correctly by accident on
  videotoolbox and wrong on nvenc.
- `assembleStage.ts`: `hls` branches to `packageHls` instead of
  mux/faststart, after the same `padOrTrimAudioToVideoFrameCount` the mp4
  mux uses.
- `artifactTransaction.ts`: a directory artifact can now carry a
  `probeTarget` + `requiredFiles`, so the #3395 truncation gate still runs
  for HLS against `video.m3u8`. `expectedFrames` is inert there (ffprobe
  reports no frame count through the hls demuxer); duration carries it.
- `renderRequest.ts`: accepts `hls` + `hlsSegmentSeconds`; distributed
  conversion rejects it alongside `gif`. `DistributedFormat` is unchanged.
  `RENDER_REQUEST_VERSION` stays at 1: the new field is optional, the
  parser ignores unknown option keys, and an older worker already rejects
  `format: "hls"` with "Render request format is invalid".
- `hdrMode.ts`: the non-mp4 SDR downgrade warning no longer claims
  "HDR + alpha" for `hls`; the helper extraction landed in the preceding
  refactor commit.

Verified: `hlsRender.integration.test.ts` renders a real 10 s composition
and asserts 3 segments, the first two exactly 4.000 s, every segment
starting on an IDR frame, and PTS 0. Removing the GOP wiring collapses it
to 2 segments, so the test is load-bearing. Both producer lanes (unit and
integration) run on every PR in `ci.yml`, so that wiring check is on the
PR gate.

CLI flags and docs are deliberately left to the next PR in the series.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(engine): close the HLS master playlist rewrite race

CodeQL flagged two high-severity alerts on the `stripAudioOnlyVariants`
rewrite: a check-then-use race (`existsSync` then a separate path-based
read and write) and insecure creation of a file at a predictable path in
the OS temp dir.

Both came from resolving `master.m3u8` three times. Open it once with
`r+` and owner-only mode — which neither creates the playlist nor widens
its permissions, ffmpeg owns that — then read, truncate and write through
that one descriptor. Absence is still tolerated for the argument-level
tests that stub ffmpeg, now via ENOENT rather than a prior existence
check.

No behavior change: `hlsRender.integration.test.ts` still renders a real
composition with audio and asserts the stripped master playlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:33:55 -04:00
miga-heygenandMiguel Ángel 3c9462feea chore: release v0.8.44 (#4016)
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
  false positive v0.8.43 shipped with; a top-level mismatch is still reported
  and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
  same arguments as the disk encoder. Off by default, so an existing render
  produces an unchanged FFmpeg arg list (#3973)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 19:14:51 -04:00
miga-heygenandMiguel Ángel d6fe66707f chore: release v0.8.43 (#4007)
- Portrait compositions render the full frame when the project was
  scaffolded at a different size: html/body are sized to the composition
  root at runtime; new lint warning root_dimensions_mismatch (#4005,
  fixes #4001)
- render_complete telemetry adds composition_element_tags,
  aroll_video_count, heygen_video_count; render_error adds error_name and
  failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
  search no longer shows [object Object] (#4006)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 14:05:29 -04:00
miga-heygenandMiguel Ángel 3704863d96 feat(telemetry): per-tag, provenance, and failure-taxonomy properties on render events (#4004)
* feat(telemetry): add per-tag element and a-roll counts to render_complete

countElementTags's single regex scan (the ~83% of renders that get no
live probe session) is extended into scanElementTags, which returns a
per-tag breakdown and an a-roll video count alongside the same total,
all from one pass over the same script/style-stripped markup — the
total is the sum of the per-tag map by construction, so the two
properties can't drift apart. countElementTags itself is removed; it
had zero production callers after this change.

New render_complete properties: composition_element_tags ({tag: count},
raw lowercase tag names, capped at the top 50 by count with the rest
folded into an "other" key) and aroll_video_count (<video
data-aroll="true"> elements from the same scan). Both are static-scan-
only, mirroring the existing composition_element_count_source split —
the live probe-DOM path never runs this scan.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): count HeyGen-provenance video from the same tag scan

heygenVideoCount joins arollVideoCount/byTag as a third property
derived from scanElementTags's single pass: <video
data-media-source="heygen"> elements. media-use's resolve.md now
documents that the skill stamps this attribute on a mounted video only
when the ledger record's provider is "heygen.video" (the mount itself
is always the agent's own composition edit, never written by resolve
scripts) — the ledger stays the single source of truth for provenance,
the HTML attribute is a projection of it, and no other provider value
is invented beyond this one signal.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add a bucketable failure taxonomy to render_error

error_name (error.name, e.g. one of the ~20 typed producer error
classes) and failed_stage_code (job.currentStage/failedStage
normalized to a stable snake_case code via normalizeStageCode)
join the existing free-text error_message/failed_stage, so a fleet
query can bucket failures without parsing prose. Error-conditional by
nature — render_complete has no equivalent, since a successful render
has no error to name or stage to blame.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 12:37:57 -04:00
miga-heygenandMiguel Ángel 71b274bbfd chore: release v0.8.42 (#3988)
* chore: release v0.8.42

- Studio preview works for projects whose path has non-Latin-1 characters (#3982)
- Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984)
- check --json reports HDR auto-promotion; render prints one warning (#3985)
- Forced --sdr renders tone-map HDR sources before extraction (#3981)
- Render/preview workers no longer survive as orphans after Ctrl-C or parent exit;
  cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for
  intentionally detached renders (#3974)
- HDR preflight test coverage for held tails (#3963)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom and scope

- #3982 entry: a Studio preview server crash for compositions whose file path
  has non-Latin-1 characters (the project directory is not involved)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom

- #3982 entry: the uncaught ETag TypeError took the preview server down, so
  call it a Studio preview server crash rather than a preview failure

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 09:40:06 -04:00
heygengenesis[bot]andmiguel.sierra 08d48ab753 fix(cli): guard process snapshot capture (#3974)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:33:29 +00:00
heygengenesis[bot]andmiguel.sierra ef48485b91 fix(engine): tone-map HDR frames for forced SDR (#3981)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:32:17 +00:00
heygengenesis[bot]andmiguel.sierra 5d2478c6fa fix: report HDR auto-promotion (#3985)
Report HDR auto-promotion consistently from the render media set, including remote media, and make its regression coverage portable across supported paths.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:13:24 +00:00
terencecho edf6e4b373 chore: release v0.8.41 (#3966) 2026-09-15 17:24:09 -04:00
miga-heygenandMiguel Angel a0a6244be7 fix(producer): retry capture on a CDP Page.captureScreenshot refusal (#3892)
Chromium's CDP layer can refuse a screenshot capture outright ("Protocol
error (Page.captureScreenshot): Unable to capture screenshot") with no
timeout wording at all. classifyCaptureFailure() didn't recognize the
message, so it fell through to the fatal "authoring" bucket and neither
capture-retry path (the disk-capture retry loop, or the streaming-capture
pinned-fallback retry) ever attempted a retry — including under
--low-memory-mode, whose single-worker plain-screenshot capture never
engages either path's other retry conditions.

Adds the literal error text to classifyCaptureFailure()'s transient_browser
bucket, and threads the resulting classification into
shouldRetryViaPinnedFallback() as a routing-independent retry condition, so
the streaming path recovers on a fresh session the same way the disk path
already does for other transient browser failures.

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-09-14 17:06:45 +00:00
Miguel Ángel cfe5dcfad3 chore: release v0.8.40 (#3938) 2026-09-14 12:41:47 -04:00
Miguel Ángel d13a89b670 chore: release v0.8.39 (#3936) 2026-09-14 11:13:53 -04:00
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) 2026-09-13 22:02:19 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángelandmiga-heygen 4c26ffe8ce fix(render): keep verification fallback storage-aware (#3703)
* fix(render): keep verification fallback storage-aware

* fix(render): honour storage-aware verification fallback for every routing kind

The disk-headroom flag was computed by the orchestrator for both
worker_inversion and parallel_router routings but only honoured by
replanAfterFailure under worker_inversion, so the default-on
parallel_router path still reverted to an sdr_disk plan that the 90%
gate would reject and assertDiskCaptureHeadroom aborted the render.

Give the predicate a single owner: drawElementVerificationFailure
decides when headroom can change the fallback (non-default routing,
sdr_disk preferred fallback, off-disk memory-exhaustion fallback) and
consults the shared inspector only then. replanAfterFailure now honours
the resulting flag for any routing kind, and the orchestrator merges the
two revert log branches keyed on that flag instead of on plan kind,
which also stops OOM retries from being logged as headroom diversions.

inspectDiskCaptureHeadroom returns a discriminated union so the assert
no longer needs a redundant null guard to narrow freeBytes.

Tests: parallel_router headroom diversion and restoration, the failure
constructor across default / streaming-only / disk-only routings, and an
exact-once statfs count.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(render): prove non-verification capture failures skip the disk inspector

Extract the streaming drain's failure construction into
`streamingCaptureFailure` so the retry classification is unit-testable,
and assert that a drawElement capture failure or OOM on a routed plan
never calls the disk headroom inspector — only a self-verification
failure may, via `drawElementVerificationFailure`.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
2026-09-13 15:33:22 +00:00
miga-heygenandMiguel Ángel 1acc65dbf8 fix(core): un-hide a later root-level clip instead of leaving it display:none forever (#3893)
A root-level `[data-start]` clip with no authored `position` starts out
`position: static` until the runtime forces it to `position: absolute`. A
visibility pass over the clip while it's still inactive can observe the
pre-forcing `static` value and cache it as in-flow, which correctly hides
the clip with `display: none`. But by the time the clip later becomes
active, that same cache has been invalidated and recomputed against the
now-forced `absolute` position — so the un-hide check, which re-derived the
same cached fact instead of tracking what it had actually done, saw
"not in-flow" and skipped the removal. The clip stayed `display: none` (and
therefore zero-sized) for the rest of the render.

Tracks whether this code applied `display: none` to a given element in its
own WeakSet instead of re-deriving the in-flow cache, so the un-hide check
can no longer disagree with the hide check that set it.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-13 03:58:12 +00:00
Miguel Ángel 58707afaa0 fix(producer): reserve AAC correction headroom (#3693) 2026-09-13 03:57:18 +00:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) 2026-09-12 13:03:01 -04:00
Vance IngallsandClaude Fable 5.1 4ba8396c27 chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:30:23 -07:00
Miguel Ángel 26513984f3 fix(producer): non-DE parallel-stream router silently refused on macOS multi-worker renders (#3886)
* fix(producer): read useDrawElement post-clamp for the parallel-stream router

The non-DE parallel-streaming router read cfg.useDrawElement before the
default-on drawElement clamp ran, so on every macOS/GPU-default host with
multi-worker renders it saw a stale true and refused to stream even though
the render was about to fall back to non-DE screenshot capture anyway.

* refactor: trim fix-narrating comments to durable invariants

Adversarial simplify pass flagged two comments that described the diff
itself (what used to happen, what changed) instead of the invariant that
matters going forward. Kept only the forward-looking reasoning.

* fix(producer): capture probe console before closing on the DE clamp path; add regression coverage

- Extract the default-on drawElement clamp condition into a named, exported,
  unit-tested pure predicate (shouldClampDefaultDrawElement), matching this
  file's existing convention for the sibling router predicate.
- Preserve lastBrowserConsole when the clamp closes a drawElement probe
  session, matching every other probe-close site in this function (an
  adversarial review caught this: on the auto-worker-calibration path, a
  render that throws after the clamp would report the calibration browser's
  console instead of the probe's).
- Add tests proving the router requires the clamp's post-clamp output, not
  its input — the exact ordering bug PRINFRA-689 was about.

* test: drop internal link and fix-narrating comment from new regression test

A test comment I added carried an internal Slack archive link (this is a
public repo) and described the diff instead of the invariant — the user
caught it in review after my own simplify pass had already run and missed
it, since that pass ran before this test file existed.

* refactor: collapse duplicated ordering-invariant comment into one place

Two comment blocks explained the same 'why deParallelStreamForced alone is
safe here' reasoning, one forward-referencing the other. Stated once.
2026-09-11 17:06:32 -04:00
Vance Ingalls c93c765403 Merge pull request #3805 from heygen-com/fix/prinfra-600-fresh-screenshot-fallback
fix(render): retry drawElement failures on a fresh screenshot page
2026-09-10 17:25:49 -07:00
Miguel Ángel 59def1b66d chore(release): v0.8.34 (#3854) 2026-09-10 14:45:17 -04:00
renovate[bot]andJames eae4892ae8 chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security]

* fix(test): preserve test behavior on Vitest 4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-10 13:55:52 -04:00
Vance Ingalls 6a206ffea6 fix(render): retry drawElement failures on a fresh screenshot page 2026-09-09 22:54:33 -07:00
Miguel Ángel db51d5479d fix(ci): generated files never read half-written, lint scaling test runner-proof, probe cache test off disk (#3834)
* fix(build): stop generated files from being read half-written during the build

Two unrelated pull requests kept failing CI for reasons that had nothing to do
with their changes.

Typecheck failed with "TS1002: Unterminated string literal" pointing at a
generated file. The root build compiles several packages at the same time, and
more than one of them regenerates files under a package's src/generated while a
sibling's tsc is already reading them. A plain write empties the file and then
refills it, so for a few milliseconds what is on disk is the first half of the
new file. Whichever build read it in that gap saw a truncated file and stopped.
It never happened locally because locally nothing else is reading.

Every generator now writes the new version under a temporary name and then
renames it over the target, which the filesystem does in one step. A reader
either gets the whole previous version or the whole new one; there is no moment
where it can see half of either. Content that has not changed is not rewritten
at all, so repeat builds no longer touch these files. Four generators were
affected and all four now go through one shared helper, which is where the rule
lives from now on.

The build also rebuilt the core package a second time, in parallel with the
packages that read it. That second rebuild was already redundant, and removing
it takes the writers out of the window entirely. Renaming is what makes the file
safe; dropping the duplicate build makes the build shorter as well.

Separately, a linter performance test failed on four of the last ten failed runs
on main. It timed a scan with a stopwatch and demanded the result come in under
two seconds; one run took 3.7s. That is a statement about how busy the shared
runner was, not about the code, because a stopwatch also counts the time the
machine spent running someone else's job. The test now counts only the processor
time this process actually used, and checks that the cost grows in step with the
input rather than against a fixed number of milliseconds. Measured on a machine
under load, the stopwatch ratio for the same input reached 45x while the
processor-time ratio stayed at 20x.

Both fixes come with a check that fails if the fix is removed.

* fix(build): keep the shared write helper inside the core package

The helper the generators call had been placed in the repo-root scripts folder.
Every container image that builds a package copies the packages folders whole
but cherry-picks root scripts one file at a time, so the image builds failed on
a missing module. The repo already shows both halves of that convention: the one
root script a package build imports has a matching copy line in the image, and
cross-package imports into the core package need none.

The helper now lives with the core package's other build scripts, and the one
generator outside that package reaches it the way the engine package already
reaches core.

* test(engine): keep the probe cache bound test off the filesystem

The eviction test wrote, stat'ed and deleted 129 temp files to exercise an
in-memory LRU rule. Its runtime tracked filesystem contention rather than
the code under test, and on a busy Windows runner the file churn alone
pushed a ~300ms test past the 5s timeout, failing unrelated pull requests.

Cache identity comes from stat, so the test now synthesises stat results
and never touches disk. While here, the test also asserts the LRU touch:
re-probing an entry before the bound is hit must keep it resident and
evict the next-oldest one instead. The previous shape never hit the cache
during the fill, so that branch was untested.

* test(lint): keep the scaling check inside the scanner's linear range

The CPU-ratio version sampled 320k characters, where the output string's
own growth dominates and the whole test cost seconds of CPU; on a shared
runner that tripped the default test timeout, the failure this change
exists to remove. Sample 10k and 80k characters instead, where 8x input
measures ~8x and a quadratic scan still measures 60x or more, and give
the test an explicit timeout so a slow runner reports the ratio.

* test(core): snapshot file identity through a descriptor

The before/after inode and mtime checks read the file through a path stat
and then exercised the writer on the same path, which reads as a
check-then-use race to static analysis. Snapshot through an open
descriptor instead; the assertions are unchanged.
2026-09-10 03:08:58 +00:00
James Russo ff749b8705 fix(security): verify CDN script integrity before inlining (#3822)
* fix(security): verify CDN script integrity before inlining

* fix(producer): normalize SRI algorithm case

* fix(producer): abort compilation on integrity mismatch

* fix(compiler): preserve nested script integrity requirements

* fix(compiler): defer local inlining until integrity is known
2026-09-09 19:37:00 -04:00
Vance Ingalls d0ee207b06 fix(render): retry timed-out browser initialization once 2026-09-08 23:40:09 -07:00
James Russo 73e791178c fix(producer): isolate regression harness temporary roots (#3799) 2026-09-09 00:45:23 -04:00
James Russo c7891a9364 fix(producer): isolate assembly scratch directories (#3798) 2026-09-09 00:02:41 -04:00
miga-heygenandMiguel Ángel 6e3308be4f chore: release v0.8.33 (#3796)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 22:12:51 -04:00
miga-heygenandMiguel Ángel 662f96b3f4 chore: release v0.8.32 (#3788)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 19:40:29 -04:00
Miguel Ángel 0ce0bb13eb fix(runtime): preserve fractional final-frame visibility (#3696)
* fix(runtime): preserve fractional final-frame visibility

* test(runtime): cover repeated nested fractional tails
2026-09-08 18:11:40 +00:00
Miguel Ángel 01744f2b0c fix(media): unify nested start coordinate mapping (#3732) 2026-09-08 18:10:51 +00:00
Miguel Ángel 5c44cd212c fix(render): probe runtime media source mutations (#3729) 2026-09-08 18:10:34 +00:00
Miguel ÁngelandVance Ingalls 9308eadcfc fix(render): bound and recover capture stalls (#3700)
* test(producer): pin unpinned drawElement stall retry

* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing (#3172)

* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing

A single drawElement frame could kill an entire render. On one comp
(caption-editorial-emphasis) drawElementImage returns normally and the renderer
then stops draining its task queue: the setTimeout(…, 0) that schedules
toDataURL never fires, the capture page.evaluate never settles, and 60s later
the stage watchdog fails the whole render. Deterministic on 152.0.7977.30,
always the same frame. Root-cause detail in PRINFRA-488.

Two gaps, both closed here.

1. Nothing below the 60s stage watchdog bounded a frame. Adds a per-frame
   deadline (HF_DE_FRAME_TIMEOUT_MS, default 15000, 0 disables) around the WHOLE
   frame operation, not just the drawElementImage call — the stall surfaces at
   whichever page round-trip comes next, and was observed at both the capture
   and the seek's background-image decode. Bounding one call missed it.

   Deliberately NO per-frame screenshot fallback for this error: once the
   renderer stops scheduling it is wedged for every subsequent round-trip on
   that page. Measured — the screenshot fallback blew the same deadline. The
   frame fails fast instead, so the recovery that actually works can run.

2. That recovery was ineligible. shouldRetryViaPinnedFallback only retried a
   generic capture failure when the worker count was PINNED by the inversion or
   the router; a comp that engaged drawElement on the ordinary single-worker
   path had no whole-render fallback at all. A renderer stall is now retryable
   on any routing — the failure is a property of drawElement itself, and the
   retry re-renders on a fresh page via screenshot. Reported as
   de_fallback_reason "de_renderer_stall" so it is distinguishable from
   capture_error in telemetry, and counted per-session as deFrameTimeouts.

Verified end to end on the repro: previously exit 1 with "stalled: no frame
progress for 60000ms"; now the deadline fires, the render retries via
screenshot, and RENDER_OK with a valid 1920x1080 / 240-frame / 8.0s MP4.

Tests cover the predicate both ways plus the cross-package error match. Engine
1,481 and producer 587 green (audioPadTrim.integration flakes only under
full-suite parallel load — passes in isolation, and on clean HEAD it is green
too, so it is not from this change).

Refs PRINFRA-488

* fix(engine,producer,cli): count drawElement frame timeouts where they happen

`session.deFrameTimeouts` was incremented inside `captureFrameCore`'s catch,
but the deadline races that function from OUTSIDE it — `withFrameDeadline` is
applied by `captureFrameToBuffer`, and `DeFrameTimeoutError` is constructed
nowhere else. So the branch was unreachable: on a wedged renderer the counter
stayed 0, the engine-side stall log never printed, and
`CapturePerfSummary.deFrameTimeouts` reported 0 on every affected render. The
end-to-end recovery worked throughout — `isDeRendererStallError` matches by
name at the producer — only the observability was dead.

Moved the increment and the log into an `onTimeout` hook that fires from the
deadline itself, deleted the unreachable branch and the now-unused
`isDeFrameTimeoutError`, and threaded the count out to
`render_completed.de_frame_timeouts` so the rate is graphable apart from the
other `capture_error` fallbacks. A stall always costs a whole-render re-run,
which is why it deserves its own series.

Also corrected the `fallbackReason` docstring, which still listed only
`psnr | blank | oom | capture_error` after this branch added
`de_renderer_stall`.

`withFrameDeadline` is exported for a two-case fake-timer test: the deadline
fires and calls `onTimeout` once, and a resolving inner promise passes its
value through with no late timer. The first case fails if the counter moves
back inside the work promise.

(cherry picked from commit ec2b2b5b2a)

* fix(render): retry sequential screenshot stalls

* fix(render): bound parallel worker capture phases

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
2026-09-08 18:10:28 +00:00
heygengenesis[bot]andmiguel.sierra e02722098e fix(engine): isolate static dedup verification seeks (#3768)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-08 17:56:25 +00:00
James RussoandAnton Sidorov aka anticodeguy b9aae16d6d fix(producer): try Windows junctions before copying cached frames (#3740)
Follow up Anton Sidorov’s junction staging contribution in #2314 on current main.

Co-authored-by: Anton Sidorov aka anticodeguy <a@anticodeguy.com>
2026-09-07 15:52:24 -04:00
Miguel Ángel 30d6f43bdb chore: release v0.8.31 (#3747)
* chore: release v0.8.31

* docs(release): describe the range fix on its own terms
2026-09-07 12:12:35 -04:00
James Russo f1d0c2e553 fix(producer): pin lint entry reads to checked descriptors (#3734)
* fix(producer): pin lint entry reads to checked descriptors

* test(producer): replace lint entry paths portably
2026-09-06 00:12:41 -04:00
Miguel Ángel 3874990449 chore: release v0.8.30 (#3733) 2026-09-05 23:14:12 -04:00
James Russo be86a1ec7d fix(render): serve engine and producer files through checked descriptors (#3725)
* fix(engine): read served files through checked descriptors

* fix(producer): retain checked files through streamed responses
2026-09-05 21:38:42 -04:00