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
+48
View File
@@ -555,3 +555,51 @@ describe("audio_volume_double_automation", () => {
expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(false);
});
});
describe("audio_carve_ungrouped_sources", () => {
const withCarve = (carveJson: string, extra = "") => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<audio id="bed" src="bed.wav" data-start="0" data-duration="10" data-fx-carve='${carveJson}'></audio>
${extra}
</div>
</body></html>`;
it("warns when sources names two or more plain clip ids", async () => {
const res = await lintHyperframeHtml(
withCarve(`{"enabled":true,"sources":["vo-1","vo-2"],"strength":0.35}`),
);
const finding = res.findings.find((f) => f.code === "audio_carve_ungrouped_sources");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("bed");
});
it("stays quiet when sources names a group", async () => {
const res = await lintHyperframeHtml(
withCarve(
`{"enabled":true,"sources":["voiceover"],"strength":0.35}`,
`<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>`,
),
);
expect(res.findings.some((f) => f.code === "audio_carve_ungrouped_sources")).toBe(false);
});
it("stays quiet for a single-clip sources list", async () => {
const res = await lintHyperframeHtml(
withCarve(`{"enabled":true,"sources":["narration"],"strength":0.35}`),
);
expect(res.findings.some((f) => f.code === "audio_carve_ungrouped_sources")).toBe(false);
});
it("still warns when one entry is a group and the rest are plain clip ids", async () => {
// Mixing a group with two more bare clip ids is still an ungrouped-source
// rot risk for those two clips — only fully-grouped sources are silent.
const res = await lintHyperframeHtml(
withCarve(
`{"enabled":true,"sources":["voiceover","vo-3","vo-4"],"strength":0.35}`,
`<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>`,
),
);
const finding = res.findings.find((f) => f.code === "audio_carve_ungrouped_sources");
expect(finding?.severity).toBe("warning");
});
});
+47
View File
@@ -632,6 +632,8 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
// audio_volume_tween_overrides_gain
findVolumeTweenOverridesGainFindings,
// audio_carve_ungrouped_sources
findCarveUngroupedSourcesFindings,
];
/**
@@ -722,3 +724,48 @@ function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFin
}
return findings;
}
/**
* A carve's `sources` naming two or more plain clip ids is the normative
* mistake groups exist to prevent (groups doc §1.6): the list silently rots
* when a voice clip is added or removed, since nothing re-derives it. Naming
* a group instead means membership resolves at analysis time. Silent when
* `sources` already names a group, or names at most one clip.
*/
function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFinding[] {
const groupIds = new Set(
ctx.tags.filter((tag) => tag.name === "hf-audio-group").map((tag) => readAttr(tag.raw, "id")),
);
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
const raw = readDecodedAttr(tag.raw, "data-fx-carve");
if (raw === null) continue;
const trimmed = raw.trim();
if (!trimmed.startsWith("{")) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
const sources = (parsed as { sources?: unknown }).sources;
if (!Array.isArray(sources)) continue;
const clipIds = sources.filter(
(id): id is string => typeof id === "string" && !groupIds.has(id),
);
if (clipIds.length < 2) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "audio_carve_ungrouped_sources",
severity: "warning",
message: `${elementId ? `#${elementId}'s` : "This"} carve names ${clipIds.length} voice clips directly (${clipIds.join(", ")}) instead of a group.`,
elementId,
fixHint:
"Group the voice clips and carve against the group — a hand-rolled clip list silently rots when a clip is added.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}