mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
fix/ffmpeg-exec-failure-hint
508 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9549e038c4 |
perf(studio): a paused editor stops redrawing its overlays sixty times a second (#3846)
* perf(studio): run the editor overlays on one parkable frame loop The composition rect, the selection and hover boxes, the off-canvas indicators and the snap guides each owned an animation-frame loop that re-armed unconditionally. On a paused, untouched editor that is four callbacks per frame reading layout, and the snap-guide loop writes style on every one of them, so the compositor kept committing 55 frames a second with nothing moving. None of the four has a clock of its own; each only changes when something observable happens. They now share one loop that runs at full rate for a moment after a pointer, key, wheel, scroll, resize or visibility change, after a preview message reporting a frame the overlays have not drawn, or after a mutation inside the preview document, and otherwise polls four times a second so a wake source nobody thought of costs a quarter second of staleness rather than a frozen overlay. The frame comparison on preview messages is load-bearing rather than an optimisation: the paused preview posts an unchanged status message every 80 ms so any listener can confirm its position, and treating that as news held the overlays at 60 fps for the life of the tab. * fix(studio): one overlay's throw must not stop the other four The shared frame loop re-armed after running its subscribers, so a subscriber that threw took down the loop and its idle-poll safety net permanently. The four loops it replaced each re-armed first, which kept that failure to the one overlay that caused it. It now re-arms before running anything and isolates each subscriber, rethrowing out of band so the error still reaches the page's error reporting. The wake also sat outside the recognised-message check, so postMessage traffic from an extension, devtools or any other embed on the page held the overlays awake for 400ms at a time. Only the preview's own messages wake it now, and a repeated paused status post still does not. useMotionPathData had the same unconditional loop and is live whenever a keyframed element is selected; it joins the shared one. * fix(studio): wake the overlays only for the preview |
||
|
|
eae4892ae8 |
chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security] * fix(test): preserve test behavior on Vitest 4 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: James <james.russo@heygen.com> |
||
|
|
0408fbcd99 |
perf(studio): scrubbing a large composition no longer stalls the editor (#3842)
* perf(studio): ask each ancestor once per off-canvas rebuild, not once per element The dashed off-canvas markers are rebuilt whenever anything in the preview changes, which during playback or a scrub is several times a second. Most of what a rebuild asked about an element was really a question about its ANCESTORS: does each node up to the root render, what does each contribute to the composed transform, which node is the source-file boundary. Two siblings share their whole chain, so a preview of a thousand elements asked the platform the same questions about the same ancestors a thousand times over. A rebuild now threads one measure pass through the walk, and each node answers once. On a 1689-element carousel that takes computed-style reads from 11,159 to 2,526 per rebuild and the rebuild itself from 59 to 12-18 ms per seek, with the rendered marker set byte-identical. The pass is deliberately not a cache. A cache would have to say what could have changed since last time, and for a measurement the answer is anything: an image finishing decode, a font swapping in, a transition frame, a container query, an inserted stylesheet rule all move an element's box with nothing written to the DOM and no record to invalidate on. A pass lives inside one synchronous measurement that only reads, so nothing can move under it, and it is dropped when the measurement ends. Every rebuild still measures every element. Layout reads are untouched on purpose: each element still takes its own client rect every rebuild, because that is the read that cannot be shared and must not be remembered. * test(studio): give one PropertyPanel case the file's own render timeout Every other render test in that file passes RENDER_TIMEOUT_MS; this it.each was left on vitest's 5s default and times out when the whole suite runs on a loaded machine. Unrelated to any behaviour: it passes on either side of the change when the machine is quiet, and fails in a full-suite run on either side when it is not. * refactor(studio): share the source-boundary walk and dedupe the rebuild fixtures Three follow-ups from review of the measure pass, none of them behaviour: The source boundary was memoized against the element that asked for it, so two siblings still walked their whole chain separately and only the answer was reused. It now memoizes every node on the way up, like the other two walks: an element's boundary IS its parent's unless the element is one itself. `isElementVisibleThroughAncestors` returns what it always returned, but it now resolves top-down, so the set of nodes it takes a style read for is different (smaller for a subtree hidden near the root). Three callers use it with no memo, so that is written down next to it rather than left to be discovered. The three rebuild fixtures in the indicator tests each carried their own copy of the iframe/overlay/layout-stub scaffolding, which is four clone groups and 126 duplicated lines. One `mountPreview` helper now serves all of them and the pre-existing selector-index fixture too. * test(studio): pin the measure pass to one rebuild through an ancestor-derived input The existing no-mutation-record test varies an element's own box, and the pass never memoizes a box — so hoisting the pass to module scope, which is exactly the mistake that would reintroduce cross-rebuild staleness, left every test green. This one fades a wrapper between two rebuilds and asserts the marker under it goes away. Visibility through ancestors IS memoized, so the pass surviving the rebuild serves the stale answer: with `createOverlayMeasurePass()` hoisted the suite exits 1 on this test alone, and exits 0 at head. |
||
|
|
8bf5b4423e |
perf: sublinear Studio rebuilds and seeks (#3837)
* perf(studio): resolve the overlay coordinate basis once per composition
The iframe->overlay basis (composition root, root scale, iframe and overlay
rects) was resolved inside every geometry call, so measuring a preview cost one
querySelector("[data-composition-id]") plus three layout reads PER ELEMENT to
rediscover something that is a property of the composition and the canvas zoom.
It is now threaded through orientedGroupAwareOverlayRect, groupAwareOverlayRect,
orientedOverlayRect, orientedVisibleOverlayRect and toVisibleOverlayRect the way
toVisibleOverlayRects already batched it, and resolved once by the two callers
that measure many elements in one synchronous pass: the off-canvas indicator
rebuild and the overlay RAF loop.
Both passes only read the DOM, so nothing can move the canvas between two
measurements inside one of them.
* perf(studio): rebuild the layer walk from the mutation, not the document
A MutationObserver marked the off-canvas indicators dirty and the rebuild then
re-derived every element in the preview from scratch. The observer's loudest
source is inline style, which is what animation writes, so a composition just
sitting there re-derived the whole document several times a second, and each
element costs two getComputedStyle reads, two ancestor walks for its source
file, and a textContent read over its whole subtree.
The records now say WHAT to drop, not merely that something changed, and the
per-element derivations are memoized between rebuilds. Two lifetimes, because
they do not go stale together: whether an element renders (and how many layer
children it has) dies on any nearby attribute write, while how it is ADDRESSED
survives every style write and dies only on something that can renumber a
selector. That split is what makes an ordinary animation frame cost no document
queries at all.
Not attributable to specific elements, so still a full rebuild: nodes added or
removed, and any change to an identity attribute, which renumbers every element
sharing a selector.
Two supporting changes:
- getDirectLayerChildren asked getDomLayerPatchTarget for a full patch target
per child and used it as a boolean. The target carries the selector's
occurrence index, which is a whole-document query the yes/no does not depend
on. isDomLayerElement answers it without one, for every caller. Its unused
options parameter goes with it.
- The observer no longer filters attributes. An attribute it never hears about
is one the cache would answer stale for, and the old filter omitted id and the
data-composition-* attributes that decide a layer's identity. Widening only
makes indicators refresh sooner; a rebuild is a pure read and is throttled
either way.
Every other caller of collectDomEditLayerItems passes no cache and is unchanged.
* perf(runtime): visit only the clips a seek can flip
Every seek, and every frame of playback, rebuilt the window of every video and
audio element in the document and handed all of them to the per-clip sync loop.
On a composition of eighty videos that is eighty window derivations and eighty
per-clip passes to conclude that one clip is on screen.
A clip is active only while start <= t < end, so a clip whose window excludes
the new time is inactive there whatever its element state is. Sorting the
windows by each endpoint turns a seek into two binary searches: the clips whose
start or end lies between the old time and the new one, plus the ones that were
in window at the old time. Anything outside both was out of window before and is
out of window now, and was already paused and already evicted by the pass that
last saw it go out. A one-frame step visits the clip or two that flip; a jump
over a hundred clips still visits the hundred boundaries it crosses.
The index carries only the windows, and it rides the revision the duration
floors already ride: the same timing attributes, the same media metadata events,
the same timeline registry signature. Nothing else is cached. Every field handed
to syncRuntimeMedia is re-read from the element on every pass, because
el.duration can be reset under us by the load() retry, and a cached copy of it
would be exactly the stale duration the two-resolver-scope rule exists to
prevent.
The render/export path does not consult the index at all and visits every
element on every frame, and a render seek clears the sweep so a later live seek
cannot inherit a position it did not establish.
Also folds the duration floors' own invalidation into that shared revision.
Draining the observer is what detects a change, and only the first reader in a
task gets the records, so two caches each checking for themselves would have had
the second told nothing changed.
* refactor(studio): split the overlay basis and the layer-walk read into their own modules
Clears the 600-line file cap and the fallow audit on this branch. Behaviour is
unchanged: this moves code, it does not alter any of it.
File size. Both files were already near the cap on main and my three levers
pushed them over (615 vs 598, and 605 vs 584):
- domEditOverlayGeometry.ts -> 561. The iframe->overlay coordinate basis
(OverlayRootScale, computeOverlayRootScale and the root/dimension lookups it
owns) moves to domEditOverlayBasis.ts. It is the one piece of that file every
other piece depends on, and nothing in it is about a single element's
geometry. Its two callers now import it from there.
- domEditingLayers.ts -> 587. The per-element read that the walk performs moves
to readDomEditLayerWalkEntry in domEditLayerWalkCache.ts, the module that owns
the memoization it reads through. The walk keeps the traversal and the depth
bookkeeping, which are the parts that are actually about walking.
No unrelated code was trimmed to make room.
Duplication, all three clone groups fallow flagged:
- The runtime seek fixture (createMockTimeline, the synchronous animation-frame
clock, the CSS.escape shim, stubDuration) was copied between
init.timingResolver.test.ts and init.mediaClipIndex.test.ts. It moves to
runtimeSeekFixture.test-helpers.ts. Each suite keeps its own vi.mock calls,
which are file-scoped and cannot be shared. The file is excluded from
tsconfig.runtime.json alongside the test files it serves, for the same reason.
- domEditLayerWalkCache.test.ts repeated its mount/observe/first-walk setup in
three tests; that is now withWarmWalk.
Complexity. Only one of fallow's three findings is attributable to this branch,
and fallow agrees: it marks the other two inherited and excludes them from the
gate.
- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
the observer becomes drainPendingLayerMutations, with its own unit tests, and
the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
here. My change added one statement and no branch; the function grew 139 -> 147
lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
CRAP on main AND here. Untouched by this branch; only its line number moved
(173 -> 182) because the composition-source-map revision counter sits above it.
Left alone.
|
||
|
|
ac27b1534f |
perf(studio): resolve a selector's occurrence index once per layer walk (#3831)
The preview flattens several composition files into one DOM, so a layer's identity is its selector plus its occurrence index WITHIN its own source file. `getSourceScopedSelectorIndex` derived that per element: a whole -document `querySelectorAll(selector)`, `resolveSourceFile` on every match, then `indexOf`. Every element sharing a class paid for all of them, so a walk over n such elements did n whole-document queries and n^2 source-file resolutions — the shape a composition of repeated cards or tiles has by construction. Build the occurrence index ONCE per selector instead and share it across one walk. `withSelectorIndexPass(doc, run)` opens that scope; outside it the helper behaves exactly as before, per call. The pass lives in `collectDomEditLayerItems`, which owns the loop, rather than at a call site — its four callers (off-canvas indicators, the layers panel, the marquee hit-test and the agent look tool) all walked the same way and all paid the same cost. Behaviour is unchanged. The occurrence indices are identical, including the misses: an element outside the requested source file, or one not matching the selector, still yields undefined, as do `#`-prefixed and `[data-composition-id=` selectors and an invalid selector. Measured on a 1689-element preview over 80 single-frame seeks, per rebuild: class-selector document queries 171.6 -> 10.8, and the walk's self-timed cost 15.25ms -> 5.37ms. Elements walked per rebuild is unchanged at 973.7, so the two arms did the same work. Tests assert complexity invariance rather than a threshold: the query count must be IDENTICAL at n and 4n elements sharing a selector, which a fixture -sized threshold would not catch. Both fail on the previous algorithm. |
||
|
|
4f77c282da | fix(studio): authenticate preview message senders (#3812) | ||
|
|
f54ba56136 |
fix(studio): contain project IDs across client and server routes (#3808)
* fix(studio): contain project IDs across client and server routes * test(studio): use a portable project directory fixture * fix(studio): reject drive-relative IDs and filter project discovery |
||
|
|
7d7003aa64 |
fix(studio): match style attributes with explicit quote boundaries (#3712)
* fix(studio): match style attributes with explicit quote boundaries * fix(studio): apply quote boundaries to active source writers |
||
|
|
6b5b4cb988 | feat(studio): make agent edits live and explicit (#3581) | ||
|
|
b71f45981c |
fix(studio): export the composition the user has selected (#3550)
The header's Export button started renders with no options at all, so the request carried no `composition` and the server fell back to index.html. Selecting a sub-composition in the Comps panel showed its canvas and timeline but exported the root file instead. Studio starts renders from three controls, and the render target was owned by each of them separately: the Renders panel resolved it, the header omitted it, the sidebar's per-composition button named one explicitly. Give it one owner in `startRender`, which all three route through, defaulting to the active composition and leaving an explicit argument to win. Fixes #3549 |
||
|
|
adf9b0ccee |
fix(studio): activate a composition at any path, not just compositions/
The Comps panel sets activeCompositionPath to the selected file, but useCompositionStack's effect only pushed a stack level when that path started with compositions/. A project laying its comps out anywhere else, for example a generated multi-part build with parts/part-1.html next to the root index.html, matched no branch at all: the row highlighted and the URL hash updated while the stack silently kept the master mounted, so the canvas and timeline stayed on index.html and any edit landed in the root file instead of the part. Replaced the prefix test with a plain truthiness check, so the root stays on the master level and every other path pushes its own level. Label derivation is unchanged, matching CompositionsTab's own convention. |
||
|
|
94da403d6d |
feat(studio): expose Studio's live state to an agentic browser (WebMCP) (#3511)
* feat(studio): expose Studio's live state to an agentic browser Registers a `studio_look` tool on `document.modelContext`, so an agent in a browser that supports it can read what Studio knows: the open project and composition, the playhead, the human's current selection with its capabilities, and the timeline's elements with a handle for each. The API is `document.modelContext`, not `navigator.modelContext`. The latter is a polyfill compatibility shim rather than a spec member, so feature detecting it is wrong even where a published sample appears to work. Three decisions worth knowing: Registration happens ONCE per mount, with the dependencies held in a ref that every render refreshes. Depending on the handlers instead re-runs on nearly every interaction, because the DomEdit actions object changes identity with the selection and the element list. Each re-run aborts the registration signal and unregisters everything, and the spec warns that a quick unregister-then- reregister can apply an old call's arguments against the new schema. The test for this is the important one in the unit; breaking the empty dependency array fails it and nothing else. Tools resolve with a tagged result, they never reject. That is forced by the spec: a rejected `execute` has its reason discarded and the caller sees a bare UnknownError, so rejecting would guarantee the agent cannot learn why an edit failed. Elements are addressed by a minted handle, not by `TimelineElement.id`. That id is a synthesised identity, so `getElementById` misses most elements; the handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence. Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are only readable below `DomEditProvider` and `App.tsx` is three lines under the 600-line cap. The undo signal is reported as the shell actually exposes it, `canUndo` and a label, rather than as a revision counter. The depth lives in component-local state and is not reachable without plumbing it through the shell context, so the field says what it is instead of implying precision it does not have. Writes are not in this change. `canWrite` is optimistic and the comment says so; the write tools need a real guard against the paused-save and external- conflict states, which are not on any context this component can reach yet. * fix(studio): bound WebMCP look filters * fix(studio): remove premature WebMCP write state * docs(studio): name WebMCP singleton assumption * fix(studio): surface WebMCP registration failures |
||
|
|
21bcd5745c |
fix(studio): let a failed DOM edit report that it failed (#3510)
* fix(studio): let a failed text or style commit report itself `runDomEditCommit` catches a persist failure, reverts, fires `onError` and then resolves. That contract is deliberate and its docstring says so: the human path learns the write failed from the toast `onError` puts on screen, so a rejection would be redundant. It also means a caller awaiting `handleDomTextCommit` or `handleDomStyleCommit` cannot tell a landed write from a reverted one, because both resolve with `undefined`. The runner already offers `onSettled` as the way out. Text and style were the two commits that never got it wired. Add `runReportedDomEditCommit`, which owns `onSettled` (forwarding to a caller-supplied one rather than dropping it) and returns whether the write landed. Both handlers now return a tagged outcome, so the three preconditions that previously returned early and silently are each distinguishable: no selection, a manual-geometry property the style path refuses, and a selection that cannot edit styles. Same for text: no selection versus not text-editable. Human-facing behaviour is unchanged and the tests assert that: the toast still fires and the optimistic DOM change is still reverted. The callback props that carry these handlers ignore the result, so their declared type widens from `Promise<void>` to `Promise<unknown>`. That type is hand-copied in fourteen places; consolidating it is worth its own change. `useDomEditTextCommits.ts` is now 593 lines against the 600-line cap. The next change to it needs a split. * fix(studio): stop a paused save queue reporting a position edit as saved Two more commits that could not tell a caller they had failed. `useDomEditPositionPatchCommit` swallowed `DomEditSaveQueueOpenError` and resolved. The intent was right, a paused save queue already puts a banner on screen and one toast per blocked edit is noise, but swallowing it also skipped the caller's revert: `useDomGeometryCommits` only restores the optimistic offset, size or rotation from its `.catch`. So once the breaker opened, a drag left the element where the user dropped it while nothing reached the file, and the next reload snapped it back. It now rejects without toasting. The banner still does the telling; the caller gets to revert. `handleDomEditElementsDelete` caught everything and only toasted, so an unpatchable target and a completed delete were indistinguishable to a caller. It now returns an outcome, with `no-project` and `no-selection` separated from a failed write rather than all three sharing an early `return`. Adds the first test for `useDomEditPositionPatchCommit`, covering the paused queue, an ordinary failure, and success. * fix(studio): honor DOM edit failure outcomes * fix(studio): classify stale delete previews * fix(studio): enforce DOM edit outcome types |
||
|
|
acc6898255 | fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps | ||
|
|
0c274f7e57 |
fix(studio): reconnect property-panel audio controls (#3453)
* fix(studio): reconnect property-panel audio controls * fix(studio): unify property panel audio detection * fix(studio): satisfy panel and deletion gates |
||
|
|
f575bdadcb |
fix(studio): harden carve and FX rack behavior (#3452)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state * fix(studio): make audio-group edits transactional * fix(studio): keep preview state synchronized * fix(studio): align audio rows, automation lanes and headers * fix(studio): stabilize timeline audio derivations * refactor(studio): simplify group metadata memoization * style(studio): keep timeline layout within size gate * fix(studio): keep timeline preset apply off auditions * fix(studio): harden carve and FX rack behavior * fix(studio): repeat audio FX reveal requests |
||
|
|
4ea018a4a7 |
fix(studio): align audio rows, automation lanes and headers (#3451)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state * fix(studio): make audio-group edits transactional * fix(studio): keep preview state synchronized * fix(studio): align audio rows, automation lanes and headers * fix(studio): stabilize timeline audio derivations * refactor(studio): simplify group metadata memoization * style(studio): keep timeline layout within size gate * fix(studio): keep timeline preset apply off auditions |
||
|
|
89069d24c3 |
fix(studio): keep preview state synchronized (#3450)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state * fix(studio): make audio-group edits transactional * fix(studio): keep preview state synchronized |
||
|
|
7a024cf68e |
fix(studio): name the cause when a render request fails (#3424)
The render POST's catch took no binding, so the exception was discarded and every transport failure produced one sentence: "Could not reach render server. Use `hyperframes render` from the CLI instead." A dead server, a DNS failure, an aborted request and a server that died mid-render are all indistinguishable under that string — and it is not only a UI message, it is what travels into the feedback report. Three separate field reports carried it verbatim, one of them describing a render that fails every single time. A guaranteed reproduction that tells us nothing is worse than an intermittent one that does. Bind the error and append it. The CLI guidance stays, since it is still the right next step for the user; it just no longer stands alone. Regression test asserts both halves: the cause appears, and the guidance survives. It fails on the unfixed code with `expected 'Could not reach render server. Use `h…' to contain 'Failed to fetch'`. |
||
|
|
8612cfd40f |
fix(studio): put the timeline's portaled surfaces on the tier the other portals use (#3414)
* fix(studio): put the timeline's portaled surfaces on the tier the other portals use The FX popover, the grouping dialog it swaps for, and the automation selection menu are all portaled to `document.body`, so they land in the root stacking context — where they sat at `z-50` while the app's own chrome occupies 60, 90, 91, 92, 94, 100 and 110, and every other portal that has to clear that chrome (`Tooltip`, `AssetContextMenu`, `InlineTextToolbar`, `RenderQueue`) already uses `z-[200]`. These three were the odd ones out. Scoped honestly: the clipping in the report is fixed by the height cap in the previous commit, which is what actually cut the popover off at the timeline chrome. This commit is tier consistency — it removes the standing risk of a portaled timeline surface losing to any of those seven higher tiers, rather than a demonstrated repro. Confirm against a real window before claiming more. * fix(studio): move the remaining body-portaled context menus to the same tier The all-sites audit in review was right and the previous commit did half the set. Using `createPortal(…, document.body)` as the predicate rather than the timeline directory, four more surfaces sit in the root stacking context at `z-50` below the seven chrome tiers (60, 90, 91, 92, 94, 100, 110): - `player/components/ClipContextMenu.tsx:51` - `player/components/TrackGapContextMenu.tsx:78` - `player/components/KeyframeDiamondContextMenu.tsx:99` - `components/editor/CanvasContextMenu.tsx:215` The fourth is the easy one to miss — it is the only one outside `player/components/`, so a timeline-scoped sweep finds exactly the other three. It belongs to the same set by its own account: its className is byte-identical to `ClipContextMenu`'s and its header comment says it mirrors that file's look, positioning, and dismiss behaviour, portaled to `document.body`. Two body portals deliberately left alone. `sidebar/BlocksTab.tsx:125` portals `PromptPreviewModal`, which carries its own `z-[100]`/`z-[110]` modal tier — a `z-` class on the portal wrapper would be dead weight. `RenderQueue.tsx:235` is already `z-[200]`. `FileTree.tsx:336` and `FileTreeNodes.tsx:103` are `fixed z-50` but are NOT portaled — they render inside the sidebar's own stacking context, so the root-context argument does not reach them and raising them would be an unrelated change. Crossing the `z-[100]`/`z-[110]` modal backdrops is unreachable for the same reason it was for the first three: all four dismiss on an outside pointerdown, so the press that opens a modal closes the menu first. `CanvasContextMenu.test.tsx:95` asserted on `.fixed.z-50` to prove the menu did NOT render; left as-is it would have passed vacuously against any tier. Updated to the new class so it still fails if the menu renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): correct the interval in the popover in-bounds test comment `bottom: 32` with `maxHeight: 160` in a 200px viewport puts the box at y = 8..168, not y = 8..40 — the bottom edge sits at `innerHeight - bottom`, and the comment read it as the height instead. The assertions below already computed the right geometry; only the stated interval was wrong, on a regression test whose comment is the next reader's model of what it pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
277fe0fa22 |
fix(studio): cap the timeline FX popover to its gap, and scroll the list inside (#3413)
* fix(studio): cap the timeline FX popover to its gap, and scroll the list inside
The popover grew to whatever the preset list needed, so on a short window it ran
off the top or the bottom of the viewport and took its footer ('+ effect' /
'Open rack') with it — nothing scrolled, so the presets past the edge were
simply unreachable.
It now caps to the space on whichever side it opens toward, and the preset list
scrolls inside that while the footer stays put. `min-h-0` on the scroller is
load-bearing: a flex child defaults to min-height:auto and would refuse to
shrink, pushing the footer out instead of scrolling.
`spaceAbove` is named for the cap's benefit; it equals `anchorRect.top`, so the
flip condition is unchanged.
Four tests cover it, because this shipped once before with none: the downward
cap, the upward cap, the usable-minimum clamp, and the footer being a sibling of
the scroller rather than inside it. Verified they fail without the cap.
* fix(studio): slide the FX popover in-bounds instead of hanging it off the edge
Review found the minimum defeating the viewport cap: `Math.max(MIN_POPOVER_HEIGHT,
available)` kept the box 160px tall even when the chosen gap was smaller, so the
box extended past the edge it opened away from. At 200px of viewport with the
anchor at 100..120 it flipped up to `bottom: 104px` and spanned y = -64..96 —
every preset still reachable, but through a ~57px window with the top third of
the dialog off-screen. Reachable at high browser zoom, not only in a synthetic
short window: `available` drops under 160 once the gap is under ~172px, which
400% zoom on a 1080p display produces on both sides.
Shrinking to the gap would undo the floor on purpose (a 20px gap gives a 20px
popover — the vanishing popover in a new costume), so honour the floor and clamp
the resulting box into the viewport the way `left` already is. Two parts:
- Cap the floor by the window itself (`innerHeight - 2 * VIEWPORT_MARGIN`). The
minimum is a floor against a tight gap, not against a tight window; below
176px of viewport, physical space has to win.
- Inset the `top` / `bottom` offset to `innerHeight - height - VIEWPORT_MARGIN`,
so a floor larger than the gap slides the box back in rather than off the top.
The tight case now lands at `bottom: 32px` with `maxHeight: 160px` — the box at
y = 8..40, one margin on each side. The two ordinary cases are unchanged
(34/726 down, 72/688 up), which the existing tests pin.
Tests: two added — both edges in-bounds when the minimum exceeds the gap, and
the floor yielding when the whole window is shorter than it. Both fail on the
previous arithmetic (104px vs 32px, 160px vs 104px). The three pre-existing
geometry tests now pin `window.innerHeight` through one shared helper instead of
inheriting happy-dom's 768 default, so their expected numbers are derivable from
the test and immune to a dependency bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0e9a4f371d |
feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone The twelve-PR audio stack landed on main with all three of its canaries still at 0%, so the FX rack, the group rows, mute and solo are in the build and reachable by nobody. This removes the gates rather than raising the numbers: a canary that gates nothing is a branch every future reader has to evaluate. Gone: - the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries; - the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`, the mute label, the muted strike-through and the solo button in `TimelineTrackPlainHeader`, and the group-row derivation in `useTimelineTrackDerivations`. Each feature now renders on its own precondition (an audio track, a grouped track) exactly as it did for an enrolled user. The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which is the opposite of what should hold now. Replaced with a pin that no `audio-*` canary exists at all: re-registering one silently re-hides a shipped feature, and nothing else in the tree would say so. Verified it fails when one is added back. The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that branch is 105 commits and 310 files divergent from main now that the stack has squash-merged past it. * docs(audio): retire the last references to the audio canaries Two leftovers the gate removal did not reach. `TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the canary, unlike the preview fix" above the function that picks Mute vs Hide. Nothing gates it now, so the comment asserted the opposite of the code. `docs/weekly-updates.mdx` is published, and it told readers the audio work is "staged behind a canary at zero percent, so none of it is visible by default" and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var maps to no registry entry any more, so following the instruction does nothing at all. The week's record stays — it is a dated entry — but it now says the rollout completed and that the variable is inert. * fix(studio): name the mute action per track, and pin the newly-live audio rows Review findings on the canary removal. All three are in code the 0% gate made unreachable, so this is the first time any of it runs for a user. *blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute": the current STATE rather than the action, so nothing told a screen-reader user that activating an already-muted row would unmute it, and it dropped `suffix`, so every audio row shared one accessible name. Music plus VO is the ordinary case, which makes that two identical buttons. Now `Unmute track N` / `Mute track N`, matching the wording `timelineTrackVisibility` already writes into undo history for the same click. `showAsMute` also picks the icon, so this is the control's whole identity, not a tooltip. Tests, for paths that had never executed enabled — a canary at 0% returns `out_of_cohort` before bucketing, and studio additionally excludes `navigator.webdriver`, so no suite could reach them: - `VisibilityButton` — both audio states, two rows staying distinguishable, the visual branch unchanged, and the callback still taking the real track key rather than the display row. Fails on the old label. - `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending order with no groups, and an interleaved group's members become contiguous under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between them keeps its place. Plus label/volume/mute mirroring and the id fallback. Also pins the three retired canary names individually rather than by prefix: `audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a `startsWith("audio-")` check. The family guard stays alongside it. * fix(studio): record the row the mute button announced, not a second derivation Review finding: the header's track number and the undo-history label's are computed from two different orderings, and un-gating `audio-groups` is what makes them diverge. The header's row comes from the group-aware list — `groupTimelineTracks` emits a synthetic anchor row per group and pulls members contiguous. The history's comes from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no anchors. On the fixture in this PR's own derivation test, grouped order `[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this could not happen — the old branch returned raw tracks, so both sides sorted the same way. `onToggleTrackHidden` now carries the display row the clicked control rendered, and `toggleTimelineTrackHidden` prefers it over deriving its own. One number instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of what track number does the user see" already promised. The callback still acts on the real fractional key, so nothing muted the wrong row before or now — only the announced and recorded row was wrong. Also pins the rest of the newly-live surface: the solo button's presence and pressed state, its absence on a visual track, and the strike-through for both a row's own mute and a group mute (with the title that says which). Three existing call-site assertions now check the threaded row too. |
||
|
|
6a92d21401 |
feat(studio,core): reach presets and the rack from the timeline (#3292)
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>
|
||
|
|
254de3d1c4 |
fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of the thirteen criticals; the theme is that the editing UI shipped ahead of its apply/persist pipeline, so several controls mutated an in-memory model with no downstream effect, and the mode itself could never be exited. Mode trap: caption edit mode auto-activated on detection and had no exit — `setEditMode(false)` and `reset()` had zero call sites, so the caption overlay replaced normal element editing for the rest of the session, even after switching compositions. The store now resets on composition change (flushing the last debounced edit first), an "Editing captions · Exit" pill sits on the preview, and a re-enter button appears once dismissed. Honest gating of dead surfaces: the Animation tab (31 presets × duration/ease/stagger/intensity) edited state that was never applied to playback nor serialized — wiring it needs a CaptionOverride schema extension in packages/core plus a runtime engine, so the tab is now visibly disabled with an amber "isn't applied to playback or saved yet" notice instead of silently discarding work. Timing edge-drags moved a block that never changed playback and never saved; the handles are gone and the blocks remain as select/seek targets. Double-click split desynced the overlay↔DOM index mapping, so split is out until regeneration exists. Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target) across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is active and reapplied to the live iframe. Previously ⌘Z reverted an unrelated file edit while the bad caption drag persisted. Autosave: save failures, including non-2xx, raise a persistent "not saved — Retry" banner; the code's own comment called this a data-loss path and it was telemetry-only. Debounced saves flush on unmount instead of being discarded, `beforeunload` flushes and warns while pending, and corrupt overrides JSON is distinguished from a missing file. Input safety and a11y: arrow-key nudge no longer hijacks arrows inside form inputs; numeric fields commit finite values only (typing "-" used to inject NaN into gsap and persist null); "Mixed" shows on multi-select divergence; Escape cancels an in-flight drag and restores the pre-drag transform; ⌘A selects all; caption blocks are keyboard-selectable with a playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed but nothing passed it); 24px hit areas around the 8px handles; a hint when no boxes are visible; visible input focus styles; tablist semantics. Perf: the 66ms getBoundingClientRect polling loop is replaced with event-driven updates (player-store subscription, preview messages, ResizeObserver, rAF-coalesced); the interval now runs only during playback. Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio revamp (#2291), so the mode pill, the sync-error banner and the re-enter button move to its successor, nle/PreviewOverlays.tsx, and the caption track's onSeek is wired in EditorShell. The per-keyframe onChangeKeyframeEase change that also lived in that file is dropped: main removed the prop, and #1967 now routes the diamond menu's ease action to the focused-ease-segment editor instead. Restacked onto main now that PRs 1962-1967 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d26072e6c |
feat(studio,core): mute groups, and hear-only-this that cannot reach the export (#3291)
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>
|
||
|
|
44791c3f7d |
fix(studio): sidebar/panels UX — asset delete confirm, rename, search trap, undo (#1966)
Left-sidebar and slideshow-panel fixes from the studio UX review. Four criticals: an unconfirmed permanent asset delete, a Rename menu item that did nothing, a search box that unmounted itself while its filter stayed applied, and slideshow edits whose persist failures were swallowed. Asset context menu: Delete shows an inline DeleteConfirm before calling the API; the dead Rename item is a working inline rename (validates `/`, `\`, `..`, preserves directory + extension); role="menu"/"menuitem", Escape, arrow-key nav, focus-into-menu, viewport clamping. Assets tab: header controls gate on the UNFILTERED asset count, so a no-match query shows "No assets match" + Clear search instead of unmounting its own input; cards and font rows are keyboard-operable; a copy chip surfaces clipboard failure; the import button owns its pending state; a broken thumbnail names the file type. Slideshow panel: persist failures raise a "Changes not saved — Retry" banner (role="alert") with a working retry; in-panel undo stack (50 snapshots, scoped ⌘Z); branch delete confirms inline; reorder buttons disable at boundaries; HotspotTool explains its prerequisites. Blocks / compositions tabs: "Added!"/"Copied!" are promise-truthful; hover-only overlays reveal on focus; PromptPreviewModal gets the dialog contract + dirty-draft guard; lint dot → labeled count badge; the render button explains "A render is already in progress"; sidebar tabs are a real APG tablist; AudioRow coordinates a single preview at a time. Restacked onto main now that PRs 1962/1963/1964 have squash-merged, so this carries only its own changes. Reconciled against six weeks of main: main's newer interaction model wins (rows drag to the timeline, click reveals the clip or opens the preview, copy is a context-menu action), and this PR's a11y and error surfacing is ported on top of it. The card components main extracted to AssetCard.tsx receive the keyboard activation, focus cues and copy-outcome chip; the "Add at playhead" item main added joins the rewritten menu's arrow-key order; the Catalog tab is unconditional since the blocks-panel flag was removed. The row copy chip is feedback-only — an idle "Copy path" label would describe something the row no longer does. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5fd84c395b |
feat(studio,core): a volume and a living meter on the group row (#3290)
* 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> * fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared Two CI gates, both from this branch's own additions. `File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the group-levels branch pushed it to 605 (cap 600). Extracted the `window.message` router — which already carried a `fallow-ignore-next-line complexity` admitting it had outgrown its home — into `previewMessageRouter.ts`, with the fixture lease, sender check and protocol accept-gate collapsed into one `acceptedPreviewMessage` so the listener is a flat dispatch and the suppression is retired rather than moved. Same branches, same refs, no behaviour change; the file lands at 561. `Test: runtime contract`: `levelBuf: Float32Array` resolves to `Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and `getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345). Pinned the field to `Float32Array<ArrayBuffer>`, which is what `new Float32Array(analyser.fftSize)` already produces. Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")` — main deleted that component in favour of `feedback/StudioFeedbackCard`, and touching this file for the group prop put the dangling path in fallow's scope. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
485c037dcf |
feat(studio,lint): carve targets voiceover groups — always, when plural (#3288)
* feat(core): route grouped audio through a group bus in preview An audio element carrying `data-audio-group` no longer lands its gain on the master bus directly — it feeds a per-group `GainNode` (built lazily on first use, one per group id) which itself feeds master, so members of the same group sum before the ear, ready for a group-level FX chain and volume/mute in later steps. An id with no matching `<hf-audio-group>` element still gets a plain, unprocessed bus rather than losing the track. The group's own chain and volume lane are wired through the same `attachElementFxChain`/`scheduleVolumeLane` every element already uses, against the group's clock — composition time (design doc §1.3), since a group has no `data-start` and a missing one parses as 0. The bus persists across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a group does not rebuild its chain; only `destroy()` disposes it. Render is untouched — stays flat until B4; `audio-groups` is still a 0% canary so nothing ships this to a real composition without hand-authoring `data-audio-group`. Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/ `getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles in this suite, including this file's own `mockEl`. Made it tolerant, same style as `readChain`'s existing guard in `runtime/audioFx.ts`. `schedulePlayback` was already 110 lines pre-existing before this diff; extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92, then suppressed the remainder (inherently sequential graph wiring, not a decision tree) per the same precedent B2 used on `TimelineLogicalRow`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(studio,lint): carve targets voiceover groups — always, when plural Plural voiceover carve now targets a group instead of naming each clip: `resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its current members at analysis time, so a clip added to the group later is covered without touching `sources`. The picker (`useFxCarve.ts`) offers a grouped voice as one option instead of one row per member, tests overlap as a union of member spans (a group overlaps the bed if ANY member does), and prefers a qualifying group over its individual members in `autoSourceIds`. Picking two or more ungrouped voice clips in the carve flow now mints a group behind them (`mintGroupId`, de-duped against every id in the document) and writes `data-audio-group` on each picked clip atomically, one undo entry — `createAudioGroupAndAssignMembers` in `timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target write shape. The DSP is untouched: `mixCarveSources` already sums multiple sources correctly (verified in the design doc's own investigation) — this only fixes the picker. New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/ media.ts`, alongside `audio_volume_double_automation`) warns when a `data-fx-carve`'s `sources` names two or more plain clip ids instead of a group — the shape that silently rots when a clip is added. `/hyperframes- audio` states the same rule as an invariant, not a tip, with the grouped- narration HTML example from the design doc. The group-matching and auto-group logic (`withAutoGroupedSources`, `collectCarveCandidates`) is split into `useFxCarveGrouping.ts` — `useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings` is deliberately NOT an `async function`: wrapping it in one would force a microtask on every call, including the synchronous branch — the exact bug `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid, and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures) before fixing it back to a plain function the caller conditionally awaits. Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and `useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600 lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker IIFE to clear fallow's complexity gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
ba607bf886 |
fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel (#1965)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5e0cc75115 |
feat(core,studio): the character presets pitch shift unlocks (#3277)
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with weight and a compressor to hold the extra low end together, Monster pitches down further with saturation growl and a close, tight reverb. Every param verified against the live effect registry rather than sketched — the compressor/reverb/saturate/shelf keys all match exactly. Each gets its own title treatment (font, size, tracking, hue) so the FX rack's per-preset styling coverage and hue-distance/background-uniqueness tests extend cleanly to the three new entries, and complaint-line copy in the non-voice vocabulary the audit test enforces (no speech words — "Giant" over CapCut's "Deep Voice", as the design doc records). Updates plans/audio-fx-presets.md's two limits paragraphs to record that pitch shift landed and this half of the character list now ships; Robot and Alien stay out of scope (ring modulation, still unbuilt). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
43c4e6935e |
feat(studio): make presets the primary path into the FX rack (#3274)
Presets button becomes the stacked primary control (bold, filled outline);
Add-effect demoted to a small trailing link ("+ effect"). Button onClick
bodies and audition-revert logic are unchanged.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
64b94ebf3a |
fix(studio): resume drag-paused timelines instead of only re-seeking (#1876)
Drag start pauses every window.__timelines entry and records the list in data-hf-drag-paused-timelines; resumeGsapTimelines then removed the attribute and only re-seeked the player, never unpausing anything. The main timeline survives (seek-driven every frame) but play-state-driven sub-composition timelines froze permanently after any element drag, and deselecting could not recover them. Now unpauses exactly the recorded ids (never touching timelines the drag did not pause) before the player re-seek. Verified live: after a real drag on an animated element all scene timelines stay unpaused. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b4d5abd7b2 |
fix(studio): capture storyboard tiles at review density (#3371)
* fix(studio): capture storyboard tiles at source resolution * fix(studio): bound storyboard tile captures |
||
|
|
f0e637375f |
fix(studio): capture the storyboard frame hero at full resolution (#3338)
The thumbnail route bounds every preview capture to 240x135. That bound came from the timeline, where thumbnails are small and numerous and their decoded bytes are budgeted. The storyboard reuses the same route for its frame detail hero, which is up to 900px wide, so the poster arrived at 240x135 and upscaled past 7x on a retina display. Headlines survived it; body copy, table labels and captions did not. That is the surface where it costs the most. references/review-loop.md sends the user here to confirm layout and real copy, and tells them to run no CLI in that pass: "the poster is the only picture this pass needs". Give the caller a way to ask for the composition's own dimensions, which the route already supports as `output=source`, and fold the choice into a single `surface` prop. Whether a poster is a tile or the hero decides both the crop and the capture density, so one prop owns both rather than two that can disagree. The contact sheet keeps the bounded capture: many tiles, and it is a contact sheet. The timeline is untouched. Reported with a reproduction and a correct read of the consequences in #3271. Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com> |
||
|
|
228eabd43f |
fix(studio): make the volume fader tell the truth about the gain it writes (#3305)
* fix(studio): make the volume fader tell the truth about the gain it writes The fader travels in dB, so its stops are irrational values; serializing them through the generic two-decimal numeric formatter collapsed the bottom quarter of its travel onto "0" — a hard mute — and made the knob jump on release everywhere below unity. Both panels now use the exact serializer, which round-trips every integer stop back to itself. Raise the volume automation lane to the same ceiling the fader reaches. Clamping the lane at unity meant automating a boosted clip silently discarded the boost, and the panel disables the fader while a lane owns the level, so there was no way back. This rescales the lane's vertical axis: unity now sits a quarter of the way up rather than at the top. Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute — they replace the authored gain rather than scaling it — so a clip carrying both plays at whatever the tween names, and the fader gives no sign of it. The rule reuses the tween detector the sibling lane/tween rule already has. * fix(lint): treat a missing data-volume as unity, not as silence readAttr returns null when the attribute is absent, and Number(null) is 0 — finite, and not 1 — so a clip carrying NO data-volume cleared both filters and was reported as authored at silence. Both halves of that were false: absent means unity everywhere else in the runtime. It fired on exactly the case the rule exists to bless. The docs this PR edits say data-volume is the baseline for elements no tween touches, so a tweened clip is expected not to carry one — the common audio fade. A warning does not fail check, but an agent reading the fixHint would have written a gain to correct a level that was never wrong. |
||
|
|
ec0b23f3ce |
fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first Select all in the timeline, press Delete, and one clip disappeared while the rest stayed — still drawn as selected. The Delete hotkey built the selection set correctly and then called `elements.find(...)`, which stops at the first match, and handed that single element to a handler that deletes exactly one. The comment above it claimed the handler "expands a clip that is part of the multi-selection into an atomic delete of the whole selection (single undo)" — no such expansion existed anywhere; `useTimelineEditing` never read `selectedElementIds`. `handleTimelineElementsDelete` takes the whole selection and removes every element before saving once, so the delete is a single history entry and a single undo — what the comment already promised. The hotkey layer now takes only that plural handler, since it never deletes one element in isolation; the singular entry point stays for the context menu and clip chrome. The store drops every deleted key and clears the marquee set, rather than leaving a selection drawn around clips that no longer exist. Elements whose `sourceFile` is not the composition being edited are dropped from the pass rather than written to the wrong file. Also removes the preview's double-click-to-reset-zoom. It was a document-level capture listener, so any double-click anywhere over the viewport snapped the zoom back to fit — including double-clicks meant for the content under it. The explicit reset control beside the zoom HUD stays. Reproduced by test: restoring `elements.find` reds the new marquee case. * fix(studio): delete every canvas element in the selection, not just the primary Selecting several elements on the canvas and pressing Delete removed one of them and left the rest — still drawn as selected. The delete path only ever took the primary selection; the marquee group it belongs to was ignored. Expand the session-level delete through the group ref, the same way the other group commits already do, and let the lifecycle op remove every member under a single save so one Undo restores the whole selection. * fix(studio): let the canvas selection own Delete instead of its timeline mirror Marquee-selecting elements on the canvas and pressing Delete removed a fraction of them. The hotkey routed to the timeline delete whenever the timeline store held anything, and the timeline's copy of a canvas selection is derived and lossy by construction — a member with no timeline row of its own is dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59 stayed, still drawn as selected. The canvas selection is what the user drew the marquee around, so it owns Delete whenever it holds something; the timeline path stays as the fallback for rows with no canvas node to select. Both paths already remove through the same endpoint, so this is one addressing scheme replacing two. That makes the canvas delete the path a Delete press normally takes, so it picks up the same mid-recording refusal the timeline delete has. * fix(studio): let the marquee see the whole document, not the first 80 elements Dragging a marquee over the entire canvas selected a fraction of what it covered, so Delete left most of the page behind. The hit test sourced its candidates from the layers-panel collector, which stops after 80 items — a budget for how many rows that panel is willing to render, silently reused as if it described the document. Everything past the 80th element in document order was unselectable no matter where the user dragged. The off-canvas indicators were reading the same truncated list. The cap now belongs to the panel that wants it; the collector returns everything. To pay for that, the marquee measures its candidates once when the drag passes the threshold instead of re-reading layout for every element on every pointer-move: unbounded plus per-move stalled the tab outright, and the iframe DOM does not mutate mid-drag, so one pass stays true for the gesture. On a captured page: one marquee, one Delete, 734 elements down to 81. * fix(studio): report a no-op delete instead of claiming the elements went A target the file no longer holds answers `changed: false`, which is normal for a member nested inside another member already removed. Every target answering that is not — it means the preview is describing a document the file does not have, so each removal misses and the file is written back untouched. The toast still said "Deleted 503 elements. Use Undo to restore them." That is how a delete that did nothing at all looked from the outside: press Delete, the page stays, nothing on screen explains it. Say the preview is out of date and reload it instead. * fix(studio): keep the canvas hotkeys alive across preview reloads Pressing Delete with a canvas selection did nothing at all — no removal, no toast, nothing on screen to explain it. A keypress goes to whichever document has focus, and clicking the canvas puts focus inside the preview iframe, so the app's hotkeys have to be forwarded there. They were, but only from the iframe element's ref callback, which fires when the element mounts. A preview reload keeps the same element, so the callback never runs again, and keeps the same WindowProxy, so the forwarder's identity check saw no change and skipped re-attaching — while the inner window holding the listeners had been replaced. After the first reload the canvas had no app hotkeys left. Undo and redo kept working because their forwarder re-attaches on every load, which is why this read as "only Delete is broken". Fold the app handler into that per-load forwarder so both attach in the same place, on every load, and drop the mount-only one. Window only: the history pair also listens on the document, and capture listeners on both would run the app handler twice per press. * perf(studio): stop re-probing every restored selection member on load The hash carries the whole canvas selection, and restoring it asked the server whether each member still exists in the source — one request per member, awaited one after another. A marquee over a captured page puts hundreds of members in the URL, so every later load of that URL spent hundreds of serial round trips rebuilding the selection before the canvas answered anything, keypresses included. The marquee that produced those members already skips the probe. Restoring them skips it too; only the primary, whose panel reads the flag, still pays for one. * fix(studio): delete a canvas selection in one pass and say the key landed Reproduced with a real, focus-routed keypress instead of a synthetic one: the press does reach the handler and the delete does run to completion, but at hundreds of members it takes seconds during which the canvas is unchanged and nothing acknowledges the key. Silence for that long is indistinguishable from Delete being broken, and pressing it again or reloading mid-flight lands in a worse state. Two things, one per cause. The removal now sends the whole selection in a single request against a new remove-elements route, which reads the file once, drops every member and writes once — it was a round trip AND a full rewrite of the file per element. And a multi-element delete announces itself before the work starts, so the press is visibly acknowledged instead of leaving the canvas looking untouched until it finishes. Measured on a captured page, 84 members: 933ms of serial round trips against 84 rewrites, down to 583ms and one. * refactor(studio): narrow the SDK delete targets instead of asserting them The batch SDK path guarded on every member having an hfId and then asserted it away per member. Narrow once into a string list so the guard and the values come from the same place, and drop a threaded content variable that never changed — the SDK owns the document it edits, so every member is removed against the same starting content. Also mounts the new forwarding test through the existing harness rather than repeating its setup. * fix(studio): stop Delete acting on a canvas selection the user replaced Two things the reordered Delete arbitration got wrong, both found in review. A clip with no canvas node left the canvas selection pointing at whatever was picked before it, and the canvas branch wins whenever that ref is non-null — so selecting an audio clip and pressing Delete removed the previously selected canvas element and left the clip, right after the toast said the clip was not in the preview. The timeline fallback the comment described could not be reached. Clearing that selection has to stay quiet: the clear is announced to the timeline, so echoing it would deselect the clip that was just picked. Expanding the primary to the marquee group also moved out of the delete handler and up to the Delete key. Cut copies the primary alone, so expanding for every caller put one element on the clipboard and removed every other member with it — undo brought them back, paste restored one. The rule is a named function now, so the two callers can differ without either guessing. Also throttles the off-canvas indicator rebuild, which the cap had been hiding. It walks every element in the preview and reads layout for each — measured at 6.5ms on an 825-element captured page against a 16.7ms frame — and what marks it dirty is a MutationObserver on inline style, which is how animation writes. * fix(studio): hold the canvas selection inside the timeline selection The stale-canvas-selection defect survived at the second writer. The store-driven sync bails when a member has not resolved yet and returned without touching the canvas, so a pick with no canvas node at all left the previous selection in place — and Delete acts on the canvas first, so it deleted that. Reachable from the sidebar audio and asset reveals and from an asset drop, none of which go through the handler already fixed. Clearing on every bail would be wrong: the bail exists for a member whose node is not ready, which a later run resolves, and clearing there would flicker. Only a canvas anchor that resolves OUTSIDE the current selection goes, which is the state that is dangerous rather than merely unfinished. Quietly, for the same reason as the first writer: announcing would deselect the clip just picked. The invariant is named now, since Delete depends on it: the canvas selection never points outside the current timeline selection. Also drops the x-hf-removed header, which nothing read and whose comment promised a partial-vs-no-op distinction the response cannot make, and pins the indicator throttle that was measured but uncovered. |
||
|
|
e282ff15cc |
fix(audio): raise the authoring gain ceiling and carry it through the probes (#3333)
* fix(audio): raise the authoring gain ceiling and carry it through the probes Builds on #3328, which made the preview graph apply author gain and user volume exactly once each. That ownership is now correct but everything is still clamped to 1.0, so a clip authored above unity cannot be heard or rendered. `HTMLMediaElement.volume` is spec-clamped to [0,1], so both timeline probes lost a clip's authored gain the moment it also carried a fade: the probe seeded the element at the clamped value and every sample read back at or below 0 dB, and the mixer prefers probed keyframes over the static volume. Both probes now shadow the accessor for their own duration and forward the clamped value to the native setter, so the authored gain survives while nothing outside the probe ever sees an illegal volume. Measured on one 6 s composition, first 4 s: unity -32.8 LUFS, boosted-with-fade -32.8 before and -27.0 after — +5.8 dB, exactly the gain the clip was authored at. One ceiling, defined once in `audioGain.ts` and reachable from both sides: the render mixer imports it, and the page-serialized probe takes it as a parameter rather than re-literalling it. User volume stays spec-clamped — it is a fader, not a gain. Also holds the percent volume slider above unity in both property panels. That control tops out at 100%, so one touch would cap a boosted clip and drop up to 12 dB that now genuinely renders; the dB fader that can represent these levels replaces it in the next PR. * fix(audio): carry a static above-unity gain onto the preview gain node Review follow-up. `setElementVolume` receives the clip's author gain and clamped it to [0,1], so a static `data-volume` above unity was capped on the WebAudio preview path while the render honoured it — the exact preview/render divergence this ceiling exists to close. Automation lanes hid it: they schedule ramps onto the param directly and never pass through here. The master volume beside it stays spec-clamped, because a user fader is not a gain. Verified by mutation: restoring the [0,1] clamp reds the new case. Also scope the leveller's rationale to this rung — `VOLUME_RANGE` still stops at unity until the dB fader lands, so "both now span the same range" was premature — and say why the GSAP-tracking fallback is unity-capped: it reads back through `el.volume`, which the spec pins to [0,1], so it cannot observe an above-unity value however wide the clamp gets. * fix(audio): restore the live test files this branch overwrote, and uncap preview Review blocker: three files were wholesale copies from the abandoned #3304 branch laid over a two-day-newer base, so they silently reverted work that had landed in between. CI could not see it — deleted tests do not fail. - `audioMixer.test.ts` was byte-identical to #3304's head: 1186 lines against a base of 1353. Gone with it were the `data-playback-start` fallthrough cases from #3322 — merged 54 minutes before this branch's own merge base — and all retiming coverage (`playbackRate` 7 to 0, `atempo` 5 to 0), the strict literal-timing table, and the zero-window cases. - `mediaVolumeEnvelope.test.ts` dropped the trailing-garbage duration case and the plateau-retention case. - `packages/core/package.json` rolled the package version back 0.8.3 to 0.7.109. All three are restored from `main` with only this PR's additions re-applied on top, and the subpath export is regenerated by the repo's own script rather than hand-edited. Also closes the preview/render split the same review raised. Two clamps had to go, not one: `setElementVolume` capped the author gain at the transport, and the first-tick branch in `syncRuntimeMedia` trusted `el.volume` — which is spec-bound to [0,1] and so cannot represent a boost, opening a boosted clip at 0 dB for one tick before the steady-state branch took over. Both pinned by tests, both verified by mutation. |
||
|
|
5058236eda |
feat(studio): prompt to install FFmpeg before Export, not after (#3314)
Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.
Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.
- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
runs, so Studio and the CLI cannot disagree. Only a passing result is
cached.
- The refusal lives in startRender, not in a button. Studio renders from
three places (the panel's Export, the header's, and each composition card
in the sidebar), so a per-button check would leave the others free to
queue a render that cannot finish. The header and sidebar controls reveal
the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
the prose hint derived from it. Windows gains a winget command and keeps
the manual download route.
Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.
Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
|
||
|
|
35eb6d2906 |
refactor(studio): split the two files over the 600-line cap (#3313)
The file size check has been failing on main. It is diff-scoped on a PR but full-scans packages/studio on push, so two files that crept over the cap were only ever caught after merge, and every release since has been red: TimelineAutomationLane.tsx 674 lines StudioRightPanel.tsx 609 lines Both are pure code moves. No behavior change. TimelineAutomationLane.tsx keeps the single-lane editor and gives up the track-level layer: ClipLaneRow, ClipAutomationLanes and TimelineAutomationLaneSlot move to TimelineAutomationLaneSlot.tsx, which is the name its test file already used. The dependency runs one way, slot -> lane, so there is no cycle. 674 -> 499. StudioRightPanel.tsx gives up its props interface to a sibling .types.ts. That block is the part that changes least, so moving it keeps the component's own diffs small; two in-flight branches touch this file and both are based on a 556-line copy of it, so keeping the cut away from the body matters. 609 -> 568. Verified by running the CI rule's full-scan branch over the 679 tracked packages/studio source files: no file over 600, exit 0. Studio suite green at 2790 passed across 226 files, and typecheck clean. |
||
|
|
d6c4774ef4 |
feat(studio): instrument the audio FX rack, including work an agent did (#3229)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. * refactor(studio): break up the FX rack's largest functions and files Fallow flagged 9 complexity findings and 2 file-size violations after the telemetry stack landed. Extracts FxPresetRun, FxAddMenu, FxRackChain, FxNodeOpenBody, FxNodeParams, and useFxAudition/useFxCarve/useFxLevelling/ useFxChainObserved out of propertyPanelFxSection.tsx and propertyPanelAudioFxGroup.tsx, splits propertyPanelFxNodeRow.tsx's open-face rendering into its own component, and dedupes a clone in studioTelemetry.ts. Pure structural move — no behavior change; full test suite still green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e3ec48adce |
feat(studio): fold a preset shut, and give each one its own title design (#3191)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. * chore: fix markdown formatting --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6bcf739390 |
fix(studio): audition from the playhead while paused, and let an author out of a menu (#3190)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
acdb11250c |
feat: switch a preset off, or ramp it, as one thing (#3189)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
29f516d490 |
feat: one knob for the five effects that cannot honestly have one (#3188)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2020b82c9b |
feat(studio): family lettering, tints, and the rack as a signal path (#3187)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
96fd4d061e |
feat(studio): two faces, and the shared frequency ruler (#3186)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31fa523b7e |
feat: offer the job, not the machine — the range IS the module (#3185)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0e4da52c82 |
feat(studio): make the FX rack speak the author's language (#3192)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2f97fc2b89 |
refactor(studio): lift the effect row out of the FX section file (#3182)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c327740c79 |
refactor(studio): lift the carve out of the FX section file (#3181)
* 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-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * 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. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |