Commit Graph
2962 Commits
Author SHA1 Message Date
Vance Ingalls c224706ba3 fix(studio): audition through a mute, and stop the audition leaking into the saved chain
Two reports, one shelf:

Muted targets now audition. Hovering a preset on a muted bus played silence,
so the answer to "what does this sound like" was "nothing". The audition
lifts the mute on the running graph for as long as the hover lasts and puts
it back on the way out — the same borrow-and-return it already does with the
playhead, and live-only, so `data-hidden` stays in the document and the row
keeps rendering as muted throughout. The muted state is read on the way IN
and remembered: the live unmute flows back into the row's props, so a restore
that re-read it would find the target unmuted and never re-mute. Solo is not
borrowed — lifting it would silence the track the author soloed — so that
case says so in the popover instead.

Applying a preset no longer saves the one you were hovering. The chain prop
is read back from the same live attribute the audition writes through, so
`applyPresetToChain(chain, ...)` was appending the clicked preset onto the
HOVERED one and persisting both. Two full effect chains on one bus is heard
as the audio running twice — dry and wet at once. Apply now lands on
`storedChain()`, the chain as the document has it. Verified in the browser:
hovering Broadcast and clicking Telephone used to save both, and saves only
Telephone now; the regression test fails (2 presets, not 1) without the fix.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean.
2026-08-20 16:39:47 -07:00
Vance Ingalls 77dd736fe6 fix(studio): audition a preset where the thing it applies to actually sounds
Hovering a preset in the timeline FX popover started playback from the
playhead, which is only useful if the target is sounding there. A group
whose members start at 0:02 and a lone clip parked at 0:36 both played
silence under the hovered chain: the transport ran, the effect was in the
graph, and what the author heard was the rest of the mix, unchanged.

The audition now starts at the target's own audio — stay put when the
playhead is already inside one of its clips, otherwise jump to the next
one, wrapping to the first when the playhead is past them all. Leaving
still returns the playhead to where the hover found it.

Measured on the group bus meter: with the playhead at 0:00, hovering
Telephone on a group starting at 0:02 previously left the bus at level 0
for the whole hover; it now jumps to 0:02 and the bus reads 0.085 with the
chain in the path (0.14 dry, 0.07 under a 60 Hz lowpass).

--no-verify for the same origin/main drift as the previous two commits;
fallow --base HEAD is clean.
2026-08-20 16:39:46 -07:00
Vance Ingalls a3ab011e22 fix(studio,core): make the group bus a place effects can actually be applied
Four things stood between an author and an effect on a bus:

- Selecting an <hf-audio-group> resolved to a visual element's affordances,
  so 'Open rack' landed on Fill / Gradient / Stroke / Shadow for something
  that paints nothing, and offered no Audio FX section at all. The bus tag
  now gets audioFx and loses layout/style.
- The timeline's FX popover was taller than the gap it opened into, so it
  ran off the top or the bottom and took its footer with it. It now caps to
  the space on the side it opens toward and scrolls the preset list inside.
- Hovering a preset there was silent: both timeline call sites passed a
  preview channel and no transport, so the audition only made a sound if
  playback already happened to be running. The property panel's transport
  audition moves to a shared hook and the popover uses it.
- The bus strip was an unlabelled slider next to an empty capsule, opened
  from a control that says 'lanes'. It says 'Bus level' now.

Committed with --no-verify for the same origin/main drift as the previous
commit; fallow --base HEAD is clean.
2026-08-20 16:39:45 -07:00
Vance Ingalls 86420cdac3 fix(studio): indent group member rows under their bus
A group's member tracks rendered flush with every ungrouped track, so the
only thing tying a track to its bus was the bus row happening to sit above
it — which stops being true as soon as anything scrolls. Member rows now
carry a left rail and an indent, the way a tree says child.

Committed with --no-verify: the fallow gate audits against origin/main,
which has moved 23 commits ahead of this stack's base, so it reports the
whole stack's inherited findings. Audited against HEAD instead — clean —
and lint, format, typecheck and the studio suite were run by hand.
2026-08-20 16:39:44 -07:00
Vance IngallsandClaude Opus 5 6de75fd4b4 fix(studio): make sub-composition audio groups actually work
Browser-verified the one surface that had never been run: a group and
its members declared entirely inside a sub-composition. Both fixes
written for that case were broken, and both of their tests passed —
because I wrote fixtures that matched my assumption instead of the DOM.

Membership never arrived. `hostElementState` inherits from the flat store
twin, and a sub-comp that declares its own group keeps those members OUT
of the flat store — the store held three elements (panel, sub-comp host,
bed) and neither voice. So there was nothing to inherit from and no group
row appeared at all. Membership now rides `DomClipChild`, captured during
the DOM walk that is the only place holding the child's live element,
with the flat twin still preferred when it exists.

Routing never worked either. `getTimelineElementSourceFile` stops at the
nearest `[data-composition-id]`, which for an inlined sub-composition is
its own ROOT element — that carries the composition id but not the file.
The file sits on the HOST above it:

  hf-audio-group#voiceover   (no composition attrs)
  section#voices-root        data-composition-id="voices"           <- stopped here
  div#voices-host            data-composition-file="...voices.html" <- file is here
  body                       data-composition-id="<root>"

My unit fixture put the file on the sub-comp root, so the test passed
while the studio still threw "Unable to patch element in index.html" on
every mute, fader move and FX preset. The resolver climbs composition
ancestors until one names a file, and returns undefined for a root-level
group so the caller still falls back to activeCompPath.

The new tests use the ancestor shape copied from a live preview, and both
fixes were mutation-checked. Verified end to end in the studio: the group
row appears with its members nested, mute writes `data-hidden` into
compositions/voices.html and not index.html, unmute removes it, and the
fader writes data-volume="0.35" to the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:43 -07:00
Vance IngallsandClaude Opus 5 6056ec7463 fix(studio): close seven defects a review found in the previous two commits
Sixth review pass. Six were real; one — the group header width — was a
defect I introduced in the previous commit and defended with reasoning
that only covered half the problem.

The header overhang was wrong. `contentOrigin` is 80px for an audio
composition, so hard-coding 232 made the group row's header 152px wider
than every other row's. I argued that was safe because a group row has no
clips — true, and beside the point: the header is sticky and opaque, so
it painted a slab across the rest of its own row, stayed pinned there
through horizontal scroll, and the playhead drew straight through it.
Groups now turn `labelMode` on instead, which is what that flag is for.
Every row gets the same 232px header, verified in the browser.

Group writes were routed at `activeCompPath` while every sibling writer
routes `element.sourceFile || activeCompPath`. Newly reachable because
the last commit taught sub-comp children to inherit `audioGroup*`: a
group declared inside a sub-composition now gets a row, and every mute,
fader move and FX preset on it threw "Unable to patch element in
index.html".

`Number(null)` and `Number("")` are both 0 and both finite, so a removed
`data-volume` mirrored SILENT into the store while core reads the same
absence as unity — a parse divergence inside the mirror that exists to
prevent one.

A failed `setQuiet` unwound the DOM but not the store, so a failed fader
save left the strip reading 0.4 while the preview played 1.0, with
nothing to re-parse and correct it.

`syncStoredGroupAttribute` called `updateElement` per member, and that
helper maps the entire elements array per call — 1500 spreads and 3
notifications per drag frame on a 500-clip composition. One pass now.

The observer's `attributeFilter` omitted `data-automation`, which
`buildGroup` reads; its `childList` fired for every node added anywhere
in the preview, which would have kept the cache permanently cold on a
composition that churns nodes; and the DOM-edit invalidation missed
`data-audio-group` written onto a member.

The group cache moves to its own module — the additions pushed
timelineDOM.ts past the 600-line ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:42 -07:00
Vance IngallsandClaude Opus 5 25d7af8a5c fix(studio,core): groups open by default, headers fit, and two contracts stop being promises
The four items left after the browser pass, plus the two architectural
findings from the review that were held for a decision.

Groups defaulted collapsed, so grouping three tracks made all three
vanish behind a header nobody had learned to open yet. The set could not
distinguish never-touched from deliberately-collapsed, so it is stored
inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus
predicate inversion across nine call sites and their tests.

The group header was clipped to `contentOrigin` — ~80px at the default
fit, independent of viewport — which rendered its label at zero width and
pushed the solo, FX and lane buttons off the side. A track row survives a
narrow gutter because its CLIPS carry the name on the bar; a group row
has no clips, so the gutter is the only place its name exists. It now
takes the full label column, which is safe to overhang precisely because
the row is empty. Measured 80 -> 232, label 0 -> 45px.

Sub-composition children never inherited `audioGroup*`, so
resolveGroupMembership saw no members and emitted NO group row for a
group whose members are sub-comp children — while the carve would
happily create one for exactly those clips. Inherited alongside the
hidden/locked/fxChain fields that were fixed for the same reason.

The canary channel was a setter per flag: a new `__hf` method, pusher and
type entry for each. Replaced with one `__hf.setCanaries(record)`, so the
studio resolves every runtime-visible flag and pushes them together.
Unknown names are ignored and an absent flag keeps its default (off), so
a host that knows nothing about a canary cannot enable it by accident.

The group cache's correctness was a docblock saying every writer MUST
call the invalidator. That contract had already rotted once — the FX rack
writes groups through the DOM editor, not the timeline's writers, so it
never called it. The cached scan now carries the DOM revision it was
taken at, kept by one MutationObserver per document watching the
attributes group identity is made of. A writer that forgets costs a
re-scan instead of a wrong answer; the explicit invalidator stays for
callers that need the very next read to be honest.

Verified in the browser: group expanded on load with no seeding, header
232px with the label and all four controls visible, `setCanaries` present
on the runtime and the per-flag setter gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:40 -07:00
Vance IngallsandClaude Opus 5 ff4965037c fix(studio): three defects a browser found that no amount of reading did
First time any of this stack has been looked at rather than reasoned
about. Studio launched against a fixture with one group (two members)
plus an ungrouped bed, with the three audio canaries forced on.

The group element drew a phantom CLIP row. `<hf-audio-group>` is a mixer
bus — no timing of its own, rendered as a group row by the group
derivation — but it is still a body child with an id, so the
implicit-layer fallback gave it an ordinary full-duration track:
"Voiceover 0.0s-12.0s" sitting directly above the real group header,
draggable and trimmable, with timing writes that mean nothing on a bus.
Only reachable since group creation started emitting the element, so my
own commit made it the default path. Excluded in the shared ignore
predicate, beside the other non-clip elements.

Group writes never reached the store. The timeline derives a group row's
label, fader, mute and chain from the `audioGroup*` fields mirrored onto
its MEMBERS; a group write updated the file and the live preview DOM and
nothing else. Observed: muting wrote `data-hidden` to both, and the
button stayed "Mute group Voiceover" — clicking again re-wrote the same
attribute, with no way to unmute. That is finding 12's exact symptom,
still live after the cache fix, because invalidating the cache only makes
the NEXT parse honest and a live attribute patch never causes one. Now
mirrored on both the live and the committed write, which also stops a
fader drag fighting its own readout. Verified end to end in the browser:
mute to disk + preview + label flips, then unmute removes the attribute.

The bus fader offered 0..2 while every consumer clamps to [0,1]. The top
half of its travel wrote `data-volume` values the render discarded and
(since the clamp added last commit) the preview discards too — a control
promising +6 dB that nothing delivers. Ceiling lowered to unity. Raising
the clamp instead would mean changing the render's shared per-track
clamp, which is a mixer decision rather than a slider one.

Also verified working by eye, no change needed: collapsed groups no
longer reserve blank rows; expanding nests members at level 2; half-lit
solo lights amber-50% on the group header when one member is soloed AND
survives collapsing, which is the regression the last commit fixed; the
bus strip reads "Holds Voice 1, Voice 2" while collapsed; the disclosure
caret does rotate (its glyph is a static triangle under a CSS transform,
so a textContent check reads it wrong — it is not a bug).

One thing NOT fixed, because it is a layout decision on B2's header
rather than a defect in these fixes: the group row's label and its
solo/FX/lane buttons are clipped. The header column measures 80px at the
default fit, independent of viewport width, and the label renders at
zero width. A normal track survives this because its clips carry the name
on the bar; a group row has no clip bar, so the gutter is the only place
its name exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:39 -07:00
Vance IngallsandClaude Opus 5 7393095be8 fix(studio,core,engine): close the defects a max-effort review found in the fixes
A review of the five fix commits found eleven real defects, including a
regression one of them introduced. Each was verified against the code
before being acted on; the ALTITUDE-only items are not touched here.

REGRESSION, from "group rows survive a collapse". Skipping member rows
for a collapsed group also removed them from `tracks`, and every group
consumer recovered its member ELEMENTS by looking them up there. Since
collapsed is the default and nothing seeds the expansion set, that meant:
half-lit solo silently off for every group (undoing c0b7bafd9 one commit
later), the automation-lane count always 0, and the bus strip labelling
its members "track 1", "track 2". Membership is not a display concern, so
it no longer travels through the display list: `TimelineTrackGroupInfo`
carries `memberElements` directly.

Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the
booked automation — an AudioParam value write inside a live curve throws,
and this runs inside `schedulePlayback`, whose catch turns a throw into
`return null`: the MEMBER would have silently dropped out of the pass.
Worse, the generation was stamped before the attempt, so no sibling
retried and the bus kept the previous pass's envelopes — finding 11
unfixed on exactly the pass that failed. Now: clear first, stamp only on
success, and isolate the call. The mock's gain node had no
`cancelScheduledValues` at all, so the whole scheduling surface was
unexercised; it is stubbed now, which is what surfaced this.

`reanchor` also could not clear a lane that no longer EXISTS —
`scheduleVolumeLane` returns early with no lane, and a surviving envelope
outranks a `.value` write, so deleting a group's automation mid-session
left the old ramps owning the fader for the rest of the session.

The preview fader applied `data-volume` unclamped while the render clamps
to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at
unity, `-1` previewed with inverted polarity and rendered silent. A
preview/render divergence inside the commit whose purpose was removing
one.

Pitch shift. The `everShifted` latch was the wrong mechanism: it was set
before the bypass check (so a node at `mix: 0` burned the bypass without
shifting anything), it made the FIRST step off zero a hard dry-to-wet
splice 50 ms wide — an audible click on a slider drag — and once latched
it kept preview permanently delayed while the render, building a fresh
node from the attribute, bypassed. Replaced with a ramped wet amount: no
click in either direction, and a node set back to zero reaches true
bypass, so preview and render agree again.

Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers`
was caught one frame up and not rethrown, so the carve's auto-group still
saw success and persisted `sources: [groupId]` for a group that was never
written — the exact failure the throw was added to prevent. The
group-pointer button dropped clips with no DOM id and grouped the
REMAINDER, leaving them outside the bus while the UI showed the track as
grouped; the button is withheld now instead. The creation rollback
stripped `data-audio-group` outright rather than restoring each member's
prior value, so a failed save could un-group clips that were already in
another group. `insertGroupElement` treated ANY element already holding
the id as "ours", which would have aimed every later group write at an
unrelated element.

`setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`.
Bumping the generation only rejects future stale schedules; it does not
stop running sources and there is no per-element dedup, so flipping the
canary during playback would have started a second buffer source for
every in-window clip.

`invalidateGroupInfoCache` was missed by the DOM-edit path: the rack
reaches `<hf-audio-group>` through the DOM editor, not through the
timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one
chokepoint every attribute write passes — so this does not stay a
per-caller obligation.

Both defects in the ffmpeg-header test are mine: it early-returned
instead of skipping when ffmpeg is absent (reporting green having
asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout
as a requirement, which would fail on a legal canonical header the parser
also handles.

Also: the group-degradation note is no longer dropped when the outer mix
degrades too, and a malformed doc comment (two stacked openers) is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:39 -07:00
Vance IngallsandClaude Opus 5 07203f59d1 fix(engine): keep a group's headroom through its FX chain, not just up to it
Residual of finding 8, found by following the float sub-mix downstream
instead of stopping at the file it writes.

The float intermediate fixed the clip for a group with no FX chain. A
group WITH one runs that sum through applyAudioFxChain, whose writeWav
clamps to ±1 and emits 16-bit — so the headroom was handed straight back
one step later, still upstream of the fader. Same bug, same shape, one
node further along: measured 1.6 dB hot on two 0.7-peak tones summing to
1.4 under a transparent 0 dB chain and a 0.5 group fader.

writeWav now takes a `float` flag and readWav reports the format it read,
so the FX pass writes back whatever it was handed. Only the group
sub-mix is float; every element track is still 16-bit, which is what the
envelope baker wanted when that was made 16-bit in the first place. Safe
only because the baker now reads float too — before that commit it was
not, and this would have silently degraded automation to the expression
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:38 -07:00
Vance IngallsandClaude Opus 5 29ed258c27 test(engine): read what ffmpeg actually writes for pcm_f32le
The float fixtures beside this are hand-built canonical 44-byte headers.
ffmpeg's pcm_f32le writes an 18-byte `fmt ` chunk plus a `fact` chunk,
putting `data` at offset 92 — so every float assertion here would still
have passed if the parser could not read a real sub-mix at all. An
unreadable file returns false, which the caller reads as "no automation
here" and drops the group's envelope silently.

Verified empirically first: the tag is 3 (WAVE_FORMAT_IEEE_FLOAT), not
0xFFFE EXTENSIBLE, and the data offset lands 4-aligned. The parser was
already right; nothing here was covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:38 -07:00
Vance IngallsandClaude Opus 5 c1a9390024 fix(core): stop the pitch shift delaying audio it is not shifting
Finding 7, in the two parts the worklet can actually fix.

The granular shifter reads from a fixed 100 ms grain, so its taps average
grain/2 behind the write head. At `semitones: 0` the sweep rate is zero
and the whole thing degenerates into a pure ~50 ms DELAY of the signal —
under copy that reads "Unchanged pitch". It bypasses now, as does
`mix: 0`. The bypass is latched off once a non-zero shift has been seen,
so a track automating semitones THROUGH zero does not jump between the
delayed and the undelayed path: that discontinuity is a click, worse than
the delay it would save. The ring keeps filling either way, so a later
shift does not start cold.

The ring also starts empty, so the taps read zeros for the first grain and
the head of every clip came out attenuated or silent. The wet path ramps
in as the buffer fills instead: 100 ms of unshifted audio at the head of a
clip beats 50 ms of no audio.

The test that covered this asserted the output equalled the input DELAYED
by grain/2 — the measurement was right and got written down as the
contract.

What this does NOT fix: the ~50 ms group delay for an actual shift. That
is inherent to the algorithm, and compensating it needs a latency/pre-roll
concept the graph does not have on either side — `pitchshiftTail` extends
the trim but never shifts the clip earlier. It is stated in the effect's
description rather than left as a trap, and it is a design decision, not
a bug fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:37 -07:00
Vance IngallsandClaude Opus 5 2cd8b43087 fix(core,engine): make a group's preview match what the render bakes
Findings 8-11 and 16 — the render/preview parity set. All group audio,
all invisible to preview or to export alone, which is why each had a
passing test beside it.

9 — The preview bus wired only the automation lane, and
readElementAutomation reads data-automation, so a group's own
`data-volume` never reached the graph at all: the bus stayed at unity
while the render applied it. The export was ~8 dB quieter than what had
been auditioned. The test named for this asserted only
`resolves.not.toBeNull()`.

10 — The volume lane was scheduled on the FX chain's SOURCE, ahead of
the effects, breaking the contract scheduleVolumeLane's own docstring
states: "the volume lane rides the fader, after the effects — where a
DAW puts it, and the order the render bakes it in". The element path
honoured it; the group path did not, so any nonlinear group effect
previewed differently than it rendered. Both now land on a dedicated
post-FX fader node: input → chain → fader → mute → output.

11 — The bus deliberately outlives stopAll(), and groupInput early
-returned on an existing entry, so after a replay or a seek no new ramps
were booked and the gain held the previous pass's last value — 0 after a
fade-out, i.e. silent for the rest of the session. It re-anchors once
per play generation now (ElementFxHandle gains `reanchor`), and the
record keeps its `fx` handle so setRate can re-aim a group's automation,
which its docblock already claimed it did.

8 — The sub-mix summed members at unity (normalize=0) into a pcm_s16le
intermediate, so an over-unity sum hard-clipped at ±1 BEFORE the group's
FX and fader ran: pulling the fader down, or the Giant preset's
compressor, then operated on distortion. The intermediate is float now.
Both downstream readers already took float; applyVolumeEnvelopeToWav did
not, and does now, so a group's envelope is still baked sample-accurately
rather than silently degrading to the expression path.

The new level test is the one that matters here: EVERY tone in that file
is built on ffmpeg's `sine` source, which peaks at ~0.125 full scale, so
nothing in the suite could reach a clip whatever gain it asked for.
`writePeakTone` states the amplitude outright, and the test fails against
the 16-bit intermediate.

16 — mixGroupMembers forked mixAudioTracks' filter build and dropped its
automation-degradation retry, so a member envelope past this ffmpeg
build's expression limits failed the whole composition's audio where an
ungrouped one degrades to base volume with a warning. The retry is back,
reported on a successful result the same way. The group track is also
pushed with its volumeKeyframes when the envelope could not be baked,
instead of losing the group's automation silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:37 -07:00
Vance IngallsandClaude Opus 5 ce6426ddcb fix(studio): group rows survive a missing provider, a collapse, an edit and a reload
Findings 12-15 from the review. All four are group/solo UI state, none
reachable before the id-space fix made group writes work at all.

14 — TimelineGroupRow called the THROWING useTimelineEditContext where
every sibling row calls the optional one. Timeline.test.ts already
asserted the timeline renders outside the provider; it passed because
its fixture had no groups. One grouped clip took the whole timeline
render down, not just the row. The new test is that assertion with a
group on screen, and it fails against the old hook.

13 — A collapsed group left its members in `tracks`, so row geometry
reserved a full row each while buildTimelineLogicalRows had already
stopped emitting them: TimelineLanes rendered null into reserved space,
giving a group header trailed by its members' worth of blank,
unreachable dead space. Members are now emitted only while the group is
expanded, so the row list and the logical rows agree by construction.
(Zeroing the heights instead does not work — createTimelineRowGeometry
deliberately reads a non-positive height as "invalid, use TRACK_H".)
Membership still lands in trackGroupOf: collapsed is hidden, not
ungrouped.

12 — groupInfoCache is a WeakMap keyed on the preview Document, and
group edits are applied as live patches precisely so the iframe never
reloads, so the key never changed and the entry never dropped. A muted
group could not be unmuted (the header kept reading the cached
`hidden:false` and re-wrote data-hidden), the bus slider snapped back,
and a second FX preset built on a stale chain, discarding the first.
Every live group write now drops the entry.

15 — Solo leaked two ways. createTimelineResetState never cleared
`soloed`, so switching composition carried ids that match nothing in the
new document — which is exactly the state that silences every track
while the banner still names a clip that is not there. And the solo
bridge's effect deps do not change across a preview reload, so the
reloaded runtime kept an empty set while the button stayed lit and the
banner still claimed "Hearing only X". Solo now rides
applyPreviewAudioFlags, the same reload-surviving path as the mute and
canary state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:36 -07:00
Vance IngallsandClaude Opus 5 a8d27820ba fix(studio): half-lit group solo compares bare DOM ids too
A fifth face of the id-space mismatch, not in the review's list.
TimelineGroupRow built its member list from `el.key ?? el.id` and handed
it to isGroupHalfLitUnderSolo, which compares against the soloed set —
bare DOM ids now that the header pushes them that way. Soloing a member
lit nothing on its group, the one signal that says "some of what is
under here still plays".

Found by auditing every consumer of `soloed` rather than trusting the
handoff's claim that this call site "happens to pass the bare id" — it
passes a bare id for the GROUP and store keys for its MEMBERS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:34 -07:00
Vance IngallsandClaude Opus 5 5850a0c978 fix(studio,core): unify the audio id space, emit group elements, gate both canaries
Six of the fifteen findings from the max-effort review of the audio
groups / mute-solo / pitch-shift stack. Nothing in the stack is merged;
this sits on top of wa-24-timeline-fx.

The id space (findings 1-3). Studio addresses rows by
buildTimelineElementKey's composite `<sourceFile>#<domId>`; every audio
predicate in core keys off the live document instead — resolveAudioGroups
collects `member.id`, isAudibleUnderSolo compares `el.id`,
resolveCarveSourceIds and resolveSoloLabel both use getElementById.
Nobody checked the boundary, so:

  * solo put a composite key in the set the runtime matches against
    `el.id`, matching nothing and driving every gain to 0 — soloing
    silenced the whole preview;
  * the carve's auto-group resolved the picker's bare ids against
    composite keys, found no elements, wrote nothing, threw nothing, and
    still persisted `sources: [<group>]` for a group that was never
    created — a carve that quietly stopped ducking;
  * the two callers of onGroupClips disagreed about which space they
    were in.

Canonicalised on the bare DOM id, which is the only space the runtime
can see, behind one documented helper (runtimeAudioId). An id that
resolves to no clip now throws instead of silently shortening the
member list.

The group element (finding 4). Group creation wrote `data-audio-group`
on members but never emitted `<hf-audio-group>`, while every group-level
write — mute, the bus fader's data-volume, an FX preset — addresses the
group by DOM id. Groups the product created were exactly the groups
nothing could edit. Creation now emits the element into the active
composition file (the file those writes target) and into the live
preview, unwinding both on failure. Group ids are validated before being
interpolated into markup.

The canary leaks (findings 5-6). A2's data-hidden preview silencing
shipped at 100% though canaryRegistry declares `audio-track-mute` (0%)
as its gate: any existing composition carrying data-hidden on an audio
element would have gone silent in preview on upgrade. Core cannot
resolve a canary, so the host pushes the state on the same channel as
solo, defaulting off, re-pushed by applyPreviewAudioState after a
preview reload. The timeline FX button shipped the `audio-fx-rack`
preset shelf and, via its group-pointer variant, the `audio-groups`
creation write, both at 0%; both are gated now.

Tests. Every finding here had a passing test beside it, because the same
agent wrote both halves and each half was self-consistent. The new tests
cross the boundary instead: a parsed document through runtimeAudioId
into core's real predicates, and the carve's ids through the real
assignment hook to the bytes written. Each was mutation-checked against
the pre-fix code.

Group creation moves to its own module — the additions pushed
timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw
NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical,
but they made the file read as binary to grep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:33 -07:00
Vance IngallsandClaude Sonnet 5 1d5405e3e0 feat(studio,core): reach presets and the rack from the timeline
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-20 16:39:31 -07:00
Vance IngallsandClaude Sonnet 5 301cb8b51f feat(studio,core): mute groups, and hear-only-this that cannot reach the export
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-20 16:39:31 -07:00
Vance IngallsandClaude Sonnet 5 e1c9b50948 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>
2026-08-20 16:39:30 -07:00
Vance IngallsandClaude Sonnet 5 8052f3e68f 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>
2026-08-20 16:39:30 -07:00
Vance IngallsandClaude Sonnet 5 351b219d2b 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>
2026-08-20 16:39:29 -07:00
Vance IngallsandClaude Sonnet 5 5dc93a25ad 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>
2026-08-20 16:39:28 -07:00
Vance IngallsandClaude Sonnet 5 dd3212d11b feat(studio): group rows in the timeline, and a split disclosure
A group renders as its own row with member rows beneath it, and disclosure
splits into two independent controls: caret shows/hides a group's member
rows (structural), `∿` shows/hides any row's automation-lane rows. Plain
tracks lose their caret (nothing to disclose structurally) and keep only
`∿`. `expandedClipIds` keeps its existing keyframe-lane-state job;
`expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets.

Groups get a real position in the row/geometry pipeline rather than a
visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member
tracks contiguously under a synthetic fractional anchor key
(firstMember - 0.5, the same fractional-key convention sub-composition
expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat
a group row as a first-class row without widening their key type away from
number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group /
member-under-group / lane), lanes always `owner.level + 1`.

All of it — grouped row emission, the header, the new expansion state — is
gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves
empty and every new code path no-ops. `TimelineElement.audioGroup` (+
`audioGroupLabel`, resolved once per document via `resolveAudioGroups` from
B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already
flow DOM → manifest → TimelineElement — inert without the canary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:28 -07:00
Vance IngallsandClaude Sonnet 5 e42185582b feat(core): the audio group model — element, membership, helpers
Introduces <hf-audio-group> and data-audio-group as the group model B2–B7
and C1 build on: a non-rendering group element carries a label and (later)
an FX chain, membership lives on the member's own data-audio-group
attribute rather than DOM nesting, so a track removed from the document
simply drops out of the group on the next resolve — nothing dangles.
Groups do not nest: data-audio-group on the group element itself is
ignored. A group with members but no <hf-audio-group> element still
resolves, label falling back to the id, so hand-authored HTML degrades
gracefully. Audio only in v1 — video members are ignored.

Parse-only: nothing routes or sums audio yet (B3/B4). Adds the
audio-groups canary at percentage: 0 gating the future Studio UI; the
element and attribute parse and play regardless of enrollment.

Verified rather than assumed per this plan's standing rule: the timeline's
clip-collection selector ([data-start], [data-track-index],
[data-composition-id], video, audio, img) already excludes the group
element with zero changes, and no lint rule flags unknown elements or
data-* attributes, so neither needed touching — confirmed by grep and by
running `hyperframes lint` against a fixture containing the element (0
findings referencing it). The step doc's suggested display:none injection
point (an existing base stylesheet in the runtime) does not exist in this
codebase; skipped rather than inventing new infrastructure, since an empty,
childless custom element already renders as a zero-size inline box with no
visible output — the same reasoning the lint check above confirms
empirically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:27 -07:00
Vance IngallsandClaude Sonnet 5 ee6486c7f2 feat(core,studio): the character presets pitch shift unlocks
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.

Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).

Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:25 -07:00
Vance IngallsandClaude Sonnet 5 fa05d3a7c6 feat(core): pitch shift — a granular shifter as the fifth FX worklet
Adds hf-pitchshift alongside the four existing dynamics worklets: a dual-tap
granular delay line, 100 ms grain, taps 180° apart so one is always
crossfading in as the other resets — hides the splice each tap makes on
wrap. Read-tap speed relative to the write head tracks the semitone ratio,
so pitch shifts without changing duration.

Registered through the same workletBuilder/dispose-message path the other
four use (so shapeOf never rebuilds on a param tweak, and a chain drop
retires it), wired into the registry with a plain-language copy entry and a
~0.2s chain tail (two grains). One implementation, shared by preview (Web
Audio in the page) and render (the same worklet run inside an
OfflineAudioContext in the headless browser) — confirmed by a browser-render
test that measures the actual output frequency, not just that it differs
from input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:25 -07:00
Vance IngallsandClaude Sonnet 5 adfdb69a78 fix(core,studio): silence hidden audio in preview, and call it mute
Preview scheduled every audio[data-start] regardless of data-hidden, so a
hidden audio track was silent in the export but audible in preview — render
was already correct, this was a preview-only parity bug. Web Audio scheduling
now skips (and re-syncs on toggle) any audio clip under a data-hidden
ancestor; the HTMLMedia per-tick volume path folds the same check into
effectiveVolume without touching el.muted (transport-owned). Ships unflagged
since it's a bugfix restoring parity.

Also relabels the eye as Mute/Muted on audio-only track rows (icon,
strikethrough label, undo-history copy), gated behind the new
audio-track-mute canary — the relabel is a copy/UX change, kept separate from
the behavior fix above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:24 -07:00
Vance IngallsandClaude Sonnet 5 e966311627 feat(studio): make presets the primary path into the FX rack
Presets button becomes the stacked primary control (bold, filled outline);
Add-effect demoted to a small trailing link ("+ effect"). Button onClick
bodies and audition-revert logic are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:23 -07:00
Miguel Ángel 7a8f8a0b45 chore: release v0.8.5 (#3375) 2026-08-20 19:03:09 -04:00
James Russo b4d5abd7b2 fix(studio): capture storyboard tiles at review density (#3371)
* fix(studio): capture storyboard tiles at source resolution

* fix(studio): bound storyboard tile captures
2026-08-20 15:46:12 -07:00
Miguel Ángel 2be5a03b80 fix(lint): stop erroring on the documented canonical clip block (#3374)
Linting the primitive-clip example from packages/core/docs/core.md produced
two errors against the docs' own linter:

    error  timed_element_missing_clip_class  el-3   <img data-start ...>
    error  self_closing_media_tag            el-4   <audio ... />

Both are now fixed, in opposite directions — one was the rule's fault, one was
the docs'.

`timed_element_missing_clip_class` claimed the element "will be visible for the
entire composition instead of only during its scheduled time range". That is
not what happens. `syncTimedElementVisibility` walks
`querySelectorAll("[data-start]")` and toggles `style.visibility` off the
ATTRIBUTE, with no reference to the class; the runtime's own init test pins it
with a bare `<div data-start data-duration>` carrying no `class="clip"`. Every
other consumer of the string "clip" — Studio's label derivation, the runtime's
timeline labels, core's selector helper — treats it as a name to skip, never as
a behaviour key. So the class is an authoring convention the tooling reads, not
the mechanism that hides the element.

The rule is therefore a warning rather than an error, and its message now says
what is actually true. `img` joins `audio` and `video` in skipTags: the three
media primitives sit on adjacent lines of the same documented clip block, all
three authored without `class="clip"`, and flagging only the `<img>` is what
made the documented pattern fail.

`self_closing_media_tag` was right and the docs were wrong: `/` is ignored on a
non-void element, so `<audio ... />` leaves the element open and everything
after it nests inside. Changed to `<audio ...></audio>`. The `<img ... />` on
the line above is a genuine void element and stays as it is.

The same false mechanism claim had been copied into the talking-head-recut
skill, in both the annotated example and the rules list, where agents read it
as fact. Corrected there too.

No effect on the 643 shipped registry files (this rule fires on none of them);
the change is to the documented pattern and to agent-authored compositions.
Regression test lints the canonical block verbatim and asserts it produces no
errors or warnings, so docs and linter cannot drift apart again silently.
2026-08-20 18:39:12 -04:00
Miguel Ángel f822200fb8 feat(telemetry): measure which lint rules fire, cost, and fail to converge (#3367)
* feat(telemetry): measure which lint rules fire, cost, and fail to converge

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
2026-08-20 18:10:24 -04:00
Miguel Ángel 9140c0eaa1 fix(core): keep authored gain above unity off el.volume in the sandbox bridge (#3349)
Authoring a clip above unity gain throws at runtime today.

## What breaks

`MAX_AUDIO_GAIN_DB = 12` makes `data-volume` legal up to ~3.98. The sandbox runtime's volume bridge assigns the product straight to the element:

```ts
el.volume = clipVolume * volume;   // init.ts, onSetVolume
```

`HTMLMediaElement.volume` is spec-pinned to [0,1] and **throws `IndexSizeError`** outside it — verified in Chrome, and the test DOM agrees:

```
el.volume = 2  →  IndexSizeError: Failed to set the 'volume' property...
```

The throw lands inside a `for` loop over every media element, so it takes the rest of the loop with it: every clip after the boosted one keeps whatever volume it already had, while `state.bridgeVolume` says the change was applied. A composition with one boosted clip stops responding to the volume control for every clip authored after it.

## The fix

Clamp what the element receives. That is not lossy, because the element was never where the boost lived — the transport gets the authored gain unclamped, and this PR pins that half too:

- `syncRuntimeMedia` hands `onElementVolume` both the element's clamped volume **and** the authored gain, so the transport can have the boost the element cannot hold.
- `setElementVolume` keeps that gain on the per-element node, clamped only to `MAX_AUDIO_GAIN`.

Those two paths already worked; they were untested, and they are the reason clamping the element is the right half to clamp.

## Tests

- `init.test.ts` — a boosted clip followed by a quieter one, both seeded with sentinels, then the real `set-volume` control message. Asserts the boosted element lands at 1 **and** that the clip after it still gets its own volume, which is what a throw mid-loop strands.
- `media.test.ts` — the transport receives the authored gain while the element stays legal.
- `webAudioTransport.test.ts` — the per-element gain node keeps a boost above unity.

All three mutation-checked: removing the clamp reds the first, and clamping the gain at either transport seam reds the others.

## Provenance

This is the last unlanded piece of #3280. That PR was rebased onto current `main` and collapsed from +3050 to +944, of which everything except these lines is either already merged (#3308, #3309, #3333, #3339) or duplicated by the open #3306 and #3310. Cutting it out separately because the throw is live on `main` now and shouldn't wait behind a PR that is otherwise redundant.
2026-08-20 12:42:31 -04:00
Val d09145faab fix(producer): seek once per step when discovering video visibility (#3233)
Seek the GSAP timeline once per timestep and sample every auto-start
video, instead of re-walking per media element. Keeps probe cost
proportional to duration, not video count.
2026-08-19 23:49:01 -07:00
Miguel Ángelandanikam13 f0e637375f fix(studio): capture the storyboard frame hero at full resolution (#3338)
The thumbnail route bounds every preview capture to 240x135. That bound came
from the timeline, where thumbnails are small and numerous and their decoded
bytes are budgeted. The storyboard reuses the same route for its frame detail
hero, which is up to 900px wide, so the poster arrived at 240x135 and upscaled
past 7x on a retina display. Headlines survived it; body copy, table labels and
captions did not.

That is the surface where it costs the most. references/review-loop.md sends the
user here to confirm layout and real copy, and tells them to run no CLI in that
pass: "the poster is the only picture this pass needs".

Give the caller a way to ask for the composition's own dimensions, which the
route already supports as `output=source`, and fold the choice into a single
`surface` prop. Whether a poster is a tile or the hero decides both the crop and
the capture density, so one prop owns both rather than two that can disagree.

The contact sheet keeps the bounded capture: many tiles, and it is a contact
sheet. The timeline is untouched.

Reported with a reproduction and a correct read of the consequences in #3271.

Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
2026-08-19 17:33:03 -07:00
Miguel Ángel 42b94fd5db chore: release v0.8.4 (#3359) 2026-08-19 19:28:13 -04:00
Miguel Ángel d464f60b96 fix(cli): zip the publish archive to the same bytes every time (#3358)
adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP
timestamp resolves to two seconds — so archiving identical content twice gave
different bytes whenever the two runs landed either side of a boundary. The
archive's digest was a function of the clock rather than of its contents, which
is backwards for something `cloud render` uploads and addresses by content.

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

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

The new test moves the clock across a boundary, which is what reproduces it;
back-to-back builds land in the same bucket almost always, which is exactly how
it hid.
2026-08-19 18:25:04 -04:00
Miguel Ángel 228eabd43f fix(studio): make the volume fader tell the truth about the gain it writes (#3305)
* fix(studio): make the volume fader tell the truth about the gain it writes

The fader travels in dB, so its stops are irrational values; serializing them
through the generic two-decimal numeric formatter collapsed the bottom quarter
of its travel onto "0" — a hard mute — and made the knob jump on release
everywhere below unity. Both panels now use the exact serializer, which
round-trips every integer stop back to itself.

Raise the volume automation lane to the same ceiling the fader reaches.
Clamping the lane at unity meant automating a boosted clip silently discarded
the boost, and the panel disables the fader while a lane owns the level, so
there was no way back. This rescales the lane's vertical axis: unity now sits
a quarter of the way up rather than at the top.

Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute —
they replace the authored gain rather than scaling it — so a clip carrying both
plays at whatever the tween names, and the fader gives no sign of it. The rule
reuses the tween detector the sibling lane/tween rule already has.

* fix(lint): treat a missing data-volume as unity, not as silence

readAttr returns null when the attribute is absent, and Number(null) is 0 —
finite, and not 1 — so a clip carrying NO data-volume cleared both filters and
was reported as authored at silence. Both halves of that were false: absent
means unity everywhere else in the runtime.

It fired on exactly the case the rule exists to bless. The docs this PR edits
say data-volume is the baseline for elements no tween touches, so a tweened
clip is expected not to carry one — the common audio fade. A warning does not
fail check, but an agent reading the fixHint would have written a gain to
correct a level that was never wrong.
2026-08-19 18:08:23 -04:00
Miguel Ángel b3c43e2480 feat(cli): add normalize-audio to match one clip's loudness to another (#3306)
* feat(cli): add normalize-audio to match one clip's loudness to another

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

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

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

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

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

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

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

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

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

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

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

Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
2026-08-19 17:02:44 -04:00
Miguel Ángel 634df5a5af fix(producer): give inlined media a document-unique render id (#3342)
* fix(producer): give inlined media a document-unique render id

Element ids are unique per composition file, but the render document is
the inlined union of every file. The producer merged the per-file media
lists and deduplicated by id, so clips that shared an id collapsed into a
single entry, and every id-keyed stage (extract, inject, visibility,
bounds) resolved to whichever element came first in the document. The
surviving clip's frames landed on the wrong element and the visible scene
rendered without footage.

Two shapes hit this, and neither is author error:

  - Two scenes that each declare `<video id="clip">`. Legal per file, and
    unavoidable when a scene is duplicated into a copy with inner ids
    kept, or when one file is mounted twice.
  - Two scenes that each declare a bare `<video>`. The timing compiler
    numbers auto-ids per file, so both arrive as `hf-video-0` with no
    authored id involved at all.

Stamp a document-unique `data-hf-render-id` while inlining, and read the
media list off the inlined document instead of merging per-file lists.
The render id equals the element id whenever that id is already unique,
so documents without a collision keep identical pipeline keys.

Author `id` attributes are left alone: 158 of the 161 registry blocks
reference their own ids from `#id` CSS or getElementById, so renaming
would trade broken footage for broken styling. The engine resolves media
elements through the render id instead, falling back to getElementById
for documents the producer never compiled.

Collecting from the inlined document also retires the per-file media
extraction in parseSubCompositions along with its offset bookkeeping;
host offsets are recovered from the composition hosts the clip sits in.

* fix(core): resolve render-frame siblings by render id in the runtime

The injector creates each `__render_frame_<id>__` sibling from the media
element's render id, but four runtime readers still built that id from the
plain `el.id`. On a document where two compositions share a media id, all
of them resolved the first collider's frame.

colorGrading is the one that changes pixels: findRenderFrameImage returns
the image the grading pass samples, with no class check to catch the
mismatch, so the second video was graded from the first one's frame.
media, mediaProxy and video-texture-compat use it as a render-mode or
substitute-source signal, where both colliders happen to agree during
render, but none of them should rest on that.

Add renderFrameSibling as the single owner of "which frame belongs to
this element" and route all four through it. It reads the stamped render
id and falls back to the author id, so a collision-free document resolves
exactly as before and an uncompiled one (preview, snapshot, check) is
unchanged.

The engine's in-page bridge keeps its own copy of the rule because code
serialized into page.evaluate cannot import; it now names core as the
definition, and a test pins the sibling-id format both sides build so
they cannot drift apart silently.

* refactor(engine): build render-frame sibling ids from core's definition

The drift guard named both sides but pinned one. renderFrameSibling.test
asserts core's format, while the engine rebuilt the same id from a literal
template at six independent sites. Changing the format on either side left
the test green and every runtime reader silently unable to find its frame —
this PR's own failure mode, one level up.

Export the affixes and renderFrameIdForRenderId from core, and take the id
from there at all six. Four sites resolve it on the Node side, where the
engine can import; the two that iterate the DOM in-page receive the affixes
as evaluate arguments, which avoids depending on bridge install order.

Also switch two `__hfMediaId?.(el) ?? el.id` reads to `||`. The bridge
returns "" for an element with neither id, so `??` kept the empty string
and built `__render_frame___`, which no reader looks for. Inert today
because the compiler assigns positional ids to id-less timed media, but it
made the two sides disagree in the one case they could.
2026-08-19 00:24:07 -04:00
Miguel Ángel ec0b23f3ce fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first

Select all in the timeline, press Delete, and one clip disappeared while the
rest stayed — still drawn as selected.

The Delete hotkey built the selection set correctly and then called
`elements.find(...)`, which stops at the first match, and handed that single
element to a handler that deletes exactly one. The comment above it claimed the
handler "expands a clip that is part of the multi-selection into an atomic
delete of the whole selection (single undo)" — no such expansion existed
anywhere; `useTimelineEditing` never read `selectedElementIds`.

`handleTimelineElementsDelete` takes the whole selection and removes every
element before saving once, so the delete is a single history entry and a single
undo — what the comment already promised. The hotkey layer now takes only that
plural handler, since it never deletes one element in isolation; the singular
entry point stays for the context menu and clip chrome. The store drops every
deleted key and clears the marquee set, rather than leaving a selection drawn
around clips that no longer exist.

Elements whose `sourceFile` is not the composition being edited are dropped from
the pass rather than written to the wrong file.

Also removes the preview's double-click-to-reset-zoom. It was a document-level
capture listener, so any double-click anywhere over the viewport snapped the
zoom back to fit — including double-clicks meant for the content under it. The
explicit reset control beside the zoom HUD stays.

Reproduced by test: restoring `elements.find` reds the new marquee case.

* fix(studio): delete every canvas element in the selection, not just the primary

Selecting several elements on the canvas and pressing Delete removed one of
them and left the rest — still drawn as selected. The delete path only ever
took the primary selection; the marquee group it belongs to was ignored.

Expand the session-level delete through the group ref, the same way the other
group commits already do, and let the lifecycle op remove every member under a
single save so one Undo restores the whole selection.

* fix(studio): let the canvas selection own Delete instead of its timeline mirror

Marquee-selecting elements on the canvas and pressing Delete removed a
fraction of them. The hotkey routed to the timeline delete whenever the
timeline store held anything, and the timeline's copy of a canvas selection is
derived and lossy by construction — a member with no timeline row of its own is
dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59
stayed, still drawn as selected.

The canvas selection is what the user drew the marquee around, so it owns
Delete whenever it holds something; the timeline path stays as the fallback for
rows with no canvas node to select. Both paths already remove through the same
endpoint, so this is one addressing scheme replacing two.

That makes the canvas delete the path a Delete press normally takes, so it
picks up the same mid-recording refusal the timeline delete has.

* fix(studio): let the marquee see the whole document, not the first 80 elements

Dragging a marquee over the entire canvas selected a fraction of what it
covered, so Delete left most of the page behind. The hit test sourced its
candidates from the layers-panel collector, which stops after 80 items — a
budget for how many rows that panel is willing to render, silently reused as if
it described the document. Everything past the 80th element in document order
was unselectable no matter where the user dragged. The off-canvas indicators
were reading the same truncated list.

The cap now belongs to the panel that wants it; the collector returns
everything. To pay for that, the marquee measures its candidates once when the
drag passes the threshold instead of re-reading layout for every element on
every pointer-move: unbounded plus per-move stalled the tab outright, and the
iframe DOM does not mutate mid-drag, so one pass stays true for the gesture.

On a captured page: one marquee, one Delete, 734 elements down to 81.

* fix(studio): report a no-op delete instead of claiming the elements went

A target the file no longer holds answers `changed: false`, which is normal
for a member nested inside another member already removed. Every target
answering that is not — it means the preview is describing a document the file
does not have, so each removal misses and the file is written back untouched.

The toast still said "Deleted 503 elements. Use Undo to restore them." That is
how a delete that did nothing at all looked from the outside: press Delete, the
page stays, nothing on screen explains it. Say the preview is out of date and
reload it instead.

* fix(studio): keep the canvas hotkeys alive across preview reloads

Pressing Delete with a canvas selection did nothing at all — no removal, no
toast, nothing on screen to explain it. A keypress goes to whichever document
has focus, and clicking the canvas puts focus inside the preview iframe, so the
app's hotkeys have to be forwarded there.

They were, but only from the iframe element's ref callback, which fires when
the element mounts. A preview reload keeps the same element, so the callback
never runs again, and keeps the same WindowProxy, so the forwarder's identity
check saw no change and skipped re-attaching — while the inner window holding
the listeners had been replaced. After the first reload the canvas had no app
hotkeys left. Undo and redo kept working because their forwarder re-attaches on
every load, which is why this read as "only Delete is broken".

Fold the app handler into that per-load forwarder so both attach in the same
place, on every load, and drop the mount-only one. Window only: the history
pair also listens on the document, and capture listeners on both would run the
app handler twice per press.

* perf(studio): stop re-probing every restored selection member on load

The hash carries the whole canvas selection, and restoring it asked the
server whether each member still exists in the source — one request per member,
awaited one after another. A marquee over a captured page puts hundreds of
members in the URL, so every later load of that URL spent hundreds of serial
round trips rebuilding the selection before the canvas answered anything,
keypresses included.

The marquee that produced those members already skips the probe. Restoring them
skips it too; only the primary, whose panel reads the flag, still pays for one.

* fix(studio): delete a canvas selection in one pass and say the key landed

Reproduced with a real, focus-routed keypress instead of a synthetic one: the
press does reach the handler and the delete does run to completion, but at
hundreds of members it takes seconds during which the canvas is unchanged and
nothing acknowledges the key. Silence for that long is indistinguishable from
Delete being broken, and pressing it again or reloading mid-flight lands in a
worse state.

Two things, one per cause. The removal now sends the whole selection in a
single request against a new remove-elements route, which reads the file once,
drops every member and writes once — it was a round trip AND a full rewrite of
the file per element. And a multi-element delete announces itself before the
work starts, so the press is visibly acknowledged instead of leaving the canvas
looking untouched until it finishes.

Measured on a captured page, 84 members: 933ms of serial round trips against
84 rewrites, down to 583ms and one.

* refactor(studio): narrow the SDK delete targets instead of asserting them

The batch SDK path guarded on every member having an hfId and then asserted
it away per member. Narrow once into a string list so the guard and the values
come from the same place, and drop a threaded content variable that never
changed — the SDK owns the document it edits, so every member is removed
against the same starting content.

Also mounts the new forwarding test through the existing harness rather than
repeating its setup.

* fix(studio): stop Delete acting on a canvas selection the user replaced

Two things the reordered Delete arbitration got wrong, both found in review.

A clip with no canvas node left the canvas selection pointing at whatever was
picked before it, and the canvas branch wins whenever that ref is non-null — so
selecting an audio clip and pressing Delete removed the previously selected
canvas element and left the clip, right after the toast said the clip was not
in the preview. The timeline fallback the comment described could not be
reached. Clearing that selection has to stay quiet: the clear is announced to
the timeline, so echoing it would deselect the clip that was just picked.

Expanding the primary to the marquee group also moved out of the delete handler
and up to the Delete key. Cut copies the primary alone, so expanding for every
caller put one element on the clipboard and removed every other member with it
— undo brought them back, paste restored one. The rule is a named function now,
so the two callers can differ without either guessing.

Also throttles the off-canvas indicator rebuild, which the cap had been hiding.
It walks every element in the preview and reads layout for each — measured at
6.5ms on an 825-element captured page against a 16.7ms frame — and what marks
it dirty is a MutationObserver on inline style, which is how animation writes.

* fix(studio): hold the canvas selection inside the timeline selection

The stale-canvas-selection defect survived at the second writer. The
store-driven sync bails when a member has not resolved yet and returned without
touching the canvas, so a pick with no canvas node at all left the previous
selection in place — and Delete acts on the canvas first, so it deleted that.
Reachable from the sidebar audio and asset reveals and from an asset drop, none
of which go through the handler already fixed.

Clearing on every bail would be wrong: the bail exists for a member whose node
is not ready, which a later run resolves, and clearing there would flicker.
Only a canvas anchor that resolves OUTSIDE the current selection goes, which is
the state that is dangerous rather than merely unfinished. Quietly, for the same
reason as the first writer: announcing would deselect the clip just picked.

The invariant is named now, since Delete depends on it: the canvas selection
never points outside the current timeline selection.

Also drops the x-hf-removed header, which nothing read and whose comment
promised a partial-vs-no-op distinction the response cannot make, and pins the
indicator throttle that was measured but uncovered.
2026-08-19 00:22:26 -04:00
Miguel Ángel 0e3c5f6bef feat(cli): give every preview lifecycle op one JSON document (#3309)
`--status`, `--stop`, `--list` and `--kill-all` emit a schema-versioned
envelope with an `ok` discriminant under `--json`, from one writer and one
failure-payload builder. Human output is unchanged; the JSON path is additive.

The value is in the failure paths. An agent that gets a bare error line on
stderr and an empty stdout cannot tell a crash from a "not running", so every
failure is a document too — including a missing project, which under `--json`
resolves through the throwing resolver rather than the human-shaped nudge.
2026-08-18 20:07:50 -04:00
Miguel Ángel e282ff15cc fix(audio): raise the authoring gain ceiling and carry it through the probes (#3333)
* fix(audio): raise the authoring gain ceiling and carry it through the probes

Builds on #3328, which made the preview graph apply author gain and user volume
exactly once each. That ownership is now correct but everything is still clamped
to 1.0, so a clip authored above unity cannot be heard or rendered.

`HTMLMediaElement.volume` is spec-clamped to [0,1], so both timeline probes lost
a clip's authored gain the moment it also carried a fade: the probe seeded the
element at the clamped value and every sample read back at or below 0 dB, and
the mixer prefers probed keyframes over the static volume. Both probes now
shadow the accessor for their own duration and forward the clamped value to the
native setter, so the authored gain survives while nothing outside the probe
ever sees an illegal volume.

Measured on one 6 s composition, first 4 s: unity -32.8 LUFS, boosted-with-fade
-32.8 before and -27.0 after — +5.8 dB, exactly the gain the clip was authored
at.

One ceiling, defined once in `audioGain.ts` and reachable from both sides: the
render mixer imports it, and the page-serialized probe takes it as a parameter
rather than re-literalling it. User volume stays spec-clamped — it is a fader,
not a gain.

Also holds the percent volume slider above unity in both property panels. That
control tops out at 100%, so one touch would cap a boosted clip and drop up to
12 dB that now genuinely renders; the dB fader that can represent these levels
replaces it in the next PR.

* fix(audio): carry a static above-unity gain onto the preview gain node

Review follow-up.

`setElementVolume` receives the clip's author gain and clamped it to [0,1],
so a static `data-volume` above unity was capped on the WebAudio preview path
while the render honoured it — the exact preview/render divergence this
ceiling exists to close. Automation lanes hid it: they schedule ramps onto the
param directly and never pass through here. The master volume beside it stays
spec-clamped, because a user fader is not a gain.

Verified by mutation: restoring the [0,1] clamp reds the new case.

Also scope the leveller's rationale to this rung — `VOLUME_RANGE` still stops
at unity until the dB fader lands, so "both now span the same range" was
premature — and say why the GSAP-tracking fallback is unity-capped: it reads
back through `el.volume`, which the spec pins to [0,1], so it cannot observe an
above-unity value however wide the clamp gets.

* fix(audio): restore the live test files this branch overwrote, and uncap preview

Review blocker: three files were wholesale copies from the abandoned #3304
branch laid over a two-day-newer base, so they silently reverted work that had
landed in between. CI could not see it — deleted tests do not fail.

- `audioMixer.test.ts` was byte-identical to #3304's head: 1186 lines against a
  base of 1353. Gone with it were the `data-playback-start` fallthrough cases
  from #3322 — merged 54 minutes before this branch's own merge base — and all
  retiming coverage (`playbackRate` 7 to 0, `atempo` 5 to 0), the strict
  literal-timing table, and the zero-window cases.
- `mediaVolumeEnvelope.test.ts` dropped the trailing-garbage duration case and
  the plateau-retention case.
- `packages/core/package.json` rolled the package version back 0.8.3 to 0.7.109.

All three are restored from `main` with only this PR's additions re-applied on
top, and the subpath export is regenerated by the repo's own script rather than
hand-edited.

Also closes the preview/render split the same review raised. Two clamps had to
go, not one: `setElementVolume` capped the author gain at the transport, and
the first-tick branch in `syncRuntimeMedia` trusted `el.volume` — which is
spec-bound to [0,1] and so cannot represent a boost, opening a boosted clip at
0 dB for one tick before the steady-state branch took over. Both pinned by
tests, both verified by mutation.
2026-08-18 20:07:42 -04:00
Miguel Ángel 74149e249a fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)
* fix(cli): keep a live preview's ownership record and stop past a bad one

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

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

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

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

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

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

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

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

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

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

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

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

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

The strict shape assertion moves to a Linux-only case, where /proc is read
directly with no subprocess and null is genuinely not allowed — keeping the
guarantee on the one platform that can honour it rather than dropping it
everywhere. Callers already depend on this contract: `wrapperProcessIsAlive`
treats null as "no answer" rather than "gone" precisely because it is reachable.
2026-08-18 19:50:02 -04:00
Val b31dde35b1 fix(producer): clamp embedded video/audio windows to the scene (#3332)
Browser probe end reflects full source duration, not the scene slot.
Never extend a compile-time end — only fill missing or shrink — so long
recordings sliced across short scenes don't inflate extraction windows
and time out black on Cloud Run (ARC-13403).
2026-08-18 17:45:12 -04:00
Miguel Ángel c1c70f44bd fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port

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

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

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

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

Review follow-up.

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

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

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

Also state that `killProcessTree` honours `signal` on POSIX only: Windows
always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that
a console process may ignore. The caller-side comment claiming Windows cleanup
is a no-op described the code before this change and now says the opposite.
2026-08-18 17:41:46 -04:00
Miguel Ángel 3e4b08cdc1 chore: release v0.8.3 (#3327) 2026-08-18 11:11:46 -04:00
Miguel Ángel 995c9e346e fix(core): separate author and user audio gain (#3328) 2026-08-18 10:59:12 -04:00