|
| 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, "&").replace(/"/g, """).replace(/</g, "<"); |
| 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