Commit Graph
4070 Commits
Author SHA1 Message Date
Vance Ingalls f575bdadcb fix(studio): harden carve and FX rack behavior (#3452)
* 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
2026-08-23 18:51:07 -07:00
Vance Ingalls 4ea018a4a7 fix(studio): align audio rows, automation lanes and headers (#3451)
* 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
2026-08-23 18:48:08 -07:00
Vance Ingalls 89069d24c3 fix(studio): keep preview state synchronized (#3450)
* 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
2026-08-23 18:10:46 -07:00
Vance Ingalls 8e96ccb0b2 fix(studio): make audio-group edits transactional (#3449)
* 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
2026-08-23 18:10:19 -07:00
Vance Ingalls 0f302285a2 fix(studio): unify audio IDs and group state (#3448)
* 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
2026-08-23 18:09:58 -07:00
Vance Ingalls 54091b5015 feat(lint): validate audio group membership and timing (#3447)
* 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
2026-08-23 18:09:40 -07:00
Vance Ingalls 1aec3b4a09 fix(engine): harden grouped audio rendering (#3446)
* 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
2026-08-23 18:09:21 -07:00
Vance Ingalls 16ff1eb145 fix(core): align preview transport with grouped audio (#3445)
* 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
2026-08-23 18:08:37 -07:00
Vance Ingalls 6faf4d6084 fix(core): harden audio FX and group identity (#3444)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback
2026-08-23 17:58:25 -07:00
Miguel Ángel 32d58a73e3 chore: release v0.8.11 (#3440) v0.8.11 2026-08-23 14:49:13 -04:00
Miguel Ángel 65b2299db2 fix(engine): preserve static dedup across caption runs (#3438)
* fix(engine): preserve authored clip boundaries after normalization

* perf(engine): bound static verification work across caption runs

* fix(core): preserve explicit nonpositive timeline windows
2026-08-23 13:18:18 -04:00
Vance Ingalls dd0626a55a fix(studio): stop the grouping dialog opening off the bottom of the window (#3421)
Reported as "the grouping button did nothing — I clicked it and nothing
happened". The dialog WAS opening. It positioned itself at
`anchorRect.bottom + 4` with no flip and no clamp, and this button lives in a
track header at the bottom of the studio window, so it opened past the viewport
edge. It was the last floating surface in the timeline with no viewport handling
at all.

It now goes through `resolveFloatingPanelPosition`, the helper the other body
portals already position with (`RenderQueue`, `propertyPanelColor`), so it flips
above the anchor when there is no room below and clamps so neither edge leaves
the viewport. `GROUP_DIALOG_SIZE` is a declared estimate in the same style as
`FORMAT_PANEL_SIZE` and `COLOR_PICKER_SIZE`: `w-56` is exact, only the flip
decision reads the height, and the clamp keeps the dialog on screen either way.

Two tests, at a realistic bottom-of-window anchor and hard against the right
edge. Both verified to fail against the raw positioning.

Worth noting why this shipped: the existing `group-pointer` test passes with or
without the fix. happy-dom reports an all-zero rect for an unlaid-out button, so
the dialog landed at top:4 — on screen, and nothing like the real app. A geometry
test that never sets a geometry proves nothing.

Deliberately NOT included: a toast for the grouping write's silent
`elements.length < 2` bail. That path is real in code but I could not reach it
from the UI — the button only renders on a track with 2+ ungrouped clips, and
sub-composition audio arrives as separate single-clip rows, so the offer never
appears there. Adding a message for an unreachable branch, plus the file split it
would force to stay under the 600-line studio cap, is not justified by evidence.
2026-08-22 17:04:45 -07:00
Vance IngallsandClaude Opus 4.8 f11b60854a test(sdk): render-faithfulness test for serialize() bake contract (WS-F) (#1575)
* test(sdk): render-faithfulness test for serialize() bake contract (WS-F)

Adds session.render-faithful.test.ts with 8 assertions covering the full
op batch (setStyle + setText + setTiming + addGsapTween + moveElement).
Confirms serialize() emits fully override-baked, render-ready HTML — this
is the SDK-side guarantee that the backend render input needs no separate
override-set field. Also asserts GSAP <script> edits and data-composition-
variables survive serialization unchanged.

Decision recorded: no SDK bake helper. session.serialize() IS the bake.
Content-address → zip → S3 upload → pointer-swap is host/backend (WS-P/WS-R).

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

* test(sdk): close the two false-positive slots in the bake-contract assertions

`toContain('data-end="5"')` in the setTiming case and `toContain('data-y="50"')`
in the full-batch case both match a DIFFERENT element in the fixture —
hf-title already ends at 5, hf-box already sits at y=50 — so either assertion
would still pass if its write regressed to a no-op. Paired each with the
disappearance of the target element's own pre-mutation value, which is the
pattern the setText case already uses.

* test(sdk): assert the canonical data-duration timing shape, not legacy data-end

The false-positive slot was hiding a real behaviour change. `setTiming` now
routes through the parsers' `writeClipTiming`, which canonicalizes timing onto
`data-start` + `data-duration` and REMOVES the legacy `data-end`. The
full-batch case asserted `data-end="4.5"` and failed once rebased onto main;
the standalone case asserted `data-end="5"` and passed only because hf-title
carries that exact value in the fixture — the very collision this pass set out
to close.

Both now assert the start/duration pair and the disappearance of the target's
own legacy end.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 16:42:40 -07:00
Miguel Ángel 59a69a145b chore: release v0.8.10 (#3426) v0.8.10 2026-08-22 11:16:32 -04:00
Miguel Ángel 7a024cf68e fix(studio): name the cause when a render request fails (#3424)
The render POST's catch took no binding, so the exception was discarded and
every transport failure produced one sentence: "Could not reach render server.
Use `hyperframes render` from the CLI instead."

A dead server, a DNS failure, an aborted request and a server that died
mid-render are all indistinguishable under that string — and it is not only a
UI message, it is what travels into the feedback report. Three separate field
reports carried it verbatim, one of them describing a render that fails every
single time. A guaranteed reproduction that tells us nothing is worse than an
intermittent one that does.

Bind the error and append it. The CLI guidance stays, since it is still the
right next step for the user; it just no longer stands alone.

Regression test asserts both halves: the cause appears, and the guidance
survives. It fails on the unfixed code with `expected 'Could not reach render
server. Use `h…' to contain 'Failed to fetch'`.
2026-08-22 11:04:16 -04:00
Vance Ingalls f6e8e8ddfd chore: release v0.8.9 (#3422) v0.8.9 2026-08-22 05:57:57 -07:00
Vance Ingalls 0eca7b1e0a docs(audio): document the audio effects system (#3420)
* docs(audio): document the audio effects system

The effects feature had no documentation at all — the only mentions anywhere in
docs/ were changelog entries. This adds the three pages the color-grading feature
already has, one per audience, and closes a gap in the existing audio guide.

- prompting/audio-effects — a new Level 5 chapter on asking for a mix in
  symptoms rather than in filters, with the voiceover carve as the headline, the
  level-before-depth check, groups, and the three requests that have no honest
  answer (de-essing, noise removal, tone matching).
- studio/audio-effects — the rack, presets by symptom, the carve module, groups,
  the deliberately asymmetric mute/solo, automation lanes, and troubleshooting.
- reference/audio-effects — the contract: all four attributes, every effect and
  parameter range, why some parameters cannot be automated, the 19 presets, the
  five jobs and five one-knob profiles, carve semantics, the group model, the
  render bus, preview/render parity, and the three lint rules.

Also points the existing "duck music under important speech" advice in
guides/voice-and-audio at the carve, which does that properly and was never
named there.

Facts verified against source rather than the shipped skill prose: the panel
section is "Audio FX" (the neighbouring "Effects" section is visual effects, and
an earlier draft of this page named the wrong one), MAX_AUTOMATION_POINTS is 512,
fromPreset carries a preset id rather than a boolean, the leveller targets the
track's own 80th percentile, group mute reaches the render while solo never
leaves Studio, and the CLI carve currently writes clip ids.

No screenshots in this pass, and no placeholders for them either — the pages
carry concrete markup and tables instead. Screenshots of the rack, the carve
module, and a group row would each earn their place later.

mint validate and mint broken-links both pass.

* docs(audio): give audio its own Studio group, split by task

Review feedback: the audio page did not belong in Studio / Edit. That group is
already one task per page — canvas, timeline, animation, captions — and a single
page covering the rack, the carve, groups, and automation lanes was four tasks
bundled together and dropped in beside them.

Studio now has an Audio group holding four task pages:

- Effects and presets — the rack, presets by symptom, adding single effects in a
  working order, the one-knob controls, Even Out Levels
- Voiceover carve — its own page, because it is the feature people come for
- Groups, mute, and solo — including why mute reaches the export and solo cannot
- Automation lanes — drawing envelopes, the shape menu, and which parameters
  cannot move at all

Not a new top-level tab: the tabs here are audience-scoped (Guides, Studio,
Catalog, Developers), so a feature tab would be the only one of its kind and
would strand the prompting chapter out of its Level 5 sequence and the reference
page out of Developers.

Repointed the deep link in guides/voice-and-audio at the carve's own page.

Lane interactions verified in source before documenting: the right-click menu
offers Ramp up, Ramp down, Swell, Dip, and Simplify (which needs three points).
Deliberately not documented: "clicking a lane label reveals it in the rack",
which is not on main.

mint validate and mint broken-links both pass.

* docs(audio): fix the four review blockers

All four verified in source before fixing; the review was right on every count.

**The registry was incomplete.** `pitchshift` ships at `audioFx.ts:509-535`
(`semitones` −12–12, `mix` 0–1, worklet-backed so neither automatable) and was
missing entirely — sixteen effects, not fifteen. It also joins the worklet list,
so five effects expose no automatable parameters rather than four.

**Three presets were missing.** `chipmunk`, `giant`, and `monster` ship at
`audioFxPresets.ts:336-357`, all built on `pitchshift`. Twenty-two presets, and
Character holds ten. Fixed in both the reference table and the Studio list.

**The copyable markup contradicted the warning above it.** The page said
`carve.mjs` only finds double-quoted attributes and then gave three
single-quoted examples — copying the chain example would make a later carve miss
the existing chain and overwrite it. All three are now double-quoted with
`&quot;`, each followed by its unescaped reading so it stays legible.

**The attribute table over-claimed.** `data-audio-group` is a plain id, not JSON,
and is ignored on `<video>`; the other three also live on `<hf-audio-group>` for
a group. The table now carries shape and valid host per attribute.

**The automation contract was wrong on two axes.** A clip lane's `t` is
clip-relative but a GROUP lane's is composition time, because a group has no
`data-start` (`webAudioTransport.ts:337-342`, `audioMixer.ts:1311-1344`) — both
pages now split the two clocks. And `volume` is not 0–1: the ceiling is
`MAX_AUDIO_GAIN`, +12 dB or about 3.981 (`audioGain.ts:8-9`), so a boosting lane
is valid and documented.

**Current-main drift.** #3416 is merged, so the CLI now records the voices'
shared group when it is safe and falls back to clip ids when that group contains
the bed or a music/SFX member. Documented, including why neither refusal shows
up on the run that writes it, and rebased onto main.

mint validate and mint broken-links both pass.

* docs(audio): name the real add-menu family, and finish propagating pitchshift

Second review round. All three findings were my own incomplete propagation — I
corrected the reference for `pitchshift` last round and left the reader-facing
pages behind it.

**The add-menu family is `Time`, not `Space`.** `propertyPanelFxAddMenu.tsx:22-28`
labels the four groups Filters / Dynamics / Non-linear / Time, and the time group
holds pitchshift, delay, chorus, phaser, and reverb. The Studio page sent readers
looking for a group that does not exist. It is now a table naming the family and
its contents, and the reference's "Time — space and width" heading is retitled,
since that description stopped covering the family the moment pitch shift joined
it.

Also from the same file: the menu offers the named jobs in place of a bare
`peaking`, because picking `peaking` is picking a machine and leaving the real
decision — which range — for afterwards. Worth saying on the task page.

**Pitch shift was missing from both no-automation lists** that a reader actually
follows — `studio/audio-automation` and, unflagged but the same defect, the
prompting chapter. Five worklet effects in all four places now. Called out
explicitly on the Studio page, because a rising pitch is exactly the thing
someone reaches for a lane to do, and the lane will not report that it cannot.

**Narrowed the group-metadata sentence.** "The other three are JSON, and on a
group they live on `<hf-audio-group>`" swept in `data-fx-carve`, contradicting the
table directly above it. Only `data-fx-chain` and `data-automation` are group
metadata.

mint validate and mint broken-links both pass.
2026-08-22 05:49:35 -07:00
Vance IngallsandClaude Opus 5 c594023895 fix(studio): give the group row's caret the panel's glyph and size back (#3415)
* fix(studio): put the timeline's portaled surfaces on the tier the other portals use

The FX popover, the grouping dialog it swaps for, and the automation selection
menu are all portaled to `document.body`, so they land in the root stacking
context — where they sat at `z-50` while the app's own chrome occupies 60, 90,
91, 92, 94, 100 and 110, and every other portal that has to clear that chrome
(`Tooltip`, `AssetContextMenu`, `InlineTextToolbar`, `RenderQueue`) already uses
`z-[200]`. These three were the odd ones out.

Scoped honestly: the clipping in the report is fixed by the height cap in the
previous commit, which is what actually cut the popover off at the timeline
chrome. This commit is tier consistency — it removes the standing risk of a
portaled timeline surface losing to any of those seven higher tiers, rather than
a demonstrated repro. Confirm against a real window before claiming more.

* fix(studio): move the remaining body-portaled context menus to the same tier

The all-sites audit in review was right and the previous commit did half the set.
Using `createPortal(…, document.body)` as the predicate rather than the timeline
directory, four more surfaces sit in the root stacking context at `z-50` below
the seven chrome tiers (60, 90, 91, 92, 94, 100, 110):

- `player/components/ClipContextMenu.tsx:51`
- `player/components/TrackGapContextMenu.tsx:78`
- `player/components/KeyframeDiamondContextMenu.tsx:99`
- `components/editor/CanvasContextMenu.tsx:215`

The fourth is the easy one to miss — it is the only one outside
`player/components/`, so a timeline-scoped sweep finds exactly the other three.
It belongs to the same set by its own account: its className is byte-identical
to `ClipContextMenu`'s and its header comment says it mirrors that file's look,
positioning, and dismiss behaviour, portaled to `document.body`.

Two body portals deliberately left alone. `sidebar/BlocksTab.tsx:125` portals
`PromptPreviewModal`, which carries its own `z-[100]`/`z-[110]` modal tier — a
`z-` class on the portal wrapper would be dead weight. `RenderQueue.tsx:235` is
already `z-[200]`. `FileTree.tsx:336` and `FileTreeNodes.tsx:103` are `fixed
z-50` but are NOT portaled — they render inside the sidebar's own stacking
context, so the root-context argument does not reach them and raising them would
be an unrelated change.

Crossing the `z-[100]`/`z-[110]` modal backdrops is unreachable for the same
reason it was for the first three: all four dismiss on an outside pointerdown,
so the press that opens a modal closes the menu first.

`CanvasContextMenu.test.tsx:95` asserted on `.fixed.z-50` to prove the menu did
NOT render; left as-is it would have passed vacuously against any tier. Updated
to the new class so it still fails if the menu renders.

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

* fix(studio): correct the interval in the popover in-bounds test comment

`bottom: 32` with `maxHeight: 160` in a 200px viewport puts the box at y = 8..168,
not y = 8..40 — the bottom edge sits at `innerHeight - bottom`, and the comment
read it as the height instead. The assertions below already computed the right
geometry; only the stated interval was wrong, on a regression test whose comment
is the next reader's model of what it pins.

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

* fix(studio): give the group row's caret the panel's glyph and size back

The timeline group row was the only disclosure caret in the studio still drawn as
a rotated 11px non-mono glyph. Both of the property panel's carets
(`hf-fx-preset-run-caret` in propertyPanelFxPresetRun, and
propertyPanelFxNodeOpenBody) swap between ▸ and ▾ in `font-mono`, so the same
affordance was rendering smaller and differently on the row than in the panel it
opens.

Now mono, a size up, and swapped rather than rotated — a rotated ▸ also sits
off-centre in its box because the glyph is not square.

Three tests, mounting the header: the swap, the absence of a rotate transform,
and the mono/size class. Verified all three fail against the previous caret.

* fix(studio): stop the caret comment and test name claiming a size match

Both reached past what was actually verified, and the comment is the part that
stays in the tree.

The comment said the caret matches the property panel's carets and "should not be
smaller here than it is there". Inverted for one of the two: the node-body caret
sits under `text-[9px]` (`propertyPanelFxNodeOpenBody.tsx:240`), so at 13px this
one is materially larger, and `hf-fx-preset-run-caret` has no size rule of its
own — its rendered size is unmeasured. Narrowed to the two claims that hold:
mono, and swapped rather than rotated.

The third test was named "matches the property panel's carets" but reads only
this component's own className, so the panel carets could move and it would stay
green. Renamed to what it pins.

No behaviour change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:17:31 -07:00
Vance IngallsandClaude Opus 5 f0cc9b1a34 fix(skills): make the carve CLI work against the published core, and honour its own group invariant (#3416)
* fix(skills): make the carve CLI work against the published core, and honour its own group invariant

Two defects found by using the shipped feature end to end on a real project
rather than inside this repo.

**It could not load core at all.** `loadCore` resolved `./audio-carve` and
`./audio-fx` with `require.resolve`. The workspace manifest declares a `node`
condition, so that resolved fine here — but the PUBLISHED manifest
(`publishConfig.exports`) carries only `import` + `types`, so every consumer of
the released package got ERR_PACKAGE_PATH_NOT_EXPORTED for a package that ships
those files perfectly well. The script was broken everywhere except where it was
developed, and its error text blamed a missing/outdated package, which no
install can fix. It now keeps the project anchor and falls back to the manifest's
declared `import` target.

**It violated the invariant its own SKILL.md sets.** SKILL.md is explicit: "A
carve against more than one clip id is wrong. Group the clips and carve against
the group. This is an invariant, not a tip." The script wrote
`sources: voices.map((v) => v.id)` unconditionally, so every run against grouped
voices produced output that tripped the repo's own
`audio_carve_ungrouped_sources` lint rule, and a voice added to the group later
would silently play outside the carve's awareness. When every voice shares one
group it now records the group; mixed, partially grouped or ungrouped voices keep
their ids so the lint rule still fires on the case it is meant to catch.

`main()` moves behind an entry guard so the pure helper can be imported and
tested; `node carve.mjs` is unaffected (verified against a real composition).

Six tests, and the manifest hash is regenerated for the changed skill.

* fix(skills): run the carve CLI through symlinks, and keep the bed out of its own sources

Two blockers from review, both of the class this PR's first fix was about:
correct where it was developed, broken for the audience it ships to.

**The entry guard silently skipped `main()` through any symlinked path.**
`process.argv[1]` keeps the spelling the caller typed while `import.meta.url` is
derived from the realpath, because node resolves the main module's symlinks. So
the raw compare added to make the helpers importable turned the CLI into a no-op
that wrote nothing and exited 0. Reachable with no symlink of one's own: on macOS
`/tmp` is a link to `/private/tmp`, and `SKILL.md` documents the entry point as
`node <SKILL_DIR>/scripts/carve.mjs`, so any install placed behind a link breaks
too. Reproduced against the published core by a reviewer, not only inferred.

Fixed by realpathing the left side. This repo already documents and solves the
same trap in three scripts (`frame-packets-core.mjs`, `preflight.mjs`,
`project-dir.mjs`); the canonical comment is carried over verbatim. A local copy
rather than an import, because skills install independently — `hyperframes-audio`
has no dependency on `hyperframes-core` being present.

**`carveSources` could make the bed its own carve source.** It decided from the
voices alone, so a bed sharing their group (`mix`) got `sources: ["mix"]` written
onto it. `resolveCarveSourceIds` expands a group id to every current member and
takes no host element to exclude, so the next analysis in Studio hands the bed to
itself and the duck envelope fights the bed's own content instead of speech —
the "never carve a track against itself" invariant, arriving one re-analysis
after a first pass that was genuinely correct (`main()` sums the detected voices
directly and never round-trips through group resolution, which is why the PR's
own end-to-end check could not catch it).

The fix is at the call site, not in the resolver: neither `resolveCarveSourceIds`
nor `resolveCarveVoices` receives the host, so "make the resolver skip the
target" would be a signature change on shared core. `carveSources(voices, bed)`
declines the group form when the bed is a member and records clip ids, which is
exactly what `audio_carve_ungrouped_sources` exists to raise — plus a stderr note
saying why, so the lint message does not read as "group clips you already
grouped". Scoped to `<audio>` beds: group membership is audio-only, so a `<video>`
bed cannot be pulled in by an expansion and declining there would be a false
positive. SKILL.md now states the constraint next to the group invariant it
belongs to.

Tests: six added, closing both gaps review named. The bed-in-group regression and
a symlinked CLI invocation both fail on the previous commit (silent exit 0 vs the
usage error) and pass now; three more pin the cases that must NOT decline
(different group, ungrouped bed, video bed). `loadCore` is now exported and
covered by a fixture package carrying an import-only export map — the published
manifest's shape — so this PR's first fix is pinned without depending on npm.

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

* fix(skills): refuse the carve group when a non-voice member would widen it

Closes the second branch of the original blocker, which the bed fix did not
cover: detected voices sharing `voiceover` with an existing SFX or music member.
Detection correctly leaves that member out, but the persisted `sources:
["voiceover"]` resolves wider on the next Studio analysis —
`resolveCarveSourceIds` expands the group to every current member and
`resolveCarveVoices` keeps any audio with a src — so the extra clip enters the
sidechain and the bed starts ducking under a whoosh. Same shape as the bed case:
the first pass is genuinely correct because `main()` sums the voice list
`detectTracks` returned and never round-trips through group resolution.

Taking the first of the two suggested fixes (membership + classification in the
collapse decision) rather than deriving the first pass from the resolved group:
analysing whatever the group happens to hold would make the CLI measure clips it
classified as non-voice, which is the arrangement problem rather than a licence
to sidechain them.

`groupSourceRefusal(voices, bed, members)` replaces `bedInVoiceGroup` and returns
`{group, reason, ids}` or null, so the decision and the stderr note come from one
place. `members` is every `<audio>` in the composition as `{id, group, nameKind}`
with `nameKind` from core's `classifyAudioName`, so this and Studio's picker
classify identically. `detectTracks` now returns the media list it already built.

Classification, not membership, is what makes this safe. A member classified
`music` or `sfx` blocks the group; a member classified `voice` or `unknown` does
not. That distinction is load-bearing: `detectTracks` only analyses voices that
overlap the bed, so an outro line that starts after the bed ends is routinely a
group member this run did not measure — and covering it on a later analysis
without editing `sources` is the entire reason SKILL.md says to name the group.
Refusing on "any member the run did not analyse" would collapse the group form
into clip ids for every ordinary narration sequence. `unknown` follows detection's
own loose-in-the-safe-direction rule, since detection treats an unknown name as a
possible voice.

The note now names the blocking member, for either reason, since "sources are
clip ids" plus `audio_carve_ungrouped_sources` reads as nonsense to an author who
did group their clips.

Tests: six added, 18 in the file. The two regressions (sfx member, music member)
and the refusal shape fail with the mixed branch ablated and pass with it; three
more pin the cases that must NOT refuse — a non-overlapping voice member, an
`unknown` member, and an sfx member of a different group. SKILL.md states both
refusals and the voice-member exemption next to the group invariant.

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

* test(skills): make `members` required so dropping it cannot undo the widening fix

Review finding, and the one link no test covered. `carveSources` and
`groupSourceRefusal` defaulted `members = []`, and with an empty list the `mixed`
refusal cannot fire — so a refactor that dropped the third argument at the call
site would return the group form again with the entire suite green.

That is the same signature as the bug the argument exists to prevent: `main()`
sums the detected voice list directly, so the first CLI pass is correct either
way and only a later Studio re-analysis reads the widened attribute. Nothing goes
red. `main()` is also the only code that BUILDS `members`, and no test runs it —
the symlink test stops at the usage error and a real run needs ffmpeg.

Both defaults are gone, so a missing argument throws on `members.filter`. The
nine cases that predate the membership check now pass `[]` explicitly, which
also documents that they are about the bed and the group attributes alone, and a
new test asserts both functions throw when the argument is omitted. Verified it
fails when the defaults are restored.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:16:50 -07:00
Vance IngallsandClaude Opus 5 8612cfd40f fix(studio): put the timeline's portaled surfaces on the tier the other portals use (#3414)
* fix(studio): put the timeline's portaled surfaces on the tier the other portals use

The FX popover, the grouping dialog it swaps for, and the automation selection
menu are all portaled to `document.body`, so they land in the root stacking
context — where they sat at `z-50` while the app's own chrome occupies 60, 90,
91, 92, 94, 100 and 110, and every other portal that has to clear that chrome
(`Tooltip`, `AssetContextMenu`, `InlineTextToolbar`, `RenderQueue`) already uses
`z-[200]`. These three were the odd ones out.

Scoped honestly: the clipping in the report is fixed by the height cap in the
previous commit, which is what actually cut the popover off at the timeline
chrome. This commit is tier consistency — it removes the standing risk of a
portaled timeline surface losing to any of those seven higher tiers, rather than
a demonstrated repro. Confirm against a real window before claiming more.

* fix(studio): move the remaining body-portaled context menus to the same tier

The all-sites audit in review was right and the previous commit did half the set.
Using `createPortal(…, document.body)` as the predicate rather than the timeline
directory, four more surfaces sit in the root stacking context at `z-50` below
the seven chrome tiers (60, 90, 91, 92, 94, 100, 110):

- `player/components/ClipContextMenu.tsx:51`
- `player/components/TrackGapContextMenu.tsx:78`
- `player/components/KeyframeDiamondContextMenu.tsx:99`
- `components/editor/CanvasContextMenu.tsx:215`

The fourth is the easy one to miss — it is the only one outside
`player/components/`, so a timeline-scoped sweep finds exactly the other three.
It belongs to the same set by its own account: its className is byte-identical
to `ClipContextMenu`'s and its header comment says it mirrors that file's look,
positioning, and dismiss behaviour, portaled to `document.body`.

Two body portals deliberately left alone. `sidebar/BlocksTab.tsx:125` portals
`PromptPreviewModal`, which carries its own `z-[100]`/`z-[110]` modal tier — a
`z-` class on the portal wrapper would be dead weight. `RenderQueue.tsx:235` is
already `z-[200]`. `FileTree.tsx:336` and `FileTreeNodes.tsx:103` are `fixed
z-50` but are NOT portaled — they render inside the sidebar's own stacking
context, so the root-context argument does not reach them and raising them would
be an unrelated change.

Crossing the `z-[100]`/`z-[110]` modal backdrops is unreachable for the same
reason it was for the first three: all four dismiss on an outside pointerdown,
so the press that opens a modal closes the menu first.

`CanvasContextMenu.test.tsx:95` asserted on `.fixed.z-50` to prove the menu did
NOT render; left as-is it would have passed vacuously against any tier. Updated
to the new class so it still fails if the menu renders.

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

* fix(studio): correct the interval in the popover in-bounds test comment

`bottom: 32` with `maxHeight: 160` in a 200px viewport puts the box at y = 8..168,
not y = 8..40 — the bottom edge sits at `innerHeight - bottom`, and the comment
read it as the height instead. The assertions below already computed the right
geometry; only the stated interval was wrong, on a regression test whose comment
is the next reader's model of what it pins.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:01:48 -07:00
Vance IngallsandClaude Opus 5 277fe0fa22 fix(studio): cap the timeline FX popover to its gap, and scroll the list inside (#3413)
* fix(studio): cap the timeline FX popover to its gap, and scroll the list inside

The popover grew to whatever the preset list needed, so on a short window it ran
off the top or the bottom of the viewport and took its footer ('+ effect' /
'Open rack') with it — nothing scrolled, so the presets past the edge were
simply unreachable.

It now caps to the space on whichever side it opens toward, and the preset list
scrolls inside that while the footer stays put. `min-h-0` on the scroller is
load-bearing: a flex child defaults to min-height:auto and would refuse to
shrink, pushing the footer out instead of scrolling.

`spaceAbove` is named for the cap's benefit; it equals `anchorRect.top`, so the
flip condition is unchanged.

Four tests cover it, because this shipped once before with none: the downward
cap, the upward cap, the usable-minimum clamp, and the footer being a sibling of
the scroller rather than inside it. Verified they fail without the cap.

* fix(studio): slide the FX popover in-bounds instead of hanging it off the edge

Review found the minimum defeating the viewport cap: `Math.max(MIN_POPOVER_HEIGHT,
available)` kept the box 160px tall even when the chosen gap was smaller, so the
box extended past the edge it opened away from. At 200px of viewport with the
anchor at 100..120 it flipped up to `bottom: 104px` and spanned y = -64..96 —
every preset still reachable, but through a ~57px window with the top third of
the dialog off-screen. Reachable at high browser zoom, not only in a synthetic
short window: `available` drops under 160 once the gap is under ~172px, which
400% zoom on a 1080p display produces on both sides.

Shrinking to the gap would undo the floor on purpose (a 20px gap gives a 20px
popover — the vanishing popover in a new costume), so honour the floor and clamp
the resulting box into the viewport the way `left` already is. Two parts:

- Cap the floor by the window itself (`innerHeight - 2 * VIEWPORT_MARGIN`). The
  minimum is a floor against a tight gap, not against a tight window; below
  176px of viewport, physical space has to win.
- Inset the `top` / `bottom` offset to `innerHeight - height - VIEWPORT_MARGIN`,
  so a floor larger than the gap slides the box back in rather than off the top.

The tight case now lands at `bottom: 32px` with `maxHeight: 160px` — the box at
y = 8..40, one margin on each side. The two ordinary cases are unchanged
(34/726 down, 72/688 up), which the existing tests pin.

Tests: two added — both edges in-bounds when the minimum exceeds the gap, and
the floor yielding when the whole window is shorter than it. Both fail on the
previous arithmetic (104px vs 32px, 160px vs 104px). The three pre-existing
geometry tests now pin `window.innerHeight` through one shared helper instead of
inheriting happy-dom's 768 default, so their expected numbers are derivable from
the test and immune to a dependency bump.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:39:24 -07:00
Vance IngallsandClaude Opus 4.7 073b098e21 feat(engine): preflight psnr filter availability and force-fallback to screenshot on missing (#3418)
## What

Adds a one-shot ffmpeg-psnr filter probe at drawElement session bootstrap.
When the resident ffmpeg is missing or lacks libpostproc (no `psnr` filter),
the capture-session router now force-fallbacks to the screenshot capture
path and emits a `de_gate_reason = "ffmpeg_no_psnr_filter"` telemetry
signal via the existing `render_complete` breakdown.

Also tightens `psnrForDiskSample`'s catch: infrastructure-class ffmpeg
failures (ENOENT, "No such filter") no longer silently skip the sample —
they abort the render so the safety net cannot fail-open post-preflight.

## Why

The drawElement self-verify safety net (parallelCoordinator's
`psnrForDiskSample` → `psnrDb`) shells to `ffmpeg -lavfi psnr`. If ffmpeg
is missing, or was compiled without libpostproc (so the `psnr` filter is
absent), every per-sample compare throws. The existing catch swallows the
error and returns `null` — callers treat that as "skip this sample" and
the render completes with the safety net inoperative.

Field signal ( 9/10 CLI feedback, Slack ts=1787380767.210079,
hyperframes 0.8.7, darwin/arm64, tid=93ff9910-2207-45c2-bc1f-54c0b347d4fe):

> "host ffmpeg lacked psnr filter used by drawElement self-verification,
> but render completed."

The user's frames happened to be byte-identical so no visual damage
shipped — but the safety net silently wasn't running. Any future
compositor-damage bug on that host would have shipped straight through.

## How

Two-part fix, both in `packages/engine`:

1. New `utils/psnrFilterAvailability.ts` — cached probe that runs
   `ffmpeg -hide_banner -filters` once per process and word-boundary-
   matches `psnr` in the output. Any failure (ENOENT, non-zero exit,
   timeout, unparseable output) returns `false`; never rejects.

2. Wired into `services/frameCapture.ts` `initDrawElementOrTransparentBackground`
   right after the Chrome capability probe: when useDrawElement resolves
   true and the preflight returns false, set
   `session.deGateReason = "ffmpeg_no_psnr_filter"` (same low-cardinality
   bucket every other DE gate uses; flows through `getCapturePerfSummary`
   → `render_complete.de_gate_reason` in PostHog), emit a stderr warning
   naming what's missing, and call `routeToFallback()` — the same
   fail-graceful shape as the SwiftShader / CSS-effect / at-risk-timeline
   gates. Skipped under `HF_FORCE_DRAWELEMENT=1` (matches the diagnostic
   knob's policy of bypassing every other gate).

Belt-and-braces: `psnrForDiskSample` now discriminates infrastructure-
class failures (ENOENT / "No such filter" / "Unknown filter") from
per-sample noise (readFile races, transient EPERM). Only the former
re-throw — per-sample noise still returns `null` (skipped sample). The
preflight normally catches this at bootstrap; the re-throw covers
ffmpeg-swapped-mid-render.

## Test plan

- [x] Unit tests added:
  `packages/engine/src/utils/psnrFilterAvailability.test.ts` — mocked
  `execFile` covers: `psnr` present → true; `psnr` absent → false; ENOENT
  → false; non-zero exit → false; result memoized + reset works;
  substring-not-word-boundary → false.
- [x] Unit tests added:
  `isFfmpegInfrastructureFailure` in
  `packages/engine/src/services/parallelCoordinator.test.ts` covers
  ENOENT, "No such filter", "Unknown filter", per-sample EACCES, parse
  errors, null/non-object.
- [x] `bun run test` — `packages/engine/src/utils/psnrFilterAvailability.test.ts`
  (6 tests) + `packages/engine/src/services/parallelCoordinator.test.ts`
  (50 tests) + `frameCapture.test.ts` (26 tests) all pass. Pre-existing
  ffprobe test failures (4) on the base commit are unrelated (missing PNG
  fixture bytes — the file is 129 B on disk, likely LFS-stored).
- [x] `bunx tsc --noEmit -p packages/engine/tsconfig.json` — clean.
- [x] `bunx oxlint <files>` — 0 warnings, 0 errors.
- [x] `bunx oxfmt --check <files>` — clean.

Not covered here: an integration test that boots
`initDrawElementOrTransparentBackground` end-to-end. That path is
Puppeteer-driven and has no unit-scale bootstrap harness in the
repository — the pure preflight + pure discriminator coverage above are
what this PR can prove at the vitest layer.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 04:23:34 -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) v0.8.8 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 92a6076807 test(engine): budget the ffmpeg audio-level tests, and make a stall say why (#3410)
`places a delayed track on its authored start` timed out on the windows runner
and failed an unrelated PR, the second time this week an ffmpeg audio test has
done that.

The previous fix raised the budget in `audioMixer.grouping.test.ts`, which was
the file the symptom named. It was the wrong scope: that file was the only audio
suite with explicit timeouts at all. `audioMixer.level.test.ts` had none, so its
two real-ffmpeg tests ran on vitest's 5s default. The failing one takes ~137ms
locally, so the runner is not 36x slower — but 5s was never a budget anyone
chose for a full mix.

Applied to the suite rather than to each test, so there is one home for it, and
scoped to the ffmpeg-gated describe: the sibling parsing suites are pure and
should keep failing fast at 5s.

Headroom alone would only have delayed an undiagnosable failure. The ffmpeg
process timeout is 5 minutes by default, far above any test budget, so a stalled
mix could only ever surface as a bare "Test timed out" with no stderr and no
failing stage. Tests now cap it at 20s and assert through a helper that reports
`failures` instead of collapsing to `expected false to be true`.

Both claims verified rather than asserted: a deliberately 6s test now passes
where the 5s default would have killed it, and forcing the process timeout to
1ms reports `stage: "prepare", reason: "ffmpeg_timeout"` instead of a timeout.

Reviewing with whitespace ignored is much smaller: adding the third argument to
`describe` reindents the suite body, so 131/101 is really 34/4.
2026-08-21 21:31:58 -04:00
Vance Ingalls 0e9a4f371d feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone

The twelve-PR audio stack landed on main with all three of its canaries still
at 0%, so the FX rack, the group rows, mute and solo are in the build and
reachable by nobody. This removes the gates rather than raising the numbers: a
canary that gates nothing is a branch every future reader has to evaluate.

Gone:
- the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries;
- the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`,
  the mute label, the muted strike-through and the solo button in
  `TimelineTrackPlainHeader`, and the group-row derivation in
  `useTimelineTrackDerivations`. Each feature now renders on its own
  precondition (an audio track, a grouped track) exactly as it did for an
  enrolled user.

The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which
is the opposite of what should hold now. Replaced with a pin that no
`audio-*` canary exists at all: re-registering one silently re-hides a shipped
feature, and nothing else in the tree would say so. Verified it fails when one
is added back.

The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that
branch is 105 commits and 310 files divergent from main now that the stack has
squash-merged past it.

* docs(audio): retire the last references to the audio canaries

Two leftovers the gate removal did not reach.

`TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the
canary, unlike the preview fix" above the function that picks Mute vs Hide.
Nothing gates it now, so the comment asserted the opposite of the code.

`docs/weekly-updates.mdx` is published, and it told readers the audio work is
"staged behind a canary at zero percent, so none of it is visible by default"
and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var
maps to no registry entry any more, so following the instruction does nothing
at all. The week's record stays — it is a dated entry — but it now says the
rollout completed and that the variable is inert.

* fix(studio): name the mute action per track, and pin the newly-live audio rows

Review findings on the canary removal. All three are in code the 0% gate made
unreachable, so this is the first time any of it runs for a user.

*blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute":
the current STATE rather than the action, so nothing told a screen-reader user
that activating an already-muted row would unmute it, and it dropped `suffix`,
so every audio row shared one accessible name. Music plus VO is the ordinary
case, which makes that two identical buttons. Now `Unmute track N` /
`Mute track N`, matching the wording `timelineTrackVisibility` already writes
into undo history for the same click. `showAsMute` also picks the icon, so this
is the control's whole identity, not a tooltip.

Tests, for paths that had never executed enabled — a canary at 0% returns
`out_of_cohort` before bucketing, and studio additionally excludes
`navigator.webdriver`, so no suite could reach them:

- `VisibilityButton` — both audio states, two rows staying distinguishable, the
  visual branch unchanged, and the callback still taking the real track key
  rather than the display row. Fails on the old label.
- `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending
  order with no groups, and an interleaved group's members become contiguous
  under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between
  them keeps its place. Plus label/volume/mute mirroring and the id fallback.

Also pins the three retired canary names individually rather than by prefix:
`audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a
`startsWith("audio-")` check. The family guard stays alongside it.

* fix(studio): record the row the mute button announced, not a second derivation

Review finding: the header's track number and the undo-history label's are
computed from two different orderings, and un-gating `audio-groups` is what
makes them diverge.

The header's row comes from the group-aware list — `groupTimelineTracks` emits a
synthetic anchor row per group and pulls members contiguous. The history's comes
from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no
anchors. On the fixture in this PR's own derivation test, grouped order
`[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's
first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this
could not happen — the old branch returned raw tracks, so both sides sorted the
same way.

`onToggleTrackHidden` now carries the display row the clicked control rendered,
and `toggleTimelineTrackHidden` prefers it over deriving its own. One number
instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of
what track number does the user see" already promised. The callback still acts on
the real fractional key, so nothing muted the wrong row before or now — only the
announced and recorded row was wrong.

Also pins the rest of the newly-live surface: the solo button's presence and
pressed state, its absence on a visual track, and the strike-through for both a
row's own mute and a group mute (with the title that says which). Three existing
call-site assertions now check the threaded row too.
2026-08-21 18:22:40 -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 9c73e64a07 test(engine): give the audio grouping mixes room, and make a stall say why (#3408)
`a group FX chain fully cutting its members leaves an ungrouped track
untouched` timed out on the windows runner, failing an unrelated PR. The whole
file runs in ~4s locally and that test in ~1.1s, so 30s was not generous — but
the runner is roughly 10x slower and this test drives more ffmpeg than any of
its siblings, two full mixes plus a group FX chain. 30s was the tightest budget
in the package; 60s is what the rest of the ffmpeg-driven engine tests use.

Headroom alone would only have moved the same undiagnosable failure later,
because nothing here could report why. The production ffmpeg process timeout is
5 minutes, far above any test budget, so a stalled mix could only ever surface
as "Test timed out in 30000ms" with no stderr and no failing stage. Tests now
cap it at 20s, and the mix wrapper throws the recorded failures instead of
returning `success: false` into an `expect(...).toBe(true)` that reports
`expected false to be true` and discards the reason.

Verified by forcing the process timeout to 1ms: the failure goes from a 30s
wall-clock timeout to a 150ms error naming the stage, reason and element
(`stage: "prepare", reason: "ffmpeg_timeout", elementId: "a"`).

This does not explain the Windows stall itself, which I could not reproduce on
macOS. It makes the next occurrence report what it was doing.
2026-08-21 19:46:06 -04:00
Miguel Ángel 41edbfb2ce fix(lint): surface unloadable media variable defaults, stop reading data-var-src ids as paths (#3406)
A data-var-src value the runtime refuses to load is dropped at bind time and
the element's authored fallback src renders instead, so the video ships the
wrong media and the render still exits 0. lint said nothing, because the
scheme allowlist only existed inside the runtime.

Move that predicate into @hyperframes/parsers, where both the runtime and the
linter can reach it, and error at lint time on any declared default it
rejects. The value provably cannot load, so there is no false positive.

While reproducing that, lint turned out to report an unrelated missing file:
`\bsrc\s*=` also matches the tail of `data-var-src="bg"` (hyphen to `s` is a
word boundary), and `[^>]*` is greedy, so the variable id beat the real src
earlier in the same tag. Every binding was reported as a missing asset named
after the variable, and `<audio data-var-src>` was told the render would be
silent. All three copies of that regex now share one helper that requires
whitespace before the attribute.
2026-08-21 19:20:19 -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 09a5ef7092 fix(lint): stop duplicate_composition_id firing on repeated sub-composition mounts (#3404)
sub-compositions.md documents mounting one sub-composition several times
with different data-variable-values to get per-instance variations. That
necessarily repeats data-composition-id, so the rule reported our own
documented pattern as an error and blocked check with no correct way to
satisfy it.

The rule bucketed every element by id with no awareness of
data-composition-src, so it could not tell a composition root from a
mount. The runtime already distinguishes them: repeated mounts are
rewritten to id__hf1, id__hf2 so they coexist, and render, validate,
inspect and snapshot all handle the pattern.

Skip mounts, the same way the rule already skips tags inside an inert
template. The collision it exists for is unaffected: its own fixHint
names a <meta> tag carrying the root's id, and that tag has no
data-composition-src.

Closes #3403
2026-08-21 18:59:21 -04:00
Miguel Ángel e1191edba6 fix(producer): anchor local-font embedding to its url() occurrence (#3405)
The embed step rewrote the compiled document with
result.replaceAll(localPath, dataUri) — a bare substring replace with no
surrounding syntax. That also rewrites the path anywhere else it appears,
including inside a LONGER url whose tail happens to match, producing a
corrupted value like url("file:///abs/data:font/woff2;base64,...").

Any two paths where one is a suffix of the other collide the same way;
img/logo.ttf and assets/img/logo.ttf are enough. Every sibling rewrite in
this file already anchors on url(...), so this one was the outlier.

Also add file: to LOCAL_FONTFACE_URL_RE's exclusion list. Without it an
absolute file:// src was classified as a project-relative path and
resolved to <projectDir>/file:/abs/..., and the failed read was swallowed
by an empty catch. That catch now logs, since a silently skipped font
means the composition renders in a fallback typeface with nothing saying
why.

Closes #3369
2026-08-21 18:58:21 -04:00
Miguel Ángel 41af866bcb chore: release v0.8.7 (#3402) v0.8.7 2026-08-21 15:21:20 -04:00
Miguel Ángel 9bb4b4ce60 fix(lint): break two fix-loops and drop two rules the runtime owns (#3400)
An eval of time-to-finished-video against Remotion found the whole gap sits
after authoring, in the lint/check -> fix -> recheck loop. Lint execution is
3-5% of wall; the cost is the model turn each finding triggers. So the
expensive rule is the one an agent cannot satisfy, and the next most expensive
is the one that fires on correct code.

Two rules could not be satisfied at all.

`gsap_fullscreen_overlay_starts_visible` on a from() reveal was a closed cycle.
It errored on `tl.from("#flash", { opacity: 0 })`, which is not a defect:
from() seats its start values immediately, so on a paused timeline the overlay
already measures opacity 0 at t=0. Both of its fixHints (authored CSS
`opacity: 0`, or an immediate `gsap.set`) turn that working composition into a
real defect, which `gsap_from_opacity_noop` correctly errors on -- and that
rule's fixHint says to remove the very thing we just asked for. Applying either
hint bounced between the two errors forever.

The root cause was not the reporting condition but `laterHidden`, which counted
the reveal itself: a from-tween records its START values, so
`from({opacity: 0})` read as its own later hide. Excluding the reveal, and
excluding from-tweens (which end visible), is what actually fixes it. The
later-hidden shape still reports and still converges.

`caption_text_overflow_risk` told authors to add `overflow: hidden`, which is
exactly what `caption_overflow_clips_scaled_words` errors on. Following the
warning produced an error. The hint now says to keep overflow visible.

Two rules asserted a failure that cannot happen.

`root_composition_missing_data_start` errored because "the runtime needs
data-start=0 on the root element to begin playback". The runtime sets it itself
-- init.ts:286-292, whose comment reads "Agents sometimes omit data-start on the
root composition element ... Default to 0 for the root." The rule demanded the
fix the runtime had already applied. 16 of the 643 shipped registry files.

`overlapping_clips_same_track` claimed overlapping clips "cause rendering
conflicts". Nothing reads the track index at render: timeline.ts:586 states
"Track index is display-only; render never reads it", and grepping
`trackIndex|track-index` across engine and producer source returns zero hits.
Two clips overlapping on one track is a crossfade.

Two false positives fixed rather than removed, because the invariants are real.

`timeline_id_mismatch` fired on the legal one-liner
`window.__timelines = { main: gsap.timeline({ paused: true }) }`. The body regex
was non-greedy, so it stopped at the brace of the inlined OPTIONS object, and
the entry scanner harvested `paused` as a composition id. The resulting fixHint
named a registration that does not exist, so it could never be applied; hoisting
to a variable was the only escape and nothing said so. It now walks brace depth
and reads top-level keys only.

`non_deterministic_code` fired on `new Date("2026-01-01T00:00:00Z")`, which is
deterministic, and on `Math.random()` inside a string literal that a code-display
composition renders but never executes. Date is now zero-arg only, and patterns
run against string-stripped source -- except the GSAP `"random(...)"` tween
value, where the string IS the executed value and must still be scanned. That
exception is what the first attempt got wrong; the existing tests caught it.

Measured over the 643 shipped registry files: 386 -> 370 errors, no new codes.
The corpus does not exercise the loop cases, so it understates the change -- the
point is the turns those loops cost, not the finding count.
2026-08-21 15:15:12 -04:00
Miguel Ángel d4765512df fix(catalog): render the Matrix Decode docs preview; remove Checkout Flow (#3396)
* fix(catalog): reveal Matrix Decode captions with autoAlpha so the docs preview renders

The demo and snippet flipped word spans with zero-duration display sets,
which the seek-driven docs player never applies after its style restore:
the composition played 8s of black. autoAlpha reveals with the scrambles
as same-length absolute overlays follow the keyframes contract (never
tween display) and survive seeks and loop wraps. Scramble text now
matches each word's length so it decodes in place instead of jumping.

* chore(registry): remove the Checkout Flow component

Owner-directed removal of the checkout-flow catalog item: source,
demo, generated docs page and payload, and its entries in the registry
manifest, catalog index, docs nav, and search vectors. The deletions
are allowlisted in check-no-main-deletions.
2026-08-21 15:14:11 -04:00
Miguel Ángel a1c1f519cb fix(core): bind native window methods in the scoped sub-composition proxy (#3378)
The scoped window proxy handed native methods back unbound, so `this` at
call time was the Proxy and Chrome rejected it with "Illegal invocation".
That broke window.addEventListener, setTimeout, matchMedia and
getComputedStyle inside every sub-composition, including the
window.addEventListener("hf-seek", ...) form the Three.js and TypeGPU
adapters document. The sibling document and gsap proxies in this file
already bound; this one was the outlier.

Bind only non-constructors: Function.prototype.bind drops static members,
so binding a class exposed on window would silently strip its statics.
Built-in methods have no .prototype, classes and constructors do.

Closes #3376
2026-08-21 15:11:53 -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 77566a198b test(engine): give the ffmpeg-bound grouping mixes their 30s timeout (#3398)
audioMixer.grouping.test.ts spawns real ffmpeg per assertion and ran on
vitest's 5s default; on slow Windows runners the FX-chain and envelope
cases land right at the line and fail runs that touch nothing in the
engine. The other ffmpeg-bound engine suites (videoFrameExtractor)
already carry a per-test 30_000 timeout; this brings the grouping suite
in line.
2026-08-21 15:01:44 -04:00
Vance IngallsandClaude Sonnet 5 6a92d21401 feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).

The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).

The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).

Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
  (data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
  (timelineElementFxAttribute.ts), addressed by the TimelineElement
  itself rather than the current selection. This is the one real
  architectural gap the step doc's assumption didn't survive: the
  property panel's onSetAttributeQuiet closes over domEditSelection,
  so writing a clip that isn't already selected has no synchronous
  path through it. Extracted the shared live-patch-then-persist core
  (persistElementAttribute, timelineEditingHelpers.ts) out of both
  this new path and the existing setAudioGroupAttribute, which the
  fallow duplication gate flagged as a 66-line clone on first pass —
  now a single ~50-line core parameterized by patchLive/readLive, with
  each caller a ~15-line wrapper resolving its own patch target
  (buildPatchTarget({domId}) for a group, buildPatchTarget(element)
  for an arbitrary clip) and live-DOM lookup.

Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.

Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.

Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:01:34 -07:00
Miguel Ángel 9ec75a485f docs: drop --full-depth from skills install commands (#3399)
* Update skills.mdx

* docs: drop --full-depth from skills install commands
2026-08-21 14:45:01 -04:00
Vance IngallsandClaude Opus 5 254de3d1c4 fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of
the thirteen criticals; the theme is that the editing UI shipped ahead of
its apply/persist pipeline, so several controls mutated an in-memory model
with no downstream effect, and the mode itself could never be exited.

Mode trap: caption edit mode auto-activated on detection and had no exit —
`setEditMode(false)` and `reset()` had zero call sites, so the caption
overlay replaced normal element editing for the rest of the session, even
after switching compositions. The store now resets on composition change
(flushing the last debounced edit first), an "Editing captions · Exit" pill
sits on the preview, and a re-enter button appears once dismissed.

Honest gating of dead surfaces: the Animation tab (31 presets ×
duration/ease/stagger/intensity) edited state that was never applied to
playback nor serialized — wiring it needs a CaptionOverride schema
extension in packages/core plus a runtime engine, so the tab is now visibly
disabled with an amber "isn't applied to playback or saved yet" notice
instead of silently discarding work. Timing edge-drags moved a block that
never changed playback and never saved; the handles are gone and the blocks
remain as select/seek targets. Double-click split desynced the overlay↔DOM
index mapping, so split is out until regeneration exists.

Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target)
across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is
active and reapplied to the live iframe. Previously ⌘Z reverted an
unrelated file edit while the bad caption drag persisted.

Autosave: save failures, including non-2xx, raise a persistent "not
saved — Retry" banner; the code's own comment called this a data-loss path
and it was telemetry-only. Debounced saves flush on unmount instead of
being discarded, `beforeunload` flushes and warns while pending, and
corrupt overrides JSON is distinguished from a missing file.

Input safety and a11y: arrow-key nudge no longer hijacks arrows inside
form inputs; numeric fields commit finite values only (typing "-" used to
inject NaN into gsap and persist null); "Mixed" shows on multi-select
divergence; Escape cancels an in-flight drag and restores the pre-drag
transform; ⌘A selects all; caption blocks are keyboard-selectable with a
playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed
but nothing passed it); 24px hit areas around the 8px handles; a hint when
no boxes are visible; visible input focus styles; tablist semantics.

Perf: the 66ms getBoundingClientRect polling loop is replaced with
event-driven updates (player-store subscription, preview messages,
ResizeObserver, rAF-coalesced); the interval now runs only during playback.

Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio
revamp (#2291), so the mode pill, the sync-error banner and the re-enter
button move to its successor, nle/PreviewOverlays.tsx, and the caption
track's onSeek is wired in EditorShell. The per-keyframe
onChangeKeyframeEase change that also lived in that file is dropped:
main removed the prop, and #1967 now routes the diamond menu's ease action
to the focused-ease-segment editor instead.

Restacked onto main now that PRs 1962-1967 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:37:28 -07:00
Vance IngallsandClaude Sonnet 5 0d26072e6c feat(studio,core): mute groups, and hear-only-this that cannot reach the export (#3291)
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:19:53 -07:00
Vance IngallsandClaude Opus 5 a01a5d7b3c fix(studio): player UX — honest waveform, keyframe menu actions, beat-delete gesture (#1967)
Player and timeline fixes from the studio UX review, reconciled against six
weeks of main.

Honest media states:
- AudioWaveform no longer falls back to synthesised sine-wave peaks when a
  decode fails. The failure propagates and the clip renders a dashed flat
  line + "waveform unavailable" instead of a plausible waveform an author
  would trim and beat-align against. Main's thumbnail scheduler already
  caches the failure with a TTL, so this neither refetch-loops nor pins the
  degraded state past a transient error.
- VideoThumbnail renders a static "no preview" placeholder on a failed
  decode rather than resolving to an empty box.

Keyframe context menu, restored:
- "Edit Ease…" (showing the current ease) and "Copy Properties" (async,
  "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes
  to the same focused-ease-segment path a segment click takes, so the menu
  advertises the editor that exists instead of growing a second one; it is
  offered only for a keyframe that names a tween to focus. Copy Properties
  matches the keyframe cache on clip-% with the same tolerance main's
  move-to-playhead uses. Every row is a role="menuitem" with arrow-key
  navigation and focus handling via the new useMenuKeyboardNav helper, and a
  separator now isolates "Delete All Keyframes" from the single delete.

Error prevention:
- Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off
  double-click to ⌥-click — a stuttered drag reads as a double-click and
  would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons
  instead of deleting.
- ShortcutsPanel moves focus into the panel on open and returns it to the
  trigger on close; SpeedMenu's trigger is labelled and reports its popup.

Superseded by main, deliberately dropped: the seek-slider keyboard and
aria-valuenow fixes (the transport no longer owns a seek bar), the Player
load-error inline retry (main's reports the actual message and retries with
a cache-busting src), TimelineClip keyboard selection (main renders a native
button, and this PR's onKeyDown would have preventDefault'ed the synthesized
click), the keyframe-diamond keyboard guard and label (both already on main,
with a richer label), and the waveform's own cache/failure maps (main's
scheduler owns that). TimelineOverlays.tsx is a main-side file edited to
thread the two restored menu actions; BeatStrip.test.tsx tracks the new
gesture and hit target.

Restacked onto main now that PRs 1962-1966 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:02:47 -07:00
Vance IngallsandClaude Opus 5 44791c3f7d fix(studio): sidebar/panels UX — asset delete confirm, rename, search trap, undo (#1966)
Left-sidebar and slideshow-panel fixes from the studio UX review. Four
criticals: an unconfirmed permanent asset delete, a Rename menu item that
did nothing, a search box that unmounted itself while its filter stayed
applied, and slideshow edits whose persist failures were swallowed.

Asset context menu: Delete shows an inline DeleteConfirm before calling
the API; the dead Rename item is a working inline rename (validates `/`,
`\`, `..`, preserves directory + extension); role="menu"/"menuitem",
Escape, arrow-key nav, focus-into-menu, viewport clamping.

Assets tab: header controls gate on the UNFILTERED asset count, so a
no-match query shows "No assets match" + Clear search instead of
unmounting its own input; cards and font rows are keyboard-operable; a
copy chip surfaces clipboard failure; the import button owns its pending
state; a broken thumbnail names the file type.

Slideshow panel: persist failures raise a "Changes not saved — Retry"
banner (role="alert") with a working retry; in-panel undo stack (50
snapshots, scoped ⌘Z); branch delete confirms inline; reorder buttons
disable at boundaries; HotspotTool explains its prerequisites.

Blocks / compositions tabs: "Added!"/"Copied!" are promise-truthful;
hover-only overlays reveal on focus; PromptPreviewModal gets the dialog
contract + dirty-draft guard; lint dot → labeled count badge; the render
button explains "A render is already in progress"; sidebar tabs are a
real APG tablist; AudioRow coordinates a single preview at a time.

Restacked onto main now that PRs 1962/1963/1964 have squash-merged, so
this carries only its own changes. Reconciled against six weeks of main:
main's newer interaction model wins (rows drag to the timeline, click
reveals the clip or opens the preview, copy is a context-menu action),
and this PR's a11y and error surfacing is ported on top of it. The card
components main extracted to AssetCard.tsx receive the keyboard
activation, focus cues and copy-outcome chip; the "Add at playhead" item
main added joins the rewritten menu's arrow-key order; the Catalog tab is
unconditional since the blocks-panel flag was removed. The row copy chip
is feedback-only — an idle "Copy path" label would describe something the
row no longer does.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:24:24 -07:00
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

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

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00
Vance IngallsandClaude Sonnet 5 99f42be04c feat(engine): render grouped audio through a summed, FX-processed bus (#3289)
* feat(core): route grouped audio through a group bus in preview

An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

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

* feat(studio,lint): carve targets voiceover groups — always, when plural

Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

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

* feat(engine): render grouped audio through a summed, FX-processed bus

Renders what B3 already routes in preview: a group's members sub-mix into
one PCM WAV at full composition length (adelay already places each member
at its composition position, so the group WAV's t=0 IS composition time),
run through the group's own FX chain and automation via the same
applyAudioFxChain/envelope-bake path a member uses, then fold into the flat
track list as one processed AudioTrack — the final mixAudioTracks call
never has to know groups exist.

Gain law verified against plans/spikes/amix-nesting-spike.sh (brought over
from the plans branch, along with audioMixer.grouping.test.ts, since both
were committed there and never merged to origin/main — every step branch in
this stack descends from origin/main): the sub-mix's own amix prefers
normalize=0 (nulls exactly against a flat mix), falling back to per-node
compensation by the group's OWN member count only when this ffmpeg build's
amix rejects the option. Carrying any other count into a nested amix node
is the exact +2.499 dB silent failure the spike measured — confirmed by a
manual mutation check (wrong-count compensation landed 3.5 dB hot, exactly
20*log10(3/2) for a 2-member group compensated as 3; reverted after
confirming the level test catches it).

A group element carrying data-hidden drops every member before the sub-mix
ever runs (RULES: mute-by-drop, never mute-by-volume-0) — parseAudioElements
now resolves groups once per parse and skips hidden-group members the same
way it already skips data-hidden ancestors.

HfAudioGroup (packages/core/src/audioGroups.ts, from B1) gains fxChain,
automation, volume and hidden, read off the group element the same way
resolveAudioGroups already reads data-label — audioGroups.test.ts updated
for the wider shape plus new coverage for the added reads.

it.todo("mixes a grouped composition at the same level as the ungrouped
one") is now a real, passing test; two more added per the step doc (FX
routing isolation, member-level envelope survives grouping).

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:34 -07:00
Vance IngallsandClaude Sonnet 5 485c037dcf feat(studio,lint): carve targets voiceover groups — always, when plural (#3288)
* feat(core): route grouped audio through a group bus in preview

An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

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

* feat(studio,lint): carve targets voiceover groups — always, when plural

Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:16 -07:00
Vance IngallsandClaude Sonnet 5 602cf53cf6 feat(core): route grouped audio through a group bus in preview (#3287)
An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:02 -07:00
Vance IngallsandClaude Opus 5 ba607bf886 fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel (#1965)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:36:31 -07:00