mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
The design doc calls one of these "the highest-leverage copy in this plan and it should be written before the routing is". The routing shipped; the copy did not. **Naming a group.** Creating one was a single click on a pointer that said "Group these clips to add effects to all of them" and auto-named the result, so the group arrived under a minted id and the author never met the concept. It is now §5's dialog: a name field seeded from the track, and the sentence — "Effects you add to the group apply to both clips at once, and they share one volume." That is a submix bus explained without the word, which is the whole point. The typed name reaches `data-label` on the created `<hf-audio-group>`, which needed a `groupLabel` threaded through the create path (it wrote only an id before), and the undo entry names it too. **The video limit, said out loud.** Groups are audio-only in v1 (§1.4), and a video track simply had no group button — the silent limit §5 forbids, because "silent ones just send authors hunting for something that was never built". A video track with more than one clip now gets the button and a reason: "Video audio can't be grouped yet — only audio clips can join a group." **Two curves that multiply.** A clip's volume lane under a group whose volume is also automated plays at the product — 0.42 × 0.80 = 0.34 — and nothing said so. The clip's lane now reads "Voiceover is also fading this." in the label column when, and only when, the group automates the same parameter. Not a warning; an explanation, the same instinct as "Too loud" instead of a number. Not built, deliberately: §1.7's peak meter and resettable peak-hold. The runbook step that implements §1.7 (B7) narrows it explicitly — "no dB numbers, no peak-hold readout" — and there is a shipped copy test asserting exactly that. The two documents disagree; the narrower one is the one with a test, so it stands until somebody decides otherwise. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4342 green.
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
/**
|
|
* 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/timelineGroupInfo";
|
|
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";
|
|
|
|
/**
|
|
* Assign (or restore) `data-audio-group` across a set of members.
|
|
*
|
|
* `restore` carries each member's PRIOR value so the unwind can put back a
|
|
* membership that already existed, rather than removing the attribute outright.
|
|
* `setElementsHidden`, which this mirrors, gets away with a plain `!hidden`
|
|
* because hidden is boolean; group membership is an arbitrary id, and the carve
|
|
* path does not check whether a clip is already grouped — so a failed save
|
|
* could silently un-group clips that belonged to another group before it.
|
|
*/
|
|
function patchLiveAudioGroupState(
|
|
iframe: HTMLIFrameElement | null,
|
|
elements: readonly TimelineElement[],
|
|
groupId: string | null,
|
|
activeCompPath: string | null,
|
|
restore?: ReadonlyMap<TimelineElement, string | null>,
|
|
): void {
|
|
for (const element of elements) {
|
|
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
if (!target) continue;
|
|
const next = restore ? (restore.get(element) ?? null) : groupId;
|
|
if (next) target.setAttribute(HF_AUDIO_GROUP_ATTR, next);
|
|
else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
|
|
}
|
|
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
}
|
|
|
|
/** Each member's `data-audio-group` before this write, for the unwind. */
|
|
function captureAudioGroupState(
|
|
iframe: HTMLIFrameElement | null,
|
|
elements: readonly TimelineElement[],
|
|
activeCompPath: string | null,
|
|
): Map<TimelineElement, string | null> {
|
|
const prior = new Map<TimelineElement, string | null>();
|
|
for (const element of elements) {
|
|
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
prior.set(element, target?.getAttribute(HF_AUDIO_GROUP_ATTR) ?? null);
|
|
}
|
|
return prior;
|
|
}
|
|
|
|
/** 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 `<hf-audio-group>` element, appended before `</body>` 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.
|
|
*/
|
|
/** Attribute-safe, for a name the author typed. */
|
|
function escapeAttr(value: string): string {
|
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
}
|
|
|
|
function insertGroupElement(html: string, groupId: string, label?: string): string {
|
|
const existing = readTagSnippetByTarget(html, { id: groupId });
|
|
if (existing !== undefined) {
|
|
// Only OUR tag counts as "already there". The id was minted against the
|
|
// live preview document, which does not contain markup that is on disk but
|
|
// not rendered (inside a `<template>`, or an unloaded sub-composition) — so
|
|
// an unrelated element can already own it. Writing nothing there would aim
|
|
// every later group write (`buildPatchTarget({ domId })`) at that element,
|
|
// stamping data-volume / data-hidden / data-fx-chain onto it.
|
|
if (new RegExp(`^<\\s*${HF_AUDIO_GROUP_TAG}\\b`, "i").test(existing)) return html;
|
|
throw new Error(`Cannot create audio group: id ${groupId} is already used in this file`);
|
|
}
|
|
// The author's name for the group, from the naming dialog (groups doc §5).
|
|
// Without it the timeline falls back to the minted id, which is the one thing
|
|
// the dialog exists to stop an author having to read.
|
|
const labelAttr = label ? ` data-label="${escapeAttr(label)}"` : "";
|
|
const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"${labelAttr}></${HF_AUDIO_GROUP_TAG}>`;
|
|
const closeBody = html.lastIndexOf("</body>");
|
|
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,
|
|
label?: 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;
|
|
if (label) el.setAttribute("data-label", label);
|
|
doc.body.appendChild(el);
|
|
invalidateGroupInfoCache(doc);
|
|
return true;
|
|
}
|
|
|
|
interface CreateAudioGroupAndAssignMembersInput {
|
|
projectId: string;
|
|
activeCompPath: string | null;
|
|
elements: readonly TimelineElement[];
|
|
groupId: string;
|
|
/** The author's name for it, from the naming dialog (groups doc §5). */
|
|
groupLabel?: string;
|
|
previewIframe: HTMLIFrameElement | null;
|
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
domEditSaveTimestampRef: MutableRef<number>;
|
|
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
}
|
|
|
|
/**
|
|
* Group two or more voice clips: write `data-audio-group="<groupId>"` on
|
|
* every one of them, atomically, one undo entry — the same multi-target shape
|
|
* `setElementsHidden` uses for mute — plus the group's own `<hf-audio-group>`
|
|
* 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,
|
|
groupLabel,
|
|
previewIframe,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
|
|
// 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)}`);
|
|
}
|
|
|
|
const priorGroups = captureAudioGroupState(previewIframe, elements, activeCompPath);
|
|
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
|
|
const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId, groupLabel);
|
|
reseekPreviewRuntime(previewIframe);
|
|
|
|
const groupOperation: PatchOperation = {
|
|
type: "attribute",
|
|
property: HF_AUDIO_GROUP_ATTR,
|
|
value: groupId,
|
|
};
|
|
const originalByPath = new Map<string, string>();
|
|
const files: Record<string, string> = {};
|
|
|
|
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, groupLabel);
|
|
if (withGroupElement !== groupContent) {
|
|
files[groupPath] = withGroupElement;
|
|
pendingTimelineEditPathRef.current.add(groupPath);
|
|
}
|
|
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
projectId,
|
|
label: groupLabel
|
|
? `Group ${elements.length} clips as ${groupLabel}`
|
|
: `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, priorGroups);
|
|
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,
|
|
groupLabel?: string,
|
|
) => Promise<void> {
|
|
const expandedElements = useExpandedTimelineElements();
|
|
return useCallback(
|
|
async (clipIds: readonly string[], groupId: string, groupLabel?: 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({
|
|
groupLabel,
|
|
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);
|
|
// Rethrown, not just reported: the carve's auto-group chains
|
|
// `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
|
|
// swallowing here let it persist a carve pointing at a group that was
|
|
// never written — the exact silent no-op the throw inside
|
|
// `createAudioGroupAndAssignMembers` exists to prevent.
|
|
throw error;
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
expandedElements,
|
|
previewIframeRef,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
domEditSaveTimestampRef,
|
|
pendingTimelineEditPathRef,
|
|
isRecordingRef,
|
|
showToast,
|
|
projectIdRef,
|
|
],
|
|
);
|
|
}
|