1132 Commits
Author SHA1 Message Date
Vance Ingalls 3ed971d018 chore: release v0.8.13 2026-08-24 12:51:02 -07:00
Miguel Ángel e5a5e6b151 fix(cli): keep overlap waivers local to marked text (#3464)
* fix(cli): scope overlap waiver to marked text

* fix(skills): guard changelog caption rail

* fix(skills): densify changelog caption checks

* test(skills): satisfy strict seek typing
2026-08-24 14:22:03 -04:00
Santhi Prakash 95e1ac9f04 fix(skills): skip mirror fan-out to agents that read the universal store (#3325) 2026-08-24 09:41:19 -04:00
Vance Ingalls 2ca578f945 chore: release v0.8.12 (#3457) 2026-08-23 19:54:55 -07:00
Vance Ingalls 2685c8f223 docs(audio): document grouped audio and its guardrails (#3455)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions

* fix(studio): harden carve and FX rack behavior

* fix(studio): repeat audio FX reveal requests

* fix(studio): reconnect property-panel audio controls

* fix(studio): unify property panel audio detection

* fix(studio): satisfy panel and deletion gates

* feat(studio,core)!: remove solo and the group meter

* docs(audio): keep removal rationale current

* refactor(core): retire studio solo bridge

* docs(audio): document grouped audio and its guardrails

* docs(audio): point handoff at replacement stack
2026-08-23 19:19:27 -07:00
Miguel Ángel 32d58a73e3 chore: release v0.8.11 (#3440) 2026-08-23 14:49:13 -04:00
Miguel Ángel 59a69a145b chore: release v0.8.10 (#3426) 2026-08-22 11:16:32 -04:00
Vance Ingalls f6e8e8ddfd chore: release v0.8.9 (#3422) 2026-08-22 05:57:57 -07:00
Santhi Prakash 718bf5ef32 fix(producer,cli): surface every tried manifest path in the missing-manifest error (#3370) (#3387)
Closes #3370

## What

When `hyperframeRuntimeLoader` could not locate `hyperframe.manifest.json`, the loader reported a single fallback path that was never searched for (`/usr/local/lib/core/dist/hyperframe.manifest.json`). Inside a Docker render the user is then told to look at the wrong directory; the file that was actually missing (`/usr/local/lib/node_modules/hyperframes/dist/hyperframe.manifest.json`) was nowhere in the message.

## Why

`resolveHyperframeManifestPath()` built a 5-element `candidates` array, walked it with `existsSync`, and on total miss returned the last candidate. The error then quoted that candidate verbatim. The reporter even shows the exact reproducing command from a published image.

A second issue rode the same failure path: `packages/cli/src/commands/render.ts:902` keeps attaching the hint `"Try --docker for containerized rendering"` to users who are *already inside* the container. The container sets `ENV CONTAINER=true` and nothing reads it.

A third small thing came along: `CWD_RELATIVE_MANIFEST_PATHS[0]` was a byte-identical duplicate of `SIBLING_MANIFEST_PATH` — same path, two names.

## How

1. Hoist the candidate list to a single `MANIFEST_CANDIDATES` owner in `hyperframeRuntimeLoader.ts` and share it between the resolver and the error reporter. De-duplicate while doing it.
2. Add `triedManifestPaths()` as a tiny export so callers (and tests) can see what was actually searched.
3. Replace the source-text regex test that asserted on string positions inside `const candidates = [...]` with a behaviour test that points `PRODUCER_HYPERFRAME_MANIFEST_PATH` at a missing file and asserts the thrown error names it. Also exercise the no-override branch to confirm the sibling path is the first entry.
4. In `render.ts`, check `process.env.CONTAINER === "true"` before attaching the `--docker` hint. The chrome-launch and macos-old-chrome remediation branches already short-circuit before the hint, so an empty string is a safe value when the user is in the container.

## Test plan

- [x] `bunx vitest run src/services/hyperframeRuntimeLoader.test.ts` — 7/7 pass (`hyperframeRuntimeLoader error path (#3370)` describe covers the missing-manifest message and the tried-paths export).
- [x] `bunx tsc --noEmit` in `packages/producer` and `packages/cli` — clean.
- [x] `bunx oxfmt --check` and `bunx oxlint` on the touched files — clean.
- [x] `bunx fallow audit --base origin/main` — no new findings on the touched files.
- [x] Targeted producer unit lane: `node scripts/run-test-lane.mjs unit` — same 7 pre-existing failures as `origin/main` before the change (htmlCompiler.parity, audioPadTrim.integration); no regressions introduced.

Files touched:
- `packages/producer/src/services/hyperframeRuntimeLoader.ts`
- `packages/producer/src/services/hyperframeRuntimeLoader.test.ts`
- `packages/cli/src/commands/render.ts`
2026-08-22 02:09:11 -04:00
Vance Ingalls 6f82acf50c chore: release v0.8.8 (#3411) 2026-08-21 19:04:29 -07:00
James Russo dac8f9f912 feat: add promoted template edit contracts (#3407)
* feat: add promoted template edit contracts

* fix: address template contract review feedback
2026-08-21 18:39:17 -07:00
Miguel Ángel ea95b7d44e fix(cli): stop a caught post-render throw reporting a valid render as failed (#3409)
A render that produced and validated its artifact still exited 1. Reported
again from the field on 0.8.7: the MP4 was on disk and an independent ffprobe
and full decode both passed, and the CLI exited 1 immediately after logging
`artifact validated`.

`render-success-state.ts` exists for exactly this and documents three earlier
cases, so the sentinel was already there. Its gap is which paths read it: the
uncaughtException and unhandledRejection handlers both consult
`isRenderSucceeded()`, but a post-render throw that the command wrapper CATCHES
never reaches either. It becomes an ordinary non-zero CommandResult, and
`finalizeCli` wrote that straight to `process.exitCode`.

The result was a run that disagreed with itself: `commandSucceededForTelemetry()`
already lets a validated render override a failure, so telemetry recorded
success while the shell saw exit 1.

Sanitize once in `finalizeCli`, where every command result funnels through,
rather than wrapping the individual steps. Which step threw does not matter;
that the artifact is committed does. The throw is still printed, so it stays
visible for diagnosis without being fatal.

Reproduced first as a failing test (`expected 1 to be +0`) on macOS, so this is
not Windows-specific — the field reports are one instance of it. A second test
pins the other side: a command that throws with no validated render still exits
non-zero, so the sanitizer cannot swallow a genuine failure.
2026-08-21 21:01:43 -04:00
Miguel Ángel 5842dd8df4 fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)
* fix(studio): invalidate the preview signature off the watcher that sees project writes

The preview ETag is a hash of the project's files, memoised per project
directory. That cache was cleared from Vite's own watcher, which
`server.watch.ignored` deliberately excludes `data/projects/**` from, so
nothing ever cleared it: the ETag stayed frozen for the life of the dev
server, the preview answered every revalidation with 304, and the browser
went on serving the composition as it was when it first loaded.

The visible cost is thumbnails. Their disk cache key already content-hashes
the composition, so an edit correctly asks for a fresh capture, but the
capture is taken against the stale page, and a clip's filmstrip keeps
showing frames of a layout that no longer exists until the dev server is
restarted.

Studio already runs its own chokidar watcher over exactly these
directories, because Vite's would answer a composition edit with a full
page reload. That watcher now owns the invalidation, and the cache asks it
to follow any project directory it has not seen. All five event types
count: an added or deleted asset changes the signature as surely as an
edited one.

The cache moves behind `createProjectSignatureCache` so the invalidation
rule is a unit under test rather than a subscription buried in the adapter.

* fix(studio): filter signature invalidation, and stop the CLI server missing motion saves

Review follow-up on the unfiltered invalidation.

The watcher fired on everything under a project dir, but the signature walk
skips 14 directories and `.thumbnails` is one of them. That directory is
where the thumbnail route keeps its disk cache, and every capture also reads
the preview, so populating a timeline row discarded the memo on roughly every
request of the one workload it exists for.

The filter is a single exported predicate beside the exclusion set it reads,
and it is applied inside `invalidate` rather than at the watcher, so no caller
can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`:
that set is character-identical but drops all of `.hyperframes/`, and the
signature reads two manifest files back out of there.

Which is the same bug, still live, in the CLI server: its watcher filters
through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never
reached the listener that clears the cached signature. Studio writes that file
at runtime, so saving motion state left the preview ETag stale until restart.
The watcher now admits signature-relevant paths and the reload listener
re-applies its own filter, so what triggers a browser reload is unchanged.

Also from review: drop the `createViteAdapter` signature-cache default, which
produced exactly the memo-nothing-clears bug this PR fixes, and correct the
docstring — the content hash is already gated behind a stat fingerprint, so
what the memo saves is the walk.
2026-08-21 19:13:37 -04:00
Miguel Ángel 41af866bcb chore: release v0.8.7 (#3402) 2026-08-21 15:21:20 -04:00
Miguel Ángel 8b67bb6db5 fix(cli,studio): surface project lint in Studio (#3393)
* fix(cli,studio): surface project lint in Studio

* fix(studio): preserve per-file lint coverage
2026-08-21 15:11:24 -04:00
Miguel Ángel 63eb35041c fix(deps): bump puppeteer so the browser hides its console window on Windows (#3394)
Windows users see a console window per chrome-headless-shell worker during a
render. Those windows come from Puppeteer's own launcher, not from any spawn
in this repo, so the windowsHide work on our ffmpeg spawns could not reach
them.

@puppeteer/browsers added windowsHide: true to its spawn in 3.2.1. It is
absent in 3.1.0 and 3.2.0. puppeteer-core pins that dependency exactly, and
25.8.0 is the first release pinning 3.2.1 (25.5.0 -> 3.1.0, 25.6.0 and
25.7.0 -> 3.2.0), so 25.8.0 is the minimum that carries the fix rather than
a preference for the latest.

Verified after install that exactly one copy resolves, at 3.2.1, and that its
launcher carries the flag. A draft render still completes.

Refs #3379
2026-08-21 11:50:18 -04:00
Miguel Ángel a9ea07edde fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry

* fix(cli): complete blank entry safeguards
2026-08-21 11:00:43 -04:00
Vance Ingalls 556fe936f8 chore(cli): bump pinned Chromium to 152.0.7977.30 (#3231)
Picks up crbug 522872457's fix (CL 8032671), which landed after the
152.0.7935.0 canary cut and so was absent from the old 152.0.7928.2 pin.

Re-probed every 3D signal the compile gate matches, drawElementImage vs a
CDP screenshot of the identical state, on the shipping headless-shell
binary. PSNR, old pin -> new pin:

  backface-visibility:hidden    1.4 dB  ->  14.8 dB   still DAMAGED
  preserve-3d (no backface)    46.7 dB  ->  46.7 dB   clean
  perspective()                45.2 dB  ->  45.2 dB   clean
  matrix3d()                   45.2 dB  ->  45.2 dB   clean
  rotate3d()                   45.3 dB  ->  45.3 dB   clean
  translateZ under perspective 29.9 dB  ->  29.9 dB   marginal

The upstream fix repaired the collateral damage only: dropped sibling
content and lost backgrounds now render, but a culled backface is still
painted. So the 3D gate stays. Beta rather than Canary because
153.0.8000.0 measured identical on every variant.

Follow-up filed as PRINFRA-486: four of the five signals the gate matches
were never broken on any build tested, so it may be able to narrow to
backface-visibility alone. Needs a corpus eval first — this probe covers
static angles only, and animated 3D subtrees take a different path.
2026-08-20 23:39:45 -07:00
Vance Ingalls 7563b644a2 fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes (#2481)
* fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes

Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64, CLI 0.7.58) hit
`Failed to launch the browser process ... Code: 3221225595` with no stderr. Exit
code 3221225595 = 0xC0000409 = STATUS_STACK_BUFFER_OVERRUN, a Windows stack-
corruption fatal from the pinned chrome-headless-shell binary. The reporter
recovered by pointing HYPERFRAMES_BROWSER_PATH at system Chrome; render then
used the screenshot fallback and produced the MP4 cleanly.

The generic "Try --docker" hint the CLI already emits didn't name that env var,
so the workaround was undiscoverable. Add a Windows-scoped launch-crash
remediation sibling to `chromeLaunchRemediation` (Linux, `linuxDeps.ts`) and
`wrapDownloadFailureWithBrowserPathHint` (download-time, `manager.ts` — #2443).
Fresh concrete case for the #2078 lineage (closed with explicit invite to
resubmit on a concrete case).

- New `packages/cli/src/browser/windowsCrash.ts` — `isWindowsChromeCrashError`
  gates on Puppeteer's `Failed to launch the browser process` wrapper AND the
  specific crash code (decimal `3221225595`, hex `0xC0000409`, or symbol
  `STATUS_STACK_BUFFER_OVERRUN`), so unrelated Windows launch failures don't
  mis-fire this hint. `windowsChromeCrashRemediation` returns the actionable
  block scoped to win32.
- `render.ts` `handleRenderError` calls it after the existing
  `chromeLaunchRemediation` (Linux) check; both fall through to the generic
  errorBox if neither matches.
- Tests: 9 vitest cases covering positive matches on all three code forms,
  negative on Linux-shared-lib launch failures, negative on the code alone
  without the launch wrapper, and off-platform / non-launch short-circuits.

— Via

* fix(cli): fail the Windows crash branch through failCommand, not process.exit

`scripts/check-cli-process-ownership.mjs` AST-walks every non-test file
under `packages/cli/src` (bar `cli.ts`) and forbids direct process
termination — only the CLI entrypoint owns exit. The new Windows
chrome-headless-shell arm called `process.exit(1)` while both sibling arms
(Linux shared-lib, macOS) and the generic fallback call `failCommand()`,
so the required Lint job failed on that line and preview-regression failed
downstream of its preflight.

`failCommand()` carries the central failure-hook wiring, so this is the
behaviour the branch already wanted.
2026-08-20 23:38:58 -07:00
James Russo 36c7dffe5c chore: release v0.8.6 (#3386) 2026-08-20 21:47:29 -07:00
Miguel Ángel 7a8f8a0b45 chore: release v0.8.5 (#3375) 2026-08-20 19:03:09 -04:00
Miguel Ángel f822200fb8 feat(telemetry): measure which lint rules fire, cost, and fail to converge (#3367)
* feat(telemetry): measure which lint rules fire, cost, and fail to converge

Lint rule changes are currently argued from anecdote. This adds the three
measurements needed to argue them from data.

`lint_report`, once per `hyperframes lint` or `hyperframes check`:
- `code_counts` / `codes` — which rules actually fire, and how often
- `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...)
- `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>`
- `rule_count` — how many rules this build ran

`lint_rule_streak`, once per finding that survives an edit to its file:
- `edits` — how many edits the finding survived
- `cleared` — whether it eventually went away

The streak event is the one that matters. A lint pass costs about 5ms, so
per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot
satisfy is, because every failed attempt costs a full edit-and-relint cycle. A
single run cannot see that, so `lint_rule_streak` reconstructs it across runs:
high `edits` with `cleared: false` is a rule nobody can fix, and the
`cleared: true` distribution is the baseline to judge it against.

An iteration is counted only when the file's content digest CHANGED and the
finding is still there. Re-linting an untouched project is not an attempt,
which is what stops `check` (which lints on every invocation) from inflating
the numbers.

Rule identity is the source module plus an index within it. Naming all 86
rules would make the timings prettier but it is a refactor this measurement
does not need: the group locates the file, and the index locates the rule.

Version, agent runtime, CI flag, and invocation id are already attached to
every event by `trackEvent`, so lint pain can be split by CLI version and by
which agent produced it without adding anything here.

Privacy: only rule codes, counts, and timings are sent. Streak state lives in
~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf
~/.hyperframes` is still a full reset) and stores digests only — no file
paths, no project names, no composition source. Nothing is written and nothing
is emitted when telemetry is off. Entries expire after 14 days and are capped
at 500 files.

`EventProperties` gains string arrays and numeric maps. `codes` and
`code_counts` are inherently a set and a histogram; flattening them into
dynamic top-level keys would make them unqueryable. PostHog stores both
natively.

`trackLintRun` is the single call site shared by `lint` and `check`, and it
swallows every error — telemetry must never turn a green lint red.

* feat(telemetry): emit per-group rule counts so slowest_rule stays comparable

Review catch on #3367: `slowest_rule` is the one positional key in either
event. It is `<group>#<index>`, so adding or removing a rule renumbers every
later slot in that group and the same string means different rules in two
builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count`
alone says only THAT the ruleset moved, not which groups.

`rule_group_counts` carries the per-group sizes alongside it, so a consumer
comparing two builds can tell which groups' indices still mean the same thing
without anyone having to remember which release dropped rules. `codes`,
`code_counts` and `rule_group_ms` are keyed by name and were never affected.

Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as
LINT_RULE_COUNT in the same file computes.
2026-08-20 18:26:14 -04:00
Miguel Ángel 83ceaeb902 refactor(lint): drop seven rules that fire on correct compositions (#3366)
Each rule below either reports a hazard the compiler or runtime already
prevents, duplicates another rule's invariant with a weaker detector, or
cannot be cleared by its own fixHint. Measured over the 643 shipped
registry HTML files, this cuts lint output from 1740 findings to 507
(-70.9%) and removes 40 errors, with no new codes introduced.

- scene_layer_missing_visibility_kill: regex heuristic keyed on `#sceneN`
  ids. It only accepts the literal string `visibility: "hidden"`, so the
  canonical GSAP hard kill (`tl.set(el, { autoAlpha: 0 })`, which sets
  visibility hidden at runtime) never clears it — an unfixable error. It
  also matched the `0` inside `opacity: 0.5` and treated `.from({opacity:
  0})` entrances as exits. gsap_exit_missing_hard_kill owns this invariant
  using parsed tween timing and real clip boundaries, and accepts every
  hidden encoding.
- unscoped_gsap_selector: wrapScopedCompositionScript already rewrites
  string GSAP targets to the composition root for every sub-composition
  script (pinned by compositionScoping.test.ts "executes document and GSAP
  selectors inside the composition root"). The rule also never fired on a
  standalone sub-composition file or a <template> sub-comp.
- caption_transcript_parse_error: required the inline TRANSCRIPT array to
  be strict JSON so Studio could read it, but Studio's parseTranscriptArray
  already normalizes unquoted keys, single quotes, and trailing commas. It
  errored on ten shipped caption components whose transcripts Studio parses.
- composition_self_attribute_selector: warned that
  `[data-composition-id="x"] .y` leaks across instances, but
  scopeCssToComposition rewrites that selector to each instance's runtime
  scope. It was also the pattern the rest of the toolchain prescribes.
- timed_element_missing_visibility_hidden: strict subset of
  timed_element_missing_clip_class, which reports the same condition as an
  error, so it only ever added a second line saying the same thing.
- pointer_events_none: Studio selection ergonomics only, no render impact,
  on 124 of 211 shipped blocks.
- google_fonts_import: the producer resolves Google Fonts during
  compile/render, as the message itself said.

system_font_will_alias is narrowed to distributed/Lambda renders, where
system-font capture is off and the fallback is a real defect. Under a local
render the substitution is the renderer working as designed, so the info
tier is gone.

The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
2026-08-20 18:10:24 -04:00
Miguel Ángel 42b94fd5db chore: release v0.8.4 (#3359) 2026-08-19 19:28:13 -04:00
Miguel Ángel d464f60b96 fix(cli): zip the publish archive to the same bytes every time (#3358)
adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP
timestamp resolves to two seconds — so archiving identical content twice gave
different bytes whenever the two runs landed either side of a boundary. The
archive's digest was a function of the clock rather than of its contents, which
is backwards for something `cloud render` uploads and addresses by content.

It surfaced as a CI flake: publishProject.test.ts asserts two archives built
back to back are byte-identical, and both sides are the same expression, so the
only way it can fail is non-determinism. The window is narrow, which is why it
survived since July and why re-running always cleared it.

Entry times are now fixed. Built from local components deliberately:
`fromDate2DOS` reads getFullYear/getMonth/getHours, so a fixed instant would
still encode differently per timezone — verified identical bytes under UTC,
America/Los_Angeles and Asia/Kolkata.

The new test moves the clock across a boundary, which is what reproduces it;
back-to-back builds land in the same bucket almost always, which is exactly how
it hid.
2026-08-19 18:25:04 -04:00
Miguel Ángel b3c43e2480 feat(cli): add normalize-audio to match one clip's loudness to another (#3306)
* feat(cli): add normalize-audio to match one clip's loudness to another

Measures two authored `<audio>` clips with FFmpeg's integrated EBU R128
loudness and writes the target's matching `data-volume`, leaving the
reference untouched.

The measurement is bounded to the window the composition actually plays.
`data-end` bounds a clip's timeline window just as `data-duration` does, and
`-ss`/`-t` belong before `-i`: after it they bound the OUTPUT, and with
`-f null` there is none, so ebur128 keeps integrating past the clip. On a
fixture whose played window is -61.8 LUFS inside a file that measures -27.9
whole, either mistake reports a loudness the composition never plays and
"corrects" an already-matched clip by tens of dB.

Two EBU R128 passes run between reading the composition and writing it, each
bounded only by a two-minute timeout, and the skill docs tell agents to keep
Studio open meanwhile — so the attribute patch is re-applied to a fresh read
and written through a temp file and a rename.

Under `--json` the failures are documents too: an agent doing
`JSON.parse(stdout)` on a bare error line throws. A pair needing more than the
+12 dB ceiling has a source-file problem rather than a mixer one — mixer gain
raises the noise floor with the signal — so the refusal names the remedy.

* fix(cli): validate --tolerance before paying for the measurement

Each EBU R128 pass is bounded at 120s and normalize-audio runs two, so
parsing the argument afterwards made a typo'd --tolerance cost both of them
before failing on something that was wrong from the start.

Not pinned by a test: the ordering is internal to the command and neither it
nor the parser is exported, so covering it would mean restructuring for a spy
rather than asserting the behaviour.

* docs(cli): restore the blank line between the preview and normalize-audio sections

Lost when I resolved the rebase conflict against the background-preview docs
by hand instead of letting the formatter near it. oxfmt --check failed on the
one file, which fails Preflight — and because preview-parity needs Preflight it
skipped, and the preview-regression gate fails closed on a skip, so a missing
newline read as a preview defect.

The quieter half: the same needs chain meant the required Test context was
never created at that head. Not failing — absent, so there was no test signal
at all on the PR.
2026-08-19 17:36:10 -04:00
Miguel Ángel 9da422fd7f feat(cli): run a managed background preview in every launch mode (#3310)
`--background` was rejected outside the embedded server. It now re-execs the
CLI in foreground, which makes it mode-agnostic by construction: whichever
server the child resolves to serves the config endpoint the readiness probe
looks for. `--foreground` is its counterpart, for a non-interactive shell that
wants to stay attached, and a bare launch keeps the same promise — attached in
an interactive terminal, managed in an agent session.

That generalization exposed an existing hole. Local-studio mode runs Vite with
the studio package as its cwd and needs that package's own Vite config, which
the published tarball does not carry, but resolving the package was treated as
proof the mode was usable. An npm-installed studio therefore took a path that
can never come up — previously a clear error, now a ten-second silent timeout.
The predicate becomes "can this studio actually be served", so a published
install falls back to embedded mode, which works.

Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
2026-08-19 17:02:44 -04:00
Miguel Ángel 0e3c5f6bef feat(cli): give every preview lifecycle op one JSON document (#3309)
`--status`, `--stop`, `--list` and `--kill-all` emit a schema-versioned
envelope with an `ok` discriminant under `--json`, from one writer and one
failure-payload builder. Human output is unchanged; the JSON path is additive.

The value is in the failure paths. An agent that gets a bare error line on
stderr and an empty stdout cannot tell a crash from a "not running", so every
failure is a document too — including a missing project, which under `--json`
resolves through the throwing resolver rather than the human-shaped nudge.
2026-08-18 20:07:50 -04:00
Miguel Ángel 74149e249a fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)
* fix(cli): keep a live preview's ownership record and stop past a bad one

A missed liveness probe is not proof the preview is gone — a server blocked on
a Puppeteer capture answers nothing for a second or two — but any miss retired
the session record, and the record carries the only PID-reuse guard `--stop`
has. Reproduced by SIGSTOPping a managed preview and running `--status`: the
record was deleted and never came back, leaving every later stop to fall
through to an unauthenticated port scan with no ownership proof at all. Only a
wrapper process that is provably gone now retires a record.

That record gains a process-birth token so a recycled PID reads as a different
process, and it is written through a temp file and renamed — every reader
deletes it when it fails to parse, so a torn read would otherwise destroy a
live server's proof of ownership.

Two failure-propagation bugs in the stop path: `--kill-all` collected the
first unprovable record's exception and abandoned every server after it, so
they were left running AND unreported; and a replacement refused to launch
when the server it was replacing had already exited on its own, which is the
goal state rather than a failure. `--list` now shows managed sessions ahead of
whatever else answers the scan.

* fix(cli): keep a record whose identity lookup gave no answer, not a different one

Review blocker. The keep-alive path this PR adds could still retire a LIVE
record — through a different door than the one it closed.

`processIdentity` catches every failure into `null`, and on two of three
platforms that failure is a subprocess timeout on a live process: the win32
`Win32_Process` CIM query and the POSIX `ps -o lstart=` both run on a 2 s
budget, under exactly the load that made the HTTP probe miss in the first
place. A `null` compared unequal to the saved token, so the record was deleted
and `wrapperIdentity` — the only PID-reuse guard `--stop` has — was gone for
good. Only Linux, reading /proc directly, was reliable.

No answer is now distinguished from a different answer: the PID is checked with
`kill(pid, 0)` first, which asks the kernel without signalling and treats EPERM
as alive. A PID nothing can signal is gone and retires the record with no
subprocess at all; a signalable PID whose token cannot be read keeps it. Only a
token that comes back and differs retires it.

That ordering also answers the `--list` note: the identity subprocess no longer
runs for the stale records that made it slow, so the N x 2 s worst case is gone
along with the timeouts that fed the bug.

Verified by mutation: restoring the old "no answer means gone" behaviour reds
the new case. Also clean up the temp file when a rename fails, rather than
orphaning it in the session directory.

* test(cli): assert only what the birth-token lookup actually guarantees

`captures a stable birth token for the current process` made two assertions
that a lookup allowed to fail cannot support. `processIdentity` returns null
whenever the lookup cannot be completed — not only when the process is absent —
and on Windows and macOS it shells out to PowerShell or `ps` on a 2 s budget
that a cold CI runner routinely outruns.

Both failed on windows-latest, in sequence: first `.toMatch()` received null,
and once that was guarded, `expect(second).toBe(first)` compared a null from the
cold first spawn against a token from the warm second one.

Two lookups can disagree for exactly one reason — one of them failed — so
stability is only assertable across two successful ones. The token itself
cannot change between calls; it is a birth timestamp and the process did not
restart. `processIdentity(-1)` stays unconditional: the guard rejects it before
any subprocess runs.

The strict shape assertion moves to a Linux-only case, where /proc is read
directly with no subprocess and null is genuinely not allowed — keeping the
guarantee on the one platform that can honour it rather than dropping it
everywhere. Callers already depend on this contract: `wrapperProcessIsAlive`
treats null as "no answer" rather than "gone" precisely because it is reachable.
2026-08-18 19:50:02 -04:00
Miguel Ángel c1c70f44bd fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port

`/__hyperframes_config` is unauthenticated and the PID it reports is what
`--stop` and `--kill-all` send signals to, so any local process answering on
a scanned port could name an arbitrary PID and have the CLI kill it.
Reproduced with a twenty-line HTTP server on a scanned port self-reporting an
unrelated PID: before this, `--kill-all` killed that process; after it, the
process survives and only the real listener is stopped.

The listening PID now comes from the OS — `lsof`, and `netstat` on Windows,
where the lookup was previously unavailable and the self-reported value was
taken on trust. The response's own PID is used only where the OS lookup
fails, which is also the only case where it is unfalsifiable.

Orphan cleanup moves to the last step before a launch. It reaches outside the
process and kills other people's PIDs, so it must not run for an invocation
that turns out to be a validation error and never starts anything.

* fix(cli): fail closed when the OS cannot confirm who owns a port

Review follow-up.

The two halves of this change picked opposite directions for the same
condition. `isProcessDescendant` fails closed by design; `activeServerOnPort`
fell back to the self-reported PID whenever the OS lookup came back empty —
and that is not only "unsupported platform". `lsof` may be absent (the default
on many slim images), may time out, or may not see a socket owned by another
user. On such a machine every scanned port silently reverted to pre-change
behaviour, with nothing said.

Provenance is now part of the type rather than a convention: `ActiveServer`
carries `pidSource`, so a caller cannot mistake a self-report for the kernel's
answer. `--kill-all` requires `"os"` and skips the rest, naming the ports it
left alone and why. That is the deliberate trade — a blind sweep of a port
range has no evidence beyond an unauthenticated response, so an unconfirmed
PID must not be signalled. Managed previews are unaffected: they stop through
their session record, which proves ownership by process birth identity.

The fallback branch — the one with the security consequence — now has the
coverage it lacked, via an injected lookup matching the seam `testPortOnAllHosts`
and `isProcessDescendant` already use, including a live process that survives
because nothing confirmed it owns the socket.

Also state that `killProcessTree` honours `signal` on POSIX only: Windows
always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that
a console process may ignore. The caller-side comment claiming Windows cleanup
is a no-op described the code before this change and now says the opposite.
2026-08-18 17:41:46 -04:00
Miguel Ángel 3e4b08cdc1 chore: release v0.8.3 (#3327) 2026-08-18 11:11:46 -04:00
Miguel Ángel 049f5618d7 chore: release v0.8.2 (#3324) 2026-08-18 01:57:54 -04:00
Miguel Ángel ad84b00c90 chore: release v0.8.1 (#3319) 2026-08-17 21:16:00 -04:00
Miguel Ángel 5058236eda feat(studio): prompt to install FFmpeg before Export, not after (#3314)
Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.

Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.

- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
  optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
  runs, so Studio and the CLI cannot disagree. Only a passing result is
  cached.
- The refusal lives in startRender, not in a button. Studio renders from
  three places (the panel's Export, the header's, and each composition card
  in the sidebar), so a per-button check would leave the others free to
  queue a render that cannot finish. The header and sidebar controls reveal
  the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
  unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
  the prose hint derived from it. Windows gains a winget command and keeps
  the manual download route.

Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.

Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
2026-08-17 21:00:27 -04:00
Miguel Ángel ea7c48f372 fix(add): make chosen variables actually take effect, in the CLI and the preview (#3316)
* fix(add): apply --vars to components, and explain a failed download

Customising an item on the catalog page, copying the printed command and
running it did nothing for a component. `--vars` was accepted, documented
and then dropped: buildSnippet put the values on a block's mount element
and returned a bare "paste from ..." comment for a component, so 221 of
the 375 catalog items silently ignored every value the page produced.

A component has no mount element to hang values on. It is markup pasted
into a host, and it resolves values through __hyperframes.getVariables(),
which merges the declared defaults of every [data-composition-variables]
element in the document with render-time overrides. So the component's
own declaration is the only place a chosen value can live and still be
there after the paste. `add --vars` now rewrites those defaults.

Blocks keep the mount attribute. Per-mount values are strictly better
where a mount exists: the file on disk stays byte-identical to the
registry's, so a later reinstall can still tell an edit from an update,
and two mounts of the same block can differ.

A value the item cannot accept is now refused rather than written. An
out-of-range number or an unlisted enum value falls back at runtime and
warns, so writing one would produce a file that renders exactly as if the
value had been ignored -- the failure this change exists to remove. Ids
the item never declared are reported too, instead of vanishing. Only the
requested item is rewritten; a dependency dragged in behind it never
declared these variables.

Separately, `Install failed: fetch failed` is now a sentence. Item FILES
are not cached (only manifests are), so a network blip surfaces as node's
bare message with no URL and no cause, immediately after the user copied
a command off a web page -- which reads as "the command was wrong" rather
than "the network was". It now names what failed, says it is usually
connectivity or a proxy rather than a bad command, and mentions
HTTPS_PROXY.

Also fixes the two transcribe tests that were failing before this branch.
They assert the whisper soft-skip path but never pinned the engine, and
`auto` picks Parakeet whenever parakeet-mlx is installed -- so on those
machines the test shelled out to a real ASR binary, failed with "Parakeet
did not produce output", and landed in the generic failure branch it
claims is never taken. Pinned to `engine: "whisper"`, plus an assertion
that the mocked transcribe actually ran, which is what stops the test
passing on a machine without Parakeet while testing nothing on one with
it. The file now runs in 18ms rather than 3.7s, because it no longer
launches a subprocess.

Test plan: 10 new tests for the rewrite (enum and range refusal, the
numeric-string coercion the catalog URL depends on since every query
value is a string, delimiter escaping, unparseable declarations) and 3
for the failure message. Full CLI suite: 2661 passed, ZERO failures.

Verified as a user, not just in unit tests: installed blur-in with the
exact reported command, confirmed the declaration carried 76 / accent /
center, pasted it into a composition and ran `check` -- which reported
canvas_overflow at 76px, which only happens if the baked size is really
in effect. Bad values warn and are refused; blocks still emit
data-variable-values.

* fix(player): load the runtime before the body, not after

Customising a component on a catalog page did nothing to the preview.
badge-pop with count 10 and a green accent rendered 3, in red.

The probe injects the runtime by appending a script to an already loaded
document, and only once it has a reason to: a nested composition, or five
polls with a timeline present. A component has neither. It is markup
pasted into a composition, and it reads its values in an inline IIFE that
runs while the body is parsing:

    var vars = window.__hyperframes && window.__hyperframes.getVariables
      ? window.__hyperframes.getVariables() : {};

With the runtime arriving afterwards that guard always took the empty
branch, so the component used the defaults hardcoded in its own script
and every chosen value was dropped. The values were never the problem:
the preview sets window.__hfVariables correctly, and nothing was there to
read it.

prepareSrcdocForElement now puts the same runtime URL in the document's
head before the srcdoc is set. A classic external script in head is
parser-blocking, so it runs before body scripts without changing what
gets loaded or adding a dependency the player did not already have. A CLI
render never had this bug because the engine already orders it this way.

Skipped when the page carries the runtime already, so a CLI-rendered page
(which inlines it) does not get a second copy re-initialising the runtime
underneath a live composition. The probe's late injection stays for the
src= path, where there is no srcdoc to prepare. The runtime URL moved to
its own module so the two injection points cannot drift apart.

Test plan: 8 new tests for the injection (ordering against the reading
script, head placement, both no-op guards, missing head/body, attributes
on the head tag). Three srcdoc tests asserted byte-identical forwarding
and now assert what they were actually protecting -- that the composition
arrives intact -- plus the new runtime guarantee. player 338 passed,
studio 4249 passed.

Verified end to end against the real runtime and a real registry
component, asking for size 96 / accent / right:
  before  52px, rgb(243,243,243), flex-start, runtime absent
  after   96px, rgb(60,230,172),  flex-end,   runtime present
rgb(60,230,172) is #3ce6ac, the accent green. That is the reported bug
before, and the chosen values after.

* fix(add): name the registry and the real reason an install failed, and retry

`Install failed: fetch failed` was two words that describe every network
problem equally badly. Three things were missing, and each of them was
the whole answer in a different case.

The URL. undici throws with no URL attached, so a project that points
`registry` at a private host in hyperframes.json got a message that
looked like the public registry had failed. Naming the URL is the entire
diagnosis there.

The cause. undici buries the real reason one or two levels down in
`cause`, and it was being dropped. The reported failure turned out to be
`self-signed certificate in certificate chain`: a private registry whose
certificate node refuses and curl accepts, which is why the host looked
healthy from a terminal. That sentence tells the reader which knob to
turn; `fetch failed` sends them to check a connection that is working.

The retry. Item files are the one uncached path -- manifests fall back to
a stale copy, but every install downloads its files fresh -- so a single
blip killed the whole command. Now two extra attempts with short backoff,
and deliberately NOT for TLS failures: a self-signed certificate fails
identically every time, so retrying it only makes the user wait three
times as long for the same message.

Also retypes the declaration reader. It modelled variables as a local
interface of six `unknown` fields and re-checked each one at every use.
Core already owns this shape as a discriminated union and exports
`isCompositionVariable`, the same predicate `parseCompositionVariables`
filters with, so the union is used directly and the duplicate type is
gone. A declaration the schema rejects now leaves the file untouched
rather than being partially rewritten from guesses.

Test plan: 4 retry and URL tests, 5 cause-chain tests, and the add-side
tests now cover the custom-registry hint and its absence on the default
registry. The variableDefaults fixtures gained the `label` the schema
actually requires; without it they were not valid declarations, which the
stricter reader caught. CLI suite 2671 passed, zero failures.

Verified with the BUILT dist rather than the source, in the reporter's
own project directory. The failure now reads:

  File fetch failed: https://<host>/registry/components/blur-in/blur-in.html
    - fetch failed (self-signed certificate in certificate chain
      [SELF_SIGNED_CERT_IN_CHAIN])

and once the project points back at the public registry the original
command succeeds with `variables applied: size, tone, align`.

* fix(registry): name the registry on the not-found path too

The item-file failure now names the host it could not reach, but the
sibling path did not. A project whose registry is unreachable at the
MANIFEST stage got `Item "blur-in" not found - registry unreachable or
empty`, which reads as the public catalog having lost the item and sends
the reader to search a registry that never saw the request.

Same fix, same reason, applied where the other three call sites live so
one of them cannot stay behind: the message names the host and says it
came from this project's hyperframes.json, and only when it is not the
public registry, so the common case stays short.

Test plan: 3 tests covering the private-registry hint and its absence on
the default registry and on no registry at all. CLI suite 2674 passed,
zero failures. Verified with the built dist against a host with a bad
certificate:

  Item "blur-in" not found - registry unreachable or empty. Contacted
  https://self-signed.badssl.com/registry, set by this project's
  hyperframes.json, not the public registry.

* fix(catalog): reconcile the two spellings of a compound word

`countdown` returned exactly one item, the only thing tagged with that
spelling. `count down timer` returned sixteen, and that one was in none
of them. The tokenizer splits on word boundaries, so the two spellings of
a single idea produced disjoint sets, and whichever phrasing an author
happened to type decided which half of the answer they saw. Neither half
was the whole answer: the one-word spelling hid count-up and
decline-chart, which are the two things you would actually build with.

Both directions now, each gated on the catalog's own vocabulary so this
can only add signal. A query token is split when both halves are words
the catalog uses, and adjacent tokens are joined when the compound is.
A word in neither form, like `timer` which appears in no item, is left
alone: this widens phrasing, it does not invent matches.

Everything inferred this way carries a fraction of a real token's weight.
That is the part worth keeping honest, because the first version relied
on the halves being statistically common in a 375-item catalog, which is
not the same as making them count for less. In a small corpus that
version let `type` matching the name of `type-match-cut` outrank
`typewriter` matching the name of `typewriter`: searching a word returned
something that merely contained half of it. Two tests written against
that real failure caught it.

All spellings now return the same 17 items, and each still ranks its own
exact match first: `countdown` leads with yt-circle-pointer, `count down`
leads with the two-word items, and count-up and decline-chart appear in
both.

Test plan: 6 new tests covering both directions, the identical-set
property that was the actual defect, exact-match precedence, an unknown
word left alone, and the typewriter case. Eval set unchanged at 33/39
top-1 and 39/39 top-3, so no query regressed. CLI suite 2680 passed.
2026-08-17 20:31:37 -04:00
James Russo 232686f7e0 chore: release v0.8.0 (#3318) 2026-08-17 17:06:28 -07:00
Miguel Ángel 4403b8beef chore: release v0.7.111 (#3315) 2026-08-17 17:34:58 -04:00
Miguel Ángel 6b17c24f98 fix(catalog): rank on where a word appears and how rare it is (#3312)
* fix(catalog): rank on where a word appears and how rare it is

Word search returned the right move in the top three for 87% of a
39-query eval set built from real catalog intents. Three defects, all in
the same 75-line scorer, and all found by running the queries rather than
by reading the code.

A token matching an item's NAME counted exactly as much as one buried in
a description. Searching "typewriter effect on a title" ranked the item
literally called `typewriter` seventh, behind entries that merely mention
typing. Name and title now carry three times the weight: an author who
types a move's name is giving the strongest signal available and it was
being averaged away.

Plurals shared no vocabulary with the singular. "a stat that counts up
and then pulses once" matched nothing in a description reading "lands
with a restrained scale pulse", because `counts` is not `count`. Adding
detail to a query made results strictly worse, which is the opposite of
what a search should do. Plurals now fold, and only plurals: Porter would
fold `counter` to `count` and `values` to `valu`, merging moves that mean
different things.

Field weighting alone made one case worse, which is why inverse document
frequency is here too. "reveal a headline one line at a time" put every
item merely NAMED `*-reveal` on top, because one strong hit on the
catalog's most common word outscored several weak hits on the words that
actually narrowed it down. Rarity now scales each term.

Separately: a query in a script this ranker cannot index no longer
reports itself as an empty catalog. Tokenising on [a-z]+ leaves nothing
of a Japanese query, and returning "no items match" told the author the
catalog lacked a move it may well have, then invited them to file a gap
report about it. That case now says what actually happened and withholds
the gap prompt, since nothing was searched.

Measured on the same 39 queries, before and after:
  top-1  31/39 (79%) -> 33/39 (85%)
  top-3  34/39 (87%) -> 39/39 (100%)

Test plan: 13 new tests, each a real failing query reduced to the
smallest fixture that still reproduces it. Existing tests migrated to the
fields API (two callers total). Full CLI suite 2643 passed, 2
pre-existing transcribe failures unchanged. Verified against the real
CLI: "typewriter effect on a title" now returns typewriter first, and
"chat conversation between a user and an assistant" returns chat-message,
chat-thread, ai-chat-reveal instead of transitions-blur.

* docs(skills): say to query the catalog in English

The runtime message added alongside this explains an unsearchable query
after the fact. Saying it up front is cheaper: an agent that never writes
the query in Japanese never sees the error, never wastes the turn, and
never files a gap report about a component that exists.

Worth stating rather than assuming, because the mistake is a reasonable
one. On a Japanese or Chinese project the brief, the narration and the
captions are all in that language and the query naturally follows. The
rule is that the query language and the video language are unrelated:
describe the move in English, write the on-screen copy in whatever the
video needs.

Both skills that own `catalog --query` carry it, and those are the only
two that mention the command at all.

* fix(catalog): fail a non-English query instead of returning nothing

The message explaining an unsearchable query went to stdout and the
command exited 0. An agent that checks the exit code, which is most of
them, read that as "searched successfully, the catalog has nothing" and
went off to hand-author a move that is sitting in the registry. The
explanation only helped a human who happened to be reading the terminal.

It is bad input, not an empty shelf, so it now behaves like one: the
guidance goes to stderr and the command exits 1, matching what an invalid
--type already does. A genuine empty result, where the query parsed fine
and the catalog simply has nothing, still exits 0 -- that distinction is
the whole point, and both halves are pinned by tests.

The wording now also says what to do rather than only what happened:
search in English, and let the on-screen copy of the video stay in
whatever language it needs. That was the part agents were getting wrong,
since a Japanese project makes a Japanese query feel natural.

Test plan: 3 new tests covering the exit code, the wording, and the
genuine-empty case that must stay at 0. Also asserts the gap-report line
is absent, since nothing was searched and a report there is noise in the
one signal that tells us what to build. catalog.test.ts 32 passed;
commands + registry suites 887 passed with the 2 pre-existing transcribe
failures unchanged. Verified against the real CLI: a CJK query exits 1, a
genuine miss exits 0.
2026-08-17 17:20:22 -04:00
Miguel Ángel 5e36f7ac54 chore: release v0.7.110 (#3303) 2026-08-17 15:11:38 -04:00
Miguel Ángel 37f8c48449 fix(catalog): survive an unreachable registry, and ask for the gap (#3299)
Serve an expired registry cache when revalidation fails, so one timeout against the registry host no longer reports the whole catalog as unreachable while a usable copy sits on disk.

Hand back the gap-report command at the moment a search comes back wrong: catalog --query prints it pre-filled on both tiers, and every --json search envelope carries it as report_gap. Report on either tier, since the on-device tier needs a consented download and every gap reported to date came from the word tier.

Document the gap channel in the registry skill, which owns hyperframes catalog and never mentioned it, and name the CLI commands no skill did.
2026-08-17 14:55:17 -04:00
Vance Ingalls de4062a933 fix: create temp dirs with mkdtemp, not a name built from Date.now() (#3241)
* fix: create temp dirs with mkdtemp, not a name built from Date.now()

Closes nine open `js/insecure-temporary-file` alerts — the technically
correct ones. An audit of all 29 open alerts for that rule split them
three ways:

- 19 false positives: the write lands inside a directory the caller
  already made with `mkdtempSync`, and CodeQL's dataflow reaches
  `tmpdir()` without seeing the mkdtemp in between.
- 1 mitigated: `fontCompression.ts` writes with `flag: "wx"` and only
  takes the tmpdir branch inside Lambda, where /tmp is single-tenant.
- 9 real, and these are them. A name built from `Date.now()` under the
  shared temp dir, followed by `mkdirSync`, is guessable to the
  millisecond AND leaves a window between choosing the name and creating
  it, so on a shared machine another user can pre-create or symlink the
  path first.

`mkdtempSync` closes both halves: it picks the random suffix and creates
the directory 0700 in one syscall. Same shape, one line shorter, and the
alerts go away rather than being dismissed.

Six sites in `normalize.test.ts` (its `mkdirSync` import goes with them),
one in `generate-catalog-previews.ts` — that single construction accounted
for three alerts, since the other two were writes into the directory it
made.

No shared helper. `mkdtempSync` is already the stdlib primitive for
exactly this, and the two callers live in different packages, so a wrapper
would need a home in core to serve one CLI test and one build script —
more indirection than the line it saves.

Deliberately not touching the other 20: excluding the rule repo-wide would
hide this class of bug from future code, which is the reason these are
fixed rather than silenced.

* fix: track the wav temp dir for cleanup and finish the mkdtemp sweep

The wav helper pushed the file path into `dirs`, so `afterEach` removed
`tone.wav` and left the directory it had just made — four per suite run.
Push the directory and derive the file path from it. Measured: the old code
leaks 4 directories per run, the new code leaks 0.

Three sites still built a predictable name and then created it. CodeQL never
flagged them — its dataflow reaches the template preview writes through a
`readdir` walk and does not connect them back to the `tmpdir()` root — so the
alert list was narrower than the pattern, and closing only the alerts would
turn the rule green while the shape survived where nothing would re-flag it.
`generate-template-previews.ts` is the near-twin of the file this change
started from, and the other two are producer dev entry points. All three use
the path only through the variable, so the random suffix changes nothing.

Catalog previews now call the existing `createCatalogPreviewTempDir` instead
of repeating its body. That test was in no runner, so it pinned uniqueness and
mode 0700 on a function nothing called; adding it to `test:scripts` alongside
a real caller makes it load-bearing. The rationale for the primitive moves to
the helper, which is now the only place it lives.

* ci: re-run catalog previews when the temp-dir module changes

Routing the renderer through `createCatalogPreviewTempDir` made that module
part of its runtime path, and the workflow already states the rule for the
sibling case: a module the renderer imports has to appear in the trigger, or a
change to it alone never re-runs the job that exercises it. Add it to the
`paths:` filter and to the renderer canary, so a PR touching only the temp-dir
allocation still renders both shape canaries.

Verified against this branch's own range: the previous argument list does not
report the file, so a helper-only PR was invisible to both checks.
2026-08-14 11:20:37 -07:00
Miguel Ángel 12fd6d9087 chore: release v0.7.109 (#3273) 2026-08-14 10:21:26 -04:00
Miguel Ángel 532caf7aa2 chore(catalog): remove internal source markers 2026-08-13 21:10:12 -04:00
James Russo f7d2260f9d feat(engine): stamp rendered files with hidden renderer provenance (#3264)
* feat(engine): stamp rendered files with hidden renderer provenance

* fix(engine,producer): re-assert provenance at every container writer

Review found that a no-audio MOV render still shipped untagged. The concat
step is the last container write on that path (mux is skipped without audio,
and applyFaststart only copies mov/webm), and the concat demuxer does not
carry the chunks' container metadata through.

The same hole applies to no-audio WebM, and to the in-process chunked encode
in chunkEncoder, not just the distributed assemble path. mp4 was masked
throughout because applyFaststart re-runs ffmpeg for that format and re-tagged
the output.

Tags the four remaining writers: the chunked-encode concat, and assemble's
single-chunk remux, concat and cfr re-encode.

Also corrects the trust claim. These are unsigned, freely writable keys, so a
present tag means the file claims to be HyperFrames output, not that
HyperFrames wrote it. Documented as an unauthenticated diagnostic hint rather
than an authenticity or attribution boundary.

Tests assert on the assembled file through the real assemble() path for both
mov and webm; both fail without the concat fix.

* test(engine): pin provenance through the in-process chunked concat

Review noted the distributed writers are mutation-pinned but the
encodeFramesChunkedConcat fix had no real-file regression of its own.

Encodes 70 frames at a 30-frame chunk size so the concat step actually runs,
then asserts the tags on the resulting no-audio mov. Fails without the concat
fix, passes with it.
2026-08-13 16:32:09 -07:00
Vance Ingalls 9ba528914d chore: release v0.7.108 (#3265) 2026-08-13 15:11:20 -07:00
Vance IngallsandClaude Sonnet 5 d6c4774ef4 feat(studio): instrument the audio FX rack, including work an agent did (#3229)
* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

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

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

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

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

* refactor(studio): break up the FX rack's largest functions and files

Fallow flagged 9 complexity findings and 2 file-size violations after the
telemetry stack landed. Extracts FxPresetRun, FxAddMenu, FxRackChain,
FxNodeOpenBody, FxNodeParams, and useFxAudition/useFxCarve/useFxLevelling/
useFxChainObserved out of propertyPanelFxSection.tsx and
propertyPanelAudioFxGroup.tsx, splits propertyPanelFxNodeRow.tsx's open-face
rendering into its own component, and dedupes a clone in studioTelemetry.ts.
Pure structural move — no behavior change; full test suite still green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:05:51 -07:00
Vance IngallsandClaude Sonnet 5 ea0344122c fix(engine): duck before quantising, chunk the PCM, reschedule on rate change (#3174)
* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge

An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.

* fix(studio): pull TimelineLanes under the 600-line cap

TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.

* fix(studio): split the extracted pointerdown handler under the CRAP threshold

Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.

* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat

CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

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

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

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

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

* fix(cli): stop render.test.ts from downloading a real browser

The "render command explicit composition" test drives the full render.js
command handler, which takes the plan-based execute.ts path instead of the
renderLocal path the other tests in this file exercise. That path calls
ensureBrowser directly, bypassing the mocked preflight.js, and performs a
real network install of chrome-headless-shell into the shared
~/.cache/hyperframes/chrome cache as a side effect of running the test suite.
In CI this raced with the engine's audioFxRender browser tests running in a
parallel worker against the same HOME, producing an intermittent EACCES on
the partially-installed binary.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:23:40 -07:00
Vance IngallsandClaude Opus 5 56d8df65ca docs(skills): add /hyperframes-audio, and key the waveform cache by file (#3211)
* feat(studio): show every automated knob at the playhead, and carve as one module

An automated parameter has two values: the number sitting in the chain, which is
only the seed a lane replaced, and the number the envelope is on right now. The
second is the true one, so the panel shows it — on the carve rack's readouts and
on every effect's own fader and number field. A rack that showed the seed stood
still while the carve was audibly working.

Off the clip it keeps sampling rather than falling back to the stored number: a
lane holds its first value backwards and its last forwards, so before the clip
starts it already knows what it will open on, and the stored seed is a value
nothing will ever play. Showing it made the fader jump the moment the clip came
under the playhead.

The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop
deliberately keeps frames out of the store, so a panel watching only the store
would sit still for a whole take. PropertyPanel had that subscription inline;
it is now one shared hook with two callers.

Readouts reserve the width their parameter can need rather than what its current
value takes, because an updating value one character narrower shunted everything
after it sideways 30 times a second.

The carve's effects are presented as one module: an author switched on a carve,
and the peaking filters plus the level stage are how it is built, not six things
to remove one at a time. Opening it lists every member's settings as readouts,
since strength is what sets them. No carve control is offered on a track another
track already carves against — that track is the voice, not the bed.

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

* fix(studio-server): key the waveform cache on the file, not just its path

Two takes written to the same path returned the first one's waveform, so a
re-recorded track drew the shape of the audio it replaced. The key now carries
size and mtime, which is enough to notice the bytes changed.

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

* feat(engine): render audio FX in an OfflineAudioContext

Reads `data-fx-chain` off an audio element and runs the chain over the trimmed
WAV before volume automation is baked in — effects should see the raw signal,
and the envelope belongs on their output.

The processing happens in an OfflineAudioContext inside the headless browser
the engine already drives, running the same graph builders the studio previews
with. That is the point of the approach: one implementation per effect, so the
render agreeing with the preview is a property of the architecture rather than
a tolerance to police. Reimplementing each effect as an FFmpeg filter would
mean two implementations to keep in step, and for the dynamics processors and
modulated delays there is no filter that behaves the same way.

`build:audio-fx-runtime` bundles the graph builders into an injectable IIFE,
following the same pattern as the existing runtime artifacts, so the browser
runs exactly the code the studio does.

The page loads from a file:// URL rather than about:blank because AudioWorklet
is only exposed in a secure context — the compressor, limiter, gate and
bitcrush processors would otherwise fail to register with an opaque error.
file:// qualifies and needs no listening socket.

The chain is serialised into the attribute the way colour grading carries its
config, so there is no side-car file to resolve or lose.

An FX failure is fatal for the whole mix rather than a per-track soft failure.
Every other audio failure mode degrades gracefully — the track drops, siblings
continue — but substituting the dry signal for a processed one ships a render
that sounds plausible and is not what the author set up. Since the per-element
work races under Promise.all, an internal AbortController chained off the
caller's signal aborts in-flight siblings before workDir is removed.

* feat(core): voiceover carve analysis

Finds the bands a voice occupies so a music bed can be dipped there, letting
the voice sit in front without ducking the whole track.

Carve is a relationship between two tracks rather than an effect on one, so it
stays out of the FX chain. What it emits is an ordinary chain of peaking
filters, so a carve composes with whatever else is on the track and needs no
separate rendering path.

Selection is weighted toward intelligibility rather than raw voice energy.
Ranking purely by power lands on the fundamental almost every time, because
that is where a voice is loudest — but the masking that actually hurts a
voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The
bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights
toward 1-3 kHz.

Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB
across these bands — it falls off roughly 6 dB per octave above the fundamental
— so a weighting has to be on that scale to move anything at all. A
multiplicative weight of `1 - bias + bias * shaped` is bounded below by
`1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7
default, 3 dB at 0.5. That is no influence against a real voice — every bias
short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the
outcome the bias exists to prevent, while looking decisive against a fixture
whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth
up to 30 dB at full strength, and relative cut depths come from a dB difference
rather than a ratio of weighted linear powers.

The bias reweights ranking without overriding the spectrum — a band the voice
has no energy in is not worth carving, and scores -Infinity rather than
competing — so a strongly low-pitched voice can still select low at full bias.
What the tests hold is that biasing never selects lower than the unbiased
ranking, that the DEFAULT bias reaches the presence region on a voice with a
realistic tilt, and that bias 0 still follows raw power exactly.

Includes a radix-2 FFT rather than a dependency; one Welch-style averaged
spectrum over third-octave bands does not justify pulling in a DSP library.

* fix(engine): keep the FX render 16-bit, stereo, and correctly sized

Three defects in the offline FX path, none of which any test could see.

**Float output silently disabled sample-accurate volume automation.** The writer
emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope
into the samples and accepts only 16-bit PCM, returning null otherwise. So
enabling any effect downgraded that track to the ffmpeg expression path — capped
at 32 straight segments, quantising a curved envelope, and on a dense one falling
back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a
limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test
asserts the baker accepts the writer's own output and actually fades it.

**Everything was folded to mono.** `prepareAudioTrack` goes out of its way to
emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo
rematrix — and this folded it, then wrote one channel. So adding a single peaking
EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed
stereo. Channels now travel as one plane each, through an OfflineAudioContext of
the same width, and come back interleaved.

**Small results decoded the wrong length.** `new Float32Array(buf.buffer)`
discards byteOffset and byteLength, and Node pools small allocations: a 400-byte
payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples
decoded as 2048 samples of unrelated memory — and the empty-result guard could
not see it. The reader has the mirror-image fix: a float data chunk on an odd
boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now
copies instead of throwing RangeError on an unaligned view.

The tail limitation is now stated rather than mis-stated: the context is exactly
as long as the input, so a reverb or delay still ringing is cut there. The old
comment claimed the opposite. How far a tail may run past a clip's end changes
the clip's length in the mix, so it is a product decision, not one to make here.

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

* fix(producer): report an FX render failure as an audio error

`processCompositionAudio` reports per-track failures in its result, but an FX
failure it cannot degrade past — a browser that will not launch, a chain that
will not build — rejects instead. `runAudioStage` had no try, so that rejection
escaped to the orchestrator as an unclassified pipeline exception, losing the
stage/owner/retryable classification this stage exists to attach, and skipping
its abort check on the way out.

It now lands in `audioError` alongside every other cause, while an abort still
keeps its own shape rather than being reported as an audio problem.

Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh
clone typechecks packages/engine without building first. The bundle is built from
the stub, and the stub changes three times across this stack — so the artifact
differs per branch and would conflict on every restack. Its model,
position-edits-render-inline.ts, is committed only because it is stable. Building
before testing is this monorepo's existing contract (studio's tests need core's
dist too), so the gap is not specific to audio FX and is better closed by a build
ordering gate than by committing a per-branch artifact.

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

* test(engine): skip the browser FX render cases when there is no browser

CI's `Test` job was red on this PR with four failures, all the same cause:

  Failed to launch the browser process: spawn
  /home/runner/.cache/hyperframes/chrome/chrome-headless-shell

The job installs ffmpeg and no browser, deliberately — every other suite
that needs an external binary already guards on it
(`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming
a Chrome, so they failed on an absent dependency rather than on anything
about the code.

Guards on `resolveHeadlessShellPath()` — the same resolver
`acquireBrowser` launches through, so the check cannot drift from the
thing it guards the way a hard-coded cache path would. A configured path
that does not exist throws; that is caught and read as "cannot run here".

Checked both directions rather than just the green one: with a browser all
11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a
missing binary exactly 3 skip and the other 8 still run. A guard that
silently skipped everything would have looked identical in CI.

They keep their value where it exists — every developer machine, and any
job that has run `hyperframes browser ensure`.

Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five
days and several force-pushes stale. None of the 17 open repo alerts are
in files this PR changes; it re-runs on this push.

* chore(engine): suppress the temp-file alert with the reason it is safe

CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file
(high) — the one new alert on #3021, and the reason its CodeQL check is
red.

It is a false positive, and the comment says why rather than just silencing
it: `path` is always inside a directory made by `mkdtempSync`, never a
name assembled directly under `tmpdir()`. Both callers are covered — the
browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`,
and the render output goes to the producer work dir, itself
`mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the
random suffix and creates the directory 0700 in one syscall, so the
predictable filename inside it cannot be pre-created or symlinked by
another user, which is the attack the rule is about. The analyzer sees the
dataflow reach `tmpdir()` and not the mkdtemp in between.

Suppressed inline rather than dismissed in the UI, so the justification
lives next to the code and the rule stays live for anything added later in
this file. Matches the repo's existing convention — `planV2.ts:222`
carries an `lgtm[js/insecure-temporary-file]` for a different reason on
the same rule.

Correcting myself: I first reported this alert as not real, having
intersected the PR's files against the default-branch alert list, which
does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns
it straight away.

* test(engine): probe ffmpeg and Chrome instead of assuming them

Two failures on #3021's Test job, both about the environment rather than
the code under test.

**Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to
`execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide
ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()`
resolves — every other ffmpeg-dependent suite in this package already goes
through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)`
so a contributor without ffmpeg skips rather than fails.

**The browser guard trusted the wrong thing.** It asked
`resolveHeadlessShellPath()` and treated a returned path as "a browser is
here". CI's cache holds a chrome-headless-shell that resolves and then
fails to spawn — a partial download is indistinguishable from a working
one by `existsSync`, which is all that resolver checks. So the three
browser cases ran anyway and failed on the launch.

It now runs `--version` and requires exit 0, which is the same probe the
ffmpeg suites use: ask the binary, do not infer from the filesystem.

Checked both directions rather than just the green one. With a working
browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed
at a binary that exits non-zero — CI's exact situation — exactly 3 skip
and the other 8 still run. A guard that quietly skipped everything would
have looked identical on the CI summary.

* feat(core): register the audio-fx-rack canary at 0%

Lands the rollout switch dark, per the registry's own procedure: "Start at
percentage: 0 and merge that — a canary at 0 is dead code you can land
safely and ramp without a code review."

Declared at the bottom of the stack so every branch above can read it. The
gate itself goes in at wa-4-fx-panel, where the rack first appears.

Scope is deliberate and stated in the description: it gates the AUTHORING
surface only. A composition that already carries `data-fx-chain` still
plays and renders it. A canary should stage who can REACH a feature, not
make an attribute somebody already wrote silently inert — an agent that
writes a chain through the skill would otherwise produce a file whose audio
processing vanishes with no error.

* feat(studio): audio FX panel generated from the registry

Controls for the whole chain: add, remove, reorder, bypass, and every knob each
effect declares.

Nothing in the panel knows what a compressor is. The registry supplies each
parameter's range, step, unit and scale and the panel renders what it finds, so
adding an effect or a knob upstream needs no change here, and the panel cannot
offer a value the renderer would reject — a typed-in figure is clamped into the
declared range on the way through.

Frequency and time controls span three or four decades, so those declare a log
scale and the slider maps exponentially; a linear slider would spend most of
its travel somewhere useless.

Reorder is a first-class control because chain order changes the sound: a
reverb before a compressor is not the same as after.

Carve gets its own block rather than an entry in the add menu, with a picker
for the voice track to listen to. It processes this track based on another one,
which is how a sidechain control works — it lives on the track that changes,
and names the source.

* feat(studio): show the Audio FX section on audio tracks

Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `<audio>` element is selected.

The section is audio-only. A `<video>` carries its sound on a separate
`<audio>` element, so an FX chain on the video would have nothing to process.

Chain and carve settings are written straight back onto the element as
serialised attributes, the way colour grading carries its config, so
persistence is an ordinary attribute write and needs no new server route. A
chain that cannot be parsed renders as empty rather than breaking the panel,
and the attribute is left untouched until the user changes something.

The collapsed group summarises what is on the track ("2 effects + carve") so
the state is visible without expanding it.

Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED
defaults to true, so the flat inspector is what actually renders.

* refactor(studio): lift audioFxSummary out of PropertyPanelFlat

`PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap,
so the required File size check is red — the sole reason this PR is
blocked. The review says as much: "mechanical fix (~5 min), not a design
problem. Code itself is LGTM."

Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later
branch creates for it. Deliberately the smallest cut that clears the cap
rather than the whole `AudioFxGroup` extraction: every later commit in the
stack edits AudioFxGroup, so moving it here would collide with each of
them, while almost nothing touches this function.

595 lines.

* feat(core,studio): hear the FX chain in preview, and run the carve analysis

Splices an element's FX chain into the playback graph so preview stops being
silent about effects, and wires the carve button that was previously inert.

The chain goes between the decoded source and its gain stage: effects see the
raw signal and volume automation rides on their output, matching the order the
offline render uses. Since preview and render call the same graph builders,
what is heard while scrubbing is what gets written.

The splice lives in the transport rather than on the `<audio>` element. The
transport plays each track from a decoded AudioBuffer and mutes the element to
avoid doubling, so capturing the element with createMediaElementSource would
have processed a stream nothing is listening to — it looked like it worked
because the call succeeded, and the audio was unchanged.

A chain that cannot be built plays dry rather than silencing the track, which
is the right failure in preview: the author keeps working and hears the source.
The render still refuses, because shipping the dry signal there would be wrong.

Carve now analyses for real: it decodes the chosen voice track, ranks its bands
and writes the resulting peaking filters onto this track. Generated nodes are
tagged `fromCarve`, so re-running replaces the previous carve instead of
stacking another set on top of hand-added effects.

Known limitation: the graph is built when a source is scheduled, so a knob
turned mid-playback takes effect on the next play or seek rather than
immediately. Live re-parameterisation needs the transport to hold the handle
and forward updates.

* fix(studio,core): stop parameter drags from restarting playback

Dragging a knob wrote the chain through the persisting attribute path on every
input event. That path refreshes the preview, which reloads the composition and
reschedules audio — so a single drag reloaded dozens of times and playback
stuttered the whole way.

Drags now go through `onSetAttributeLive`, the same path colour grading uses for
scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens.
The persisting write fires once, when the gesture ends — pointer-up or blur for
a slider, Enter or blur for a typed value. A select commits immediately since
there is no drag to wait for.

While dragging, the control is driven from local state. Waiting for the value to
round-trip through the element attribute made the knob lag behind the pointer.

For the change to be audible without a reload, the graph now follows the
attribute: the chain installed by the transport observes the element and
re-parameterises itself in place, so a value change lands on the next
128-sample quantum. A shape change (effect added, bypassed, pole count) cannot
be patched into a running graph, so it still waits for the next schedule rather
than cutting the audio mid-play.

The regression test drags a slider through several values and asserts the
persisting handler is untouched until release.

* feat(studio): put the audio FX rack behind its canary

Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered
at 0% — so the whole 47-PR stack can land without showing anyone a feature
that has not been measured yet.

The gate sits on the AUTHORING surface and nowhere else. The runtime and
the render still honour a `data-fx-chain` already on an element, so a
composition written through the skill or by `carve.mjs` keeps its
processing rather than going silently dry for anyone outside the cohort. A
canary should stage who can REACH a feature, not make an attribute somebody
already wrote stop working with no error.

Gated at the panel rather than in `resolveEditingSections`: the affordance
resolver is a pure function in core describing what an element CAN support,
and rollout state is not a property of an `<audio>` tag.

Pinned the 0% with a test, and checked it fails at 25 — a ramp should have
to break something that says "this ships dark" out loud.

One gap, stated rather than papered over: the gate itself has no unit test.
I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness
never renders the Audio FX group for its audio fixture even with the gate
removed — so the test passed for the wrong reason in the off case and could
not pass at all in the on case. A test that cannot fail for the right
reason is worse than none. Verifying the gate needs the panel harness to
mount that section first, which is its own change.

* fix(core): register FX worklets before building nodes that need them

An AudioWorkletNode cannot be constructed before its processor is registered —
it throws, and the surrounding chain is lost with it. `attachElementFxChain`
built the chain first and only then called `ensureAudioFxWorklets`, so every
worklet-backed effect (compressor, limiter, gate, bitcrush) threw on
construction and the track fell back to dry. Instrumenting the preview showed
`hf-compressor: InvalidStateError` with addModule never called at all.

When the module has not landed yet the track now plays dry and the graph is
swapped in once registration resolves, so the effect arrives a moment late
instead of never.

Registration is also tracked per context rather than in one module-level
promise. A processor registered on one AudioContext does not exist on another,
so the shared promise made every context after the first believe it was ready
when it was not — the studio's transport owns its own context, which is exactly
that case.

With the worklets actually running, the compressor's per-sample log10 and pow
became real audio-thread work. Samples below the knee have a gain of exactly
unity and need neither, so the envelope is now compared in the linear domain
and the transcendentals only run for samples that are actually being
compressed.

* refactor(studio): split the FX node row out of FxSection

Clears the health findings the FX stack left behind: the chain-node render
callback was a 70-line closure over half of FxSection's state, and the two
reorder arrows were the same button written twice.

Also drops two exports with no consumers, and registers the audio FX runtime
stub as an entry point — it is bundled by file path, so nothing imports it.

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

* feat(core): automation envelope model for audio tracks

Adds the data model behind Ableton-style automation lanes: breakpoint
envelopes over track volume or one knob of one effect in the track's FX
chain, stored on the element as `data-automation`.

Times are clip-local, so an envelope travels with the clip when it moves —
the clip-envelope model rather than arrangement automation.

`sampleAutomationLane` is the single interpolator. The lane drawing, the
preview scheduler and the render bake all call it, so the picture and the
sound cannot disagree about the curve. Log-scaled parameters interpolate in
log space, matching what their own knob already promises.

FX nodes gain a stable `id`, minted by count rather than randomly so the
document is the same on every machine. Lanes address nodes by id, so
reordering a chain never re-points a lane at a different effect, and a lane
whose effect was deleted is dropped rather than left to reattach.

Also warns when a track carries both a volume lane and a GSAP volume tween,
since only the lane is heard and the tween silently does nothing.

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

* refactor(studio): lift the audio FX group out of PropertyPanelFlat

`PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so
the required File size check was red — the sole reason #3014 and #3022 are
blocked. Both reviews say the same thing: "mechanical fix, not a design
problem. Code itself is LGTM."

Moves `AudioFxGroup` and `audioFxSummary` into
`propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them
anyway — done here so the file is under the cap from the point it first
crosses it, rather than ten branches later.

533 lines now. The four audio imports it no longer needs go with it.

Not fixed here: three `FxSection carve` tests fail on this branch with
"Cannot read properties of undefined (reading 'toFixed')". Confirmed
pre-existing by stashing this change and re-running — that is the separate
`Test` failure the review also flags.

* feat(core): expose the AudioParams behind automatable FX knobs

Marks the knobs an automation lane can drive and has each graph builder hand
back the AudioParam behind them, so a scheduler can write to a running effect
without knowing what the effect is.

A knob is not always one AudioParam. A wet/dry mix is two gains moving in
opposition, and a knob in milliseconds drives a delay time in seconds, so
each target carries the mapping out of the knob's own declared unit.

What stays unautomatable is stated where it is decided: a WaveShaper curve, a
convolution impulse and a one-pole filter's coefficients are all rebuilt
wholesale rather than scheduled, and the four worklet effects take values by
postMessage rather than through AudioParams.

The registry flag is written by hand, so a test builds every effect and
checks the exposure both ways — nothing flagged is missing, nothing exposed
is unflagged. A flag that lied would offer a lane that silently did nothing.

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

* feat(core): play automation envelopes in preview

Schedules each lane onto the AudioParams behind its knob using native ramps
and value curves. Nothing evaluates the envelope per frame: it is handed to
the audio thread once, so it stays sample-accurate however busy the main
thread is, and the offline render will schedule it the same way.

Timing comes from the transport, so an envelope survives seeking into the
middle of a clip, a clip that has not started yet, and a playback rate that
compresses clip seconds into context seconds.

A straight line is only scheduled as a ramp when nothing bends it — no
curvature, a linear parameter scale, and no unit mapping. Log-scaled
parameters and mapped ones are sampled instead, since a delay knob in
milliseconds and a wet/dry pair moving in opposition are not linear in the
parameter they drive.

Lanes with nowhere to write are skipped rather than reported: a one-pole
filter exposes no frequency param, and the worklet effects expose none at
all. Editing an envelope mid-playback re-aims it at the live playhead rather
than restarting the track.

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

* fix(core): make the volume lane audible in preview

The envelope was scheduled onto the transport's gain AudioParam, but the
runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked
value — so it was erased within a frame. Volume automation was correct in the
render and inaudible while previewing.

The lane now feeds the per-tick path where the probed volume keyframes already
sit, checked ahead of them so the two cannot fight, and the transport no
longer schedules volume at all: one mechanism instead of two racing.

The cost is honest — in preview the level steps per tick rather than per
sample, exactly as the existing keyframe path does. The render still bakes it
into the PCM sample-accurately, and FX parameters are still scheduled on their
own AudioParams, since nothing rewrites those.

Parsed lanes are cached by attribute text: the runtime asks once per tick per
track, and parsing there would run the JSON parser 60 times a second for a
value that only changes on an edit.

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

* feat(engine): bake automation envelopes into the render

The offline render schedules FX lanes with the same scheduler preview uses,
inside the OfflineAudioContext that already runs the same graph builders. The
input WAV is the clip's own audio from its first sample, so clip-local time
is offline time and the envelope needs no offset.

Volume lanes take the existing PCM bake rather than a second mechanism: the
lane is converted to keyframes, so a straight fade stays two of them and only
a bent segment is sampled — the baker interpolates linearly and would
otherwise quietly straighten the curve. A volume lane supersedes keyframes
probed from the timeline, which `lint` already warns about.

A browser test sweeps a lowpass from below a 2 kHz tone to well above it and
measures both ends. Parsing the envelope is not the same as scheduling it,
and only running the real thing tells the two apart.

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

* fix(core): apply chain edits to the running graph

A structural edit — an effect added, removed, bypassed, or a filter's pole
count switched — was dropped. `buildFxChain`'s update reports false when the
change is not merely new values, and the attribute observer ignored that, so
the edit only took hold when the persisting write reloaded the composition.
That reload restarted every playing track, which is what was heard as the
audio chopping.

The graph is now swapped in place: the old effects are detached, the new ones
built and connected between the same source and gain, and any lanes
re-scheduled onto the new nodes. The source node is never touched, so playback
does not restart.

A track with no chain is watched too, rather than wired through and forgotten,
so adding its first effect is heard the same way. That means the function
always returns a disposer instead of null for the empty case.

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

* fix(studio): drop the FX panel's dead __testables export

Fallow audit flagged it — no test imports the module.

* fix(core,studio): clear the remaining Fallow audit findings on the FX panel

- Split FxSection's per-node row into FxNodeRow + FxNodeControls so the
  CRAP score (31.6, threshold 30) splits across two smaller units instead
  of moving wholesale with one extraction.
- Dedupe the repeated "open the add menu, read its items" block in
  propertyPanelFxSection.test.tsx into openAddMenuItems().
- Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into
  one build-inline-artifact.ts, config-selected by CLI arg — the two
  scripts were a byte-for-byte clone save for names.
- Exempt canary.test.ts's rawFnv (a deliberate independent
  reimplementation used to cross-check canaryBucket, per its own
  docstring) and the property-panel test files' shared renderInto/mount
  scaffolding (pre-existing across 9 files, 2 outside this stack) in
  .fallowrc.jsonc, consistent with this file's existing exemptions for
  the same class of intentional/pre-existing duplication.

* fix(ci): allowlist the build-script consolidation in the no-main-deletions guard

build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into
build-inline-artifact.ts to kill a fallow duplication finding; the deletion
guard flagged that as an accidental loss since main still has both originals.

* fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo

Both effect builders set wet.gain to the mix and dry.gain to its complement
in identical two-line blocks; fallow kept re-flagging it as a 10-line clone
on every unrelated change. Extracted setWetDryMix.

* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge

An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.

* fix(studio): pull TimelineLanes under the 600-line cap

TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.

* fix(studio): split the extracted pointerdown handler under the CRAP threshold

Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.

* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat

CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

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

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

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

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:16:36 -07:00
Xuanru LiandCursor 43dba22057 fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing (#3236)
* fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing

Fonts/Vercel-class sites were failing after navigation when a single in-page
scroll/evaluate hung until protocolTimeout. Drive lazy scroll from Node,
degrade on evaluate timeouts, and stop labeling those failures as bot blocks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(capture): enforce stage budgets and surface evaluate degradation

Bound each scroll/content evaluate with remaining stage time so a wedged
page.evaluate cannot outlive the advertised 15s/8s budgets, skip recovery
CDP calls after expiry, and return/propagate timed-out animation and
screenshot work so caller warnings are reachable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(capture): prefer TimeoutError instanceof for timeout classification

Use puppeteer-core TimeoutError as the primary signal for navigation vs
evaluate/protocol timeouts, with message checks only as a fallback for
string formatting and non-TimeoutError cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:26:22 -07:00
Miguel Ángel e0ba41c024 chore: release v0.7.107 (#3228) 2026-08-11 16:19:20 -04:00