Commit Graph
5 Commits
Author SHA1 Message Date
Vance Ingalls 1f4bccd646 feat(studio): the three pieces of §5 copy the routing shipped without
The design doc calls one of these "the highest-leverage copy in this plan and
it should be written before the routing is". The routing shipped; the copy did
not.

**Naming a group.** Creating one was a single click on a pointer that said
"Group these clips to add effects to all of them" and auto-named the result,
so the group arrived under a minted id and the author never met the concept.
It is now §5's dialog: a name field seeded from the track, and the sentence —
"Effects you add to the group apply to both clips at once, and they share one
volume." That is a submix bus explained without the word, which is the whole
point. The typed name reaches `data-label` on the created `<hf-audio-group>`,
which needed a `groupLabel` threaded through the create path (it wrote only an
id before), and the undo entry names it too.

**The video limit, said out loud.** Groups are audio-only in v1 (§1.4), and a
video track simply had no group button — the silent limit §5 forbids, because
"silent ones just send authors hunting for something that was never built". A
video track with more than one clip now gets the button and a reason: "Video
audio can't be grouped yet — only audio clips can join a group."

**Two curves that multiply.** A clip's volume lane under a group whose volume
is also automated plays at the product — 0.42 × 0.80 = 0.34 — and nothing said
so. The clip's lane now reads "Voiceover is also fading this." in the label
column when, and only when, the group automates the same parameter. Not a
warning; an explanation, the same instinct as "Too loud" instead of a number.

Not built, deliberately: §1.7's peak meter and resettable peak-hold. The
runbook step that implements §1.7 (B7) narrows it explicitly — "no dB numbers,
no peak-hold readout" — and there is a shipped copy test asserting exactly
that. The two documents disagree; the narrower one is the one with a test, so
it stands until somebody decides otherwise.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4342 green.
2026-08-20 16:39:55 -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 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 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 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