mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
co-authored by
Claude Sonnet 5
parent
8d48a6f52f
commit
1abad17650
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user