fix(studio): group rows survive a missing provider, a collapse, an edit and a reload

Findings 12-15 from the review. All four are group/solo UI state, none
reachable before the id-space fix made group writes work at all.

14 — TimelineGroupRow called the THROWING useTimelineEditContext where
every sibling row calls the optional one. Timeline.test.ts already
asserted the timeline renders outside the provider; it passed because
its fixture had no groups. One grouped clip took the whole timeline
render down, not just the row. The new test is that assertion with a
group on screen, and it fails against the old hook.

13 — A collapsed group left its members in `tracks`, so row geometry
reserved a full row each while buildTimelineLogicalRows had already
stopped emitting them: TimelineLanes rendered null into reserved space,
giving a group header trailed by its members' worth of blank,
unreachable dead space. Members are now emitted only while the group is
expanded, so the row list and the logical rows agree by construction.
(Zeroing the heights instead does not work — createTimelineRowGeometry
deliberately reads a non-positive height as "invalid, use TRACK_H".)
Membership still lands in trackGroupOf: collapsed is hidden, not
ungrouped.

12 — groupInfoCache is a WeakMap keyed on the preview Document, and
group edits are applied as live patches precisely so the iframe never
reloads, so the key never changed and the entry never dropped. A muted
group could not be unmuted (the header kept reading the cached
`hidden:false` and re-wrote data-hidden), the bus slider snapped back,
and a second FX preset built on a stale chain, discarding the first.
Every live group write now drops the entry.

15 — Solo leaked two ways. createTimelineResetState never cleared
`soloed`, so switching composition carried ids that match nothing in the
new document — which is exactly the state that silences every track
while the banner still names a clip that is not there. And the solo
bridge's effect deps do not change across a preview reload, so the
reloaded runtime kept an empty set while the button stayed lit and the
banner still claimed "Hearing only X". Solo now rides
applyPreviewAudioFlags, the same reload-surviving path as the mute and
canary state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:36 -07:00
co-authored by Claude Opus 5
parent a8d27820ba
commit ce6426ddcb
13 changed files with 285 additions and 16 deletions
@@ -4,6 +4,7 @@ import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM,
invalidateGroupInfoCache,
mergeTimelineElementsPreservingDowngrades,
} from "./timelineDOM";
import type { TimelineElement } from "../store/playerStore";
@@ -110,6 +111,54 @@ 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);
});
});
describe("parseTimelineFromDOM — canonical playback rate", () => {
it.each([
["10", 5],
@@ -79,6 +79,20 @@ interface GroupInfo {
const groupInfoCache = new WeakMap<Document, Map<string, GroupInfo>>();
/**
* Drop the cached group scan for a document.
*
* MUST be called by every live write to group state. The cache is keyed on the
* document, and group edits are applied as live patches precisely so the iframe
* never reloads — so the key never changes and the entry would otherwise live
* forever. Left stale, a muted group could never be unmuted (the header keeps
* reading `hidden:false` and re-writes `data-hidden`), the bus slider snapped
* back, and a second FX preset built on a stale chain, discarding the first.
*/
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
if (doc) groupInfoCache.delete(doc);
}
function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
if (!doc) return { label: groupId, volume: 1, hidden: false };
let info = groupInfoCache.get(doc);
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
applyPreviewAudioFlags,
buildMissingCompositionElements,
scrubPreviewAudio,
setPreviewMediaVolume,
@@ -87,3 +88,45 @@ describe("scrubPreviewAudio", () => {
stopScrubPreviewAudio();
});
});
describe("applyPreviewAudioFlags", () => {
function fakeIframe(): { iframe: HTMLIFrameElement; calls: Record<string, unknown[]> } {
const calls: Record<string, unknown[]> = {};
const win = {
__hf: {
setAudioSolo: (ids: readonly string[]) => {
calls.solo = [...ids];
},
setAudioMuteHidden: (enabled: boolean) => {
calls.muteHidden = [enabled];
},
},
};
const iframe = {
contentWindow: win,
contentDocument: null,
querySelector: () => null,
} as unknown as HTMLIFrameElement;
return { iframe, calls };
}
// Everything pushed here is state the runtime loses on reload and nothing
// else re-sends: the solo bridge's effect deps do not change across a
// reload, so the button stayed lit while every track played.
it("re-pushes the whole audio state, solo included", () => {
const { iframe, calls } = fakeIframe();
applyPreviewAudioFlags(iframe, false, 1, new Set(["voice-1"]));
expect(calls.solo).toEqual(["voice-1"]);
expect(calls.muteHidden).toEqual([false]);
});
it("pushes an empty solo set rather than skipping the call", () => {
const { iframe, calls } = fakeIframe();
applyPreviewAudioFlags(iframe, false, 1, new Set());
expect(calls.solo).toEqual([]);
});
});
@@ -157,23 +157,39 @@ function setPreviewMuteHidden(iframe: HTMLIFrameElement | null, enabled: boolean
} catch {}
}
/** Replace the runtime's soloed set. Same channel `useAudioSoloBridge` uses for
* live changes; repeated here because the bridge's effect deps do not change
* across a preview reload, so it never re-fires and the reloaded runtime would
* keep an empty set while the button stays lit. */
function setPreviewSolo(iframe: HTMLIFrameElement | null, ids: readonly string[]): void {
if (!iframe) return;
try {
const win = iframe.contentWindow as
| (Window & { __hf?: { setAudioSolo?: (ids: readonly string[]) => void } })
| null;
win?.__hf?.setAudioSolo?.(ids);
} catch {}
}
/**
* Everything the preview runtime has to be told about audio after it loads:
* the transport's mute, and the canary flags core cannot resolve for itself.
* Called from `applyPreviewAudioState`, which is the path that re-runs after a
* preview reload — the runtime comes back with every flag at its default and
* nothing else pushes them again.
* the transport's mute, the session's solo set, and the canary flags core
* cannot resolve for itself. Called from `applyPreviewAudioState`, which is the
* path that re-runs after a preview reload — the runtime comes back with every
* one of these at its default and nothing else pushes them again.
*/
export function applyPreviewAudioFlags(
iframe: HTMLIFrameElement | null,
muted: boolean,
volume: number,
soloed: ReadonlySet<string>,
): 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);
setPreviewMuteHidden(iframe, isCanaryEnabled("audio-track-mute"));
setPreviewSolo(iframe, [...soloed]);
}
export function setPreviewPlaybackRate(