mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
cd5cc0060924106d843974aed0c52ea6fed9c054
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f26f48b3a6 |
fix(studio): re-read a group's own automation after an edit lands on it
Automating a group's effect parameter from the FX rack left the group's timeline row showing nothing — no lanes, and after the previous commit no wave button either, since it now gates on the count. The rack is not group-aware: selecting a group and writing its `data-automation` goes through the ordinary element attribute path. That path's resync re-read the element's OWN automation and chain and stopped there. But a group's lanes are derived from the mirrored `audioGroup*` fields its MEMBERS carry, never from the group element — so the mirrors kept the value they were born with until a reload, and the row had nothing to draw. Fixed at the sync sink, which exists precisely so a writer does not have to know what needs refreshing: it now also re-reads what each member's group carries, through the cache that already invalidates itself on any group attribute change. The four fields are compared as one record rather than a growing `&&` chain, which keeps the callback under the complexity gate and stops the next field being added to the read but not the compare. Verified live: the Voiceover group's row went from no toggle to `∿ 1` — its one author-owned lane, the carve's three bands correctly excluded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2199f55c3b |
feat(studio,core)!: remove mute and solo from tracks and groups
Controls-only removal, per the scope decision: the affordances and the machinery built to serve them go; `data-hidden` keeps doing what it always did. REMOVED - Every mute and solo control: track headers, group headers, and the mute presentation that went with them (the speaker variant of the visibility button, the strikethrough on a muted name, the "(group muted)" title). - Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`, `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the transport's per-source solo gain, `isAudibleUnderSolo` / `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four modules deleted outright. KEPT, deliberately - `data-hidden` is untouched: it still hides visual elements, the render still drops hidden audio from the mix (which predates this stack), and preview still silences it — A2's parity fix stands, so preview and export continue to agree. - Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays, because `data-hidden` on a group still has to reach the preview bus. Only the button that wrote it is gone. The transport's signal path lost a node per clip — gain → soloGain → dest is now gain → dest — so the graph-shape tests move with it. Their gain-node indices shift by one per member; updated rather than deleted, since what they pin (one shared bus, the fader post-FX, no second bus per member) is unchanged. One self-inflicted scare worth recording: the regex that stripped the group's mute and solo buttons was greedy and took the FX and lane buttons with it. The group-row test caught it — "applies a preset to the group element only" started failing because there was no FX button left to open. Restored from HEAD. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full `bun run test` green. |
||
|
|
e9baba66cf |
feat(studio): a group's own automation lanes, where its ∿ said they were
Expanding a group's `∿` showed the bus strip and nothing else, while the button
beside it advertised a lane count. Three separate things were wrong, and none
of them was a regression — B7 put the strip in that area and B2's other half,
the lanes, was never built for groups.
**The count measured the wrong element.** It read
`groupAutomationLanes(memberElements)` — the MEMBERS' lanes. `∿` is per-row
(groups doc §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), so
a group advertised curves it does not own and cannot show. On the playground
that read `∿4` for a group with one lane of its own.
**The group's `data-automation` never reached the UI.** `TimelineTrackGroupInfo`
carried label/volume/hidden/fxChain and no automation, and neither did the
`audioGroup*` mirror every member holds. Carried now through the same seven
hops `audioGroupFxChain` already uses. `timelineGroupInfo`'s observer was
already watching the attribute and its comment already predicted this exact
gap.
**Nothing rendered them, and the row had no room.** `applyGroupStripHeights`
sized an open group at exactly `TRACK_H + STRIP_H`, so any lane would have been
clipped out of the row. It now adds the group's own lanes.
Rendering them needed the missing-entity problem answered (§1.9: "a group is
the first real audio entity in the system"). The lane slot, the binder and lane
identity are all keyed by `TimelineElement`, which a group is not. Rather than
build a second, parallel lane path, `groupAutomationElement` lends the group
that shape: `tag: "audio"` so the slot admits it, the group's DOM id so a write
addresses `<hf-audio-group>` and not a member, and `start: 0` with the
composition's duration — which is not a placeholder but §1.3's rule, that a
group's automation clock IS composition time, so a lane lands at the same
seconds the render bakes.
Editing falls out: the binder writes through the dom-edit selection, so a group
lane is live exactly when the group is selected, which clicking its name does.
Lanes get the accent rail §5 asks for. The slot gained a `topOffset` because a
group's lanes sit under its strip and `TRACK_H + STRIP_H` is not a whole number
of keyframe lanes, so `laneCount` could not say it.
Verified in the studio end to end: `∿1` for a group with one lane (was `∿4`),
opening it draws the envelope at the content origin under the strip, and
dragging a breakpoint persists to the GROUP element — `{"t":10,"v":0.3}` became
`{"t":10.004,"v":0.85}` on `#sfx`, with the members untouched. The geometry
test fails without the fix ("expected 88 to be 160").
Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4337 green.
|
||
|
|
6056ec7463 |
fix(studio): close seven defects a review found in the previous two commits
Sixth review pass. Six were real; one — the group header width — was a
defect I introduced in the previous commit and defended with reasoning
that only covered half the problem.
The header overhang was wrong. `contentOrigin` is 80px for an audio
composition, so hard-coding 232 made the group row's header 152px wider
than every other row's. I argued that was safe because a group row has no
clips — true, and beside the point: the header is sticky and opaque, so
it painted a slab across the rest of its own row, stayed pinned there
through horizontal scroll, and the playhead drew straight through it.
Groups now turn `labelMode` on instead, which is what that flag is for.
Every row gets the same 232px header, verified in the browser.
Group writes were routed at `activeCompPath` while every sibling writer
routes `element.sourceFile || activeCompPath`. Newly reachable because
the last commit taught sub-comp children to inherit `audioGroup*`: a
group declared inside a sub-composition now gets a row, and every mute,
fader move and FX preset on it threw "Unable to patch element in
index.html".
`Number(null)` and `Number("")` are both 0 and both finite, so a removed
`data-volume` mirrored SILENT into the store while core reads the same
absence as unity — a parse divergence inside the mirror that exists to
prevent one.
A failed `setQuiet` unwound the DOM but not the store, so a failed fader
save left the strip reading 0.4 while the preview played 1.0, with
nothing to re-parse and correct it.
`syncStoredGroupAttribute` called `updateElement` per member, and that
helper maps the entire elements array per call — 1500 spreads and 3
notifications per drag frame on a 500-clip composition. One pass now.
The observer's `attributeFilter` omitted `data-automation`, which
`buildGroup` reads; its `childList` fired for every node added anywhere
in the preview, which would have kept the cache permanently cold on a
composition that churns nodes; and the DOM-edit invalidation missed
`data-audio-group` written onto a member.
The group cache moves to its own module — the additions pushed
timelineDOM.ts past the 600-line ceiling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
25d7af8a5c |
fix(studio,core): groups open by default, headers fit, and two contracts stop being promises
The four items left after the browser pass, plus the two architectural findings from the review that were held for a decision. Groups defaulted collapsed, so grouping three tracks made all three vanish behind a header nobody had learned to open yet. The set could not distinguish never-touched from deliberately-collapsed, so it is stored inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus predicate inversion across nine call sites and their tests. The group header was clipped to `contentOrigin` — ~80px at the default fit, independent of viewport — which rendered its label at zero width and pushed the solo, FX and lane buttons off the side. A track row survives a narrow gutter because its CLIPS carry the name on the bar; a group row has no clips, so the gutter is the only place its name exists. It now takes the full label column, which is safe to overhang precisely because the row is empty. Measured 80 -> 232, label 0 -> 45px. Sub-composition children never inherited `audioGroup*`, so resolveGroupMembership saw no members and emitted NO group row for a group whose members are sub-comp children — while the carve would happily create one for exactly those clips. Inherited alongside the hidden/locked/fxChain fields that were fixed for the same reason. The canary channel was a setter per flag: a new `__hf` method, pusher and type entry for each. Replaced with one `__hf.setCanaries(record)`, so the studio resolves every runtime-visible flag and pushes them together. Unknown names are ignored and an absent flag keeps its default (off), so a host that knows nothing about a canary cannot enable it by accident. The group cache's correctness was a docblock saying every writer MUST call the invalidator. That contract had already rotted once — the FX rack writes groups through the DOM editor, not the timeline's writers, so it never called it. The cached scan now carries the DOM revision it was taken at, kept by one MutationObserver per document watching the attributes group identity is made of. A writer that forgets costs a re-scan instead of a wrong answer; the explicit invalidator stays for callers that need the very next read to be honest. Verified in the browser: group expanded on load with no seeding, header 232px with the label and all four controls visible, `setCanaries` present on the runtime and the per-flag setter gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ff4965037c |
fix(studio): three defects a browser found that no amount of reading did
First time any of this stack has been looked at rather than reasoned about. Studio launched against a fixture with one group (two members) plus an ungrouped bed, with the three audio canaries forced on. The group element drew a phantom CLIP row. `<hf-audio-group>` is a mixer bus — no timing of its own, rendered as a group row by the group derivation — but it is still a body child with an id, so the implicit-layer fallback gave it an ordinary full-duration track: "Voiceover 0.0s-12.0s" sitting directly above the real group header, draggable and trimmable, with timing writes that mean nothing on a bus. Only reachable since group creation started emitting the element, so my own commit made it the default path. Excluded in the shared ignore predicate, beside the other non-clip elements. Group writes never reached the store. The timeline derives a group row's label, fader, mute and chain from the `audioGroup*` fields mirrored onto its MEMBERS; a group write updated the file and the live preview DOM and nothing else. Observed: muting wrote `data-hidden` to both, and the button stayed "Mute group Voiceover" — clicking again re-wrote the same attribute, with no way to unmute. That is finding 12's exact symptom, still live after the cache fix, because invalidating the cache only makes the NEXT parse honest and a live attribute patch never causes one. Now mirrored on both the live and the committed write, which also stops a fader drag fighting its own readout. Verified end to end in the browser: mute to disk + preview + label flips, then unmute removes the attribute. The bus fader offered 0..2 while every consumer clamps to [0,1]. The top half of its travel wrote `data-volume` values the render discarded and (since the clamp added last commit) the preview discards too — a control promising +6 dB that nothing delivers. Ceiling lowered to unity. Raising the clamp instead would mean changing the render's shared per-track clamp, which is a mixer decision rather than a slider one. Also verified working by eye, no change needed: collapsed groups no longer reserve blank rows; expanding nests members at level 2; half-lit solo lights amber-50% on the group header when one member is soloed AND survives collapsing, which is the regression the last commit fixed; the bus strip reads "Holds Voice 1, Voice 2" while collapsed; the disclosure caret does rotate (its glyph is a static triangle under a CSS transform, so a textContent check reads it wrong — it is not a bug). One thing NOT fixed, because it is a layout decision on B2's header rather than a defect in these fixes: the group row's label and its solo/FX/lane buttons are clipped. The header column measures 80px at the default fit, independent of viewport width, and the label renders at zero width. A normal track survives this because its clips carry the name on the bar; a group row has no clip bar, so the gutter is the only place its name exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ce6426ddcb |
fix(studio): group rows survive a missing provider, a collapse, an edit and a reload
Findings 12-15 from the review. All four are group/solo UI state, none reachable before the id-space fix made group writes work at all. 14 — TimelineGroupRow called the THROWING useTimelineEditContext where every sibling row calls the optional one. Timeline.test.ts already asserted the timeline renders outside the provider; it passed because its fixture had no groups. One grouped clip took the whole timeline render down, not just the row. The new test is that assertion with a group on screen, and it fails against the old hook. 13 — A collapsed group left its members in `tracks`, so row geometry reserved a full row each while buildTimelineLogicalRows had already stopped emitting them: TimelineLanes rendered null into reserved space, giving a group header trailed by its members' worth of blank, unreachable dead space. Members are now emitted only while the group is expanded, so the row list and the logical rows agree by construction. (Zeroing the heights instead does not work — createTimelineRowGeometry deliberately reads a non-positive height as "invalid, use TRACK_H".) Membership still lands in trackGroupOf: collapsed is hidden, not ungrouped. 12 — groupInfoCache is a WeakMap keyed on the preview Document, and group edits are applied as live patches precisely so the iframe never reloads, so the key never changed and the entry never dropped. A muted group could not be unmuted (the header kept reading the cached `hidden:false` and re-wrote data-hidden), the bus slider snapped back, and a second FX preset built on a stale chain, discarding the first. Every live group write now drops the entry. 15 — Solo leaked two ways. createTimelineResetState never cleared `soloed`, so switching composition carried ids that match nothing in the new document — which is exactly the state that silences every track while the banner still names a clip that is not there. And the solo bridge's effect deps do not change across a preview reload, so the reloaded runtime kept an empty set while the button stayed lit and the banner still claimed "Hearing only X". Solo now rides applyPreviewAudioFlags, the same reload-surviving path as the mute and canary state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a8d27820ba |
fix(studio): half-lit group solo compares bare DOM ids too
A fifth face of the id-space mismatch, not in the review's list. TimelineGroupRow built its member list from `el.key ?? el.id` and handed it to isGroupHalfLitUnderSolo, which compares against the soloed set — bare DOM ids now that the header pushes them that way. Soloing a member lit nothing on its group, the one signal that says "some of what is under here still plays". Found by auditing every consumer of `soloed` rather than trusting the handoff's claim that this call site "happens to pass the bare id" — it passes a bare id for the GROUP and store keys for its MEMBERS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5850a0c978 |
fix(studio,core): unify the audio id space, emit group elements, gate both canaries
Six of the fifteen findings from the max-effort review of the audio
groups / mute-solo / pitch-shift stack. Nothing in the stack is merged;
this sits on top of wa-24-timeline-fx.
The id space (findings 1-3). Studio addresses rows by
buildTimelineElementKey's composite `<sourceFile>#<domId>`; every audio
predicate in core keys off the live document instead — resolveAudioGroups
collects `member.id`, isAudibleUnderSolo compares `el.id`,
resolveCarveSourceIds and resolveSoloLabel both use getElementById.
Nobody checked the boundary, so:
* solo put a composite key in the set the runtime matches against
`el.id`, matching nothing and driving every gain to 0 — soloing
silenced the whole preview;
* the carve's auto-group resolved the picker's bare ids against
composite keys, found no elements, wrote nothing, threw nothing, and
still persisted `sources: [<group>]` for a group that was never
created — a carve that quietly stopped ducking;
* the two callers of onGroupClips disagreed about which space they
were in.
Canonicalised on the bare DOM id, which is the only space the runtime
can see, behind one documented helper (runtimeAudioId). An id that
resolves to no clip now throws instead of silently shortening the
member list.
The group element (finding 4). Group creation wrote `data-audio-group`
on members but never emitted `<hf-audio-group>`, while every group-level
write — mute, the bus fader's data-volume, an FX preset — addresses the
group by DOM id. Groups the product created were exactly the groups
nothing could edit. Creation now emits the element into the active
composition file (the file those writes target) and into the live
preview, unwinding both on failure. Group ids are validated before being
interpolated into markup.
The canary leaks (findings 5-6). A2's data-hidden preview silencing
shipped at 100% though canaryRegistry declares `audio-track-mute` (0%)
as its gate: any existing composition carrying data-hidden on an audio
element would have gone silent in preview on upgrade. Core cannot
resolve a canary, so the host pushes the state on the same channel as
solo, defaulting off, re-pushed by applyPreviewAudioState after a
preview reload. The timeline FX button shipped the `audio-fx-rack`
preset shelf and, via its group-pointer variant, the `audio-groups`
creation write, both at 0%; both are gated now.
Tests. Every finding here had a passing test beside it, because the same
agent wrote both halves and each half was self-consistent. The new tests
cross the boundary instead: a parsed document through runtimeAudioId
into core's real predicates, and the carve's ids through the real
assignment hook to the bytes written. Each was mutation-checked against
the pre-fix code.
Group creation moves to its own module — the additions pushed
timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw
NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical,
but they made the file read as binary to grep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1d5405e3e0 |
feat(studio,core): reach presets and the rack from the timeline
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
301cb8b51f |
feat(studio,core): mute groups, and hear-only-this that cannot reach the export
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).
Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.
Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.
Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.
Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).
Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
e1c9b50948 |
feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the casual-user design constraints (groups doc §5): a volume slider, a level bar that moves with the sound, and the words "Too loud" when it clips. No dB numbers, no peak-hold readout, no routing row. Transport (core): groupInput() now routes each group through input -> [FX chain or dry passthrough] -> output -> master, with one AnalyserNode per group tapped off `output` (post-FX, so the meter reads what the bus actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId) returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer (no per-frame allocation), or null when the group is idle/unknown. The runtime posts group-levels messages only while playing, piggybacking the existing message channel rather than adding a new poll loop. Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's shape) fed by useTimelinePlayer's message handler via parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms. TimelineGroupBusStrip renders in the group row's own `∿` lane area (STRIP_H, already sized in B2's row-height pipeline) — drag writes live via onSetAudioGroupAttributeLive, release commits one undo entry via onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/ timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to stay under the 600-line cap; mirrors FxParamRow's live/commit split). "Too loud" holds for ~2s after the last clipped block, tracked in the component, not the transport. volumeByGroup mirrors labelByGroup in useTimelineTrackDerivations.ts so the strip's slider round-trips the group's own data-volume. Fixed two pre-existing group-routing tests in webAudioTransport.test.ts that hardcoded gain-node creation order/count — B7 inserts an extra `output` gain node between the group's input and master (for the meter to tap), which shifted node indices the tests asserted on directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
dd3212d11b |
feat(studio): group rows in the timeline, and a split disclosure
A group renders as its own row with member rows beneath it, and disclosure
splits into two independent controls: caret shows/hides a group's member
rows (structural), `∿` shows/hides any row's automation-lane rows. Plain
tracks lose their caret (nothing to disclose structurally) and keep only
`∿`. `expandedClipIds` keeps its existing keyframe-lane-state job;
`expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets.
Groups get a real position in the row/geometry pipeline rather than a
visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member
tracks contiguously under a synthetic fractional anchor key
(firstMember - 0.5, the same fractional-key convention sub-composition
expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat
a group row as a first-class row without widening their key type away from
number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group /
member-under-group / lane), lanes always `owner.level + 1`.
All of it — grouped row emission, the header, the new expansion state — is
gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves
empty and every new code path no-ops. `TimelineElement.audioGroup` (+
`audioGroupLabel`, resolved once per document via `resolveAudioGroups` from
B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already
flow DOM → manifest → TimelineElement — inert without the canary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
fecaf72d1f | feat(studio): add a preview volume control (#3282) | ||
|
|
69face9dc6 |
fix(core): make audio automation survive being rescheduled (#3208)
* 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. * fix(core): make audio automation survive being rescheduled mid-playback Anything landing inside a running value curve is refused unless the parameter is cancelled first, and two paths were not cancelling: the chain observer wrote each knob straight onto its AudioParam before rescheduling, and a bent segment read as straight because only the curve exponent was checked, never the via point the timeline actually writes. The first threw NotSupportedError into the console and abandoned the rest of the envelope; the second played a dragged bend as a ramp. Measured against Chrome, in a live context and in an offline one suspended mid-curve: any cancel frees the span, and only a missing cancel is refused. clearParamLane takes the strongest form on purpose, because curve-over-curve refusals were reported with a cancel at the new schedule time already in place and have never reproduced; emit keeps a ramp fallback as the backstop for whatever that mechanism turns out to be. Dynamic carve is what exercises all of it, so it lands here too: - a `gain` primitive, so a carve can match levels as well as carve bands - carve settings collapse to one `strength`, with carveProfile deriving the six numbers that always moved together anyway - analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with a slow release so the bed does not snap back the instant a word ends - worklets are awaited inside attach, so adding a compressor to a carved bed no longer kills its envelopes and freezes every later edit - per-track failure detail in the render's audio stage, which was being discarded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(studio): select automation points with a box, and stop them crossing Replaces the time-range selection with a rectangle. A lane selection is a set of breakpoints, not a span, so it now has value bounds as well as time bounds and a point is caught only if it falls inside both — which is what lets you take the peaks of an envelope and leave the dips between them. Delete, the group drag and the rings drawn on caught points all read the one rule, so what looks selected is exactly what those act on. Copy, paste, shape insert and simplify still work on the box's time span, because they act on the envelope over a stretch of time. Dragging is bounded by its neighbours in both the single and group cases. A point cannot cross another, and cannot land exactly on one either: the lane collapses points that share a `t`, keeping the later one, so arriving on top of a neighbour deleted it. It stops a millisecond short, which is under a pixel at any zoom the lane offers and keeps both points. Only stationary neighbours constrain a group, per member rather than per end, since a box can select a non-contiguous set. Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime the points inside it was the feature this branch opened for, and it is not wanted: the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel revert path all go, along with the ~360 lines of tests that pinned them. Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a fragmented chain of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): put #3207's edge-stretch back, folded into the unified hook The review blocks this PR for deleting a feature two PRs downstack: "#3209 deletes #3207 edge-stretch instead of folding it into the unified hook... merging the stack would ship #3207 and then silently remove its user-facing retime/edge-stretch feature." Verified, all four claims: `useAutomationEdgeStretch.ts` (248 lines) and its test were deleted, `retimeRange` was dropped from `automationLaneSelection.ts`, and the consolidated hook has no edge hit-test, arm/move/finish path, or resize cursor. Restored: the module, `retimeRange`, the selection regressions, and the lane wiring (`col-resize` cursor, `pointercancel` reverting a partial retime rather than persisting it). It is not a straight revert, because #3209 changed the selection from a time range to a box. Edge-stretch now takes `{t0,t1,v0,v1}` and moves only the time edges — the value extent rides through untouched, which keeps it the same gesture it was. **One arbitration call worth a second opinion.** #3207's rule was that a selection's edge outranks a point sitting on it, because every range operation leaves a breakpoint exactly on the edge it created — a point-first rule made the second stretch of an edge resolve to a point-drag. Under a box that rule now contradicts #3209's own test ("stops the group at a point it did not select"), which presses at t=0 v=1 — simultaneously the t0 edge and a selected point. I inverted it: selected content wins, the edge stretches everywhere it is not also selected content. The reasoning is that a box makes the point visibly part of the selection, and dragging selected content has to move it. That restores #3209's test and keeps the stretch usable along the rest of the edge — but it is a product decision between two deliberate designs, so flag it if #3207's original precedence was load-bearing. 954 player tests pass, including the 17 restored ones. * 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. * fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): cap the via conic's weight so an edge-clamped via point can't NaN A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to (0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0. viaConic divided by that zero to get an infinite weight, and shapeVia turned Infinity into NaN a few steps later (Infinity - Infinity in the quadratic coefficient). NaN reaching setValueCurveAtTime silences the automated parameter for the rest of the render. Capped the weight at 1e6 instead of leaving it unbounded — past that point the arc already reads as touching the via point, so nothing visible is lost. Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`, since NaN fails the original comparison and fell through it. Review by Miga (PR #3208). * fix(studio): widen PropertyPanel's resetModules render timeout again The 20s margin (already once widened for the same reason) is timing out in CI's full-monorepo Test run — the resetModules()+fresh-import render this test needs is uncached and competes with every other package's test suite for the same worker pool, and the same test passes in well under 2s standalone. Went to 45s rather than re-tuning to whatever number happens to clear the current CI load, since that number moves every time CI gains a package. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cfacf440d |
feat(studio): the geometry and plumbing behind automation lanes (#3023)
* feat(core): audio FX registry One declarative description of every effect that can be applied to an audio track: fourteen across filters, dynamics, non-linear and time, each exposing its full parameter surface rather than a curated subset. Parameters carry the range, step, unit and scale a control needs, so a panel can generate its UI from this rather than hard-coding a form per effect, and a value that survives `normalizeAudioFxParams` is always safe to realise. Everything is declared in the units a person thinks in — dB, ms, Hz. Parsing rejects an unknown effect id rather than skipping the node. A chain that quietly loses an effect renders something other than what was authored, which is worse than refusing to load it. Data only: no audio is produced here. The graph that realises each effect is referenced by the `web` id and lands in the next change, which keeps this module free of browser globals so the engine and the linter can import it. * fix(core): stop declaring knobs that move nothing Three parameters were declared with ranges, defaults and hints, and read by no builder — dials an author could turn with no audible result. - `chorus.decay` and `bitcrush.aa`: removed. FFmpeg's chorus feeds a decay back into its delay line and a bitcrusher's anti-alias needs a real filter; adding either is new DSP, not a fix, so the honest move is to stop advertising them. - `lowshelf.q` / `highshelf.q`: removed. The Web Audio spec leaves Q unused for shelving filters, so the control moved nothing — and because the shared Q helper marks it automatable, an author could draw an envelope on it and hear nothing at all. `phaser.decay` and `gate.knee` stay: the first drives the sweep depth, and the second is now read by the gate's processor. A test asserts each of these directly, since the existing exposure invariant only checks that a flagged parameter reaches an AudioParam — a parameter the node then ignores passes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): Web Audio graphs for the FX registry One graph builder per `web` id, turning the registry's declarations into running audio. Every node exposes `update`, so turning a dial re-parameterises the live graph rather than rebuilding it: an AudioParam change lands on the next 128-sample quantum, about 2.7 ms at 48 kHz. `buildFxChain` reports whether an update could be applied in place — adding or bypassing an effect, or switching a filter between one and two poles (which changes the node type from BiquadFilterNode to IIRFilterNode), changes the graph's shape and returns false so the caller rebuilds. Four effects have no native node and run as AudioWorklet processors: compressor, limiter, gate and bitcrush. The module is registered from a data: URL rather than a blob:, because a blob inherits the page origin and is opaque on a file:// page, where it fails with an unhelpful AbortError. Reverb has no single node either. `synthesizeReverbImpulse` generates a tail from the room parameters, seeded so the same room sounds the same on every machine, and the ConvolverNode uses it. Tests cover the wiring — which nodes get built, how they connect, parameter application and clamping, in-place update versus rebuild, disposal — against a fake AudioContext, since happy-dom has no Web Audio. * fix(core): reverb level, phaser wiring, per-channel dynamics, one-pole rebuild Four defects in the graph builders, all found by review rather than by ear. **Reverb was unusable at its own defaults.** A ConvolverNode applies the impulse's gain whole — the graph sets `normalize = false` so a room is deterministic rather than browser-defined — but the impulse was raw decaying noise. Measured L2 at the registry default (size 0.7 / damping 0.5): 46.4, or +33.3 dB, putting the wet path ~24 dB over dry at the default `wet: 0.35`. It is now normalised to unit energy, so the wet knob means what it says. Preview and render both convolve this buffer, so they stayed identical throughout — equally deafening before, equally correct now. **Phaser in_gain/out_gain trim the signal entering and leaving the effect**, not a wet/dry pair. Wired to the wet and dry legs, "Input" muted the dry path and the two defaults summed to 1.14, so inserting a phaser raised the track level. They are now input and output trims with the legs summed at unity. Its declared waveform is also honoured: `lfo.type` was never assigned, so the default "Triangular" was silently a sine. **The dynamics worklets held one envelope across a channel-major loop.** The followers advance per sample, so on stereo a 20 ms attack behaved as 10 ms, and the right channel's gain came from an envelope that had already traversed the left — the two ducked differently from the same input and the image pumped. State is now per channel, as is the gate's smoothed gain and bitcrush's sample-hold counter, which previously advanced only on the last channel and left every earlier one frozen for a whole quantum. The gate also honours the knee it declares instead of chattering on material sitting at the threshold. **A one-pole filter's cutoff was swallowed in preview.** Its coefficients are fixed at construction, so `update` cannot push a new frequency — but the shape signature carried only type and pole count, so a cutoff change looked like a values-only edit and went into a no-op updater. Preview kept filtering at the old frequency while the render used the new one: a preview/render divergence in exactly the two effects that do not use a BiquadFilterNode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * feat(core): register the audio-fx-rack canary at 0% Lands the rollout switch dark, per the registry's own procedure: "Start at percentage: 0 and merge that — a canary at 0 is dead code you can land safely and ramp without a code review." Declared at the bottom of the stack so every branch above can read it. The gate itself goes in at wa-4-fx-panel, where the rack first appears. Scope is deliberate and stated in the description: it gates the AUTHORING surface only. A composition that already carries `data-fx-chain` still plays and renders it. A canary should stage who can REACH a feature, not make an attribute somebody already wrote silently inert — an agent that writes a chain through the skill would otherwise produce a file whose audio processing vanishes with no error. * feat(studio): audio FX panel generated from the registry Controls for the whole chain: add, remove, reorder, bypass, and every knob each effect declares. Nothing in the panel knows what a compressor is. The registry supplies each parameter's range, step, unit and scale and the panel renders what it finds, so adding an effect or a knob upstream needs no change here, and the panel cannot offer a value the renderer would reject — a typed-in figure is clamped into the declared range on the way through. Frequency and time controls span three or four decades, so those declare a log scale and the slider maps exponentially; a linear slider would spend most of its travel somewhere useless. Reorder is a first-class control because chain order changes the sound: a reverb before a compressor is not the same as after. Carve gets its own block rather than an entry in the add menu, with a picker for the voice track to listen to. It processes this track based on another one, which is how a sidechain control works — it lives on the track that changes, and names the source. * feat(studio): show the Audio FX section on audio tracks Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders. * refactor(studio): lift audioFxSummary out of PropertyPanelFlat `PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap, so the required File size check is red — the sole reason this PR is blocked. The review says as much: "mechanical fix (~5 min), not a design problem. Code itself is LGTM." Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later branch creates for it. Deliberately the smallest cut that clears the cap rather than the whole `AudioFxGroup` extraction: every later commit in the stack edits AudioFxGroup, so moving it here would collide with each of them, while almost nothing touches this function. 595 lines. * feat(studio): put the audio FX rack behind its canary Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered at 0% — so the whole 47-PR stack can land without showing anyone a feature that has not been measured yet. The gate sits on the AUTHORING surface and nowhere else. The runtime and the render still honour a `data-fx-chain` already on an element, so a composition written through the skill or by `carve.mjs` keeps its processing rather than going silently dry for anyone outside the cohort. A canary should stage who can REACH a feature, not make an attribute somebody already wrote stop working with no error. Gated at the panel rather than in `resolveEditingSections`: the affordance resolver is a pure function in core describing what an element CAN support, and rollout state is not a property of an `<audio>` tag. Pinned the 0% with a test, and checked it fails at 25 — a ramp should have to break something that says "this ships dark" out loud. One gap, stated rather than papered over: the gate itself has no unit test. I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness never renders the Audio FX group for its audio fixture even with the gate removed — so the test passed for the wrong reason in the off case and could not pass at all in the on case. A test that cannot fail for the right reason is worse than none. Verifying the gate needs the panel harness to mount that section first, which is its own change. * feat(core,studio): hear the FX chain in preview, and run the carve analysis Splices an element's FX chain into the playback graph so preview stops being silent about effects, and wires the carve button that was previously inert. The chain goes between the decoded source and its gain stage: effects see the raw signal and volume automation rides on their output, matching the order the offline render uses. Since preview and render call the same graph builders, what is heard while scrubbing is what gets written. The splice lives in the transport rather than on the `<audio>` element. The transport plays each track from a decoded AudioBuffer and mutes the element to avoid doubling, so capturing the element with createMediaElementSource would have processed a stream nothing is listening to — it looked like it worked because the call succeeded, and the audio was unchanged. A chain that cannot be built plays dry rather than silencing the track, which is the right failure in preview: the author keeps working and hears the source. The render still refuses, because shipping the dry signal there would be wrong. Carve now analyses for real: it decodes the chosen voice track, ranks its bands and writes the resulting peaking filters onto this track. Generated nodes are tagged `fromCarve`, so re-running replaces the previous carve instead of stacking another set on top of hand-added effects. Known limitation: the graph is built when a source is scheduled, so a knob turned mid-playback takes effect on the next play or seek rather than immediately. Live re-parameterisation needs the transport to hold the handle and forward updates. * fix(studio,core): stop parameter drags from restarting playback Dragging a knob wrote the chain through the persisting attribute path on every input event. That path refreshes the preview, which reloads the composition and reschedules audio — so a single drag reloaded dozens of times and playback stuttered the whole way. Drags now go through `onSetAttributeLive`, the same path colour grading uses for scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens. The persisting write fires once, when the gesture ends — pointer-up or blur for a slider, Enter or blur for a typed value. A select commits immediately since there is no drag to wait for. While dragging, the control is driven from local state. Waiting for the value to round-trip through the element attribute made the knob lag behind the pointer. For the change to be audible without a reload, the graph now follows the attribute: the chain installed by the transport observes the element and re-parameterises itself in place, so a value change lands on the next 128-sample quantum. A shape change (effect added, bypassed, pole count) cannot be patched into a running graph, so it still waits for the next schedule rather than cutting the audio mid-play. The regression test drags a slider through several values and asserts the persisting handler is untouched until release. * fix(core): register FX worklets before building nodes that need them An AudioWorkletNode cannot be constructed before its processor is registered — it throws, and the surrounding chain is lost with it. `attachElementFxChain` built the chain first and only then called `ensureAudioFxWorklets`, so every worklet-backed effect (compressor, limiter, gate, bitcrush) threw on construction and the track fell back to dry. Instrumenting the preview showed `hf-compressor: InvalidStateError` with addModule never called at all. When the module has not landed yet the track now plays dry and the graph is swapped in once registration resolves, so the effect arrives a moment late instead of never. Registration is also tracked per context rather than in one module-level promise. A processor registered on one AudioContext does not exist on another, so the shared promise made every context after the first believe it was ready when it was not — the studio's transport owns its own context, which is exactly that case. With the worklets actually running, the compressor's per-sample log10 and pow became real audio-thread work. Samples below the knee have a gain of exactly unity and need neither, so the envelope is now compared in the linear domain and the transcendentals only run for samples that are actually being compressed. * refactor(studio): split the FX node row out of FxSection Clears the health findings the FX stack left behind: the chain-node render callback was a 70-line closure over half of FxSection's state, and the two reorder arrows were the same button written twice. Also drops two exports with no consumers, and registers the audio FX runtime stub as an entry point — it is bundled by file path, so nothing imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(studio): lift the audio FX group out of PropertyPanelFlat `PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so the required File size check was red — the sole reason #3014 and #3022 are blocked. Both reviews say the same thing: "mechanical fix, not a design problem. Code itself is LGTM." Moves `AudioFxGroup` and `audioFxSummary` into `propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them anyway — done here so the file is under the cap from the point it first crosses it, rather than ten branches later. 533 lines now. The four audio imports it no longer needs go with it. Not fixed here: three `FxSection carve` tests fail on this branch with "Cannot read properties of undefined (reading 'toFixed')". Confirmed pre-existing by stashing this change and re-running — that is the separate `Test` failure the review also flags. * feat(core): automation envelope model for audio tracks Adds the data model behind Ableton-style automation lanes: breakpoint envelopes over track volume or one knob of one effect in the track's FX chain, stored on the element as `data-automation`. Times are clip-local, so an envelope travels with the clip when it moves — the clip-envelope model rather than arrangement automation. `sampleAutomationLane` is the single interpolator. The lane drawing, the preview scheduler and the render bake all call it, so the picture and the sound cannot disagree about the curve. Log-scaled parameters interpolate in log space, matching what their own knob already promises. FX nodes gain a stable `id`, minted by count rather than randomly so the document is the same on every machine. Lanes address nodes by id, so reordering a chain never re-points a lane at a different effect, and a lane whose effect was deleted is dropped rather than left to reattach. Also warns when a track carries both a volume lane and a GSAP volume tween, since only the lane is heard and the tween silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): expose the AudioParams behind automatable FX knobs Marks the knobs an automation lane can drive and has each graph builder hand back the AudioParam behind them, so a scheduler can write to a running effect without knowing what the effect is. A knob is not always one AudioParam. A wet/dry mix is two gains moving in opposition, and a knob in milliseconds drives a delay time in seconds, so each target carries the mapping out of the knob's own declared unit. What stays unautomatable is stated where it is decided: a WaveShaper curve, a convolution impulse and a one-pole filter's coefficients are all rebuilt wholesale rather than scheduled, and the four worklet effects take values by postMessage rather than through AudioParams. The registry flag is written by hand, so a test builds every effect and checks the exposure both ways — nothing flagged is missing, nothing exposed is unflagged. A flag that lied would offer a lane that silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): play automation envelopes in preview Schedules each lane onto the AudioParams behind its knob using native ramps and value curves. Nothing evaluates the envelope per frame: it is handed to the audio thread once, so it stays sample-accurate however busy the main thread is, and the offline render will schedule it the same way. Timing comes from the transport, so an envelope survives seeking into the middle of a clip, a clip that has not started yet, and a playback rate that compresses clip seconds into context seconds. A straight line is only scheduled as a ramp when nothing bends it — no curvature, a linear parameter scale, and no unit mapping. Log-scaled parameters and mapped ones are sampled instead, since a delay knob in milliseconds and a wet/dry pair moving in opposition are not linear in the parameter they drive. Lanes with nowhere to write are skipped rather than reported: a one-pole filter exposes no frequency param, and the worklet effects expose none at all. Editing an envelope mid-playback re-aims it at the live playhead rather than restarting the track. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): bake automation envelopes into the render The offline render schedules FX lanes with the same scheduler preview uses, inside the OfflineAudioContext that already runs the same graph builders. The input WAV is the clip's own audio from its first sample, so clip-local time is offline time and the envelope needs no offset. Volume lanes take the existing PCM bake rather than a second mechanism: the lane is converted to keyframes, so a straight fade stays two of them and only a bent segment is sampled — the baker interpolates linearly and would otherwise quietly straighten the curve. A volume lane supersedes keyframes probed from the timeline, which `lint` already warns about. A browser test sweeps a lowpass from below a 2 kHz tone to well above it and measures both ends. Parsing the envelope is not the same as scheduling it, and only running the real thing tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): make the volume lane audible in preview The envelope was scheduled onto the transport's gain AudioParam, but the runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked value — so it was erased within a frame. Volume automation was correct in the render and inaudible while previewing. The lane now feeds the per-tick path where the probed volume keyframes already sit, checked ahead of them so the two cannot fight, and the transport no longer schedules volume at all: one mechanism instead of two racing. The cost is honest — in preview the level steps per tick rather than per sample, exactly as the existing keyframe path does. The render still bakes it into the PCM sample-accurately, and FX parameters are still scheduled on their own AudioParams, since nothing rewrites those. Parsed lanes are cached by attribute text: the runtime asks once per tick per track, and parsing there would run the JSON parser 60 times a second for a value that only changes on an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): apply chain edits to the running graph A structural edit — an effect added, removed, bypassed, or a filter's pole count switched — was dropped. `buildFxChain`'s update reports false when the change is not merely new values, and the attribute observer ignored that, so the edit only took hold when the persisting write reloaded the composition. That reload restarted every playing track, which is what was heard as the audio chopping. The graph is now swapped in place: the old effects are detached, the new ones built and connected between the same source and gain, and any lanes re-scheduled onto the new nodes. The source node is never touched, so playback does not restart. A track with no chain is watched too, rather than wired through and forgotten, so adding its first effect is heard the same way. That means the function always returns a disposer instead of null for the empty case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(studio): the geometry and plumbing behind automation lanes Everything an automation lane needs before there is a lane to look at, kept apart from the component so the maths can be read and tested without a pointer. `automationLaneGeometry` is pure: which parameters a clip can automate (its fader, then each automatable knob of each effect that carries a node id), how a value maps to a position in the lane, and how a lane is edited. Two decisions live here and are worth review: - A log-read knob maps on its own log scale, so the middle of a 100 Hz–20 kHz lane is the geometric mean. Dragging and drawing then agree with what the knob's own scale already promises. - `withLane` replaces a lane in place rather than appending. A lane with no explicitly chosen parameter shows whichever comes first, so moving the edited one to the end would switch the lane out from under the pointer on the first edit. `automationLaneData` parses the two attributes, cached by their text so the identity only changes when the text does — the lane holds an optimistic draft while a point is dragged and compares against that identity, and a fresh object on every playhead tick would throw the drag away. It binds automation to the chain the way preview and the render bind it, so a lane whose effect was deleted is dropped rather than drawn against the wrong axis. `useAutomationLanes` routes edits through the DOM edit session, targeting the selected element because that is what the attribute commit path writes to. Row height reserves each lane at its own height rather than counting it as another keyframe lane, and `TimelinePropertyLanes` gains a footer slot so the lanes share the keyframe disclosure — and its `aria-controls`. `TimelineElement` moves to its own module: playerStore had reached the 600-line studio ceiling exactly and could not carry another field. It is re-exported from there, so no importer changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(studio): make automationLaneData reviewable, and evict one entry not all The cache key held a literal NUL byte instead of its escape, so git classified the whole module as binary: it landed as `Bin 0 -> 2677 bytes` with zero diffable lines, invisible to review, to grep, and to any textual merge. The escape is behaviour-identical. With the file readable, two things in it needed fixing. Eviction cleared the entire map. Clearing changes the identity of every lane's automation at once, and a lane compares its drag draft against that identity — so one unrelated element arriving at the limit would release an in-progress drag and snap the point back. It now drops the oldest entry, and a hit is re-inserted so it counts as recently used. Nothing tested this module, which is what let the binary blob through. Now covered: identity stability, re-parsing when the chain changes but the automation text does not, a hot entry surviving 40 evictions, and an unreadable attribute reading as nothing. The geometry module's exports are ignored for dead-code while its consumer sits one PR upstack, following the convention already used for the fast-capture stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): carry the audio FX attributes onto every timeline row Both element builders read `data-fx-chain` and `data-automation` off the host element, and an expanded sub-composition child is built without one — so an audio track inside a sub-composition reserved no automation height and drew no lanes, while the property panel, which reads the live DOM selection rather than the row, still showed its chain and its toggles. `hostElementState` exists to re-inherit exactly this class of host-only field; it now covers these two alongside `hidden`, `timelineLocked` and `timelineRole`. `parseTimelineFromDOM` had the same gap and now reads both directly. Also exempts the offline FX render's browser entry from the health gate: it runs only inside the headless page the engine drives, so its CRAP score is coverage-driven rather than complexity-driven, and its behaviour is covered by the engine's real-browser render tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * test(engine): probe ffmpeg and Chrome instead of assuming them Two failures on #3021's Test job, both about the environment rather than the code under test. **Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to `execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()` resolves — every other ffmpeg-dependent suite in this package already goes through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)` so a contributor without ffmpeg skips rather than fails. **The browser guard trusted the wrong thing.** It asked `resolveHeadlessShellPath()` and treated a returned path as "a browser is here". CI's cache holds a chrome-headless-shell that resolves and then fails to spawn — a partial download is indistinguishable from a working one by `existsSync`, which is all that resolver checks. So the three browser cases ran anyway and failed on the launch. It now runs `--version` and requires exit 0, which is the same probe the ffmpeg suites use: ask the binary, do not infer from the filesystem. Checked both directions rather than just the green one. With a working browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed at a binary that exits non-zero — CI's exact situation — exactly 3 skip and the other 8 still run. A guard that quietly skipped everything would have looked identical on the CI summary. * feat(core): register the audio-fx-rack canary at 0% Lands the rollout switch dark, per the registry's own procedure: "Start at percentage: 0 and merge that — a canary at 0 is dead code you can land safely and ramp without a code review." Declared at the bottom of the stack so every branch above can read it. The gate itself goes in at wa-4-fx-panel, where the rack first appears. Scope is deliberate and stated in the description: it gates the AUTHORING surface only. A composition that already carries `data-fx-chain` still plays and renders it. A canary should stage who can REACH a feature, not make an attribute somebody already wrote silently inert — an agent that writes a chain through the skill would otherwise produce a file whose audio processing vanishes with no error. * feat(studio): audio FX panel generated from the registry Controls for the whole chain: add, remove, reorder, bypass, and every knob each effect declares. Nothing in the panel knows what a compressor is. The registry supplies each parameter's range, step, unit and scale and the panel renders what it finds, so adding an effect or a knob upstream needs no change here, and the panel cannot offer a value the renderer would reject — a typed-in figure is clamped into the declared range on the way through. Frequency and time controls span three or four decades, so those declare a log scale and the slider maps exponentially; a linear slider would spend most of its travel somewhere useless. Reorder is a first-class control because chain order changes the sound: a reverb before a compressor is not the same as after. Carve gets its own block rather than an entry in the add menu, with a picker for the voice track to listen to. It processes this track based on another one, which is how a sidechain control works — it lives on the track that changes, and names the source. * feat(studio): show the Audio FX section on audio tracks Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders. * refactor(studio): lift audioFxSummary out of PropertyPanelFlat `PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap, so the required File size check is red — the sole reason this PR is blocked. The review says as much: "mechanical fix (~5 min), not a design problem. Code itself is LGTM." Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later branch creates for it. Deliberately the smallest cut that clears the cap rather than the whole `AudioFxGroup` extraction: every later commit in the stack edits AudioFxGroup, so moving it here would collide with each of them, while almost nothing touches this function. 595 lines. * feat(core,studio): hear the FX chain in preview, and run the carve analysis Splices an element's FX chain into the playback graph so preview stops being silent about effects, and wires the carve button that was previously inert. The chain goes between the decoded source and its gain stage: effects see the raw signal and volume automation rides on their output, matching the order the offline render uses. Since preview and render call the same graph builders, what is heard while scrubbing is what gets written. The splice lives in the transport rather than on the `<audio>` element. The transport plays each track from a decoded AudioBuffer and mutes the element to avoid doubling, so capturing the element with createMediaElementSource would have processed a stream nothing is listening to — it looked like it worked because the call succeeded, and the audio was unchanged. A chain that cannot be built plays dry rather than silencing the track, which is the right failure in preview: the author keeps working and hears the source. The render still refuses, because shipping the dry signal there would be wrong. Carve now analyses for real: it decodes the chosen voice track, ranks its bands and writes the resulting peaking filters onto this track. Generated nodes are tagged `fromCarve`, so re-running replaces the previous carve instead of stacking another set on top of hand-added effects. Known limitation: the graph is built when a source is scheduled, so a knob turned mid-playback takes effect on the next play or seek rather than immediately. Live re-parameterisation needs the transport to hold the handle and forward updates. * fix(studio,core): stop parameter drags from restarting playback Dragging a knob wrote the chain through the persisting attribute path on every input event. That path refreshes the preview, which reloads the composition and reschedules audio — so a single drag reloaded dozens of times and playback stuttered the whole way. Drags now go through `onSetAttributeLive`, the same path colour grading uses for scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens. The persisting write fires once, when the gesture ends — pointer-up or blur for a slider, Enter or blur for a typed value. A select commits immediately since there is no drag to wait for. While dragging, the control is driven from local state. Waiting for the value to round-trip through the element attribute made the knob lag behind the pointer. For the change to be audible without a reload, the graph now follows the attribute: the chain installed by the transport observes the element and re-parameterises itself in place, so a value change lands on the next 128-sample quantum. A shape change (effect added, bypassed, pole count) cannot be patched into a running graph, so it still waits for the next schedule rather than cutting the audio mid-play. The regression test drags a slider through several values and asserts the persisting handler is untouched until release. * feat(studio): put the audio FX rack behind its canary Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered at 0% — so the whole 47-PR stack can land without showing anyone a feature that has not been measured yet. The gate sits on the AUTHORING surface and nowhere else. The runtime and the render still honour a `data-fx-chain` already on an element, so a composition written through the skill or by `carve.mjs` keeps its processing rather than going silently dry for anyone outside the cohort. A canary should stage who can REACH a feature, not make an attribute somebody already wrote stop working with no error. Gated at the panel rather than in `resolveEditingSections`: the affordance resolver is a pure function in core describing what an element CAN support, and rollout state is not a property of an `<audio>` tag. Pinned the 0% with a test, and checked it fails at 25 — a ramp should have to break something that says "this ships dark" out loud. One gap, stated rather than papered over: the gate itself has no unit test. I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness never renders the Audio FX group for its audio fixture even with the gate removed — so the test passed for the wrong reason in the off case and could not pass at all in the on case. A test that cannot fail for the right reason is worse than none. Verifying the gate needs the panel harness to mount that section first, which is its own change. * fix(core): register FX worklets before building nodes that need them An AudioWorkletNode cannot be constructed before its processor is registered — it throws, and the surrounding chain is lost with it. `attachElementFxChain` built the chain first and only then called `ensureAudioFxWorklets`, so every worklet-backed effect (compressor, limiter, gate, bitcrush) threw on construction and the track fell back to dry. Instrumenting the preview showed `hf-compressor: InvalidStateError` with addModule never called at all. When the module has not landed yet the track now plays dry and the graph is swapped in once registration resolves, so the effect arrives a moment late instead of never. Registration is also tracked per context rather than in one module-level promise. A processor registered on one AudioContext does not exist on another, so the shared promise made every context after the first believe it was ready when it was not — the studio's transport owns its own context, which is exactly that case. With the worklets actually running, the compressor's per-sample log10 and pow became real audio-thread work. Samples below the knee have a gain of exactly unity and need neither, so the envelope is now compared in the linear domain and the transcendentals only run for samples that are actually being compressed. * refactor(studio): split the FX node row out of FxSection Clears the health findings the FX stack left behind: the chain-node render callback was a 70-line closure over half of FxSection's state, and the two reorder arrows were the same button written twice. Also drops two exports with no consumers, and registers the audio FX runtime stub as an entry point — it is bundled by file path, so nothing imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): automation envelope model for audio tracks Adds the data model behind Ableton-style automation lanes: breakpoint envelopes over track volume or one knob of one effect in the track's FX chain, stored on the element as `data-automation`. Times are clip-local, so an envelope travels with the clip when it moves — the clip-envelope model rather than arrangement automation. `sampleAutomationLane` is the single interpolator. The lane drawing, the preview scheduler and the render bake all call it, so the picture and the sound cannot disagree about the curve. Log-scaled parameters interpolate in log space, matching what their own knob already promises. FX nodes gain a stable `id`, minted by count rather than randomly so the document is the same on every machine. Lanes address nodes by id, so reordering a chain never re-points a lane at a different effect, and a lane whose effect was deleted is dropped rather than left to reattach. Also warns when a track carries both a volume lane and a GSAP volume tween, since only the lane is heard and the tween silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(studio): lift the audio FX group out of PropertyPanelFlat `PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so the required File size check was red — the sole reason #3014 and #3022 are blocked. Both reviews say the same thing: "mechanical fix, not a design problem. Code itself is LGTM." Moves `AudioFxGroup` and `audioFxSummary` into `propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them anyway — done here so the file is under the cap from the point it first crosses it, rather than ten branches later. 533 lines now. The four audio imports it no longer needs go with it. Not fixed here: three `FxSection carve` tests fail on this branch with "Cannot read properties of undefined (reading 'toFixed')". Confirmed pre-existing by stashing this change and re-running — that is the separate `Test` failure the review also flags. * feat(core): expose the AudioParams behind automatable FX knobs Marks the knobs an automation lane can drive and has each graph builder hand back the AudioParam behind them, so a scheduler can write to a running effect without knowing what the effect is. A knob is not always one AudioParam. A wet/dry mix is two gains moving in opposition, and a knob in milliseconds drives a delay time in seconds, so each target carries the mapping out of the knob's own declared unit. What stays unautomatable is stated where it is decided: a WaveShaper curve, a convolution impulse and a one-pole filter's coefficients are all rebuilt wholesale rather than scheduled, and the four worklet effects take values by postMessage rather than through AudioParams. The registry flag is written by hand, so a test builds every effect and checks the exposure both ways — nothing flagged is missing, nothing exposed is unflagged. A flag that lied would offer a lane that silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): play automation envelopes in preview Schedules each lane onto the AudioParams behind its knob using native ramps and value curves. Nothing evaluates the envelope per frame: it is handed to the audio thread once, so it stays sample-accurate however busy the main thread is, and the offline render will schedule it the same way. Timing comes from the transport, so an envelope survives seeking into the middle of a clip, a clip that has not started yet, and a playback rate that compresses clip seconds into context seconds. A straight line is only scheduled as a ramp when nothing bends it — no curvature, a linear parameter scale, and no unit mapping. Log-scaled parameters and mapped ones are sampled instead, since a delay knob in milliseconds and a wet/dry pair moving in opposition are not linear in the parameter they drive. Lanes with nowhere to write are skipped rather than reported: a one-pole filter exposes no frequency param, and the worklet effects expose none at all. Editing an envelope mid-playback re-aims it at the live playhead rather than restarting the track. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): make the volume lane audible in preview The envelope was scheduled onto the transport's gain AudioParam, but the runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked value — so it was erased within a frame. Volume automation was correct in the render and inaudible while previewing. The lane now feeds the per-tick path where the probed volume keyframes already sit, checked ahead of them so the two cannot fight, and the transport no longer schedules volume at all: one mechanism instead of two racing. The cost is honest — in preview the level steps per tick rather than per sample, exactly as the existing keyframe path does. The render still bakes it into the PCM sample-accurately, and FX parameters are still scheduled on their own AudioParams, since nothing rewrites those. Parsed lanes are cached by attribute text: the runtime asks once per tick per track, and parsing there would run the JSON parser 60 times a second for a value that only changes on an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): bake automation envelopes into the render The offline render schedules FX lanes with the same scheduler preview uses, inside the OfflineAudioContext that already runs the same graph builders. The input WAV is the clip's own audio from its first sample, so clip-local time is offline time and the envelope needs no offset. Volume lanes take the existing PCM bake rather than a second mechanism: the lane is converted to keyframes, so a straight fade stays two of them and only a bent segment is sampled — the baker interpolates linearly and would otherwise quietly straighten the curve. A volume lane supersedes keyframes probed from the timeline, which `lint` already warns about. A browser test sweeps a lowpass from below a 2 kHz tone to well above it and measures both ends. Parsing the envelope is not the same as scheduling it, and only running the real thing tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): apply chain edits to the running graph A structural edit — an effect added, removed, bypassed, or a filter's pole count switched — was dropped. `buildFxChain`'s update reports false when the change is not merely new values, and the attribute observer ignored that, so the edit only took hold when the persisting write reloaded the composition. That reload restarted every playing track, which is what was heard as the audio chopping. The graph is now swapped in place: the old effects are detached, the new ones built and connected between the same source and gain, and any lanes re-scheduled onto the new nodes. The source node is never touched, so playback does not restart. A track with no chain is watched too, rather than wired through and forgotten, so adding its first effect is heard the same way. That means the function always returns a disposer instead of null for the empty case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): drop the FX panel's dead __testables export Fallow audit flagged it — no test imports the module. * fix(core,studio): clear the remaining Fallow audit findings on the FX panel - Split FxSection's per-node row into FxNodeRow + FxNodeControls so the CRAP score (31.6, threshold 30) splits across two smaller units instead of moving wholesale with one extraction. - Dedupe the repeated "open the add menu, read its items" block in propertyPanelFxSection.test.tsx into openAddMenuItems(). - Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into one build-inline-artifact.ts, config-selected by CLI arg — the two scripts were a byte-for-byte clone save for names. - Exempt canary.test.ts's rawFnv (a deliberate independent reimplementation used to cross-check canaryBucket, per its own docstring) and the property-panel test files' shared renderInto/mount scaffolding (pre-existing across 9 files, 2 outside this stack) in .fallowrc.jsonc, consistent with this file's existing exemptions for the same class of intentional/pre-existing duplication. * fix(ci): allowlist the build-script consolidation in the no-main-deletions guard build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into build-inline-artifact.ts to kill a fallow duplication finding; the deletion guard flagged that as an accidental loss since main still has both originals. * fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo Both effect builders set wet.gain to the mix and dry.gain to its complement in identical two-line blocks; fallow kept re-flagging it as a 10-line clone on every unrelated change. Extracted setWetDryMix. * fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge An earlier merge with main brought this deleted file back (git's merge/delete handling on an unchanged-on-one-side file); package.json already points at build-inline-artifact.ts, so it sat unreachable and duplicating that file's config, both of which fallow flagged. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4cc46f5f9f |
feat(studio): edit and style text in the preview (#3143)
* feat(studio): edit and style text in the preview Double-press a text element in the canvas and the caret opens where you pressed, in the element itself rather than in a panel. Select characters and a small toolbar offers colour, bold, italic and underline, applied to exactly those characters. The toolbar lives in Studio's document rather than the composition's. Putting it in the preview would inject Studio's chrome into the user's composition, where a render would capture it and the composition's own styling would inherit into it. In a flex or grid container the rebuilt runs go inside one wrapper, so a coloured word cannot reflow the element it sits in. Also fixes the keyboard: the shortcut guards matched contenteditable=true only, so playback shortcuts ate letters typed into the composition. * refactor(studio): keep domEditingLayers under the size cap The rich-text operation pushed this file past the 600-line gate. Same change the branch made later, landed with the commit that caused it. * test(studio): wrap selection changes in act * fix(studio): restore rich text after failed save * fix(studio): polish inline text editing * fix(studio): harden inline text editing |
||
|
|
88853f170f | fix(studio): route rooted timeline media through preview (#3061) | ||
|
|
96861cbafc |
perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails * perf(studio): bound thumbnail decoding resources * perf(studio): virtualize timeline thumbnail media * perf(studio): prioritize timeline thumbnail work * perf(studio-server): coordinate cancelable thumbnail generation --------- Co-authored-by: Codex <codex@local> |
||
|
|
c6925e471a |
feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default * fix(ci): measure timeline performance in production React |
||
|
|
723d3381c4 |
fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures * test(studio): gate timeline viewport performance in Chromium * refactor(studio): isolate clip drag lifecycle * refactor(studio): extract timeline render contracts * perf(studio): centralize timeline viewport geometry * perf(studio): follow playhead across virtualized rows * perf(studio): add timeline clip-window index primitive * perf(studio): virtualize timeline clip windows * perf(studio): stop timeline scroll work when row virtualization is off The row virtualization stack made the timeline publish a viewport snapshot on every scroll frame and swap `renderClipContent` across every mounted clip at gesture start and settle. Both are windowing concessions, and neither was gated on the flag, so the build users actually run paid for them while mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247. Gate both on the row virtualization flag. The scroll path now stops at the door when the flag is off, so `isScrolling` stays false and resize-driven and programmatic syncs still publish through the immediate path. The flag moves into its own module: the scroll-viewport hook needs to read it, and the virtualization hook already imports the viewport snapshot type back, which would have closed an import cycle. Also release the perf fixture lease from the fixture rather than from the test-hook effect. Loading a fixture writes player state, which changed that effect's dependency identities and tore it down on the next frame, so the lease was revoked moments after it was taken and live iframe discovery overwrote the fixture before the gate could measure it. The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements) next to the existing flag-on one. It refuses the 50,000-element combination, verifies from the mounted DOM that the server under test matches the requested flag, and skips the DOM-size budgets for the unvirtualized build rather than relaxing them, so a skipped budget never reads as a passed one. Verified against a live Studio dev server on the fixture project: flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0 The flag-on arm's fourth run reproducibly reports a 55-58ms long task against a 50ms budget. That is the residual tail of the window swap itself, tracked separately and not addressed here. * ci(studio): run the timeline viewport gate on studio changes The gate has existed since the row virtualization stack landed but nothing under `.github/` referenced it, so it only ever ran when someone ran it by hand. That is how the flag-off scroll regression reached eight merged-ready PRs without anything noticing. Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one per flag state, and runs both arms of the gate against them. Two servers are needed because row virtualization is read from `import.meta.env` at module load, so one process cannot serve both builds. Scoped to a new `studio` paths filter rather than the broad `code` one: the gate only says anything about `packages/studio`, `packages/core` and `packages/studio-server`. Adds a `ci` tier. It applies the constrained budgets without any emulation, because a hosted runner is already slower and noisier than the machine the strict numbers were recorded on, while the existing `low-resource` tier would throttle it a further 4x and measure the throttle rather than the build. The fixture composition is tracked under `tests/e2e/fixtures` but Studio resolves projects from the gitignored `data/projects`, so the job copies it into place instead of a project directory being committed. Both arms run in about 7 seconds each locally, so the job cost is almost entirely dependency install and the workspace build it shares with `studio-load-smoke`. * fix(ci): preserve both timeline gate evidence arms * ci(studio): report timeline gate arm statuses * ci(studio): require timeline gate evidence artifacts * fix(studio): keep dense keyframes readable * fix(ci): resolve timeline stack audit findings |
||
|
|
e317f1fbe3 |
refactor(studio): double-cast test fixtures and split three dense functions
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare `as T`; the gsapShared fixtures only carry the fields under test. The fallow complexity gate flagged three functions on this branch. Each is split at its natural seam rather than suppressed: the auto-expand scan moves out of the effect, the four repeated attribute guards in nodeMatchesManifestClip collapse into one table-driven check, and the segment-% interpolation moves out of onPathDown. |
||
|
|
8f66b06d12 |
fix(studio): retime the dragged element's own keyframe
Drag-to-retime resolved the dragged diamond against the selected element's animations and committed through the selected element's DOM selection, so dragging a diamond on a non-selected clip retimed the wrong tween. It now resolves against the clicked element's animations and commits through that element's selection, matching the delete path. The three diamond callbacks also take the TimelineKeyframeTarget they already had instead of five positional fields, and the two copies of the sourceFile#domId split share splitTimelineElementKey. |
||
|
|
07965e9fe9 |
fix(studio): play preview audio at speeds above 1x (#2691)
* fix(studio): play preview audio at speeds above 1x
The player force-muted preview audio whenever playback rate exceeded 1x
and disabled the mute button (and the M shortcut) in that state. Users
changing speed to 2x heard nothing, and clicking unmute did nothing.
Media elements play audio fine at any rate (pitch preserved by default),
so remove the special case: audio now follows only the user's mute
toggle at every speed. Drops the shouldMutePreviewAudio helper and the
audioAutoMuted UI/keyboard gating that existed solely for it.
* test(studio): update seek audio test for unmuted-above-1x behavior
useTimelinePlayer.seek.test.ts still asserted the old force-mute: at 2x
with the user unmuted it expected a set-muted{muted:true} message. With
audio now following only the user's toggle, the preview receives
set-muted{muted:false}. Update the assertion and title to match.
|
||
|
|
2b65b4efce |
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests |
||
|
|
21cb722ebd |
refactor(core): unify composition contract (#2157)
* refactor(core): unify composition contract * fix(parsers): parse start expressions linearly |
||
|
|
89db718899 |
feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
|
||
|
|
a33b3f35e1 |
fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields |
||
|
|
84963ea8ba |
fix(studio): persist canvas z-order actions correctly for static elements
An adversarial review of the canvas context-menu z-order pipeline (Bring to Front / Forward / Backward / Send to Back) found the resolver math sound but the glue between the menu and the commit hook broken: - The menu optimistically wrote style.zIndex AND position: relative to the live elements BEFORE the commit hook ran. The hook decides whether to persist position by checking getComputedStyle(el).position === 'static' — always false after the pre-apply — so the position patch was never persisted on the menu path and the reorder silently reverted at the post-commit reload for any nested/static element (root clips survive only because the runtime forces position:absolute). The same pre-apply made the failure rollback capture the already-mutated values, restoring the broken state on persist errors. The menu no longer pre-applies; the hook owns the live writes (it already applied both synchronously) and now sees true priors. Siblings without a persistable identity still get their z applied live-only so a renumber stays visually coherent. - The commit hook's entry.key store-sync plumbing had zero production callers; the store zIndex went stale until full reload. All three callers (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now derive and pass the timeline store key (new deriveTimelineStoreKey helper). - patchElementBatch discarded the server's per-patch matched[]; unresolvable siblings persisted partially and silently. Unmatched targets now warn and report save-failure telemetry (z-reorder-unmatched) without rolling back the matched subset. - template/noscript elements counted as painting siblings, so renumber fallbacks wrote z-index/position into <template> tags in the source file. Excluded from the sibling family. - The default undo coalesce key merged DISTINCT z actions within 300ms into one undo entry; the action kind is now part of the key (LayersPanel drags keep coalescing within a drag; explicit lane-move gesture keys untouched). - rectsIntersect comment claimed touching rects intersect; the strict inequalities say otherwise — comment fixed. |
||
|
|
dcefdd98ca | fix(player): version runtime protocol | ||
|
|
df29fa7a5e |
feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and <Timeline> itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com> |
||
|
|
e5ed512529 |
feat(studio): timeline track = stacking layer (NLE-style layering)
Re-architect the timeline row model from data-track-index rows to stacking layers. Rows represent stacking layers per context: explicit-z clips merge onto one track when they share a z and don't overlap in time; auto-z clips stay one row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a stable layer id, not data-track-index. Vertical drag always writes z-index, never track: drop onto a layer joins it (same z), between layers interpolates a new z, past the ends creates a new front/back layer. data-track-index is never rewritten; #958 holds. Adds hasExplicitZIndex capture (computed z != auto). L1: hasExplicitZIndex on the element model L2: buildStackingTimelineLayers (layer-based rows) L3: layer-aware vertical drag (join / interpolate / new-extreme) |
||
|
|
03f8089e52 |
fix(studio): capture effective (computed) z-index for timeline ordering
createTimelineElementFromManifestClip used `clip.zIndex ?? computed`, but the runtime reports inline-only z-index (0 for CSS-rule authored z-index), and `0 ?? x` keeps the 0 — so every CSS-styled clip collapsed to a z=0 tie. The timeline then ordered rows by DOM position instead of true stacking, and the first vertical drag renumbered the whole tie group, clobbering the author's z-index. Prefer the effective computed read from the live element (the same read the reorder commit uses); fall back to the runtime value only when the element isn't live. |
||
|
|
5ce362299c |
refactor(studio): single-source the timeline stacking key + guard audio reorder
The element stacking key (element.key ?? id) was recomputed in four places (reorder-intent generation, row ordering, the commit-time sibling lookup via a threaded keyOf param, and resolveTimelineMove). Any drift would silently break the sibling lookup and no-op the reorder. Route all of them through the existing getTimelineElementIdentity owner, share one toStackingOrderItem mapper between row ordering and reorder intent, and drop the keyOf parameter. Also enforce the audio side-effect invariant in the single mutation owner (applyTimelineStackingReorder): dragging an audio clip has no visual layer to restack, so it never writes z-index. Covered by a new hook test. |
||
|
|
dd980697a2 |
feat(studio): unify timeline vertical reorder with z-index stacking
Timeline rows now order by scoped stacking (z-index per stacking context) instead of data-track-index, and dragging a clip up/down commits a targeted z-index change through the same shared path the layers panel uses. Both panels stay consistent and moving a clip actually changes front/back. data-track-index is demoted to time-overlap layout only; no bulk z-index injection (#958 intact). Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat, orphaned when keyframe dragging was removed) which surfaced while unifying the model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep StudioApp / the timeline hook / Timeline under the studio 600-LOC cap. U3: scoped stacking row order in the timeline U4: vertical drag commits z-index via the shared reorder commit U8: restore keyframe beat-snap on retime |
||
|
|
070ee91694 | feat(studio): expose resolved z-index and stacking context on timeline clips | ||
|
|
dae26e72b2 | refactor(studio): extract shared layerOrdering module from LayersPanel | ||
|
|
037266e72b |
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change. |
||
|
|
adb40321d6 |
chore(studio): remove all console.* calls from studio package (#1691)
* chore(studio): remove all console.* calls from studio package * chore(studio): address review — remove dead stubs, restore consent notice - Delete empty if-blocks left after console removal (snapTargetCollection, Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording dev guard + now-unused isDevBuild) and the stale "surface in dev" comment. - Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher. - Restore the one-time telemetry consent disclosure in showNoticeOnce (kept behind a pragma — it is a user-facing notice, not debug noise). - Remove the missed timelineIcons console.warn while preserving the `tag || "div"` null-safety fallback. - Route caption auto-save failures (a data-loss path) through telemetry instead of swallowing silently. - Restore the accidentally-clobbered css-var-fonts output.mp4 fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d4aa5f93e1 |
fix(studio): guard against null tag in timeline track style (#1679)
getTrackStyle() can receive a falsy tag at runtime (e.g. empty string from timeline element defaults), causing toLowerCase() and startsWith() to throw. Default to "div" when tag is falsy. |
||
|
|
091137e3c3 |
feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports) |
||
|
|
c0ffdc0fb0 |
fix(core,studio): escape user values in querySelector attribute selectors
Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and use it (or CSS.escape for browser-side code) at all 12 sites that previously interpolated raw user-authored values into querySelector attribute selectors. A " in a composition ID, script src, or data-start value would produce a malformed selector that throws. Node-side (core compiler/parser): uses the shared cssAttrSelector. Browser-side (runtime, studio): uses native CSS.escape(). Supersedes #1568 which fixed only the 3 bundler sites. |
||
|
|
8b92f37635 | feat(studio): add color grading inspector controls | ||
|
|
d9f69f61e7 |
feat(studio,cli): music beat detection with timeline guides + headless beats CLI (#1424)
* feat(studio,cli): music beat detection with timeline guides + headless beats CLI Beat detection for music tracks: the Studio draws beat guides on the active track, beats are user-editable and persist to a project file, and a new `hyperframes beats` CLI generates that file headlessly before the Studio opens. Detection lives in @hyperframes/core/beats (shared by Studio + CLI): an energy onset detector cross-validated with bpm-detective, regularized to an octave- aligned grid, silence-gated, with per-beat loudness. Music-only — an <audio data-timeline-role="music"> is analyzed; voiceover is excluded. Studio: green beat lines + draggable dots on the selected track; add at playhead, drag to move, double-click to delete (audio scrubs); edits persist to beats/<audio>.json and are undoable (interleaved with file history). CLI: `hyperframes beats [dir]` runs the same detection in headless Chrome (prebuilt browser bundle in dist) and writes the beat file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): timeline beat-grid + zoom UX refinements - Center-anchored magnify: zooming via the toolbar/slider keeps the time at the viewport center fixed instead of anchoring at the left. Pinch still anchors at the cursor. - Move-snap to beats: dragging a clip snaps whichever edge (start or end) is nearest a beat, matching the existing resize-edge snapping. - Beat lines on track backgrounds: faint full-height beat lines now paint behind the clips on every track lane (brightness scales with loudness); the green dots stay on the active track's top bar. - Waveform follows zoom: bars fill the full clip width and resample the windowed peaks, so the waveform stretches with zoom instead of stopping partway across a widened clip. - Beat dots centered in the top bar: align the dot band to the clip top (CLIP_Y) so the dots sit centered in the dark bar instead of being bisected by the clip's top border. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio): preserve media sourceDuration across element re-derivation Moving a non-music clip re-derived the timeline elements into fresh objects whose sourceDuration the DOM scan hadn't loaded yet. The async probe skips srcs already in its cache, so the value was silently dropped — trimFractions then returned no window and the trimmed music waveform reset to the full source pinned at the track start. Re-apply the cached probe duration synchronously on every derivation (applyCachedSourceDurations) and extract the async probe loop into probeMissingSourceDurations to keep useTimelinePlayer within the file size limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): skip beat-snap on the music track, highlight move-snap target The music track defines the beats, so moving or trimming it no longer snaps to its own beats (isMusicTrack guard on both the move and resize snap paths). Moving another clip snapped only on drop with no cue. snapMoveStartToBeat now also returns the beat it will snap to; BeatBackgroundLines draws that beat's line as a bright neon-green glow while the clip's edge is within the snap region, so the target is visible before drop. Also drops .commitmsg.tmp, accidentally committed via git add -A. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): hide playhead while dragging a beat; default beat dots to music track - Dragging a beat dot now hides the playhead guideline (new beatDragging store flag set on beat pointer down/up) so its line doesn't track the scrub and clutter the beat being moved. - Beat dots render on the selected track, falling back to the music track when nothing is selected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core): remove polynomial-ReDoS regex from audioRelPathForSrc CodeQL js/polynomial-redos: the lazy `.+?` followed by an optional trailing `[?#].*$` backtracks polynomially on crafted `/preview/...` inputs. Parse the preview-relative path with indexOf/slice instead, and strip the query/hash with a single linear char-class search. Behavior is unchanged for all preview/absolute/blob/data/bare inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio,core,cli): review hardening for beat detection + timeline UX - playerStore.reset() now clears beat state (analysis, edits, undo/redo, persist) so a project switch can't apply the previous project's beats, undo stack, or file-writer to the new one. - removeUserBeat returns the same reference on a no-op, and delete/move beat actions skip committing when nothing changed — no more phantom undo entries / debounced writes for no-op edits. - regularizeBeats bails to raw onsets when the (octave-misread) tempo would produce a sub-125ms grid, avoiding a tens-of-thousands-of-beats freeze. - parseBeats clamps strength to [0,1] and rejects non-finite time/strength, so a hand-edited file can't feed NaN into the gamma curve (Math.pow on a negative base) and blank out beat markers. - Start-edge beat-snap now also requires duration >= minDuration, matching the end-edge guard, so a rightward snap can't collapse the clip. - Center-anchor zoom effect always consumes its skip flag, so a pinch that produced no pps change can't leave it stranded and skip the next zoom. - Headless beats analyzer projects to {beatTimes,beatStrengths,bpm,confidence} before returning, so page.evaluate no longer serializes the full decoded PCM (channelData) across the CDP boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core): gate parseBeats on schema version parseBeats accepted any object with a beats array, so a future v2 beat file (with changed semantics) would be parsed silently as v1. Reject anything whose version is not 1, treating an unknown version like an absent/invalid file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> |
||
|
|
7a99ccec6d |
fix(core): honor root data-duration when GSAP timeline ends short (#1378)
* fix(core): honor root data-duration when GSAP timeline ends short The authored-duration floor only counted child composition clips, never the root element's own data-duration. A composition whose GSAP timeline ended even 0.1s short of its declared data-duration reported the shorter timeline length from player.getDuration() — and the studio's adapter selection (docDuration <= adapterDur) then silently rejected the audio-capable runtime player, downgrading preview playback to the seek-scrubbing adapter, which never starts media elements or WebAudio. Result: total audio silence with zero errors anywhere. - include the root's declared data-duration in resolveAuthoredCompositionDurationFloorSeconds, making data-duration the source of truth for playable length (per the documented contract) - console.warn in the studio when playback falls back to the seek-driven adapter, since the downgrade loses audio invisibly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): release static-seek adapter on native win, warn once on downgrade Review findings on the previous commit, all in the static-seek fallback path of useTimelinePlayer.getAdapter: - A cached static-seek adapter was never paused when adapter selection later resolved a native adapter (the early returns bypass the fallback branch entirely), leaving its private rAF loop seeking the player while the native transport also drives it. The core data-duration fix makes this switch path much more common. releaseStaticSeekCache() now runs at every native-adapter return and at unmount. - The downgrade warning fired on every cache miss — and the cache key can never hold for __timelines compositions because wrapTimeline() returns a fresh object per call, so it fired every rAF tick. It now warns once per downgrade streak (re-armed when a native adapter takes over). - The warning interpolated adapterDur (the native __player duration, 0 when absent) instead of the selected adapter's duration, and used a one-off "[hyperframes-studio]" prefix instead of the file's "[useTimelinePlayer]" convention. The fallback cache logic moved to playbackAdapter.ts (with unit tests for warn-once, cache identity, and pause-on-replace/release), which also keeps useTimelinePlayer.ts inside the studio 600-line limit. Also corrected a stale "no DOM reads" comment on the runtime transport tick — the duration floor has always queried the DOM per call, and now also reads the root's declared data-duration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0923bc0787 |
feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) * refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review) - Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined` normalisation; guards against empty-string data-hf-id reaching findTagByTarget - Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site that still used `?? undefined` instead of `|| undefined`) - Re-export readHfId through domEditing.ts public API - Add readHfId unit tests: present, absent, empty-string, whitespace-only - Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only - Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports brought into fallow scope by the T5b hfId changes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review) cloneNode(true) copies all attributes including data-hf-id. Without clearing it, both halves of a split share the same hf-id; the server's findByHfId picks the first match and silently patches the wrong clip. Remove the attribute from the clone so write-back re-mints a fresh id on the next preview load. Adds a test: splitElementInHtml — hfId clone isolation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review) - Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item construction in collectDomEditLayerItems compiles - Widen getDomLayerPatchTarget return type to include hfId + populate it from data-hf-id attribute (domEditingElement.ts) - Widen findDomEditSelectionTarget to check hfId-first when no id/selector - Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts - Add hfId to buildMissingCompositionElements element construction - Add hfId-targeted test coverage in domEditing.test.ts, domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts - Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
8ecef4b939 |
fix(studio): make static-seek adapter honor keepPlaying option (#1089)
createStaticSeekPlaybackAdapter.seek now accepts the same options as the
PlaybackAdapter contract and aligns the default-pause semantics with
wrapTimeline (hardened in
|
||
|
|
3a24aed9bc |
fix(studio): fit preview reset to composition dimensions (#1085)
* fix(studio): fit preview reset to composition dimensions * fix(core): keep runtime root resolution explicit * fix(studio): resume playback after keep-playing seek |
||
|
|
0cc012cc06 | fix: update tests for double-pause seek and formatted insertion | ||
|
|
3e7b464b3b |
fix(studio): ensure GSAP timeline stays paused after seek
Call tl.pause() both before AND after tl.seek() in the adapter. GSAP's seek() can reactivate a timeline depending on internal state; the second pause() guarantees it stays frozen at the seeked position. |