mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): retime and edge-stretch an automation selection (#3207)
* 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(studio): the automation lane itself Draws each automated parameter as its own lane under the audio clip, on the same disclosure caret the keyframe lanes use — that caret is the DAW automation triangle. One lane per parameter rather than a selector to swap between them, so two envelopes can be read and edited without hiding either. Double-click the line to add a point, drag to shape it, right-click a point to remove it. Three things here took more than one attempt, and the comments say why: - **A dragged point did not move.** The live write deliberately skips the preview refresh — that is what keeps dragging from restarting playback — so the stored value does not move under the pointer. The lane keeps a local draft. - **Releasing snapped it back.** The draft was dropped when the drag ended, which is before the persisted write comes around; it now lives until the automation it was drawn over actually changes. - **A press was eaten.** Not stopping propagation let the timeline start its own gesture and swallow the second half of a double-click. The lane owns the press once it is live — and when it is not, it selects its clip instead, since lanes sit below the clip bar where the timeline's own selection handler never sees them. The envelope is inset by the grab radius so a point at the clip's first or last frame is drawn whole rather than half outside the lane, and clip time still lines up with screen position because the inset and the offset cancel. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(studio): let a track disclose its automation without a tween The lane was mounted inside the property-lanes wrapper, which renders only for a track's GSAP keyframe clip — so an audio clip with no tween resolved to nothing: no disclosure caret, no reserved height, no lanes. Verified on a composition with one `<audio>`, an envelope, and no tweens anywhere: 0 carets, 0 lanes. The attribute still wrote and the render still baked it, so the feature failed silently for exactly the tracks it exists for. Same composition now: 1 caret, and expanding it draws the Volume lane. Automation counts as something to disclose. `resolveTrackKeyframeClip` takes a counter alongside the keyframe lane counts and qualifies a clip on either; the header asks the same counter about the clip it already holds. A function rather than another map threaded through the props: every caller then reads one cached parse, so the height a row reserves and the lanes drawn in it cannot drift apart. That drift is also fixed for the lane's own offset, which passed the raw tween count where every other consumer uses distinct property groups. Two tweens on one property drew one keyframe lane but pushed the automation lane down by two, spilling into the next track; one tween on two properties did the inverse and drew it over a diamond lane, stealing its pointer events. It now reads the same `laneCounts` map the reserved height and the drawn lanes use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(studio): automate a parameter without reloading the preview The write path and the volume half of the panel surface. **A commit that persists without reloading.** For attributes the runtime applies to the live graph itself — an FX chain, its automation — a reload would only interrupt playback to reach the state the preview already has. `skipRefresh` and `refreshAfter` were already independent options; this exposes the combination that skips the reload but still re-reads the selection. Both halves are needed, and they were fighting each other. Without the reload, audio no longer chops on an edit. Without the resync, the panel keeps reading the selection snapshot it was built with, so a second edit computes from a pre-edit value and appears to do nothing — deleting one effect made every later delete a no-op. `handleDomAttributeLiveCommit` is untouched and still used for knob dragging, where a per-move re-render is exactly what you do not want. **Volume.** An automated track's slider is disabled, since a level set there would be overwritten by the envelope on the next tick, and the toggle beside it adds or deletes the lane. Adding seeds it with a single point at the level the slider already shows, so automating a track never changes how loud it is. **One shared reader** for both panel sections, which is what surfaced that resolving against an absent chain would have deleted every FX lane the moment someone automated a volume: the volume section does not parse the chain, so "no chain" now means "do not resolve" rather than "drop what cannot be resolved". The toggle itself lives with the FX controls it is shared with, and says `Automated` / `Automate` through the studio's own Tooltip rather than a native browser hover. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(studio): stop the last two automation writes reloading the preview The quiet commit added here was only used by the FX group. Two writers still went through the refreshing one, so they reloaded the preview and restarted every playing track — the exact chop the live write during a drag exists to avoid: - releasing a dragged breakpoint, so the audio hitched at the end of every point you moved; - clicking the volume toggle, while the same click on an effect parameter was already silent. Both are quiet now: still persisted, still resyncing the selection so a following edit computes from the value just written. Also fixes the seeded volume. `Number(dataAttributes.volume ?? "1")` is 0 for an attribute that is present but empty, so automating such a track started its lane at silence while the engine read the same empty value as unity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(studio): automate and un-automate each effect parameter The per-parameter surface in the FX panel. An automated parameter's control is disabled — a value typed there would be overwritten by the envelope on the next tick, so the lane is the value now — and the toggle beside it adds or deletes that parameter's lane. Adding seeds the lane with a single point at the value the control already holds, so switching to an envelope never changes the sound, only where the value comes from. Parameters no envelope can drive have no toggle at all: the worklet-backed dynamics expose no AudioParams, a WaveShaper's curve and a convolution impulse are rebuilt wholesale rather than scheduled. Neither does a chain node with no id, since a lane addresses nodes by id — so adding an effect now mints one. Carve moves onto the same non-reloading write, and decodes its source in an `OfflineAudioContext`: opening a second output device mid-playback makes the running track glitch while the hardware is reconfigured. Turning carve off now also drops the filters it generated, which otherwise kept dipping the bed with nothing in the panel to explain it. `AudioFxGroup` moves into its own module — PropertyPanelFlat was at its size budget — which also gave the panel's write behaviour somewhere to be tested: what it writes, seeded at the current value, preserving the lanes it is not touching, and clearing the attribute when the last one goes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * feat(studio): only offer voiceover carve when there is a voice to carve against Carve is a relationship between two tracks — it analyses another track's voice and dips this bed where that voice sits. In a composition with a single audio track there is nothing to listen to, so the block offered an empty source picker and an Analyse button that could never do anything. It is now shown only when the composition holds another audio track, and still shown when carve is already configured: hiding a live setting because its voice track was removed would leave the bed being dipped from out of sight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(studio): cover carve visibility through the real element The panel derives carve's source list from the selected element's document, so a selection with no element has no sources — which the new visibility rule correctly reads as 'nothing to carve against'. The suite mounted exactly that, so it was asserting on a hidden block. Selections now carry a real <audio> with a sibling track, and the two cases the rule exists for are pinned directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): keep FX panel writes from clobbering each other Three writes in the audio panel each read the source file, mutate one attribute and write it back. Fired without ordering they read the same content and the last one lands, dropping the others. - Deleting an effect left its automation lanes in the attribute. Ids are minted lowest-free, so the next effect added took the same id and inherited the dead envelope: disabled and "Automated" without the author ever automating it, and baked into the render. - Switching carve off wrote the chain (dropping the filters it generated) and the carve settings at once, so either the filters stayed with no carve to explain them or the settings survived with no filters. - The three carve dials committed per input event, patching the source and resyncing the selection dozens of times per drag. They now preview live and persist on release, like the FX knobs already do. Volume automation reads through the quiet commit too, so removing a lane resyncs the panel instead of leaving the slider disabled. * feat(engine): let an FX tail decay instead of cutting it at the clip The offline render ended at the last input sample, so a reverb or a delay was still ringing when the context stopped. Measured on a 1.5 s tone through a default reverb, the render cut at 1.524 s while the tail was still at -29.7 dB — an audible chop, and the one place the render did not match preview. The length does not have to be guessed. Every tail here follows from its own settings: a convolution is exactly as long as its impulse, and `synthesizeReverbImpulse` derives that from room size; a delay's repeats fall by `feedback` every `time`, so the count down to -60 dB is a log. Everything else settles with its input — an all-pass chain has group delay, not a tail, and a 9-second compressor release has no signal to release once the clip stops. `chainTailSeconds` sums them (the chain is serial, so a delay in front of a reverb hands each repeat to the room), reads a lane's maximum rather than the static knob where one is automated, and caps at 5 s — 5 s between repeats at 0.95 feedback is eleven minutes of decay, and the panel can dial exactly that. The mixer's per-track atrim now allows the clip plus its tail; the atrim after apad still holds every track to the composition's length, so a tail can run over what follows but never extends the video. Same fixture after: a smooth decay to -72 dB, last non-zero sample at 3.306 s against the 3.4 s the settings predict. * feat(studio): curve, snap and type a value in an automation lane Four gestures from Ableton's envelope editor, which is the muscle memory an automation lane inherits. Alt-drag the line between two breakpoints to bend it, Alt-double-click to straighten. `curve` was already honoured everywhere it is read — drawn in the lane, sampled in preview, baked into the render through setValueCurveAtTime — with no gesture anywhere that could set it, so every envelope anyone could draw was linear in practice. The curve is solved, not accumulated (x^e = f, so e = ln f / ln x), which keeps the segment under the pointer instead of drifting away over a long drag; the test asserts that by sampling with the renderer's own sampler. Shift locks a drag to one axis and fines the vertical travel to a quarter. Which axis won is decided in pixels — seconds and dB are not comparable numbers, and comparing them would make the lock depend on the zoom. A dragged point snaps to the beat grid and to its neighbouring points, with Alt to ignore it. The radius is tight on purpose: a lane is often a few seconds wide, where a generous radius makes a point unplaceable between two beats. Double-click a point to type its value. -6.0 dB is not a pixel you can find, and there was no way to enter one. The gesture layer moves to useAutomationLaneGestures and the path builder to envelopePath: the component was at the studio's 600-line ceiling, and both are worth testing without a render. trackShowsBeatStrip comes out of TimelineLanes for the same reason. * feat(studio): pure range ops for automation lane selections Add pointsIn() and replaceRange() functions for managing automation envelope edits within a time range. The key invariant: envelope values outside the selection never move. Implemented by anchoring the boundaries at t0 and t1 by sampling the original lane, so cutting middle sections cannot reshape the rest. Inner points from shape generators can suppress redundant anchors at merge distance. * fix(studio): budget replaceRange's inner points before capping, not after * feat(studio): automation selection slice * feat(studio): drag-select a time range on an automation lane Dragging on an automation lane's empty background now arms a range selection, snapped to the beat grid and clamped to the lane duration; a sub-3px drag counts as a click and clears instead. Point drags and Alt-drag segment bends still take priority, since the range arm only runs where the existing point/segment hit-test already returned null. useAutomationLanes binds the selection slice per element/lane so the rect renders from the store, matching the read pattern the writes already use. * feat(studio): delete an automation selection from the keyboard Escape clears the active automation-lane time selection; Delete/Backspace empties it via replaceRange(..., inner: []), which pins anchor points at both edges and leaves the envelope outside the selection untouched. Mounted in TimelineLanes.tsx next to the useAutomationLanes() call that already lives there. Also adds a stale-selection guard in TimelineAutomationLaneSlot that clears the selection if its lane's target stops existing on the bound element's automation (e.g. the automated effect was deleted). * test(studio): cover the automation selection stale-target guard * feat(studio): ramp, swell and dip generators for automation selections * fix(studio): let an automation range keep Delete from the clip useAppHotkeys listens on window/capture, so it runs before useAutomationSelectionKeyboard's document/capture handler. With a range selected, Delete fell straight through to the clip-delete branch and destroyed the whole audio clip the lane belongs to; Backspace hit the reset-keyframes branch on the way and wiped the clip's keyframes. Guard both by returning early when automationSelection is set, mirroring the selectedKeyframes precedent six lines above. No preventDefault: the downstream handler still needs the key. dispatchPlainKey is exported so the arbitration between keyframes, an automation range and the clip can be pinned without standing up the hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: suppress unused export for automationShapes (consumed upstack) * feat(studio): simplify dense automation runs Implements Ramer-Douglas-Peucker point-thinning for audio automation lane breakpoints, working in unit space for correct log-scaled parameter handling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore: add automationSimplify to fallow complexity ignore The Ramer-Douglas-Peucker algorithm in automationSimplify.ts has inherent complexity (12 cyclomatic / 20 cognitive) that is by design and not refactorable. Added to health.ignore list and ignoreExports list since it's consumed by the UI layer one PR upstack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(studio): internal clipboard for automation ranges * feat(studio): copy and paste automation ranges across lanes Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the selection's start or the playhead, chaining the selection to the pasted span so a second paste lands right after the first). Paste falls through untouched when no target lane resolves, so clip-level paste keeps working. Also fixes a latent test-isolation bug: setup() never unmounted the previous test's Host, so document keydown listeners leaked across tests and could consume later events before the current test's own listener ran. * feat(studio): shape and simplify menu on an automation selection Right-click inside an active time-selection rectangle on an automation lane now opens a menu offering the four utility shapes (Ramp up, Ramp down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints with pointsIn/replaceRange from the prior selection tasks. A point's own right-click still stops propagation and deletes it, unaffected. * feat(studio): retime an automation selection Add retimeRange pure operation that scales interior points proportionally into a new time span, then uses replaceRange to update the lane while preserving the envelope outside the union of old and new ranges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(studio): repair the automation paste path and finish the key arbitration Paste was the least safe path in this feature: it resolved its target from the player store but committed through a different, asynchronously-lagging channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half of the arbitration wa-15 started for Delete. - Write channel: resolvePasteTarget bails unless the binding's commitTargetKey equals the element it resolved. useAutomationLanes exposes that key, resolved through resolveTimelineIdForSelection — the same resolver applyDomSelection uses — and read in the same render as the commit handlers, so a handler and the key cannot describe different moments. Before this, clicking clip B then immediately pasting serialized B's automation onto A and left B untouched. - Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second Cmd+V lands after the first instead of on top of it. The old comment claimed the new behaviour while the code did the opposite, and no test pressed Cmd+V twice. - Empty copy: copyRange returns false rather than arming a clipboard whose every paste is a destructive flatten, and samples the range's edges so copying a smooth stretch yields a real segment instead of no points. - Playhead: the playhead branch requires the playhead to be inside the clip rather than silently clamping an out-of-clip playhead to the clip's start. - Keys: one chord helper normalizes with toLowerCase() and gates on !shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the shortcut and Ctrl+Alt+V no longer pastes where the app declines. - Arbitration: useAppHotkeys consults automationOwnsKey before its c/v branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the same way it keeps Delete. Without it Cmd+V duplicated the clip while the automation paste wrote the same file, and Cmd+C armed both clipboards. It returns without preventDefault — the downstream handler needs the key — and declines when the automation clipboard is empty so clip paste still works. dispatchModifierKey is exported to pin this, like dispatchPlainKey. - Double-action: the hook now returns early on e.defaultPrevented. useAppHotkeys is on window/capture and deliberately lets a keyframe selection outrank a range on Delete; without this that press deleted the keyframes there AND emptied the range here. - Project scoping: the clipboard scopes itself. Every entry point carries the project it speaks for and a mismatch empties the module, the shape keyframeSlice already uses to discard a request from a previous session. Scoping it inside the module rather than clearing it from the session seam is deliberate: the failure is silent and destructive — a range copied in project A pasted into B is remapped through A's captured sourceRange for an FX node B may not have, and the keystroke is consumed so clip paste never runs — so no future caller should be able to forget the guard. The mark isLastPasteSpan reads is scoped transitively, through the same check. - Session reset: createTimelineResetState clears automationSelection. It is as ephemeral as selectedKeyframes, and a range surviving a project switch can match a same-keyed clip in the new project and redirect a paste through sel.elementKey === paste.elementKey to a stale t0. Five of the six paste fixes above shipped without a test that fails without them, which is how the branch reached review with a comment describing chaining that the code did not do. Each now has one: a second Cmd+V landing after the first, a commit-target mismatch declining, an out-of-clip playhead declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V with CapsLock on. All five fail against this branch's parent. * test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge The failing test probed t=5.1, which sits inside the reshaped transition segment between the new edge (t=5) and the existing point (t=6). When growing past an existing breakpoint, the transition TO that point legitimately reshapes — the edge moved (t=3→t=5) even though the far point (t=6) did not. The real guarantee: all BREAKPOINTS strictly outside the union keep exact (t, v) values. Corrected test to: 1. Verify sample continuity on unaffected side: t=[0,1,1.9] 2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(studio): stretch an automation selection by its edges Add an edge-handle drag to a selection's rect: grabbing within 8px of either edge retimes the selection via the already-landed retimeRange, scaling interior points proportionally and clamping the dragged edge against its partner and the clip's duration. Priority is point-drag > curve-drag > edge-stretch > new-range-select, so a point sitting on an edge still wins the press. Cursor shows col-resize while hovering or dragging a handle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(studio): retime edge-stretch from a fixed points snapshot moveEdge fed retimeRange the live draft on every pointermove while origin.t0/t1 stayed pinned to the drag's start. retimeRange is a relative transform that scales a lane's own current point positions, so repeated pointermoves compounded the scale factor (interior points drift toward the far edge) and could drop points that retimed past the selection's original bound out of the next move's `inner` set entirely. Snapshot lane.points at arm time (armBackgroundGesture) alongside the existing frozen origin, and always retime from that snapshot in moveEdge instead of the live draft. finishEdgeDrag is unchanged: it already just persists the last (now-correct) preview. Adds a regression test asserting a multi-pointermove edge-drag (both edges) lands on the exact same final points as a single-shot drag to the same target — the case that exposed the bug, since the existing suite only ever tested a single move. * fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup - useAutomationSelectionKeyboard: clamp the selection-start paste branch to [0, element.duration - clip.span], same as the playhead branch already does. An unclamped paste near a clip's end could write points past element.duration and leave the resulting selection's edge ungrabbable off the visible lane. - automationClipboard.test.ts: swap the cross-parameter mapping test's target from fx.r.wet (numerically identical to VOLUME_RANGE) to the log-scaled fx.n1.frequency, so the test actually discriminates real unit-space mapping from a linear guess or a verbatim value copy. - automationLaneSelection.ts: drop the lone `!` non-null assertion in decimateEvenly's budget-of-1 branch for a guarded pattern, matching the loop right below it and the repo's no-`!` convention. - .fallowrc.jsonc: remove the two ignoreExports entries for AUTOMATION_SHAPES and simplifyPoints — both are now genuinely consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx). - AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge clamping so a right-click near the bottom/right of the timeline doesn't render the shape/simplify menu partially off-screen. * fix(studio): give edge-stretch the gesture contract the other four follow Seven review findings against this branch, five of which were one defect: edge-stretch was added as a fifth mutually-exclusive gesture on the lane without joining the threshold / live-preview / revert-on-cancel contract the point drag, curve bend, range drag and double-click all obey. Patching them one at a time would have been more code and less coherent, so this makes the stretch structurally parallel to its sibling range drag instead, and extracts it to useAutomationEdgeStretch on the way out — the gestures file had ~60 lines of headroom under the 600-line studio cap, and shaving comments to fit a refactor in is not a plan. - Threshold. A press within the 8px halo of either edge used to persist a no-op commit and push an undo entry that changed nothing (commitDataAttribute has no unchanged-value short-circuit). Worse, it made the pre-existing "click the background to clear the selection" escape unreachable anywhere near an edge. Below 3px of travel — the same threshold the range drag uses — the press now clears the selection and writes nothing at all. - Live preview. moveEdge never fired onRangeSelect and the hook discarded the drag's live position, so the highlight rect and both edge lines stayed pinned at the pre-drag bounds for the whole gesture and snapped into place on release: the user dragged an invisible handle. It now reports bounds on every move, exactly as the marquee drag does and for the same reason. - Revert on cancel. pointercancel means the browser abandoned the gesture; it was routed to the same handler as pointerup, which persisted whatever partial retime it had reached. It now restores the arm-time snapshot through the preview channel — there is nothing persisted to undo — and puts the selection back. A new cancelDrag handler owns that, so a release and an abandonment are no longer the same event. - Lost capture. capturePointer took the capture on e.target, i.e. whichever child the press landed on. A child that unmounts mid-drag takes the capture with it, silently, with no pointercancel — after which edgeDrag stayed non-null and every later button-less pointermove kept retiming and writing. Capture is now taken on the svg, which outlives every gesture on it, and a move reporting no buttons held ends the drag as a cancel. - Hit priority. A breakpoint sitting exactly on the selection's edge used to win the press. Since replaceRange pins an anchor at the union bound and finishEdgeDrag leaves the selection edge at that same time, EVERY range operation — stretch, delete, shape insert — leaves a point exactly on the edge it just created: the second stretch of the same edge resolved to a point-drag, at the one height (on the envelope) where a user naturally grabs it. The feature was not repeatable. An active selection's edge now outranks a point on it; clearing the selection reaches the point again, which is tested. - Clamp order. The dragged edge was bounded against its partner AFTER the 0-floor, so a selection thinner than the minimum width yielded a negative t0, which core's cleanPoint then collapses onto a duplicate t=0 on the serialize round-trip — silent envelope corruption. The floor is now applied last. The minimum width is its own MIN_SELECTION_SEC rather than a borrowed POINT_MERGE_SEC: when two breakpoints are the same breakpoint is a different question from how thin a time selection may get. One finding does not survive: edgeAt's `d0 <= d1` tiebreak was reported as making the t1 edge ungrabbable on a narrow selection, but that comparison IS nearest-wins, and a press right of the midpoint already resolved to t1. The midpoint split here is the same rule written so it is legible rather than inferred, and the test for it is labelled as characterizing behaviour, not fixing it. What was genuinely unreachable inside a narrow halo — starting a fresh range, or clearing the old one without Escape — the threshold above fixes. Also settles what retimeRange does with a breakpoint sitting ON a dragged edge, which was never decided: pointsIn is endpoint-inclusive, so it is interior and travels with the stretch. It has to be, because the commonest stretch of all is grabbing an edge to drag exactly that point outward, and anchoring it would delete it and flatten the span instead. The price is that the retimed point lands on the union's own boundary where a preservation anchor would go, and anchor() stands down within a merge radius — one time cannot hold two values — so the segment leaving the union reshapes. That is the one place replaceRange's outside-never-moves invariant bends, and both halves are now pinned: the exact points and the sampled slope for the on-edge case, and the full two-sided invariant for a selection whose edges are off any breakpoint. The earlier right-side probe at t=5.1 that caught this was deleted during development as inherent; it was reporting the real behaviour. * 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. * fix(studio): pull TimelineLanes under the 600-line cap TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer gestures (resize-start, pointer-down move-arm, click/razor-split) into createClipGestureHandlers — one factory call per rendered clip instead of ~120 lines of inline handler bodies in the render loop. 529 lines now. * fix(studio): split the extracted pointerdown handler under the CRAP threshold Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts concentrated it into two functions fallow flagged (onPointerDown at CRAP 63.6, onResizeStart at 31.6). Split the decision logic (which gesture a pointerdown implies) into a pure resolvePointerDownAction, then split its own intent-blocking check into isIntentBlocked. onResizeStart's guard moved into canStartResize. Every function now scores under 30. * fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the stack removed the last use of the type here without removing the import. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c7c95e2f01
commit
120ea37c2f
@@ -221,6 +221,40 @@ describe("useAutomationSelectionKeyboard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
|
||||||
|
// The playhead branch already clamps to duration - span; the
|
||||||
|
// selection-start branch didn't, so pasting a 2s clip at a selection
|
||||||
|
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
|
||||||
|
// past element.duration — and leave the selection itself out of bounds.
|
||||||
|
clearAutomationClipboard();
|
||||||
|
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
||||||
|
usePlayerStore
|
||||||
|
.getState()
|
||||||
|
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
|
||||||
|
const { onCommit } = setup({});
|
||||||
|
combo("c");
|
||||||
|
expect(readClipboard(null)?.span).toBe(2);
|
||||||
|
|
||||||
|
// A 0.1s-wide selection right near the clip's 6s end.
|
||||||
|
usePlayerStore
|
||||||
|
.getState()
|
||||||
|
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 });
|
||||||
|
combo("v");
|
||||||
|
const written = onCommit.mock.calls.at(-1)?.[0];
|
||||||
|
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
|
||||||
|
for (const t of times) {
|
||||||
|
expect(t).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t).toBeLessThanOrEqual(bgmElement.duration);
|
||||||
|
}
|
||||||
|
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
|
||||||
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
||||||
|
elementKey: "bgm",
|
||||||
|
target: "volume",
|
||||||
|
t0: 4,
|
||||||
|
t1: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
||||||
// selectedElementId says "bgm" but the commit channel is still on the
|
// selectedElementId says "bgm" but the commit channel is still on the
|
||||||
// previously selected clip — writing here would serialize bgm's automation
|
// previously selected clip — writing here would serialize bgm's automation
|
||||||
|
|||||||
@@ -30,11 +30,19 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|||||||
const menuRef = useContextMenuDismiss(onClose);
|
const menuRef = useContextMenuDismiss(onClose);
|
||||||
const row =
|
const row =
|
||||||
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
|
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
|
||||||
|
// Same edge-clamping precedent as TrackGapContextMenu: without it a
|
||||||
|
// right-click near the bottom/right of the timeline renders this menu
|
||||||
|
// partially off-screen.
|
||||||
|
const menuWidth = 140;
|
||||||
|
const menuHeight = AUTOMATION_SHAPES.length * 24 + 32;
|
||||||
|
const overflowY = y + menuHeight - window.innerHeight;
|
||||||
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
||||||
|
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
||||||
style={{ left: x, top: y }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
>
|
>
|
||||||
{AUTOMATION_SHAPES.map((shape) => (
|
{AUTOMATION_SHAPES.map((shape) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ function fire(
|
|||||||
clientX?: number;
|
clientX?: number;
|
||||||
clientY?: number;
|
clientY?: number;
|
||||||
button?: number;
|
button?: number;
|
||||||
|
/** Buttons still held. A move reporting none is how a lost capture shows up. */
|
||||||
|
buttons?: number;
|
||||||
altKey?: boolean;
|
altKey?: boolean;
|
||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -485,6 +487,27 @@ const mount = (automation: HfAutomation, over: Record<string, unknown> = {}) =>
|
|||||||
return { container, svg, props };
|
return { container, svg, props };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `mount`, plus the re-render a real store update causes — the persisted
|
||||||
|
* automation and the new selection coming back down as props. */
|
||||||
|
const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
|
||||||
|
const base = laneProps({ automation, ...over });
|
||||||
|
const props = {
|
||||||
|
...base,
|
||||||
|
onPreview: base.onPreview as ReturnType<typeof vi.fn>,
|
||||||
|
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
|
||||||
|
};
|
||||||
|
const { container, rerender } = renderRerenderable(<TimelineAutomationLane {...props} />);
|
||||||
|
const svg = container.querySelector("svg")!;
|
||||||
|
stubBox(svg, BOX);
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
svg,
|
||||||
|
props,
|
||||||
|
rerender: (next: Record<string, unknown>) =>
|
||||||
|
rerender(<TimelineAutomationLane {...props} {...next} />),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
describe("TimelineAutomationLane modifiers", () => {
|
describe("TimelineAutomationLane modifiers", () => {
|
||||||
it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
|
it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
|
||||||
// `curve` was honoured everywhere it is read — drawn, sampled in preview,
|
// `curve` was honoured everywhere it is read — drawn, sampled in preview,
|
||||||
@@ -686,3 +709,348 @@ describe("TimelineAutomationLane selection menu", () => {
|
|||||||
expect(document.querySelector(".hf-automation-menu")).toBeNull();
|
expect(document.querySelector(".hf-automation-menu")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TimelineAutomationLane stretch", () => {
|
||||||
|
// Most edges here are off any existing point, which is the EASY case. The
|
||||||
|
// normal state after any range operation is the opposite — delete, shape
|
||||||
|
// insert and stretch all leave a breakpoint exactly on the edge they created
|
||||||
|
// — so the priority test below is the one that decides whether the feature is
|
||||||
|
// repeatable, not an edge case.
|
||||||
|
|
||||||
|
/** Press, drag and release the right edge of a stretchable selection — the
|
||||||
|
* shape most of this block's tests share, differing only in where the
|
||||||
|
* drag ends up. */
|
||||||
|
function dragRightEdge(svg: Element, from: number, to: number): void {
|
||||||
|
fire(svg, "pointerdown", at(from, 0.5));
|
||||||
|
fire(svg, "pointermove", at(to, 0.5));
|
||||||
|
fire(svg, "pointerup", at(to, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stretchable: HfAutomation = {
|
||||||
|
version: 1,
|
||||||
|
lanes: [
|
||||||
|
{
|
||||||
|
target: "volume",
|
||||||
|
points: [
|
||||||
|
{ t: 0, v: 1 },
|
||||||
|
{ t: 1, v: 0.5 },
|
||||||
|
{ t: 2, v: 0.8 },
|
||||||
|
{ t: 4, v: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("dragging the right edge retimes the interior and persists on release", () => {
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg, props } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3
|
||||||
|
|
||||||
|
expect(props.onCommit).toHaveBeenCalledTimes(1);
|
||||||
|
const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
|
||||||
|
const points = written.lanes[0]?.points ?? [];
|
||||||
|
// Interior points (t=1, t=2) scale by the new/old span ratio (2.8 / 2 = 1.4).
|
||||||
|
expect(points.some((p) => Math.abs(p.t - 1.2) < 0.01 && p.v === 0.5)).toBe(true);
|
||||||
|
expect(points.some((p) => Math.abs(p.t - 2.6) < 0.01 && p.v === 0.8)).toBe(true);
|
||||||
|
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the selection with the pointer instead of snapping it on release", () => {
|
||||||
|
// The highlight and both edge lines render from the rangeSelection prop, so
|
||||||
|
// a stretch that only reported its bounds on release dragged an invisible
|
||||||
|
// handle: the rect stayed pinned at the pre-drag bounds for the whole
|
||||||
|
// gesture. The marquee drag fires live for exactly this reason.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(svg, "pointermove", at(3, 0.5));
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1));
|
||||||
|
fire(svg, "pointermove", at(3.3, 0.5));
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bare click on an edge clears the selection instead of committing a no-op", () => {
|
||||||
|
// Without a movement threshold this pushed an undo entry that changed
|
||||||
|
// nothing, and — because the halo covers both edges — it also made the
|
||||||
|
// "click the background to clear" escape unreachable near either one.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const onRangeClear = vi.fn();
|
||||||
|
const { svg, props } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
onRangeClear,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(svg, "pointerup", at(2.5, 0.5));
|
||||||
|
expect(props.onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(props.onPreview).not.toHaveBeenCalled();
|
||||||
|
expect(onRangeSelect).not.toHaveBeenCalled();
|
||||||
|
expect(onRangeClear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a press that jitters under the threshold still clears rather than retiming", () => {
|
||||||
|
const onRangeClear = vi.fn();
|
||||||
|
const { svg, props } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeClear,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
// ~2px at 100 px/s: a hand resting on the button, not a drag.
|
||||||
|
fire(svg, "pointermove", at(2.52, 0.5));
|
||||||
|
fire(svg, "pointerup", at(2.52, 0.5));
|
||||||
|
expect(props.onPreview).not.toHaveBeenCalled();
|
||||||
|
expect(props.onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(onRangeClear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts an interrupted stretch rather than persisting the partial retime", () => {
|
||||||
|
// pointercancel means the browser abandoned the gesture. Routing it to the
|
||||||
|
// same handler as pointerup persisted whatever half-drag it had reached,
|
||||||
|
// with no way back other than undo.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg, props } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(svg, "pointermove", at(3.3, 0.5));
|
||||||
|
fire(svg, "pointercancel", at(3.3, 0.5));
|
||||||
|
|
||||||
|
expect(props.onCommit).not.toHaveBeenCalled();
|
||||||
|
// The envelope goes back through the preview channel — nothing to undo —
|
||||||
|
// and the selection returns to the bounds the drag started from.
|
||||||
|
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
|
||||||
|
?.points;
|
||||||
|
expect(reverted).toEqual(stretchable.lanes[0]?.points);
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up a stretch whose pointer capture vanished without a cancel", () => {
|
||||||
|
// A capture taken on a child that unmounts mid-drag is lost silently: no
|
||||||
|
// pointercancel, no pointerup. Every later hover kept retiming and writing.
|
||||||
|
const { svg, props } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(svg, "pointermove", { ...at(3.3, 0.5), buttons: 1 });
|
||||||
|
fire(svg, "pointermove", { ...at(3.4, 0.5), buttons: 0 });
|
||||||
|
const writes = props.onPreview.mock.calls.length;
|
||||||
|
|
||||||
|
fire(svg, "pointermove", { ...at(3.8, 0.5), buttons: 0 });
|
||||||
|
fire(svg, "pointermove", { ...at(1, 0.5), buttons: 0 });
|
||||||
|
expect(props.onPreview.mock.calls.length).toBe(writes);
|
||||||
|
expect(props.onCommit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews the stretch on move without persisting, then commits once on release", () => {
|
||||||
|
const onPreview = vi.fn();
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const { svg } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onPreview,
|
||||||
|
onCommit,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(svg, "pointermove", at(3, 0.5));
|
||||||
|
fire(svg, "pointermove", at(3.3, 0.5));
|
||||||
|
expect(onPreview).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
fire(svg, "pointerup", at(3.3, 0.5));
|
||||||
|
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A breakpoint sitting exactly on the selection's right edge — the state every
|
||||||
|
* range operation leaves behind, and the one a stretch has to be able to grab
|
||||||
|
* a second time. */
|
||||||
|
const pointOnEdge: HfAutomation = {
|
||||||
|
version: 1,
|
||||||
|
lanes: [
|
||||||
|
{
|
||||||
|
target: "volume",
|
||||||
|
points: [
|
||||||
|
{ t: 0, v: 1 },
|
||||||
|
{ t: 1.5, v: 0.5 },
|
||||||
|
{ t: 2, v: 0.8 },
|
||||||
|
{ t: 4, v: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("the selection's edge wins over a point sitting exactly on it", () => {
|
||||||
|
// The regression this pins is the feature not being repeatable: stretching
|
||||||
|
// pins a breakpoint on the edge it just created, so a point-first rule made
|
||||||
|
// the SECOND stretch of that edge a point-drag every time — at the one
|
||||||
|
// height (on the envelope) where the user naturally grabs it.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg } = mount(pointOnEdge, {
|
||||||
|
rangeSelection: { t0: 1, t1: 2 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2, 0.8)); // the point at t=2, which is also the right edge
|
||||||
|
fire(svg, "pointermove", at(3, 0.8));
|
||||||
|
fire(svg, "pointerup", at(3, 0.8));
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(1, expect.closeTo(3, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves that point reachable once the selection is gone", () => {
|
||||||
|
// The escape hatch the rule above depends on: no selection, no handle, so
|
||||||
|
// the point is an ordinary point again.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg, props } = mount(pointOnEdge, { rangeSelection: null, onRangeSelect });
|
||||||
|
fire(svg, "pointerdown", at(2, 0.8));
|
||||||
|
fire(svg, "pointermove", at(3, 0.8));
|
||||||
|
fire(svg, "pointerup", at(3, 0.8));
|
||||||
|
const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
|
||||||
|
const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
|
||||||
|
expect(times).toContain(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stretches the same edge twice in a row", () => {
|
||||||
|
// The whole point of the priority rule, end to end: two stretches of the
|
||||||
|
// right edge, the second grabbing the breakpoint the first one left there.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg, props, rerender } = mountRerenderable(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
dragRightEdge(svg, 2, 2.6);
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1));
|
||||||
|
const afterFirst = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
|
||||||
|
// A breakpoint landed on the new edge, which is what used to disarm it.
|
||||||
|
expect((afterFirst.lanes[0]?.points ?? []).some((p) => Math.abs(p.t - 2.6) < 0.05)).toBe(true);
|
||||||
|
|
||||||
|
// The store comes back with the persisted envelope and the new selection.
|
||||||
|
rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6 } });
|
||||||
|
dragRightEdge(svg, 2.6, 3.4);
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1));
|
||||||
|
expect(props.onCommit).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the far edge grabbable on a selection narrower than its own halos", () => {
|
||||||
|
// A selection this thin — a pasted short span, or any span at low zoom — has
|
||||||
|
// both handles under one press, so which edge a press takes is decided
|
||||||
|
// entirely by the midpoint split. Characterizing it here because nothing
|
||||||
|
// else does: every other test in this block has halos far enough apart that
|
||||||
|
// the rule never comes up.
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 2, t1: 2.08 }, // 8px wide at 100 px/s: both halos overlap
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
fire(svg, "pointerdown", at(2.07, 0.5)); // nearer t1, inside t0's halo too
|
||||||
|
fire(svg, "pointermove", at(3, 0.5));
|
||||||
|
fire(svg, "pointerup", at(3, 0.5));
|
||||||
|
// t0 stayed put and t1 moved out: the press resolved to the right edge.
|
||||||
|
expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the dragged edge so it cannot cross its partner", () => {
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
|
||||||
|
const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
|
||||||
|
expect(t1).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the dragged edge to the lane's own duration", () => {
|
||||||
|
const onRangeSelect = vi.fn();
|
||||||
|
const { svg } = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onRangeSelect,
|
||||||
|
});
|
||||||
|
dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
|
||||||
|
const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
|
||||||
|
expect(t1).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retimes identically whether the right edge arrives in one move or several", () => {
|
||||||
|
// moveEdge must always retime from the points snapshotted at arm time,
|
||||||
|
// never from the live draft — retimeRange is a RELATIVE transform (it
|
||||||
|
// scales the lane's OWN current point positions by newSpan/oldSpan), so
|
||||||
|
// feeding it the live draft on every pointermove compounds the scale
|
||||||
|
// factor instead of applying it once. A real drag fires dozens of moves;
|
||||||
|
// this asserts the FINAL preview is identical regardless of how many.
|
||||||
|
const onPreviewSingle = vi.fn();
|
||||||
|
const single = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onPreview: onPreviewSingle,
|
||||||
|
});
|
||||||
|
fire(single.svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
fire(single.svg, "pointermove", at(3.3, 0.5));
|
||||||
|
const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
|
||||||
|
?.lanes[0]?.points;
|
||||||
|
expect(singleShot).toBeDefined();
|
||||||
|
|
||||||
|
const onPreviewMulti = vi.fn();
|
||||||
|
const multi = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 0.5, t1: 2.5 },
|
||||||
|
onPreview: onPreviewMulti,
|
||||||
|
});
|
||||||
|
fire(multi.svg, "pointerdown", at(2.5, 0.5));
|
||||||
|
// At least 3 separate pointermoves crossing the same span, not one jump.
|
||||||
|
fire(multi.svg, "pointermove", at(2.7, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(2.9, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(3.1, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(3.3, 0.5));
|
||||||
|
const afterFourMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
|
||||||
|
?.lanes[0]?.points;
|
||||||
|
expect(afterFourMoves).toBeDefined();
|
||||||
|
|
||||||
|
// Both interior points (t=1, t=2) land exactly where a single-shot retime
|
||||||
|
// puts them — not compounded, and not dropped.
|
||||||
|
expect(afterFourMoves).toEqual(singleShot);
|
||||||
|
expect(afterFourMoves?.length).toBe(6);
|
||||||
|
expect(afterFourMoves?.some((p) => Math.abs(p.t - 1.2) < 0.001 && p.v === 0.5)).toBe(true);
|
||||||
|
expect(afterFourMoves?.some((p) => Math.abs(p.t - 2.6) < 0.001 && p.v === 0.8)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retimes identically whether the left edge arrives in one move or several", () => {
|
||||||
|
const onPreviewSingle = vi.fn();
|
||||||
|
const single = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 1, t1: 3 },
|
||||||
|
onPreview: onPreviewSingle,
|
||||||
|
});
|
||||||
|
fire(single.svg, "pointerdown", at(1, 0.5));
|
||||||
|
fire(single.svg, "pointermove", at(0.2, 0.5));
|
||||||
|
const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
|
||||||
|
?.lanes[0]?.points;
|
||||||
|
expect(singleShot).toBeDefined();
|
||||||
|
|
||||||
|
const onPreviewMulti = vi.fn();
|
||||||
|
const multi = mount(stretchable, {
|
||||||
|
rangeSelection: { t0: 1, t1: 3 },
|
||||||
|
onPreview: onPreviewMulti,
|
||||||
|
});
|
||||||
|
fire(multi.svg, "pointerdown", at(1, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(0.7, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(0.4, 0.5));
|
||||||
|
fire(multi.svg, "pointermove", at(0.2, 0.5));
|
||||||
|
const afterThreeMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
|
||||||
|
?.lanes[0]?.points;
|
||||||
|
expect(afterThreeMoves).toBeDefined();
|
||||||
|
expect(afterThreeMoves).toEqual(singleShot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a resize cursor when hovering an edge with nothing else live", () => {
|
||||||
|
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
|
||||||
|
fire(svg, "pointermove", at(3, 0.5)); // near the right edge, nothing pressed
|
||||||
|
expect(svg.style.cursor).toBe("col-resize");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the normal cursor away from the selection's edges", () => {
|
||||||
|
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
|
||||||
|
fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
|
||||||
|
expect(svg.style.cursor).not.toBe("col-resize");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ import { getTimelineLaneTop } from "./timelineLayout";
|
|||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
||||||
|
|
||||||
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
|
/** Pointer shape: a stretch handle wins over everything else it might also
|
||||||
function laneCursor(readOnly: boolean | undefined, dragging: boolean): string {
|
* sit above, a read-only lane can only be selected, a live one edited. */
|
||||||
|
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
|
||||||
|
if (stretching) return "col-resize";
|
||||||
if (readOnly) return "pointer";
|
if (readOnly) return "pointer";
|
||||||
return dragging ? "grabbing" : "crosshair";
|
return dragging ? "grabbing" : "crosshair";
|
||||||
}
|
}
|
||||||
@@ -204,8 +206,9 @@ export function TimelineAutomationLane({
|
|||||||
onRangeSelect,
|
onRangeSelect,
|
||||||
onRangeClear,
|
onRangeClear,
|
||||||
duration,
|
duration,
|
||||||
|
rangeSelection,
|
||||||
});
|
});
|
||||||
const { dragIndex, curveIndex, hint, editing } = gestures;
|
const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
|
||||||
|
|
||||||
const removeAt = useCallback(
|
const removeAt = useCallback(
|
||||||
(index: number): void => {
|
(index: number): void => {
|
||||||
@@ -285,7 +288,11 @@ export function TimelineAutomationLane({
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: widthPx + PAD_X * 2,
|
width: widthPx + PAD_X * 2,
|
||||||
height: h,
|
height: h,
|
||||||
cursor: laneCursor(readOnly, dragIndex !== null || curveIndex !== null),
|
cursor: laneCursor(
|
||||||
|
readOnly,
|
||||||
|
dragIndex !== null || curveIndex !== null,
|
||||||
|
edgeDrag !== null || edgeHover,
|
||||||
|
),
|
||||||
opacity: readOnly ? 0.55 : 1,
|
opacity: readOnly ? 0.55 : 1,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
@@ -294,7 +301,7 @@ export function TimelineAutomationLane({
|
|||||||
onPointerDown={gestures.onPointerDown}
|
onPointerDown={gestures.onPointerDown}
|
||||||
onPointerMove={gestures.onPointerMove}
|
onPointerMove={gestures.onPointerMove}
|
||||||
onPointerUp={gestures.endDrag}
|
onPointerUp={gestures.endDrag}
|
||||||
onPointerCancel={gestures.endDrag}
|
onPointerCancel={gestures.cancelDrag}
|
||||||
onDoubleClick={gestures.onDoubleClick}
|
onDoubleClick={gestures.onDoubleClick}
|
||||||
onContextMenu={onSvgContextMenu}
|
onContextMenu={onSvgContextMenu}
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -39,19 +39,34 @@ describe("automation clipboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps values through unit space onto a different parameter", () => {
|
it("maps values through unit space onto a different parameter", () => {
|
||||||
const wet = resolveAutomationRange("fx.r.wet", {
|
// fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
|
||||||
|
// linear unit math and a literal copy of the source value would both
|
||||||
|
// read as a passing test on a range that happens to be numerically
|
||||||
|
// identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
|
||||||
|
// genuinely log for the test to discriminate real unit-space mapping.
|
||||||
|
const frequency = resolveAutomationRange("fx.n1.frequency", {
|
||||||
version: 1,
|
version: 1,
|
||||||
nodes: [{ type: "reverb", id: "r", params: {} }],
|
nodes: [{ type: "lowpass", id: "n1", params: {} }],
|
||||||
});
|
});
|
||||||
expect(wet).toBeTruthy();
|
expect(frequency).toBeTruthy();
|
||||||
if (!wet) return;
|
if (!frequency) return;
|
||||||
|
expect(frequency.scale).toBe("log");
|
||||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||||
const entry = readClipboard("project-a");
|
const entry = readClipboard("project-a");
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const pts = pastePoints(entry, wet, 0);
|
const pts = pastePoints(entry, frequency, 0);
|
||||||
// volume 1 (unit 1) → wet max; volume 0.25 (unit 0.25) → a quarter up wet's axis
|
// volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
|
||||||
expect(pts[0]?.v).toBeCloseTo(wet.max, 5);
|
// up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
|
||||||
expect(pts[1]?.v).toBeCloseTo(wet.min + 0.25 * (wet.max - wet.min), 5);
|
// NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
|
||||||
|
// literal copy of 0.25.
|
||||||
|
expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
|
||||||
|
const expectedLog = Math.exp(
|
||||||
|
Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
|
||||||
|
);
|
||||||
|
const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
|
||||||
|
expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
|
||||||
|
expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
|
||||||
|
expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads null when nothing was copied", () => {
|
it("reads null when nothing was copied", () => {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Pointer capture for a lane gesture. Its own module only because both gesture
|
||||||
|
* hooks need it and `automationLaneGeometry` is deliberately DOM-free.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the rest of a gesture even if the pointer leaves the lane. Without it a
|
||||||
|
* drag that strays outside the svg stops sending moves and the point sticks.
|
||||||
|
*
|
||||||
|
* Captured on the svg the handler is bound to, NOT on `e.target`: the target is
|
||||||
|
* whatever child the press happened to land on — a breakpoint circle, a
|
||||||
|
* selection line — and a child that unmounts mid-drag takes the capture with it,
|
||||||
|
* silently and with no `pointercancel` to notice it by. The svg outlives every
|
||||||
|
* gesture on it.
|
||||||
|
*
|
||||||
|
* Structurally typed so this stays testable without React's event types.
|
||||||
|
*/
|
||||||
|
export function capturePointer(e: { currentTarget: Element; pointerId: number }): void {
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { pointsIn, replaceRange } from "./automationLaneSelection";
|
import { pointsIn, replaceRange, retimeRange } from "./automationLaneSelection";
|
||||||
import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
||||||
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
||||||
|
|
||||||
@@ -94,3 +94,90 @@ describe("replaceRange", () => {
|
|||||||
expect(Math.max(...innerTimes)).toBeGreaterThan(3.0);
|
expect(Math.max(...innerTimes)).toBeGreaterThan(3.0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("retimeRange", () => {
|
||||||
|
it("scales interior points proportionally into the new span", () => {
|
||||||
|
const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
|
||||||
|
const moved = pts.find((p) => p.v === 0.4); // the t=3 point
|
||||||
|
expect(moved?.t).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the envelope outside the union of old and new spans", () => {
|
||||||
|
const before: HfAutomationLane = { target: "volume", points: ramp.points };
|
||||||
|
const after: HfAutomationLane = {
|
||||||
|
target: "volume",
|
||||||
|
points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }),
|
||||||
|
};
|
||||||
|
// Nothing to the left of t0=2 moved (newT0 === t0 here), so sampled
|
||||||
|
// continuity holds all the way up to the edited region.
|
||||||
|
for (const t of [0, 1, 1.9]) {
|
||||||
|
expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
|
||||||
|
sampleAutomationLane(before, t, "linear"),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The next real breakpoint past the edited region keeps its own exact value.
|
||||||
|
const farPoint = after.points.find((p) => p.t === 6);
|
||||||
|
expect(farPoint).toEqual({ t: 6, v: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the envelope on BOTH sides when no breakpoint sits on the moved edge", () => {
|
||||||
|
// The right side is where the invariant is worth asserting — `newT0 === t0`
|
||||||
|
// makes the left side of the test above trivially true, and an earlier
|
||||||
|
// right-side probe at t=5.1 was DELETED as inherent when it was reporting
|
||||||
|
// the real behaviour below. With the selection's edges off any breakpoint,
|
||||||
|
// the guarantee holds exactly, in both directions.
|
||||||
|
const before: HfAutomationLane = { target: "volume", points: ramp.points };
|
||||||
|
const after: HfAutomationLane = {
|
||||||
|
target: "volume",
|
||||||
|
points: retimeRange({
|
||||||
|
lane: ramp,
|
||||||
|
range: VOLUME_RANGE,
|
||||||
|
t0: 2.2,
|
||||||
|
t1: 2.9,
|
||||||
|
newT0: 2.2,
|
||||||
|
newT1: 4,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
for (const t of [0, 1, 2, 4.5, 5, 5.5, 6]) {
|
||||||
|
expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
|
||||||
|
sampleAutomationLane(before, t, "linear"),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a breakpoint sitting exactly on the dragged edge, reshaping the segment past it", () => {
|
||||||
|
// The design decision this pins, because it is not free either way.
|
||||||
|
// `pointsIn` is endpoint-inclusive, so a breakpoint ON the edge is interior
|
||||||
|
// and travels with the stretch. It has to: every range operation leaves a
|
||||||
|
// breakpoint exactly on the edge it created, so treating that point as an
|
||||||
|
// anchor instead would make the commonest stretch — grabbing the edge to
|
||||||
|
// drag that very point outward — delete it and flatten the span.
|
||||||
|
//
|
||||||
|
// The cost is that the retimed point lands ON the union's boundary, where a
|
||||||
|
// preservation anchor would also go, and `anchor()` stands down within a
|
||||||
|
// merge radius. Two different values cannot occupy one time; the segment
|
||||||
|
// leaving the union reshapes, which is the "envelope outside the selection
|
||||||
|
// never moves" invariant bending exactly here and nowhere else.
|
||||||
|
const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
|
||||||
|
expect(pts).toEqual([
|
||||||
|
{ t: 0, v: 1 },
|
||||||
|
{ t: 2, v: 0.6 },
|
||||||
|
{ t: 5, v: 0.4 }, // the t=3 point, retimed onto the new edge
|
||||||
|
{ t: 6, v: 0 },
|
||||||
|
]);
|
||||||
|
// The old 3→6 segment sloped -0.133/s; the new 5→6 slopes -0.4/s, so the
|
||||||
|
// envelope past the union genuinely moves. Asserted, not tolerated: if this
|
||||||
|
// number changes, the decision above changed with it.
|
||||||
|
const after: HfAutomationLane = { target: "volume", points: pts };
|
||||||
|
expect(sampleAutomationLane(after, 5.5, "linear")).toBeCloseTo(0.2, 5);
|
||||||
|
expect(sampleAutomationLane(ramp, 5.5, "linear")).toBeCloseTo(0.0667, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a degenerate span", () => {
|
||||||
|
expect(
|
||||||
|
retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 4, newT1: 4 }),
|
||||||
|
).toEqual(ramp.points);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ function anchor(
|
|||||||
function decimateEvenly<T>(items: readonly T[], budget: number): T[] {
|
function decimateEvenly<T>(items: readonly T[], budget: number): T[] {
|
||||||
if (budget <= 0) return [];
|
if (budget <= 0) return [];
|
||||||
if (items.length <= budget) return [...items];
|
if (items.length <= budget) return [...items];
|
||||||
if (budget === 1) return [items[0]!];
|
if (budget === 1) {
|
||||||
|
const item = items[0];
|
||||||
|
return item ? [item] : [];
|
||||||
|
}
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
const step = (items.length - 1) / (budget - 1);
|
const step = (items.length - 1) / (budget - 1);
|
||||||
for (let i = 0; i < budget; i += 1) {
|
for (let i = 0; i < budget; i += 1) {
|
||||||
@@ -72,3 +75,43 @@ export function replaceRange(input: {
|
|||||||
const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget);
|
const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget);
|
||||||
return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
|
return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retime a selection: interior points scale proportionally into the new span,
|
||||||
|
* then replaceRange runs over the UNION of old and new spans — growing eats
|
||||||
|
* whatever it covers, shrinking pins anchors where the envelope re-enters.
|
||||||
|
*
|
||||||
|
* Interior is `pointsIn`, so a breakpoint sitting exactly ON an edge travels
|
||||||
|
* with the stretch. Deliberate: every range operation leaves a breakpoint on the
|
||||||
|
* edge it created, so treating that point as a fixed anchor would make the
|
||||||
|
* commonest stretch of all — grabbing the edge to drag that point outward —
|
||||||
|
* delete it instead. The price is that such a point lands on the union's own
|
||||||
|
* boundary, where `anchor` then stands down (one time cannot hold two values),
|
||||||
|
* so the segment leaving the union reshapes. That is the ONE place
|
||||||
|
* `replaceRange`'s outside-never-moves invariant bends, and it is pinned by name
|
||||||
|
* in automationLaneSelection.test.ts.
|
||||||
|
*/
|
||||||
|
export function retimeRange(input: {
|
||||||
|
lane: HfAutomationLane;
|
||||||
|
range: AutomationRange;
|
||||||
|
t0: number;
|
||||||
|
t1: number;
|
||||||
|
newT0: number;
|
||||||
|
newT1: number;
|
||||||
|
}): HfAutomationPoint[] {
|
||||||
|
const { lane, range, t0, t1, newT0, newT1 } = input;
|
||||||
|
const oldSpan = t1 - t0;
|
||||||
|
const newSpan = newT1 - newT0;
|
||||||
|
if (oldSpan <= 0 || newSpan <= 0) return lane.points;
|
||||||
|
const inner = pointsIn(lane, t0, t1).map((p) => ({
|
||||||
|
...p,
|
||||||
|
t: newT0 + ((p.t - t0) * newSpan) / oldSpan,
|
||||||
|
}));
|
||||||
|
return replaceRange({
|
||||||
|
lane,
|
||||||
|
range,
|
||||||
|
t0: Math.min(t0, newT0),
|
||||||
|
t1: Math.max(t1, newT1),
|
||||||
|
inner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clampEdge } from "./useAutomationEdgeStretch";
|
||||||
|
|
||||||
|
describe("clampEdge", () => {
|
||||||
|
it("keeps the dragged edge inside the clip", () => {
|
||||||
|
expect(clampEdge("t0", -5, { t0: 1, t1: 3 }, 4)).toBe(0);
|
||||||
|
expect(clampEdge("t1", 99, { t0: 1, t1: 3 }, 4)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dragged edge clear of its partner", () => {
|
||||||
|
expect(clampEdge("t0", 3.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(2.98, 5);
|
||||||
|
expect(clampEdge("t1", 0.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(1.02, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never yields a negative time, even when the partner bound is itself below zero", () => {
|
||||||
|
// A selection thinner than the minimum width has no legal t0 at all. Bounding
|
||||||
|
// against the partner AFTER the 0-floor returned that illegal value, and
|
||||||
|
// core's cleanPoint collapses a negative time onto a duplicate t=0 on the
|
||||||
|
// next serialize round-trip — silent envelope corruption, not a visible bug.
|
||||||
|
expect(clampEdge("t0", -1, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
|
||||||
|
expect(clampEdge("t0", 0.008, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never yields a time past the clip when the partner bound is past it", () => {
|
||||||
|
expect(clampEdge("t1", 99, { t0: 3.995, t1: 4 }, 4)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Stretching an automation selection by one of its edges.
|
||||||
|
*
|
||||||
|
* Its own module because edge-stretch is a fifth mutually-exclusive gesture on a
|
||||||
|
* lane that already sits near the studio's file ceiling, and because it has to
|
||||||
|
* follow the same three-part contract the other gestures do — a movement
|
||||||
|
* threshold before anything is written, a live preview the user can see, and a
|
||||||
|
* revert when the browser abandons the gesture. It was first written without
|
||||||
|
* them, and every one of its bugs came from that: a bare click near an edge
|
||||||
|
* pushed an undo entry that changed nothing, the highlight stayed frozen at the
|
||||||
|
* pre-drag bounds so the handle was invisible while it moved, and a
|
||||||
|
* `pointercancel` persisted whatever partial retime it had reached.
|
||||||
|
*
|
||||||
|
* `retimeRange` is a RELATIVE transform — it scales a lane's own current point
|
||||||
|
* positions by newSpan/oldSpan — so it must always run against the snapshot
|
||||||
|
* taken when the drag armed, never the live draft, or the scale factor compounds
|
||||||
|
* on every pointermove.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import type {
|
||||||
|
AutomationRange,
|
||||||
|
HfAutomationLane,
|
||||||
|
HfAutomationPoint,
|
||||||
|
} from "@hyperframes/core/audio-automation";
|
||||||
|
import { capturePointer } from "./automationLanePointer";
|
||||||
|
import { retimeRange } from "./automationLaneSelection";
|
||||||
|
|
||||||
|
/** Hit radius for grabbing a selection's edge, in screen px — independent of a
|
||||||
|
* point's own grab radius so the two zones can be reasoned about on their own. */
|
||||||
|
const EDGE_GRAB_PX = 8;
|
||||||
|
|
||||||
|
/** Screen px a press has to travel before it counts as a stretch rather than a
|
||||||
|
* click. The sibling range-drag uses the same 3px, and for the same reason:
|
||||||
|
* below it, a press is the "clear the selection" escape, and writing anything
|
||||||
|
* would put a no-op entry in undo. */
|
||||||
|
const EDGE_DRAG_PX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowest selection a stretch can leave behind, in clip seconds. Its own
|
||||||
|
* constant rather than a borrowed `POINT_MERGE_SEC`: that one is about when two
|
||||||
|
* breakpoints are the same breakpoint, which is a different question from how
|
||||||
|
* thin a time selection may get and still be grabbable.
|
||||||
|
*/
|
||||||
|
const MIN_SELECTION_SEC = 0.02;
|
||||||
|
|
||||||
|
export interface UseAutomationEdgeStretchInput {
|
||||||
|
getBox(): DOMRect | null;
|
||||||
|
lane: HfAutomationLane;
|
||||||
|
range: AutomationRange;
|
||||||
|
/** Pointer position as a clip-local time and a parameter value. */
|
||||||
|
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
||||||
|
xOf(t: number): number;
|
||||||
|
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
||||||
|
/** Clamp bound for the dragged edge. */
|
||||||
|
duration: number;
|
||||||
|
readOnly?: boolean | undefined;
|
||||||
|
/** Active selection on this lane, so its edges have something to grab. */
|
||||||
|
rangeSelection?: { t0: number; t1: number } | null | undefined;
|
||||||
|
onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
|
||||||
|
onRangeClear?: (() => void) | undefined;
|
||||||
|
/** Value readout owned by the lane's gesture hook. */
|
||||||
|
onHint(text: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAutomationEdgeStretchResult {
|
||||||
|
/** Edge being stretched, for the cursor. Null when no stretch is live. */
|
||||||
|
edge: "t0" | "t1" | null;
|
||||||
|
/** Pointer sits over a handle with no gesture live — the col-resize hint. */
|
||||||
|
hover: boolean;
|
||||||
|
/** Take the press as a stretch, or decline it so another gesture can have it. */
|
||||||
|
arm(e: ReactPointerEvent<SVGSVGElement>): boolean;
|
||||||
|
move(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
|
/** Pointer released: persist the stretch, or clear the selection when the
|
||||||
|
* press never travelled far enough to be one. */
|
||||||
|
finish(): void;
|
||||||
|
/** The browser abandoned the gesture: put the envelope and the selection back
|
||||||
|
* the way they were, with nothing persisted. */
|
||||||
|
cancel(): void;
|
||||||
|
updateHover(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dragged edge, clamped inside the clip AND clear of its partner. The
|
||||||
|
* 0/duration bound is applied LAST so a selection thinner than
|
||||||
|
* `MIN_SELECTION_SEC` can never push its own t0 negative — core's `cleanPoint`
|
||||||
|
* collapses negative times onto a duplicate t=0 on the next serialize.
|
||||||
|
* Exported for that ordering's own test: reaching it through the pointer needs
|
||||||
|
* a selection so thin that no drag can clear the movement threshold. */
|
||||||
|
export function clampEdge(
|
||||||
|
edge: "t0" | "t1",
|
||||||
|
raw: number,
|
||||||
|
origin: { t0: number; t1: number },
|
||||||
|
duration: number,
|
||||||
|
): number {
|
||||||
|
if (edge === "t0") {
|
||||||
|
return Math.max(0, Math.min(raw, origin.t1 - MIN_SELECTION_SEC));
|
||||||
|
}
|
||||||
|
return Math.min(duration, Math.max(raw, origin.t0 + MIN_SELECTION_SEC));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomationEdgeStretch({
|
||||||
|
getBox,
|
||||||
|
lane,
|
||||||
|
range,
|
||||||
|
pointAt,
|
||||||
|
xOf,
|
||||||
|
commitPoints,
|
||||||
|
duration,
|
||||||
|
readOnly,
|
||||||
|
rangeSelection,
|
||||||
|
onRangeSelect,
|
||||||
|
onRangeClear,
|
||||||
|
onHint,
|
||||||
|
}: UseAutomationEdgeStretchInput): UseAutomationEdgeStretchResult {
|
||||||
|
/** The live stretch: which edge, the selection it started from (fixed, as the
|
||||||
|
* retime's untouched anchor), the edge's own live position, and the lane's
|
||||||
|
* points as they stood at arm time. */
|
||||||
|
const [drag, setDrag] = useState<{
|
||||||
|
edge: "t0" | "t1";
|
||||||
|
origin: { t0: number; t1: number };
|
||||||
|
current: number;
|
||||||
|
points: HfAutomationPoint[];
|
||||||
|
} | null>(null);
|
||||||
|
/** Whether the drag has travelled far enough to write anything at all. */
|
||||||
|
const crossed = useRef(false);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which edge of the active selection is within grab range of the pointer's
|
||||||
|
* screen x, over the full lane height — the handle spans the rect.
|
||||||
|
*
|
||||||
|
* A selection narrower than two halos has both edges under one press. The
|
||||||
|
* midpoint split says which one wins — the same rule a nearest-distance
|
||||||
|
* comparison expresses, written so that is legible rather than inferred, since
|
||||||
|
* on a narrow selection it is the ONLY thing deciding the gesture. Pressing a
|
||||||
|
* handle without moving clears the selection (see `finish`), which is what
|
||||||
|
* gets a user out of a halo too small to aim inside.
|
||||||
|
*/
|
||||||
|
const edgeAt = useCallback(
|
||||||
|
(clientX: number): "t0" | "t1" | null => {
|
||||||
|
if (!rangeSelection) return null;
|
||||||
|
const box = getBox();
|
||||||
|
if (!box) return null;
|
||||||
|
const px = clientX - box.left;
|
||||||
|
const x0 = xOf(rangeSelection.t0);
|
||||||
|
const x1 = xOf(rangeSelection.t1);
|
||||||
|
const near0 = Math.abs(x0 - px) <= EDGE_GRAB_PX;
|
||||||
|
const near1 = Math.abs(x1 - px) <= EDGE_GRAB_PX;
|
||||||
|
if (near0 && near1) return px <= (x0 + x1) / 2 ? "t0" : "t1";
|
||||||
|
if (near0) return "t0";
|
||||||
|
return near1 ? "t1" : null;
|
||||||
|
},
|
||||||
|
[rangeSelection, getBox, xOf],
|
||||||
|
);
|
||||||
|
|
||||||
|
const arm = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): boolean => {
|
||||||
|
if (readOnly || !rangeSelection) return false;
|
||||||
|
const edge = edgeAt(e.clientX);
|
||||||
|
if (!edge) return false;
|
||||||
|
e.preventDefault();
|
||||||
|
capturePointer(e);
|
||||||
|
setHover(false);
|
||||||
|
crossed.current = false;
|
||||||
|
setDrag({
|
||||||
|
edge,
|
||||||
|
origin: rangeSelection,
|
||||||
|
current: edge === "t0" ? rangeSelection.t0 : rangeSelection.t1,
|
||||||
|
points: lane.points,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[readOnly, rangeSelection, edgeAt, lane],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Preview the stretch: the partner edge stays put as the retime's anchor, and
|
||||||
|
* the selection itself follows the pointer so the handle being dragged is
|
||||||
|
* visible — the same live `onRangeSelect` the marquee drag fires. */
|
||||||
|
const move = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (drag === null) return;
|
||||||
|
const { edge, origin, points } = drag;
|
||||||
|
const current = clampEdge(edge, pointAt(e.clientX, e.clientY).t, origin, duration);
|
||||||
|
setDrag({ edge, origin, current, points });
|
||||||
|
if (!crossed.current) {
|
||||||
|
const from = edge === "t0" ? origin.t0 : origin.t1;
|
||||||
|
if (Math.abs(xOf(current) - xOf(from)) <= EDGE_DRAG_PX) return;
|
||||||
|
crossed.current = true;
|
||||||
|
}
|
||||||
|
const newT0 = edge === "t0" ? current : origin.t0;
|
||||||
|
const newT1 = edge === "t1" ? current : origin.t1;
|
||||||
|
onHint(`${newT0.toFixed(2)}s → ${newT1.toFixed(2)}s`);
|
||||||
|
onRangeSelect?.(newT0, newT1);
|
||||||
|
commitPoints(
|
||||||
|
retimeRange({
|
||||||
|
lane: { target: lane.target, points },
|
||||||
|
range,
|
||||||
|
t0: origin.t0,
|
||||||
|
t1: origin.t1,
|
||||||
|
newT0,
|
||||||
|
newT1,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[drag, pointAt, duration, xOf, onHint, onRangeSelect, commitPoints, lane.target, range],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finish = useCallback((): void => {
|
||||||
|
if (drag === null) return;
|
||||||
|
const { edge, origin, current } = drag;
|
||||||
|
setDrag(null);
|
||||||
|
onHint(null);
|
||||||
|
// A press that never travelled is the "clear the selection" click, exactly
|
||||||
|
// as it is anywhere else on the background. Persisting here instead pushed
|
||||||
|
// an undo entry that changed nothing AND made that escape unreachable
|
||||||
|
// within a halo of either edge.
|
||||||
|
if (!crossed.current) {
|
||||||
|
onRangeClear?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crossed.current = false;
|
||||||
|
commitPoints(lane.points, true);
|
||||||
|
onRangeSelect?.(edge === "t0" ? current : origin.t0, edge === "t1" ? current : origin.t1);
|
||||||
|
}, [drag, onHint, onRangeClear, commitPoints, lane, onRangeSelect]);
|
||||||
|
|
||||||
|
const cancel = useCallback((): void => {
|
||||||
|
if (drag === null) return;
|
||||||
|
const { origin, points } = drag;
|
||||||
|
setDrag(null);
|
||||||
|
onHint(null);
|
||||||
|
if (!crossed.current) return;
|
||||||
|
crossed.current = false;
|
||||||
|
// A live write is a preview, so putting the snapshot back through the same
|
||||||
|
// channel is the whole revert — there is nothing persisted to undo.
|
||||||
|
commitPoints(points, false);
|
||||||
|
onRangeSelect?.(origin.t0, origin.t1);
|
||||||
|
}, [drag, onHint, commitPoints, onRangeSelect]);
|
||||||
|
|
||||||
|
const updateHover = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (!readOnly) setHover(edgeAt(e.clientX) !== null);
|
||||||
|
},
|
||||||
|
[readOnly, edgeAt],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { edge: drag?.edge ?? null, hover, arm, move, finish, cancel, updateHover };
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Its own hook because the lane component sits at the studio's file ceiling and
|
* Its own hook because the lane component sits at the studio's file ceiling and
|
||||||
* because these are the parts worth testing on their own: which of a press,
|
* because these are the parts worth testing on their own: which of a press,
|
||||||
* a drag and a modifier resolves to moving a point, bending a segment, or
|
* a drag and a modifier resolves to moving a point, bending a segment,
|
||||||
* nothing at all.
|
* stretching a selection's edge, or nothing at all.
|
||||||
*
|
*
|
||||||
* Modifiers follow Ableton's, since that is the muscle memory an automation lane
|
* Modifiers follow Ableton's, since that is the muscle memory an automation lane
|
||||||
* inherits: Shift locks a drag to one axis and fines the value down, Alt over a
|
* inherits: Shift locks a drag to one axis and fines the value down, Alt over a
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
POINT_MERGE_SEC,
|
POINT_MERGE_SEC,
|
||||||
snapLaneTime,
|
snapLaneTime,
|
||||||
} from "./automationLaneGeometry";
|
} from "./automationLaneGeometry";
|
||||||
|
import { capturePointer } from "./automationLanePointer";
|
||||||
|
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
|
||||||
|
|
||||||
/** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds
|
/** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds
|
||||||
* wide, where a generous radius makes a point unplaceable between two beats. */
|
* wide, where a generous radius makes a point unplaceable between two beats. */
|
||||||
@@ -31,15 +33,6 @@ function originOf(point: HfAutomationLane["points"][number] | undefined): { t: n
|
|||||||
return point ? { t: point.t, v: point.v } : { t: 0, v: 0 };
|
return point ? { t: point.t, v: point.v } : { t: 0, v: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep the rest of the gesture even if the pointer leaves the lane. Without it a
|
|
||||||
* drag that strays outside the svg stops sending moves and the point sticks.
|
|
||||||
*/
|
|
||||||
function capturePointer(e: ReactPointerEvent<SVGSVGElement>): void {
|
|
||||||
const target = e.target;
|
|
||||||
if (target instanceof Element) target.setPointerCapture?.(e.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseAutomationLaneGesturesInput {
|
export interface UseAutomationLaneGesturesInput {
|
||||||
/** The lane's box on screen. A getter, not the ref: the hook only ever needs
|
/** The lane's box on screen. A getter, not the ref: the hook only ever needs
|
||||||
* the rectangle, and a ref read inside a callback is a lint the rule is right
|
* the rectangle, and a ref read inside a callback is a lint the rule is right
|
||||||
@@ -60,6 +53,8 @@ export interface UseAutomationLaneGesturesInput {
|
|||||||
onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
|
onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
|
||||||
onRangeClear?: (() => void) | undefined;
|
onRangeClear?: (() => void) | undefined;
|
||||||
duration: number; // clamp bound for range endpoints
|
duration: number; // clamp bound for range endpoints
|
||||||
|
/** Active selection on this lane, so its edges have something to grab. */
|
||||||
|
rangeSelection?: { t0: number; t1: number } | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseAutomationLaneGesturesResult {
|
export interface UseAutomationLaneGesturesResult {
|
||||||
@@ -67,6 +62,11 @@ export interface UseAutomationLaneGesturesResult {
|
|||||||
dragIndex: number | null;
|
dragIndex: number | null;
|
||||||
/** Segment being bent, identified by the point that owns its curve. */
|
/** Segment being bent, identified by the point that owns its curve. */
|
||||||
curveIndex: number | null;
|
curveIndex: number | null;
|
||||||
|
/** Edge being stretched, for the cursor. */
|
||||||
|
edgeDrag: "t0" | "t1" | null;
|
||||||
|
/** Whether the pointer sits over a stretch handle with no gesture live —
|
||||||
|
* the col-resize cursor hint before a press commits to the drag. */
|
||||||
|
edgeHover: boolean;
|
||||||
/** Value readout to show while a gesture is live. */
|
/** Value readout to show while a gesture is live. */
|
||||||
hint: string | null;
|
hint: string | null;
|
||||||
hitIndex(clientX: number, clientY: number): number | null;
|
hitIndex(clientX: number, clientY: number): number | null;
|
||||||
@@ -74,6 +74,9 @@ export interface UseAutomationLaneGesturesResult {
|
|||||||
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
|
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
|
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
endDrag(e: ReactPointerEvent<SVGSVGElement>): void;
|
endDrag(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
|
/** The browser took the gesture away (`pointercancel`): a stretch reverts
|
||||||
|
* rather than persisting whatever partial retime it had reached. */
|
||||||
|
cancelDrag(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
/** Adds a point, opens the value field on one, or straightens a segment. */
|
/** Adds a point, opens the value field on one, or straightens a segment. */
|
||||||
onDoubleClick(e: ReactPointerEvent<SVGSVGElement>): void;
|
onDoubleClick(e: ReactPointerEvent<SVGSVGElement>): void;
|
||||||
/** The point whose value is being typed, and the text so far. */
|
/** The point whose value is being typed, and the text so far. */
|
||||||
@@ -97,6 +100,7 @@ export function useAutomationLaneGestures({
|
|||||||
onRangeSelect,
|
onRangeSelect,
|
||||||
onRangeClear,
|
onRangeClear,
|
||||||
duration,
|
duration,
|
||||||
|
rangeSelection,
|
||||||
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
||||||
@@ -111,6 +115,21 @@ export function useAutomationLaneGestures({
|
|||||||
* into an actual range, rather than a click that should just clear one. */
|
* into an actual range, rather than a click that should just clear one. */
|
||||||
const rangeCrossed = useRef(false);
|
const rangeCrossed = useRef(false);
|
||||||
|
|
||||||
|
const stretch = useAutomationEdgeStretch({
|
||||||
|
getBox,
|
||||||
|
lane,
|
||||||
|
range,
|
||||||
|
pointAt,
|
||||||
|
xOf,
|
||||||
|
commitPoints,
|
||||||
|
duration,
|
||||||
|
readOnly,
|
||||||
|
rangeSelection,
|
||||||
|
onRangeSelect,
|
||||||
|
onRangeClear,
|
||||||
|
onHint: setHint,
|
||||||
|
});
|
||||||
|
|
||||||
/** Index of a point under the pointer, or null. */
|
/** Index of a point under the pointer, or null. */
|
||||||
const hitIndex = useCallback(
|
const hitIndex = useCallback(
|
||||||
(clientX: number, clientY: number): number | null => {
|
(clientX: number, clientY: number): number | null => {
|
||||||
@@ -153,6 +172,25 @@ export function useAutomationLaneGestures({
|
|||||||
[hitIndex, segmentIndex],
|
[hitIndex, segmentIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a press on the lane's empty background arms: a new range selection,
|
||||||
|
* and only when a caller wants to hear about one — a read-only lane never
|
||||||
|
* reaches here at all.
|
||||||
|
*/
|
||||||
|
const armRangeDrag = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (!onRangeSelect) return;
|
||||||
|
e.preventDefault();
|
||||||
|
capturePointer(e);
|
||||||
|
const raw = pointAt(e.clientX, e.clientY).t;
|
||||||
|
const clamped = Math.min(duration, Math.max(0, raw));
|
||||||
|
const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
|
||||||
|
rangeCrossed.current = false;
|
||||||
|
setRangeDrag({ from: t, to: t });
|
||||||
|
},
|
||||||
|
[onRangeSelect, pointAt, duration, snapTimes],
|
||||||
|
);
|
||||||
|
|
||||||
const onPointerDown = useCallback(
|
const onPointerDown = useCallback(
|
||||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -166,21 +204,15 @@ export function useAutomationLaneGestures({
|
|||||||
onSelect?.();
|
onSelect?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// An active selection's edge outranks a point sitting on it. Every range
|
||||||
|
// operation — stretch, delete, shape insert — leaves a breakpoint exactly
|
||||||
|
// on the edge it just created, so a point-first rule meant the second
|
||||||
|
// stretch of the same edge resolved to a point-drag and the feature was
|
||||||
|
// not repeatable. Clear the selection to reach that point again.
|
||||||
|
if (stretch.arm(e)) return;
|
||||||
const gesture = gestureAt(e);
|
const gesture = gestureAt(e);
|
||||||
if (!gesture) {
|
if (!gesture) {
|
||||||
// Neither a point nor an Alt-held segment: the press landed on the
|
armRangeDrag(e);
|
||||||
// lane's empty background. That is a range selection's gesture, not
|
|
||||||
// nothing — but only when a caller wants to hear about one; a
|
|
||||||
// read-only lane already returned above, so this is a live one with no
|
|
||||||
// range feature wired up.
|
|
||||||
if (!onRangeSelect) return;
|
|
||||||
e.preventDefault();
|
|
||||||
capturePointer(e);
|
|
||||||
const raw = pointAt(e.clientX, e.clientY).t;
|
|
||||||
const clamped = Math.min(duration, Math.max(0, raw));
|
|
||||||
const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
|
|
||||||
rangeCrossed.current = false;
|
|
||||||
setRangeDrag({ from: t, to: t });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -192,7 +224,7 @@ export function useAutomationLaneGestures({
|
|||||||
dragOrigin.current = originOf(lane.points[gesture.index]);
|
dragOrigin.current = originOf(lane.points[gesture.index]);
|
||||||
setDragIndex(gesture.index);
|
setDragIndex(gesture.index);
|
||||||
},
|
},
|
||||||
[gestureAt, lane, readOnly, onSelect, onRangeSelect, pointAt, duration, snapTimes],
|
[gestureAt, lane, readOnly, onSelect, armRangeDrag, stretch],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Bend the segment under the pointer, which is what Alt-dragging the line does. */
|
/** Bend the segment under the pointer, which is what Alt-dragging the line does. */
|
||||||
@@ -239,46 +271,69 @@ export function useAutomationLaneGestures({
|
|||||||
[dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf],
|
[dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Update the live range-drag as the pointer moves, firing `onRangeSelect`
|
||||||
|
* once it has covered enough pixels to count as an actual range rather
|
||||||
|
* than a click that should just clear one. */
|
||||||
|
const moveRangeDrag = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (rangeDrag === null) return;
|
||||||
|
const raw = pointAt(e.clientX, e.clientY).t;
|
||||||
|
const clamped = Math.min(duration, Math.max(0, raw));
|
||||||
|
const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
|
||||||
|
setRangeDrag({ from: rangeDrag.from, to: t });
|
||||||
|
if (Math.abs(xOf(t) - xOf(rangeDrag.from)) <= 3) return;
|
||||||
|
rangeCrossed.current = true;
|
||||||
|
onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t));
|
||||||
|
},
|
||||||
|
[rangeDrag, pointAt, duration, snapTimes, xOf, onRangeSelect],
|
||||||
|
);
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
const onPointerMove = useCallback(
|
||||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
if (rangeDrag !== null) {
|
if (stretch.edge !== null) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const raw = pointAt(e.clientX, e.clientY).t;
|
// A capture lost without a `pointercancel` — the child it was taken on
|
||||||
const clamped = Math.min(duration, Math.max(0, raw));
|
// unmounted, or the browser handed the gesture elsewhere — leaves no
|
||||||
const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
|
// gesture-end event at all, and every later hover would keep retiming.
|
||||||
setRangeDrag({ from: rangeDrag.from, to: t });
|
// A move with no button held is the only signal left that it is over.
|
||||||
if (Math.abs(xOf(t) - xOf(rangeDrag.from)) > 3) {
|
if (e.buttons === 0) stretch.cancel();
|
||||||
rangeCrossed.current = true;
|
else stretch.move(e);
|
||||||
onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t));
|
return;
|
||||||
}
|
}
|
||||||
|
if (rangeDrag !== null) {
|
||||||
|
e.stopPropagation();
|
||||||
|
moveRangeDrag(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (curveIndex === null && dragIndex === null) {
|
||||||
|
stretch.updateHover(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (curveIndex === null && dragIndex === null) return;
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
||||||
else movePoint(e);
|
else movePoint(e);
|
||||||
},
|
},
|
||||||
[
|
[stretch, rangeDrag, moveRangeDrag, curveIndex, dragIndex, bendSegment, movePoint],
|
||||||
rangeDrag,
|
|
||||||
pointAt,
|
|
||||||
duration,
|
|
||||||
snapTimes,
|
|
||||||
xOf,
|
|
||||||
onRangeSelect,
|
|
||||||
bendSegment,
|
|
||||||
curveIndex,
|
|
||||||
dragIndex,
|
|
||||||
movePoint,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** A sub-threshold press clears the selection rather than leaving a
|
||||||
|
* zero-width one behind. */
|
||||||
|
const finishRangeDrag = useCallback((): void => {
|
||||||
|
if (!rangeCrossed.current) onRangeClear?.();
|
||||||
|
rangeCrossed.current = false;
|
||||||
|
setRangeDrag(null);
|
||||||
|
}, [onRangeClear]);
|
||||||
|
|
||||||
const endDrag = useCallback(
|
const endDrag = useCallback(
|
||||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (stretch.edge !== null) {
|
||||||
|
e.stopPropagation();
|
||||||
|
stretch.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (rangeDrag !== null) {
|
if (rangeDrag !== null) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!rangeCrossed.current) onRangeClear?.();
|
finishRangeDrag();
|
||||||
rangeCrossed.current = false;
|
|
||||||
setRangeDrag(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dragIndex === null && curveIndex === null) return;
|
if (dragIndex === null && curveIndex === null) return;
|
||||||
@@ -289,7 +344,22 @@ export function useAutomationLaneGestures({
|
|||||||
setHint(null);
|
setHint(null);
|
||||||
commitPoints(lane.points, true);
|
commitPoints(lane.points, true);
|
||||||
},
|
},
|
||||||
[rangeDrag, onRangeClear, curveIndex, dragIndex, lane, commitPoints],
|
[stretch, rangeDrag, finishRangeDrag, curveIndex, dragIndex, lane, commitPoints],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** `pointercancel`: the browser abandoned the gesture, so a stretch reverts
|
||||||
|
* instead of persisting the partial retime `endDrag` would have committed.
|
||||||
|
* Anything else ends the way a release ends it. */
|
||||||
|
const cancelDrag = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
|
if (stretch.edge !== null) {
|
||||||
|
e.stopPropagation();
|
||||||
|
stretch.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endDrag(e);
|
||||||
|
},
|
||||||
|
[stretch, endDrag],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDoubleClick = useCallback(
|
const onDoubleClick = useCallback(
|
||||||
@@ -352,12 +422,15 @@ export function useAutomationLaneGestures({
|
|||||||
return {
|
return {
|
||||||
dragIndex,
|
dragIndex,
|
||||||
curveIndex,
|
curveIndex,
|
||||||
|
edgeDrag: stretch.edge,
|
||||||
|
edgeHover: stretch.hover,
|
||||||
hint,
|
hint,
|
||||||
hitIndex,
|
hitIndex,
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
endDrag,
|
endDrag,
|
||||||
|
cancelDrag,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
editing,
|
editing,
|
||||||
setEditingText,
|
setEditingText,
|
||||||
|
|||||||
Reference in New Issue
Block a user