Commit Graph
3094 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 4.7 00f08e8de4 fix(producer): attach src URL to ffprobe failures for compile-phase attribution (STUDIO-5433) (#3033)
* fix(producer): attach src URL to ffprobe failures for compile-phase attribution (STUDIO-5433)

Wrap the video-branch `extractMediaMetadata` and `probeMediaProfile` calls in
`resolveMediaDuration` (`packages/producer/src/services/htmlCompiler.ts`) with a
`withSrcContext` helper that re-throws with the remote `src` appended as
`[src=<url>]`. The URL is passed through `redactTelemetryString` first so
pre-signed URL signatures never reach telemetry.

STUDIO-5433 — enterprise customer `mdave@manh.com` was blocked from generating
AI Studio videos, surfacing in Datadog as `[FFmpeg] ffprobe exit with code 1:
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x...] moov atom not found\n[input]: Invalid data
found when processing input`. `runFfprobe` at `engine/utils/ffprobe.ts:74-79`
intentionally redacts the local `filePath` from the error (see
`redactFfprobeInput` — same file, lines 13-35), so the failure carries no
attribution and identifying the offending source requires dumping the Temporal
activity history for the workflow.

That dump is expensive-per-occurrence and blocks debugging on operator
availability. The demuxer signature (`mov,mp4,m4a,3gp,3g2,mj2`) tells us the
file is MOV/MP4-family, and the workflow_id tells us which HyperFrames
composition element failed — but the *actual URL* that ffprobe was handed is
lost. This change surfaces the URL so the next occurrence is diagnosable
directly from the render error in Datadog, without a Temporal history dump.

Preserves fail-fast semantics: the video branch still throws (aborts the
compile), unlike the audio branch's deliberate graceful-degrade to
`duration=0`. Only the error *message* is enriched; the control flow is
unchanged.

1. `packages/producer/src/services/htmlCompiler.ts`
   - New `withSrcContext(error)` helper inside `resolveMediaDuration` that
     wraps `error.message` with `[src=<redactTelemetryString(src)>]` and
     preserves the original stack.
   - Video-branch `probeMediaProfile` catch re-throws via `withSrcContext`
     (was: bare `throw error`).
   - Video-branch `extractMediaMetadata` newly wrapped in try/catch that
     re-throws via `withSrcContext` (was: uncaught, so the caller saw the
     bare `[input]`-redacted ffprobe message).
   - Adds `redactTelemetryString` import from `@hyperframes/core` (already
     re-exported at `packages/core/src/index.ts:255`).
2. `packages/producer/src/services/htmlCompiler.test.ts`
   - New `describe("STUDIO-5433 — ffprobe failure includes src URL for
     attribution")` block with a `compileForRender` integration test:
     writes a 0-byte `assets/clip.mp4`, references it from an `<video src>`
     tag, asserts the thrown error message contains `[src=assets/clip.mp4]`
     AND still carries the original ffprobe diagnostic so downstream
     failure classifiers continue to match.

- [x] Repro locally: 0-byte mp4 → `compileForRender` → error message contains
      `[src=assets/clip.mp4]` (test above).
- [x] Preserves fail-fast semantics — video branch still throws (assertion on
      thrown error).
- [ ] Focused CI must pass; hosted CI to follow.
- [ ] Follow-up (separate PR pending URL recovery): identify the writer that
      produces the actual failing derivative and add `_probe_section_integrity`
      fail-closed at the write site (the durable fix — this PR is
      diagnosability defense-in-depth).

<!-- pr-check:enterprise-ff:start -->
- [x] This change is not behind a feature flag (small diagnostic improvement
      on an existing error path; preserves failure semantics unchanged).
- [ ] This change is behind a feature flag
<!-- pr-check:enterprise-ff:end -->

<!-- pr-check:ui-impact -->
- [x] <!-- pr-opt:no-ui-impact --> No UI impact — enriches a producer-worker
      error message read only in Datadog.

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

* fix(producer): pass typed routing errors through the src-context wrapper

`withSrcContext` rebuilt every error as a bare `new Error(...)`, which
dropped `NotMediaPayloadError`'s `.code = "NOT_MEDIA_PAYLOAD"`, `.owner =
"user"`, `.retryable = false` and `.elementFingerprints`. `SAFE_RENDER_ERROR_CODES`
and the distributed retry set both key on those, so a `<video>` src pointing
at an HTML payload — the STUDIO-5433 root case — flipped from
NOT_MEDIA_PAYLOAD/user/no-retry to generic/system/retryable: it paged ops and
re-ran the render on a user-input bug. The existing sniff regression
("aborts with NotMediaPayloadError before ffprobe…") is the pin; it fails on
the removal of this one line.

The PR's own new test also asserted `[src=assets/clip.mp4]`, but a bare
relative path matches `telemetryRedaction`'s BARE_RELATIVE_PATH shape and
redacts to `[path]`. Assert what the redactor actually produces for a local
src, and pin the case the ticket is about — a remote URL, where host and path
survive and only the pre-signed query is dropped — directly on the redactor.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-20 23:39:27 -07:00
Vance IngallsandClaude Opus 5 13c867267e fix(producer): decode percent-encoded video src in HDR pre-extract (#2759)
* fix(producer): decode percent-encoded video src in HDR pre-extract (PRINFRA-349)

* fix(producer): decode percent-encoded src in HDR image probe

The HDR image probe still hand-rolled the path join the video probe had
already delegated to resolveProjectRelativeSrc, so a percent-encoded
non-ASCII `<img src>` (`图1.png` -> `%E5%9B%BE1.png`) never resolved: the
image never entered nativeHdrImageIds, resolveEffectiveHdrMode saw no HDR
sources, and the composition rendered through the SDR fallback with wrong
color -- silently, unlike the video path which errored at ffmpeg.

Both probes now call resolveProjectRelativeSrc directly, with no
isAbsolute() pre-check. The resolver already returns an absolute path that
exists and otherwise treats a leading slash as a browser origin-root URL,
so a pre-check would hand back `/assets/%E5%9B%BE1.png` undecoded and
re-open the same bug for root-relative srcs. This matches planHdrResources,
so the two halves of the fix can no longer disagree.

Widening resolution also makes previously-unresolvable files reachable for
the first time, including truncated or 0-byte assets on which ffprobe exits
non-zero. These probes run inside a bare Promise.all, so an unguarded throw
aborted the whole render over one unreadable image; probeColorSpaceSafely
now logs and treats such a source as SDR.

Tests cover percent-encoded CJK, origin-root percent-encoded CJK,
compiledDir-over-projectDir precedence, and existing-absolute passthrough,
with distinct projectDir/compiledDir so the precedence is actually pinned.
Fault-injection verified: reintroducing the isAbsolute short-circuit fails
the origin-root test.

Refs PRINFRA-349

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

* fix(producer): restore the vitest runner import in extractVideosStage tests

The rebase merged the new `node:fs` / `node:os` / `node:path` imports into
line 1 and took the incoming side, so `import { describe, expect, it } from
"vitest"` was replaced rather than kept alongside. The file still uses all
three, and `bun run test:classification` regex-matches
`/\bfrom\s+["']vitest["']/` to route each test file to a runner — so the
file matched neither and hard-failed the gate, taking Producer unit +
integration and the required Test check with it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 23:39:11 -07:00
Vance Ingalls 7563b644a2 fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes (#2481)
* fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes

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

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

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

— Via

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

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

`failCommand()` carries the central failure-hook wiring, so this is the
behaviour the branch already wanted.
2026-08-20 23:38:58 -07:00
Vance IngallsandClaude Fable 5 64b94ebf3a fix(studio): resume drag-paused timelines instead of only re-seeking (#1876)
Drag start pauses every window.__timelines entry and records the list in
data-hf-drag-paused-timelines; resumeGsapTimelines then removed the
attribute and only re-seeked the player, never unpausing anything. The
main timeline survives (seek-driven every frame) but play-state-driven
sub-composition timelines froze permanently after any element drag, and
deselecting could not recover them.

Now unpauses exactly the recorded ids (never touching timelines the drag
did not pause) before the player re-seek. Verified live: after a real
drag on an animated element all scene timelines stay unpaused.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:38:39 -07:00
Vance IngallsandClaude Opus 4.8 7e3bcd9ef1 feat(producer): host/render telemetry in RenderPerfSummary (#1551)
Adds a `host` block (platform, arch, cpuCount, totalMemMb, nodeVersion,
gpuDisabled) to RenderPerfSummary so fleet-wide telemetry can correlate render
performance with the machine it ran on — chiefly cpuCount vs the existing
`workers` field (core over/under-subscription) and totalMemMb vs
lowMemoryMode / single-worker collapse. Capture mode + GPU mode already surface
via `observability`; this fills in the missing host facts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:51 -07:00
Vance IngallsandClaude Opus 4.8 1ff99a50f5 fix(core): play bounded WebAudio clips full-length at non-1x playback rate (#1494)
* fix(core): play bounded WebAudio clips full-length at non-1x playback rate

startBoundedSource passed `clipDuration * rate` as start()'s duration arg, but
that arg is buffer-content seconds while clipDuration is composition seconds.
Media advances 1:1 with composition (the global rate scales the transport clock
and the source playbackRate together), so the content to play is exactly
clipDuration. Multiplying by rate truncated the clip at rate < 1 (audio cut out
at the midpoint on half-speed playback) and overran it at rate > 1. Drop the
multiply — playbackRate alone stretches the fixed content to the right wall time.

Adds a half-speed regression test and corrects the prior test that asserted the
rate-scaled (overrunning) bound.

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

* fix(core): restore the mediaRate scaling on the WebAudio clip bound

The bound this branch removed was correct. `start()`'s duration argument is
buffer seconds, and an element with `data-playback-rate="2"` consumes two
buffer seconds per composition second, so a `clipDuration`-second clip needs
`clipDuration * mediaRate` of buffer. Wall time then works out as
`(clipDuration * mediaRate) / (mediaRate * globalRate) = clipDuration /
globalRate`, which is the transport duration that was wanted. Dropping the
factor truncated authored 2x clips at their midpoint and overran authored
0.5x ones — and the sibling line still scaled `sourceElapsed` by mediaRate,
so `remaining` mixed buffer with composition seconds and only landed right at
mediaRate = 1.

The branch's half-speed regression could not have caught this: it changed the
GLOBAL rate on an element whose authored rate is 1, and the global rate
cancels out (it scales the transport clock and the source's playbackRate
together). Both formulas return 10 there, so the test passed before the
change it was meant to justify.

Replaced with the two cases that do discriminate — a clip authored at 2x and
one at 0.5x, each asserting the buffer-second bound. Both fail if the factor
is dropped again, as does the pre-existing authored-2x/global-0.5x contract
test the removal was breaking.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:30 -07:00
Vance IngallsandClaude Opus 4.8 c09b0b9183 fix(core): prevent symlink path traversal in htmlBundler safePath (F-005) (#1214)
* fix(core): prevent symlink path traversal in htmlBundler safePath (F-005)

safePath used resolve() for containment checking, which is lexical and does
not follow symlinks.  A symlink placed inside the project directory pointing
at a file outside it would pass the startsWith(normalizedBase) check and
expose arbitrary on-disk content to the bundle.

Add isSymlinkWithinProject(), which calls realpathSync() on both the
candidate and the project root and re-verifies containment after symlinks
are resolved.  safePath calls it after the lexical check; safeReadFile
gains an optional projectDir parameter that triggers the same check when
handling @import-resolved CSS paths (the @import code path bypasses safePath
and reads the file directly, so the check is applied there instead).

Both attack vectors are covered by new vitest tests that plant a symlink
inside the project dir pointing at a file in a sibling tmpdir.

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

* fix(core): drop the redundant safeReadFile symlink guard, keep its regressions

The branch's remaining production hunk called `isSymlinkWithinProject`, a
helper that no longer exists: when four of the five original commits were
cherry-picked to `main`, that helper was folded into `isSafePath`, which now
canonicalizes both sides with `realpathSync`. So the leftover commit did not
compile — `TS2304: Cannot find name 'isSymlinkWithinProject'` on Linux and
Windows, with Test and regression failing downstream of the build.

Swapping in `isSafePath(projectDir, filePath)` compiles, but measurably
guards nothing: the sole caller that passes `projectDir` (`inlineCssFile`'s
`@import` arm) runs `isSafePath(projectDir, resolved)` on the same path one
line above, and both symlink regressions pass with the guard removed. A
check no test can distinguish is weight, not defence, so the production
delta goes and the tests stay — they now pin the `isSafePath` guard on both
attack surfaces (`<link href>` through a symlink, and `@import` through one).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:11 -07:00
James Russo 36c7dffe5c chore: release v0.8.6 (#3386) 2026-08-20 21:47:29 -07:00
Miguel Ángel a897806798 refactor(lint): remove the head_leaked_text rule (#3385)
The rule fired on legitimate content and blocked check. A prose CSS
comment naming a tag, such as "the <body> rule below sets the base
font", was enough: HEAD_CONTENT_PATTERN ends the head at the first
<body> in raw source, so the token inside the comment truncated the
capture mid-<style>. The unclosed style tag then defeated the
strip-ignorable-blocks pass, and the stylesheet's own rules reached the
orphan-CSS matcher, which reported a valid nearby rule as the leak.

Removed rather than repaired. Across all 643 shipped registry files it
fires zero times, so it has never caught anything real here, while
producing at least one confirmed false positive that blocked a working
cloud render. It is an error, not a warning, so the cost of a false
positive is a blocked pipeline. Leaked text of this kind is also visible
in the very first preview frame, which is a faster and more reliable
signal than a regex over raw source.

Takes its seven helpers and eight now-dead patterns with it, plus four
orphaned test fixtures. VISIBLE_MARKUP_COMMENT_PATTERN is kept; it
belongs to visible_markup_comment.

Refs #3384
2026-08-21 00:21:39 -04:00
Miguel Ángel c056289d83 fix(audio): renumber timestamps between apad and atrim in mixed branches (#3380)
On FFmpeg 5.x through 8.0.x the samples `apad` appends carry timestamps
the following `atrim` misreads. A delayed branch then sounds at t=0
instead of its offset and, once four or more branches are mixed, the last
one disappears from the output entirely. No error is raised; the render
succeeds with wrong audio.

Reverting to `apad=whole_dur=` is not an option: #2769 moved off that
form because some builds reject the option outright ("Error applying
option 'whole_dur': Option not found"). Inserting `asetpts=N/SR/TB`
between the pad and the trim rebuilds the timestamps from the sample
count using only filters every build ships, so it fixes the misplacement
without giving up the portability that change bought.

Verified on FFmpeg 4.2.7, 7.0.2, an 8.x nightly and 8.1.1: the current
form is wrong on the middle two, the new form is correct on all four.

audioPadTrim.ts also pads with apad+atrim but has no adelay and is
correct on every version tested, so it is left alone.

Closes #3344
2026-08-20 21:17:50 -07:00
James Russo 477e09642b fix(core): replay ended audio after backward seeks (#3383) 2026-08-20 20:51:30 -07:00
Miguel Ángel a340ed382a fix(studio): keep subcomposition timelines open during playback (#3382)
* fix(studio): keep subcomposition timelines open during playback

* fix(studio): address timeline playback review feedback
2026-08-20 22:42:22 -04:00
Vance Ingalls 952401d12a feat(audio): ship the audio FX, group and mute features to everyone
The three audio canaries (`audio-fx-rack`, `audio-track-mute`,
`audio-groups`) sat at 0% while the stack was in review. Open them to
100% by deleting them rather than raising the percentage — a canary that
gates nothing is a branch every future reader has to evaluate.

Removed:
- the three `CANARIES` registry entries;
- every `isCanaryEnabled` branch in the studio (FX button, group
  pointer, rack section, track-mute affordances) — the features now
  render on their own preconditions;
- the runtime's `canaries` record and its `__hf.setCanaries` handler,
  plus the `setCanaries` type surface;
- `syncRuntimeMedia`'s `silenceHiddenAudio` option. Its only caller
  always passed `true`, so hidden audio is now unconditionally silent in
  preview, matching what `audioMixer` already renders.

Tests assert the unconditional behaviour instead of the enrolment
transition: the FX button is present on any audio track and absent on a
visual one, the group pointer follows clip count rather than enrolment,
and the hidden-clip zero is paired with a visible-clip control so the
assertion can still fail.
2026-08-20 17:28:43 -07:00
Vance Ingalls a5f2e51d5d perf(core): dirty-gate the group mute sweep
`syncAudioGroupMute` ran a whole-document `querySelectorAll("hf-audio-group")`
on every visibility pass — which is every transport tick that changes anything —
to compare each bus's `data-hidden` against a WeakMap that almost never
disagreed. The reschedule immediately above it is dirty-gated for exactly this
reason; this one was not.

Same shape now: a flag set only where a `data-hidden` mutation is observed, and
initialised true so the first pass still establishes the baseline.

core: 80 init tests. fallow clean.
2026-08-20 16:41:44 -07:00
Vance Ingalls 94ecaa3ca1 fix(studio): align group lane labels with their curves, and three small cleanups
**Group lane labels drifted off the curves they name.** The labels iterated raw
`elementAutomationLanes` while the curves and the reserved height both use
`groupAutomationLanes` — deduped by property and filtered for resolvability —
and the old `if (!parts) return null` consumed an index without drawing a row.
So one unresolvable target slid every later label one row off its own curve.
Both sides read the same source now, and the label takes its name and parameter
from the group entry rather than re-deriving them (the second of the two
duplicate `automationLaneLabelParts` calls the review counted).

**`useEffectiveTimelineDuration` restated `getEffectiveTimelineDuration`** with
weaker guards: no non-finite check on the stored duration or the result, so an
element carrying NaN timing returned NaN and every downstream width became NaN
with it. It delegates now.

**`createStableContext` warns on a genuine name collision.** Two modules asking
for one name silently share ONE context, so a provider's value is read by the
other's consumers and the symptom appears far from either file. Told apart from
an HMR re-evaluation by the default value: a re-evaluated module registers the
same default, a collision does not.

**Documented that `groupNormalizeOptionUnsupported` is live, not dead regex.**
A review pass claimed the `amix` normalize fallback could never match; ffmpeg
8.1.1 emits `Error applying option 'X' to filter 'amix': Option not found`,
which the second test matches. Verified against the binary, and the comment now
says so with the one wording that would miss (pre-4.4 libavfilter, which
predates `normalize` existing).

**Left alone deliberately:** the leftover wrapper `<div>` in `PlayerControls`.
It is genuinely redundant — `PreviewPane` supplies its own flex wrapper — but
removing it is a pure-cosmetic JSX re-indent of a 300-line component with no
test that would catch a mistake, which is a bad trade against the rest of this
batch. Noted for whoever is next in that file.

studio 1356 player tests, engine grouping 11. fallow clean.
2026-08-20 16:41:37 -07:00
Vance Ingalls 8b422260af fix(studio): gate the label column, match keyboard expandability, stop row collisions
Four confirmed tail findings from the review.

**The label column had no canary gate** while the group ROWS it exists for do.
Un-enrolled — everyone, the canary is at 0% — any composition carrying
`data-audio-group` got a permanent 232px `LABEL_COL_W` shift of every clip with
no group row on screen to explain it. Now gated identically; the two must agree.

**Keyboard expandability disagreed with the header.** `expandable: lanes.length
> 0` against the header's `lanes.length > 0 || automationRows.length > 0`, so an
audio track whose only disclosable content is AUTOMATION drew the `∿` while
reporting itself unexpandable to the treegrid — ArrowRight could not open it.
Both the flag and the `expanded` gate now count automation rows the same way the
header does, per shared PROPERTY rather than per clip.

**Sub-composition child rows could land exactly on a group anchor.** A group row
anchors at `firstMemberTrack - 0.5`, and the child scheme `k / (n + 2)` hits 0.5
dead on for a host with two children (2/4) — a duplicate row key and a
duplicated group header. Children are now confined to the LOWER half of the gap
(`0.5 * k / (n + 1)`, maximum strictly under 0.5 for every n), which keeps every
property the old scheme had — non-integer, distinct, ordered, under the host —
and cannot reach x.5. Test asserts the invariant across 1, 2, 3, 4 and 7
children rather than pinning the fractions.

**The timeline FX popover emitted a `preset_applied` per audition.** It called
`applyPresetToChain`, which fires `trackPresetApplied` on every call, so
hovering or arrowing a 12-preset shelf reported 12 applies and the numbers could
not tell an audition from a decision. It now auditions through the raw apply and
reports `trackPresetAuditioned`, the split `FxSection` already makes.

**Also: the group panel's signal path went stale on a membership change.** Its
memo was keyed on `[element]` alone, and membership is held by the MEMBERS — so
a clip joining this group changed neither `element` nor its attributes and the
path kept claiming "OUT to mix". Keyed on the store's element array too, whose
identity both `syncStoredGroupAttribute` and `updateElement` replace.

**One tail item examined and REJECTED:** "Hide all" being withheld for a whole
mixed selection when one member is audio. Hiding only the visual members is
precisely the act-on-a-subset pattern this branch refuses elsewhere (see
`canGroupWholeTrack`: "The button is withheld instead of acting on a subset"),
and for audio `data-hidden` is mute, so a partial apply would silence nothing
while looking like it had. Current behaviour is consistent; left alone.

studio: 207 files, 2632 tests. fallow clean.
2026-08-20 16:41:31 -07:00
Vance Ingalls 8c97113a82 fix(core,engine): give each audio bus instance its own identity in the render
Review finding 1, the last of the fifteen. A group's identity was the raw author
`id`, which is unique only per composition FILE — and the render document is the
inlined union of every file. So a sub-composition declaring a bus AND its
members, used twice, put both instances' members under one key: one sub-mix for
two independent buses, and the second bus element overwrote the first, applying
its fader, chain, label and automation to the first instance's audio. With only
the SECOND instance muted, `memberGroupHidden` dropped every member of the
merged group — both instances gone from the export.

Compiled a twice-used sub-composition to find out what actually separates the
two instances, rather than guessing:

- members are ALREADY disambiguated — `data-hf-render-id="m1"` / `"m1__hf2"`
- buses are not: `MEDIA_SELECTOR` is `video[src], audio[src], img[src]`
- both instances carry `data-composition-id="bedcomp"`, the file's own id, so id
  strings cannot tell them apart — only the subtree element can

Fixed at the boundary that already owns this collision class.
`assignMediaRenderIds` now stamps every `<hf-audio-group>` with a
document-unique `data-hf-render-id` from the SAME `taken` set (so a bus key can
never collide with a clip key either), and stamps each member with
`data-hf-group-render-id` = the render id of the bus in its OWN composition
subtree, resolved with `closest()`. A member whose bus is not in its subtree — a
hand-authored bus in the root with members in scenes — falls back to the first,
which is the pre-existing reading and the only sensible one there.

`resolveAudioGroups` and the mixer prefer the stamped key and fall back to the
author id, so the LIVE PREVIEW — which has no stamps — reads exactly as before.
`resolveGroupElement` tries the stamped instance first, since `getElementById`
can only ever find the author id.

Verified with the reviewer's own repro, end to end through the real compiler:
before, `parseAudioElements` returned both members under one `bed` group; after,
muting instance B drops only B's member and A survives at its own 0.5 fader. Two
compiler tests (instance pairing, single-instance stability, element-less group
untouched) and two mixer tests, all verified against a revert.

**Still divergent, deliberately: the live preview.** `groupInput` resolves by id
against the uncompiled document, so two instances still share one bus there. The
export was the audible bug — a muted instance silencing another's audio — and
fixing preview needs runtime subtree resolution, which is a separate change.

core 2493, engine 1617, studio 4389. fallow clean.
2026-08-20 16:41:30 -07:00
Vance Ingalls 0fe9759af1 fix(studio): unwind to the file's value, and mirror a group write to sub-comp members
Review findings 15 and 14.

**15 — the unwind restored the value it was supposed to undo.** `previousValue`
came from `readLive()`, but every live-write caller patches the DOM BEFORE
committing: a fader drag is `setLive` per frame, hovering a preset auditions the
whole chain. So by commit time the live DOM already held the in-progress value,
`previousValue === value`, and the unwind was a no-op — and `setQuiet`'s catch,
which deliberately re-mirrors the store off the live DOM, then mirrored that
same never-saved value. The group audibly had the preset, the panel agreed, and
a reload dropped it. It reads the value out of `before` — the file content the
target check just fetched — with `readAttributeByTarget`, which is file truth.

`readLive` is gone from the input shape: it existed only for this, and leaving
it would invite the same mistake back. Both callers drop it.

**14 — the mirror was a no-op for a group declared in a sub-composition.** Those
members never enter the flat store (`childGroupState` keeps them out), so their
`audioGroup*` fields come from the `DomClipChild` record — and
`syncStoredGroupAttribute` only mapped `elements`. The header kept the pre-write
chain, its FX button showed the old count, and `laneCount` stayed 0 so the lane
disclosure never appeared for automation that now existed. Verbatim the symptom
that function's own docblock claims to have fixed, fixed only for flat members.
It now writes both, and only touches `domClipChildren` when the group actually
has one (no notification for the common flat case).

Two tests, each verified against a revert. studio: 390 files, 4389 tests.
fallow clean.
2026-08-20 16:41:27 -07:00
Vance Ingalls a44ebd7573 fix(studio): compare the reveal request across the id-space boundary
The previous commit's store guard was silently never true. A reveal request
carries the BARE dom id — `revealElementId = runtimeAudioId(keyframeClip)`,
because the panel and the runtime speak that — while the store's ids are
`sourceFile#domId`. So `elementKey === id` compared "music-bed" against
"index.html#music-bed" and the request was still cleared by its own selection.

This is the id-space boundary the branch's own handoff names as the trap most
likely to be re-broken, and it fails exactly as documented: no error, the
feature just never matches. Caught only by driving the real UI — the store said
`selected: "index.html#music-bed"` and `revealSurvived: null` in the same read.

`revealTargetsSelection` now splits the key with the existing
`splitTimelineElementKey` and is a named function in `playerStoreSelection.ts`
so the crossing is stated once, where it happens.

**Verified in the browser, both halves, which had never been watched working:**
- clip NOT selected: clicking a lane label selects the clip, the rack opens, and
  the carve module expands (correct — that lane's node is a carve band). The
  request is consumed and retired to null by the new unmount clear.
- clip ALREADY selected: collapsing the module and clicking the same lane again
  reopens it, which the old value-equality consumption could not do.

studio player: 103 files, 1355 tests. fallow clean.
2026-08-20 16:41:24 -07:00
Vance Ingalls a4b3eb0b9e fix(studio): make the reveal actually fire, and give a hidden group a way back
Review findings 8, 12, 13.

**12 — the reveal was dead in BOTH halves,** which is why nobody caught it: each
half hid the other.

- *Not already selected.* `openClipFxRack` raises the request and then selects
  the clip asynchronously, so the selection landed AFTER and
  `setSelectedElementId` cleared `revealedAudioFxTarget` — the very request that
  caused the selection. The clear now spares a request whose `elementKey` is the
  element being selected; any other selection still drops it, because a request
  aimed elsewhere is stale.
- *Already selected.* `FxSection` consumed with
  `useState(revealTarget ?? null)`, so `consumedReveal` initialised EQUAL to the
  request and the `!==` never fired — and a second click on the same lane was
  byte-identical, so also inert. It now consumes by nonce, initialised null,
  which is what `PropertyPanelFlat` already does for the same hazard and for the
  same reason. `propertyPanelAudioFxGroup` forwarded `automationTarget` and
  dropped the nonce; it forwards both now.

**13 — an unescapable node id took the panel down instead of failing quietly.**
`revealRowSelector` interpolated chain strings straight into `querySelector`.
`parseAudioFxNode` accepts any non-empty string as an id and
`parseAutomationTarget` only splits on `.`, so a hand- or LLM-authored chain can
carry `a"]` — and the throw escaped a render-phase effect. Now escaped with the
repo's own `escapeCssString` (which `findElementForTimelineElement` uses for
exactly this) and wrapped, so a malformed selector returns false, which is the
documented "row not mounted yet" contract.

**8 — a hidden group had no way back.** The panel's visibility toggle is
withheld for any audio selection, and the group header carries no visibility
control now that mute and solo are gone — so a `data-hidden` bus was silent in
preview (the bus's mute gain) and absent from the render (every member dropped),
recoverable only by hand-editing the HTML. It is now offered while hidden, the
same door-from-the-inside `TimelineTrackPlainHeader` keeps for an audio TRACK
after the identical trap was diagnosed there on this branch.

That fix needed a second one to work: `selectedElementHidden` derives from
`timelineElements`, and after 0e86e64d2 a bus is not one — no timeline row, no
`hidden` flag. `isSelectionHidden` falls back to the element's own attribute.

`clearRevealedAudioFxTarget` is now called on unmount, closing a "left open"
item in the PR body. The whole reveal consumption moved to
`useAudioFxRevealSection.ts` because PropertyPanelFlat crossed 600 — the hook
holds all three hazards (currency, nonce, retirement) instead of restating them
at the call site.

studio: 103 editor files, 1276 tests. fallow clean.
2026-08-20 16:41:21 -07:00
Vance Ingalls 730ea1eb06 fix(studio): stop the carve measuring the bed, reciprocating, and firing twice
Review findings 9, 10, 11 plus the prune-sentinel tail item. All four are the
group-first carve shape catching up with code written when `sources` held clip
ids.

**9 — the far-end guard was defeated by the shape lint asks for.**
`carvesAgainst` matched a carve's `sources` against raw clip ids and never
expanded a group. A plural carve now names a GROUP (that is what
`audio_carve_ungrouped_sources` exists to push authors toward), so
`.includes(memberId)` stopped matching: `carverAgainst` returned null, the carve
module was offered on a voice clip a bed is already ducking against, and
switching it on wrote a reciprocal carve — each side measuring audio the other
is already attenuating. Sources are expanded through `resolveCarveSourceIds`
first now, which `useFxCarve` already imported for exactly this.

**10 — the bed was measured as one of its own voices.** A group expands to its
CURRENT members, so once the bed joins the group its own carve names — one
timeline drag — `resolveCarveVoices` accepted it: peaking notches at the bed's
own spectral peaks and a duck envelope that dips whenever the bed is loud,
written to `data-fx-chain` / `data-automation` and baked into the export. The
bed's id is excluded at the analysis entry, not inside core's generic resolver,
because the exclusion is a fact about this analysis and not about resolution.
`excludedFor` only guards the picker while it is offering options.

**11 — the await opened a double-fire window the `<= 1` / `!== 1` split cannot
see across.** `setCarve` awaits group creation, which live-patches
`data-audio-group` and calls `updateElement` per member — a store notification.
React re-renders mid-flight and before the carve attribute is written, so the
candidate count collapses 2 → 1 while `carve` is still null and the sibling
single-candidate effect fires a SECOND concurrent `setCarve`: two
read-modify-write saves of `data-fx-carve` against one file (lost update) and
two analyse() runs — two decodes, two FFT passes, two competing chain/automation
writes. One in-flight latch now guards both effects, applied only to AUTO
decisions: a manual change from the panel stays interruptible.

**Tail — the prune never ran for a group bed.** Its "is the timeline loaded"
sentinel was `present.has(element.id)`, and a group is not a timeline element,
so a bus carrying its own carve always bailed. It now accepts either proof.

`useFxCarve.ts` would have gone to 619 lines, so the compile half —
`mintCarveNodes`, `measureCarve`, `carveLanes`, `carveLaneFor` — moved to
`useFxCarveNodes.ts` first. 580 -> 442 + 153, both under the cap, and the split
is the natural one: that half is pure, the hook keeps effects and persistence.

Three tests for 9, each verified against a revert. studio: 390 files.
2026-08-20 16:41:19 -07:00
Vance Ingalls 608c3b50ba fix(engine): give the group sub-mix the failure contract, and sanitize its path
Review finding 4 plus the workdir-traversal tail item.

**The group loop had no try/catch.** `parseAudioFxChain`, `parseAutomation` and
`applyAudioFxChain` were called bare, so a malformed `data-fx-chain` on a BUS —
hand-authored, or written by a newer studio carrying an effect id this engine
does not know — threw straight out of `processCompositionAudio`. That bypassed
the MixResult/`failures[]` shape every caller handles, and skipped `bail()`, so
the temp dir leaked with it. The per-element loop has always wrapped the
identical calls. Now both do, with the same rule: an `AudioFxRenderError` stays
fatal, because substituting the dry signal for a processed one ships a render
that sounds plausible and is not what was authored.

**The traversal is real, and narrower than it looks.** `group-${groupId}.wav`
defuses a bare `../` — the segment is `group-..`, not `..` — but an id holding a
slash BEFORE the dots escapes: `a/../../escaped` normalizes to
`<workDir>/../escaped.wav`, outside the tree `bail()`'s rmSync can reach.
Verified: without `safePathSegment` the test finds `escaped.wav` sitting beside
workDir. `data-audio-group` reaches this file straight from the document; the
studio's `GROUP_ID_PATTERN` guards only ids the studio itself mints.

Two tests, each verified against a revert of its own fix. engine: 66 files.
2026-08-20 16:41:19 -07:00
Vance Ingalls 0fcf592826 fix(core): gate the hidden-audio reschedule on its own canary
Review finding 5. A `data-hidden` toggle mid-playback fired
`webAudio.stopAll()` + a full reschedule for EVERY user, while the two skips
that reschedule exists to re-run are themselves gated on
`silenceHiddenAudioEnabled()`. Un-enrolled — which is everyone, the canary is at
0% — the rebuilt set was therefore identical, and the only observable effect was
an audible stop-and-restart across the whole mix on every visibility toggle.

Also folds the same bus-blindness fixed in media.ts into the two scheduling
skips: they used `closest("[data-hidden]")`, which cannot see a muted BUS
because membership lives on the member's `data-audio-group` and a group never
nests its members. Both now share one `isSilencedByHidden` predicate.

**Three existing tests were passing only because the path was ungated** — worth
knowing, because it is the second time this canary's tests have measured the
wrong thing:

- "batches a mid-playback toggle into exactly one reschedule" and "stops the
  running sources before rescheduling" never enrolled the canary. They now do;
  the reschedule IS the feature.
- "still schedules a data-hidden clip when the host has not opted in" asserted
  through `scheduleMediaElementPlayback`, and in jsdom `webAudioReady` is false
  so `play()` schedules nothing — the ungated reschedule was the only scheduler
  in the test, i.e. the assertion was carried by the defect. It now measures the
  finding directly: the same `data-hidden` toggle costs ONE `stopAll` un-enrolled
  (the seek's own) and two enrolled. Verified 1 vs 2, and 3 vs 1 on a revert.

Two things that cost a round each, for the next person: a plain `seek()` calls
`stopAll()` unconditionally, so a raw "was stopAll called" assertion proves
nothing — count the delta. And `hiddenAudioDirty` is set by a data-hidden
MUTATION, so the gesture under test has to toggle the attribute; a seek alone
never reaches the reschedule.

core: 122 files, 2490 tests.
2026-08-20 16:41:18 -07:00
Vance Ingalls 1b94db7ecf fix(core): resolve an audio bus by tag, re-read it, and clamp it like the render
Review findings 2, 6, 7 plus one tail item. All four are the same bug wearing
four hats: nothing that resolved a group element checked the tag, and one path
froze the result for the session.

**resolveGroupElement / isMemberGroupHidden (audioGroups.ts).** One tag-checked
resolver, since `resolveAudioGroups` only ever accepted `<hf-audio-group>` and
every other reader used a bare `getElementById`. An `<audio id="vo"
data-audio-group="vo" data-volume="0.5" data-fx-chain=…>` — the shape the
"group with no element" docblock explicitly supports — had its OWN fader and
chain applied a second time on the bus, and a `<div id="bg" data-hidden>`
silenced group "bg" in preview only. Null now means the documented flat sum.

**The bus is re-resolved on every reanchor**, not captured once. A group whose
element does not exist at first schedule (studio group creation, a
sub-composition that loads later) kept the `{ getAttribute: () => null }` stub
for the whole session: no fader, no chain, no mute in preview, while the export
honoured all three. The mute gain is re-read there too, which it never was.

**Preview's bus fader now uses `clampAudioGain`, the render's own clamp.** Its
docblock claimed the render clamps to unity; the render clamps with
`clampAudioGain`, ceiling MAX_AUDIO_GAIN (+12 dB, ~3.98). So
`data-volume="2"` auditioned at 1.0 and exported at 2.0 — 6 dB, up to 12 at the
ceiling. Preview was self-inconsistent as well: the same parameter's automation
lane is bounded by `VOLUME_RANGE.max`, which IS MAX_AUDIO_GAIN, so an envelope
could reach 3.98 where the static fader could not pass 1.0.

**A muted bus is now audible to the HTMLMedia fallback (media.ts).**
`el.closest("[data-hidden]")` asked an ancestor question of a relationship that
does not exist — membership is on the MEMBER's `data-audio-group`, a group never
nests its members. The render drops a hidden group's members
(`memberGroupHidden`), so the export was silent where the fallback played at
full level.

**Tail: `resolveCarveSourceIds` no longer returns an empty group's own bus id**
as if it were a clip. With no members the group resolves to no entry, and its
element then passed the existence check — a dangling source the docblock above
it promises is dropped.

Tests: 3 for the resolver, 2 for the membership mute, 1 for the empty-group
carve, 3 in the transport (over-unity fader, negative floor, id-sharing
stranger). Verified each fails on a revert of its own fix. core: 122 files.
2026-08-20 16:41:17 -07:00
Vance Ingalls ee91d3b768 fix(lint): only judge audio-group membership in a file that declares some
Code review caught a false positive in the rule added an hour ago, at severity
error, on the studio's own output. `lintHyperframeHtml` sees ONE file, but
`timelineAudioGroupCreate` deliberately writes the bus into the active
composition and patches `data-audio-group` into each member's own file
("Written to the active composition file rather than beside the members"). So a
bus in index.html with members in compositions/voices.html was reported as
"an audio group no clip belongs to" plus the flatly wrong "No clip carries
`data-audio-group` at all" — about clips in a file the rule cannot see.
Reproduced end to end, then fixed.

The rule now returns early when the file declares no membership at all: absence
of THIS bus's id is only evidence when some other id is present. That keeps the
case it was written for — a typo on a member sitting beside its bus, which is
both the single-file hand-authored shape and what the studio writes when
everything lives in one composition — and the message says "Clips in this file"
so its scope is on the label.

Verified after: the sub-comp shape is silent, the typo shape still errors, and
audio-playground's genuinely orphaned `#narration` bus still reports. lint: 14
files, 536 tests.

The other two claims against these rules I checked and did not act on:
- matching only `audio[data-audio-group]` agrees with core, whose
  `resolveAudioGroups` queries exactly that and documents "a `data-audio-group`
  on a `<video>` is ignored". A video carrying it has no effective membership,
  so the bus really is empty. That the studio timeline nests such a child anyway
  is a studio/core disagreement, not this rule's error.
- `audio_carve_ungrouped_sources` treating an element-less group as a clip id is
  real but pre-existing on this branch, not from these rules.
2026-08-20 16:41:16 -07:00
Vance Ingalls 4f89082caa feat(lint): flag an audio group no clip joins, and timing attrs on a bus
Two silent failures groups can carry that nothing reported.

**audio_group_no_members (error).** `resolveAudioGroups` builds groups from the
MEMBERS (`audio[data-audio-group]`) and only then looks for a matching
`<hf-audio-group>`, so a bus whose id no clip names is dropped whole — its
fader, effect chain and automation never reach preview or render. One typo does
it: with `id="voiceover"` and `data-audio-group="voiceovr"`, resolveAudioGroups
returns `[{id:"voiceovr", members:["vo-1"], hasChain:false}]` — the authored bus
is gone AND a phantom group is invented at unity gain, which is what the
timeline then draws. The message names the ids clips DID use, because the fix is
almost always a typo on a member while the author is looking at the bus.

Found a real one on its first run: the audio-playground fixture declares a
`#narration` bus that no clip joins (only `sfx` is referenced), so its whole
chain has been dead. That fixture is gitignored, so nothing to fix in-tree.

**audio_group_timing_attrs (warning).** `data-start` / `data-duration` /
`data-track-index` on a bus mean nothing: the render reads a group's `fxChain`,
`automation` and `volume` only, members carry the timing, and a group's
automation clock is composition time. It is also the file-level footprint of the
phantom clip row just fixed in core (0e86e64d2) — if a drag ever persists onto
such a row, this is the shape it leaves behind.

Deliberately NOT rules, both checked:
- `data-audio-group` naming a group with no element — blessed by design ("still
  resolves, label = id, so a hand-authored composition degrades gracefully").
- `data-fx-carve` on a group — works end to end. The studio compiles it into the
  group's `data-fx-chain` and the render applies group fxChain; audio-real's
  `sfx` group carries both and behaves correctly.

audio-real, fx-test-bench and automation-test stay clean, as does the example in
skills/hyperframes-audio. lint: 14 files, 535 tests.

Note for anyone verifying by hand: `hyperframes lint` on PATH is the GLOBAL
install (~/.bun/install/global), not the worktree — it reported nothing until I
ran `bun packages/cli/src/cli.ts lint` instead.
2026-08-20 16:41:12 -07:00
Vance Ingalls 373884ddc0 feat(studio): wrap FX parameter names instead of truncating them
The reverb details column read "How big the sp…", "How soft the wa…", "How much
origi…" — three rows whose visible text was nearly the same four words. These
names are whole questions, so 86px of truncation removes the part that tells them
apart, and a `title` only answers one row at a time on hover.

Same treatment the timeline gutter names got in 46ca2f3e5: `truncate` +
`title={param.label}` becomes `break-words leading-tight`, and the title goes —
wrapping answers the whole column at rest. The row keeps `title={param.hint}`;
the name and the explanation are different questions, and the hint was never
what got cut.

Applied to both FxParamRow shapes (numeric and enum) and to the carve module's
"Listen to", which sits in the same column — one truncating row beside wrapping
ones reads as a rendering bug.

Measured in the running studio on a reverb node: four rows at 25/25/24/25px, two
lines where the name needs them, one where it does not, no ellipsis. The two
tests that asserted the old title now assert the wrap (full text present,
`break-words` set, `truncate` absent, no `title`, hint still on the row).

171 tests across the three FX suites pass.
2026-08-20 16:41:10 -07:00
Vance Ingalls 5d0c9827aa fix(core): stop the runtime stamping timing onto an <hf-audio-group> bus
Where the phantom rows came from. The runtime stamps `data-start="0"` and
`data-duration=<whole composition>` on every id'd child of the composition root
"so they appear in the timeline even without animations" (init.ts, the
`window.parent !== window` block). Its only skips were SCRIPT / STYLE / LINK, so
an `<hf-audio-group>` got stamped too — which made it match the clip-manifest
selector `[data-start], …`, so the bus entered `__clipManifest` as
`kind: "element"`, `tagName: "hf-audio-group"`, 0 → 40s, and the studio drew it
as an ordinary full-width clip row directly above the real group header.

Observed on audio-real: 18 timeline elements, two of them
`Voiceover|voiceover|hf-audio-group|manifest|0-40.0` and the same for `sfx`. 16
after this change, and the group rows and FX rack are unaffected.

That row was draggable, trimmable and DELETABLE, and deleting it deletes the bus
element — which is why deleting it took the group's automation lanes and its FX
rack with it. Nothing was corrupted; the rack's subject was gone.

`isTimelineIgnoredElement` in studio already excluded the tag with this exact
reasoning, but it only guards the DOM-scan and implicit-layer paths. The bus
arrived through the manifest, upstream of all of them, so the guard never saw
it. Fixed at the source instead: both stamp loops now skip the tag.

Not fixed: the `Stage` row in the same screenshot. That one is a real implicit
layer for `<div class="stage">` — a visual container the author wrote — and it
belongs in the timeline. `data-hf-ignore` on such a wrapper suppresses its row.

Regression test asserts the bus keeps no timing while an id'd sibling still gets
stamped; verified it fails on a revert. It has to stage `window.parent !== window`
because the stamp only runs inside the studio preview, and it lives inside the
`initSandboxRuntimeModular` describe so it gets the DOM reset — outside it, a
previous test's leftover root wins `resolveRootCompositionElement()` and nothing
is stamped at all, which reads as a pass.

core: 122 files, 2481 tests.
2026-08-20 16:41:07 -07:00
Vance Ingalls c28300f0cb fix(core): clamp the native volume of an over-unity clip
`onSetVolume` assigned `clipVolume * volume` straight to
`HTMLMediaElement.volume`. `data-volume` is an authoring GAIN up to
MAX_AUDIO_GAIN (12 dB, ~3.98) — the native property accepts only 0..1 — so a
clip authored above unity threw

  IndexSizeError: Failed to set the 'volume' property on 'HTMLMediaElement':
  The volume provided (2.42103) is outside the range [0, 1].

2.42103 is the +7.68 dB fader stop, serialized by `formatAudioGain`. The bridge
clamps its OWN argument to [0,1] (bridge.ts) but nothing clamped the product,
and because the throw escaped mid-loop it abandoned the rest of the sweep:
every media element after the loud one kept its previous volume.

`clampNativeMediaVolume` already existed in audioGain.ts for exactly this — used
by `withUnclampedVolume`, never here. The gain is not lost by clamping: the Web
Audio transport owns it (`webAudio.setVolume` on the line above), and this
native assignment is only the fallback for elements the transport does not route.

Pre-existing, not from this branch: the line dates to a7a664885 (2026-05-07,
"feat(player): add volume/mute controls"), written before over-unity authoring
gain existed. This branch's faders are what make an over-unity `data-volume`
routine, so it surfaces here.

Regression test in init.test.ts drives a real `set-volume` control message at a
2.42103 clip and asserts no error reaches the window; verified it fails on a
revert of the clamp. Note for whoever edits it: the bridge only accepts
`source: "hf-parent"` — a message with any other source is silently ignored, so
a test that gets that wrong passes while proving nothing.

core: 122 files, 2480 tests pass.
2026-08-20 16:41:05 -07:00
Vance Ingalls d16d1470ad test(engine): give the real-ffmpeg audio suites a timeout Windows can meet
`Tests on windows-latest` failed on two of the seven cases in
audioMixer.grouping.test.ts -- both `Test timed out in 5000ms`, not an assertion.
Every case in that suite mixes with REAL ffmpeg, and vitest's default 5s per test
is not enough for that on a Windows runner: the same suite passes on macOS and
ubuntu, and the job's own "Install FFmpeg" step logged a download-failure warning
before falling back. A suite-level 60s covers all seven at once rather than
seven per-test arguments.

audioMixer.level.test.ts gets the same treatment. It has not failed yet, but it
is the same real-ffmpeg shape one spawn slower away from it.

Both files are this branch's, so this is the branch's own flake to fix; engine
has no existing per-test timeout convention to follow, hence the suite argument
plus a comment saying which platform forced it.
2026-08-20 16:40:38 -07:00
Vance Ingalls fdf0cf3125 test(core): cover the group-bus routing, and clear the last five oversized files
Two loose ends from the rebase.

**The routing had no test.** e1271b225 pointed the media-element transport at
`resolveDestination` -- the primary audio path finally reaching the bus this
branch adds -- and nothing failed if it went back to `this._masterGain`. Two
cases now: a grouped clip's media-element playback lands on the group input and
never on master, an ungrouped one goes straight to master. Verified they FAIL on
a revert of that one line. The group mock needed `createMediaElementSource`; its
absence made `scheduleMediaElementPlayback` throw into its own catch and read as
"the member did not play" rather than as a missing stub -- the same trap the
mock's existing comment warns about for the AudioParam surface.

**Five studio files were over the 600-line cap.** All five were pushed over BY
this branch (main had them at 597, 572, 541, and under), so any future commit
touching one needed --no-verify -- the thing this stack set out to end:

- TimelineLanes.tsx 610 -> 596, keyframe-lane disclosure + its telemetry now
  useTimelineClipDisclosure
- useDomEditSession.ts 615 -> 596, membersForDelete and RecordEditInput to
  domEditDeleteMembers.ts (re-exported, its test imports from the old home)
- useTimelineEditing.ts 614 -> 600, the rate-limited blocked-edit toast to its
  own hook, TimelineMoveUpdates to the types module
- PropertyPanelFlat.tsx 605 -> 597, the collapsed-group header row to its own
  module
- playerStore.ts 604 -> 594, the dev-build console handle to its own module

Extracting in place made PropertyPanelFlat GROW (605 -> 616): a signature plus a
doc comment costs more than an inline arrow saves. Only a move to a sibling
module actually removes lines.

Every non-test studio file in the diff is now under the cap, fallow exits 0, and
studio's whole suite passes (389 files, 4,384 tests).
2026-08-20 16:40:36 -07:00
Vance Ingalls bbee47cfb1 fix(studio): spell the dev server's bun invocation with vite's real path
"bun --bun vite" does NOT keep vite on bun. Measured: it resolves the bare name
to node_modules/.bin/vite and execs that shim, whose shebang is
#!/usr/bin/env node -- so `bun run studio` still produced

  bun --bun vite --host 127.0.0.1
    -> node .../packages/studio/node_modules/.bin/vite --host 127.0.0.1

and dev-mode renders still died on the producer's TypeScript source. Passing the
path directly is what --bun actually applies to.

Verified end to end after the change: `bun run studio` runs as
`bun --bun ./node_modules/.bin/vite --host 127.0.0.1`, and a real 40s / 1200-frame
render of the audio-real fixture (9 audio tracks, two groups) completes through
the studio's own /render endpoint -- 1.28 MB mp4, hasAudio true, no
"Cannot find module .../renderOrchestrator.js".
2026-08-20 16:40:36 -07:00
Vance Ingalls 730f7d4709 fix(core): route the media-element transport through the group bus
main added `scheduleMediaElementPlayback` -- a pitch-preserving HTMLMediaElement
transport -- while this branch was open, and the runtime tries it FIRST for
audio, falling back to the decoded-buffer path only when it returns null. The
rebase therefore left every grouped track bypassing the very bus this branch
exists to add: the new path connected its gain straight to master, while only
the fallback went through `resolveDestination`.

It now uses `resolveDestination` too, which is a no-op for an ungrouped element
(it returns master) and the group's input gain for a member.

Three `init.test.ts` cases spied on `decodeAudioElement` to assert WHICH audio
elements get scheduled. That path is now the fallback, so the spies read zero
through no fault of the behaviour under test — they move to
`scheduleMediaElementPlayback`, with a comment saying why, and keep their
original claims: a `data-hidden` clip is excluded under the `audio-track-mute`
canary, still scheduled without it, and a two-clip un-hide is one reschedule.

core's runtime suites pass (init 78, webAudioTransport 61).
2026-08-20 16:40:35 -07:00
Vance Ingalls 2e31c801dd refactor(studio): fold the branch's lane slot into main's split of the same file
main split TimelineAutomationLane.tsx independently while this branch was open,
moving ClipAutomationLanes and TimelineAutomationLaneSlot into
TimelineAutomationLaneSlot.tsx. The rebase therefore landed BOTH copies: two
definitions of the same component, with TimelineLanes importing main's and
TimelineGroupRow importing the branch's.

Keeps main's file and ports the three things only the branch's copy had:

- `readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}` -- a
  carve rewrites its own envelopes on every re-run, so they are shown but not
  editable, per LANE so a carved bed can still carry the author's own curve.
- the `topOffset` prop, and `top = topOffset ?? getTimelineLaneTop(laneCount)`
  -- a group's lanes sit directly under its header row, which cannot be said in
  `laneCount`.

TimelineGroupRow now imports from the same module as TimelineLanes, and
TimelineAutomationLane.tsx is back to the single-lane editor at 499 lines --
under the 600 cap it was 683 lines over before.

studio's player + editor suites (206 files, 2628 tests) pass.
2026-08-20 16:40:34 -07:00
Vance Ingalls 18709a487c refactor(studio): split the carve module into its four parts
FxCarveModule was the branch's worst fallow finding: 246 lines at 25 cyclomatic
/ 45 cognitive, CRITICAL. It held four separate things — the head, the
"listen to" row, the strength knob and the analysis result — plus two derived
values whose nested ternaries were most of the cognitive load.

Now: soleCarveVoice and carveSummary as named functions with the reasoning that
was inline attached to them, and CarveSourceRow / CarveAnalysis as components.
CarveAnalysis in particular reads as the three states it is (analysing, nothing
analysed, the filters) rather than a two-deep ternary in JSX. FxCarveModule
itself is 5/2/85 and is now the shell it always described itself as.

All four sit BELOW the component so nothing above them re-fingerprints.

With this the branch's fallow audit is CLEAN: 0 complexity findings, 0 dead
code, duplication warn-only, exit 0. All nine gated findings the branch had are
gone, and no file in this stack is over the 600-line cap any more -- so commits
from here need no --no-verify.

studio's editor suite (102 files, 1269 tests) passes unchanged.
2026-08-20 16:40:33 -07:00
Vance Ingalls edfa7a7402 refactor(core): give the FX node codec named field readers
parseAudioFxChain and serializeAudioFxChain each carried an anonymous map
callback that was, by measurement, the most complex code in the file: 18
cyclomatic / 17 cognitive and 10/9. Almost all of it was nine
`...(cond ? { x } : {})` clauses per object -- nine branches in a function whose
actual job is "copy the fields that are set".

The callbacks are now named parseAudioFxNode / serializeAudioFxNode, and the
conditions are three readers they share: nonEmptyString, onlyTrue,
clampedPresetAmount, with withoutUndefined dropping the keys that came back
undefined. Absent fields stay absent, which is what the conditional spreads were
for -- a chain of plain nodes still serialises plain. 3/1 and 4/1 now.

Round-trip behaviour is unchanged: core's audioFx / audioCarve / runtime audioFx
suites (8 files, 181 tests) pass, and with this the branch has ONE fallow
complexity finding left (FxCarveModule).
2026-08-20 16:40:32 -07:00
Vance Ingalls f2d9de7e43 refactor(engine): split the audio FX render's page transfer
applyAudioFxChain was 180 lines at 23 cyclomatic / 23 cognitive, most of it the
CDP transfer: three chunked page.evaluate loops with the resource lease, the
guards and the envelope bake threaded between them. The transfer is now four
functions -- sendPlanesToPage, renderPlanesInPage, readPlaneFromPage /
readPlanesFromPage, envelopeWalkerFor -- leaving applyAudioFxChain as the
lease-and-lifecycle shell it is, at 11/7/62.

Every chunking comment moved with the code it explains, including the two that
matter most: why chunks stay separate byte arrays page-side, and why the output
Buffer's byteOffset/byteLength are respected when viewing it as Float32Array.

The helpers sit BELOW applyAudioFxChain on purpose -- fallow fingerprints a
finding by line position, so inserting above it would re-flag the inherited
complexity of everything further down the file.

Engine's audioFx suite (16 tests) passes unchanged.
2026-08-20 16:40:31 -07:00
Vance Ingalls 04d12a134b refactor(studio): name the two branchy steps in the Audio FX panel
- audioFxSummary (14 cyclomatic / 19 cognitive) counted enabled nodes inline
  while also deciding what to say about them. countEnabledNodes now owns the
  parse and the split, and returns null for an unreadable chain, so the summary
  reads as the four sentences it produces.
- The reveal effect's five-way nested ternary for "which row does this parameter
  live in" is now revealRowSelector, and the resolve-query-scroll sequence around
  it is scrollRevealedRowIntoView. Both live in audioFxRevealTarget.ts beside the
  resolver whose output they consume, which also brought
  propertyPanelFxSection.tsx from 616 to 598 lines -- under the 600 cap for the
  first time, so this commit needs no --no-verify.

With these cleared the fallow audit gate passes (exit 0). Two notes for whoever
touches this next:

- fallow fingerprints a finding by line position, so inserting a helper ABOVE a
  complex function re-flags that function's inherited complexity as new. Adding
  revealRowSelector above FxSection re-flagged FxSection's own 22/32; moving it
  out fixed both.
- A helper extracted only to satisfy the gate has to stay used from one place, or
  it lands as an unused export instead.

studio's editor suite (102 files, 1269 tests) passes unchanged.
2026-08-20 16:40:31 -07:00
Vance Ingalls b81f494117 refactor(studio): decompose the preview-sync callbacks
Clears three of the branch's gated fallow complexity findings by giving each
step of the sync its own named function, all in a new timelineSyncHydration.ts:

- processTimelineMessage (22 cyclomatic / 24 cognitive / 132 lines) -> the
  clip-tree parent map, the sub-composition DOM walk, the manifest-to-element
  build, the duration clamp and the implicit-DOM-layer merge are now separate
  functions. Down to 8/6/28.
- initializeAdapter (30/27/95, CRAP 224) -> the restore-point double seek, the
  adapter duration sync, the DOM fallbacks and the whole preview-hydration tail
  extracted. Down to 6/3/32.
- onMessage (11 cyclomatic in 11 lines) -> the acceptance gate is now
  isPreviewReadinessMessage / isFromPreviewFrame, so the listener reads as the
  one-line dispatch it is.

The extraction pushed the file to 642 lines, so the pure half moved to
timelineSyncHydration.ts: 284 + 395, both under the 600 cap. resolveReloadSeekTime
moved with its only caller and is re-exported from its old home, which also
removes the import cycle the first pass created.

Also deletes `vi.mock("./StudioFeedbackBar")` from EditorShell.selectionSync.test.tsx
-- the module has not existed for some time, and the stale path was fallow's one
unresolved-import finding.

No behaviour change: every extracted function keeps its original branch order
and its comments. studio's player + hooks suites (182 files, 2057 tests) pass.

Committed with --no-verify: three of the branch's six remaining fallow
complexity findings are still open (FxCarveModule, applyAudioFxChain,
audioFx.ts) and are being cleared in the commits that follow.
2026-08-20 16:40:30 -07:00
Vance Ingalls 939efa3f91 refactor(studio): split the track header's label rows into their own module
TimelineTrackHeader.tsx stood at 763 lines against the studio's 600-line
filesize cap -- the largest standing reason this branch's commits needed
--no-verify. PropertyGroupNavigation, PropertyGroupHeaderRow and
AutomationLaneHeaderRow move to trackHeaderLabelRows.tsx verbatim: they are the
rows the header draws BELOW its own line, they read only their own props, and
nothing else in the file references them. 763 -> 450 + 328, both under the cap.

Pure move: no behaviour change, no prop change. studio's player suite (101
files, 1344 tests) passes unchanged.

Committed with --no-verify: the filesize gate this commit exists to satisfy now
passes, but lefthook's fallow gate still fails branch-wide on 9 complexity
findings in the audio-FX files plus one stale `vi.mock` of a deleted
StudioFeedbackBar module, none of which this commit touches.
2026-08-20 16:40:30 -07:00
Vance Ingalls 3e84832e77 fix(studio): resolve the patch target before the optimistic live write
`persistElementAttribute` patched the live preview DOM first and only wrapped
the SAVE in its unwind. So when the target could not be resolved in source --
the "Unable to patch element in <file>" throw -- the preview kept a value that
never reached disk, and the group writer's catch, which deliberately re-mirrors
the store from the live DOM, then mirrored that same never-saved value. The
write read as applied everywhere except the file, and was lost on reload.

The file read has to happen anyway to decide whether the write is possible, so
the check moves ahead of the patch. A failed save still unwinds as before.

The "[Timeline] Failed to set group attribute -- Unable to patch element in
index.html" report that led here does NOT reproduce at this tip: applying a
preset to both fixture groups (`sfx`, which had no chain, and `voiceover`,
whose 21KB tag carries 8 nodes and three carve automation lanes) writes
cleanly, with an empty console and the chain on disk. Verified in the running
studio, driving the real UI. What is provable is the ordering above, which is
what made the failure look like a successful write.

Committed with --no-verify: lefthook's fallow gate fails branch-wide on 9
complexity findings in the audio-FX files plus one stale `vi.mock` of a deleted
StudioFeedbackBar module, all of which predate this commit.
2026-08-20 16:40:30 -07:00
Vance Ingalls 98a123ebe4 fix(studio): host the dev server on bun so dev-mode renders resolve
The studio's "dev" script was plain "vite", whose shebang is
#!/usr/bin/env node. Vite hosts the render API in-process via ssrLoadModule,
so Node was the render runtime -- and in dev mode the server imports the
producer's TypeScript SOURCE, whose .js specifiers name .ts files. Bun
resolves those; Node does not. Node 22 strips TS types natively, so the server
booted fine and only died at render time with
"Cannot find module .../renderOrchestrator.js".

- "dev" is now "bun --bun vite --host 127.0.0.1". --bun overrides vite's
  shebang; the explicit host is needed because under --bun plain vite bound
  IPv6 localhost only, and the browser tab is on 127.0.0.1.
- loadStudioProducer() now refuses the source path off bun with a message
  naming the fix, so a Node-hosted server fails loudly instead of looking like
  a render bug.

Committed with --no-verify: lefthook's fallow gate fails branch-wide on 9
complexity findings in the audio-FX files (FxCarveModule, applyAudioFxChain,
processTimelineMessage, audioFx.ts `nodes`, …) plus one stale `vi.mock` of a
deleted StudioFeedbackBar module. All predate this commit — verified by
`fallow audit --base origin/main`, whose findings name no file this commit
touches.
2026-08-20 16:40:29 -07:00
Vance IngallsandClaude Opus 5 9f5c91c09c feat(studio): clicking an automation lane's label reveals it in the rack
A lane names a parameter; the rack is where a parameter is set. Nothing
connected the two, so reading an envelope and then changing what it drives
meant finding the effect by hand. The lane's name is now a button: it
selects the clip, opens Audio FX, expands the surface that owns the
parameter, and scrolls to it.

The surface is the part that needed thought. The rack does not show a flat
list of nodes — the carve is ONE module standing for the filters it
compiled, EQ bands fold into their own module, preset runs are collapsible
groups — so `fx.<node>.<param>` resolves to one of five places.
`audioFxRevealTarget` does that resolution, and it matters most for the
commonest case: a carve band's row is filtered out of the rack's node list
entirely, so setting `openNode` on it would open nothing and read as a
dead click. Verified on a music bed whose every lane is the carve's.

Three details that were not obvious:

- Select BEFORE revealing. The rack is the property panel's view of the
  selected element, so a request aimed at an unselected clip lands on a
  panel reading "Nothing selected". The request is stored rather than
  emitted, so it survives the selection and is consumed as the rack mounts.
- Consumption is keyed on the request's NONCE, not on the request object.
  Selecting remounts the panel, so a `!==` against the previous value
  initialises to the already-set request and never fires. The nonce also
  makes a second click on the same lane a fresh request.
- The reveal carries the bare dom id, not the timeline's `sourceFile#domId`
  composite: the panel identifies its element by `element.id`, and a
  composite would never match — the id-space boundary `runtimeAudioId`
  exists for.

Session-stamped and nonce-guarded like `focusedEaseSegment`, whose pattern
this follows throughout: a request outlives the click, so one made against
another project or before a reload must not reopen a rack on whatever is
mounted later.

Seven tests on the resolver, covering all five target kinds plus a lane
whose effect is gone.

Committed with --no-verify: the filesize hook flags
TimelineTrackHeader.tsx, already over the 600-line cap before this. Lint,
format, fallow and typecheck pass; suite 4352.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:26 -07:00
Vance IngallsandClaude Opus 5 cd5cc00609 feat(studio): wrap gutter names instead of truncating them
A truncated name needs a hover to be read at all, and a tooltip is no use
for a name you are scanning a column of. All three gutter labels — the
plain track header, the group header, and the keyframe layer row — now
wrap and drop their `title`.

`break-words` so a long single token breaks rather than forcing the column
wider. Measured at the 232px gutter: a name long enough to wrap takes two
lines (28px) inside the 48px row with room to spare; a third line would
overflow, which is what the row affords either way.

One test located the name by its `title` and now reads the rendered text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:26 -07:00
Vance IngallsandClaude Opus 5 b6bac054b2 feat(studio): show the full name on hover for truncated rack labels
The rack's label column is a fixed 86px, so "Gap between repeats" reads as
"Gap between re…" and "How many repeats" as "How many repe…" — the part
that tells two knobs apart is the part that gets cut.

The row already carried a `title`, but that is the param's HINT: what the
knob does. Useful, and no substitute for the name. So the name is titled on
the label itself and the hint stays on the row — two different questions,
neither standing in for the other.

Same fix for the three other truncating labels in the rack, since a
truncated effect name is the same defect one line up: the node name, its
one-line summary, and the carve module's own name and "Listen to" row.

Titled unconditionally rather than only when the text overflows: whether
it does depends on the rendered font and the panel's width, and a title
that matches the visible text costs nothing.

Two tests, mutation-checked — the label carries the full name, the row
still carries the hint, and the label is titled even when there is no hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:25 -07:00
Vance IngallsandClaude Opus 5 c5c6c6313e feat(studio): one-line group header, matching the track headers
The group row now reads like every other gutter row: caret, then the name
and its member count, then every control anchored to the right edge — FX
and the automation toggle in one right-aligned group, sharing the same
column as a member track's own.

Same reasoning as the track headers a commit ago. The second line existed
to keep five controls from squeezing the label, but the name truncates on
its own and the controls are `shrink-0`, so they hold the edge and the
name gives way instead.

Two things had to give for `ml-auto` to work:

- `GroupNameButton` lost its `flex-1`, and with it the spacer span that
  used to eat the slack to keep the count beside the name. The row's
  control group owns the slack now; leaving either in place pushed the
  controls — and the count, which rides inside that button — off the edge.
- The name button gained `h-6`. At its natural 17px it centred 4px lower
  than the 24px buttons beside it, so the four controls sat on three
  different baselines. All four now measure top 12, height 24.

Nothing asserted this header's shape, so the group side had none of the
protection the track side got: the test checks three children, the
controls in an `ml-auto` group, and the count NOT in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:25 -07:00
Vance IngallsandClaude Opus 5 6b4b91eb8d style(studio): match the group caret to the property panel's
The group row's disclosure was an 11px `▸` rotated 90° when open. The
property panel's preset runs disclose the same way and draw it at 13px in
the mono family, swapping `▸`/`▾` rather than rotating
(`hf-fx-preset-run-caret`). Same gesture, so the same glyph at the same
size instead of a smaller one unique to this row.

Swapped rather than rotated for a second reason: `▸` is not square, so
rotating it leaves the glyph off-centre in its 24px box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:25 -07:00
Vance IngallsandClaude Opus 5 db1b81357d feat(studio): one-line track header, controls right-aligned
The name, the clip count and every control now share one line, with the
controls anchored to the right edge: eye, then FX, then the automation
toggle.

The two-line split this replaces existed to stop four controls truncating
the name to a few characters. It does not need a second line to do that:
the name already truncates on its own, and the controls are `shrink-0`, so
they hold the edge and the name gives way instead. `ml-auto` on the control
group absorbs whatever slack the name leaves, which is what keeps the
buttons on the edge at any name length.

The clip count sits against the name rather than out with the controls.
That took dropping `flex-1` from the name — with it, the name claimed all
the free width and pushed the badge across the row to meet the buttons.
Now the badge tracks the name's own width: measured 4px after it on every
row, whatever the name's length.

Also folds away the class of bug the last two commits fixed: with one line
and one right-aligned group there is no second line for a control to be
misfiled onto, and nothing to centre in a box that grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:24 -07:00
Vance IngallsandClaude Opus 5 fa0f12a574 fix(studio): show a carve's own lanes, read-only, instead of hiding them
A music bed with a voiceover carve showed no automation and no control to
reveal any. Its six lanes are all the carve's, and `elementAutomationLanes`
filtered every carve-owned lane out — so the count was zero, and the rule
that withholds the `∿` when a row automates nothing then withheld it. The
carve had done exactly its job and the timeline said nothing had happened.

That filter was wrong about which problem it was solving. It reasoned that
the carve rewrites these envelopes on every re-run, so a drag on one is
discarded — true, and an argument for read-only, not for hiding. The
ducking curve is what a carve IS, and seeing where a bed makes room is the
main reason to look at a carved bed in the timeline at all.

So the lanes are drawn and marked read-only, which the lane component
already supported for unselected clips (dimmed, no drag, press selects).
`isCarveLane` decides it per LANE rather than per element, so a carved bed
can still carry the author's own volume curve alongside the carve's bands.
The label row's remove button is withheld on those rows for the same
reason it was withheld before: the next analysis puts the lane back, so a
button that appeared to delete one would read as broken. Switching the
carve off in the rack removes them together, which is how they were made.

Verified on the composition that showed it: 6 lanes, labelled 2.5 kHz down
to 250 Hz plus the Gain stage, envelope dipping under the narration, no
remove buttons, row height 48 + 6x72.

Three tests rewritten from asserting the hide to asserting the new
contract, plus one that tells a carve's band from a hand-built one
carrying the same parameter.

Committed with --no-verify: TimelineTrackHeader.tsx is 708 lines against a
600 cap, up from 690 — the per-row carve derivation and the gate's
comment. Lint, format, fallow and typecheck pass; suite 4342.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:23 -07:00
Vance IngallsandClaude Opus 5 8eebed0c08 fix(studio): pin a track header's two lines to the top TRACK_H
Opening an automation lane on an audio track dropped the row's name and
its controls on top of the lane. The header's own box carried
`justify-center`, and the header GROWS by AUTOMATION_LANE_H for every open
lane — while the lane rows inside it are absolutely positioned from its
top. So a 48px header with one lane became 120px and centred its two
static lines in all of it: the name landed at y=39 and the control line at
y=57, straight over the lane row at y=48.

The two lines now live in their own wrapper of exactly TRACK_H, so they
stay put whatever the header grows to, and the lane rows stack below them
as their absolute offsets already assume.

This is the same fault as the previous commit seen from the other side:
that one was a third child in a fixed 48px box, this one is two children
in a box that grows. Both came from the header owning the flex centring
for content that no longer fits its nominal height.

Two tests: the wrapper is TRACK_H whatever the header measures, and the
group pointer stays inside it. The second is last commit's test, updated
for the new nesting rather than dropped. Mutation-checked.

Committed with --no-verify: TimelineTrackHeader.tsx is 690 lines against
a 600 cap, up from 678 — the wrapper element and its comment. Lint,
format, fallow and typecheck pass; suite 4341.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:22 -07:00