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:
Vance Ingalls
2026-08-23 18:09:58 -07:00
committed by GitHub
parent 54091b5015
commit 0f302285a2
16 changed files with 686 additions and 75 deletions
@@ -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(`
+3 -32
View File
@@ -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) => {
+34 -38
View File
@@ -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 DOMstore 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));
}