3960 Commits
Author SHA1 Message Date
Miguel Ángel aca4bc2f49 chore: release v0.8.113 (#4913) 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
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) 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
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 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
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
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
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
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
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
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
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 Angel Simon Sierra 6691e59b1a chore: release v0.8.109 2026-10-02 00:02:36 -07:00
Vance IngallsandClaude Opus 5.5 45b8e171e2 feat(studio): sync origin, out-of-sync repair, linked selection toggle and grouped clip menu (7/8) (#4820)
* feat(studio): order the clip menu into time, sound, picture and delete groups

Split and Freeze frame; Normalize, Voice, link items and Duck; Look and Crop;
then Delete beside Delete this clip only. Each item file renders its pieces
separately, empty groups collapse, and the viewport clamp uses the measured
menu height instead of a row-count estimate.

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

* fix(studio-server): mint freeze-frame right-half links with the shared lk-N helper

Freeze frame now calls core relinkSplitHalves, so split halves and freeze
halves share one link-id convention instead of L -> L-2.

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

* feat(studio): apply speed edits to every data-link member in one undo step

The inspector speed slider, rate lane edits and ramp presets on a linked clip
now commit once, writing the selection and, via expandToLinkedMembers, each
partner from its own current value. Unlinked clips keep their usual path.

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

* refactor(studio): drop the audio parent tick in favour of the data-link chain badge

A linked clip now shows one link indicator: the chain badge driven by
data-link. The waveform no longer draws its 1px parent tick.

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

* fix(studio): follow late clip-menu rows and target link partners like S2

The menu height follows a ResizeObserver so rows that read the live preview
after mount still clamp inside the viewport. Speed fan-out builds partner
targets with buildPatchTarget, matching the link edit plan.

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

* fix(studio): show the sound strip and audio menu items without studio's shell

The audible-video waveform strip drops AudioWaveform's 16px label band, which
left a 0px canvas on short lanes. Normalize loudness and Duck under voice fall
back to the timeline session's project, the live preview iframe store and a new
onNotice edit callback when no StudioShellProvider is mounted.
usePreviewIframeStore is exported so a host can publish its preview.

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

* fix(studio): show duck under voice only when a voice overlaps

The item now appears only when an overlapping voice exists or the bed is
already ducked. Its check mark moves to the right edge so unchecked rows
align with siblings.

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

* feat(studio): name the partner in the clip menu link and unlink items

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

* fix(studio): clip menu stops offering unlink once the clips are unlinked

The timeline skipped store updates when only data-link changed,
so the menu kept reading the stale link.

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

* feat(core): sync origin, offset math and move/slip into sync for linked pairs

data-sync-origin survives Unlink; split gives right halves a fresh origin.

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

* fix(studio-server): split re-mints the sync origin of unlinked pairs too

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

* feat(studio): detach, link and keep-sound cutout write a sync origin that unlink keeps

TimelineElement.syncOrigin is read on both parse paths; merge back drops it.

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

* test(studio): expect sync-origin on keep-sound cutout write

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

* feat(studio): linked selection toggle in the timeline toolbar, on by default

Off: a click selects one clip; drag, trim, razor, speed and delete stay on it
(delete unlinks the survivor). Alt-click still selects one half when on.
Right-click holds the out-of-sync indicator preference. Both persist in UI prefs.

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

* feat(studio): link groups share a label colour; out-of-sync badges with move and slip

Red badge on both halves of a sync-origin pair that drifted (frames, s:ff past
a second); hidden when rates differ or the preference is off. The whole badge
opens Move / Slip into Sync; each edits that clip alone in one undo step.

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

* fix(studio): take link label colours from theme tokens

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

* feat(sdk): syncOffset, moveIntoSync and slipIntoSync for sync-origin pairs

Core gains findSyncPartner, the DOM pairing shared by the SDK and the CLI.

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

* feat(cli): timeline rows report syncOffsetFrames and print out-of-sync

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

* test(cli): the timeline row fixture carries syncOffsetFrames

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

* docs(skills): data-sync-origin in hyperframes-core

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

* fix(core,sdk): keep a one-sided cut's sync origin and slip the in-point playback reads

relinkSplitHalves reminted the right half's sync origin even when only one
member of an unlinked source pair was cut, so the uncut partner kept the old
origin and the new half lost its badge. The origin is now reminted only when
both the video and the audio were cut.

Slip into Sync wrote data-media-start whenever it existed, but playback
reads data-playback-start first, so a clip carrying both never moved. The
link timing reader now uses playback's precedence and the slip writes the
attribute that wins; lint compares the same field. A sync partner is also
looked up only in the clip's own composition (core) or source file (Studio).

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

* fix(studio): flip the out-of-sync menu above the pointer near the bottom edge

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

* refactor: trim comments the sync-origin layer added

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

* refactor(core): split the scoped candidate lookup out of findSyncPartner

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

* fix(core,studio): find sync partners inside the link scope

findSyncPartner and Studio's syncPartnerOf now scope by the same rule as
link groups: the nearest data-composition-id or data-composition-file
ancestor, plus the row's compositionScope in Studio. An origin reused in a
file host or an inline composition no longer pairs across it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:08:37 +00:00
Miguel Ángel b5e3ffa6a7 fix(studio): soft reloads restore what GSAP wrote and holds from the saved files (#4871)
* fix(studio): soft reloads put a nested element's tweened style back from its own file

* fix(studio): timing edits, held elements and DOM-building scripts reload from the files

* refactor(studio): one owner settles the nested file reads for every soft reload
2026-10-01 23:24:32 -07:00
Miguel Ángel 6dcc300fd2 fix(studio): Renders dropdown options take a mouse click again (#4890)
* fix(studio): Renders dropdown options take a mouse click again

* test(studio): name a Select whose list never opens and keep both Studio layout checks running
2026-10-01 23:22:06 -07:00
Vance IngallsandClaude Sonnet 5.5 0559be0b13 feat(studio): link a video and its audio with data-link (6/8) (#4818)
* feat(core): add data-link media link helpers

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

* feat(lint): warn on out-of-sync and orphaned linked clips

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

* feat(studio): select, drag and trim linked clips together

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

* feat(studio): detach, merge, link and unlink source transforms

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

* feat(studio): link-aware split, delete, alt-edit unlink and link edit actions

Split and delete fan out to link partners. Alt drag/trim edits one member
and unlinks it. onLinkEdit runs unlink, link, detach and merge as one undo
step each. Single-clip timing edits of a linked clip skip the SDK path so
its linked default cannot move the partner behind the store.

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

* feat(studio-server): give split right halves of a linked pair their own link

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

* feat(studio): clip menu detach, unlink, link, merge back and delete-one items

Items live in clipMenuLinkItems.tsx with a one-line hook in ClipContextMenu;
the same resolver drives the Cmd+L, Opt+Shift+D and Opt+Delete shortcuts.

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

* feat(sdk): setTiming applies to data-link partners by default

{ linked: false } edits one member and unlinks it, matching Studio's Alt-edit.

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

* docs(skills): document data-link linked clips in hyperframes-core

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

* chore(studio): keep comment share at or below base in link plumbing

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

* test(studio): audio pill link follows data-link, not a shared file name

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

* fix(studio): alt-click selects one linked member even inside the pair selection

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

* refactor(lint): drop the rule-list comment for the linked clip rules

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

* fix(sdk,lint): keep a link group inside its own composition

Link ids are minted per file, so lk-1 is normal in the root and in a child
composition. The SDK's linked-timing query descended into nested hosts and
addressed every match with the initiating scope, so moving a root clip moved
the child's pair too; lint grouped them into one drifting group. Both now
group only members of the same composition scope.

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

* fix(studio): match linked media by full asset path, scope links, keep hidden sound silent

Merge and Link compared only a lowercased basename, so assets/one/talk.mp4
and assets/two/talk.mp4 enabled a destructive Merge. Clips now compare the
src resolved against each clip's own source file, and Merge re-checks the
two srcs in the source before it runs. Link membership in the timeline
(linked members, gesture expansion, unlink orphans) is scoped to a source
file. Detach and the keep-sound cutout carry a hidden video's data-hidden
to its audio, and Merge refuses when only one member is hidden.

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

* refactor(studio): move the timeline move-edits handler into its adapter

App.tsx sat at 600 lines on this layer and 602 once merged with main.
useTimelineMoveEditsHandler now lives beside persistTimelineMoveEditsAtomically,
same callback and dependencies, bringing App.tsx to 592.

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

* fix(sdk,studio,lint): keep link groups inside inline compositions

A link group now stays inside the nearest data-composition-id or
data-composition-file ancestor. The SDK used to recognise only file hosts,
and Studio compared only sourceFile, so an inline child composition that
reused a link id was edited together with the root group. core/media-link
linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import
core, so it uses the same two attributes directly. Studio rows record their
compositionScope, and link and merge refuse pairs that cross it.

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

* fix(sdk): refuse a link edit whose partner address names another clip

A partner addressed in the caller's scope can resolve to a different element
when an inline composition reuses a root clip's data-hf-id. setTiming now
checks that each partner's address resolves back to that partner and throws
before mutating when it does not, instead of editing the root clip.

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

* fix(studio): a remote media url keeps its query when matching an asset

Two different videos at one remote path with different queries were treated as
the same file, so Merge could delete the audio and use the wrong video's
sound. Project-local srcs still drop query and hash.

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

* fix(studio): keep a remote url's query exactly as written when matching assets

Decoding the whole url folded %26 into & so two different media urls matched.
Decode only the path; the query is compared as authored.

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

* fix(studio): compare a remote url as serialized, decode only local paths

A remote pathname with an encoded reserved character (%3F) matched the same
path with a literal ?, though they can return different media. Remote urls
keep origin, path and query exactly; local srcs are still decoded and
stripped of query and hash.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:23:25 +00:00