mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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, "&").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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user