Commit Graph
202 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 5 3cfacf440d feat(studio): the geometry and plumbing behind automation lanes (#3023)
* feat(core): audio FX registry

One declarative description of every effect that can be applied to an audio
track: fourteen across filters, dynamics, non-linear and time, each exposing
its full parameter surface rather than a curated subset.

Parameters carry the range, step, unit and scale a control needs, so a panel
can generate its UI from this rather than hard-coding a form per effect, and a
value that survives `normalizeAudioFxParams` is always safe to realise.
Everything is declared in the units a person thinks in — dB, ms, Hz.

Parsing rejects an unknown effect id rather than skipping the node. A chain
that quietly loses an effect renders something other than what was authored,
which is worse than refusing to load it.

Data only: no audio is produced here. The graph that realises each effect is
referenced by the `web` id and lands in the next change, which keeps this
module free of browser globals so the engine and the linter can import it.

* fix(core): stop declaring knobs that move nothing

Three parameters were declared with ranges, defaults and hints, and read by no
builder — dials an author could turn with no audible result.

- `chorus.decay` and `bitcrush.aa`: removed. FFmpeg's chorus feeds a decay back
  into its delay line and a bitcrusher's anti-alias needs a real filter; adding
  either is new DSP, not a fix, so the honest move is to stop advertising them.
- `lowshelf.q` / `highshelf.q`: removed. The Web Audio spec leaves Q unused for
  shelving filters, so the control moved nothing — and because the shared Q
  helper marks it automatable, an author could draw an envelope on it and hear
  nothing at all.

`phaser.decay` and `gate.knee` stay: the first drives the sweep depth, and the
second is now read by the gate's processor.

A test asserts each of these directly, since the existing exposure invariant only
checks that a flagged parameter reaches an AudioParam — a parameter the node then
ignores passes it.

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

* feat(core): Web Audio graphs for the FX registry

One graph builder per `web` id, turning the registry's declarations into
running audio.

Every node exposes `update`, so turning a dial re-parameterises the live graph
rather than rebuilding it: an AudioParam change lands on the next 128-sample
quantum, about 2.7 ms at 48 kHz. `buildFxChain` reports whether an update could
be applied in place — adding or bypassing an effect, or switching a filter
between one and two poles (which changes the node type from BiquadFilterNode to
IIRFilterNode), changes the graph's shape and returns false so the caller
rebuilds.

Four effects have no native node and run as AudioWorklet processors:
compressor, limiter, gate and bitcrush. The module is registered from a data:
URL rather than a blob:, because a blob inherits the page origin and is opaque
on a file:// page, where it fails with an unhelpful AbortError.

Reverb has no single node either. `synthesizeReverbImpulse` generates a tail
from the room parameters, seeded so the same room sounds the same on every
machine, and the ConvolverNode uses it.

Tests cover the wiring — which nodes get built, how they connect, parameter
application and clamping, in-place update versus rebuild, disposal — against a
fake AudioContext, since happy-dom has no Web Audio.

* fix(core): reverb level, phaser wiring, per-channel dynamics, one-pole rebuild

Four defects in the graph builders, all found by review rather than by ear.

**Reverb was unusable at its own defaults.** A ConvolverNode applies the
impulse's gain whole — the graph sets `normalize = false` so a room is
deterministic rather than browser-defined — but the impulse was raw decaying
noise. Measured L2 at the registry default (size 0.7 / damping 0.5): 46.4, or
+33.3 dB, putting the wet path ~24 dB over dry at the default `wet: 0.35`. It is
now normalised to unit energy, so the wet knob means what it says. Preview and
render both convolve this buffer, so they stayed identical throughout — equally
deafening before, equally correct now.

**Phaser in_gain/out_gain trim the signal entering and leaving the effect**, not
a wet/dry pair. Wired to the wet and dry legs, "Input" muted the dry path and
the two defaults summed to 1.14, so inserting a phaser raised the track level.
They are now input and output trims with the legs summed at unity. Its declared
waveform is also honoured: `lfo.type` was never assigned, so the default
"Triangular" was silently a sine.

**The dynamics worklets held one envelope across a channel-major loop.** The
followers advance per sample, so on stereo a 20 ms attack behaved as 10 ms, and
the right channel's gain came from an envelope that had already traversed the
left — the two ducked differently from the same input and the image pumped.
State is now per channel, as is the gate's smoothed gain and bitcrush's
sample-hold counter, which previously advanced only on the last channel and left
every earlier one frozen for a whole quantum. The gate also honours the knee it
declares instead of chattering on material sitting at the threshold.

**A one-pole filter's cutoff was swallowed in preview.** Its coefficients are
fixed at construction, so `update` cannot push a new frequency — but the shape
signature carried only type and pole count, so a cutoff change looked like a
values-only edit and went into a no-op updater. Preview kept filtering at the
old frequency while the render used the new one: a preview/render divergence in
exactly the two effects that do not use a BiquadFilterNode.

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

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

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

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

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

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

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

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

* feat(core): voiceover carve analysis

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(studio): lift audioFxSummary out of PropertyPanelFlat

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

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

595 lines.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(core): play automation envelopes in preview

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(studio): the geometry and plumbing behind automation lanes

Everything an automation lane needs before there is a lane to look at, kept
apart from the component so the maths can be read and tested without a pointer.

`automationLaneGeometry` is pure: which parameters a clip can automate (its
fader, then each automatable knob of each effect that carries a node id), how a
value maps to a position in the lane, and how a lane is edited. Two decisions
live here and are worth review:

- A log-read knob maps on its own log scale, so the middle of a 100 Hz–20 kHz
  lane is the geometric mean. Dragging and drawing then agree with what the
  knob's own scale already promises.
- `withLane` replaces a lane in place rather than appending. A lane with no
  explicitly chosen parameter shows whichever comes first, so moving the edited
  one to the end would switch the lane out from under the pointer on the first
  edit.

`automationLaneData` parses the two attributes, cached by their text so the
identity only changes when the text does — the lane holds an optimistic draft
while a point is dragged and compares against that identity, and a fresh object
on every playhead tick would throw the drag away. It binds automation to the
chain the way preview and the render bind it, so a lane whose effect was deleted
is dropped rather than drawn against the wrong axis.

`useAutomationLanes` routes edits through the DOM edit session, targeting the
selected element because that is what the attribute commit path writes to.

Row height reserves each lane at its own height rather than counting it as
another keyframe lane, and `TimelinePropertyLanes` gains a footer slot so the
lanes share the keyframe disclosure — and its `aria-controls`.

`TimelineElement` moves to its own module: playerStore had reached the 600-line
studio ceiling exactly and could not carry another field. It is re-exported from
there, so no importer changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(studio): make automationLaneData reviewable, and evict one entry not all

The cache key held a literal NUL byte instead of its escape, so git classified
the whole module as binary: it landed as `Bin 0 -> 2677 bytes` with zero
diffable lines, invisible to review, to grep, and to any textual merge. The
escape is behaviour-identical.

With the file readable, two things in it needed fixing.

Eviction cleared the entire map. Clearing changes the identity of every lane's
automation at once, and a lane compares its drag draft against that identity —
so one unrelated element arriving at the limit would release an in-progress drag
and snap the point back. It now drops the oldest entry, and a hit is re-inserted
so it counts as recently used.

Nothing tested this module, which is what let the binary blob through. Now
covered: identity stability, re-parsing when the chain changes but the
automation text does not, a hot entry surviving 40 evictions, and an unreadable
attribute reading as nothing.

The geometry module's exports are ignored for dead-code while its consumer sits
one PR upstack, following the convention already used for the fast-capture
stack.

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

* fix(studio): carry the audio FX attributes onto every timeline row

Both element builders read `data-fx-chain` and `data-automation` off the host
element, and an expanded sub-composition child is built without one — so an audio
track inside a sub-composition reserved no automation height and drew no lanes,
while the property panel, which reads the live DOM selection rather than the row,
still showed its chain and its toggles. `hostElementState` exists to re-inherit
exactly this class of host-only field; it now covers these two alongside
`hidden`, `timelineLocked` and `timelineRole`.

`parseTimelineFromDOM` had the same gap and now reads both directly.

Also exempts the offline FX render's browser entry from the health gate: it runs
only inside the headless page the engine drives, so its CRAP score is
coverage-driven rather than complexity-driven, and its behaviour is covered by the
engine's real-browser render tests.

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

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

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

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

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

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

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

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

* feat(core): voiceover carve analysis

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(studio): lift audioFxSummary out of PropertyPanelFlat

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

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

595 lines.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(core): play automation envelopes in preview

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fallow audit flagged it — no test imports the module.

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:30:36 -07:00
Miguel Ángel d8a91fc347 fix(studio): stop three crash-boundary trips in the editor (#3102)
## What

Fixes three Studio crashes. All three throw into React and drop the user on the full-screen "Something went wrong" boundary.

**1. `NotFoundError: Failed to execute 'removeChild' on 'Node'`** — the highest-reach of the three. The `Player` mount effect appends a `<hyperframes-player>` into its container and tears it down with `container.removeChild(player)`. By the time that cleanup runs the element may already be detached: the container can re-render, a crossfade refresh can swap it, or a translation extension can reparent it. Switched to `player.remove()`, a no-op when the node has no parent. `utils/clipboard.ts` had the same unguarded `document.body.removeChild(textarea)` and is fixed with it — those are the only two `removeChild` call sites in non-vendor source.

**2. `SecurityError: Failed to read the 'localStorage' property from 'Window'`** — `getPersistedTab()` read `localStorage` unguarded and runs as a `useState` initializer. Chrome throws on the *property read itself* when site data is blocked for the document, so a profile with storage blocked lost the whole editor instead of one remembered tab. Routed through the existing `safeLocalStorage()` helper with the access guarded too, matching the pattern `telemetry/config.ts` documents. The `setItem` on tab switch was unguarded the same way and is fixed with it.

**3. `TypeError: s.indexOf is not a function`** — `pruneKeyframeCacheToFiles` calls `key.indexOf("#")` on a key that is not a string, though `keyframeCache` and `gsapAnimations` are both typed `Map<string, …>`.

## Why

None of the three loses real work — they are incidental teardown, persistence, and cache-pruning paths taking down the whole editor. The `removeChild` one reaches by far the most users.

## How

### Locating #3

The Studio build ships no sourcemaps, so the reported frame in a minified chunk was not traceable as-is. Checking out the `v0.7.90` tag and rebuilding it reproduces the same asset filename hash **byte-for-byte**, which confirms the rebuild is the same code the crash came from. Decoding the frame against that bundle lands on `gsapKeyframeCacheHelpers.ts:198`.

### Fixing #3

`elementCacheKeys` owns the key-variant list every cache write sets. Two of its three keys are template literals and coerce on their own; the bare-id key was passed through raw, so a non-string `elementId` reaching it put a non-string key into both maps, which prune then choked on. It now coerces that key.

Review caught that it was not yet the *only* write gate: `useGsapTweenCache` built the same key list by hand at two sites, so a non-string id there still reached the maps uncoerced. Both sites now loop `elementCacheKeys`, and their matching reads use the same list instead of a second hand-rolled copy. That also closes a drift the helper's own doc comment warns about — the per-element writer omitted the `index.html#<id>` fallback key its siblings all set, so a reader falling back to that key saw a stale entry. The only remaining direct writers are in the dev-only timeline performance fixture, which generates its own string ids.

The coercion **reports** the offending value's `typeof`, constructor name, and source file as `studio:cache_key_non_string` rather than swallowing it. This is deliberate: every writer that reaches `elementCacheKeys` was traced and each one produces a string, so **which caller supplies a non-string id is still unknown**. Rather than guess at a producer, this hardens the single gate that can guarantee the maps' declared contract, and makes the next occurrence name its own producer. Only the value's shape is reported, never its content.

Fixes 1 and 2 are both the smaller diff *and* the root fix: one guard where every caller routes through, rather than one per call site. No behaviour change on any happy path.

## Test plan

- [x] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)

Six regression tests, every one verified to fail without its fix:

- `Player.test.ts` — detaches the player element, then unmounts. Without the fix: `DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.`
- `LeftSidebar.storage.test.ts` — makes the `localStorage` property getter throw, then calls `getPersistedTab()`. Without the fix it fails with the same `SecurityError` the crash reports carry.
- `gsapKeyframeCacheHelpers.test.ts` — four cases: keys stay strings, the violation is reported, the normal string path stays silent, and a prune after a non-string write does not throw. Without the fix the last one fails with `TypeError: key.indexOf is not a function`.

Full Studio suite green: 3559 passed, 335 files, 0 failures. `oxlint`, `oxfmt` and `tsc --noEmit` clean.

Manual testing is unchecked deliberately: none of the three reproduces on a normal local profile, which is why they only surfaced in crash reports. The tests exercise the exact throwing boundaries instead.

## Not covered

Two other crash signatures reviewed alongside these are **not** fixed here: one occurs almost entirely on locally-built dev Studio rather than released builds, and the other has not appeared on any recent release.

**Follow-up worth its own PR:** ship sourcemaps for the Studio build. Rebuilding a tag to decode one frame worked, but it should not be the process, and it is the prerequisite for diagnosing the next minified crash.
2026-08-07 19:20:18 -07:00
Miguel Ángel 88853f170f fix(studio): route rooted timeline media through preview (#3061) 2026-08-05 22:14:28 -07:00
Miguel ÁngelandCodex 96861cbafc perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
2026-08-05 20:41:35 -07:00
Miguel Ángel 7bf425b7a9 docs(studio): document timeline keyboard navigation (#3031)
* feat(studio): expose timeline treegrid semantics

* feat(studio): coordinate logical timeline focus

* feat(studio): add timeline keyboard controls

* docs(studio): document timeline keyboard navigation
2026-08-04 17:09:07 -07:00
Miguel Ángel 0f1333c80d feat(studio): model logical timeline navigation (#2712) 2026-08-04 16:48:39 -07:00
Miguel Ángel 0a70ba6717 fix(studio): scope timeline ease focus lifecycle (#2710) 2026-08-03 23:06:46 -07:00
Miguel Ángel 677fd31e9a perf(studio): stabilize virtualized beat gestures (#2709) 2026-08-03 21:35:40 -07:00
Miguel Ángel 423c5ffadb fix(studio): scope timeline context targets (#2708) 2026-08-03 21:19:49 -07:00
Miguel Ángel 91d14744a0 perf(studio): virtualize timeline marquee selection (#2707) 2026-08-04 06:03:21 +02:00
Miguel Ángel bb5c603870 perf(studio): stabilize virtualized timeline drops (#2706) 2026-08-04 05:45:45 +02:00
Miguel Ángel 44bee4c3cf perf(studio): stabilize virtualized keyframe retiming (#2705) 2026-08-04 05:14:09 +02:00
Miguel Ángel f52398ceef perf(studio): stabilize virtualized clip gestures (#2704) 2026-08-04 04:58:15 +02:00
Miguel Ángel c6925e471a feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
2026-07-31 19:43:47 +02:00
Miguel Ángel 723d3381c4 fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures

* test(studio): gate timeline viewport performance in Chromium

* refactor(studio): isolate clip drag lifecycle

* refactor(studio): extract timeline render contracts

* perf(studio): centralize timeline viewport geometry

* perf(studio): follow playhead across virtualized rows

* perf(studio): add timeline clip-window index primitive

* perf(studio): virtualize timeline clip windows

* perf(studio): stop timeline scroll work when row virtualization is off

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.

* ci(studio): run the timeline viewport gate on studio changes

The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.

* fix(ci): preserve both timeline gate evidence arms

* ci(studio): report timeline gate arm statuses

* ci(studio): require timeline gate evidence artifacts

* fix(studio): keep dense keyframes readable

* fix(ci): resolve timeline stack audit findings
2026-07-31 18:05:22 +02:00
Miguel Ángel fbfffb1aa7 fix(studio): release retained preview resources (#2924) 2026-07-31 14:31:43 +02:00
Miguel Ángel b813b17345 fix(studio): prevent negative timeline latency telemetry (#2905) 2026-07-30 22:29:34 +02:00
Miguel Ángel 10b517dab9 feat(studio): track timeline performance (#2898) 2026-07-30 18:53:02 +02:00
Miguel Ángel cef3b86c95 chore(studio): remove fully rolled-out studio feature flags (#2889)
## What

Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:

| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |

## Why

Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.

The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:

- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)

## How

No behavior change: every flag was removed by keeping its default-`true` side.

The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.

`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.

Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.

### Deliberately not in scope

Flags authored by other people are untouched, even where they look similarly settled:

- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`

Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.

`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.

## Test plan

- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)

```
packages/studio: bunx vitest run          # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit        # clean
bun run build                             # green (all packages)
bunx oxlint  <25 changed files>           # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files>     # clean
```

Two extra checks, because part of this diff was script-generated:

1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.
2026-07-30 02:00:05 +02:00
Miguel Angel Simon Sierra adb7de5358 fix(studio): open the path node menu on arc waypoints
Right-clicking a motionPath waypoint in the preview overlay opened Chrome's
own context menu on top of the editor: the handler returned before
preventDefault for every node that was not an x/y keyframe. Both node kinds
now open Studio's menu. A waypoint has no percentage of its own, so Move to
Playhead is hidden and Delete acts on the path index, matching the hover x
badge; Delete is withheld entirely on a two-anchor arc, where the writer
refuses the removal and the entry would silently do nothing.
2026-07-29 03:44:31 +02:00
Miguel Angel Simon Sierra 10d45def05 feat(studio): bulk-edit easing for merged keyframes 2026-07-29 03:39:51 +02:00
Miguel Ángel 7482c22d82 fix(studio): target colliding keyframes exactly (#2692) 2026-07-29 03:39:17 +02:00
Miguel Angel Simon Sierra ba0d6406d2 fix(studio): scope lane ids per timeline and stop inventing a track row
Two latent defects in the announcement path this branch adds.

- lanesId was keyed by render row alone, so a second TimelineLanes on the
  page (a mini-timeline beside the main one) would mint the same
  timeline-lanes-track-0 and every caret's aria-controls would resolve to
  whichever instance mounted first. The prefix now comes from useId, with the
  colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
  find, which is indistinguishable from a real row: the label announced a row
  the user can see is wrong and nothing upstream could tell it had guessed. It
  returns null now, and trackDisplaySuffix drops the number from the label
  rather than inventing one.
2026-07-28 21:21:27 +02:00
Miguel Angel Simon Sierra 477916cbe2 fix(studio): label undo history with the track display row
The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".

Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.

Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra 1faa0cbdad fix(studio): announce real track numbers and point aria-controls at the lanes
The timeline's track key is a fractional z-order sort value, and the header
built its visibility label straight from it, so screen readers announced
"Hide track 0.16666666666666666". A track's 1-based display row is now passed
alongside the key: the row number goes in every label, the key keeps routing
every callback (visibility toggle, lane context menu). The same fix covers the
`Track N` fallback used when a track holds no labelled element.

The layer disclosure caret's aria-controls named a div in the sticky label
column. That subtree is not empty, it holds the per-lane keyframe controls, but
its children are all absolutely positioned so the div computes to 0x0, and the
diamonds the caret visibly reveals live on the canvas instead. The caret expands
two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes
now renders one static wrapper (static, not relative, so it establishes no
containing block and the absolutely-positioned lanes keep resolving against the
track-content div with identical geometry) and takes the id. TimelineLanes mints
that id, since it is the only place that sees both ends of the disclosure, and
mounts the wrapper for the track's keyframe clip in both disclosure states so
the reference still resolves while collapsed.

TimelineLaneBaseProps moves to its own module: it is the contract shared by
TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx
well under the 600-line cap instead of pushing past it.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra be3451aae4 fix(studio): resolve a panel edit through the lane groups it can see
animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
2026-07-28 20:37:21 +02:00
Miguel Angel Simon Sierra 59a818e80a fix(studio): lane every tween and attribute tweens to their real target
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
2026-07-28 19:02:25 +02:00
Miguel Angel Simon Sierra 7f0cadcbb1 fix(studio): drop aria-modal from the non-modal shortcuts popup
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
2026-07-28 18:02:09 +02:00
Miguel Angel Simon Sierra aa2811642f fix(studio): dismiss the shortcuts panel on escape and outside press
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
2026-07-28 18:02:09 +02:00
Miguel Angel Simon Sierra b82506363f fix(studio): grow the ease target only where the segment has room
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
2026-07-28 18:01:46 +02:00
Miguel Angel Simon Sierra 4e74eefddd fix(studio): meet the 24x24 pointer target minimum on toolbar and lane controls 2026-07-28 16:44:42 +02:00
Miguel Angel Simon Sierra 69020699df fix(studio): seek ruler clicks to the pressed position and round retime percentages
A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
2026-07-28 16:44:42 +02:00
ukimsanov c62bd4c454 fix(studio): harden preview recovery 2026-07-27 23:55:06 -07:00
ukimsanov 3af9a7df2d refactor(studio): reuse player probe errors 2026-07-27 22:35:09 -07:00
ukimsanov 18b9acac12 fix(studio): improve preview loading reliability 2026-07-27 22:14:23 -07:00
Miguel Angel Simon Sierra 8470b88aa1 fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks
Review follow-ups on the expanded keyframe lanes.

Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
  dispatched update-meta, so a rejected write left the diamond parked at its drop
  position. `observeGsapMutation` now resolves to whether the mutation landed and
  the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
  a layer with position AND opacity keyframes kept half of them. It now walks
  every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
  which picks an arbitrary tween when a target carries several. Match by id first.

Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
  selection to its own source keyframe, undoing a retime the user could see. The
  revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
  a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
  and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
  rows instead of hardcoding LABEL_COL_W over the canvas.

Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
  the clicked-element writes are actually pinned; three assertions that passed
  either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
  rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
  also accepts NaN); ease segments assert their label ORDER, not just that the
  three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
2026-07-28 01:19:05 +02:00
Miguel Angel Simon Sierra c20c5366da fix(studio): commit lane edits through the edited element's own selection
An explicit null selection override now aborts the write instead of falling
back to domEditSelection: a caller that resolved a selection for its own
element and found none was committing onto whichever element happened to be
selected. Ease changes and the playhead keyframe toggle resolve the edited
element's animations and selection instead of the current selection's, and
lane header rows follow the real label-column width so a narrowed column no
longer hangs its value readout over the canvas.
2026-07-28 00:40:47 +02:00
Miguel Angel Simon Sierra 3181657147 refactor(studio): share keyframe clip-% precision across lane writers
Review follow-ups on the expanded-lanes tip:

- TimelinePropertyLanes.groupKeyframes re-derived the clip-relative
  percentage inline and skipped toClipKeyframes' rounding, the one
  precision every keyframe-cache writer has to agree on (selection keys
  embed the number). It now loops the shared helper per animation with
  the group filter and only overrides the lane's own group and ease.
- TimelineClipDiamonds rebuilt and re-sorted each tween's sibling row
  inside the marker loop, so a row of N diamonds allocated N sorted
  copies of itself on every playhead tick. Built once per render instead,
  keyed by animation id.
- Add the missing symmetry test at the real callback boundary: the
  diamond test mocks onMoveKeyframe, so nothing proved the rapid-second
  retime resolves a pending clip-% the keyframe cache has not caught up
  to. The new test asserts the identity-carrying target retimes and that
  the same drag without identity fields cannot.
2026-07-28 00:40:47 +02:00
Miguel Angel Simon Sierra e317f1fbe3 refactor(studio): double-cast test fixtures and split three dense functions
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.

The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
2026-07-28 00:40:46 +02:00
Miguel Angel Simon Sierra b8ff8bf0f3 fix(studio): close the review findings that survived the stack
Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.

A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.

One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.

The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.

Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.

Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
2026-07-28 00:40:46 +02:00
Miguel Angel Simon Sierra 8b38a8562e fix(studio): close the timeline regressions the QA triage attributed here
The QA fleet's 295 findings were replayed against the merge-base. Most were
pre-existing, but these were caused by this stack:

Deleting one keyframe destroyed the whole tween. The lane-header remove toggle
escalated a flat tween to a whole-animation delete, which took the authored
`tl.to(...)` and its source comment with it on a single click. The base build
posts remove-keyframe and lets the writer refuse it; restore that.

A keyframed layer could not be hidden at all. The visibility eye had moved off
the always-mounted layer row onto a hover-gated property-group row, so it only
existed while the lanes were expanded AND the pointer was over that lane. A
keyboard-only user could reach no eye at all, and its label named a track its
row did not act on. It goes back on the layer row.

A drag from the centre of a clip bar did nothing, because the 16px inline ease
button sits exactly there and swallowed the press. It now lets the press through
to the clip and keeps only the click, dropped if the pointer travelled.

Dragging a diamond onto a neighbour silently discarded the retime. The clamp
bounded the dragged keyframe by the whole merged row, so two animations
colliding at one percentage pinned each other in place and the drag resolved
back to a click. Clamp against the dragged keyframe's own tween instead.

Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at
the zoom floor), round the diamond tooltip percentage, and prune the keyframe
caches when a composition switch drops a file from the scan set — each file only
ever cleared its own entries, so the previous composition leaked every element
into both keyframeCache and gsapAnimations, with nothing to evict it.
2026-07-28 00:40:45 +02:00
Miguel Angel Simon Sierra 538264463c refactor(studio): tighten the expanded-lane cache and height helpers
Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
2026-07-28 00:40:44 +02:00
Miguel Angel Simon Sierra 282cc883e5 fix(studio): address family B timeline review findings
- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
  labels on the keyframe toggle, 24px chevron targets, focus-visible parity
2026-07-28 00:40:42 +02:00
Miguel Angel Simon Sierra 4a12eb9d9c feat(studio): wire expanded keyframe timeline lanes 2026-07-28 00:40:42 +02:00
Miguel Angel Simon Sierra 6b58678d94 fix(studio): drop dead keyframe menu wiring and close the R1 review items
The diamond context menu still declared `onChangeEase` and `onCopyProperties`
props, and `TimelineOverlays` still threaded `onChangeKeyframeEase` plus a
`keyframeCache` it never read. Nothing on any timeline branch calls them, so
they are removed along with the `onChangeKeyframeEase` callback implementation.

Also from review:
- `deleteSelectedKeyframes` only falls back to the sole keyframed animation when
  there is exactly one. A collapsed selection key carries no animation id, so
  taking the first of several deleted an arbitrary tween's keyframe.
- The duration-less retime test asserts the real 87.601% instead of
  `expect.any(Number)`, so a wrong timing basis fails it.
- `Timeline` wires the keyframe handlers' `onSelectSegment` through to the
  diamonds; it was built and then dropped, so segment ease selection never fired.
- The flat text section arms auto-focus in state rather than reading and
  clearing a ref during render, which Strict Mode's double render swallowed.
2026-07-28 00:37:31 +02:00
Miguel Angel Simon Sierra eddc5e9224 fix(studio): pass the whole keyframe identity out of the diamond menu
The context menu handed its actions loose positional arguments, so the
overlay adapters forwarded the percentage alone and dropped the property
group, tween percentage, and animation id the menu had resolved. The target
then fell back to first-match-by-percentage and deleted or retimed the wrong
animation whenever two collide at one percentage. Every action now carries a
TimelineKeyframeTarget, which leaves no adapter shape that can drop it.
2026-07-28 00:25:41 +02:00
Miguel Angel Simon Sierra f5d3c7a3d1 refactor(studio): split the diamond row's data contract into its own file
TimelineClipDiamonds.tsx had grown past the 600-line studio file gate, and
its keyframe/props types were declared a second time next to the ones the
lanes already share. The types, the render constants, and the keyframe
identity helper move to timelineDiamondTypes.ts; the rendering stays put.
2026-07-28 00:13:47 +02:00
Miguel Angel Simon Sierra cbdd61d102 refactor(studio): give every keyframe diamond one identity
The collapsed clip row dropped a keyframe's property group and animation id
before handing it to a callback, so the same keyframe hashed to a different
selection key than the expanded property lane did. Selecting a diamond in one
view left it unselected in the other, and retime/delete on the collapsed row
lost the animation id they use to pick between two animations that collide at
one percentage.

Diamonds now always carry their full identity, the collapsed shim just curries
the element id, and Timeline reuses useTimelineKeyframeHandlers instead of its
own inline copy of the same three handlers.

Neighbour geometry moves into one marker record per diamond, which drops the
index-lookup non-null assertions the connector pass needed.
2026-07-28 00:13:46 +02:00
Miguel Angel Simon Sierra 62e866eda6 fix(studio): span the clip on the remaining duration-less tween paths
Two sibling call paths still answered from GSAP's 0.5s default while the
toggle path had moved to the clip-wide fallback.

isPlayheadWithinTween now takes the selection, so the toolbar stops
promising "extends animation" on a duration-less tween whose click actually
toggles an interior keyframe. The arc drag commit resolves its replacement
duration the same way its click-path sibling does, instead of authoring
duration 0.5 and collapsing the arc window.

The flat text section drops its two marker-clearing effects: the autofocus
marker is a ref now, read and cleared by the render that consumes it.
2026-07-28 00:13:46 +02:00
Miguel Angel Simon Sierra d57039882f fix(studio): harden keyframe editing semantics 2026-07-28 00:13:46 +02:00