diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts index 8fdc07d8b..ede1506bd 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts @@ -16,7 +16,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { usePlayerStore, type TimelineElement } from "../player"; -import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume"; +import { resolveGroupSourceFile, useSetAudioGroupAttribute } from "./timelineAudioGroupVolume"; afterEach(() => { usePlayerStore.getState().reset(); @@ -128,3 +128,48 @@ describe("group attribute writes reach the store", () => { expect(byId.get("sfx")?.audioGroupVolume).toBe(1); }); }); + +/** + * The ancestor shape here is COPIED FROM A LIVE PREVIEW, not imagined. + * + * The first attempt at this fix used `getTimelineElementSourceFile` and a + * fixture in which the sub-composition root carried `data-composition-file`. + * The test passed; the studio still threw "Unable to patch element in + * index.html", because the runtime inlines a sub-comp as its own root element + * that carries only the composition ID — the FILE is on the host above it. + */ +describe("resolveGroupSourceFile", () => { + function livePreviewShape(): Document { + const doc = document.implementation.createHTMLDocument("preview"); + doc.body.setAttribute("data-composition-id", "subcomp-group-qa"); + doc.body.innerHTML = ` +
+
+ +
+
+ + `; + return doc; + } + + it("climbs past the sub-comp root to the host that names the file", () => { + const doc = livePreviewShape(); + expect(resolveGroupSourceFile(doc.getElementById("voiceover"))).toBe( + "compositions/voices.html", + ); + }); + + // A group in the root composition: body names an id but no file, so the + // caller falls back to activeCompPath. Returning body's id here would route + // every root-composition group write at a path that does not exist. + it("returns undefined for a group in the root composition", () => { + const doc = livePreviewShape(); + expect(resolveGroupSourceFile(doc.getElementById("root-group"))).toBeUndefined(); + }); + + it("is safe on a detached or missing element", () => { + expect(resolveGroupSourceFile(null)).toBeUndefined(); + expect(resolveGroupSourceFile(document.createElement("hf-audio-group"))).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.ts index c9596252b..dc76d86f4 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.ts @@ -3,7 +3,6 @@ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx"; import { usePlayerStore } from "../player"; import type { TimelineElementPatch } from "../player/store/timelineElement"; import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo"; -import { getTimelineElementSourceFile } from "../player/lib/timelineElementHelpers"; import { buildPatchTarget, persistElementAttribute, @@ -83,6 +82,36 @@ function syncStoredGroupAttribute(groupId: string, attr: string, value: string | })); } +/** + * The composition FILE that contains a group element, walking up through + * composition ancestors until one names a file. + * + * `getTimelineElementSourceFile` stops at the nearest `[data-composition-id]`, + * which for an inlined sub-composition is its own ROOT element — that carries + * the composition id but not the file. The file is on the HOST one level above + * it. Measured on a live preview: + * + * hf-audio-group#voiceover (no composition attrs) + * section#voices-root data-composition-id="voices" <- stops here + * div#voices-host data-composition-file="…/voices.html" <- file is here + * body data-composition-id="" + * + * Returns undefined for a group in the root composition (body names an id but + * no file), which is exactly when the caller should fall back to activeCompPath. + */ +export function resolveGroupSourceFile(groupEl: Element | null): string | undefined { + let node: Element | null = groupEl?.parentElement ?? null; + while (node) { + const owner: Element | null = node.closest("[data-composition-id]"); + if (!owner) return undefined; + const file = + owner.getAttribute("data-composition-file") ?? owner.getAttribute("data-composition-src"); + if (file) return file; + node = owner.parentElement; + } + return undefined; +} + interface SetAudioGroupAttributeInput { projectId: string; activeCompPath: string | null; @@ -125,8 +154,7 @@ async function setAudioGroupAttribute({ // FX preset throws "Unable to patch element in index.html". Every sibling // timeline writer already routes `element.sourceFile || activeCompPath`. const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null; - const targetPath = - (groupEl ? getTimelineElementSourceFile(groupEl) : undefined) || activeCompPath || "index.html"; + const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html"; const patchTarget = buildPatchTarget({ domId: groupId }); if (!patchTarget) return []; diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 4b7a6f600..664514a3a 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -598,4 +598,67 @@ describe("buildExpandedElements — collision-free synthetic rows (cross-file la // Distinct ordered rows per child. expect(children[0].track).not.toBe(children[1].track); }); + + /** + * A sub-composition that declares BOTH a group and its members keeps those + * members out of the flat store entirely — the store holds only the host. + * So "inherit membership from the flat twin" had nothing to inherit from, + * and the group produced no timeline row at all, for exactly the case group + * support was extended to cover. Verified against a real studio session + * before this test was written: the flat store held three elements (the + * panel, the sub-comp host and an ungrouped bed) and neither voice. + */ + it("takes group membership from the DOM child when there is no flat store twin", () => { + const elements = [ + el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }), + ]; + const manifest = [ + clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }), + ]; + const parentMap = new Map([ + ["voice-1", "voices-host"], + ["voice-2", "voices-host"], + ]); + const domClipChildren = [ + { + id: "voice-1", + parentId: "voices-host", + hostId: "voices-host", + label: "voice-1", + stackingContextId: "css:0", + audioGroup: "voiceover", + audioGroupLabel: "Voiceover", + audioGroupVolume: 0.8, + audioGroupHidden: false, + }, + { + id: "voice-2", + parentId: "voices-host", + hostId: "voices-host", + label: "voice-2", + stackingContextId: "css:0", + audioGroup: "voiceover", + audioGroupLabel: "Voiceover", + audioGroupVolume: 0.8, + audioGroupHidden: false, + }, + ]; + + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "voices-host", + "voices-host", + domClipChildren, + ); + + const voices = out.filter((e) => e.domId?.startsWith("voice-")); + expect(voices).toHaveLength(2); + for (const voice of voices) { + expect(voice.audioGroup).toBe("voiceover"); + expect(voice.audioGroupLabel).toBe("Voiceover"); + expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6); + } + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index b5561fcb4..14e0a6ea0 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -146,6 +146,31 @@ interface DisplayBounds { * could never be shown again (not even after a reload, since the attribute is in * the source). */ +/** + * Audio-group membership for an expanded child, from whichever source has it. + * + * The flat store twin when there is one; otherwise the `DomClipChild` record, + * which carried it off the live element during the DOM walk. That fallback is + * the ONLY source for a sub-composition that declares both a group and its + * members: those members never enter the flat store, so "inherit from the flat + * twin" silently produced no membership and therefore no group row — for + * exactly the case group support was extended to cover. + */ +function childGroupState( + flat: TimelineElement | undefined, + domChild: DomClipChild | undefined, +): Partial { + const source = flat?.audioGroup ? flat : domChild?.audioGroup ? domChild : null; + if (!source) return {}; + return { + audioGroup: source.audioGroup, + audioGroupLabel: source.audioGroupLabel, + audioGroupVolume: source.audioGroupVolume, + audioGroupHidden: source.audioGroupHidden, + audioGroupFxChain: source.audioGroupFxChain, + }; +} + function hostElementState(flat: TimelineElement | undefined): Partial { if (!flat) return {}; return { @@ -159,16 +184,6 @@ function hostElementState(flat: TimelineElement | undefined): Partial, ): TimelineElement[] { const result: TimelineElement[] = []; for (const child of siblings) { @@ -210,6 +226,10 @@ function buildChildElements( result.push({ ...base, ...hostElementState(elements.find((element) => element.key === key)), + ...childGroupState( + elements.find((element) => element.key === key), + domId ? domChildrenById.get(domId) : undefined, + ), key, start: clamped.start, duration: clamped.duration, @@ -312,6 +332,7 @@ export function buildExpandedElements( }; const parentKey = topLevelElement.key ?? topLevelElement.id; + const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child])); const expanded = buildChildElements( siblings, { @@ -322,6 +343,7 @@ export function buildExpandedElements( editBasis, parentKey, elements, + domChildrenById, ); if (expanded.length === 0) return filterToTopLevel(elements, parentMap); diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index c4526e260..9b21562c3 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -12,6 +12,8 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../store/playerStore"; import type { TimelineElement, DomClipChild } from "../store/playerStore"; import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; +import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; +import { groupInfoFor } from "../lib/timelineGroupInfo"; import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; import { parseTimelineFromDOM, @@ -86,6 +88,28 @@ export function resolveReloadSeekTime(input: { return Math.min(target, input.duration); } +/** + * A sub-comp child's audio-group membership, read off its live element. + * + * Captured during the DOM walk because that walk holds the only reference to + * the element. A sub-composition that declares both a group and its members + * keeps those members out of the flat store entirely, so an expanded child has + * no flat twin to inherit membership from later — without this, a group defined + * inside a sub-composition produced no group row at all. + */ +function readChildAudioGroupState(child: Element): Partial { + const audioGroup = child.getAttribute(HF_AUDIO_GROUP_ATTR); + if (!audioGroup) return {}; + const info = groupInfoFor(child.ownerDocument, audioGroup); + return { + audioGroup, + audioGroupLabel: info.label, + audioGroupVolume: info.volume, + audioGroupHidden: info.hidden, + ...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}), + }; +} + /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */ function sanitizeDurationSeconds(value: number): number { return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0; @@ -201,6 +225,7 @@ export function useTimelineSyncCallbacks({ hostId, label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, stackingContextId: resolveCssStackingContextId(child), + ...readChildAudioGroupState(child), }); parentMap.set(child.id, parentId); if (isGroup) collect(child, child.id); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 2e2bad769..8e23092cb 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -234,6 +234,17 @@ 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; } interface BeatHistoryEntry {