Commit Graph
5129 Commits
Author SHA1 Message Date
Miguel Ángel aca4bc2f49 chore: release v0.8.113 (#4913) v0.8.113 2026-10-02 12:14:00 -07:00
Miguel Ángel 02775c716f fix(core): render GSAP set steps on the frame they land on (#4911)
* fix(core): render tl.set steps on the frame they land on

The runtime re-rendered a seek by stepping just past the target and back.
Arriving backwards, GSAP applies same-time zero-duration steps in reverse,
so a film that hides every frame then shows one, on its frame grid, lost
that layer on every step: in renders, previews and thumbnails. It also
fired a tl.call on the target time again on each repeated seek. Both seek
paths now re-render from just below the target, silently.

* test(core): expect the seek re-render to arrive from below

* test(core): pin the seek re-render from below in the runtime seek script

* fix(core): keep re-rendering a seek to 0 from above

* test(producer): rebase style-4-prod on caption frames that render forward
2026-10-02 18:43:08 +00:00
Miguel Ángel 4178a8f95d fix(cli): reject extra positional arguments on every command (#4905)
* fix(cli): reject extra positional arguments on every command

* fix(cli): read the command name without assuming a resolved meta

* fix(cli): join free-text words for catalog and tts, reject a bad repo root

* test(cli): load the listed commands through static imports

* fix(cli): keep a tts file path out of the word join and read the parsed json flag

* fix(cli): apply the file exception only to a tts .txt path

* fix(cli): keep usageLine private and test the json fallback for undeclared json

* fix(cli): keep extra arguments and the repo root value out of telemetry

* test(cli): type the skills update lookup

* fix(cli): read json mode from the parsed flag, reject tts words with --text-file, restore add's hint

* fix(cli): check tts words with --text-file before --list and test --json=false
2026-10-02 18:34:53 +00:00
miga-heygenandMiguel Ángel fcc637068c fix(cli): honor an explicit --port for background preview reuse and launch (#3915)
* fix: honor explicit --port when reusing a background preview server

startBackgroundPreview never compared a reuse candidate's actual bound
port against the caller's explicitly requested --port before returning
it, so a second `preview --port <N>` call could silently return an
existing server on a different port instead of honoring the request.

Reuses the existing PreviewServerPortMismatchError (already used by
findPreviewServerForProject for the same discipline) instead of adding
a new error type: an explicit --port that doesn't match the reuse
candidate now throws a clear conflict rather than substituting the
wrong port silently. A bare launch with no --port keeps reusing any
project-matching server, unchanged.

Deliberately out of scope: the interactive/embedded launch path
(runEmbeddedMode -> findPortAndServe) has the same silent-substitution
shape when a same-project server is found mid-scan at a port other
than the one requested. That path's output isn't the JSON lifecycle
schema this bug was reported against, and fixing it would mean
touching the interactive dev-server bind/scan loop rather than a
simple pre-return check, so it's left as a follow-up candidate.

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

* fix: honor explicit --port on every background preview exit path

`startBackgroundPreview` already refused to reuse a same-project server on
the wrong port when --port was explicit, but the fresh-launch path still
trusted whatever port the detached child bound. The child scans upward from
--port and takes the first free port, so `--background --port N` with N busy
reported `{ type: "started", port: N+1 }` and recorded ownership of it.

- After the child comes up, compare its port with the explicit --port. On a
  mismatch, reap the wrapper, wait until the substitute stops answering, and
  throw `PreviewPortUnavailableError` (JSON code `preview-port-unavailable`).
  If the substitute never stops, fail loudly instead of reporting it.
- Share one `unmetPreferredPort` predicate between the reuse and launch
  paths, and one `awaitServerGone` wait between the reap path and
  `stopBackgroundPreview`.
- When several same-project servers are running, prefer the one on the
  explicit --port as the reuse candidate instead of the lowest port.
- Map launch failures to JSON codes in one place
  (`backgroundStartFailureCode`).

Tests: preferred port bound → reported as-is; preferred port taken → reaped
and rejected with both ports, no session record; substitute that keeps
serving → distinct error; bare launch keeps the next free port; explicit
port picks the matching sibling. Deleting the launch-path check, the
candidate ordering, or the post-kill wait each turns its test red.

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

* fix(cli): reuse the same-project server on the explicit --port

`startBackgroundPreview` picked its reuse candidate before honouring
`--port`, so with an owned server on 3002 and an unmanaged same-project
server on 3003, `--port 3003` rejected with "No Studio preview server …
on port 3003. Matching server port: 3002" even though 3003 was running.

- A policy-matching same-project server already on the explicit port is
  now the reuse candidate, owned or not; the owned server stays
  authoritative otherwise, and an explicit GPU-policy change on the
  requested port still replaces it.
- `PreviewServerPortMismatchError` receives every policy-matching
  same-project server, so the ports it reports are the ones running.
- `policyMatchingServers` is the single owner of the same-project +
  GPU-policy predicate; `matchingServer` derives from it.
- `BackgroundPreviewResult` names the launch result union once;
  `PREVIEW_PORT_MISMATCH_CODE` replaces three literal error codes.

Tests: unmanaged sibling on the explicit port is reused without touching
the owned server; mismatch error lists every same-project port and no
foreign-project port; owned server on the explicit port is replaced on a
GPU-policy change. Each new check fails when its guard is removed.

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

* refactor(cli): keep the --port honour comments short

* fix(cli): drop the scan binding stopBackgroundPreview no longer reads

* refactor(cli): keep comment share at base in the preview lifecycle files

* fix(cli): reuse a same-project server on an explicit --port beyond the scan window

* test(cli): pin the JSON codes for a background start that misses --port

* refactor(cli): probe an out-of-window --port in its own helper

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 18:26:05 +00:00
Miguel Ángel fc53908742 fix(lint): flag digit-leading ids used by toArray and ScrollTrigger selectors (#4908)
* fix(lint): flag digit-leading ids used by toArray and ScrollTrigger selectors

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(lint): drop the selector sink comment

* fix(lint): ignore hyphenated and $-prefixed keys that end in pin or trigger

* fix(lint): match scroller, pinnedContainer and pinSpacer selector keys
2026-10-02 18:25:22 +00:00
miga-heygenandMiguel Ángel 821e8c8ad0 fix(cli): hide console windows on Windows for background/detached spawns (#3996)
* fix(cli): hide console windows on Windows for background/detached spawns

Add windowsHide: true to 8 spawn/execFile call sites that were missing
it: ffprobe/ffmpeg probes in the producer and engine packages, the
telemetry exit-flush and studio dev-server spawns, and the browser-open
and background-preview detached launches in the CLI.

previewLifecycle.ts's spawnDetachedPreview redirects stdout/stderr to a
raw log-file descriptor; libuv only adds Windows' CREATE_NO_WINDOW flag
when no stdio slot inherits a raw handle, so windowsHide alone does not
fully suppress a console flash there (documented inline) — it's still
added for consistency and because it correctly sets the process's
show-window state regardless.

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

* fix(cli): correct windowsHide flash-risk claims and add missing dev-server spawn tests

- The previewLifecycle.ts comment (and its mirrored test comment)
  overstated the residual console-flash risk on the detached background
  preview spawn: DETACHED_PROCESS already leaves that child without a
  console regardless of whether CREATE_NO_WINDOW gets applied.
- Add the two missing regression tests for preview.ts's runDevMode and
  runLocalStudioMode spawns, exporting both for test access.
- Drop an unused export in audioPadTrim.ts flagged by the repo's dead-code
  gate; the constant is only ever used within its own file.

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

* fix(engine): hide console window for the drawElement self-verify PSNR comparison

psnr.ts's execFile call runs in-process inside the Studio server's render
path (parallelCoordinator.ts's drawElement disk self-verify), so a render
started from Studio's UI under a detached preview hits this call from a
console-less parent — the same flash case the other windowsHide sites in
this PR already cover. psnrFilterAvailability.ts's probe is on the same
in-process render path and already had the flag.

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

* fix(cli): keep only the spawn sites main still misses and leave browser windows visible

windowsHide maps to libuv UV_PROCESS_WINDOWS_HIDE, which also starts GUI apps with SW_HIDE,
so the detached browser launch must not set it. The background preview child already has
no console (DETACHED_PROCESS with inherited log fds), so its flag was a no-op. The
telemetry, psnr and audioPadTrim sites shipped on main via #3839 and #4604.

* fix(cli): stop the studio dev server when the terminal closes

With windowsHide the dev server runs in its own hidden console on Windows, so closing the terminal
only signals the wrapper (SIGHUP). Handle it like SIGINT and SIGTERM and kill the process tree.

* fix(studio-server): hide the console window for loudness measurement

The background preview has no console, so its ffmpeg loudness run opened a visible window on Windows.

* refactor(cli): drop the comment on the studio shutdown signals

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 17:15:14 +00:00
04acbb4d9e feat(registry): add 4 community components (#4906)
* feat(registry): add cinemagraph — a still photograph with one living region

* feat(registry): add ai-answer-rank — a mock AI answer that re-ranks to put your brand in it

A 1080x1080, 9s block for answer-engine and AI-visibility marketing. A prompt
types into a chat card, the assistant answers with a ranked list of three picks,
then re-ranks: the promoted brand slides into its slot (brandRank 1-3), the picks
below step down, the third fades out of the list, and the brand row lands with
the one accent highlight and a drawn check. Monochrome placeholder UI; prompt,
lead-in, pick names, brand, rank, accent, ink and ground are variables.

* feat(registry): add caret swap text effect

* feat(registry): add pendulum-swing component

Add a motion-primitive component that hangs content from a fixed pivot and
lets it settle with a damped oscillation, like a hanging sign coming to rest
on its hook.

The swing rides a single unitless custom property (--hf-swing-angle) that the
timeline eases 1 -> 0, so only rotation animates and it stays sub-pixel under
the seek-by-frame capture engine. The demo drives it with a damped-oscillation
ease (a cosine inside an exponential decay, normalised to land exactly upright)
so the sign passes through center several times, each pass smaller, before it
rests.

Four enum variables: pivot (top-center/left/right), amplitude
(gentle/standard/wide), direction (right/left), tone (ink/paper/accent, each a
bg/fg pair to keep AA contrast on any frame). The snippet is recipe-only and
does not own its timeline; its demo carries the same variable declaration.

Ran npx hyperframes lint/validate on both files, regenerated registry.json via
scripts/generate-registry-items.ts and the docs via
scripts/generate-catalog-pages.ts.

* fix(registry): drop hand-added cinemagraph index entry; catalog-publish adds it

* style(registry): format pendulum-swing with oxfmt

* fix(registry): keep pendulum-swing demo caption clear of the swinging sign

* fix(registry): ship the ad-template editing contract for ai-answer-rank

---------

Co-authored-by: Carlos Miranda Levy <carlosmirandalevy@gmail.com>
Co-authored-by: tugrawork-creator <319556794+tugrawork-creator@users.noreply.github.com>
Co-authored-by: Akshar Patel <aksharcommit@gmail.com>
Co-authored-by: rottenpen <12029924+rottenpen@users.noreply.github.com>
2026-10-02 17:14:52 +00:00
Miguel Ángel f16e509832 fix(cli): stop browser --force from deleting chrome under running renders (#4901)
* fix(cli): stop browser --force from deleting chrome under running renders

* style(cli): format browser manager test

* fix(cli): sweep leftovers of killed browser installs and on browser clear

* docs(cli): state what browser clear returns without history wording
2026-10-02 08:36:46 -07:00
Miguel Ángel ea55218eba chore: release v0.8.112 (#4909) v0.8.112 2026-10-02 08:36:30 -07:00
Miguel Ángel 05145bf73c feat(studio): export timeline row order and fade handle boxes, readable fx badge (#4904)
* feat(studio): export the timeline's row order and fade handle boxes for hosts

displayTrackOrder(elements, collapsedGroupIds) gives the rows the timeline
draws, built by the same function as the timeline hook. fadeHandleBoxes gives
where TimelineClipFades puts its fade handles. The z-order mirror's sorted
lane list is renamed ascendingTrackOrder.

* style(studio): wrap the row derivation's doc comment

* style(studio): format the fade handle geometry

* refactor(studio): reuse timelineTrackOrder and give fade handle boxes their height

* fix(studio): make the inactive fx badge readable at 4.5:1

* docs(studio): fit the fade handle geometry comment in four lines
2026-10-02 08:21:18 -07:00
miga-heygenandMiguel Ángel e8ff5ca8c2 fix(lint): fail skill lint on unresolved doc cross-references (#3922)
* fix(lint): fail skill lint on unresolved doc cross-references

scripts/lint-skills.ts now checks every *.md under skills/, .claude/skills/
and .agents/skills/ for cross-references that do not resolve: inline links
[text](target), reference definitions [id]: target, and backticked paths
that are explicitly relative (./ or ../) and end in .md or .html. Targets
resolve against the referencing file's directory and must exist; a #anchor
on a Markdown target must match a GitHub-style heading slug of that file,
and same-file #anchor links are checked against the file's own headings.
Fenced code blocks, URI-scheme targets, absolute paths and placeholder
targets ({ } * $ < >) are skipped. Bare backticked paths without ./ or ../
are a documented blind spot: on the current tree they are mostly skill-root
shorthand, another skill's file, or a runtime artifact, and checking them
produced ~200 hits that are not wrong.

The former top-level main now runs only when the script is the entry point,
so importing the exported checkers from the test file no longer exits the
test runner when the tree is red.

On the current tree the rule reports 34 violations across 16 files, all of
the shape described in #3919 (../ one level too shallow for the referencing
file, or a file that does not exist). The docs are corrected separately.

Refs #3919

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

* refactor(lint): trim the cross-reference rule's comments and inline its reporter

* docs(lint): cite placeholders, not invented paths, in cross-reference comments

* refactor(lint): keep the skill lint entry point under the complexity limit

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 08:20:12 -07:00
9a4b2a2d74 fix(lint): flag static composition hosts without opt-out (#3763)
* fix(lint): flag static composition hosts without opt-out

* fix(lint): accept logical and nested-key timeline registrations

Keeps the registration forms the old substring check accepted (??=, ||=, [ids[0]]), carries the
test cases from #3760, and splits the rule so it stays under the complexity gate.

Co-authored-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com>

---------

Co-authored-by: QiuLG <195722592+QiuLsG@users.noreply.github.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com>
2026-10-02 08:09:01 -07:00
lorenzozaneandMiguel Ángel d865b5a7cb fix(skills): stop mirroring skills into Codex and remove old mirror links (#4105)
* fix(skills): avoid duplicate Codex skill mirrors

* fix(skills): remove Codex mirror links left by earlier skill updates

* fix(skills): guard the Codex mirror cleanup and keep links without a universal copy

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 08:05:44 -07:00
Miguel Ángel 67dbcf7bdb feat(studio): preview timeline track insertion (#4907)
* feat(studio): preview new tracks at drop boundaries

* fix(studio): preserve group ownership across overlapping moves

* refactor(studio): share timeline drop decisions and fixtures

* fix(studio): open an empty audio zone below visual tracks

* fix(studio): cancel thumbnails when project pages leave

* refactor(studio): share thumbnail regression setup

* test(studio): gate thumbnail socket release in browser CI

* chore(studio): keep optimistic revision comments within ratchet

* fix(studio): pin regression proxy requests to its backend

* test(studio): bound the proxy origin probe

* test(studio): probe proxy origin through the ready document
2026-10-02 08:04:29 -07:00
maopishouzzzandMiguel Angel Simon Sierra f2eb6d9cc1 fix(lint): flag seek-unsafe CSS transitions (#3797)
* fix(lint): flag seek-unsafe CSS transitions

* fix(lint): allow zero-duration transition resets in css_transition_used

The registry's reduced-motion and render-lock resets use transition-duration: 0s and
transition-delay: 0s, which never run on the browser clock. Also drops the one real
seek-unsafe transition the rule finds in the product-promo example.

* refactor(lint): split the zero-time check out of isSeekUnsafeTransition

* fix(lint): report seek-unsafe CSS transitions as a warning

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 07:47:13 -07:00
miga-heygenandMiguel Angel b31fb02c7e fix(cli): distinguish a skipped browser check from a genuinely clean one (#3901)
* fix(cli): distinguish a skipped browser check from a genuinely clean one

`hyperframes check`'s runtime/layout/motion/contrast JSON sections reported
the exact same ok:true/zero-findings shape whether the browser session
actually ran and found nothing, or never ran at all (a blocking lint error,
a lint-step crash, or an exception thrown before the audit could start all
took the same "empty result" fallback). Only the top-level ok field and an
undocumented layout.duration === 0 tell separated the two cases apart.

Add a skipped boolean to CheckBrowserResult, set at the only two places one
is constructed (false in the real audit result, true in the empty-result
fallback), and mirror it to a new top-level browserSkipped field on
CheckReport. The human-readable report now prints a warning when the
browser never ran; the JSON envelope carries the same signal for callers
that only check individual sections.

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

* docs(cli): document browserSkipped in the check --json envelope

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

* refactor(cli): trim browserSkipped docs and cover the linter-crash skip path

* chore(skills): refresh the hyperframes-cli manifest hash

* refactor(cli): drop the browserSkipped type comments to keep the comment share flat

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 07:32:48 -07:00
miga-heygenandMiguel Ángel 94650480e2 docs(skills): fix unresolved cross-references in skill docs (#3951)
* docs(skills): fix unresolved cross-references in skill docs

Skill reference docs that live one directory below a skill root
(`references/`, `sub-agents/`) pointed at sibling skills with `../`,
which resolves inside the referencing skill instead of `skills/`.
Rewrite those to `../../<skill>/...` so every explicitly relative
path resolves from the file that contains it.

- faceless-explainer, pr-to-video, product-launch-video: story-design,
  visual-design, motion-language, cut-catalog, sub-agents/frame-worker
  now point at ../../hyperframes, ../../hyperframes-animation and
  ../../hyperframes-creative.
- music-to-video/references/planning.md: same fix for
  hyperframes-creative design-spec and frame-presets.
- media-use/audio/references/{bgm,tts}.md: `../SKILL.md` ->
  `../../SKILL.md` (the media-use skill root).
- hyperframes/references/frame-worker-core.md: the examples pointer now
  reads ../../hyperframes-animation/examples/; the cut-catalog pointer
  is written as skill-root shorthand (`references/cut-catalog.md`),
  matching the other paths in that file, since the target is the
  consuming workflow's own copy and has no single file-relative path.
- visual-design.md tables re-padded by oxfmt after the cell widened.

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

* docs(skills): fix the two skill doc cross-references added since the first pass

* chore(skills): regenerate the skills manifest after the rebase

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 07:29:25 -07:00
miga-heygenandMiguel Angel Simon Sierra a7e95c0e37 fix(cli): stop lambda deploy from pointing at a nonexistent --sam-template flag (#3879)
* fix(cli): stop lambda deploy/destroy from pointing at a nonexistent --sam-template flag

locateSamTemplate()'s error message and doc comment both promised a
--sam-template CLI flag as an installed-package fallback, but no such
flag was ever wired up anywhere in lambda.ts/deploy.ts/destroy.ts, and
examples/aws-lambda/template.yaml isn't shipped in any published
package either -- a dead-end escape hatch that just wasted users' time.
Replace it with the honest requirement (matches repoRoot()'s own
message): these commands need a HyperFrames monorepo checkout, or
HYPERFRAMES_REPO_ROOT pointed at one.

* fix(cli): scope the missing sam template error to lambda deploy

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 07:27:02 -07:00
jbernard077andMiguel Ángel 0a04f80bbe feat(skills): motion-blur-streak — per-frame-driven form for elements riding a baked track (#3781)
* feat(skills): motion-blur-streak — per-frame-driven form for elements riding a baked track

Extends rules/motion-blur-streak.md with a Per-Frame-Driven Carve-In: when an element is driven
from a baked track (tracked insert, shake-matched title) there is no tween window to share, so the
envelope becomes a per-frame function of the track delta — velocity-gated text-free silhouette
ghosts (Path B, the default for driven subtrees) or a rotate/blur/counter-rotate filter sandwich
(Path A), streak axis from atan2 of the delta. Adds the Values rows, the constraint, and updates
the rules-index row.

* fix(skills): keep motion-blur-streak trail opacity independent of seek order

The driven ghost trail read the lead opacity written by another tween, so a frame could get a different trail opacity depending on the previous seek. Ghosts now sit inside the positioned, faded track wrapper and take the fade as group opacity. The snippet also names which element owns position, rotation and counter-rotation so the transform writes cannot clobber each other.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 07:16:51 -07:00
Miguel Ángel 30ed3cd124 fix(studio): a saved clip volume reaches the timeline store (#4883)
* fix(studio): a clip's saved volume reaches the timeline store, and undo reads it back

* fix(studio): drop the comments the volume helpers do not need

* refactor(studio): the stored volume reader lives in its own file, under the helpers' size limit

* fix(studio): drop the doc line that put the volume reader over its comment share
2026-10-02 07:15:05 -07:00
Miguel Ángel 84898fbe85 fix(ci): skill docs may name the project files a workflow writes (#4902)
* fix(ci): skill docs may name the project files a workflow writes

* style(ci): wrap the project-file citation test to the format width
2026-10-02 07:06:37 -07:00
Dante bad0bc7ce2 fix(cli): recapture snapshot composites after video injection (#4767)
* fix(cli): recapture snapshot composites after video injection

* test(cli): guard snapshot composite recapture ordering
2026-10-02 07:05:16 -07:00
miga-heygenandMiguel Ángel d7285e414b fix(engine): preserve composition root background in alpha exports (#3976)
* fix(engine): preserve composition root background in alpha exports

initTransparentBackground() forced html, body, AND any
[data-composition-id] element (the composition root, or a nested
sub-composition root) to transparent for every alpha-carrying capture
session. That's correct for the HDR two-pass layered DOM pass, where
the HDR video itself is the backdrop — but for a plain alpha export
(MOV ProRes4444, webm+alpha) of a normal composition, an author's own
background painted on the root is real, intentional content, and it
was being silently dropped.

Add CaptureOptions.clearCompositionRootBackground, threaded through
session.options into every general-session call site (default false —
preserve the root's authored background). The two HDR-layered-DOM-
session call sites set it to true, keeping their existing (correct)
behavior. initTransparentBackground's injected stylesheet is now
idempotently rewritten rather than insert-once/skip, so a caller's
choice is never silently ignored by an earlier call on the same page.

Add a producer test fixture + transparency-test.ts check proving a
composition root's authored background survives into alpha output,
plus unit tests for both flag values and the rewrite-on-repeat-call
behavior directly.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): register the new transparency fixture with the shard planner

transparency-regression-root-bg is exercised by transparency-test.ts
directly (asserts a real alpha channel), not by the sharded regression
harness, same as its sibling transparency-regression. The shard
planner hard-fails on any fixture directory it doesn't recognize as
either scheduled or explicitly excluded, so register it in the
excluded list with the same reason as its sibling.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(engine): restore explicit transparent-background call for HDR layered sessions

The previous commit removed the two HDR-layered-DOM-session call
sites' own explicit initTransparentBackground() call, relying on
initializeSession()'s internal call (gated behind
session.options.format === "png") plus a new
clearCompositionRootBackground session option.

That was wrong: a plain HDR render's own DOM session captures
"jpeg", not "png" — the final HDR MP4/HEVC output doesn't itself
carry an alpha channel, only the layered-compositing step does. So
initializeSession()'s internal call never fired at all for this
common case, and the HDR video layer never got a transparent DOM
backdrop to composite into.

Reproduced directly against the hdr-regression fixture (part of the
regression-shards CI suite): a real local render showed a severe
PSNR collapse (~8-56dB) in the "Window B: z-order sandwich" segment,
where a DOM element behind the HDR video, the video itself, and a
translucent DOM overlay in front all need correct z-ordering — with
the composition root's own background no longer cleared, it painted
solid over the video slot instead of letting the video composite
through. Confirmed via bisection: reverting to the prior commit's
files reproduces 0 failed frames (passing); the removed-call version
reproduces the exact failure; restoring the explicit calls fixes it
again (100/100 checkpoints, 0 failed frames).

Restore the explicit, unconditional call in both HDR call sites,
right after initializeSession(). clearCompositionRootBackground still
does its job for the general/plain-alpha-export path (frameCapture.ts),
which is unaffected by this fix.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(engine): let the HDR layered pass own composition-root clearing

Drop the CaptureOptions.clearCompositionRootBackground field: every HDR layered
DOM session already calls initTransparentBackground right after init, so that
call is the one owner. Engine paths use the default, which keeps root backgrounds.

* chore(producer): drop comment additions the comment ratchet rejects

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 06:51:31 -07:00
50864d57a6 fix(lint): dedupe CSS transform text in gsap_css_transform_conflict (#3761)
When one declaration (e.g. transform: scale(1.08) translate3d(1.5%, 0, 0))
matches both the translate and scale selector maps, cssFromTranslate and
cssFromScale end up equal, and join(" ") concatenated the identical string
with itself in both the finding message and fixHint.


Claude-Session: https://claude.ai/code/session_01LQXJUdfkq6CGWh6KNfVXyw

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 06:45:04 -07:00
miga-heygenandMiguel Angel ded5fa8661 fix(cli): reject extra positional arguments passed to add (#3891)
* fix(cli): reject extra positional arguments passed to add

`add <a> <b> <c>` silently installed only `<a>` and exited 0, discarding
`<b>` and `<c>` with no warning. citty binds only the first positional
token to `name`; every token after it still lands in `args._`, but
nothing read it. `add` now rejects the extra arguments up front, before
touching the registry, naming exactly what was dropped.

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

* refactor(cli): inline the add extra-argument message and test it through citty parsing

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 06:34:21 -07:00
Miguel Ángelandmiga-heygen e8659cb653 fix(cli): recognize textual motion in sweep checks (#3707)
* fix(cli): fingerprint textual sweep state

* fix(cli): share one motion classifier between sweep guard and liveness

The frozen-sweep guard (sweep_static) and keepsMoving liveness (motion_frozen)
each owned their own definition of "what counts as motion": the sweep
fingerprint in layout-audit.browser.js read nine channels while
motion-sample.browser.js compared box + opacity only. They meet in one decision
in checkPipeline.ts, so a fixed-width attr() countdown was accepted by the
sweep guard and still reported frozen by liveness.

Move the classifier into motion-signature.browser.js, injected before both
samplers, and make it the single owner:

- Per-element channels (box, opacity, font-variation axes, clip-path, direct
  text, form-control state, ::before/::after content, media pixels) as a
  reader list so further channels (e.g. SVG dash) plug in.
- Liveness consumes the same signature with 2px / 0.08 bucketing; the sweep
  guard consumes it exact. motion-sample throws at install if the shared
  script is missing.
- Counter state is a composition-level channel over box-generating elements
  and root ancestors. display:none subtrees generate no boxes and are
  excluded, and only declarations naming a counter that some painted
  generated content (::before/::after/::marker on a non-ignored, paintable
  host) consumes are folded in, so a decoy hidden counter cannot make a
  frozen composition read as live.
- clip-path is a channel, replacing the fingerprint's former reliance on the
  clipped-away visibility probe, so a wipe over a static box still counts.
- data-layout-ignore layers are excluded from the signature but not from the
  per-selector `visible` bit reported for explicit motion assertions.
- Root resolution for the "*" liveness scope now matches the sweep guard and
  the root element is part of its own signature.

Tests: motion-signature.browser.test.ts (happy-dom, per-element fake computed
style so ancestor handling comes from the classifier) and
motion-signature.browser.chromium.test.ts (real Chromium via puppeteer-core;
skips without a system browser) pin every branch: attr() substitution,
display:none decoys, named-counter gating, ::marker, zero-box hosts, ancestor
counters, textarea/indeterminate, root text, clip-path, ignore layers, and
sweep/liveness agreement on the countdown fixture.

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

* fix(cli): prune unrendered content from the motion signature and measure asserted scopes

The shared motion classifier pruned only display:none subtrees, so text,
pseudo content, media pixels, and counter decoys inside skipped
content-visibility contents changed the frozen-sweep and liveness
signatures although nothing painted. Pruning is now platform-owned where
checkVisibility exists (display:none, skipped hidden/auto contents; a
display:contents host keeps its painting pseudo-elements), with the
computed-style fallback for happy-dom. A content-visibility:hidden host
signs its own box, opacity, clip-path, font axes, and widget state, but
not its text, pseudo content, control value, or media pixels; hosts where
size containment does not apply (non-atomic inline, display:contents,
table and internal table boxes, ruby) skip nothing. isVisibleElement
regains the checkVisibility floor layout-audit uses on its opacity-floor
path, so an asserted selector inside skipped contents reports hidden.

The layout-audit opt-out no longer applies to the element passed as the
signature root: a keepsMoving scope that is itself data-layout-ignore is
measured (an assertion naming an element outranks the opt-out) instead of
producing an empty signature that motionAudit reports as a missing
selector. Opt-outs inside the scope still apply.

motion-sample defers the missing-classifier error to the first call so
the driver's evaluate surfaces it instead of an opaque "is not a
function" page error; round() is shared.

The real-Chromium suite is opt-in on Windows (HYPERFRAMES_BROWSER_TESTS=1):
in the shared Windows package lane the runner's Chrome did not launch
within the package hookTimeout, and the suite already runs on Linux CI
where the system Chrome is found. The launch hook has its own 120s ceiling.

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

* fix(cli): prune display:contents in skipped contents; table-cell skips, caption paints

A display:contents child of a content-visibility:hidden host has no box
of its own, so the platform check could not tell it apart from an
ordinary display:contents owner and it stayed a counter consumer: a
change of the owner's counter-reset altered the sweep signature although
nothing paints. The parent's skipsContents verdict now prunes it; boxed
descendants keep being decided by the platform.

NOT_CONTAINABLE_DISPLAY had table-cell and table-caption inverted
versus Chromium 152, where a table-cell host skips its contents and a
table-caption host paints them.

Four Chromium pins: the display:contents-in-skipped-contents counter, a
table-cell host's own text and pseudo content (frozen), a table-caption
host's own text and pseudo content (motion), and a counter reachable
only through a skipped host's hidden ::marker / ::after (frozen; also
asserts the skipped child is not visible for motion-sample).

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

* chore(cli): trim sweep motion comments to the comment ratchet

* chore(cli): shorten sweep motion comments to the merge-hook limit

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
2026-10-02 06:25:18 -07:00
miga-heygenandMiguel Angel Simon Sierra 1a619785d5 fix(cli): keep check --json stdout pure by sending proxy pre-resolve diagnostics to stderr (#3836)
* fix(cli): keep check --json stdout pure by sending proxy pre-resolve diagnostics to stderr

preResolveHostileMediaProxies() logged its summary and scan-failure lines via
console.info, which writes to stdout. With autoProxy on (the default), any
project containing HEVC/ProRes/AV1 media emitted that line ahead of the JSON
envelope from `check --json`, so JSON.parse(stdout) failed for consumers of
the machine-readable output.

Route both diagnostics through console.error (stderr), matching how the check
command already reports its own failures. Add regression tests asserting the
summary and scan-failure lines land on stderr and that stdout stays untouched.

* refactor(cli): shorten the pre-resolve stderr comments

* refactor(cli): drop the pre-resolve doc line the comment ratchet rejects

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 06:25:05 -07:00
Miguel Ángel 0b1f5979fd feat(studio): give timeline clips a Filmstrip name band (#4897)
* docs(studio): map filmstrip clip rendering contract

* feat(studio): give timeline clips a separate filmstrip name band

* test(studio): require the filmstrip picture wrapper

* fix(studio): keep fallback clip warnings clear of menu badges

* fix(studio): reserve clip text space beside status badges

* fix(studio): separate clip FX and fade hit areas

* fix(studio): separate clip tool pointer targets

* fix(studio): reserve FX space without lowering fades

* refactor(studio): trim fade geometry measurement

* test(studio): refresh fade target after closing FX menu

* test(studio): collapse keyframe lanes before pointer witness

* test(studio): register filmstrip control browser gate
2026-10-02 06:14:20 -07:00
miga-heygenandMiguel Ángel 5aa3c8f72f fix(cli): connector dash gate honours pathLength (#3954)
* fix(cli): connector dash gate honours pathLength

`shaftDashHidden` compared the stroke's dash window against
`getTotalLength()`, but a `pathLength` attribute rescales every distance
along the path, so the CSS draw-on idiom (`<path pathLength="1">` with
`stroke-dasharray: 1; stroke-dashoffset: 1`) — which renders zero ink —
still fired `connector_detached`. The window is now judged in the author's
units: `pathLength` when set (`SVGAnimatedNumber.baseVal`, 0 when unset),
the real length otherwise. `dashLength`/`dashArrayLengths` stay the only
owners of parsing and units.

Two adjacent changes to the gate's arithmetic:

- The `total > period` shortcut is removed so the 10% rule applies
  uniformly. A window that spans a whole period without enclosing a whole
  dash (`2 97` on a 100-long path at offset 0.5 paints 3%) is hidden, like
  any other stroke painting under 10%; any pattern that does enclose a
  whole dash is still caught by the in-loop check.
- The dash-offset wrap adds the period only to negative remainders. The
  previous `((offset % period) + period) % period` is off by one ulp for
  fractional offsets (`0.9 % 2` → 0.8999…), which flipped the exact 10%
  boundary once dashes are expressed in `pathLength` units.

Tests: rows for `pathLength="1"` (`1/1` hidden, `1/0.9` on the boundary
hidden, `1/0.5` painted, `0.1 0.9` round dot painted), a negative offset
that only the wrap gets right (`100` at -150), a percentage row (`10%`
resolves against the viewport, not as a bare number), and the `2 97`
full-period row. `installConnectorGeometry` mirrors the DOM's `pathLength`
for happy-dom, which has no SVGGeometryElement.

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

* fix(cli): treat a zero pathLength as a solid stroke in the dash gate

A present `pathLength` that resolves to 0 (`"0"` or an unparseable value)
zeroes the browser's dash scale factor, so the stroke paints solid. The gate
saw `baseVal` 0, fell back to the real length, judged the window inside a gap
and dropped the shaft, suppressing a real `connector_detached` finding.

Return painted for that case. Add a `pathLength=0` row to the dash table and
make the happy-dom `pathLength` mirror parse like the DOM (finite SVG number
only; `Infinity`, hex, `1.` read as 0).

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

* fix(cli): check a zero pathLength before the butt-cap dots shortcut

A pathLength resolving to 0 paints the stroke solid, so a butt-capped `0 4` pattern on it is
visible; the dots shortcut used to call it hidden first. Also trims the dash-table comment.

* test(cli): keep the pathLength helper apart from the geometry installer comment

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 06:09:09 -07:00
jbernard077andMiguel Ángel 6f799aaa41 docs: align infinite-repeat guidance with bounded repeat: -1 lint (#3795)
Since v0.7.71 (#2763), gsap_infinite_repeat demotes to a warning when the
root declares a finite data-duration: deterministic seeking and export clip
to that explicit window. Without one it remains a hard error (unbounded
timeline duration breaks render planning).

Update every doc site that still teaches the old absolute ban — the core
determinism contract, the animation adapter notes, and the design-guide
tables/checklists — to state the bounded allowance and keep the finite-count
formula for loops that must end before the composition does.

skills/hyperframes-animation/rules/ambient-glow-bloom.md is intentionally
untouched: its shimmer must stop before the composition ends, which is
exactly the case where a computed finite count is still required.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 05:59:04 -07:00
heygengenesis[bot]andmiguel.sierra ecfcbb0c61 docs: clarify skills installer behavior (#3863)
* docs: clarify internal skill installation behavior
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>

* docs: clarify --all checkout safety

---------

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
2026-10-02 05:56:34 -07:00
Leonel RivasandMiguel Angel Simon Sierra d2cdad418a fix(cli): refuse capture folders that resolve outside the output root (#4789)
* fix(cli): refuse capture directories that resolve outside the output root

writeCaptureFileSync guards the final path entry, but a directory in the
path that is itself a planted symlink (assets -> ~/somewhere) still carried
the staging directory and the rename into wherever it pointed, with a
filename the captured page chose.

Every capture directory is now created through ensureCaptureDirSync(root,
dir), which walks from the output root one level at a time and refuses any
directory whose real path is not the one it has under the root, before
creating anything inside it. The root itself stays trusted as given, so a
symlinked root like /tmp on macOS keeps working.

Fixes #4304

* fix(cli): vet assets/svgs before the SVG contact sheet writes into it

Under --skip-assets, downloadAssets never runs, so nothing checked
assets/svgs before createSvgContactSheet wrote its thumbnails and
contact-sheet.jpg into it; a planted assets/svgs link received them.
The sheet now runs ensureCaptureDirSync on the directory first, and a
refusal just skips the sheet.

A dangling or looping directory link is now refused with the same
message instead of a raw ENOENT/ELOOP, and a comment records why the
check uses the JS realpathSync rather than realpathSync.native.

Refs #4304

* fix(cli): vet capture --video's download folder

Video mode created assets/videos (or capture/assets/videos in a W2H
project) with a plain mkdirSync, so a planted assets or capture link
carried the downloaded clip outside the project. It now goes through
ensureCaptureDirSync with the project folder as the trusted root, and
a refused folder is reported before anything is downloaded.

* fix(cli): warn when a best-effort capture phase refuses a folder

Folder refusals now throw CaptureDirRefusedError. The contact sheets
and the video manifest still skip on any error, but a refusal adds a
warning so a planted link doesn't go unnoticed.

saveLottieAnimations now vets assets/lottie itself, taking the output
root like renderLottiePreviews, and every folder check in the capture
pipeline is pinned by a test.

* style(cli): trim the ensureCaptureDirSync doc comment to two lines

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 05:53:31 -07:00
Poetry Of CodeandMiguel Angel Simon Sierra f1b79d8e7b fix(media-use): honour HYPERFRAMES_PYTHON in the audio engine (#4614) (#4723)
* fix(media-use): honour HYPERFRAMES_PYTHON in the audio engine (#4614)

resolvePythonCommand() only probed python3/python on PATH, so on PEP 668
systems set up with a venv via HYPERFRAMES_PYTHON (the documented setup),
doctor reported MusicGen installed while BGM was silently skipped.

Check HYPERFRAMES_PYTHON first, as the CLI's findPython() does, and fall
through to the PATH probe when the override doesn't run.

* fix(media-use): require a Python 3 version from the interpreter probe

Address review on #4723:
- The probe accepted any executable that exits 0, so a Python 2
  HYPERFRAMES_PYTHON (or python on PATH) was taken while the CLI's
  validatePythonOverride() rejects it. Require "Python 3" in the
  --version output, reading stderr too since Python 2 prints there.
- Regenerate skills-manifest.json for the media-use change.

* chore(skills): regenerate manifest without local __pycache__

* chore(media-use): trim python resolver comments to the comment limit

* style(media-use): format the python probe test

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 05:51:30 -07:00
HEOJUNFO 8aefd91bf8 fix(cli): capture snapshot --at at the exact requested instant (#4827)
snapshot --at captures the frame at the exact requested instant instead of the nearest seek step.
2026-10-02 05:48:06 -07:00
miga-heygenandMiguel Ángel efdabb758d fix(cli): connector_detached now skips fully dash-hidden connector shafts (#3912)
* fix(cli): connector_detached now skips dash-hidden connector shafts

connector_detached fired on SVG connector paths that were 100% hidden
via stroke-dashoffset/stroke-dasharray draw-on entrances, evaluating
rendered/user-space geometry unconditionally. The sibling
connector_orphan check already gates on shaftDashHidden(path); add the
same gate here, in the same position.

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

* fix(cli): connector dash gate reads the whole stroke-dasharray; one owner for connector enumeration

`shaftDashHidden` read only the first `stroke-dasharray` entry, so any
pattern starting with 0 (`0 4` dotted) or a long first dash counted as
hidden and suppressed both connector findings on a visible stroke. It now
resolves the full computed list (comma/space separated, units,
percentages against the SVG viewport diagonal, odd lists repeated;
`none`, all-zero and negative lists render solid) and treats the shaft as
hidden only when the window `[dashoffset, dashoffset + length]` sits in a
single gap, with up to 10% of the length overlapping a neighbouring dash.
Zero-length dashes paint only under `stroke-linecap: round | square`;
under the default `butt` they render nothing, which also covers the
`0px, 999999px` state a finished draw-off tween leaves behind.

`connector_detached` and `connector_orphan` each walked SVG paths with
their own copy of the candidate/gate logic, which had already drifted.
A single `connectorShafts(root)` generator now owns enumeration, the
dash gate and endpoint resolution, and yields the `painted` verdict;
both findings consume it. The path length is queried once per shaft
(`pathUserEndpoints` returns it) instead of twice, and the attach
threshold formula has one owner.

Tests pin `0 4` (butt and round), `0, 4`, `4 0`, `0`, `none`, px units,
negative offsets, a draw-off end state, and both sides of the 10%
boundary.

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

* refactor(cli): shorten the connector and dash-window comments

* test(cli): shorten the dash-gate table comment

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 05:38:42 -07:00
miga-heygenandMiguel Ángel 385e9cf337 fix(lint): error on digit-leading ids only when a selector targets them (#3980)
* fix(lint): block on digit-leading ids instead of only warning

A digit-leading id (e.g. id="1-hook") breaks any bare `#1-hook` CSS
selector with a SyntaxError, including inline GSAP string selectors
like tl.fromTo("#1-hook", ...). That throw silently aborts the rest of
the <script> block that built the selector, so the referenced element
never animates — but the render itself still reports success, and the
freeze has no visible error anywhere.

id_requires_css_escape already detects this correctly; it was only
"warning" severity, which hyperframes render and hyperframes check
both treat as non-blocking by default. Promote it to "error" so
`hyperframes check` (which always treats errors as blocking) catches
this unconditionally instead of only under --strict.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(lint): drop the digit-leading id severity comments

* fix(lint): error on digit-leading ids only when a selector targets them

* fix(lint): say a CSS-only digit selector drops the rule instead of throwing

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 05:32:52 -07:00
miga-heygen 1248639e71 fix(lint): point the repeated fromTo baseline hint at immediateRender: false (#3895)
The repeated fromTo baseline lint hint now points at immediateRender: false. Hint text only; rule logic unchanged.
2026-10-02 05:06:17 -07:00
miga-heygenandMiguel Angel Simon Sierra 9afafa79ec fix(lint): flag an id override written with reduced-specificity selector forms (#3871)
* fix(lint): flag an id override written with reduced-specificity selector forms

An attribute selector on id (`[id="x"]`, class-level specificity) or a
`:where(#x)`-wrapped id selector (spec-defined zero specificity) genuinely
loses to a compound class selector like `.parent .row` under standard CSS
cascade rules — unlike a bare `#id`, which always wins regardless of how the
competing class rule is written. When either form is used to override a
position property (left/top/right/bottom), the element can silently render
at the class-default position instead of its authored one, with lint,
validate, inspect, and layout all reporting clean (a real, resolved position
exists, it's just the wrong one).

No existing check performs any CSS rule-specificity analysis — confirmed at
the source level (dist/cli.js has zero "specificity" occurrences, and
createInspectCommand reports position via getBoundingClientRect() only).

Adds `id_override_reduced_specificity`, a new core lint rule flagging this
authoring pattern specifically (not general cascade-conflict detection,
which would require full rule-graph modeling well beyond this P3's scope).
Extracted `rightmostCompoundNodes()` out of `selectorAliasesRuntimeHiddenStyle`
so the new rule's selector-subject walk doesn't duplicate it.

* fix(lint): share the selector-safety parse and skip compounds that keep a bare id

* style(lint): wrap the id attribute check for oxfmt

* refactor(lint): move the reduced-specificity id check into its own helper

* fix(lint): hint an escaped #id and skip prefix and !important id overrides

* style(lint): wrap the prefix-selector test for oxfmt

* refactor(lint): split the weak-id node check out of the selector walk

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 05:06:11 -07:00
Santhi PrakashandMiguel Ángel cad7dbed0d fix(engine): warn when a video input declares alpha but decodes opaque (#3771)
* fix(engine): warn when a video input declares alpha but decodes opaque

- Problem: a video input whose alpha_mode=1 tag outlives its alpha plane (a remux drops the BlockAdditional sidecar while keeping the tag) composites as a solid rectangle, and nothing tells the user their file, not the renderer, is the problem (#3220 / #3226).
- Fix: during extraction preflight, when an input declares alpha (hasAlpha) and its codec can carry alpha, sample the decoded alpha plane (up to 3 frames at 8x8 rgba, bounded 30s, ~768-byte ceiling). If uniformly opaque, emit a non-blocking stderr warning naming the file with the re-export remedy. Inconclusive probes stay silent; opaque full-frame backgrounds are legitimate and never fail the render.
- Verification: 8 new unit tests (byte logic + message); engine suite 1483/1487 with 4 pre-existing failures on clean main (missing hdr-regression PNG fixture in this clone); typecheck/lint/format clean; real-fixture checks: lying-tag WebM warns, genuine-alpha WebM stays silent, both agree with ffmpeg alphaextract.

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>

* fix(engine): probe input alpha with the decoder that matches its codec

* style(engine): format the alpha probe test

* fix(engine): keep the alpha probe from failing or racing a render

---------

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 04:59:16 -07:00
miga-heygenandMiguel Angel Simon Sierra 0eba8d6a06 fix(cli): route FFmpeg exec-failure hint through getFFmpegInstallHint() (#3877)
* fix(cli): route FFmpeg exec-failure hint through getFFmpegInstallHint()

checkFFmpeg()'s "cannot start" branch (binary found but fails to run
-version) hardcoded a Windows/DLL-flavored install hint on every
platform instead of delegating to the already platform-aware
getFFmpegInstallHint() used by every other branch in this file.

* test(cli): pin the ffmpeg cannot-start hint through the async probe mock

* fix(cli): keep the runtime-DLL cannot-start hint on Windows

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-10-02 04:54:30 -07:00
renovate[bot]andMiguel Ángel 0b88cd0537 chore(deps): update node.js (#4245)
* chore(deps): update node.js

* fix(deps): keep the Node 22 engines floor at >=22

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 04:38:31 -07:00
lorenzozane 924b933456 fix(lint): ignore attribute values when checking media attributes (#4396) 2026-10-02 04:26:16 -07:00
miga-heygenandMiguel Angel e0d4c96003 fix(producer): honor FFMPEG_PROCESS_TIMEOUT_MS during video-frame extraction (#3918)
* fix: honor FFMPEG_PROCESS_TIMEOUT_MS during video-frame extraction

FFMPEG_PROCESS_TIMEOUT_MS resolves correctly into EngineConfig and is
correctly forwarded to the audio-extraction ffmpeg call, but
runExtractVideosStage (shared by both the in-process renderer and
distributed plan()) built the config object it passes to
extractAllVideoFrames with only { extractCacheDir, extractCacheMaxBytes },
silently dropping ffmpegProcessTimeout even though the full EngineConfig
was already in scope. That config flows straight into
extractVideoFramesRange, which falls back to a hardcoded 300s deadline
whenever the field is undefined -- so video-frame extraction always
used a fixed 300s timeout no matter what the env var was set to.

Threads cfg.ffmpegProcessTimeout into that config literal the same way
the two cache fields already are.

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

* fix(producer): pass the whole engine config to video-frame extraction

The stage hand-picked fields from its resolved config, so any field it forgot
(the ffmpeg process timeout) silently fell back to the engine default.

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 04:26:10 -07:00
9d6ca45857 fix: unbreak the regression suite on Windows (PSNR filtergraph escaping + console window popups) (#3839)
* fix: hide console windows spawned by ffmpeg and nvidia-smi on Windows

Node's `windowsHide` defaults to false, so every child process that is a
console application pops a window on the user's desktop. During a render
or a regression run that is a stream of black windows stealing focus.

The repo already sets `windowsHide: true` across cli, studio-server and
most of engine; these were the remaining gaps, all on paths that run
during a normal render:

- core/mediaGradeAnalyzer: ffprobe + ffmpeg, once per analyzed media file
- engine/browserManager: `nvidia-smi` VRAM probe. This one goes through a
  shell (`execSync`), so it flashes a cmd.exe window even on machines with
  no NVIDIA GPU, where the command exists only to fail
- producer: the regression harness ffmpeg runner, PSNR/audio comparison
  helpers, fixture synthesis, and the ffprobe call in audioPadTrim

No behavior change on POSIX, where the flag is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(producer): escape PSNR stats_file for ffmpeg's two-pass filtergraph parse

An ffmpeg filtergraph argument is unescaped twice: once when the graph is
split into filters and their options, then again when the option value is
read. `psnrAtFrames` escaped `\` and `:` a single time, which only survives
the first pass.

On POSIX that is invisible, because mkdtemp under tmpdir() produces neither
character. On Windows the stats path is always `C:\Users\...`, so it reaches
the option parser with a bare `:`, which starts a new option and fails the
whole graph:

    [AVFilterGraph] No option name near '\Users\...\psnr.log'
    Error parsing filterchain '[rv][gv]psnr=...:stats_file=C\:\Users\...'

Every suite that got as far as quality validation died there, so a Windows
regression run reported 0 passes with only 2 genuine compilation failures.
Escaping for both passes fixes it and stays a no-op on POSIX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(producer): pin PSNR stats path escaping with a colon and backslash path

* fix(producer): keep the PSNR stats path out of the filtergraph

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 04:14:36 -07:00
Miguel Ángel 6090f539e6 chore(renovate): stop raising engines and peer dependency floors (#4900) 2026-10-02 04:10:33 -07:00
renovate[bot]andMiguel Ángel 29ded9b0fa chore(deps): update github-actions (#3934)
* chore(deps): update github-actions

* ci: re-pin producer test job hash after the checkout action bump

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 03:41:24 -07:00
6632d99c9c fix(engine): pin capture surface scale to 1 device pixel per CSS pixel (#3782)
* fix(engine): pin capture surface scale to 1 device pixel per CSS pixel

Capture reads the browser's compositor surface (`Page.captureScreenshot`
with `fromSurface: true`, and `HeadlessExperimental.beginFrame` likewise),
and that surface is rasterized at the host display's backing scale.
`page.setViewport({ deviceScaleFactor })` goes through
`Emulation.setDeviceMetricsOverride`, which only moves
`window.devicePixelRatio` on the page side — it does not resize the
surface.

On a HiDPI host every render therefore came out at twice the composition:
a 1920x1080 composition encoded to a 3840x2160 file, and no flag could
bring it down, because the pipeline's own deviceScaleFactor was already 1.
Supersampling stacked on top of it, so `--resolution landscape-4k`
produced 7680x4320 rather than 3840x2160.

Pinning the surface with `--force-device-scale-factor=1` leaves the
deliberate supersampling path (the capture clip's `scale`, from
`resolveDeviceScaleFactor`) as the only thing that multiplies output
pixels. The flag is a no-op on Linux, where the surface is already 1x, so
CI and cloud output are unchanged.

* fix(engine): drop the capture-scale comments the ratchet rejects

---------

Co-authored-by: Leo Xu <xuliyou@bytedance.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 03:30:44 -07:00
Miguel Ángel 70ed332a45 test(studio): score edits on GSAP-animated size, scale, rotation and keyframes (#4866)
* test(studio): score edits on GSAP-animated size, scale, rotation, keyframes and from/fromTo

* test(studio): settle the edit bench only once no shadow preview is waiting to be swapped in

* test(studio): end a drag at its recorded pointer-up, even when no frame painted while held

* ci(studio): run the keyframed edit accuracy cases in CI across 20 shards

* refactor(studio): split the keyframe render and stray-css checks into small helpers

* test(studio): bank the edit bench's smooth verdict as smooth, judged on the worst frame's work

* style(studio): name the edit gate's smooth count helper

* test(studio): count smooth edit cases only among the accurate ones

* test(studio): only keyframed cases wait for the preview swap, so others keep main's undo timing

* refactor(studio): keep the edit bench's keyframe check apart from the hidden preview scan

* test(studio): check the keyframed cases are in the full grid without pinning its size

* test(studio): quarantine the nudge sequence whose redo waits behind preview videos (#4889)

* test(studio): bank the 255 keyframed edit cases that pass
2026-10-02 03:26:08 -07:00
Wu ShuwenandMiguel Ángel 74e6df4370 fix(release): use portable Git paths in release guards (#3766)
* fix(release): use portable Git paths in release guards

* fix(release): drop the comment on a path git never compares

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 03:17:16 -07:00
Miguel Ángel cafb86a4ab Merge pull request #4896 from heygen-com/release/v0.8.111
chore: release v0.8.111
v0.8.111
2026-10-02 01:33:39 -07:00