fix(studio): make audio-group edits transactional (#3449)

* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional
This commit is contained in:
Vance Ingalls
2026-08-23 18:10:19 -07:00
committed by GitHub
parent 0f302285a2
commit 8e96ccb0b2
12 changed files with 1068 additions and 227 deletions
@@ -0,0 +1,345 @@
/**
* 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
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,
],
);
}
@@ -0,0 +1,204 @@
// @vitest-environment jsdom
/**
* A group write has to reach the STORE, not just the file and the live DOM.
*
* The timeline derives a group row's label, fader, mute and chain from the
* `audioGroup*` fields mirrored onto its members — so a write that lands
* everywhere except there leaves the header rendering whatever it parsed at
* load. Observed in the studio: muting a group wrote `data-hidden` to disk and
* to the preview, and the button stayed "Mute group Voiceover", re-writing the
* same attribute on every click with no way to unmute.
*
* Invalidating the parse cache is necessary but not sufficient — it only makes
* the NEXT parse honest, and a live attribute patch never triggers one.
*/
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore, type TimelineElement } from "../player";
import { resolveGroupSourceFile, useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
afterEach(() => {
usePlayerStore.getState().reset();
});
function member(domId: string, track: number): TimelineElement {
return {
id: domId,
key: `index.html#${domId}`,
domId,
tag: "audio",
start: 0,
duration: 5,
track,
audioGroup: "voiceover",
audioGroupHidden: false,
audioGroupVolume: 1,
};
}
/** The hook without React — it only closes over refs and callbacks. */
function makeSetter() {
const input = {
projectIdRef: { current: "project-1" },
activeCompPath: "index.html",
showToast: () => {},
writeProjectFile: async () => {},
recordEdit: async () => {},
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set<string>() },
previewIframeRef: { current: null },
};
// `setLive` takes no async path and touches only the preview DOM + store, so
// it can be exercised directly; `setQuiet` additionally persists, which this
// test deliberately does not cover (that is timelineTrackVisibility's job).
let setter: ReturnType<typeof useSetAudioGroupAttribute> | null = null;
const Probe = () => {
setter = useSetAudioGroupAttribute(input as never);
return null;
};
// Minimal hook harness: call the component function directly. It uses only
// useCallback, which React allows outside a renderer when the result is used
// immediately and never re-rendered.
return { Probe, get: () => setter };
}
describe("group attribute writes reach the store", () => {
it("mirrors data-hidden onto every member so the header can flip", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const harness = makeSetter();
renderToStaticMarkup(react.createElement(harness.Probe));
const setter = harness.get();
expect(setter).not.toBeNull();
usePlayerStore.getState().setElements([member("voice-1", 0), member("voice-2", 1)]);
setter?.setLive("voiceover", "data-hidden", "");
expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === true)).toBe(
true,
);
setter?.setLive("voiceover", "data-hidden", null);
expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === false)).toBe(
true,
);
});
// `Number(null)` and `Number("")` are both 0 AND finite, so the obvious
// isFinite check mirrored "silent" for a removed attribute while core's
// `readAudioGroupVolume` reads the same absence as unity — a parse divergence
// inside the mirror whose entire job is to prevent one.
it("reads a removed data-volume as unity, the way core does", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const harness = makeSetter();
renderToStaticMarkup(react.createElement(harness.Probe));
const setter = harness.get();
usePlayerStore.getState().setElements([member("voice-1", 0)]);
setter?.setLive("voiceover", "data-volume", "0.3");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBeCloseTo(0.3, 6);
setter?.setLive("voiceover", "data-volume", null);
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
setter?.setLive("voiceover", "data-volume", "");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
setter?.setLive("voiceover", "data-volume", "nonsense");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
});
it("mirrors data-volume, and leaves other groups alone", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const harness = makeSetter();
renderToStaticMarkup(react.createElement(harness.Probe));
const setter = harness.get();
const other: TimelineElement = { ...member("sfx", 2), audioGroup: "effects" };
usePlayerStore.getState().setElements([member("voice-1", 0), other]);
setter?.setLive("voiceover", "data-volume", "0.4");
const byId = new Map(usePlayerStore.getState().elements.map((el) => [el.id, el]));
expect(byId.get("voice-1")?.audioGroupVolume).toBeCloseTo(0.4, 6);
expect(byId.get("sfx")?.audioGroupVolume).toBe(1);
});
});
/**
* The ancestor shape here is COPIED FROM A LIVE PREVIEW, not imagined.
*
* The first attempt at this fix used `getTimelineElementSourceFile` and a
* fixture in which the sub-composition root carried `data-composition-file`.
* The test passed; the studio still threw "Unable to patch element in
* index.html", because the runtime inlines a sub-comp as its own root element
* that carries only the composition ID — the FILE is on the host above it.
*/
describe("the mirror reaches sub-composition members", () => {
/**
* A group declared inside a sub-composition has no FLAT member to mirror onto
* — `childGroupState` keeps those members out of `elements` — so mirroring
* only `elements` made this whole function a no-op for it: the header kept the
* pre-write chain and `laneCount` stayed 0, so the lane disclosure never
* appeared for automation that now existed.
*/
it("mirrors a group write onto DomClipChild members as well as flat ones", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const harness = makeSetter();
renderToStaticMarkup(react.createElement(harness.Probe));
const setter = harness.get();
usePlayerStore.getState().setElements([member("flat-1", 0)]);
usePlayerStore.getState().setDomClipChildren([
{ id: "sub-1", parentId: "host", hostId: "host", label: "Sub 1", audioGroup: "voiceover" },
{ id: "other", parentId: "host", hostId: "host", label: "Other", audioGroup: "sfx" },
]);
setter?.setLive("voiceover", "data-label", "Voices");
const children = usePlayerStore.getState().domClipChildren;
expect(children.find((c) => c.id === "sub-1")?.audioGroupLabel).toBe("Voices");
// A member of another group is untouched.
expect(children.find((c) => c.id === "other")?.audioGroupLabel).toBeUndefined();
});
});
describe("resolveGroupSourceFile", () => {
function livePreviewShape(): Document {
const doc = document.implementation.createHTMLDocument("preview");
doc.body.setAttribute("data-composition-id", "subcomp-group-qa");
doc.body.innerHTML = `
<div id="voices-host" data-composition-id="voices-host" data-composition-file="compositions/voices.html">
<section id="voices-root" data-composition-id="voices">
<hf-audio-group id="voiceover"></hf-audio-group>
</section>
</div>
<hf-audio-group id="root-group"></hf-audio-group>
`;
return doc;
}
it("climbs past the sub-comp root to the host that names the file", () => {
const doc = livePreviewShape();
expect(resolveGroupSourceFile(doc.getElementById("voiceover"))).toBe(
"compositions/voices.html",
);
});
// A group in the root composition: body names an id but no file, so the
// caller falls back to activeCompPath. Returning body's id here would route
// every root-composition group write at a path that does not exist.
it("returns undefined for a group in the root composition", () => {
const doc = livePreviewShape();
expect(resolveGroupSourceFile(doc.getElementById("root-group"))).toBeUndefined();
});
it("is safe on a detached or missing element", () => {
expect(resolveGroupSourceFile(null)).toBeUndefined();
expect(resolveGroupSourceFile(document.createElement("hf-audio-group"))).toBeUndefined();
});
});
@@ -1,4 +1,9 @@
import { useCallback } from "react";
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
import { usePlayerStore } from "../player";
import type { TimelineElementPatch } from "../player/store/timelineElement";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
import {
buildPatchTarget,
persistElementAttribute,
@@ -21,6 +26,111 @@ function patchLiveGroupAttribute(
if (!target) return;
if (value === null) target.removeAttribute(attr);
else target.setAttribute(attr, value);
invalidateGroupInfoCache(iframe?.contentDocument);
}
/**
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
* empty attribute is UNITY, not zero.
*
* `Number(null)` and `Number("")` are both 0 and both finite, so the obvious
* `Number.isFinite(Number(value))` mirrored "silent" into the store for a
* removed attribute while the DOM, the preview bus and the render all read 1 —
* exactly the parse divergence this mirror exists to eliminate.
*/
function mirroredGroupVolume(value: string | null): number {
if (!value) return 1;
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 1;
}
/** Which store field each writable group attribute mirrors into. */
const GROUP_ATTR_TO_MIRROR: Record<
string,
(value: string | null, groupId: string) => TimelineElementPatch
> = {
"data-hidden": (value) => ({ audioGroupHidden: value !== null }),
"data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
"data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
[HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
[HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ audioGroupAutomation: value ?? undefined }),
};
/**
* Mirror a group attribute onto the store copy every member carries.
*
* The timeline derives a group's label / volume / mute / chain from these
* mirrored `audioGroup*` fields on its MEMBERS, not from the group element —
* and a group write only ever touched the file and the live preview DOM.
* Nothing re-parsed, so the header went on reading the old value: the observed
* symptom was a muted group whose button stayed "Mute group", re-writing
* `data-hidden` on every click and never offering to unmute.
*
* Invalidating the parse cache is necessary but not sufficient — it only
* ensures the NEXT parse is honest, and a live attribute patch does not cause
* one. Same reason `commitDataAttribute` carries `syncStoredAutomationFromPreview`.
*/
function syncStoredGroupAttribute(groupId: string, attr: string, value: string | null): void {
const toPatch = GROUP_ATTR_TO_MIRROR[attr];
if (!toPatch) return;
const patch = toPatch(value, groupId);
// ONE pass and one notification, rather than `updateElement` per member.
// That helper maps the whole `elements` array per call, so a 3-member group on
// a 500-clip composition was 1500 object spreads and 3 store notifications per
// drag frame — at ~60/s, with every `elements`-keyed memo downstream
// recomputing each time.
// BOTH stores, because a group declared inside a sub-composition has no flat
// member to mirror onto: `childGroupState` keeps those members out of
// `elements` entirely, so their `audioGroup*` fields come from the
// `DomClipChild` record instead. Mirroring only `elements` made this whole
// function a no-op for such a group — the header kept the pre-write chain, its
// FX button showed the old count, and `laneCount` stayed 0 so the lane
// disclosure never appeared for automation that now existed. Verbatim the
// symptom this docblock claims to have fixed, fixed only for flat members.
//
// `setDomClipChildren` has one other writer, inside `processTimelineMessage`,
// which a live attribute patch does not trigger.
usePlayerStore.setState((state) => {
const next: Partial<typeof state> = {
elements: state.elements.map((el) => (el.audioGroup === groupId ? { ...el, ...patch } : el)),
};
if (state.domClipChildren.some((child) => child.audioGroup === groupId)) {
next.domClipChildren = state.domClipChildren.map((child) =>
child.audioGroup === groupId ? { ...child, ...patch } : child,
);
}
return next;
});
}
/**
* The composition FILE that contains a group element, walking up through
* composition ancestors until one names a file.
*
* `getTimelineElementSourceFile` stops at the nearest `[data-composition-id]`,
* which for an inlined sub-composition is its own ROOT element — that carries
* the composition id but not the file. The file is on the HOST one level above
* it. Measured on a live preview:
*
* hf-audio-group#voiceover (no composition attrs)
* section#voices-root data-composition-id="voices" <- stops here
* div#voices-host data-composition-file="…/voices.html" <- file is here
* body data-composition-id="<root>"
*
* Returns undefined for a group in the root composition (body names an id but
* no file), which is exactly when the caller should fall back to activeCompPath.
*/
export function resolveGroupSourceFile(groupEl: Element | null): string | undefined {
let node: Element | null = groupEl?.parentElement ?? null;
while (node) {
const owner: Element | null = node.closest("[data-composition-id]");
if (!owner) return undefined;
const file =
owner.getAttribute("data-composition-file") ?? owner.getAttribute("data-composition-src");
if (file) return file;
node = owner.parentElement;
}
return undefined;
}
interface SetAudioGroupAttributeInput {
@@ -56,7 +166,16 @@ async function setAudioGroupAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetAudioGroupAttributeInput): Promise<string[]> {
const targetPath = activeCompPath || "index.html";
// The file that actually CONTAINS the group element, not just the active
// composition. A hand-authored sub-composition can declare both the members
// and their `<hf-audio-group>`, and until sub-comp children inherited
// `audioGroup*` no group row existed for that case so nothing could reach
// here. Now the row appears, and routing its writes at `activeCompPath`
// means `readTagSnippetByTarget` finds nothing and every mute, fader move and
// FX preset throws "Unable to patch element in index.html". Every sibling
// timeline writer already routes `element.sourceFile || activeCompPath`.
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
const patchTarget = buildPatchTarget({ domId: groupId });
if (!patchTarget) return [];
@@ -72,8 +191,6 @@ async function setAudioGroupAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
readLive: () =>
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
});
}
@@ -101,6 +218,10 @@ export function useSetAudioGroupAttribute({
const setLive = useCallback(
(groupId: string, attr: string, value: string | null) => {
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
// Live too, not just on commit: a fader drag is `setLive` per frame and
// `setQuiet` once on release, so without this the strip's own readout
// fights the drag.
syncStoredGroupAttribute(groupId, attr, value);
},
[previewIframeRef],
);
@@ -126,7 +247,18 @@ export function useSetAudioGroupAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
syncStoredGroupAttribute(groupId, attr, value);
} catch (error) {
// `persistElementAttribute` leaves the live DOM at the previous value
// however it failed — it unwinds a failed save, and an unresolvable
// target now throws before patching at all. But `setLive` mirrored the
// in-progress value into the store on every drag frame — so without
// this the fader reads 0.4 while
// the preview and the file are both back at 1.0, and nothing re-parses
// to correct it (a live patch causing no parse is this mirror's whole
// premise). Re-mirror from the DOM, which is now authoritative again.
const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
console.error("[Timeline] Failed to set group attribute", error);
const message = error instanceof Error ? error.message : "Failed to update group";
showToast(message);
@@ -6,6 +6,7 @@ import {
deleteSelectedKeyframes,
extendRootDurationIfNeeded,
patchIframeDomTiming,
persistElementAttribute,
persistTimelineBatchEdit,
type PersistTimelineBatchChange,
} from "./timelineEditingHelpers";
@@ -423,3 +424,88 @@ describe("deleteSelectedKeyframes", () => {
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
});
});
describe("persistElementAttribute", () => {
/**
* The optimistic live patch used to run BEFORE the target was resolved, and
* only the save was wrapped in the unwind. So an unresolvable target threw
* with the preview holding a value that never reached disk — and the group
* writer's catch mirrors the live DOM into the store, so the UI reported the
* write as applied until a reload dropped it.
*/
it("does not patch the live DOM when the target resolves to nothing", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ content: '<body><audio id="other"></audio></body>' })),
);
const patchLive = vi.fn();
const writeProjectFile = vi.fn();
await expect(
persistElementAttribute({
projectId: "p",
targetPath: "index.html",
patchTarget: { id: "missing" },
attr: "data-volume",
value: "0.4",
label: "Set volume",
writeProjectFile,
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
patchLive,
}),
).rejects.toThrow("Unable to patch element in index.html");
expect(patchLive).not.toHaveBeenCalled();
expect(writeProjectFile).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe("persistElementAttribute — unwind value", () => {
/**
* The unwind has to restore the value on DISK, not the one in the preview.
*
* Every live-write caller patches the DOM before committing (a fader drag is
* `setLive` per frame; hovering a preset auditions the whole chain), so by
* commit time the live DOM already holds the in-progress value. Reading it as
* `previousValue` made the unwind a no-op, and the group writer's catch —
* which deliberately re-mirrors the store off the live DOM — then mirrored the
* never-saved value: the panel agreed with the preview, and a reload dropped it.
*/
it("restores the file's value, not the audition already in the live DOM", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(
JSON.stringify({ content: `<body><audio id="bgm" data-volume="0.25"></audio></body>` }),
),
);
const patched: Array<string | null> = [];
const writeProjectFile = vi.fn(() => Promise.reject(new Error("save failed")));
await expect(
persistElementAttribute({
projectId: "p",
targetPath: "index.html",
patchTarget: { id: "bgm" },
attr: "data-volume",
value: "0.9",
label: "Set volume",
writeProjectFile,
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
// The live DOM is ALREADY at the new value when the commit runs — that
// is what `setLive` does on every drag frame.
patchLive: (v) => patched.push(v),
}),
).rejects.toThrow("save failed");
// First the optimistic write, then the unwind — back to what the FILE said.
expect(patched).toEqual(["0.9", "0.25"]);
fetchSpy.mockRestore();
});
});
@@ -410,8 +410,6 @@ export interface PersistElementAttributeInput {
pendingTimelineEditPathRef: { current: Set<string> };
/** Write the attribute directly on the live preview DOM node. */
patchLive: (value: string | null) => void;
/** Read the attribute's current value off the live preview DOM node. */
readLive: () => string | null;
}
/**
@@ -420,7 +418,7 @@ export interface PersistElementAttributeInput {
* `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
* `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
* how the live node is found and where the patch target resolves to differs,
* which is exactly what `patchLive`/`readLive`/`patchTarget` parameterize.
* which is exactly what `patchLive`/`patchTarget` parameterize.
*/
export async function persistElementAttribute({
projectId,
@@ -434,15 +432,29 @@ export async function persistElementAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
patchLive,
readLive,
}: PersistElementAttributeInput): Promise<string[]> {
const previousValue = readLive();
patchLive(value);
// Resolve the target BEFORE patching the live DOM. The optimistic patch used
// to run first, and only the save was wrapped in the unwind — so an
// unresolvable target threw with the live preview (and, through the callers'
// catch, the store mirrored off it) holding a value that never reached disk.
// The write then read as successful until a reload dropped it.
const before = await readFileContent(projectId, targetPath);
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
throw new Error(`Unable to patch element in ${targetPath}`);
}
// The unwind value comes from the FILE, not from `readLive()`.
//
// Every live-write caller patches the DOM before committing — a fader drag is
// `setLive` per frame, hovering a preset auditions the whole chain — so by the
// time this runs the live DOM already holds the in-progress value. Reading it
// here made `previousValue === value`, so the unwind below was a no-op, and
// `setQuiet`'s catch (which deliberately re-mirrors the store from the live
// DOM) then mirrored that same never-saved value. The group audibly had the
// preset, the panel agreed, and a reload dropped it — the failure class the
// target check above was added to close, still open on the live-write path.
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
patchLive(value);
const operation: PatchOperation = { type: "attribute", property: attr, value };
const patched = applyPatchByTarget(before, patchTarget, operation);
@@ -75,9 +75,6 @@ async function setElementAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
readLive: () =>
findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
null,
});
}
@@ -2,11 +2,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../player";
import {
createAudioGroupAndAssignMembers,
toggleTimelineElementHidden,
toggleTimelineTrackHidden,
} from "./timelineTrackVisibility";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
import { createAudioGroupAndAssignMembers } from "./timelineAudioGroupCreate";
import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
afterEach(() => {
document.body.innerHTML = "";
@@ -513,23 +512,106 @@ describe("createAudioGroupAndAssignMembers", () => {
).toBe("voiceover");
});
it("does nothing for fewer than two elements — grouping is a plural concept", async () => {
const recordEdit = vi.fn();
const changedPaths = await createAudioGroupAndAssignMembers({
// The group element is what every LATER group write addresses — mute, the bus
// fader's data-volume, an FX preset all go through
// `buildPatchTarget({ domId: groupId })`. Membership alone parses, but leaves
// a group nothing can edit.
it("emits the group's own <hf-audio-group> element, patchable by its DOM id", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
if (iframe.contentDocument) {
iframe.contentDocument.body.innerHTML = `
<audio id="narration"></audio>
<audio id="interview-guest"></audio>
`;
}
const files = new Map([
[
"index.html",
`<html><body>
<audio id="narration" data-start="0" data-duration="5"></audio>
<audio id="interview-guest" data-start="10" data-duration="5"></audio>
</body></html>`,
],
]);
stubProjectFiles(files);
const narration = element({ id: "narration", domId: "narration", track: 0 });
const guest = element({ id: "interview-guest", domId: "interview-guest", track: 1 });
usePlayerStore.getState().setElements([narration, guest]);
const writes = new Map<string, string>();
await createAudioGroupAndAssignMembers({
projectId: "project-1",
activeCompPath: "index.html",
elements: [element({ id: "narration", domId: "narration" })],
elements: [narration, guest],
groupId: "voiceover",
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
previewIframe: iframe,
writeProjectFile: async (path, content) => {
writes.set(path, content);
},
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(changedPaths).toEqual([]);
const written = writes.get("index.html") ?? "";
expect(written).toContain('<hf-audio-group id="voiceover"></hf-audio-group>');
// The actual contract: the group-attribute writer can now find a target.
// This is the read that threw "Unable to patch element in index.html".
expect(readTagSnippetByTarget(written, { id: "voiceover" })).toBeDefined();
// ...and in the live preview, which is what patchLiveGroupAttribute reads
// before the next reload.
expect(iframe.contentDocument?.getElementById("voiceover")?.tagName.toLowerCase()).toBe(
"hf-audio-group",
);
// Both members still resolve into it.
expect(resolveAudioGroups(iframe.contentDocument as Document)[0]).toMatchObject({
id: "voiceover",
memberIds: ["narration", "interview-guest"],
});
});
// Rejects rather than resolving empty: the carve's auto-group persists
// `sources: [groupId]` once this resolves, so a silent no-op leaves the carve
// pointing at a group that was never written — and stops ducking.
it("rejects for fewer than two elements — grouping is a plural concept", async () => {
const recordEdit = vi.fn();
await expect(
createAudioGroupAndAssignMembers({
projectId: "project-1",
activeCompPath: "index.html",
elements: [element({ id: "narration", domId: "narration" })],
groupId: "voiceover",
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
}),
).rejects.toThrow("a group needs at least two");
expect(recordEdit).not.toHaveBeenCalled();
});
it("rejects a group id that is not safe to interpolate into markup or a path", async () => {
await expect(
createAudioGroupAndAssignMembers({
projectId: "project-1",
activeCompPath: "index.html",
elements: [
element({ id: "narration", domId: "narration" }),
element({ id: "guest", domId: "guest" }),
],
groupId: '../x"><script>',
previewIframe: null,
writeProjectFile: async () => {},
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
}),
).rejects.toThrow("Invalid audio group id");
});
it("reverts the optimistic live patch when the save fails", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
@@ -8,7 +8,6 @@ import {
} from "../player/components/timelineTrackDisplay";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { isAudioTimelineElement } from "../utils/timelineInspector";
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
@@ -32,12 +31,8 @@ interface ToggleTimelineTrackHiddenInput {
timelineElements: readonly TimelineElement[];
track: number;
hidden: boolean;
/**
* The display row the clicked control announced, when the caller has one.
* Absent (a keyboard path or a programmatic call), the row is derived here
* from ascending element-bearing keys — correct whenever no group has
* reordered the header.
*/
/** The row the CLICKED control announced. Absent when the caller has no
* rendered number to hand over, which falls back to deriving one. */
displayNumber?: number | null;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -110,7 +105,7 @@ function patchLiveHiddenState(
}
}
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
export function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
try {
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
iframe?.contentWindow ?? null;
@@ -118,7 +113,7 @@ function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
} catch {}
}
function groupElementsByTargetPath(
export function groupElementsByTargetPath(
elements: readonly TimelineElement[],
activeCompPath: string | null,
): Map<string, TimelineElement[]> {
@@ -227,7 +222,8 @@ export async function toggleTimelineTrackHidden({
// read by a human, so it gets the display row instead — the one the clicked
// control announced, when the caller passed it. Deriving it again here would
// use ascending element-bearing keys, which stop matching the header as soon
// as an audio group reorders the rows and inserts an anchor.
// as an audio group reorders the rows and inserts an anchor: the same click
// then said "Mute track 2" and recorded "Mute track 1".
const suffix = trackDisplaySuffix(
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
);
@@ -289,114 +285,6 @@ export async function toggleTimelineElementHidden({
});
}
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);
}
}
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. The group needs no `<hf-audio-group>`
* element of its own to exist: `resolveAudioGroups` already degrades
* gracefully to label = id when one is absent, and a naming dialog is out of
* scope here — the id itself is the default name.
*/
// fallow-ignore-next-line complexity
export async function createAudioGroupAndAssignMembers({
projectId,
activeCompPath,
elements,
groupId,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
if (elements.length < 2) return [];
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
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);
}
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);
reseekPreviewRuntime(previewIframe);
throw error;
}
}
export function useTimelineTrackVisibilityEditing({
projectIdRef,
activeCompPath,
@@ -532,67 +420,3 @@ export function useTimelineElementVisibilityEditing({
],
);
}
/**
* 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;
const keys = new Set(clipIds);
const elements = expandedElements.filter((item) => keys.has(item.key ?? item.id));
try {
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,
],
);
}
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
/**
* The carve's auto-group write path, end to end from the ids its picker hands
* over. Both callers the carve picker (`withAutoGroupedSources`) and the
* timeline's group-pointer button name clips by DOM id, because that is the
* space `collectCarveCandidates` reads them out of and the space
* `resolveAudioGroups` reads them back in. Resolving against store keys here
* matched nothing, wrote nothing, threw nothing, and let the carve persist
* `sources: [<group>]` for a group that was never created a carve silently
* not ducking.
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../player";
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
usePlayerStore.getState().reset();
});
const FILE = `<html><body>
<audio id="voice-1" data-start="0" data-duration="5"></audio>
<audio id="voice-2" data-start="5" data-duration="5"></audio>
</body></html>`;
function stubProjectFiles(files: Map<string, string>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const path = decodeURIComponent(url.slice(url.lastIndexOf("/") + 1));
const content = files.get(path);
return new Response(JSON.stringify({ content }), {
status: content === undefined ? 404 : 200,
headers: { "Content-Type": "application/json" },
});
}),
);
}
function audio(overrides: Partial<TimelineElement>): TimelineElement {
return {
id: overrides.domId ?? "clip",
// A store key that is NOT the DOM id — the shape every real row has.
key: `index.html#${overrides.domId ?? "clip"}`,
tag: "audio",
start: 0,
duration: 5,
track: 0,
...overrides,
};
}
type Assign = (clipIds: readonly string[], groupId: string) => Promise<void>;
function renderAssign(writeProjectFile: (path: string, content: string) => Promise<void>) {
const showToast = vi.fn();
// A holder, not a bare `let`: TS narrows a variable only assigned inside a
// component body to `never` at the call site.
const held: { assign: Assign | null } = { assign: null };
function Probe() {
held.assign = useAudioGroupCarveAssignment({
projectIdRef: { current: "project-1" },
activeCompPath: "index.html",
showToast,
writeProjectFile,
recordEdit: async () => {},
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
previewIframeRef: { current: null },
});
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(<Probe />));
const assign = held.assign;
expect(assign).not.toBeNull();
return { assign: assign as Assign, showToast, root };
}
describe("useAudioGroupCarveAssignment", () => {
it("resolves the picker's DOM ids and writes the group", async () => {
stubProjectFiles(new Map([["index.html", FILE]]));
usePlayerStore
.getState()
.setElements([audio({ domId: "voice-1" }), audio({ domId: "voice-2", track: 1 })]);
const writes = new Map<string, string>();
const { assign, showToast, root } = renderAssign(async (path, content) => {
writes.set(path, content);
});
await act(async () => {
await assign(["voice-1", "voice-2"], "voiceover");
});
const written = writes.get("index.html") ?? "";
expect(written).toContain(
'id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"',
);
expect(written).toContain(
'id="voice-2" data-start="5" data-duration="5" data-audio-group="voiceover"',
);
expect(written).toContain('<hf-audio-group id="voiceover"></hf-audio-group>');
expect(showToast).not.toHaveBeenCalled();
act(() => root.unmount());
});
// Loud AND rejecting: the carve chains `.then(() => ({...next, sources:
// [groupId]}))` off this promise, so a resolved-but-failed call let it
// persist a carve aimed at a group that was never written. Toasting alone
// was not enough — the promise has to carry the failure too.
it("rejects, and toasts, when an id resolves to no clip", async () => {
stubProjectFiles(new Map([["index.html", FILE]]));
usePlayerStore.getState().setElements([audio({ domId: "voice-1" })]);
const writes = new Map<string, string>();
const { assign, showToast, root } = renderAssign(async (path, content) => {
writes.set(path, content);
});
await act(async () => {
await expect(assign(["voice-1", "voice-gone"], "voiceover")).rejects.toThrow("voice-gone");
});
expect(writes.size).toBe(0);
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("voice-gone"));
act(() => root.unmount());
});
});
@@ -0,0 +1,27 @@
/**
* The "can't be moved from the timeline yet" toast, rate-limited.
*
* Its own hook so `useTimelineEditing.ts` stays under the studio's 600-line cap.
* The 1.5s gate matters: a blocked drag fires this per pointermove, and without
* it one gesture stacked dozens of identical toasts.
*/
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
const BLOCKED_TOAST_INTERVAL_MS = 1500;
export function useBlockedTimelineEditToast(
showToast: (message: string, tone?: "info" | "error") => void,
): (element: TimelineElement) => void {
const lastAtRef = useRef(0);
return useCallback(
(_element: TimelineElement) => {
const now = Date.now();
if (now - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
lastAtRef.current = now;
showToast("This clip can't be moved or resized from the timeline yet.", "info");
},
[showToast],
);
}
@@ -20,25 +20,21 @@ import {
syncPreviewContentDuration,
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
import { useSetElementAttribute } from "./timelineElementFxAttribute";
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
import {
useAudioGroupCarveAssignment,
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
} from "./timelineTrackVisibility";
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
export function useTimelineEditing({
projectId,
activeCompPath,
@@ -61,9 +57,7 @@ export function useTimelineEditing({
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const editQueueRef = useRef(Promise.resolve());
const lastBlockedTimelineToastAtRef = useRef(0);
const enqueueEdit = useCallback(
(
@@ -451,15 +445,7 @@ export function useTimelineEditing({
observeProjectFileVersion,
});
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip can't be moved or resized from the timeline yet.", "info");
},
[showToast],
);
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
projectId,
@@ -1,6 +1,7 @@
import type { MutableRefObject, RefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import type { EditHistoryKind } from "../utils/editHistory";
import type { PublishSdkSession } from "../utils/sdkCutover";
@@ -55,3 +56,9 @@ export type TimelineFileDropHandler = (
files: File[],
placement?: { start: number; track: number },
) => Promise<void>;
/** What a timeline move commits: the new start and track, plus the z-index
* reorder a vertical drag resolves to (absent for a pure horizontal move). */
export type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};