Commit Graph
4343 Commits
Author SHA1 Message Date
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>
v0.8.42
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
heygengenesis[bot]andmiguel.sierra b599f031a9 fix(studio-server): make render download headers ByteString-safe (#3984)
Build RFC 6266 Content-Disposition values with an ASCII filename fallback and UTF-8 filename* parameter for each render file response. This preserves inline and attachment disposition semantics while preventing non-Latin-1 filenames from crossing the HTTP ByteString boundary.

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 05:03:28 +00:00
heygengenesis[bot]andmiguel.sierra edb6e87ba7 fix(studio-server): make sub-composition ETags ByteString-safe (#3982)
Hash the decoded sub-composition path before including it in the preview ETag. This keeps ETags deterministic and path-specific while ensuring Hono receives an ASCII-safe header value for Unicode composition filenames.

Add regression coverage for percent-encoded CJK sub-composition paths, including stability and distinctness assertions.

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 04:15:48 +00:00
heygengenesis[bot]andmiguel.sierra ae18e3a327 test(engine): cover held tails in HDR preflight (#3963)
Add regression coverage for finite SDR video slots that start past EOF in mixed HDR timelines, and verify lint exclusions with successful local duration probes.

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 01:27:09 +00:00
terencecho edf6e4b373 chore: release v0.8.41 (#3966) v0.8.41 2026-09-15 17:24:09 -04:00
heygengenesis[bot]andmiguel.sierra f3bd4234f5 fix(engine): hold finite video slots past EOF (#3962)
Allow explicit finite non-looping video slots to use the existing held-final-frame extraction path when data-media-start is at or beyond source duration. Warn during project lint for local static slots that will use this behavior.

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-15 19:10:33 +00:00
heygengenesis[bot]andmiguel.sierra cccc78c65e fix(studio): fail closed on backup failures (#3961)
Fail closed Studio-server mutations when a backup snapshot reports an error. Return the existing backup-failed HTTP 500 response before any raw or structured mutation can change the original.

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-15 18:20:59 +00:00
miga-heygenandMiguel Angel e2d60cf27c fix(engine): keep a render's dependent extraction-cache entry alive during long captures (#3913)
The shared, size-capped video-frame extraction cache evicts entries whose
LRU-clock sentinel hasn't been touched in over an hour. That clock was set
once, at cache-hit lookup time, and never again — so a render holding a
compiled-dir symlink (or a still-open partial writer dir) into an entry for
longer than an hour could lose it to a concurrent render's GC sweep on the
same shared machine, leaving its frame reads a dangling ENOENT with no
retry.

Every active video's captured frame now renews its cache entry's clock,
throttled to once per 5 minutes per directory. touchCacheDir renews both
signals gcExtractionCache actually reads: the directory's own mtime (which
gates a still-open partial writer dir) and the completion sentinel's mtime
(which gates a published entry).

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-09-15 03:12:45 +00:00
terencechoandClaude Opus 4.8 f98b8ead37 fix(studio): guard import.meta.env reads so the player survives non-Vite ESM hosts (#3948)
In non-Vite ESM hosts, import.meta.env is undefined and direct reads crash at module load.

Guard both player reads with optional chaining. Existing Vite defaults are unchanged.

Reject future unguarded import.meta.env property access in either module.

— tai

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 23:40:48 +00:00
James Russo 14ad0c08a3 docs(changelog): weekly digest 2026-09-07–2026-09-14 (#3939)
* docs(changelog): weekly digest 2026-09-07–2026-09-14

Rewrites the generated draft into themed sections. 113 new commits across v0.8.32 through v0.8.40.

Signed-off by Rames.

* docs(weekly-updates): embed Sep 07-14 changelog video

Adds the weekly changelog video to the Week of September 7 digest entry,
matching the DocsVideo embed convention used by the prior weeks.

Video: 57.0s, square 1080, h264 + aac. Verified through CloudFront -
content-length and SHA-256 both match the local render exactly.

- Rames Jusso
2026-09-14 11:56:50 -07:00
James Russo 8886743f44 fix(core): tell an opaque document how to actually get its Web Audio back (#3940)
The opaque-origin fix in #3937 routed correctly but sent the diagnostic down
a dead end. An opaque document reached the same `cross_origin_no_cors` verdict
a genuinely foreign asset gets, so the message told the author to "proxy or
download the asset to a same-origin URL". That is impossible advice, because
nothing is same-origin with an opaque origin and the asset is usually
same-origin by URL already.

Split the verdict into two reasons and give each its own note and remedy. The
opaque case now names the two fixes that work: `allow-same-origin` on the
sandbox, or `crossorigin` plus an `Access-Control-Allow-Origin` of `null` or
`*`. Opacity wins when both conditions hold, since it is the blocking one and
the one an author cannot fix by moving the asset.

The CLI scraper matches on the code prefix only (checkBrowser.ts:309), so the
new reason value does not touch that contract.

- Rames
2026-09-14 11:56:45 -07:00
James RussoandClaude Opus 5 e8cbf0ca85 fix(skills): keep changelog-video's stamped seams off the clip elements (#3946)
The committed changelog-video scaffold no longer survives its own gate. Three
independent breaks, all reproduced against origin/main:

1. `gsap_animates_clip_element` (error severity, shipped this week) flags
   visibility/display/autoAlpha on any clip element. The skeleton's slides are
   `class="slide clip"` and every seam seam-stamp emits writes `autoAlpha` onto
   the ledger's selectors, so a film built from the scaffold fails
   `hyperframes check` before a scene is authored. Per the rule's own fixHint,
   each slide now holds an inner `<div class="swrap">`; the `opacity: 0` base
   moves off `.slide` onto `.swrap`, and the ledger names `#w-*`.

2. The title seam then fails as `exit-visible … op 0.00` while `exit-vector`
   PASSES. seam-stamp emits a zero-duration `tl.set(autoAlpha: 0)` at the cut,
   which immediate-renders at build time, so the exit `.to()` captures 0 as its
   start and tweens 0 → 0. Every other wrapper is saved by its stamped entry
   `fromTo(..., immediateRender: false)`; the title has no stamped entry. The
   scaffold now ships that entry, and build-spec says why the flag is required.

3. `seam-gate.mjs` defaulted `--server-cmd` to `npx --yes hyperframes preview`
   — the published package — while SKILL.md forbids `npx hyperframes@latest`
   for gate 1 and calls the repo-local CLI the contract. The two gates could
   measure different runtimes. It now prefers the repo-local bin, falling back
   to npx only for a copy of the skill living outside the repo.

Also fixes gate 1's own command: `bun run --cwd packages/cli hyperframes` exits
"Script not found" because `hyperframes` is a bin, not a script.

Both mirror dirs updated; `check-skill-mirror` confirms byte-identity.

Reported by the hyperframes session after the Sep 07-14 weekly build.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:56:25 -07: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) v0.8.40 2026-09-14 12:41:47 -04:00
Miguel Ángel 293829a2c1 fix(core): route media through Web Audio only when the document can actually read it (#3937)
isCorsSilenced() compared a media URL's origin against window.location.origin,
which stays URL-shaped even inside a sandboxed iframe without
allow-same-origin. Inside such a frame the document's actual security origin
(window.origin) is the string "null", so same-URL-origin media without a
crossorigin attribute was waved through createMediaElementSource, and per the
Web Audio spec's MediaElementAudioSourceNode security section that node
outputs silence without throwing. The composition kept playing with the
timeline advancing and no audio at all.

Compare against window.origin instead, treating "null" as never same-origin,
so an opaque document falls back to the existing decode-only path unless the
media element opts in with crossorigin.
2026-09-14 12:32:38 -04:00
Miguel Ángel d13a89b670 chore: release v0.8.39 (#3936) v0.8.39 2026-09-14 11:13:53 -04:00
Harim Kang f6041d7597 fix(lint): preserve opening-tag source bounds after multiline closes (#3917)
Capture explicit source name bounds before attributes are parsed. Preserve malformed names, Unicode lowercase expansion, and existing implied-tag ranges while retaining malformed-attribute lint failures.
2026-09-14 07:47:29 +00:00
Santhi Prakash dc6d78b462 fix(cli): assign w{index} ids when loading JSON transcripts (#3770)
loadTranscript assigns id: w{index} on the srt/vtt branches but never on
the JSON branches: parseWhisperCpp and parseOpenAI drop the field and the
words-json branch defaults it to "". Every engine funnels through
loadTranscript, and transcribeAudio rewrites transcript.json from its
output, so CLI-produced transcripts ship without the stable word ids that
transcribe.md documents for caption overrides — per-word overrides have
nothing to key on.

Assign id: w.id || `w{index}` across the JSON branches, matching the
srt/vtt behavior. || also repairs the empty-string ids older CLIs wrote
to words-json files, which otherwise collapse every word onto one key.

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
2026-09-14 07:29:04 +00:00
renovate[bot] 558c11b019 chore(deps): update github-actions (#3932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 07:20:16 +00:00
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) v0.8.38 2026-09-13 22:02:19 -04:00
Miguel Ángel f059a6eb53 feat(cli): default BeginFrame, agent blank, and looks quality (#3927)
* feat(cli): render summary names the capture path, gpu mode and stage timings

* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers

* fix(cli): render slow-path hint fires only when the gpu was auto-probed

* test(cli): isolate agent hint suite from ambient agent env keys

The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.

* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal

Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.

* refactor(cli): share hint-key pattern and collapse render summary args

Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.

* test(cli): lock capture-mode preference and 16-key hint isolation

Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.

* feat(cli): default BeginFrame, agent blank, and looks quality

Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.

* fix(cli): honor screenshot env and teach agent pitfalls on the happy path

Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.

* feat(cli): default init to the centered blank

Bare init scaffolds the centered Inter stage. --agent is a hidden alias.

* fix(cli): pack from-file and use it for video init

TTY --video with no --example selects from-file. Pack copies that template.

* fix(cli): wire --video onto from-file even with --example blank

One resolver picks the scaffold. Spawned init --video asserts a-roll src.

* fix(cli): narrow looks CRF on the quality alias

Only looks carries crf. Typecheck failed on the union without a guard.

* style(cli): shorten render-pipeline summary comment
2026-09-13 21:44:19 -04:00
Miguel Ángel 95bea1631f refactor(skills): keep hyperframes-core as the HTML contract (#3928)
* refactor(skills): keep hyperframes-core as the HTML contract

Move brief, storyboard, review, production, dispatch, and
frame-worker docs to hyperframes. Slim remaining core references.

* fix(skills): restore camera recipes and root sizing

Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent.

* fix(skills): stamp size on the composition root

Runtime sizes the composition root, not html/body. Overlap pin requires is valid.
2026-09-13 21:20:58 -04:00
Miguel Ángel e30992fdb8 Merge pull request #3925 from heygen-com/release/v0.8.37
chore: release v0.8.37
v0.8.37
2026-09-13 13:10:06 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángel cd975d8c28 fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)
`bundleToSingleHtml` documented itself as producing "a single
self-contained HTML file", but `INLINE_MIME` covered only `.svg`,
`.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed
a live project-relative reference.

That is invisible to every consumer in this repo, because each one
serves the bundled string from a server rooted at the project directory,
so the relative paths resolve. It breaks the moment the bundle is stored
on its own, with no sibling asset directory: the font 404s and the page
silently reflows in a fallback face, which is worse than a visible
failure.

Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images
(png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is
measured against this repo's own assets rather than guessed: the largest
of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked
raster images is 2.00 MB, so everything in-tree inlines while a
video-sized file cannot. Oversized assets keep their relative URL and
warn, reusing the existing "may not be self-contained" wording.

Audio and video stay external on purpose: they are large, streamed
rather than laid out, and their absence is obvious rather than silent.
Scripts already had a better path (`script[src]` is folded in as source),
so they are deliberately not added to the MIME table.

The five rebasing tests that asserted a relative path survived now
assert the data URL's decoded content instead. That is a stronger check:
resolving from the wrong base directory finds no file, so nothing
inlines and the assertion fails.
2026-09-13 12:47:01 -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
Miguel Ángel fdf9ffac95 fix(engine): preserve nested sibling video layout (#3639) 2026-09-13 04:20:34 +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
heygengenesis[bot]andmiguel.sierra 4aa6e17b76 docs: fix skill documentation cross-references (#3920)
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
2026-09-13 03:19:21 +00:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) v0.8.36 2026-09-12 13:03:01 -04:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
Miguel Ángel 51a88b9566 docs(catalog): put the install command above the preview (#3888)
The install command is the one line a reader comes to a catalog page to copy,
and it sat below the whole variables explorer, under its Customize panel, a
screen or more down on long items. Move the Install section above the preview
and regenerate all 399 pages.
2026-09-11 18:15:00 -04:00
Vance Ingalls 63574a7c7f Merge pull request #3887 from heygen-com/release/v0.8.35
chore: release v0.8.35
v0.8.35
2026-09-11 14:35:39 -07: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
Vance Ingalls b848d81681 Merge pull request #3878 from heygen-com/docs/require-voiceover-carve
feat(audio): require the voiceover carve, default strength 0.8, slower release
2026-09-11 14:20:04 -07:00
Miguel Ángel 849ce0176a fix(catalog): name the better search tier when a search finds nothing (#3872)
* fix(catalog): name the better search tier when a search finds nothing

The hint that tells a scripted caller the on-device tier exists was wired
to the branch where the search succeeded, and was silent on the branch
where it found nothing. The caller with the most reason to hear it was the
only one who never did.

It also only ever reached stderr, so a --json caller never saw it at all.
The hint is now a returned sentence pushed into the same warnings array the
zero-result envelope already serializes, and printed from that one value, so
the terminal and the envelope cannot drift.

Withheld when the query parsed to no searchable tokens, where the advice is
already to search in English, and when a warning has already explained why
the tier cannot run here.

The consent gate is untouched: nothing downloads, and the sentence still
asks the caller to check with a person before enabling it.

* fix(review): pass the resolved model status, and pin both hint guards

Deriving the status inside the hint helper re-read and re-hashed the
on-device model from disk to answer a question the caller had already
answered a few lines earlier. It now takes the resolved value.

Both guards below the status check were invisible to the suite: every
other case pinned a status that already returned null, so deleting either
guard passed everything. Two tests now hold them, and each fails for its
own mutation.

* docs(skills): tell agents to read the tier hint the envelope now carries

The CLI skill said that under --json nothing about the offline tier is
printed at all. An agent following that sentence would never read the
warnings array this branch writes the tier sentence into, so the fix
would land in the code and never reach the caller it was written for.

It also described warnings as firing only when a tier was asked for and
could not run. That is now too narrow: a zero-result search emits the
hint with no tier requested.

Regenerate skills-manifest.json for the changed content hash.
2026-09-11 17:14:10 -04:00
Vance IngallsandClaude Fable 5.1 06897fe0be style(audio): format carve.test.mjs and re-sync the skills manifest
The pre-commit format hook's glob does not include .mjs, so the new test
file reached CI unformatted while oxfmt --check . covers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:08:10 -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 IngallsandClaude Fable 5.1 e488871ce1 fix(audio): pin the slower release and the carve default, fold the requirement, sync the manifest
Review at fe53f51a: the moved test points passed at both 1.6s and 2.4s, so a
revert of DUCK_RELEASE_S was silent. Add the 9s probe that reds on 1.6s. Export
DEFAULT_STRENGTH and parseArgs from carve.mjs and pin 0.8 in carve.test.mjs.
Fold the new requirement paragraph into the existing 'Carve by default' one so
the rule is stated once. Regenerate skills-manifest.json after the last edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:57:07 -07:00
miga-heygenandClaude Sonnet 5 42a3e7a519 fix(skills): loop-extended BGM writes .mp3, not MP3-in-a-.wav (#3884)
ensureBgmCovers() (duplicated in faceless-explainer, pr-to-video, and
product-launch-video's assemble-index.mjs) always re-encodes a short BGM
track with libmp3lame, but preserved the source asset's own extension when
naming the loop-extended output — so a "bgm.wav" source produced
"bgm.loop.wav" containing genuine MP3 audio. ffprobe/ffmpeg/Chromium all
decode this correctly via the WAVE_FORMAT_MPEGLAYER3 tag, but a naive/strict
WAV parser (e.g. Python's stdlib `wave` module) hard-fails on it. The
function's own doc comment already documented the intended output as
"*.loop.mp3" — the code just didn't match it.

Fix: always emit a .loop.mp3 path regardless of the source extension.
music-to-video's assemble-index.mjs does not have this function (it uses
the raw BGM asset directly, no loop-extension), so only 3 of the 4 skills
needed the change.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:07:49 -04:00
Vance IngallsandClaude Fable 5.1 fe53f51adb fix(core): slow the voiceover carve's level release to 2.4s
At 1.6s the bed audibly came back at every sentence break on narrated builds,
which reads as the effect switching off rather than the mix breathing. 2.4s
lets it swell back over a breath. Band-filter release is unchanged; a notch
closing is inaudible. Test timings move with the constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:27:37 -07:00
Vance IngallsandClaude Fable 5.1 2e2b5c8530 feat(hyperframes-audio): default the voiceover carve strength to 0.8
0.25 left the bed audibly fighting the voice; a build carved at 0.25 had to be
redone at 0.8 before it was accepted. Make carve.mjs default to 0.8 and rewrite
the strength prose and the sample output to match. The core DEFAULT_CARVE used
by the Studio panel is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:24:18 -07:00
Vance IngallsandClaude Fable 5.1 0b9852fee4 docs(skills): require the voiceover carve whenever music plays under a voice
The hyperframes-audio skill described the carve as one option among several,
so builds with BGM under narration shipped with only a volume duck. Make it a
requirement in the three places an agent reads while assembling audio: the
carve section of hyperframes-audio, general-video's Assemble step, and the
production loop's Audio stage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 10:25:50 -07:00
miga-heygen ea7e1dbd0b fix(cli): surface a warning when doctor's HYPERFRAMES_PYTHON override is rejected (#3870)
## Summary
- `findPython()` correctly resolves `HYPERFRAMES_PYTHON` before falling back to the PATH probe — that part already works, confirmed by direct testing. But when the override is set and fails validation (nonexistent path, non-executable, non-Python-3 output, timeout), it silently falls through to the PATH probe with zero diagnostic. A user whose override had any subtle issue got a plain "Not installed" from `doctor` with no signal the variable was even seen.
- This is a corrected, narrower version of a report that originally claimed `doctor` ignores `HYPERFRAMES_PYTHON` entirely — that claim was refuted directly (the override resolution works). The real defect is the silent validation-failure path.
- Extracts the override-validation logic into `validatePythonOverride()` and adds `describeRejectedPythonOverride()`, which `doctor`'s TTS (Kokoro) and BGM (MusicGen) checks now call to append the rejection reason to their `detail` when applicable. `findPython()`'s own behavior (including its fallback) is unchanged.

PRINFRA-669

## Test plan
- [x] New `packages/cli/src/tts/python.test.ts`: `describeRejectedPythonOverride` returns null when unset / when the override validates; names the override + exception message when the override can't run; names the override + actual output when it isn't Python 3; `findPython` still falls back to the PATH probe when the override is rejected (unchanged behavior) and still uses a valid override directly.
- [x] Confirmed RED against the pre-fix source (tagged stash) — all 4 new `describeRejectedPythonOverride` tests failed with "not a function"; GREEN after restoring the fix.
- [x] `bunx tsc --noEmit`, `bunx oxlint`, `bunx oxfmt --write` clean on changed files.
- [x] `bunx fallow audit --base origin/main --fail-on-issues`: no issues in the 3 changed files.
- [x] Full `packages/cli` vitest suite: 3033/3038 passing (2 pre-existing unrelated failures — a PID/socket sandbox quirk and an agent-env-var-pollution test — plus 4 browser-test files failing at collection on a pre-existing `node:` builtin import issue under this sandbox's happy-dom setup; all confirmed identical on pristine `origin/main` and unrelated to this change, consistent with every other fix from this backlog-drain session).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:57:11 -04:00
miga-heygenandClaude Sonnet 5 2ef76bf97b fix(lint): flag an unclosed tag that swallows a nested element as bogus attribute text (#3869)
A start tag missing its closing `>` before the next `<` (e.g. a bad
string-replace that leaves `<img ... <div class="hl"></div>` behind) is
parsed leniently by the browser's HTML5 tokenizer: the `<div` text is
consumed as a bogus attribute name on the still-open `img` tag, and the
intended element never becomes a real DOM node. No existing lint/check gate
catches this — they all operate on the resolved DOM, which looks
structurally valid once the browser has already dropped the element.

Adds `unclosed_tag_swallowed_element`, a new core lint rule that flags any
parsed tag whose attribute text contains a `<` outside a quoted value (a
legitimate attribute value may itself contain a raw `<`, e.g.
`data-expr="x < y"`, which htmlparser2 parses correctly and is not flagged).

PRINFRA-668

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 03:46:54 +00:00
miga-heygen bafc7b4e00 fix(cli): stop promising a universal per-time filmstrip for --layout strip (#3867)
## What

`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.

## Why

A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.

This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.

## How

Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.

## Testing

Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.

- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:22:14 -04:00