mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Findings 12-15 from the review. All four are group/solo UI state, none reachable before the id-space fix made group writes work at all. 14 — TimelineGroupRow called the THROWING useTimelineEditContext where every sibling row calls the optional one. Timeline.test.ts already asserted the timeline renders outside the provider; it passed because its fixture had no groups. One grouped clip took the whole timeline render down, not just the row. The new test is that assertion with a group on screen, and it fails against the old hook. 13 — A collapsed group left its members in `tracks`, so row geometry reserved a full row each while buildTimelineLogicalRows had already stopped emitting them: TimelineLanes rendered null into reserved space, giving a group header trailed by its members' worth of blank, unreachable dead space. Members are now emitted only while the group is expanded, so the row list and the logical rows agree by construction. (Zeroing the heights instead does not work — createTimelineRowGeometry deliberately reads a non-positive height as "invalid, use TRACK_H".) Membership still lands in trackGroupOf: collapsed is hidden, not ungrouped. 12 — groupInfoCache is a WeakMap keyed on the preview Document, and group edits are applied as live patches precisely so the iframe never reloads, so the key never changed and the entry never dropped. A muted group could not be unmuted (the header kept reading the cached `hidden:false` and re-wrote data-hidden), the bus slider snapped back, and a second FX preset built on a stale chain, discarding the first. Every live group write now drops the entry. 15 — Solo leaked two ways. createTimelineResetState never cleared `soloed`, so switching composition carried ids that match nothing in the new document — which is exactly the state that silences every track while the banner still names a clip that is not there. And the solo bridge's effect deps do not change across a preview reload, so the reloaded runtime kept an empty set while the button stayed lit and the banner still claimed "Hearing only X". Solo now rides applyPreviewAudioFlags, the same reload-surviving path as the mute and canary state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 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/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 `<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.
|
|
*/
|
|
function insertGroupElement(html: string, groupId: string): string {
|
|
if (readTagSnippetByTarget(html, { id: groupId }) !== undefined) return html;
|
|
const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"></${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): 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<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,
|
|
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)}`);
|
|
}
|
|
|
|
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<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);
|
|
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<void> {
|
|
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,
|
|
],
|
|
);
|
|
}
|