mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): unify audio IDs and group state (#3448)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state
This commit is contained in:
@@ -80,4 +80,30 @@ describe("syncStoredAutomationFromPreview", () => {
|
||||
syncStoredAutomationFromPreview(null);
|
||||
expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
|
||||
});
|
||||
// The reported symptom: automate a parameter on a GROUP from the FX rack and
|
||||
// the group's row shows no automation. The rack is not group-aware — it
|
||||
// writes the attribute on the group node through the ordinary element path —
|
||||
// and the timeline reads a group's lanes from the mirrors its MEMBERS carry,
|
||||
// which that path used to leave untouched.
|
||||
it("re-reads what the group carries onto every member that belongs to it", () => {
|
||||
const chain = '{"version":1,"nodes":[{"type":"gain","id":"n1","params":{"gain":0}}]}';
|
||||
const groupAutomation =
|
||||
'{"version":1,"lanes":[{"target":"fx.n1.gain","points":[{"t":0,"v":0}]}]}';
|
||||
const doc = document.implementation.createHTMLDocument("preview");
|
||||
const group = doc.createElement("hf-audio-group");
|
||||
group.id = "voiceover";
|
||||
group.setAttribute("data-fx-chain", chain);
|
||||
group.setAttribute("data-automation", groupAutomation);
|
||||
const audio = doc.createElement("audio");
|
||||
audio.id = "bgm";
|
||||
audio.setAttribute("data-audio-group", "voiceover");
|
||||
doc.body.append(group, audio);
|
||||
|
||||
usePlayerStore.setState({ elements: [el({ audioGroup: "voiceover" })] });
|
||||
syncStoredAutomationFromPreview(doc);
|
||||
|
||||
const stored = usePlayerStore.getState().elements[0];
|
||||
expect(stored?.audioGroupAutomation).toBe(groupAutomation);
|
||||
expect(stored?.audioGroupFxChain).toBe(chain);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { groupInfoFor } from "./timelineGroupInfo";
|
||||
|
||||
/** The preview node an element stands for, by dom id and then by `data-hf-id`. */
|
||||
function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
|
||||
@@ -38,6 +39,27 @@ function previewNodeFor(doc: Document, element: TimelineElement): Element | null
|
||||
* Reads rather than being told: an undo restores whole files, so the attribute it
|
||||
* reverted is only known by looking.
|
||||
*/
|
||||
/**
|
||||
* What an element's four synced fields SHOULD read, given the preview.
|
||||
*
|
||||
* Its own two come off its node; the other two are its copy of what its group
|
||||
* carries. The timeline derives a group's lanes and chain from these mirrors,
|
||||
* never from the group element — and the FX rack is not group-aware: selecting
|
||||
* a group and automating one of its parameters writes `data-automation` on the
|
||||
* group node through the ordinary element path, which used to refresh an
|
||||
* element's own two fields and nothing else. So the group's row went on reading
|
||||
* the value it was born with, and its `∿` never appeared.
|
||||
*/
|
||||
function syncedFields(doc: Document, element: TimelineElement, node: Element) {
|
||||
const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
|
||||
return {
|
||||
automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
|
||||
fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
|
||||
audioGroupAutomation: group?.automation,
|
||||
audioGroupFxChain: group?.fxChain,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
|
||||
if (!doc) return;
|
||||
usePlayerStore.setState((state) => {
|
||||
@@ -45,11 +67,13 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
|
||||
const elements = state.elements.map((element) => {
|
||||
const node = previewNodeFor(doc, element);
|
||||
if (!node) return element;
|
||||
const automation = node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined;
|
||||
const fxChain = node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined;
|
||||
if (automation === element.automation && fxChain === element.fxChain) return element;
|
||||
const fields = syncedFields(doc, element, node);
|
||||
// Same array back when nothing moved: `elements` keys memos all over the
|
||||
// timeline, and a fresh object per sync would re-render every one.
|
||||
const keys = Object.keys(fields) as (keyof typeof fields)[];
|
||||
if (keys.every((key) => fields[key] === element[key])) return element;
|
||||
changed = true;
|
||||
return { ...element, automation, fxChain };
|
||||
return { ...element, ...fields };
|
||||
});
|
||||
return changed ? { elements } : {};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* The studio → runtime boundary, which nothing else crosses.
|
||||
*
|
||||
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
||||
* `<sourceFile>#<domId>`; every audio predicate in `@hyperframes/core` keys off
|
||||
* the live document instead. Both halves have their own passing tests — one
|
||||
* with composite keys, one with bare ids — and the mismatch between them lived
|
||||
* in the gap. These parse a real document, take the ids the way the UI does,
|
||||
* and hand them to the real core predicates.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
import { parseTimelineFromDOM } from "./timelineDOM";
|
||||
import { runtimeAudioId } from "./timelineElementHelpers";
|
||||
|
||||
function docWith(body: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument("comp");
|
||||
doc.body.innerHTML = body;
|
||||
return doc;
|
||||
}
|
||||
|
||||
const COMPOSITION = `
|
||||
<div data-composition-id="root" data-duration="30"></div>
|
||||
<audio id="voice-1" data-start="0" data-duration="10" data-audio-group="voiceover"></audio>
|
||||
<audio id="voice-2" data-start="10" data-duration="10" data-audio-group="voiceover"></audio>
|
||||
<audio id="music-bed" data-start="0" data-duration="30"></audio>
|
||||
<hf-audio-group id="voiceover"></hf-audio-group>
|
||||
`;
|
||||
|
||||
describe("group membership ids cross into the runtime", () => {
|
||||
it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const trackElements = parseTimelineFromDOM(doc, 30).filter(
|
||||
(el) => el.tag.toLowerCase() === "audio",
|
||||
);
|
||||
const clipIds = trackElements.map(runtimeAudioId).filter((id): id is string => id !== null);
|
||||
expect(clipIds).toEqual(["voice-1", "voice-2", "music-bed"]);
|
||||
|
||||
// Same space membership is read back in — a composite key here produces a
|
||||
// group whose members nothing can find.
|
||||
const memberIds = resolveAudioGroups(doc).flatMap((g) => g.memberIds);
|
||||
expect(memberIds.every((id) => doc.getElementById(id) !== null)).toBe(true);
|
||||
for (const id of memberIds) expect(clipIds).toContain(id);
|
||||
});
|
||||
|
||||
it("an element with no DOM id is not groupable", () => {
|
||||
const doc = docWith(`
|
||||
<div data-composition-id="root" data-duration="10"></div>
|
||||
<audio data-start="0" data-duration="5"></audio>
|
||||
`);
|
||||
const [clip] = parseTimelineFromDOM(doc, 10);
|
||||
expect(clip).toBeDefined();
|
||||
expect(runtimeAudioId(clip)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
createImplicitTimelineLayersFromDOM,
|
||||
mergeTimelineElementsPreservingDowngrades,
|
||||
} from "./timelineDOM";
|
||||
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
||||
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
||||
@@ -110,6 +112,92 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("group info cache", () => {
|
||||
const parseMember = (doc: Document) =>
|
||||
createTimelineElementFromManifestClip({
|
||||
clip: {
|
||||
id: "voice-1",
|
||||
label: "voice-1",
|
||||
kind: "element",
|
||||
tagName: "audio",
|
||||
start: 0,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
},
|
||||
fallbackIndex: 0,
|
||||
doc,
|
||||
hostEl: doc.getElementById("voice-1"),
|
||||
});
|
||||
|
||||
// Group edits are applied as LIVE patches so the preview iframe never
|
||||
// reloads, which means the document identity this cache is keyed on never
|
||||
// changes either. Without an explicit drop, a muted group could never be
|
||||
// unmuted: the header kept reading the cached `hidden: false` and re-wrote
|
||||
// `data-hidden` forever.
|
||||
it("re-reads group state after an invalidation", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
||||
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
||||
|
||||
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
|
||||
|
||||
invalidateGroupInfoCache(doc);
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
||||
|
||||
doc.getElementById("voiceover")?.removeAttribute("data-hidden");
|
||||
invalidateGroupInfoCache(doc);
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
||||
});
|
||||
|
||||
// The explicit invalidator is a convenience, not the contract. A cache whose
|
||||
// only defence is "every writer must remember to call this" rots the first
|
||||
// time a writer does not know it exists — which is precisely what happened
|
||||
// with the FX rack, whose group writes go through the DOM editor rather than
|
||||
// the timeline's own writers. The scan carries the DOM revision it was taken
|
||||
// at, so a forgotten call costs a re-scan rather than a wrong answer.
|
||||
it("expires itself on a group edit nobody announced", async () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
||||
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
||||
|
||||
// No invalidateGroupInfoCache call anywhere in this test.
|
||||
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // observer microtask
|
||||
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("notices a member joining the group, not just an attribute edit", async () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
||||
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
||||
</div>
|
||||
`);
|
||||
expect(parseMember(doc).audioGroupLabel).toBe("Voices");
|
||||
|
||||
doc.getElementById("voiceover")?.setAttribute("data-label", "Narration");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(parseMember(doc).audioGroupLabel).toBe("Narration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
||||
it.each([
|
||||
["10", 5],
|
||||
@@ -207,6 +295,33 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
|
||||
});
|
||||
});
|
||||
|
||||
// Caught by looking at the studio, not by reading: a grouped composition drew
|
||||
// "Voiceover • 0.0s – 12.0s" as a full-duration clip row directly above its own
|
||||
// group header. `<hf-audio-group>` is a mixer bus — no timing, drawn as a group
|
||||
// row by the group derivation — but it is still a body child with an id, so the
|
||||
// implicit-layer fallback happily gave it a track. Draggable and trimmable, and
|
||||
// writing timing onto a bus means nothing.
|
||||
describe("<hf-audio-group> is not a timeline layer", () => {
|
||||
it("gets no implicit row of its own", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio>
|
||||
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []);
|
||||
|
||||
expect(implicit.map((el) => el.domId)).not.toContain("voiceover");
|
||||
});
|
||||
|
||||
it("is excluded by the shared ignore predicate", () => {
|
||||
const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`);
|
||||
expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true);
|
||||
expect(isTimelineIgnoredElement(doc.getElementById("panel") as Element)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
||||
it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
|
||||
const doc = makeDoc(`
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
||||
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
||||
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
import { groupInfoFor } from "./timelineGroupInfo";
|
||||
import {
|
||||
resolveMediaElement,
|
||||
applyMediaMetadataFromElement,
|
||||
@@ -68,37 +68,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
return clip.tagName || clip.kind || "div";
|
||||
}
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
interface GroupInfo {
|
||||
label: string;
|
||||
volume: number;
|
||||
hidden: boolean;
|
||||
fxChain?: string;
|
||||
}
|
||||
|
||||
const groupInfoCache = new WeakMap<Document, Map<string, GroupInfo>>();
|
||||
|
||||
function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
let info = groupInfoCache.get(doc);
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{
|
||||
label: group.label,
|
||||
volume: group.volume,
|
||||
hidden: group.hidden,
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, info);
|
||||
}
|
||||
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function createTimelineElementFromManifestClip(params: {
|
||||
clip: ClipManifestClip;
|
||||
@@ -178,6 +147,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
entry.audioGroupVolume = info.volume;
|
||||
entry.audioGroupHidden = info.hidden;
|
||||
if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
|
||||
if (info.automation) entry.audioGroupAutomation = info.automation;
|
||||
}
|
||||
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||
if (fxChain) entry.fxChain = fxChain;
|
||||
@@ -405,6 +375,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
entry.audioGroupVolume = domGroupInfo.volume;
|
||||
entry.audioGroupHidden = domGroupInfo.hidden;
|
||||
if (domGroupInfo.fxChain) entry.audioGroupFxChain = domGroupInfo.fxChain;
|
||||
if (domGroupInfo.automation) entry.audioGroupAutomation = domGroupInfo.automation;
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import { isFinitePositive } from "./playbackAdapter";
|
||||
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
||||
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer-reveal lift transparency
|
||||
@@ -81,6 +82,14 @@ function normalizePlaybackRate(raw: number): number {
|
||||
}
|
||||
|
||||
export function isTimelineIgnoredElement(el: Element): boolean {
|
||||
// An `<hf-audio-group>` is a mixer bus, not a clip: it carries the group's
|
||||
// label, fader, mute and FX chain, has no timing of its own, and is drawn as
|
||||
// a GROUP ROW by the group derivation. Left in, the implicit-layer fallback
|
||||
// also gave it an ordinary full-duration track — so a grouped composition
|
||||
// showed "Voiceover • 0.0s – 12.0s" as a phantom clip directly above the real
|
||||
// group header. Harmless-looking, but that row is draggable and trimmable,
|
||||
// and writing timing onto the bus is meaningless.
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
|
||||
return Boolean(
|
||||
el.closest(
|
||||
[
|
||||
@@ -346,6 +355,24 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id space the RUNTIME matches on — a bare DOM id, never a store key.
|
||||
*
|
||||
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
||||
* `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off
|
||||
* the live document: `resolveAudioGroups` collects `member.id`,
|
||||
* `resolveCarveSourceIds` goes through `getElementById`. Anything crossing into
|
||||
* that space — a group membership list, a carve source — has to be
|
||||
* converted here first; a composite key silently matches nothing.
|
||||
*
|
||||
* `null` for a row with no DOM id at all (selector-addressed elements): such an
|
||||
* element cannot be grouped, because `resolveAudioGroups` skips
|
||||
* members without an `id` and would build a group that is half there.
|
||||
*/
|
||||
export function runtimeAudioId(element: { domId?: string | null }): string | null {
|
||||
return element.domId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
|
||||
* expansion (canvas context menu / LayersPanel), so the reorder commit can
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The audio-group scan cached per preview document, and the mechanism that
|
||||
* keeps it honest.
|
||||
*
|
||||
* Split out of `timelineDOM.ts` (600-line studio ceiling). Self-contained: the
|
||||
* cache, its revision counter, the observer that bumps it, and the one reader.
|
||||
*/
|
||||
|
||||
import {
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
HF_AUDIO_GROUP_TAG,
|
||||
resolveAudioGroups,
|
||||
} from "@hyperframes/core/audio-groups";
|
||||
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
interface GroupInfo {
|
||||
label: string;
|
||||
volume: number;
|
||||
hidden: boolean;
|
||||
fxChain?: string;
|
||||
automation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached scan, plus the DOM revision it was taken at.
|
||||
*
|
||||
* Keeping the revision beside the entry is what makes staleness *detectable*
|
||||
* rather than a documented obligation. The cache is keyed on the document, and
|
||||
* group edits are applied as live patches precisely so the iframe never
|
||||
* reloads, so the key alone never changes: an explicit "remember to invalidate"
|
||||
* contract silently rots the first time a new writer forgets — which is exactly
|
||||
* what happened with the FX rack, whose group writes go through the DOM editor
|
||||
* rather than the timeline's own writers.
|
||||
*/
|
||||
const groupInfoCache = new WeakMap<
|
||||
Document,
|
||||
{ revision: number; entries: Map<string, GroupInfo> }
|
||||
>();
|
||||
|
||||
/** Bumped by every observed mutation to group state in a document. */
|
||||
const groupRevisions = new WeakMap<Document, number>();
|
||||
const groupObservers = new WeakSet<Document>();
|
||||
|
||||
/**
|
||||
* Watch a document for any change to group state, so the cache expires itself.
|
||||
*
|
||||
* One observer per document, attached the first time a group is read from it.
|
||||
* It watches the attributes a group's identity is made of, anywhere in the
|
||||
* tree, plus added/removed nodes — which covers a group element appearing, a
|
||||
* member joining or leaving, and any group attribute being edited, by any
|
||||
* writer, without that writer having to know this cache exists.
|
||||
*/
|
||||
function observeGroupState(doc: Document): void {
|
||||
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
groupObservers.add(doc);
|
||||
const observer = new MutationObserver((records) => {
|
||||
// `childList` fires for EVERY node added or removed anywhere in the live
|
||||
// preview, which on a composition that churns nodes during playback
|
||||
// (SplitText, a typewriter, anything runtime-inserted) would expire this
|
||||
// cache permanently and put it back to one whole-tree scan per parse. Only
|
||||
// a group ELEMENT appearing or leaving actually changes the answer, so
|
||||
// childList records are filtered rather than trusted; attribute records
|
||||
// always count, because the filter below already narrowed them.
|
||||
const relevant = records.some(
|
||||
(record) =>
|
||||
record.type !== "childList" ||
|
||||
[...record.addedNodes, ...record.removedNodes].some(
|
||||
(node) =>
|
||||
node instanceof Element &&
|
||||
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
||||
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
||||
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
||||
),
|
||||
);
|
||||
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
||||
});
|
||||
observer.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
// Every attribute `resolveAudioGroups`/`buildGroup` reads, including
|
||||
// `data-automation` — which `GroupInfo` does not cache TODAY, so omitting it
|
||||
// was inert, but group automation lanes already exist and the first person
|
||||
// to cache one would have got a silently never-firing observer. That is the
|
||||
// precise rot this observer replaced.
|
||||
attributeFilter: [
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
HF_AUDIO_FX_ATTR,
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
"data-label",
|
||||
"data-volume",
|
||||
"data-hidden",
|
||||
"id",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached group scan for a document.
|
||||
*
|
||||
* Belt to the observer's braces: a caller that has just written and wants the
|
||||
* very next read to be honest cannot wait for the observer's microtask. Callers
|
||||
* that forget are no longer punished — the revision check catches them.
|
||||
*/
|
||||
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
|
||||
if (doc) groupInfoCache.delete(doc);
|
||||
}
|
||||
|
||||
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
observeGroupState(doc);
|
||||
const revision = groupRevisions.get(doc) ?? 0;
|
||||
const cached = groupInfoCache.get(doc);
|
||||
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{
|
||||
label: group.label,
|
||||
volume: group.volume,
|
||||
hidden: group.hidden,
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
...(group.automation ? { automation: group.automation } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, { revision, entries: info });
|
||||
}
|
||||
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyPreviewAudioFlags,
|
||||
buildMissingCompositionElements,
|
||||
scrubPreviewAudio,
|
||||
setPreviewMediaVolume,
|
||||
@@ -87,3 +88,23 @@ describe("scrubPreviewAudio", () => {
|
||||
stopScrubPreviewAudio();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPreviewAudioFlags", () => {
|
||||
// Everything pushed here is state the runtime loses on reload and nothing else
|
||||
// re-sends, so the push has to carry all of it every time. Volume in
|
||||
// particular: the transport comes back at unity, so a preview the author had
|
||||
// turned down came back loud.
|
||||
it("re-pushes mute and volume together", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
|
||||
|
||||
applyPreviewAudioFlags(iframe, true, 0.4);
|
||||
|
||||
const actions = postMessage.mock.calls.map(
|
||||
(call) => (call[0] as { action?: string }).action ?? "",
|
||||
);
|
||||
expect(actions).toContain("set-muted");
|
||||
expect(actions).toContain("set-volume");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,23 @@ export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume:
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the preview runtime has to be told about audio after it loads.
|
||||
* Called from `applyPreviewAudioState`, which is the path that re-runs after a
|
||||
* preview reload — the runtime comes back with the transport at its defaults
|
||||
* and nothing else pushes them again.
|
||||
*/
|
||||
export function applyPreviewAudioFlags(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
muted: boolean,
|
||||
volume: number,
|
||||
): void {
|
||||
setPreviewMediaMuted(iframe, muted);
|
||||
// Volume too: the transport comes back at unity after a reload, so a preview
|
||||
// the author had turned down came back loud.
|
||||
setPreviewMediaVolume(iframe, volume);
|
||||
}
|
||||
|
||||
export function setPreviewPlaybackRate(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
playbackRate: number,
|
||||
|
||||
@@ -34,6 +34,27 @@ export interface FocusedEaseSegment {
|
||||
|
||||
type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
|
||||
|
||||
/**
|
||||
* A request to reveal one automated parameter in the audio FX rack.
|
||||
*
|
||||
* `elementKey` is the timeline element whose rack it belongs to — a group's own
|
||||
* id for a group lane, the clip's key otherwise — so the panel can refuse a
|
||||
* request aimed at something it is not showing.
|
||||
*/
|
||||
export interface RevealedAudioFxTarget {
|
||||
elementKey: string;
|
||||
/** The lane's own `fx.<node>.<param>` / `volume` target. */
|
||||
automationTarget: string;
|
||||
projectId: string | null;
|
||||
sessionEpoch: number;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export type RevealedAudioFxTargetRequest = Omit<
|
||||
RevealedAudioFxTarget,
|
||||
"projectId" | "sessionEpoch" | "nonce"
|
||||
>;
|
||||
|
||||
interface TimelineSessionIdentity {
|
||||
timelineProjectId: string | null;
|
||||
timelineSessionEpoch: number;
|
||||
@@ -80,6 +101,19 @@ export interface KeyframeSlice {
|
||||
setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
|
||||
clearFocusedEaseSegment: (nonce: number) => void;
|
||||
|
||||
/**
|
||||
* "Show me this automated parameter in the rack" — raised by clicking an
|
||||
* automation lane's label in the timeline, consumed by the property panel.
|
||||
*
|
||||
* Session-stamped and nonce-guarded exactly like `focusedEaseSegment`: a
|
||||
* request outlives the click, so one made against a different project or
|
||||
* before a reload must not reopen a rack on whatever is mounted later.
|
||||
*/
|
||||
revealedAudioFxTarget: RevealedAudioFxTarget | null;
|
||||
revealedAudioFxNonce: number;
|
||||
setRevealedAudioFxTarget: (target: RevealedAudioFxTargetRequest) => void;
|
||||
clearRevealedAudioFxTarget: (nonce: number) => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
/** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
|
||||
@@ -166,6 +200,27 @@ export function createKeyframeSlice(
|
||||
state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
|
||||
),
|
||||
|
||||
revealedAudioFxTarget: null,
|
||||
revealedAudioFxNonce: 0,
|
||||
setRevealedAudioFxTarget: (target) =>
|
||||
set((state) => {
|
||||
const nonce = state.revealedAudioFxNonce + 1;
|
||||
const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
|
||||
return {
|
||||
revealedAudioFxNonce: nonce,
|
||||
revealedAudioFxTarget: {
|
||||
...target,
|
||||
projectId: timelineProjectId,
|
||||
sessionEpoch: timelineSessionEpoch,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
}),
|
||||
clearRevealedAudioFxTarget: (nonce) =>
|
||||
set((state) =>
|
||||
state.revealedAudioFxTarget?.nonce === nonce ? { revealedAudioFxTarget: null } : state,
|
||||
),
|
||||
|
||||
keyframeCache: new Map(),
|
||||
setKeyframeCache: (elementId, data) =>
|
||||
set((state) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle";
|
||||
import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { BeatEditState } from "../../utils/beatEditing";
|
||||
@@ -14,15 +16,15 @@ import {
|
||||
createAutomationSelectionSlice,
|
||||
type AutomationSelectionSlice,
|
||||
} from "./automationSelectionSlice";
|
||||
import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
|
||||
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
||||
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
||||
import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
|
||||
import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
|
||||
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
export { liveTime } from "./liveTime";
|
||||
|
||||
import type { TimelineElement } from "./timelineElement";
|
||||
import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
|
||||
|
||||
export type { TimelineElement };
|
||||
export type ZoomMode = "fit" | "manual";
|
||||
@@ -135,22 +137,7 @@ interface PlayerState
|
||||
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
|
||||
/** Move the selection anchor within an active multi-selection without collapsing it. */
|
||||
setSelectionAnchor: (id: string | null) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
TimelineElement,
|
||||
| "start"
|
||||
| "duration"
|
||||
| "track"
|
||||
| "zIndex"
|
||||
| "hasExplicitZIndex"
|
||||
| "playbackStart"
|
||||
| "hidden"
|
||||
| "audioGroup"
|
||||
>
|
||||
>,
|
||||
) => void;
|
||||
updateElement: (elementId: string, updates: TimelineElementPatch) => void;
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setManualZoomPercent: (percent: number) => void;
|
||||
bumpZEditVersion: () => void;
|
||||
@@ -241,6 +228,19 @@ export interface DomClipChild {
|
||||
hostId: string;
|
||||
label: string;
|
||||
stackingContextId: string;
|
||||
/**
|
||||
* The child's audio-group state, read off its live element during the DOM
|
||||
* walk — the only place that sees it. A sub-composition can declare a group
|
||||
* and its members entirely within itself, and those members never reach the
|
||||
* flat store, so an expanded child has no twin to inherit membership from.
|
||||
*/
|
||||
audioGroup?: string;
|
||||
audioGroupLabel?: string;
|
||||
audioGroupVolume?: number;
|
||||
audioGroupHidden?: boolean;
|
||||
audioGroupFxChain?: string;
|
||||
/** The group element's `data-automation`, mirrored the same way. */
|
||||
audioGroupAutomation?: string;
|
||||
}
|
||||
|
||||
interface BeatHistoryEntry {
|
||||
@@ -274,6 +274,7 @@ export function createTimelineResetState() {
|
||||
expandedGroupIds: new Set<string>(),
|
||||
expandedLaneOwnerIds: new Set<string>(),
|
||||
focusedEaseSegment: null,
|
||||
revealedAudioFxTarget: null,
|
||||
selectedElementIds: new Set<string>(),
|
||||
requestedSeekTime: null,
|
||||
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
|
||||
@@ -519,18 +520,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
// echoes that must preserve a group go through setSelectionAnchor instead.
|
||||
setSelectedElementId: (id, options) =>
|
||||
set((s) => {
|
||||
const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
|
||||
const selectedElementIds = preserveSet
|
||||
? new Set(s.selectedElementIds)
|
||||
: options?.preserveSet
|
||||
? new Set<string>()
|
||||
: id
|
||||
? new Set([id])
|
||||
: new Set<string>();
|
||||
const selectedElementIds = nextSelectionSet(s.selectedElementIds, id, options?.preserveSet);
|
||||
// Selecting a different element drops any active keyframe selection — otherwise
|
||||
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
||||
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|
||||
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
|
||||
// A reveal request survives the selection it is FOR. `openClipFxRack`
|
||||
// raises the request and then selects the clip asynchronously, so the
|
||||
// selection lands afterwards and used to clear the very request that
|
||||
// caused it — the panel then read null and the section never opened.
|
||||
// Any OTHER selection still drops it: a request aimed elsewhere is stale.
|
||||
//
|
||||
// Compared across the ID-SPACE BOUNDARY, which is why this needs saying:
|
||||
// a request carries the BARE dom id (`runtimeAudioId`, because the panel
|
||||
// and the runtime speak that), while this store's ids are
|
||||
// `sourceFile#domId`. A direct `===` was silently never true — the exact
|
||||
// shape of failure the id-space split produces.
|
||||
const revealSurvives = revealTargetsSelection(s.revealedAudioFxTarget, id);
|
||||
return id !== s.selectedElementId
|
||||
? {
|
||||
selectedElementId: id,
|
||||
@@ -538,6 +544,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
activeKeyframePct: null,
|
||||
motionPathArmed: false,
|
||||
focusedEaseSegment: null,
|
||||
...(revealSurvives ? {} : { revealedAudioFxTarget: null }),
|
||||
}
|
||||
: { selectedElementId: id, selectedElementIds };
|
||||
}),
|
||||
@@ -579,15 +586,4 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
reset: () => set(createTimelineResetState()),
|
||||
}));
|
||||
|
||||
function isDevBuild(): boolean {
|
||||
try {
|
||||
return import.meta.env.DEV === true;
|
||||
} catch {
|
||||
// Turbopack and other non-Vite bundlers may not provide import.meta.env.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isDevBuild() && typeof window !== "undefined") {
|
||||
// Console handle for dumping live Studio state during bug-bash reproduction.
|
||||
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
||||
}
|
||||
attachPlayerStoreDevHandle(usePlayerStore);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* A console handle on the live store, dev builds only.
|
||||
*
|
||||
* Split out of `playerStore.ts` to keep it under the studio's 600-line cap. The
|
||||
* dev check is its own function because `import.meta.env` is absent under
|
||||
* Turbopack and other non-Vite bundlers, where reading it throws.
|
||||
*/
|
||||
|
||||
function isDevBuild(): boolean {
|
||||
try {
|
||||
return import.meta.env.DEV === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Expose `store` as `window.__playerStore` for dumping live Studio state
|
||||
* during bug-bash reproduction. No-op outside a dev build or a browser. */
|
||||
export function attachPlayerStoreDevHandle(store: unknown): void {
|
||||
if (!isDevBuild() || typeof window === "undefined") return;
|
||||
(window as unknown as { __playerStore?: unknown }).__playerStore = store;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Selection-set arithmetic for the player store, and the reveal request's
|
||||
* cross-id-space match.
|
||||
*
|
||||
* Its own module so `playerStore.ts` stays under the studio's 600-line cap.
|
||||
*/
|
||||
|
||||
import { splitTimelineElementKey } from "../lib/timelineElementHelpers";
|
||||
|
||||
/**
|
||||
* The id set a selection change leaves behind.
|
||||
*
|
||||
* `preserveSet` means "keep the multi-selection if this id is already in it" —
|
||||
* a DOM→store echo re-announcing a member must not collapse the set — and
|
||||
* anything else is a genuine single selection.
|
||||
*/
|
||||
export function nextSelectionSet(
|
||||
current: ReadonlySet<string>,
|
||||
id: string | null,
|
||||
preserveSet: boolean | undefined,
|
||||
): Set<string> {
|
||||
if (preserveSet) return id && current.has(id) ? new Set(current) : new Set<string>();
|
||||
return id ? new Set([id]) : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a pending reveal request aimed at the element being selected?
|
||||
*
|
||||
* Compared across the ID-SPACE BOUNDARY, which is why it is a named function:
|
||||
* a request carries the BARE dom id (`runtimeAudioId` — the panel and the
|
||||
* runtime speak that), while the store's ids are `sourceFile#domId`. A direct
|
||||
* `===` between the two is silently never true, which is the exact shape of
|
||||
* failure the split produces and how the reveal came to be dead.
|
||||
*/
|
||||
export function revealTargetsSelection(
|
||||
request: { elementKey: string } | null,
|
||||
id: string | null,
|
||||
): boolean {
|
||||
if (!request || id === null) return false;
|
||||
return request.elementKey === splitTimelineElementKey(id).domId;
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export interface TimelineElement {
|
||||
audioGroupHidden?: boolean;
|
||||
/** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
|
||||
audioGroupFxChain?: string;
|
||||
audioGroupAutomation?: string;
|
||||
/**
|
||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||
* child: the absolute master-timeline start of the sub-comp host the child
|
||||
@@ -86,3 +87,34 @@ export interface TimelineElement {
|
||||
expandedParentStart?: number;
|
||||
expandedHostKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields `updateElement` may write.
|
||||
*
|
||||
* Deliberately a narrow allow-list rather than `Partial<TimelineElement>`: most
|
||||
* of an element is derived from the document at parse time, and letting a
|
||||
* caller poke those would put the store out of step with the file it mirrors.
|
||||
*
|
||||
* The `audioGroup*` entries are the GROUP's state, mirrored onto every member —
|
||||
* a group row derives its label, fader, mute and chain from these, so a group
|
||||
* write has to be able to land here or the header goes on rendering whatever it
|
||||
* parsed at load.
|
||||
*/
|
||||
export type TimelineElementPatch = Partial<
|
||||
Pick<
|
||||
TimelineElement,
|
||||
| "start"
|
||||
| "duration"
|
||||
| "track"
|
||||
| "zIndex"
|
||||
| "hasExplicitZIndex"
|
||||
| "playbackStart"
|
||||
| "hidden"
|
||||
| "audioGroup"
|
||||
| "audioGroupLabel"
|
||||
| "audioGroupVolume"
|
||||
| "audioGroupHidden"
|
||||
| "audioGroupFxChain"
|
||||
| "audioGroupAutomation"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
|
||||
import {
|
||||
canHideSelections,
|
||||
isAudioDomElement,
|
||||
isAudioTimelineElement,
|
||||
isMusicTrack,
|
||||
resolveBeatSourceTrack,
|
||||
} from "./timelineInspector";
|
||||
import type { TimelineElement } from "../player";
|
||||
|
||||
// Minimal element factory for tests
|
||||
@@ -119,3 +126,30 @@ describe("resolveBeatSourceTrack", () => {
|
||||
expect(result!.isFallback).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAudioDomElement / canHideSelections", () => {
|
||||
const el = (tag: string, src?: string): Element => {
|
||||
const node = document.createElement(tag);
|
||||
if (src) node.setAttribute("src", src);
|
||||
return node;
|
||||
};
|
||||
|
||||
it("agrees with isAudioTimelineElement about tags and source extensions", () => {
|
||||
expect(isAudioDomElement(el("audio"))).toBe(true);
|
||||
expect(isAudioDomElement(el("div", "narration.mp3"))).toBe(true);
|
||||
expect(isAudioDomElement(el("div"))).toBe(false);
|
||||
expect(isAudioDomElement(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a group bus as audio, the way the single-selection panel does", () => {
|
||||
expect(isAudioDomElement(el("hf-audio-group"))).toBe(true);
|
||||
});
|
||||
|
||||
// `data-hidden` on audio is what mutes it — preview silences it and the render
|
||||
// drops it from the mix. A control labelled "Hide all" must not reach that.
|
||||
it("refuses to hide a selection holding any audio, and allows a layout one", () => {
|
||||
expect(canHideSelections([{ element: el("div") }, { element: el("span") }])).toBe(true);
|
||||
expect(canHideSelections([{ element: el("div") }, { element: el("audio") }])).toBe(false);
|
||||
expect(canHideSelections([{ element: el("hf-audio-group") }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import type { TimelineElement } from "../player";
|
||||
|
||||
const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]);
|
||||
const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
|
||||
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
|
||||
|
||||
/**
|
||||
* Is this DOM node an audio clip, judged the way `isAudioTimelineElement`
|
||||
* judges a timeline element?
|
||||
*
|
||||
* The selection layer holds real elements rather than timeline records, and
|
||||
* layout grouping is decided there — so it needs the same question asked of a
|
||||
* node. Same tag set and same source-extension fallback, so the two cannot
|
||||
* drift into disagreeing about what counts as audio.
|
||||
*/
|
||||
export function isAudioDomElement(node: Element | null | undefined): boolean {
|
||||
if (!node) return false;
|
||||
// A group bus counts: it is audio-only, and the panel's single-select path
|
||||
// already treats `<hf-audio-group>` as audio for exactly these decisions.
|
||||
if (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
|
||||
return isAudioTimelineElement({
|
||||
tag: node.tagName,
|
||||
src: node.getAttribute("src") ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function isAudioTimelineElement(
|
||||
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
|
||||
): boolean {
|
||||
@@ -60,3 +81,20 @@ export function resolveBeatSourceTrack(
|
||||
}
|
||||
return best ? { element: best, isFallback: true } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this multi-selection be hidden as one action?
|
||||
*
|
||||
* Audio has no visual to hide, and `data-hidden` on an audio element is what
|
||||
* MUTES it — preview silences it and the render drops it from the mix. The
|
||||
* timeline withholds the eye on an audio track for that reason
|
||||
* (`visible={!isAudioTrack}`), and the single-selection panel gates the same
|
||||
* write on `audioSelection`. The multi-selection "Hide all" was the one path
|
||||
* left back to it, on a control whose label promises visibility.
|
||||
*
|
||||
* A shared predicate rather than a check in the handler so the panel's button
|
||||
* and the handler's refusal cannot disagree — the button is not the only caller.
|
||||
*/
|
||||
export function canHideSelections(selections: readonly { element?: Element | null }[]): boolean {
|
||||
return !selections.some((selection) => isAudioDomElement(selection.element));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user