mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(core,studio): a voice track is never a carve bed, and never carves its own group
A narration clip inside a Voiceover group had a carve pointed at that group — a member ducking the bus it feeds. Three faults, each sufficient on its own. **No bed-eligibility rule.** `couldBeCarveSource` has said since it was written that music and sfx cannot be sources, and it is called from nowhere — exported, tested, dead. Nothing ever asked the near-end question: can this track be the BED. `showCarve` only asked "is anything already carving against me, and is there anything to listen to", so a voice track was offered the control like any other. Added `couldBeCarveBed` beside its sibling and wired it in. **Offering is not applying.** A bed with exactly one candidate carves itself unasked, which is right for a track named `music-bed` and wrong for one named `a1` — a decision taken off a name that said nothing is how a carve appears that nobody remembers configuring. `isNamedCarveBed` gates self-application on a name that positively reads as a bed; the picker stays looser, the same split the source side already makes between `sourceOptions` and `autoSourceIds`. **A bed was offered its own group.** The candidate scan excluded exactly one element, the bed itself. Its siblings survived that filter and rolled up into the very group the bed belongs to, which came back as a candidate — and being the only one, was applied. The mirror case too: a group bed's id matches no <audio> id, so nothing stopped a group carving against itself. `collectCarveCandidates` now takes the bed's id and drops both it and its group. An existing carve still shows its module (`carve !== null`), so nothing already configured becomes unreachable — only newly offered and self-applied ones are refused. The bed/relationship predicates moved to `useFxCarveGrouping.ts`, next to the source-eligibility rules they belong with. That is also what puts `useFxCarve.ts` back under the 600-line ceiling it crossed here. Five tests, each mutation-checked against the pre-fix code. Verified live: selecting `vo-2` renders no carve module; `music-bed` still gets one, listening to `Voiceover (4)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f26f48b3a6
commit
a387850032
@@ -10,6 +10,8 @@ import {
|
||||
clipsOverlap,
|
||||
mixCarveSources,
|
||||
couldBeCarveSource,
|
||||
couldBeCarveBed,
|
||||
isNamedCarveBed,
|
||||
DEFAULT_CARVE,
|
||||
normalizeCarveSettings,
|
||||
} from "./audioCarve.js";
|
||||
@@ -538,6 +540,30 @@ describe("classifyAudioName", () => {
|
||||
expect(couldBeCarveSource("sfx-explosion")).toBe(false);
|
||||
});
|
||||
|
||||
// The near-end rule, which nothing used to ask. `couldBeCarveSource` shipped
|
||||
// with its own doc comment ("music and sfx are out") and no caller; the bed
|
||||
// side had no predicate at all, so a narration clip was offered the carve and
|
||||
// — finding one candidate — had one applied for it, against the group it was
|
||||
// a member of.
|
||||
it("never offers a voice track as the bed, but keeps an unnamed one eligible", () => {
|
||||
expect(couldBeCarveBed("music-bed")).toBe(true);
|
||||
expect(couldBeCarveBed("sfx-riser")).toBe(true);
|
||||
expect(couldBeCarveBed("a1")).toBe(true);
|
||||
expect(couldBeCarveBed("vo-2")).toBe(false);
|
||||
expect(couldBeCarveBed("voiceover")).toBe(false);
|
||||
expect(couldBeCarveBed("narration-3")).toBe(false);
|
||||
});
|
||||
|
||||
// Showing the control is a suggestion; writing the attribute is a decision.
|
||||
// A decision taken off a name that said nothing is how a carve appears that
|
||||
// nobody remembers configuring — so `a1` may be offered but never chosen.
|
||||
it("only self-applies to a name that positively reads as a bed", () => {
|
||||
expect(isNamedCarveBed("music-bed")).toBe(true);
|
||||
expect(isNamedCarveBed("sfx-riser")).toBe(true);
|
||||
expect(isNamedCarveBed("a1")).toBe(false);
|
||||
expect(isNamedCarveBed("vo-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats underscores as separators, not word characters, for short hints", () => {
|
||||
// `\b` treats `_` as a word character, so `\bbed\b` used to miss `bed_01` —
|
||||
// an underscore-separated bed classified as "unknown" and could end up
|
||||
|
||||
@@ -188,6 +188,40 @@ export function couldBeCarveSource(...parts: readonly (string | null | undefined
|
||||
return kind === "voice" || kind === "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Could this track be the BED a carve is written onto?
|
||||
*
|
||||
* The other half of `couldBeCarveSource`, and the half nothing used to ask. A
|
||||
* carve makes room in a bed for a voice; a voice track has no room to make for
|
||||
* itself, and offering it the control is offering a track to duck against its
|
||||
* own kind. Observed: a narration clip in a Voiceover group carved against that
|
||||
* group — a member ducking the bus it feeds.
|
||||
*
|
||||
* Loose in the same direction as its sibling: a name that says nothing stays
|
||||
* eligible, because a name is a hint and an author may know better. Only a name
|
||||
* that positively reads as speech is refused.
|
||||
*/
|
||||
export function couldBeCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
|
||||
return classifyAudioName(...parts) !== "voice";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this track's name positively say "bed"?
|
||||
*
|
||||
* Stricter than `couldBeCarveBed`, for the one act the author did not ask for:
|
||||
* applying a carve on their behalf. Offering the control on a track named `a1`
|
||||
* is a suggestion they can ignore; writing `data-fx-carve` onto it is a decision,
|
||||
* and a decision taken off a name that said nothing is how a carve appears that
|
||||
* nobody remembers configuring.
|
||||
*
|
||||
* The same split the source side already makes between what the picker may show
|
||||
* and what `autoSourceIds` may choose unprompted.
|
||||
*/
|
||||
export function isNamedCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
|
||||
const kind = classifyAudioName(...parts);
|
||||
return kind === "music" || kind === "sfx";
|
||||
}
|
||||
|
||||
export const DEFAULT_CARVE: HfCarveSettings = {
|
||||
enabled: true,
|
||||
sources: [],
|
||||
|
||||
@@ -1341,6 +1341,62 @@ describe("AudioFxGroup carve by default", () => {
|
||||
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
||||
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount a track of the test's own naming as the selection, with siblings.
|
||||
*
|
||||
* `mount` always calls the selected track `bed`, which is what let the near-end
|
||||
* hole go unnoticed: nothing ever selected a track whose NAME said voice.
|
||||
*/
|
||||
const mountNamed = (id: string, siblings: string[]) => {
|
||||
const onSetAttributeQuiet = vi.fn();
|
||||
const selected = document.createElement("audio");
|
||||
selected.id = id;
|
||||
document.body.append(selected);
|
||||
for (const other of siblings) {
|
||||
const el = document.createElement("audio");
|
||||
el.id = other;
|
||||
document.body.append(el);
|
||||
}
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
act(() => {
|
||||
createRoot(host).render(
|
||||
<AudioFxGroup
|
||||
element={
|
||||
{
|
||||
dataAttributes: { "fx-chain": "" },
|
||||
id,
|
||||
element: selected,
|
||||
} as unknown as DomEditSelection
|
||||
}
|
||||
onSetAttributeQuiet={onSetAttributeQuiet}
|
||||
onSetAttributeLive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, onSetAttributeQuiet };
|
||||
};
|
||||
|
||||
// The reported bug. A carve makes room in a bed for a voice, so a voice track
|
||||
// is the one thing that can never be the bed — `couldBeCarveSource` has said
|
||||
// as much since it was written, and nothing called it. Selecting a narration
|
||||
// clip offered it the module, found one candidate, and applied a carve nobody
|
||||
// asked for.
|
||||
it("never offers the carve on a track whose name says voice", () => {
|
||||
const { host, onSetAttributeQuiet } = mountNamed("vo-2", ["music-bed"]);
|
||||
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
||||
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
|
||||
});
|
||||
|
||||
// Offering is a suggestion, applying is a decision. An unnamed track keeps the
|
||||
// module — the author may know better than the name does — but nothing is
|
||||
// written until they say so.
|
||||
it("offers but does not apply on a track whose name says nothing", () => {
|
||||
const { host, onSetAttributeQuiet } = mountNamed("a1", ["vo-1"]);
|
||||
expect(host.querySelector(".hf-fx-carve-module")).not.toBeNull();
|
||||
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioFxGroup carve source list", () => {
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
DEFAULT_CARVE,
|
||||
mixCarveSources,
|
||||
HF_AUDIO_CARVE_ATTR,
|
||||
normalizeCarveSettings,
|
||||
type HfCarveSettings,
|
||||
} from "@hyperframes/core/audio-carve";
|
||||
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
|
||||
import {
|
||||
carveBedRoles,
|
||||
carverAgainst,
|
||||
collectCarveCandidates,
|
||||
CARVE_ABORTED,
|
||||
isPromiseLike,
|
||||
@@ -54,17 +55,6 @@ import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
||||
*/
|
||||
const DECODE_SAMPLE_RATE = 48000;
|
||||
|
||||
/** Whether some element's own carve attribute names `targetId` as a source. */
|
||||
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
|
||||
try {
|
||||
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
|
||||
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
|
||||
} catch {
|
||||
// An unreadable carve on some other element says nothing about this one.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which carve setting actually moved, by comparing the two snapshots.
|
||||
*
|
||||
@@ -304,24 +294,10 @@ export function useFxCarve(
|
||||
*/
|
||||
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>,
|
||||
) {
|
||||
/**
|
||||
* Is some other track carving against this one?
|
||||
*
|
||||
* A carve is a relationship — a bed is carved against a voice — and the voice is
|
||||
* the far end of it. Offering the same control there offers to carve a track
|
||||
* against itself by proxy, and switching it on left a setting with no source it
|
||||
* could legally name. Read off the other elements' own carve attributes, because
|
||||
* that is where the relationship is recorded.
|
||||
*/
|
||||
const carvedAgainstBy = ((): string | null => {
|
||||
const doc = element.element?.ownerDocument;
|
||||
if (!doc || !element.id) return null;
|
||||
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
|
||||
const carver = others.find(
|
||||
(other) => other.id !== element.id && carvesAgainst(other, element.id ?? ""),
|
||||
);
|
||||
return carver ? carver.id || "another track" : null;
|
||||
})();
|
||||
const carvedAgainstBy = carverAgainst(element.element?.ownerDocument, element.id);
|
||||
|
||||
// Can this be a bed at all, and may one be applied unasked. See carveBedRoles.
|
||||
const { couldBeBed, autoBed } = carveBedRoles(element.id, element.element);
|
||||
|
||||
/**
|
||||
* The tracks worth offering as the voice.
|
||||
@@ -336,13 +312,17 @@ export function useFxCarve(
|
||||
* safe direction: a name that says nothing stays in, voice-shaped names sort
|
||||
* first, and if filtering would leave nothing at all every track comes back. A
|
||||
* picker that hides the track somebody needs is worse than a long one.
|
||||
*
|
||||
* Empty on a track that cannot be a bed at all, which is what withholds the
|
||||
* whole module: `showCarve` already asks "is there anything to carve against",
|
||||
* so the near-end rule rides that rather than a second flag to thread.
|
||||
*/
|
||||
const { sourceOptions, autoSourceIds } = ((): {
|
||||
sourceOptions: AudioTrackOption[];
|
||||
autoSourceIds: string[];
|
||||
} => {
|
||||
const doc = element.element?.ownerDocument;
|
||||
if (!doc) return { sourceOptions: [], autoSourceIds: [] };
|
||||
if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
|
||||
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
|
||||
(a) => a.id !== element.id,
|
||||
);
|
||||
@@ -353,7 +333,7 @@ export function useFxCarve(
|
||||
const overlapsBed = (a: Element): boolean =>
|
||||
clipsOverlap(bedSpan, spanOf(a.getAttribute("data-start"), a.getAttribute("data-duration")));
|
||||
|
||||
const described = collectCarveCandidates(doc, others, overlapsBed);
|
||||
const described = collectCarveCandidates(doc, others, overlapsBed, element.id ?? undefined);
|
||||
const plausible = described.filter((t) => t.kind === "voice" || t.kind === "unknown");
|
||||
const offered = plausible.length > 0 ? plausible : described;
|
||||
const byVoiceFirst = (list: typeof described) =>
|
||||
@@ -543,7 +523,7 @@ export function useFxCarve(
|
||||
// both guards passing for a single candidate fired two setCarve calls with
|
||||
// the same result — two decodes, two FFT runs, two concurrent attribute
|
||||
// writes.
|
||||
if (carvedAgainstBy || autoSourceIds.length <= 1) return;
|
||||
if (carvedAgainstBy || !autoBed || autoSourceIds.length <= 1) return;
|
||||
const all = autoSourceIds;
|
||||
// Nothing configured: the default carve, pointed at everything it could hear.
|
||||
if (carve === null) {
|
||||
@@ -557,7 +537,7 @@ export function useFxCarve(
|
||||
// Keyed on the identity of the decision, not on setCarve — which is rebuilt
|
||||
// every render and would re-fire this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [carve, carvedAgainstBy, candidateIds]);
|
||||
}, [carve, carvedAgainstBy, autoBed, candidateIds]);
|
||||
|
||||
/**
|
||||
* A bed with one obvious voice above it carves itself.
|
||||
@@ -578,7 +558,7 @@ export function useFxCarve(
|
||||
* reason the flag exists rather than "off" being an absent attribute.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (carvedAgainstBy || autoSourceIds.length !== 1) return;
|
||||
if (carvedAgainstBy || !autoBed || autoSourceIds.length !== 1) return;
|
||||
const only = autoSourceIds[0];
|
||||
if (!only) return;
|
||||
// Nothing configured: the default carve, pointed at the one candidate.
|
||||
@@ -594,7 +574,7 @@ export function useFxCarve(
|
||||
// Deliberately keyed on the identity of the decision, not on setCarve — which
|
||||
// is rebuilt every render and would re-fire this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [carve, carvedAgainstBy, autoSourceIds.length, autoSourceIds[0]]);
|
||||
}, [carve, carvedAgainstBy, autoBed, autoSourceIds.length, autoSourceIds[0]]);
|
||||
|
||||
return { carvedAgainstBy, sourceOptions, setCarve };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectCarveCandidates } from "./useFxCarveGrouping";
|
||||
|
||||
function previewDoc(html: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument("preview");
|
||||
doc.body.innerHTML = html;
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** What the panel would offer as sources for `bedId`, the way useFxCarve asks. */
|
||||
function candidatesFor(doc: Document, bedId: string) {
|
||||
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
|
||||
(a) => a.id !== bedId,
|
||||
);
|
||||
return collectCarveCandidates(doc, others, () => true, bedId).map((c) => c.id);
|
||||
}
|
||||
|
||||
const GROUPED_VOICES = `
|
||||
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
||||
<audio id="vo-1" data-audio-group="voiceover"></audio>
|
||||
<audio id="vo-2" data-audio-group="voiceover"></audio>
|
||||
<audio id="music-bed"></audio>
|
||||
`;
|
||||
|
||||
describe("collectCarveCandidates", () => {
|
||||
// The observed bug: selecting vo-2 offered "Voiceover (2)" — the group vo-2 is
|
||||
// itself a member of. The caller filters out the bed element, but vo-1 survives
|
||||
// that filter and rolls up into exactly that group. Being the only candidate, it
|
||||
// was then applied without the author asking: a member ducking the bus it feeds.
|
||||
it("never offers a member the group it belongs to", () => {
|
||||
expect(candidatesFor(previewDoc(GROUPED_VOICES), "vo-2")).toEqual(["music-bed"]);
|
||||
});
|
||||
|
||||
// And the mirror: a group bed's own id matches no <audio> id, so nothing
|
||||
// excluded it. Its members rolled up and handed the group back to itself.
|
||||
it("never offers a group itself", () => {
|
||||
const doc = previewDoc(GROUPED_VOICES);
|
||||
expect(candidatesFor(doc, "voiceover")).toEqual(["music-bed"]);
|
||||
});
|
||||
|
||||
it("still offers a group the bed has nothing to do with", () => {
|
||||
const doc = previewDoc(`
|
||||
${GROUPED_VOICES}
|
||||
<hf-audio-group id="sfx" data-label="SFX"></hf-audio-group>
|
||||
<audio id="sfx-click" data-audio-group="sfx"></audio>
|
||||
`);
|
||||
expect(candidatesFor(doc, "music-bed")).toEqual(["voiceover", "sfx"]);
|
||||
});
|
||||
|
||||
it("leaves an ungrouped bed's candidates alone", () => {
|
||||
const doc = previewDoc(`<audio id="music-bed"></audio><audio id="vo-1"></audio>`);
|
||||
expect(candidatesFor(doc, "music-bed")).toEqual(["vo-1"]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,14 @@
|
||||
* `propertyPanelAudioFxGroup.tsx`.
|
||||
*/
|
||||
|
||||
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
|
||||
import {
|
||||
classifyAudioName,
|
||||
HF_AUDIO_CARVE_ATTR,
|
||||
normalizeCarveSettings,
|
||||
couldBeCarveBed,
|
||||
isNamedCarveBed,
|
||||
type HfCarveSettings,
|
||||
} from "@hyperframes/core/audio-carve";
|
||||
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
|
||||
/**
|
||||
@@ -101,18 +108,38 @@ export interface CarveCandidate {
|
||||
* the whole timeline is relevant even if each of its segments only overlaps
|
||||
* part of the bed). Grouped members never appear individually.
|
||||
*/
|
||||
/**
|
||||
* The ids a carve may never name, for a bed of this id.
|
||||
*
|
||||
* A carve names what the bed ducks UNDER, so the bed can never be on that
|
||||
* list — directly or through the bus it plays into. The caller filters the bed
|
||||
* element out of `others`, but that only removes the bed itself: its siblings
|
||||
* survive and roll up into the very group the bed is a member of, which came
|
||||
* back as a candidate and, being the only one, was applied unprompted. And a
|
||||
* group bed's own id never matches any <audio> id at all, so nothing stopped a
|
||||
* group carving against itself.
|
||||
*/
|
||||
function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string): Set<string> {
|
||||
const selfGroupId = bedId ? groupByMember.get(bedId)?.id : undefined;
|
||||
return new Set([bedId, selfGroupId].filter((id): id is string => !!id));
|
||||
}
|
||||
|
||||
export function collectCarveCandidates(
|
||||
doc: Document,
|
||||
others: readonly HTMLAudioElement[],
|
||||
overlapsBed: (a: Element) => boolean,
|
||||
/** The bed's own id, so neither it nor the group it belongs to is offered. */
|
||||
bedId?: string,
|
||||
): CarveCandidate[] {
|
||||
const groupByMember = new Map(
|
||||
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
|
||||
);
|
||||
const excluded = excludedFor(groupByMember, bedId);
|
||||
const offeredGroupIds = new Set<string>();
|
||||
const described: CarveCandidate[] = [];
|
||||
for (const a of others) {
|
||||
const group = groupByMember.get(a.id);
|
||||
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
|
||||
if (!group) {
|
||||
if (overlapsBed(a)) {
|
||||
described.push({
|
||||
@@ -144,3 +171,61 @@ export function collectCarveCandidates(
|
||||
}
|
||||
return described;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two near-end questions about a track, asked of its name.
|
||||
*
|
||||
* `couldBeBed` — may a carve be written onto this at all? A carve makes room in
|
||||
* a bed for a voice, so a voice track is the one thing that can never be the
|
||||
* bed. The far-end rule (`couldBeCarveSource` — music and sfx are out) has
|
||||
* existed since it was written and had no caller; this is the half nothing
|
||||
* asked, and without it a narration clip was offered the control and, finding
|
||||
* exactly one candidate, had a carve applied against the group it belonged to.
|
||||
*
|
||||
* `autoBed` — may one be applied WITHOUT the author asking? Stricter. Showing
|
||||
* the module on a track named `a1` is a suggestion; writing `data-fx-carve`
|
||||
* onto it is a decision, and a decision taken off a name that said nothing is
|
||||
* how a carve turns up that nobody remembers configuring. The same split the
|
||||
* source side already makes between what the picker may show (`sourceOptions`)
|
||||
* and what it may choose unprompted (`autoSourceIds`).
|
||||
*
|
||||
* Reads the element's `data-label` as well as its id and `src`: a group carries
|
||||
* its name there rather than in a filename, and a group can be a bed.
|
||||
*/
|
||||
export function carveBedRoles(
|
||||
id: string | null | undefined,
|
||||
node: Element | null | undefined,
|
||||
): { couldBeBed: boolean; autoBed: boolean } {
|
||||
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
|
||||
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
|
||||
}
|
||||
|
||||
/** Whether some element's own carve attribute names `targetId` as a source. */
|
||||
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
|
||||
try {
|
||||
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
|
||||
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
|
||||
} catch {
|
||||
// An unreadable carve on some other element says nothing about this one.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is some other track carving against this one, and which?
|
||||
*
|
||||
* A carve is a relationship — a bed is carved against a voice — and the voice is
|
||||
* the far end of it. Offering the same control there offers to carve a track
|
||||
* against itself by proxy, and switching it on left a setting with no source it
|
||||
* could legally name. Read off the other elements' own carve attributes, because
|
||||
* that is where the relationship is recorded.
|
||||
*/
|
||||
export function carverAgainst(
|
||||
doc: Document | undefined,
|
||||
id: string | null | undefined,
|
||||
): string | null {
|
||||
if (!doc || !id) return null;
|
||||
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
|
||||
const carver = others.find((other) => other.id !== id && carvesAgainst(other, id));
|
||||
return carver ? carver.id || "another track" : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user