5121 Commits
Author SHA1 Message Date
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
Miguel Angel Simon Sierra 8447a55a1d chore: release v0.8.111 2026-10-02 01:32:47 -07:00
Miguel Ángel de13693179 feat(studio): a host undo key paints a still-saving move back at once (#4894)
* feat(studio): let a host's undo key paint a still-saving move back at once

* test(studio): prove the public revert from a .ts test and drop the export comment

* test(studio): a failing revert check cannot leave an edit pending, and a second revert paints nothing

* test(studio): load the package index once at collection, not inside the revert test
2026-10-02 01:16:28 -07:00
Vance Ingalls 8bf7d09bdf Merge pull request #4895 from heygen-com/release/v0.8.110
chore: release v0.8.110
v0.8.110
2026-10-02 00:56:12 -07:00
Miguel Ángel a2efe7b46d fix(studio): timeline lanes hold still while a clip drags onto a new track (#4892)
* fix(studio): timeline lanes hold still while a clip drags onto a new track

* fix(studio): name the timeline pin guards instead of commenting them
2026-10-02 00:55:41 -07:00
Vance Ingalls ec6363d3b6 chore: release v0.8.110 2026-10-02 00:31:59 -07:00
Vance IngallsandClaude Opus 5.5 70900216f0 feat(studio): audio gain on G, speed in clip names, additive selection and partner clamp (8/8) (#4821)
* feat(studio): show clip speed in the clip name as [150%] or [ramp]

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

* feat(studio): one fx badge per clip replaces the per-tool and link badges

Grey with no effects, white with any. The tooltip lists look, voice, other
fx, crop and ducking; a click opens the clip menu. The volume badge stays
when volume is off 100%.

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

* feat(studio): premiere-style audio gain dialog on G

Set Gain to, Adjust Gain by, Normalize Max Peak to, Normalize All Peaks to
and Normalize loudness to -16 LUFS, with the selection's peak amplitude read
from the peak map. Writes data-volume on every selected clip with sound as
one undo step. The clip menu's Normalize loudness becomes Audio Gain... (G).

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

* fix(studio): use a theme blue for the audio gain OK button

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

* fix(studio): keep shortcuts out of the audio gain modal; refuse while recording

Keys pressed inside an aria-modal dialog no longer reach the global hotkeys,
so Delete on the focused OK button cannot delete the clips the dialog edits.
The multi-clip write refuses during a recording like the single-clip one.

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

* fix(studio): offer link for any unlinked video and audio pair

Link no longer requires the same file and identical timing, matching Premiere.
Same-file pairs get a sync origin so a trimmed or moved audio shows its
offset; different files link without one. Merge back is disabled until the
pair is in sync. Linked moves keep the offset, and a trim only carries to a
partner whose edge is at the same time.

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

* fix(studio): shift and cmd-click add timeline clips to the selection

A modifier click on a clip replaced the selection, so a video and an audio
could never both be selected and Link never appeared. Shift, Cmd and Ctrl
now toggle the clip (and its link partners unless Alt) in the selection;
Shift no longer turns a clip click into a time-range popover.

Right-clicking a lone unlinked video or audio offers Link when exactly one
opposite-kind clip from the same file sits on another track.

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

* fix(studio): keep audio inside its partner video and make cmd-click add in every host

A modifier click passed the toggle as an argument to an optional
onSelectElement call, so hosts without that handler (desktop) never ran it.

An audio clip linked to a video, or sharing its sync origin, now stays
inside that video's span while dragged or trimmed, Alt edits included,
unless the video moves or trims in the same gesture.

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

* fix(studio): hold the drag actor at the partner-video clamp

The dragged audio's ghost followed the pointer past the video's end while
the landing stayed clamped; it now stops where the clip will land.

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

* feat(studio): preview a look on hover in the clip menu before applying it

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

* refactor(studio): trim comments the clip-link names already carry

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

* fix(studio): unlinking clears the timeline selection

After Unlink both clips stayed selected, so the next edge trim ran the
multi-select group resize and the former partner still followed. Unlink
from the menu, the shortcut or an Alt edit now leaves nothing selected.

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

* fix(sdk): keep a linked partner's end when a trim changes the grabbed duration

Copying the grabbed clip's duration to a partner that starts later pushed the
partner's end past the clip both ended on. The partner's duration is now the
shared new end minus its own start.

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

* fix(studio): keep every moving detached audio inside its video, after placement too

The containment clamp ran before collision placement and only for the
grabbed clip, so a drop pushed past an obstacle, or audio carried along in a
multi-selection, could land outside its video. The allowed shift is now the
intersection over every moving audio, and a placement outside it is
rejected. Link partners are matched within their source file.

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

* fix(studio): carry the normalize refusal and save outcomes into the gain dialog

Normalize moved from the clip menu into the Audio Gain dialog here, so the
volume-lane refusal and the saved/refused/failed checks go with it: a lane
owned clip is refused, and a failed single or multi-clip save rejects instead
of reporting success.

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

* refactor(studio): trim comments the gain and multiselect layer added

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

* refactor(sdk): split the linked partner's duration out of partnerTiming

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

* fix(sdk): refuse a linked trim whose new end would cross the partner's start

With linked video [2,8] and audio [3,8], setTiming(video, {duration: 0.5})
computed a partner duration of -0.5, skipped it, and committed the video alone,
leaving the audio outside its video. The partner edits are now planned before
any mutation; a non-positive partner duration throws ("Linked audio would start
after the new end — unlink or trim the audio first.") and can() reports
E_LINKED_PARTNER_CROSSED, so nothing is written and the undo stack is
untouched. In Studio the refusal fails the cutover (never declines into the
legacy path) and the timeline resize toast shows the reason.

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

* fix(sdk): key a linked edit's grabbed baselines by composition scope

planLinkedTiming stored each target's timing under its raw data-link id, while
linkedPartnerIds scopes link ids to their host composition. One setTiming naming
a root video and a nested video that both use lk-1 kept only the nested baseline,
so the root audio was skipped and left past its trimmed video. Baselines are now
keyed by host scope and link id, the same rule linkedPartnerIds uses, for both
the partner trim and the crossing refusal.

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

* test(sdk): run the two-scope grabbed-baseline cases on an inline composition too

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:19:53 +00:00
Miguel Ángel 6388845ac9 fix(studio): a hand drag moves only with its own pointer pressed (#4880)
* fix(studio): a hand drag moves only with its own pointer pressed

Chromium re-sends the last known pointer point with no button held after a
layout change. The overlay's move handler took those as drag travel, so the
element flipped between the pointer and that stale point, and the release
committed whichever came last.

* test(studio): the edit accuracy grid drops a drag after a buttonless move at the press point

Nine pathstray cases: a 180,90 drag whose last move before the release is a buttonless pointermove back at the press
point, as Chromium resends after a layout change. Main saves the element where it started; this head saves it where
the pointer let go. The frame sampler ignores that untrusted move as pointer position.

* fix(studio): keep the drag rule's comment to one line

* fix(studio): crop handles and the marquee band also ignore a buttonless move mid-gesture

* test(studio): bank the nine stray-move cases in the edit accuracy baseline

* test(studio): one crop drag helper, the band's candidate refresh in its own step, and formatting

* style(studio): wrap the pointer gesture comment under 120 characters
2026-10-02 00:08:20 -07:00
Miguel Ángel 2b495e29dd Merge pull request #4893 from heygen-com/release/v0.8.109
chore: release v0.8.109
v0.8.109
2026-10-02 00:04:05 -07:00