Skip to content

Commit 3dd9a73

Browse files
committed
fix(studio): make audio-group edits transactional
1 parent 8b35e9d commit 3dd9a73

12 files changed

Lines changed: 1068 additions & 227 deletions
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/**
2+
* Creating an audio group: the member sweep, the group element, and the
3+
* carve's auto-group write-back.
4+
*
5+
* Split out of `timelineTrackVisibility.ts`, which owns the hidden/mute writes
6+
* these mirror and had reached the 600-line studio ceiling.
7+
*/
8+
9+
import { useCallback } from "react";
10+
import { usePlayerStore, type TimelineElement } from "../player";
11+
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
12+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
13+
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
14+
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
15+
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
16+
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
17+
import {
18+
applyPatchByTarget,
19+
buildPatchTarget,
20+
findTimelineElementInIframe,
21+
readFileContent,
22+
type RecordEditInput,
23+
} from "./timelineEditingHelpers";
24+
import {
25+
groupElementsByTargetPath,
26+
reseekPreviewRuntime,
27+
type MutableRef,
28+
type UseTimelineElementVisibilityEditingInput,
29+
} from "./timelineTrackVisibility";
30+
31+
/**
32+
* Assign (or restore) `data-audio-group` across a set of members.
33+
*
34+
* `restore` carries each member's PRIOR value so the unwind can put back a
35+
* membership that already existed, rather than removing the attribute outright.
36+
* `setElementsHidden`, which this mirrors, gets away with a plain `!hidden`
37+
* because hidden is boolean; group membership is an arbitrary id, and the carve
38+
* path does not check whether a clip is already grouped — so a failed save
39+
* could silently un-group clips that belonged to another group before it.
40+
*/
41+
function patchLiveAudioGroupState(
42+
iframe: HTMLIFrameElement | null,
43+
elements: readonly TimelineElement[],
44+
groupId: string | null,
45+
activeCompPath: string | null,
46+
restore?: ReadonlyMap<TimelineElement, string | null>,
47+
): void {
48+
for (const element of elements) {
49+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
50+
if (!target) continue;
51+
const next = restore ? (restore.get(element) ?? null) : groupId;
52+
if (next) target.setAttribute(HF_AUDIO_GROUP_ATTR, next);
53+
else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
54+
}
55+
invalidateGroupInfoCache(iframe?.contentDocument);
56+
}
57+
58+
/** Each member's `data-audio-group` before this write, for the unwind. */
59+
function captureAudioGroupState(
60+
iframe: HTMLIFrameElement | null,
61+
elements: readonly TimelineElement[],
62+
activeCompPath: string | null,
63+
): Map<TimelineElement, string | null> {
64+
const prior = new Map<TimelineElement, string | null>();
65+
for (const element of elements) {
66+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
67+
prior.set(element, target?.getAttribute(HF_AUDIO_GROUP_ATTR) ?? null);
68+
}
69+
return prior;
70+
}
71+
72+
/** Group ids are interpolated into markup and into a render-side filename, so
73+
* they stay in the character set an HTML id and a path can both carry. */
74+
const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
75+
76+
/**
77+
* The group's own `<hf-audio-group>` element, appended before `</body>` when it
78+
* is not already in the file.
79+
*
80+
* Membership alone is enough for `resolveAudioGroups` to see the group, but
81+
* every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset —
82+
* addresses the group by its DOM id (`setAudioGroupAttribute` →
83+
* `buildPatchTarget({ domId: groupId })`), so without an element of its own a
84+
* group is created and then cannot be edited at all.
85+
*
86+
* Written to the active composition file rather than beside the members, which
87+
* can live in a sub-composition: that is the file the group's later writes
88+
* target, and `resolveAudioGroups` reads the flattened document, so co-location
89+
* buys nothing.
90+
*/
91+
/** Attribute-safe, for a name the author typed. */
92+
function escapeAttr(value: string): string {
93+
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
94+
}
95+
96+
function insertGroupElement(html: string, groupId: string, label?: string): string {
97+
const existing = readTagSnippetByTarget(html, { id: groupId });
98+
if (existing !== undefined) {
99+
// Only OUR tag counts as "already there". The id was minted against the
100+
// live preview document, which does not contain markup that is on disk but
101+
// not rendered (inside a `<template>`, or an unloaded sub-composition) — so
102+
// an unrelated element can already own it. Writing nothing there would aim
103+
// every later group write (`buildPatchTarget({ domId })`) at that element,
104+
// stamping data-volume / data-hidden / data-fx-chain onto it.
105+
if (new RegExp(`^<\\s*${HF_AUDIO_GROUP_TAG}\\b`, "i").test(existing)) return html;
106+
throw new Error(`Cannot create audio group: id ${groupId} is already used in this file`);
107+
}
108+
// The author's name for the group, from the naming dialog (groups doc §5).
109+
// Without it the timeline falls back to the minted id, which is the one thing
110+
// the dialog exists to stop an author having to read.
111+
const labelAttr = label ? ` data-label="${escapeAttr(label)}"` : "";
112+
const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"${labelAttr}></${HF_AUDIO_GROUP_TAG}>`;
113+
const closeBody = html.lastIndexOf("</body>");
114+
if (closeBody < 0) return `${html}\n${tag}\n`;
115+
return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`;
116+
}
117+
118+
/** The same element in the live preview, so the group is editable before the
119+
* next reload. Returns true when it created one (only then may the unwind
120+
* remove it — a pre-existing group element is not ours to delete). */
121+
function patchLiveGroupElement(
122+
iframe: HTMLIFrameElement | null,
123+
groupId: string,
124+
label?: string,
125+
): boolean {
126+
const doc = iframe?.contentDocument;
127+
if (!doc?.body || doc.getElementById(groupId)) return false;
128+
const el = doc.createElement(HF_AUDIO_GROUP_TAG);
129+
el.id = groupId;
130+
if (label) el.setAttribute("data-label", label);
131+
doc.body.appendChild(el);
132+
invalidateGroupInfoCache(doc);
133+
return true;
134+
}
135+
136+
interface CreateAudioGroupAndAssignMembersInput {
137+
projectId: string;
138+
activeCompPath: string | null;
139+
elements: readonly TimelineElement[];
140+
groupId: string;
141+
/** The author's name for it, from the naming dialog (groups doc §5). */
142+
groupLabel?: string;
143+
previewIframe: HTMLIFrameElement | null;
144+
writeProjectFile: (path: string, content: string) => Promise<void>;
145+
recordEdit: (input: RecordEditInput) => Promise<void>;
146+
domEditSaveTimestampRef: MutableRef<number>;
147+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
148+
}
149+
150+
/**
151+
* Group two or more voice clips: write `data-audio-group="<groupId>"` on
152+
* every one of them, atomically, one undo entry — the same multi-target shape
153+
* `setElementsHidden` uses for mute — plus the group's own `<hf-audio-group>`
154+
* element, which every later group-level write addresses by DOM id. No naming
155+
* dialog: the id is the default name, the way `resolveAudioGroups` reads it.
156+
*/
157+
// fallow-ignore-next-line complexity
158+
export async function createAudioGroupAndAssignMembers({
159+
projectId,
160+
activeCompPath,
161+
elements,
162+
groupId,
163+
groupLabel,
164+
previewIframe,
165+
writeProjectFile,
166+
recordEdit,
167+
domEditSaveTimestampRef,
168+
pendingTimelineEditPathRef,
169+
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
170+
// Throws rather than returning empty: the carve's auto-group awaits this and
171+
// then persists `sources: [groupId]` on success, so a quiet no-op leaves the
172+
// carve aimed at a group that does not exist.
173+
if (elements.length < 2) {
174+
throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`);
175+
}
176+
if (!GROUP_ID_PATTERN.test(groupId)) {
177+
throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`);
178+
}
179+
180+
const priorGroups = captureAudioGroupState(previewIframe, elements, activeCompPath);
181+
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
182+
const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId, groupLabel);
183+
reseekPreviewRuntime(previewIframe);
184+
185+
const groupOperation: PatchOperation = {
186+
type: "attribute",
187+
property: HF_AUDIO_GROUP_ATTR,
188+
value: groupId,
189+
};
190+
const originalByPath = new Map<string, string>();
191+
const files: Record<string, string> = {};
192+
193+
try {
194+
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
195+
let patchedContent = await readFileContent(projectId, targetPath);
196+
originalByPath.set(targetPath, patchedContent);
197+
198+
for (const element of fileElements) {
199+
const patchTarget = buildPatchTarget(element);
200+
if (!patchTarget) {
201+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
202+
}
203+
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
204+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
205+
}
206+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
207+
}
208+
209+
files[targetPath] = patchedContent;
210+
pendingTimelineEditPathRef.current.add(targetPath);
211+
}
212+
213+
const groupPath = activeCompPath || "index.html";
214+
let groupContent = files[groupPath];
215+
if (groupContent === undefined) {
216+
groupContent = await readFileContent(projectId, groupPath);
217+
originalByPath.set(groupPath, groupContent);
218+
}
219+
const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
220+
if (withGroupElement !== groupContent) {
221+
files[groupPath] = withGroupElement;
222+
pendingTimelineEditPathRef.current.add(groupPath);
223+
}
224+
225+
domEditSaveTimestampRef.current = Date.now();
226+
const changedPaths = await saveProjectFilesWithHistory({
227+
projectId,
228+
label: groupLabel
229+
? `Group ${elements.length} clips as ${groupLabel}`
230+
: `Group ${elements.length} voice clips`,
231+
kind: "timeline",
232+
files,
233+
readFile: async (path) => {
234+
const original = originalByPath.get(path);
235+
if (original !== undefined) return original;
236+
return readFileContent(projectId, path);
237+
},
238+
writeFile: writeProjectFile,
239+
recordEdit,
240+
});
241+
domEditSaveTimestampRef.current = Date.now();
242+
for (const element of elements) {
243+
usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
244+
}
245+
return changedPaths;
246+
} catch (error) {
247+
// Mirrors setElementsHidden's failure path: the optimistic live patch
248+
// already ran, so a save failure has to be unwound or the preview shows a
249+
// grouping that never made it to disk.
250+
patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath, priorGroups);
251+
if (createdLiveGroupElement) {
252+
previewIframe?.contentDocument?.getElementById(groupId)?.remove();
253+
}
254+
reseekPreviewRuntime(previewIframe);
255+
throw error;
256+
}
257+
}
258+
259+
/**
260+
* The write behind B6's auto-group: pick two or more voice clips in the carve
261+
* picker and they land in a group instead of naming each other by id. Same
262+
* expanded-rows resolution as element-visibility, for the same reason — a
263+
* nested sub-composition child has no entry in the raw store list.
264+
*/
265+
export function useAudioGroupCarveAssignment({
266+
projectIdRef,
267+
activeCompPath,
268+
showToast,
269+
writeProjectFile,
270+
recordEdit,
271+
domEditSaveTimestampRef,
272+
previewIframeRef,
273+
pendingTimelineEditPathRef,
274+
isRecordingRef,
275+
}: UseTimelineElementVisibilityEditingInput): (
276+
clipIds: readonly string[],
277+
groupId: string,
278+
groupLabel?: string,
279+
) => Promise<void> {
280+
const expandedElements = useExpandedTimelineElements();
281+
return useCallback(
282+
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
283+
if (isRecordingRef?.current) {
284+
showToast("Cannot edit timeline while recording", "error");
285+
return;
286+
}
287+
const pid = projectIdRef.current;
288+
if (!pid) return;
289+
// DOM ids, not store keys: both callers (the carve picker and the
290+
// timeline's group-pointer button) name clips the way the document does,
291+
// because that is the only space `resolveAudioGroups` reads back.
292+
const wanted = new Set(clipIds);
293+
const elements = expandedElements.filter((item) => {
294+
const domId = runtimeAudioId(item);
295+
return domId !== null && wanted.has(domId);
296+
});
297+
try {
298+
// Loud, not silent: an unresolved id used to leave `elements` short,
299+
// `createAudioGroupAndAssignMembers` returning early with no write, and
300+
// the carve still persisting `sources: [groupId]` for a group that was
301+
// never created — a carve pointing at nothing, silently not ducking.
302+
if (elements.length !== wanted.size) {
303+
const missing = [...wanted].filter(
304+
(id) => !elements.some((item) => runtimeAudioId(item) === id),
305+
);
306+
throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`);
307+
}
308+
await createAudioGroupAndAssignMembers({
309+
groupLabel,
310+
projectId: pid,
311+
activeCompPath,
312+
elements,
313+
groupId,
314+
previewIframe: previewIframeRef.current,
315+
writeProjectFile,
316+
recordEdit,
317+
domEditSaveTimestampRef,
318+
pendingTimelineEditPathRef,
319+
});
320+
} catch (error) {
321+
console.error("[Timeline] Failed to group voice clips", error);
322+
const message = error instanceof Error ? error.message : "Failed to group voice clips";
323+
showToast(message);
324+
// Rethrown, not just reported: the carve's auto-group chains
325+
// `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
326+
// swallowing here let it persist a carve pointing at a group that was
327+
// never written — the exact silent no-op the throw inside
328+
// `createAudioGroupAndAssignMembers` exists to prevent.
329+
throw error;
330+
}
331+
},
332+
[
333+
activeCompPath,
334+
expandedElements,
335+
previewIframeRef,
336+
writeProjectFile,
337+
recordEdit,
338+
domEditSaveTimestampRef,
339+
pendingTimelineEditPathRef,
340+
isRecordingRef,
341+
showToast,
342+
projectIdRef,
343+
],
344+
);
345+
}

0 commit comments

Comments
 (0)