/** * Creating an audio group: the member sweep, the group element, and the * carve's auto-group write-back. * * Split out of `timelineTrackVisibility.ts`, which owns the hidden/mute writes * these mirror and had reached the 600-line studio ceiling. */ import { useCallback } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups"; import { runtimeAudioId } from "../player/lib/timelineElementHelpers"; import { invalidateGroupInfoCache } from "../player/lib/timelineDOM"; import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher"; import { applyPatchByTarget, buildPatchTarget, findTimelineElementInIframe, readFileContent, type RecordEditInput, } from "./timelineEditingHelpers"; import { groupElementsByTargetPath, reseekPreviewRuntime, type MutableRef, type UseTimelineElementVisibilityEditingInput, } from "./timelineTrackVisibility"; function patchLiveAudioGroupState( iframe: HTMLIFrameElement | null, elements: readonly TimelineElement[], groupId: string | null, activeCompPath: string | null, ): void { for (const element of elements) { const target = findTimelineElementInIframe(iframe, element, activeCompPath); if (!target) continue; if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId); else target.removeAttribute(HF_AUDIO_GROUP_ATTR); } invalidateGroupInfoCache(iframe?.contentDocument); } /** Group ids are interpolated into markup and into a render-side filename, so * they stay in the character set an HTML id and a path can both carry. */ const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/; /** * The group's own `` element, appended before `` when it * is not already in the file. * * Membership alone is enough for `resolveAudioGroups` to see the group, but * every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset — * addresses the group by its DOM id (`setAudioGroupAttribute` → * `buildPatchTarget({ domId: groupId })`), so without an element of its own a * group is created and then cannot be edited at all. * * Written to the active composition file rather than beside the members, which * can live in a sub-composition: that is the file the group's later writes * target, and `resolveAudioGroups` reads the flattened document, so co-location * buys nothing. */ function insertGroupElement(html: string, groupId: string): string { if (readTagSnippetByTarget(html, { id: groupId }) !== undefined) return html; const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}">`; const closeBody = html.lastIndexOf(""); if (closeBody < 0) return `${html}\n${tag}\n`; return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`; } /** The same element in the live preview, so the group is editable before the * next reload. Returns true when it created one (only then may the unwind * remove it — a pre-existing group element is not ours to delete). */ function patchLiveGroupElement(iframe: HTMLIFrameElement | null, groupId: string): boolean { const doc = iframe?.contentDocument; if (!doc?.body || doc.getElementById(groupId)) return false; const el = doc.createElement(HF_AUDIO_GROUP_TAG); el.id = groupId; doc.body.appendChild(el); invalidateGroupInfoCache(doc); return true; } interface CreateAudioGroupAndAssignMembersInput { projectId: string; activeCompPath: string | null; elements: readonly TimelineElement[]; groupId: string; previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } /** * Group two or more voice clips: write `data-audio-group=""` on * every one of them, atomically, one undo entry — the same multi-target shape * `setElementsHidden` uses for mute — plus the group's own `` * element, which every later group-level write addresses by DOM id. No naming * dialog: the id is the default name, the way `resolveAudioGroups` reads it. */ // fallow-ignore-next-line complexity export async function createAudioGroupAndAssignMembers({ projectId, activeCompPath, elements, groupId, previewIframe, writeProjectFile, recordEdit, domEditSaveTimestampRef, pendingTimelineEditPathRef, }: CreateAudioGroupAndAssignMembersInput): Promise { // Throws rather than returning empty: the carve's auto-group awaits this and // then persists `sources: [groupId]` on success, so a quiet no-op leaves the // carve aimed at a group that does not exist. if (elements.length < 2) { throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`); } if (!GROUP_ID_PATTERN.test(groupId)) { throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`); } patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath); const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId); reseekPreviewRuntime(previewIframe); const groupOperation: PatchOperation = { type: "attribute", property: HF_AUDIO_GROUP_ATTR, value: groupId, }; const originalByPath = new Map(); const files: Record = {}; try { for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) { let patchedContent = await readFileContent(projectId, targetPath); originalByPath.set(targetPath, patchedContent); for (const element of fileElements) { const patchTarget = buildPatchTarget(element); if (!patchTarget) { throw new Error(`Timeline element ${element.id} is missing a patchable target`); } if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) { throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`); } patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation); } files[targetPath] = patchedContent; pendingTimelineEditPathRef.current.add(targetPath); } const groupPath = activeCompPath || "index.html"; let groupContent = files[groupPath]; if (groupContent === undefined) { groupContent = await readFileContent(projectId, groupPath); originalByPath.set(groupPath, groupContent); } const withGroupElement = insertGroupElement(groupContent, groupId); if (withGroupElement !== groupContent) { files[groupPath] = withGroupElement; pendingTimelineEditPathRef.current.add(groupPath); } domEditSaveTimestampRef.current = Date.now(); const changedPaths = await saveProjectFilesWithHistory({ projectId, label: `Group ${elements.length} voice clips`, kind: "timeline", files, readFile: async (path) => { const original = originalByPath.get(path); if (original !== undefined) return original; return readFileContent(projectId, path); }, writeFile: writeProjectFile, recordEdit, }); domEditSaveTimestampRef.current = Date.now(); for (const element of elements) { usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId }); } return changedPaths; } catch (error) { // Mirrors setElementsHidden's failure path: the optimistic live patch // already ran, so a save failure has to be unwound or the preview shows a // grouping that never made it to disk. patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath); if (createdLiveGroupElement) { previewIframe?.contentDocument?.getElementById(groupId)?.remove(); } reseekPreviewRuntime(previewIframe); throw error; } } /** * The write behind B6's auto-group: pick two or more voice clips in the carve * picker and they land in a group instead of naming each other by id. Same * expanded-rows resolution as element-visibility, for the same reason — a * nested sub-composition child has no entry in the raw store list. */ export function useAudioGroupCarveAssignment({ projectIdRef, activeCompPath, showToast, writeProjectFile, recordEdit, domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, }: UseTimelineElementVisibilityEditingInput): ( clipIds: readonly string[], groupId: string, ) => Promise { const expandedElements = useExpandedTimelineElements(); return useCallback( async (clipIds: readonly string[], groupId: string) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid) return; // DOM ids, not store keys: both callers (the carve picker and the // timeline's group-pointer button) name clips the way the document does, // because that is the only space `resolveAudioGroups` reads back. const wanted = new Set(clipIds); const elements = expandedElements.filter((item) => { const domId = runtimeAudioId(item); return domId !== null && wanted.has(domId); }); try { // Loud, not silent: an unresolved id used to leave `elements` short, // `createAudioGroupAndAssignMembers` returning early with no write, and // the carve still persisting `sources: [groupId]` for a group that was // never created — a carve pointing at nothing, silently not ducking. if (elements.length !== wanted.size) { const missing = [...wanted].filter( (id) => !elements.some((item) => runtimeAudioId(item) === id), ); throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`); } await createAudioGroupAndAssignMembers({ projectId: pid, activeCompPath, elements, groupId, previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, domEditSaveTimestampRef, pendingTimelineEditPathRef, }); } catch (error) { console.error("[Timeline] Failed to group voice clips", error); const message = error instanceof Error ? error.message : "Failed to group voice clips"; showToast(message); } }, [ activeCompPath, expandedElements, previewIframeRef, writeProjectFile, recordEdit, domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, projectIdRef, ], ); }