Commit Graph
911 Commits
Author SHA1 Message Date
Miguel Ángel a4eb602ee2 chore: release v0.7.93 (#3044) 2026-08-04 20:27:07 -07:00
Varo b390b71bde fix(engine): keep BeginFrame time monotonic across frame rates (#3011)
Fixes #3012

## What

Fixes `HeadlessExperimental.beginFrame` hanging at 60fps and other high frame rates.

## Why

Warmup always advances Chrome in 33ms steps, but the first capture timestamp was calculated from the output fps.

At 60fps, the last warmup tick is 1947ms, while the next commit tick used to jump back to about 1067ms. Chrome sees time moving backwards and `beginFrame` can stall.

## How

Keep the old timestamps when they are already safe. If the commit tick would go backwards, move the capture baseline past warmup first.

The init commit and both producer probes now use the same timestamp helpers, so they cannot calculate different values.

## Test plan

- [x] Added tests for 24, 30, 31, 32, 33, 60, 120, 240, and 59.94fps
- [x] Added coverage for the actual session baseline, commit parameters, and both producer probe paths
- [x] Engine tests: 1383 passed, 3 skipped
- [x] Producer unit tests passed
- [x] Engine and producer typechecks and builds passed
- [ ] Manual render test
2026-08-04 19:22:33 -07:00
James Russo bbdfee1166 fix(engine): validate remote download integrity (#2938) 2026-08-04 18:02:01 -07:00
Vance Ingalls f9ec93459f Merge pull request #2945 from heygen-com/ffprobe-6-argv-sweep
fix(cli,core,lint,producer,studio-server): terminate ffprobe options everywhere, pin the contract
2026-08-04 10:43:14 -07:00
Vance IngallsandClaude Opus 5 1664fe6ad7 fix(core,producer,skills): unicode paths, non-Error rejections, shell callers
Three R3 findings.

The redactor's segment classes were ASCII `\w`, so `/数据/客户/秘密视频.mp4` and
`/data/客户/secret.mp4` went out verbatim — and the generic redactor also feeds
CLI telemetry and producer observation messages, where no known-path list
compensates. Segments are now defined by their delimiters instead of an
alphabet, which is correct for every script by construction rather than
requiring Unicode classes to be kept correct. The bare-relative lookbehind had
the same ASCII assumption and let a match start mid-token, redacting
`客户/秘密/视频.mp4` to `客户[path]`; it is now a token boundary, and
bare-relative runs before absolute so it claims the whole token.

sanitizeProbeFailure cast the rejection reason to Error and read `.message`.
An injected probe can reject with anything, so `Promise.reject("failed")` gave
`undefined` and threw inside the redactor — converting a returned failure
result into a rejected promise. Normalized at the boundary, and
redactKnownPaths no longer throws on a non-string.

The contract only admitted .ts/.js/.mjs/.cjs, so it missed shipped shell and
Python callers. frame_strip.sh passed a user-controlled path as ffprobe's last
positional with no terminator; render-and-composite.sh had four more. Both
fixed, and the sweep now covers .py/.sh. Python list argvs are bracket
literals so they get the same position check; shell command lines get a
separate presence check, because checking position there needs a shell parser
— stated as the weaker guarantee it is rather than implied to be equal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:18:21 -07:00
Vance IngallsandClaude Opus 5 6c5403f7cd fix(producer): drop the ReDoS-prone literal-argv regex for a linear scan
CodeQL flagged js/redos on the all-literal argv matcher. It was right: the
`(?:"[^"]*"\s*,?\s*)+` form nests a quantifier inside a quantifier with an
optional separator, so whitespace can be matched two ways and a long
non-matching argv backtracks exponentially.

Replaced with a linear scan — find the spawn head, slice to the closing
bracket, and check the entries — plus small named helpers. Same behaviour: an
all-literal argv is treated as taking no input, an argv with a bare identifier
still has to be understood (verified by adding one and watching the guard fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:41:39 -07:00
Vance IngallsandClaude Opus 5 c81f68b592 test(producer): treat an all-literal probe argv as taking no input
CI runs the PR merged with main, so it saw a caller my branch predated:
`spawnSync("ffprobe", ["-version"])` in engine/src/utils/ffprobe.test.ts. That
is a capability check with no runtime path, so there is nothing to terminate,
but the unclassified guard flagged it as a caller it could not parse.

An argv whose entries are all string literals carries no input by
construction. Those are dropped before the check; an argv with a bare
identifier still has to be understood, verified by adding one and watching the
guard fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:29:19 -07:00
Vance IngallsandClaude Opus 5 255cf92915 fix(skills,producer): terminate ffprobe options in shipped skill scripts
The contract test only walked packages/*/src and only .ts, so it could not see
the shipped agent tools under skills/**, which are .mjs/.cjs. 19 call sites
there and in package tests were still missing `--` immediately before the
input while the suite reported the bug class closed — a dash-prefixed filename
is parsed as an option and fails the same way.

Sweeps packages/, skills/ and scripts/ now, including .mjs/.cjs and test
files (dither.test.mjs was one of the broken sites). Excludes only the
contract test itself, which documents the contract with example argvs
including a deliberately misordered one.

Two guards were fixed while widening: the terminator must never be inserted
after `-i`, which consumes the next token (a blind pass hit an ffmpeg input
and a base64 -i), and comment prose describing a spawn is not a spawn.

Also routes every audioPadTrim probe failure through one sanitizer at the
boundary. runFfprobeJson scrubbed its own stderr, but
defaultProbeVideoFrameInfo threw `no video stream in ${videoPath}` raw into
the public PadTrimAudioResult.error, and an injected probe can throw anything.
The redaction unit tests all passed with the caller wiring deleted; the new
public-path regressions fail without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:35 -07:00
Vance IngallsandClaude Opus 5 e79ab3ab31 fix(core,producer): redact bare relative paths and the known input path
The generic scrub still missed a relative path with no `./` prefix:
`customer/acme-secret/video.mp4` and `assets/bgm.mp3` reached telemetry
completely unredacted, because the absolute rule needs a leading slash and
the `./` rule needs the dot. Adds a rule for them that still leaves `N/A`,
`24/1` and `48000/1001` alone.

Shape matching is a net with holes by construction, so audioPadTrim now
also redacts the exact path it put in the argv, plus its basename, before
the generic scrub runs. It built the argv, so it does not have to guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:35 -07:00
Vance IngallsandClaude Opus 5 91a7cb1f5b test(producer): discover ffprobe callers and pin terminator position
The prior contract test scanned a hardcoded file list for a format flag
followed by a bare identifier, so it only matched the shape it was written
against. Mutation testing showed removing `--` from engine/utils/ffprobe.ts
and cli/commands/init.ts did not fail it.

Now walks packages/*/src and finds callers itself, checks that `--` is
immediately BEFORE the input rather than merely present, and compares
discovery against a manifest so a regex regression cannot silently stop
checking a known caller. A separate guard fails on any file that spawns a
probe binary but builds an argv this test cannot parse.

All 11 seams mutation-tested for both removal and misordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:34 -07:00
Vance IngallsandClaude Opus 5 d0dbf11ef5 fix(producer,studio-server): finish the ffprobe argv sweep, pin the contract
The previous commit claimed "all nine now terminate their options". That
was false: `producer/src/utils/audioRegression.ts:307` still passed the
path bare, and it is production source used by the regression harness.
A repo-wide audit found two more in studio-server
(`mediaValidation.ts`, `mediaMetadata.ts`) — their current callers pass
absolute paths, so they were defence-in-depth rather than live bugs, but
the exhaustiveness claim should be true rather than narrowed.

Eleven sites total, all terminated.

Adds a SOURCE-level contract test, which is the gap that let this
happen twice. #2740 fixed one of ten sites and shipped a regression
asserting the argv of that single site, so CI reported the class closed
while nine invocations still parsed `-intro.mp4` as an option. A
per-site unit test has the same blind spot for site twelve; scanning the
tree does not. The test also asserts its own coverage list has not
shrunk.

Verification: engine 1300, lint 511, core 1431, studio-server 398, cli
init/webmAlphaCheck/whisper 146, producer utils 51, audioPadTrim 18.
Removing any single terminator fails the contract test by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:34 -07:00
Vance IngallsandClaude Opus 5 47564ab94c fix(cli,core,lint,producer): terminate ffprobe options at every call site
#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:34 -07:00
Vance Ingalls df8b9604fe chore: release v0.7.92 2026-08-04 01:49:05 -07:00
Vance Ingalls 8c6cf90ff9 chore: release v0.7.91 2026-08-03 22:48:00 -07:00
Miguel Ángel 4e7fcf7f2a fix(core): resolve sub-composition sibling asset paths everywhere (#2994)
Extends the studio-preview fix to the render path and the asset-discovery
utilities, which share the same resolver and had the same defect.

`rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref
authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the
composition's own directory when that sibling exists on disk; project-root refs
with no sibling (the registry's `assets/logo.png` convention) stay as authored.
Callers that can see the filesystem supply the probe, so the module stays free
of node:fs.

Also fixes a second defect in the inliner: `<head>` <link> hrefs and external
script srcs are hoisted into the root document but never went through the
rewrite at all, so even the documented `../` form escaped the project and 404'd
at render time.

Wired into the preview bundler, the producer compiler, the studio preview
builder, the HEVC preview lint, the project lint's asset scans, publish proxy
baking, and media-treatment source resolution.
2026-08-03 21:13:35 -07:00
James Russo 127eb19371 fix(producer): correct short VFR frame coverage (#2936)
* fix(producer): correct short VFR frame coverage

* fix(engine): keep VFR extraction seek-local

* fix(engine): match ffmpeg decimal frame boundaries

* fix(engine): preserve exact extraction frame rates

* fix(engine): key frame cache by exact rate
2026-08-03 18:42:03 -07:00
James Russo 9792c32950 fix(producer): reject asset media type mismatches (#2937)
* fix(producer): reject asset media type mismatches

* fix(engine): document read-only AVIF probe

* fix(engine): bound read-only AVIF brand probe

* fix(producer): make media preflight lifecycle-safe

* fix(producer): reconcile runtime media before preflight

* fix(engine): avoid writable file-open detection

* fix(producer): close runtime media preflight gaps
2026-08-03 18:16:41 -07:00
James Russo 1e51eaec2c chore: release v0.7.90 (#2958) 2026-08-02 21:57:19 -07:00
James Russo 2339757377 fix: bound HDR and video extraction resources (#2955)
* fix: bound HDR and video extraction resources

* fix: trim negative video extraction preroll

* fix: skip invisible video extraction windows

* fix: preserve negative-start loop and held tails

* fix: cap finite video slots to source duration

* fix: bound held-tail frame extraction

* fix: plan from playable video duration

* fix: preserve open-ended held video tails

* fix: resolve held tails from decoded frames

* fix: normalize final-frame probe timestamps

* fix: handle unseekable final-frame sources

* fix: dedupe final-frame probes per render

* refactor: clarify output dynamic range contract
2026-08-02 20:21:54 -07:00
Miguel Ángel 411ada0d90 chore: release v0.7.89 (#2953) 2026-08-02 20:25:14 +02:00
Vance Ingalls 74fadf69c4 chore: release v0.7.88 2026-08-01 16:53:08 -07:00
Miguel Ángel 89e970fbd7 chore: release v0.7.87 (#2935) 2026-07-31 22:36:46 +02:00
James Russo 3a6b7f0612 fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior

* fix: address WebGPU capture review feedback

* fix: retain overlapping GPU seek work

* fix: satisfy runtime seek completion types

* fix: drain concurrent GPU seek work

* fix: prevent WebGPU capture barrier starvation

* fix: keep WebGPU presentation active during render seeks
2026-07-30 21:52:30 -07:00
James Russo 1d636f603c refactor(producer): share plan execution builder (#2906)
## What

Refactor distributed planning around one shared local execution-plan builder:

- `buildLocalExecutionPlan()` now owns compile/probe/extract/audio/freeze.
- Legacy `plan()` remains a deprecated v1 transport wrapper.
- Plan v2 calls the shared builder directly and publishes through the existing manifest/CAS contract.
- Add neutral `createPlanV2FromExecutionPlan()`, `publishPlanV2FromExecutionPlan()`, `getPlanV2ExecutionPlanHash()`, and `PLAN_PROTOCOL_V1` names.
- Retain deprecated v1-named exports and wire aliases.
- Recommend explicit Plan v2 opt-in for new producer, Lambda, and Cloud Run integrations.

## Why

Plan v2 previously looked like it invoked a v1 planner even though v1 and v2 share the same frozen local execution representation. This removes that migration-era coupling while preserving the public minor-version compatibility contract.

## How

The shared builder returns neutral internal execution-plan fields. The v1 wrapper maps those fields back to the existing `PlanResult`; the v2 publisher consumes them directly.

Compatibility is intentional and covered by exact shape tests:

- omitted `planProtocol` still serializes/selects `"v1"`;
- v1 layouts, descriptor-less decoding, event unions, workflow branches, and exports remain;
- the v1 descriptor JSON is byte-identical and `CURRENT_PLAN_PROTOCOL` is an identity-preserving alias;
- v2 manifest bytes, key order, hash framing, and `sourcePlanV1Hash` wire key remain unchanged;
- no enumerable neutral hash field was added to manifests or returned result objects;
- v1/v2 result objects, cloud event payloads, and SDK handle key sets remain unchanged.

## Test plan

- Focused Plan v1/v2/protocol/export/size compatibility: 141 passed
- `@hyperframes/core`: 1,419 passed
- `@hyperframes/producer` unit lane: 990 passed
- `@hyperframes/aws-lambda`: 140 passed
- `@hyperframes/gcp-cloud-run`: 101 passed
- Producer, Lambda, and Cloud Run typechecks
- Repository-wide lint, format check, workspace/package-subpath checks
- Full workspace build
- `git diff --check`

- [x] Unit tests added/updated
- [ ] Manual testing performed
- [x] Documentation updated (if applicable)
2026-07-30 17:41:02 -07:00
Somansh ReddyandClaude Opus 4.8 5a6e4b1a8f refactor(lint): consolidate asset-src placeholder skip into a shared predicate (#2894)
* refactor(lint): route asset-src skips through a shared isUnresolvedAssetPlaceholder predicate

Follow-up to the templating-token fix. The __UPPER__ + templating-token skip was
copy-pasted across the asset-src sites and had drifted: two non-lint sites carried only
the __UPPER__ half, and htmlCompiler's comment still claimed it "matches lint's skip"
after lint's skip became a superset. Extract one isUnresolvedAssetPlaceholder(rawSrc) in
@hyperframes/parsers/asset-resolution (both placeholder shapes, checked on the raw value)
and route every site through it: the four project.ts lint sites, hevcPreviewLint, and the
two previously-missed post-substitution sites (studio-server mediaCodecMap, producer
htmlCompiler). Remote/inline handling stays per-site (audio uses a narrower check).
Behavior-preserving for the lint sites (full suite green); the two non-lint sites are
post-substitution so they don't false-positive today, but now share one definition and
can't drift again. Adds unit tests for the predicate.

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

* docs(parsers): refresh hasUnresolvedTemplatingToken aside for the shared predicate

The parenthetical said the __UPPER__ shape keeps its own inline check at each call
site; this branch folded it into isUnresolvedAssetPlaceholder, so point there instead.
Addresses review nit.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:52:10 -07:00
Vance Ingalls 12ee861123 chore: release v0.7.85 2026-07-30 10:38:10 -07:00
Vance Ingalls 065293ecf3 chore: release v0.7.84 2026-07-30 01:39:54 -07:00
Vance IngallsandClaude Opus 5 042d5aaba6 fix(producer): strip script bodies to a fixed point, not one pass
CodeQL (incomplete multi-character sanitization, code-scanning/803) on
the script-stripping regex added in c61a24b51 — a failing check, and
correct: a single replace can reform the pattern it just removed, since
`<scr<script>ipt>` leaves a whole `<script>` behind.

The security framing does not apply — the stripped string is counted and
discarded, never rendered, inserted, or served — but the incompleteness
is real for this use: a reformed tag survives into the match pass and
perturbs the element count the routing gate reads. Suppressing a gate
over a technicality when the fix is four lines is the wrong trade.

Now loops to a fixed point. Terminates by construction: each iteration
either strictly shortens the string or changes nothing and exits.
Regression covers the reform case and an unterminated `<script>` that
must not spin; fault injection confirms the reform test fails under the
old single pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:13:42 -07:00
Vance IngallsandClaude Opus 5 c61a24b510 fix(producer): unbias the static element count and stop zeroing failures
Review findings on #2891. Two of them bite directly on this PR's own
purpose — making the fleet element-count distribution readable — so they
are fixed rather than noted.

countElementTags counted `</` + letter anywhere, including inside inline
JS. A compiled comp containing `const h = "</div>"` or a template literal
building `</span>` inflated the count once per occurrence. Compiled comps
embed large inline scripts, so the bias is systematic, not noise, and it
lands entirely on the ~83% of renders with no probe session — precisely
the cohort this PR exists to characterize. Script and style bodies are
now stripped before matching; losing their own closing tags costs 1-2
counts against a threshold in the thousands.

The new elementCount fell back to 0 when its page.evaluate threw,
following the tweenCount pattern beside it. For this field that pattern
is wrong: evaluate failures concentrate on the huge-DOM compositions the
field is meant to observe, and a 0 there is indistinguishable from a
legitimately empty comp, so the fleet p50/p99 would absorb both silently.
It is now undefined on failure, the INIT console line omits the token
entirely rather than emitting a zero, and the parser reports absent —
mirroring the live/static provenance split the routing resolver already
uses.

Also documented: the "every render reaches this path" claim holds only
for renders that survive to end of init, so the tail is survivor-biased
and should be read as a lower bound; and the two element-count fields now
say plainly which is which — composition_element_count gates routing,
observability_init_element_count is the observational counterpart — so
the follow-up analysis can't query the wrong one.

Nits: envInt is integer-only per its name, both live-DOM reads use
getElementsByTagName (live collection length, no NodeList materialized on
the 40k-node tail), and the attribution block notes that it runs with
routing off by design.

Fault injection confirms the new tests bite: disabling script stripping
fails 4, and the zero-vs-undefined case is pinned separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:51:14 -07:00
Vance IngallsandClaude Opus 5 d74afc7b7d feat(engine): measure live DOM size on every render, not just probed ones
The short-comp routing gate can only read a live element count when a
probe session exists, and the first v0.7.83 data shows that is far rarer
than estimated: 17% of renders (86/503), not the ">=28%" the video-presence
proxy suggested. The other 83% fall back to a static source scan, which
is exactly blind to the shape that motivated the live count — small
markup, thousands of script-created nodes.

That leaves the fleet element-count distribution unknowable for most
renders, and the observed distribution is already surprising: p99 ~900,
max 1,420 against a 2,500 ceiling calibrated on 7k/20k/40k synthetic
nodes. Either the ceiling is close to irrelevant, or the large-DOM tail
is hiding in the 83% we cannot see. Both readings change what PR B
should do, and neither is decidable from probed renders alone (they are
a biased sample — they got a probe *because* they carry media or
unresolved compositions).

So measure it where every render already goes: capture-session init.
`collectSessionInitTelemetry` gains a querySelectorAll("*") count beside
the tween count it already collects, riding the same channel to
`observability_init_element_count`. This is observational only — capture
has begun, far too late to route on — and it deliberately does not feed
the gate. It answers the distribution question the gate cannot.

Coverage for this channel is proven rather than assumed: the tween-count
fix that shipped in v0.7.83 took the clamped-parallel bucket from 0/272
renders to 217/217, and 23.1% -> 100% overall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:55:12 -07:00
Vance Ingalls 5244dde5f1 chore: release v0.7.83 2026-07-29 18:37:58 -07:00
Vance Ingalls b2e7d76d67 Merge pull request #2875 from heygen-com/feat/de-short-inversion
feat(producer): short-comp DE inversion band — baseline release (telemetry only, routing off)
2026-07-29 17:37:57 -07:00
Vance IngallsandClaude Opus 5 4dbf0d90b0 fix(producer): fail closed when no live element count is available
R4 review finding, and the comment I wrote in R3 was simply wrong: the
probe session is NOT running for every render. probeStage's needsBrowser
gate launches one only for unknown duration, unresolved compositions, or
specific media cases — and hasRuntimeInsertedMedia matches only
createElement("video"|"audio"), never createElement("span"). So the exact
shape that motivated the live-DOM fix (a known-duration, media-free
caption comp building thousands of nodes in script) gets NO probe, falls
back to the static source scan, reads as ~2 elements, and could enter the
applied cohort at 40k live nodes. The R3 fix measured the right thing but
only for the population that already had a probe.

Now the count carries provenance and the band fails closed:

- resolveCompositionElementCount returns { count, source: "live" |
  "static" }. Only "live" — an actual DOM measurement — may open the band.
- resolveDeShortBand gains a third decisive outcome, "unmeasured", for
  the static case. It deliberately does NOT report skipped_elements: a
  static undercount is not a real oversize observation, and putting it in
  the control arm would contaminate the DiD just as putting it in the
  treatment arm would. Neither cohort; never routes.
- composition_element_count_source ships alongside the count, so the
  fleet rate of "static" sizes the population a future
  conditional-probe-launch would unlock — which is the data PR B needs to
  decide whether that launch cost is worth paying.

Regression coverage walks the real chain rather than a full render, using
the production functions in pipeline order: probeRequiresBrowser (newly
extracted from the inline needsBrowser expression, so the gate is
testable at all) returns false for the caption-comp shape → the resolver
reports static and a count under the ceiling → the band reports
unmeasured, not applied. Fault injection confirms it bites: removing the
one guard line fails exactly these three tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:02:26 -07:00
Vance IngallsandClaude Opus 5 def98f79c3 fix(producer): count live DOM, not source markup, for the short-band gate
R3 review finding: a string scan of compiled.html — however the regex is
tuned — cannot see elements a composition's own script creates at
runtime. The repo already has a production shape that hits this exactly:
style-10-prod's per-transcript-word caption generator builds one <span>
per word via document.createElement, measuring 2 source tags against
thousands of live nodes after init. That's the same unbounded-undercount
failure class as the earlier <img>/SVG counterexamples, but this one
has no static-scan fix — the elements simply don't exist as tags in the
string.

resolveCompositionElementCount() now prefers the live DOM size, queried
from the probe session that's already running for every render at this
point in the pipeline (its Chrome gets reused for capture on the common
single-worker path, so this costs one extra CDP evaluate, not a browser
launch) once that session's init sequence has completed — session.page
.evaluate(() => document.querySelectorAll("*").length) sees runtime-
generated DOM the source scan never could. countElementTags remains as
the fallback for the rare case with no initialized probe session
(evaluate throws, session absent, or not yet initialized).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:40:27 -07:00
Miguel Ángel 87791fd01d fix(producer): keep large local fonts file-backed (#2864)
* fix(producer): keep large local fonts file-backed

* fix(producer): avoid local font file races

* fix(producer): bound local font stream reads

* fix(producer): cache large font file-backed decisions
2026-07-29 22:11:54 +02:00
Miguel Ángel 04e0ccce42 fix: preserve plateaus in sampled audio automation (#2863)
* fix: preserve audio automation plateaus

* fix(audio): align automation probe windows
2026-07-29 20:51:33 +02:00
Vance IngallsandClaude Opus 5 7bb9e3cbf9 fix(producer): address R2 review findings — element undercount and kill-switch attribution
Two review-blocking issues from Miguel's R2 pass (both confirmed by
running the counterexamples directly):

1. countElementTags still undercounted unboundedly. The void-element fix
   covered HTML tags but SVG elements (<circle/>, <path/>, ...) are
   neither closing-tag-shaped nor in the HTML void list, so
   "<circle/>".repeat(40000) reported 0 — the same failure class as the
   original <img> counterexample, and the exact shape of comp the
   measured 1.8x regression case is made of. A 2500 ceiling cannot bound
   an error with no bound of its own. Added a third alternative matching
   any self-closing tag; verified it doesn't false-positive on the
   adversarial minified-JS case (unspaced "<b/c>", which reads like a
   tag open but never contains the literal two-char "/>" the alt requires).

2. HF_DE_SHORT_MAX_ELEMENTS=0 (the documented kill switch) still reported
   deShortBand: "skipped_elements" for every in-band render instead of
   undefined — attributing "comp too large" when the real cause was "band
   disabled," which would have polluted the DiD control cohort with
   kill-switched renders and made the post-flip read look like the
   ceiling was too tight. Extracted the attribution logic into
   resolveDeShortBand(), a pure function gated on bandEnabled
   (deShortBandMaxElements > 0) as well as decisiveness — and made it
   independently unit-testable, since the inline version could only be
   exercised by a full render pipeline run.

Also from this review round: the inversion log line could report
"400 frames >= 900" for a band-routed inversion; it now names the floor
that actually decided the render. Tightened shortBand's type to match its
peer fields' unions (workerInversion, parallelRouter) instead of a bare
string. Clarified the tween-count merge docblock, which claimed workers
always agree (semantically true) while the code takes a defensive max
(in case one doesn't) — the two aren't in conflict, but the comment read
as if they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:26:41 -07:00
Miguel Ángel 6cfb05e38b fix(render): preserve transparency in GIF output (#2327)
## What

- Treat GIF as an alpha-capable output format and capture its frames as RGBA PNGs.
- Encode transparent GIFs with explicit FFmpeg palette semantics: `reserve_transparent=1` and `alpha_threshold=128`.
- Keep page-side shader compositing enabled for GIF while the resulting composite is captured through the RGBA disk-frame path.
- Extend the real render harness to verify decoded GIF alpha and compare a GIF shader-transition frame against the existing MP4 golden.
- Preserve the existing opaque encoder contract: `needsAlpha=false` continues to use JPEG frames without alpha-only palette filters.

## Why

Direct `--format gif` renders silently flattened transparent compositions when frames are captured as JPEG, because the palette encoder receives no alpha plane to preserve.

GIF also needs page-side shader compositing. A blanket `needsAlpha` exclusion disabled that path after enabling RGBA capture, while the layered compositor intentionally excludes GIF. That left shader GIFs on the DOM fallback and produced hard cuts instead of the authored WebGL blend.

## How

- Centralize output alpha detection in `outputNeedsAlpha`, shared by in-process and distributed planning.
- Select PNG or JPEG GIF frame input from the resolved alpha requirement.
- Make palette transparency flags explicit and conditional so the legacy opaque path retains its existing arguments.
- Add an explicit output-format capability for page-side shader compositing: MP4 keeps its opaque streaming path, GIF uses RGBA PNG disk frames, and WebM/MOV/PNG sequence retain their existing paths.
- Add `data-no-timeline` to the static transparency fixture so the artifact regression does not wait for a timeline it intentionally does not register.

## Test plan

- [x] RED on base: direct GIF decoded with an opaque corner instead of alpha 0.
- [x] RED on the previous PR head: the real GIF shader-transition frame scored 11.05 dB against the existing golden because neither shader compositor was active.
- [x] `bun test packages/producer/src/services/render/renderFormat.test.ts packages/producer/src/services/render/stages/encodeStage.test.ts packages/producer/src/services/render/capturePlan.test.ts` — 23 passed.
- [x] `bun run --filter @hyperframes/producer typecheck`
- [x] `bun run --filter @hyperframes/producer build`
- [x] `bun run --filter @hyperframes/producer test:transparency` — WebM, GIF, and PNG sequence alpha assertions passed; GIF shader control/transition frames scored 28.11/26.57 dB against the golden.
- [x] `bun run --cwd packages/producer tsx src/regression-harness.ts page-side-shader-compositor-render-compat --sequential` — all 100 visual checkpoints passed, stream parity passed, and audio correlation was 1.000.
- [x] Changed-file oxlint, oxfmt check, pre-commit checks, and `git diff --check`.
2026-07-29 20:05:42 +02:00
Vance IngallsandClaude Opus 5 23854f7c6a feat(producer): surface init telemetry from parallel workers — the band's missing motion axis
The routing surface the short-comp benchmarks validated is (motion x DOM
size x frames). After the baseline release, fleet telemetry carries DOM
size (composition_element_count) and frames on every render — but the
motion proxy, observability_init_tween_count, has 0% coverage on the
exact renders the band routes: parallel workers' console buffers (and so
the [FrameCapture:INIT] line the summary parses) only propagate to the
orchestrator on FAILURE. Single-worker screenshot renders report it;
the multi-worker clamp bucket never does. Verified against 7d of fleet
data: 35k screenshot renders carry tween counts, 0 of 9,600 band renders.

Fix rides the one channel parallel workers already return on success —
the per-worker CapturePerfSummary. Sessions record initTelemetry on
every init path; the perf summary now carries it; the orchestrator
max-merges across workers (same multi-session semantics the console
parser uses) and feeds it to the observability summary as a structured
fallback, console lines still refining when present.

With this, every band render carries full coordinates — (elements,
tweens, frames, path, speed) — which buys two reads: regressing wild DE
speed against element count on the existing 900+ inversions validates
the bench's 0.50ms/element slope BEFORE the routing flip, and any
post-flip misroute can be reproduced locally by feeding its telemetry
row straight into gen-crossover-comp's knobs (--movers ~ tween count,
--static ~ element count) and re-benching.

(Also drops a now-stale fallow suppression in render.ts — the test-only
reset export it guarded gained real test importers, so the issue it
suppressed no longer exists and the gate flags the leftover.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:54:24 -07:00
Vance IngallsandClaude Opus 5 e9de2fa14f refactor(producer): make the short-comp band baseline-first and its attribution decisive
Recut after pre-registering the read exposed two flaws in the first cut:

1. Attribution was wrong. de_short_band keyed on frame count + element
   ceiling alone, so a webm render, a compile-gated comp, or a forced
   screenshot at 400f reported "applied" while its routing was untouched —
   poisoning the measurement cohort with unaffected renders and diluting
   any effect toward zero. Now the predicate is evaluated twice (900 floor
   vs band floor) and the band is DECISIVE only when the calls disagree:
   every other eligibility condition passed and only the floor differed.
   The cohort contains exactly the renders whose routing the band decides.

2. A same-release flip is unfalsifiable. composition_element_count ships
   WITH the routing change, so the before-period cannot be filtered to the
   same cohort as the after-period — the comparison would show a speedup
   even if the change did nothing (the after-cohort excludes big comps by
   construction; the before-cohort includes them). Routing is therefore
   gated behind HF_DE_SHORT_BAND_ROUTE, default OFF: this release computes
   and emits the full band decision on every render ("applied" is the
   counterfactual "would have inverted"), a follow-up flips the default.
   Identical cohort selector on both sides of the boundary, and the
   skipped/oversize renders in the same frame band form a concurrent
   control — a difference-in-differences that absorbs secular drift
   (content mix, version-correlated populations, hardware), which a plain
   before/after cannot.

Also: countElementTags now counts HTML void elements. Counting only
closers read an image gallery as a tiny comp and opened the band on
exactly the content most likely to lose it (images skew expensive to
paint). Opening tags stay uncounted — inline scripts' `a < b` would
false-positive. Counter semantics are frozen while the baseline is read:
the distribution the baseline release records must be measured by the
same counter that later gates.

Revert-rate baseline for the pre-registered read, measured over 14d
fleet-wide: the 900+ inversion runs 31,756 inverted / 1,705 reverted =
5.1%. At the benched 1.16-1.24x win and ~1.8x revert cost, expected net
for the band is ~12%. Kill criteria for the flip release: DiD <= 0,
in-band revert rate > 5.1% baseline, or band fallback rate > DE baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:02:05 -07:00
Miguel Ángel b904343949 chore: release v0.7.82 2026-07-29 11:25:04 +00:00
Vance IngallsandClaude Opus 5 0749cd9ff8 feat(producer): open the DE single-worker inversion to short comps under an element ceiling
31% of fleet renders (24h, v0.7.78+) are DE-eligible comps clamped to
parallel screenshot purely because they sit under the 900-frame inversion
floor — the median fleet render is ~250-600 frames, below every DE entry
threshold. This opens a 250-899 frame band, gated on composition size.

Measured, not assumed. A controlled sweep (fixed synthetic content,
{250,400,600,900}f, single-DE vs parallel-screenshot-W4, 3 reps, capture
mode verified per row, AC power, load-gated) showed single-DE winning
1.16-1.24x at every size — but only for content in constant motion. A
follow-up 2x2 found motion and DOM size pull in OPPOSITE directions, so
neither alone predicts the winner (ratio = ss4/de1, >1 means DE wins):

     24 movers /     0 nodes -> 1.05
    320 movers /     0 nodes -> 1.24
    320 movers /  7000 nodes -> 1.09
     24 movers /  7000 nodes -> 0.96
     24 movers / 20000 nodes -> 0.71
     24 movers / 40000 nodes -> 0.55

DE's wall-clock scales ~0.50ms/element against parallel screenshot's
~0.22ms — drawElement repaints the whole tree per frame while fan-out
amortizes it — so the downside is NOT bounded and a bare floor drop would
have handed a 1.8x regression to large comps. Since motion only ever helps
DE, an element ceiling calibrated at the lowest-motion case is safe at
every motion level; crossover there is ~3.9k, and the default sits at 2500.

The predicate is untouched; the call site picks the floor. Above the
ceiling, or at 900+ frames, behaviour is bit-identical to today — the
change can only add inversions in the new band, never remove one.

Instrumentation, since this ships at full exposure rather than cohorted:
`composition_element_count` on EVERY render (the fleet distribution of the
gate variable is unknown — without it we cannot tell whether 2500 opens the
band for most short comps or almost none, nor re-derive the threshold from
real content), and `de_short_band` = applied | skipped_elements, unset when
the frame count made the band irrelevant, so a fleet perf shift is
attributable to this change rather than to content mix.

Safety is unchanged and already proven on this path: per-frame PSNR
self-verify with screenshot fallback, exactly as the 900+ band has shipped
default-on. Knobs: HF_DE_SHORT_MIN_FRAMES, HF_DE_SHORT_MAX_ELEMENTS (0
disables the band).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 02:36:40 -07:00
James 0d42d65525 chore: release v0.7.81 2026-07-29 06:11:11 +00:00
James Russo 20f4f8f49c fix(producer): retry transient deterministic font fetches (#2865)
* fix(producer): retry transient deterministic font fetches

* fix(producer): secure Lambda font cache directory
2026-07-28 22:49:03 -07:00
Miguel Ángel 5829515932 chore: release v0.7.80 2026-07-29 02:22:54 +00:00
Miguel Ángel 9bbb6d50a0 fix: offset nested template video timing (#2859)
* fix: offset nested template video timing

* test(producer): cover nested sequential video render

* fix: share canonical nested media timing
2026-07-29 03:36:04 +02:00
Miguel Ángel 4f344c50b0 Merge pull request #2855 from heygen-com/fix/secure-runtime-dependencies
fix: remove vulnerable runtime dependency paths
2026-07-29 01:14:46 +02:00
Miguel Ángel a68729bcf1 chore: release v0.7.79 2026-07-28 23:06:45 +00:00
Miguel Ángel 13ac9e3905 fix: remove vulnerable runtime dependency paths 2026-07-28 20:45:43 +00:00
Vance Ingalls c691869e22 chore: release v0.7.78 2026-07-28 05:27:29 -07:00