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>
This commit is contained in:
Vance Ingalls
2026-08-20 02:11:37 -07:00
co-authored by Claude Sonnet 5
parent 8d48a6f52f
commit 1abad17650
22 changed files with 914 additions and 68 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import type { StudioRightPanelProps } from "./StudioRightPanel.types";
export type { StudioRightPanelProps };
@@ -20,15 +20,14 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { usePlayerStore } from "../player";
import { waitForMediaJob } from "./studioMediaJobs";
import {
applyColorGradingScopeUpdate,
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
import { timelineKeysForSelections } from "../utils/studioHelpers";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
import { useRemoveBackground } from "../hooks/useRemoveBackground";
// fallow-ignore-next-line complexity
export function StudioRightPanel({
@@ -45,6 +44,7 @@ export function StudioRightPanel({
domEditSaveTimestampRef,
recordEdit,
onToggleElementHidden,
onAutoGroupCarveSources,
onAddMediaOverlay,
}: StudioRightPanelProps) {
const {
@@ -163,14 +163,6 @@ export function StudioRightPanel({
handleInspectorSplitResizeMove,
handleInspectorSplitResizeEnd,
} = useInspectorSplitResize();
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
useEffect(
() => () => {
backgroundRemovalAbortRef.current?.abort();
},
[],
);
const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -237,52 +229,7 @@ export function StudioRightPanel({
],
);
const handleRemoveBackground = useCallback(
// fallow-ignore-next-line complexity
async (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputPath,
createBackgroundPlate: options.createBackgroundPlate === true,
quality: options.quality ?? "balanced",
}),
},
);
const data = (await response.json().catch(() => ({}))) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
throw new Error(data.error || `Background removal failed (${response.status})`);
}
showToast("Removing background...", "info");
backgroundRemovalAbortRef.current?.abort();
const controller = new AbortController();
backgroundRemovalAbortRef.current = controller;
try {
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
await refreshFileTree();
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
return result;
} finally {
if (backgroundRemovalAbortRef.current === controller) {
backgroundRemovalAbortRef.current = null;
}
}
},
[projectId, refreshFileTree, showToast],
);
const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
/**
* A dial being dragged writes to the preview and stops there.
@@ -328,6 +275,7 @@ export function StudioRightPanel({
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onToggleElementHidden={onToggleElementHidden}
onAutoGroupCarveSources={onAutoGroupCarveSources}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
@@ -51,5 +51,6 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: ToggleHiddenHandler;
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
onAddMediaOverlay?: AddMediaOverlayHandler;
}
@@ -79,6 +79,7 @@ export function PropertyPanelFlat({
onRemoveTextField,
onAskAgent,
onToggleElementHidden,
onAutoGroupCarveSources,
onImportAssets,
onAddMediaOverlay,
onImportFonts,
@@ -440,6 +441,7 @@ export function PropertyPanelFlat({
element={element}
onSetAttributeQuiet={onSetAttributeQuiet ?? onSetAttributeLive}
onSetAttributeLive={onSetAttributeLive}
onAutoGroupCarveSources={onAutoGroupCarveSources}
/>
),
});
@@ -1507,6 +1507,142 @@ describe("AudioFxGroup carve source list", () => {
});
});
describe("AudioFxGroup carve targets groups (B6)", () => {
// These tests are the only ones in this file whose auto-carve effect makes a
// real cross-element write call (`onAutoGroupCarveSources`), which can be a
// genuine Promise. None of this file's other `mount*` helpers ever unmount
// their React root — harmless everywhere else because their effects only
// ever touch a plain `onSetAttributeQuiet` mock, so an orphaned root left
// over from an earlier test does nothing observable if it ever re-renders.
// Here it can re-fire the auto-group effect against WHATEVER a later test's
// fixture put in the (file-global) `document`, calling a long-dead test's
// mock — unmounting is what prevents that.
const roots: ReturnType<typeof createRoot>[] = [];
afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
});
/** A bed, plus tracks that may carry `data-audio-group`, plus an optional auto-group handler. */
const mountWith = (
tracks: { id: string; group?: string; start?: string; duration?: string }[],
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>,
) => {
const bed = document.createElement("audio");
bed.id = "bed";
document.body.append(bed);
for (const t of tracks) {
const el = document.createElement("audio");
el.id = t.id;
if (t.group) el.setAttribute("data-audio-group", t.group);
if (t.start !== undefined) el.setAttribute("data-start", t.start);
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
document.body.append(el);
}
const onSetAttributeQuiet = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
roots.push(root);
act(() => {
root.render(
<AudioFxGroup
element={{ dataAttributes: {}, id: "bed", element: bed } as unknown as DomEditSelection}
onSetAttributeQuiet={onSetAttributeQuiet}
onSetAttributeLive={vi.fn()}
onAutoGroupCarveSources={onAutoGroupCarveSources}
/>,
);
});
const offered = Array.from(host.querySelectorAll<HTMLElement>("[data-carve-source]"));
const options = offered.map((el) => el.dataset["carveSource"] ?? "");
const boxes = offered.filter((el): el is HTMLInputElement => el instanceof HTMLInputElement);
return { host, options, boxes, onSetAttributeQuiet };
};
it("offers one entry for a group and hides its members individually", () => {
const { options } = mountWith([
{ id: "vo-1", group: "voiceover" },
{ id: "vo-2", group: "voiceover" },
]);
expect(options).toEqual(["voiceover"]);
});
it("offers the group when only ONE member overlaps the bed (union, not per-clip)", () => {
// The bed spans the whole default window (no start/duration on the
// selection itself in this harness resolves to [0, Infinity)), so give the
// members explicit, non-overlapping-with-each-other spans and confirm the
// group still appears as long as at least one of them is in range.
const { options } = mountWith([
{ id: "vo-1", group: "voiceover", start: "0", duration: "5" },
{ id: "vo-2", group: "voiceover", start: "1000", duration: "5" },
]);
expect(options).toEqual(["voiceover"]);
});
it("auto-selects the group over its individual members", () => {
const { onSetAttributeQuiet } = mountWith([
{ id: "vo-1", group: "voiceover" },
{ id: "vo-2", group: "voiceover" },
]);
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
});
it("auto-groups a multi-voice carve into one named group, atomically", async () => {
// Two ungrouped, both voice-classified: the existing "every candidate
// carves itself" mount effect names both by id, which is exactly the
// plural-ungrouped case B6 intercepts — the carve should land on the
// minted group, not on the two ids directly.
const onAutoGroupCarveSources = vi.fn().mockResolvedValue(undefined);
const { onSetAttributeQuiet } = mountWith(
[
{ id: "narration", start: "0", duration: "5" },
{ id: "interview-guest", start: "10", duration: "5" },
],
onAutoGroupCarveSources,
);
expect(onAutoGroupCarveSources).toHaveBeenCalledWith(
["narration", "interview-guest"],
"voiceover",
);
// Explicitly await the exact promise `assignGroup` returned — a bare
// `await Promise.resolve()`/`setTimeout` flush is guessing how deep the
// chain behind it goes (its `.then(...)`, the write, and the re-analysis
// `setCarve` awaits afterward). Left genuinely unresolved when this test
// returns, that chain settles during a LATER test instead, after this
// one's mocks and DOM are gone.
await act(async () => {
await onAutoGroupCarveSources.mock.results[0]?.value;
// Two more turns of the microtask queue: one for the `.then(...)` that
// builds the grouped settings, one for `setCarve`'s own trailing
// `await analyse(next)` (a no-op here — the fixture's tracks have no
// `src`, so `resolveCarveVoices` returns empty and `analyse` exits
// immediately, but it still has to actually run to completion).
await Promise.resolve();
await Promise.resolve();
});
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
expect(write).toBeTruthy();
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
});
it("leaves sources alone when one of them already names a group", () => {
const onAutoGroupCarveSources = vi.fn();
const { onSetAttributeQuiet } = mountWith(
[
{ id: "vo-1", group: "voiceover" },
{ id: "vo-2", group: "voiceover" },
],
onAutoGroupCarveSources,
);
// The default-carve effect already named the group (previous test above),
// so no auto-group call should ever fire for an all-group source list.
expect(onAutoGroupCarveSources).not.toHaveBeenCalled();
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
});
});
describe("AudioFxGroup carve source range", () => {
const spanned = (tracks: { id: string; start?: string; duration?: string }[]): string[] => {
const bed = document.createElement("audio");
@@ -55,6 +55,7 @@ export function AudioFxGroup({
element,
onSetAttributeQuiet: onSetAttributeQuietRaw,
onSetAttributeLive,
onAutoGroupCarveSources,
}: {
element: DomEditSelection;
/**
@@ -70,6 +71,8 @@ export function AudioFxGroup({
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>;
/** Continuous, non-persisting write for a dial being dragged. */
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
/** Write `data-audio-group` on every named clip, atomically, one undo entry. */
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
}) {
const chain = ((): HfAudioFxChain => {
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
@@ -231,6 +234,7 @@ export function AudioFxGroup({
onSetAttributeQuiet,
writeAutomation,
setAnalysing,
onAutoGroupCarveSources,
);
const { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller } =
@@ -27,6 +27,7 @@ export type PropertyPanelFlatProps = Pick<
| "onRemoveTextField"
| "onAskAgent"
| "onToggleElementHidden"
| "onAutoGroupCarveSources"
| "onImportAssets"
| "onAddMediaOverlay"
| "onImportFonts"
@@ -92,6 +92,8 @@ export interface PropertyPanelProps {
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
/** B6: group two or more picked voice clips, atomically, one undo entry. */
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onAddMediaOverlay?: AddMediaOverlayHandler;
fontAssets?: ImportedFontAsset[];
Binary file not shown.
@@ -0,0 +1,132 @@
/**
* B6's normative rule: plural voiceover carve always targets a group.
*
* Split out of `useFxCarve.ts`, which owned all of this before the file grew
* past a size where "carve targets a group" was still one thing to read
* same reason `useFxCarve.ts` itself was split out of
* `propertyPanelAudioFxGroup.tsx`.
*/
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
/**
* An id for a new voiceover group, de-duped against every id already in the
* document group ids and plain element ids share one namespace, so both
* have to be checked.
*/
function mintGroupId(doc: Document): string {
const taken = new Set([
...resolveAudioGroups(doc).map((g) => g.id),
...Array.from(doc.querySelectorAll("[id]")).map((el) => el.id),
]);
if (!taken.has("voiceover")) return "voiceover";
let n = 2;
while (taken.has(`voiceover-${n}`)) n += 1;
return `voiceover-${n}`;
}
export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as { then?: unknown })?.then === "function";
}
/**
* Plural voiceover carve, always against a group normative, not a
* suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
* group behind the two of them and points the carve at it instead, the same
* way a hand-authored composition is expected to work; a source list already
* naming a group is left alone; this only fires on a run of plain clip ids.
*
* Returns the settings unchanged, synchronously, when there is nothing to do
* NOT wrapped in a promise even though the caller may `await` the result. An
* `async` function always yields a microtask, which would push every
* `setCarve` call (grouped or not) one tick later than before this existed;
* several tests assert on `onSetAttributeQuiet` synchronously after mount and
* would miss that write.
*/
function withAutoGroupedSources(
doc: Document,
next: HfCarveSettings,
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
): HfCarveSettings | Promise<HfCarveSettings> {
if (!assignGroup || next.sources.length < 2) return next;
const groupIds = new Set(resolveAudioGroups(doc).map((g) => g.id));
if (next.sources.some((id) => groupIds.has(id))) return next;
const groupId = mintGroupId(doc);
return assignGroup(next.sources, groupId).then(() => ({ ...next, sources: [groupId] }));
}
/**
* `setCarve`'s first step: apply the auto-group rule, if a document and a
* write-back are both available.
*
* Deliberately NOT an `async function`: wrapping this in one would make every
* call return a promise-wrapped value, forcing the caller's `await` to yield
* a microtask even on the synchronous branch the exact bug
* `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid.
* The caller does the `isPromiseLike` check (re-exported from here) and only
* awaits the branch that is genuinely async.
*/
export function resolveNextCarveSettings(
nextRaw: HfCarveSettings | null,
doc: Document | undefined,
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
): HfCarveSettings | Promise<HfCarveSettings> | null {
return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
}
export interface CarveCandidate {
id: string;
label: string;
kind: ReturnType<typeof classifyAudioName>;
}
/**
* One row per ungrouped clip that overlaps the bed, one row per group that has
* ANY overlapping member (union, not per-clip a narration group spanning
* the whole timeline is relevant even if each of its segments only overlaps
* part of the bed). Grouped members never appear individually.
*/
export function collectCarveCandidates(
doc: Document,
others: readonly HTMLAudioElement[],
overlapsBed: (a: Element) => boolean,
): CarveCandidate[] {
const groupByMember = new Map(
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
);
const offeredGroupIds = new Set<string>();
const described: CarveCandidate[] = [];
for (const a of others) {
const group = groupByMember.get(a.id);
if (!group) {
if (overlapsBed(a)) {
described.push({
id: a.id,
label: a.id,
kind: classifyAudioName(a.id, a.getAttribute("src")),
});
}
continue;
}
if (offeredGroupIds.has(group.id)) continue;
const members = group.memberIds
.map((id) => doc.getElementById(id))
// Not `instanceof HTMLAudioElement`: these belong to the composition's
// iframe document, so the constructor is a different realm's and the
// instanceof is false for every one (mirrors resolveCarveVoices in
// useFxCarve.ts).
.filter((el): el is HTMLElement => el?.tagName === "AUDIO");
if (!members.some(overlapsBed)) continue;
offeredGroupIds.add(group.id);
described.push({
id: group.id,
label: `${group.label} (${members.length})`,
kind: classifyAudioName(
group.label,
...members.flatMap((m) => [m.id, m.getAttribute("src")]),
),
});
}
return described;
}