diff --git a/packages/studio/src/player/lib/automationStoreSync.test.ts b/packages/studio/src/player/lib/automationStoreSync.test.ts index 78148dbb3..1786c3cd7 100644 --- a/packages/studio/src/player/lib/automationStoreSync.test.ts +++ b/packages/studio/src/player/lib/automationStoreSync.test.ts @@ -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); + }); }); diff --git a/packages/studio/src/player/lib/automationStoreSync.ts b/packages/studio/src/player/lib/automationStoreSync.ts index 02894699d..2b7f79762 100644 --- a/packages/studio/src/player/lib/automationStoreSync.ts +++ b/packages/studio/src/player/lib/automationStoreSync.ts @@ -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 } : {}; }); diff --git a/packages/studio/src/player/lib/runtimeAudioId.test.ts b/packages/studio/src/player/lib/runtimeAudioId.test.ts new file mode 100644 index 000000000..1c88e10f9 --- /dev/null +++ b/packages/studio/src/player/lib/runtimeAudioId.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +/** + * The studio → runtime boundary, which nothing else crosses. + * + * Studio addresses rows by `buildTimelineElementKey`'s composite + * `#`; 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 = ` +
+ + + + +`; + +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(` +
+ + `); + const [clip] = parseTimelineFromDOM(doc, 10); + expect(clip).toBeDefined(); + expect(runtimeAudioId(clip)).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 78a0bc7ec..b33c81244 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -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 { @@ -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(` +
+ + +
+ `); + + 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(` +
+ + +
+ `); + + 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(` +
+ + +
+ `); + 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. `` 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(" is not a timeline layer", () => { + it("gets no implicit row of its own", () => { + const doc = makeDoc(` +
+ + +
+ `); + + 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(`
`); + 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(` diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index a6632ead0..0131ffe60 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -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 `` 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>(); - -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 diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index fc403f9b2..24c93f3dd 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -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 `` 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 + * `#`, 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 diff --git a/packages/studio/src/player/lib/timelineGroupInfo.ts b/packages/studio/src/player/lib/timelineGroupInfo.ts new file mode 100644 index 000000000..8c4f3026b --- /dev/null +++ b/packages/studio/src/player/lib/timelineGroupInfo.ts @@ -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 `` 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 } +>(); + +/** Bumped by every observed mutation to group state in a document. */ +const groupRevisions = new WeakMap(); +const groupObservers = new WeakSet(); + +/** + * 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 }; +} diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts index 354d47d76..4aa69a826 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -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"); + }); +}); diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 209acf2a6..c2845dff6 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -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, diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index 0e8a5b786..7732e3f3e 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -34,6 +34,27 @@ export interface FocusedEaseSegment { type FocusedEaseSegmentTarget = Omit; +/** + * 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..` / `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; /** 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) => { diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index bc797f731..70654f139 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -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(), expandedLaneOwnerIds: new Set(), focusedEaseSegment: null, + revealedAudioFxTarget: null, selectedElementIds: new Set(), requestedSeekTime: null, lintFindingsByElement: new Map(), @@ -519,18 +520,23 @@ export const usePlayerStore = create((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() - : id - ? new Set([id]) - : new Set(); + 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((set, get) => ({ activeKeyframePct: null, motionPathArmed: false, focusedEaseSegment: null, + ...(revealSurvives ? {} : { revealedAudioFxTarget: null }), } : { selectedElementId: id, selectedElementIds }; }), @@ -579,15 +586,4 @@ export const usePlayerStore = create((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); diff --git a/packages/studio/src/player/store/playerStoreDevHandle.ts b/packages/studio/src/player/store/playerStoreDevHandle.ts new file mode 100644 index 000000000..ee2c6fde9 --- /dev/null +++ b/packages/studio/src/player/store/playerStoreDevHandle.ts @@ -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; +} diff --git a/packages/studio/src/player/store/playerStoreSelection.ts b/packages/studio/src/player/store/playerStoreSelection.ts new file mode 100644 index 000000000..ab514399b --- /dev/null +++ b/packages/studio/src/player/store/playerStoreSelection.ts @@ -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, + id: string | null, + preserveSet: boolean | undefined, +): Set { + if (preserveSet) return id && current.has(id) ? new Set(current) : new Set(); + return id ? new Set([id]) : new Set(); +} + +/** + * 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; +} diff --git a/packages/studio/src/player/store/timelineElement.ts b/packages/studio/src/player/store/timelineElement.ts index 2564f16b8..76e40c13e 100644 --- a/packages/studio/src/player/store/timelineElement.ts +++ b/packages/studio/src/player/store/timelineElement.ts @@ -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`: 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" + > +>; diff --git a/packages/studio/src/utils/timelineInspector.test.ts b/packages/studio/src/utils/timelineInspector.test.ts index 318f3cbb0..fb5cef015 100644 --- a/packages/studio/src/utils/timelineInspector.test.ts +++ b/packages/studio/src/utils/timelineInspector.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/utils/timelineInspector.ts b/packages/studio/src/utils/timelineInspector.ts index 4a33e8fc2..af4b053b9 100644 --- a/packages/studio/src/utils/timelineInspector.ts +++ b/packages/studio/src/utils/timelineInspector.ts @@ -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 `` 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 | 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)); +}