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 16:39:29 -07:00
co-authored by Claude Sonnet 5
parent 5dc93a25ad
commit 351b219d2b
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}