fix(studio): re-read a group's own automation after an edit lands on it

Automating a group's effect parameter from the FX rack left the group's
timeline row showing nothing — no lanes, and after the previous commit no
wave button either, since it now gates on the count.

The rack is not group-aware: selecting a group and writing its
`data-automation` goes through the ordinary element attribute path. That
path's resync re-read the element's OWN automation and chain and stopped
there. But a group's lanes are derived from the mirrored `audioGroup*`
fields its MEMBERS carry, never from the group element — so the mirrors
kept the value they were born with until a reload, and the row had
nothing to draw.

Fixed at the sync sink, which exists precisely so a writer does not have
to know what needs refreshing: it now also re-reads what each member's
group carries, through the cache that already invalidates itself on any
group attribute change. The four fields are compared as one record rather
than a growing `&&` chain, which keeps the callback under the complexity
gate and stops the next field being added to the read but not the compare.

Verified live: the Voiceover group's row went from no toggle to `∿ 1` —
its one author-owned lane, the carve's three bands correctly excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:14 -07:00
co-authored by Claude Opus 5
parent 624166f380
commit f26f48b3a6
2 changed files with 54 additions and 4 deletions
@@ -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);
});
});
@@ -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 } : {};
});