fix(studio): make sub-composition audio groups actually work

Browser-verified the one surface that had never been run: a group and
its members declared entirely inside a sub-composition. Both fixes
written for that case were broken, and both of their tests passed —
because I wrote fixtures that matched my assumption instead of the DOM.

Membership never arrived. `hostElementState` inherits from the flat store
twin, and a sub-comp that declares its own group keeps those members OUT
of the flat store — the store held three elements (panel, sub-comp host,
bed) and neither voice. So there was nothing to inherit from and no group
row appeared at all. Membership now rides `DomClipChild`, captured during
the DOM walk that is the only place holding the child's live element,
with the flat twin still preferred when it exists.

Routing never worked either. `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 sits on the HOST above it:

  hf-audio-group#voiceover   (no composition attrs)
  section#voices-root        data-composition-id="voices"           <- stopped here
  div#voices-host            data-composition-file="...voices.html" <- file is here
  body                       data-composition-id="<root>"

My unit fixture put the file on the sub-comp root, so the test passed
while the studio still threw "Unable to patch element in index.html" on
every mute, fader move and FX preset. The resolver climbs composition
ancestors until one names a file, and returns undefined for a root-level
group so the caller still falls back to activeCompPath.

The new tests use the ancestor shape copied from a live preview, and both
fixes were mutation-checked. Verified end to end in the studio: the group
row appears with its members nested, mute writes `data-hidden` into
compositions/voices.html and not index.html, unmute removes it, and the
fader writes data-volume="0.35" to the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:43 -07:00
co-authored by Claude Opus 5
parent 6056ec7463
commit 6de75fd4b4
6 changed files with 208 additions and 14 deletions
@@ -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 = `
<div id="voices-host" data-composition-id="voices-host" data-composition-file="compositions/voices.html">
<section id="voices-root" data-composition-id="voices">
<hf-audio-group id="voiceover"></hf-audio-group>
</section>
</div>
<hf-audio-group id="root-group"></hf-audio-group>
`;
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();
});
});
@@ -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="<root>"
*
* 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 [];