fix(studio): half-lit group solo compares bare DOM ids too

A fifth face of the id-space mismatch, not in the review's list.
TimelineGroupRow built its member list from `el.key ?? el.id` and handed
it to isGroupHalfLitUnderSolo, which compares against the soloed set —
bare DOM ids now that the header pushes them that way. Soloing a member
lit nothing on its group, the one signal that says "some of what is
under here still plays".

Found by auditing every consumer of `soloed` rather than trusting the
handoff's claim that this call site "happens to pass the bare id" — it
passes a bare id for the GROUP and store keys for its MEMBERS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:34 -07:00
co-authored by Claude Opus 5
parent 5850a0c978
commit a8d27820ba
2 changed files with 24 additions and 1 deletions
@@ -1,6 +1,7 @@
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
@@ -65,7 +66,10 @@ export function TimelineGroupRow({
const domEditActions = useDomEditActionsContextOptional();
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
const memberIds = memberElements.map((el) => el.key ?? el.id);
// Bare DOM ids: this list is compared against the `soloed` set, which the
// runtime matches on `el.id` (see `runtimeAudioId`). Store keys here made the
// half-lit state unreachable — soloing a member lit nothing on its group.
const memberIds = memberElements.map(runtimeAudioId).filter((id): id is string => id !== null);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
import {
audioGroupOf,
isAudibleUnderSolo,
isGroupHalfLitUnderSolo,
resolveAudioGroups,
} from "@hyperframes/core/audio-groups";
import { parseTimelineFromDOM } from "./timelineDOM";
@@ -95,6 +96,24 @@ describe("group membership ids cross into the runtime", () => {
for (const id of memberIds) expect(clipIds).toContain(id);
});
// TimelineGroupRow's half-lit state ("some of what's under here still
// plays") compares its member list against the same soloed set. Built from
// store keys it never matched, so soloing a member lit nothing on its group.
it("half-lights the group when one member is soloed", () => {
const doc = docWith(COMPOSITION);
const members = parseTimelineFromDOM(doc, 30).filter((el) => el.audioGroup === "voiceover");
const memberIds = members.map(runtimeAudioId).filter((id): id is string => id !== null);
expect(memberIds).toEqual(["voice-1", "voice-2"]);
const soloed = new Set(["voice-1"]);
expect(isGroupHalfLitUnderSolo(soloed, "voiceover", memberIds)).toBe(true);
// Store keys are the shape that silently failed.
const storeKeys = members.map((el) => el.key ?? el.id);
expect(isGroupHalfLitUnderSolo(soloed, "voiceover", storeKeys)).toBe(false);
// Soloing the group itself is lit, not half-lit.
expect(isGroupHalfLitUnderSolo(new Set(["voiceover"]), "voiceover", memberIds)).toBe(false);
});
it("an element with no DOM id is not groupable or soloable", () => {
const doc = docWith(`
<div data-composition-id="root" data-duration="10"></div>