mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user