mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio,core): unify the audio id space, emit group elements, gate both canaries
Six of the fifteen findings from the max-effort review of the audio
groups / mute-solo / pitch-shift stack. Nothing in the stack is merged;
this sits on top of wa-24-timeline-fx.
The id space (findings 1-3). Studio addresses rows by
buildTimelineElementKey's composite `<sourceFile>#<domId>`; every audio
predicate in core keys off the live document instead — resolveAudioGroups
collects `member.id`, isAudibleUnderSolo compares `el.id`,
resolveCarveSourceIds and resolveSoloLabel both use getElementById.
Nobody checked the boundary, so:
* solo put a composite key in the set the runtime matches against
`el.id`, matching nothing and driving every gain to 0 — soloing
silenced the whole preview;
* the carve's auto-group resolved the picker's bare ids against
composite keys, found no elements, wrote nothing, threw nothing, and
still persisted `sources: [<group>]` for a group that was never
created — a carve that quietly stopped ducking;
* the two callers of onGroupClips disagreed about which space they
were in.
Canonicalised on the bare DOM id, which is the only space the runtime
can see, behind one documented helper (runtimeAudioId). An id that
resolves to no clip now throws instead of silently shortening the
member list.
The group element (finding 4). Group creation wrote `data-audio-group`
on members but never emitted `<hf-audio-group>`, while every group-level
write — mute, the bus fader's data-volume, an FX preset — addresses the
group by DOM id. Groups the product created were exactly the groups
nothing could edit. Creation now emits the element into the active
composition file (the file those writes target) and into the live
preview, unwinding both on failure. Group ids are validated before being
interpolated into markup.
The canary leaks (findings 5-6). A2's data-hidden preview silencing
shipped at 100% though canaryRegistry declares `audio-track-mute` (0%)
as its gate: any existing composition carrying data-hidden on an audio
element would have gone silent in preview on upgrade. Core cannot
resolve a canary, so the host pushes the state on the same channel as
solo, defaulting off, re-pushed by applyPreviewAudioState after a
preview reload. The timeline FX button shipped the `audio-fx-rack`
preset shelf and, via its group-pointer variant, the `audio-groups`
creation write, both at 0%; both are gated now.
Tests. Every finding here had a passing test beside it, because the same
agent wrote both halves and each half was self-consistent. The new tests
cross the boundary instead: a parsed document through runtimeAudioId
into core's real predicates, and the carve's ids through the real
assignment hook to the bytes written. Each was mutation-checked against
the pre-fix code.
Group creation moves to its own module — the additions pushed
timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw
NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical,
but they made the file read as binary to grep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d5405e3e0
commit
5850a0c978
@@ -1349,6 +1349,10 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
// Behind the `audio-track-mute` canary — off until the host pushes it, so a
|
||||
// composition that already carries data-hidden on an audio element keeps
|
||||
// playing in preview for anyone not enrolled.
|
||||
window.__hf?.setAudioMuteHidden?.(true);
|
||||
|
||||
const decodeSpy = vi
|
||||
.spyOn(WebAudioTransport.prototype, "decodeAudioElement")
|
||||
@@ -1362,6 +1366,39 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(decodeSpy.mock.calls[0]?.[0]).toBe(audibleAudio);
|
||||
});
|
||||
|
||||
it("still schedules a data-hidden audio clip when the host has not opted in", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const hiddenAudio = document.createElement("audio");
|
||||
hiddenAudio.setAttribute("data-start", "0");
|
||||
hiddenAudio.setAttribute("data-duration", "10");
|
||||
hiddenAudio.setAttribute("data-hidden", "");
|
||||
hiddenAudio.load = () => {};
|
||||
hiddenAudio.play = vi.fn(() => Promise.resolve());
|
||||
root.appendChild(hiddenAudio);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const decodeSpy = vi
|
||||
.spyOn(WebAudioTransport.prototype, "decodeAudioElement")
|
||||
.mockResolvedValue(null);
|
||||
|
||||
const player = window.__player;
|
||||
player?.play();
|
||||
player?.seek(0);
|
||||
|
||||
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(decodeSpy.mock.calls[0]?.[0]).toBe(hiddenAudio);
|
||||
});
|
||||
|
||||
it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -192,6 +192,22 @@ export function initSandboxRuntimeModular(): void {
|
||||
soloedIds = new Set(ids);
|
||||
webAudio.setSolo(soloedIds);
|
||||
};
|
||||
// A2's preview/export parity fix — silencing `data-hidden` audio the way the
|
||||
// render already does — behind the `audio-track-mute` canary, which is what
|
||||
// that canary was declared for. Core cannot read the registry (canaries are
|
||||
// resolved from the studio's install id), so the host pushes the resolved
|
||||
// state on the same channel as solo. Default OFF = the shipped behaviour: a
|
||||
// composition carrying `data-hidden` on an audio element keeps playing in
|
||||
// preview until its author is enrolled. Non-studio hosts (CLI preview, the
|
||||
// bare player) never push, so they stay on the old behaviour too.
|
||||
let silenceHiddenAudio = false;
|
||||
window.__hf.setAudioMuteHidden = (enabled) => {
|
||||
if (silenceHiddenAudio === enabled) return;
|
||||
silenceHiddenAudio = enabled;
|
||||
// The active-clip set is built with this predicate baked in, so a flip
|
||||
// mid-session has to rebuild it — same reason a `data-hidden` toggle does.
|
||||
if (clock.isPlaying()) scheduleWebAudioForActiveClips();
|
||||
};
|
||||
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
||||
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
||||
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
||||
@@ -2080,6 +2096,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
||||
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
|
||||
silenceHiddenAudio,
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -2982,7 +2999,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let foundActive = false;
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (rawEl.closest("[data-hidden]")) continue;
|
||||
if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue;
|
||||
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
||||
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
|
||||
@@ -3090,7 +3107,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (rawEl.closest("[data-hidden]")) continue;
|
||||
if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue;
|
||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
if (!Number.isFinite(compStart)) continue;
|
||||
const mediaStart = readElementPlaybackStart(rawEl);
|
||||
|
||||
@@ -574,38 +574,57 @@ describe("syncRuntimeMedia", () => {
|
||||
});
|
||||
|
||||
describe("data-hidden silences preview volume", () => {
|
||||
it("zeroes effective volume for a clip under a data-hidden ancestor", () => {
|
||||
const hiddenClip = () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
const hiddenAncestor = document.createElement("div");
|
||||
hiddenAncestor.setAttribute("data-hidden", "");
|
||||
document.body.appendChild(hiddenAncestor);
|
||||
hiddenAncestor.appendChild(clip.el);
|
||||
|
||||
return clip;
|
||||
};
|
||||
const volumeSeen = (clip: ReturnType<typeof hiddenClip>, silenceHiddenAudio?: boolean) => {
|
||||
let seen = -1;
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 1,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
...(silenceHiddenAudio === undefined ? {} : { silenceHiddenAudio }),
|
||||
onElementVolume: (_el, v) => {
|
||||
seen = v;
|
||||
},
|
||||
});
|
||||
return seen;
|
||||
};
|
||||
|
||||
expect(seen).toBe(0);
|
||||
it("zeroes effective volume for a clip under a data-hidden ancestor", () => {
|
||||
expect(volumeSeen(hiddenClip(), true)).toBe(0);
|
||||
});
|
||||
|
||||
// The `audio-track-mute` canary sits at 0%: an existing composition that
|
||||
// carries data-hidden on an audio element must keep playing in preview
|
||||
// until its author is enrolled, or the upgrade silences them with no way
|
||||
// back short of a revert.
|
||||
it("leaves a hidden clip audible when the host has not opted in", () => {
|
||||
expect(volumeSeen(hiddenClip(), false)).toBe(0.8);
|
||||
});
|
||||
|
||||
it("defaults to audible when the flag is absent entirely", () => {
|
||||
expect(volumeSeen(hiddenClip())).toBe(0.8);
|
||||
});
|
||||
|
||||
it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
const hiddenAncestor = document.createElement("div");
|
||||
hiddenAncestor.setAttribute("data-hidden", "");
|
||||
document.body.appendChild(hiddenAncestor);
|
||||
hiddenAncestor.appendChild(clip.el);
|
||||
const clip = hiddenClip();
|
||||
clip.el.muted = false;
|
||||
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1 });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 1,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
silenceHiddenAudio: true,
|
||||
});
|
||||
|
||||
expect(clip.el.muted).toBe(false);
|
||||
});
|
||||
|
||||
@@ -222,6 +222,11 @@ export function syncRuntimeMedia(params: {
|
||||
* solo gain instead — see `WebAudioTransport.setSolo`). Absent when solo
|
||||
* isn't wired up at all, which reads as "always audible". */
|
||||
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
|
||||
/** Silence media under a `data-hidden` ancestor, matching the render. Opt-in:
|
||||
* the host pushes it via `__hf.setAudioMuteHidden` when the `audio-track-mute`
|
||||
* canary is on. Absent/false = the shipped behaviour (hidden audio still
|
||||
* plays in preview). */
|
||||
silenceHiddenAudio?: boolean;
|
||||
forceSync?: boolean;
|
||||
}): void {
|
||||
const forceMuteAll = !!(params.outputMuted || params.userMuted);
|
||||
@@ -321,13 +326,19 @@ export function syncRuntimeMedia(params: {
|
||||
}
|
||||
|
||||
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
|
||||
// it); preview must match. Folded into the per-tick volume, not
|
||||
// el.muted (RULES trap: el.muted is the transport's ownership flag).
|
||||
// Solo rides the same fold for the same reason — never el.muted, and
|
||||
// never touching any attribute (it is session-only, unlike hidden).
|
||||
// it); preview matches once the host opts in (`silenceHiddenAudio`, the
|
||||
// `audio-track-mute` canary — see init.ts). Folded into the per-tick
|
||||
// volume, not el.muted (RULES trap: el.muted is the transport's ownership
|
||||
// flag). Solo rides the same fold for the same reason — never el.muted,
|
||||
// and never touching any attribute (it is session-only, unlike hidden) —
|
||||
// but is NOT gated: it is a session control with no shipped behaviour to
|
||||
// preserve.
|
||||
const silencedByHidden = params.silenceHiddenAudio
|
||||
? el.closest("[data-hidden]") !== null
|
||||
: false;
|
||||
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
|
||||
const effectiveVolume =
|
||||
el.closest("[data-hidden]") || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
|
||||
silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
|
||||
el.volume = effectiveVolume;
|
||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
||||
|
||||
+6
@@ -43,6 +43,12 @@ declare global {
|
||||
* read from or written to any document attribute.
|
||||
*/
|
||||
setAudioSolo?: (ids: readonly string[]) => void;
|
||||
/**
|
||||
* Studio's `audio-track-mute` canary state: silence audio under a
|
||||
* `data-hidden` ancestor in preview, the way the render already does.
|
||||
* Off until pushed — core cannot resolve a canary itself.
|
||||
*/
|
||||
setAudioMuteHidden?: (enabled: boolean) => void;
|
||||
};
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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 { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
buildPatchTarget,
|
||||
findTimelineElementInIframe,
|
||||
readFileContent,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import {
|
||||
groupElementsByTargetPath,
|
||||
reseekPreviewRuntime,
|
||||
type MutableRef,
|
||||
type UseTimelineElementVisibilityEditingInput,
|
||||
} from "./timelineTrackVisibility";
|
||||
|
||||
function patchLiveAudioGroupState(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
elements: readonly TimelineElement[],
|
||||
groupId: string | null,
|
||||
activeCompPath: string | null,
|
||||
): void {
|
||||
for (const element of elements) {
|
||||
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
||||
if (!target) continue;
|
||||
if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId);
|
||||
else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
|
||||
}
|
||||
}
|
||||
|
||||
/** Group ids are interpolated into markup and into a render-side filename, so
|
||||
* they stay in the character set an HTML id and a path can both carry. */
|
||||
const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* The group's own `<hf-audio-group>` element, appended before `</body>` when it
|
||||
* is not already in the file.
|
||||
*
|
||||
* Membership alone is enough for `resolveAudioGroups` to see the group, but
|
||||
* every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset —
|
||||
* addresses the group by its DOM id (`setAudioGroupAttribute` →
|
||||
* `buildPatchTarget({ domId: groupId })`), so without an element of its own a
|
||||
* group is created and then cannot be edited at all.
|
||||
*
|
||||
* Written to the active composition file rather than beside the members, which
|
||||
* can live in a sub-composition: that is the file the group's later writes
|
||||
* target, and `resolveAudioGroups` reads the flattened document, so co-location
|
||||
* buys nothing.
|
||||
*/
|
||||
function insertGroupElement(html: string, groupId: string): string {
|
||||
if (readTagSnippetByTarget(html, { id: groupId }) !== undefined) return html;
|
||||
const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"></${HF_AUDIO_GROUP_TAG}>`;
|
||||
const closeBody = html.lastIndexOf("</body>");
|
||||
if (closeBody < 0) return `${html}\n${tag}\n`;
|
||||
return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`;
|
||||
}
|
||||
|
||||
/** The same element in the live preview, so the group is editable before the
|
||||
* next reload. Returns true when it created one (only then may the unwind
|
||||
* remove it — a pre-existing group element is not ours to delete). */
|
||||
function patchLiveGroupElement(iframe: HTMLIFrameElement | null, groupId: string): boolean {
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc?.body || doc.getElementById(groupId)) return false;
|
||||
const el = doc.createElement(HF_AUDIO_GROUP_TAG);
|
||||
el.id = groupId;
|
||||
doc.body.appendChild(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface CreateAudioGroupAndAssignMembersInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
elements: readonly TimelineElement[];
|
||||
groupId: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group two or more voice clips: write `data-audio-group="<groupId>"` on
|
||||
* every one of them, atomically, one undo entry — the same multi-target shape
|
||||
* `setElementsHidden` uses for mute — plus the group's own `<hf-audio-group>`
|
||||
* element, which every later group-level write addresses by DOM id. No naming
|
||||
* dialog: the id is the default name, the way `resolveAudioGroups` reads it.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function createAudioGroupAndAssignMembers({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
elements,
|
||||
groupId,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
|
||||
// Throws rather than returning empty: the carve's auto-group awaits this and
|
||||
// then persists `sources: [groupId]` on success, so a quiet no-op leaves the
|
||||
// carve aimed at a group that does not exist.
|
||||
if (elements.length < 2) {
|
||||
throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`);
|
||||
}
|
||||
if (!GROUP_ID_PATTERN.test(groupId)) {
|
||||
throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`);
|
||||
}
|
||||
|
||||
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
|
||||
const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId);
|
||||
reseekPreviewRuntime(previewIframe);
|
||||
|
||||
const groupOperation: PatchOperation = {
|
||||
type: "attribute",
|
||||
property: HF_AUDIO_GROUP_ATTR,
|
||||
value: groupId,
|
||||
};
|
||||
const originalByPath = new Map<string, string>();
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
||||
let patchedContent = await readFileContent(projectId, targetPath);
|
||||
originalByPath.set(targetPath, patchedContent);
|
||||
|
||||
for (const element of fileElements) {
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
||||
}
|
||||
|
||||
files[targetPath] = patchedContent;
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
}
|
||||
|
||||
const groupPath = activeCompPath || "index.html";
|
||||
let groupContent = files[groupPath];
|
||||
if (groupContent === undefined) {
|
||||
groupContent = await readFileContent(projectId, groupPath);
|
||||
originalByPath.set(groupPath, groupContent);
|
||||
}
|
||||
const withGroupElement = insertGroupElement(groupContent, groupId);
|
||||
if (withGroupElement !== groupContent) {
|
||||
files[groupPath] = withGroupElement;
|
||||
pendingTimelineEditPathRef.current.add(groupPath);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label: `Group ${elements.length} voice clips`,
|
||||
kind: "timeline",
|
||||
files,
|
||||
readFile: async (path) => {
|
||||
const original = originalByPath.get(path);
|
||||
if (original !== undefined) return original;
|
||||
return readFileContent(projectId, path);
|
||||
},
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
for (const element of elements) {
|
||||
usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
|
||||
}
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// Mirrors setElementsHidden's failure path: the optimistic live patch
|
||||
// already ran, so a save failure has to be unwound or the preview shows a
|
||||
// grouping that never made it to disk.
|
||||
patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath);
|
||||
if (createdLiveGroupElement) {
|
||||
previewIframe?.contentDocument?.getElementById(groupId)?.remove();
|
||||
}
|
||||
reseekPreviewRuntime(previewIframe);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The write behind B6's auto-group: pick two or more voice clips in the carve
|
||||
* picker and they land in a group instead of naming each other by id. Same
|
||||
* expanded-rows resolution as element-visibility, for the same reason — a
|
||||
* nested sub-composition child has no entry in the raw store list.
|
||||
*/
|
||||
export function useAudioGroupCarveAssignment({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
}: UseTimelineElementVisibilityEditingInput): (
|
||||
clipIds: readonly string[],
|
||||
groupId: string,
|
||||
) => Promise<void> {
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
return useCallback(
|
||||
async (clipIds: readonly string[], groupId: string) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
// DOM ids, not store keys: both callers (the carve picker and the
|
||||
// timeline's group-pointer button) name clips the way the document does,
|
||||
// because that is the only space `resolveAudioGroups` reads back.
|
||||
const wanted = new Set(clipIds);
|
||||
const elements = expandedElements.filter((item) => {
|
||||
const domId = runtimeAudioId(item);
|
||||
return domId !== null && wanted.has(domId);
|
||||
});
|
||||
try {
|
||||
// Loud, not silent: an unresolved id used to leave `elements` short,
|
||||
// `createAudioGroupAndAssignMembers` returning early with no write, and
|
||||
// the carve still persisting `sources: [groupId]` for a group that was
|
||||
// never created — a carve pointing at nothing, silently not ducking.
|
||||
if (elements.length !== wanted.size) {
|
||||
const missing = [...wanted].filter(
|
||||
(id) => !elements.some((item) => runtimeAudioId(item) === id),
|
||||
);
|
||||
throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`);
|
||||
}
|
||||
await createAudioGroupAndAssignMembers({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
elements,
|
||||
groupId,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to group voice clips", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to group voice clips";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
expandedElements,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
@@ -482,23 +481,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,
|
||||
@@ -103,7 +102,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;
|
||||
@@ -111,7 +110,7 @@ function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function groupElementsByTargetPath(
|
||||
export function groupElementsByTargetPath(
|
||||
elements: readonly TimelineElement[],
|
||||
activeCompPath: string | null,
|
||||
): Map<string, TimelineElement[]> {
|
||||
@@ -278,114 +277,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,
|
||||
@@ -516,67 +407,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,136 @@
|
||||
// @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, not silent: the caller persists the group id once this resolves.
|
||||
it("toasts instead of silently writing nothing 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 assign(["voice-1", "voice-gone"], "voiceover");
|
||||
});
|
||||
|
||||
expect(writes.size).toBe(0);
|
||||
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("voice-gone"));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,8 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
||||
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
||||
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
||||
import {
|
||||
useAudioGroupCarveAssignment,
|
||||
useTimelineElementVisibilityEditing,
|
||||
useTimelineTrackVisibilityEditing,
|
||||
} from "./timelineTrackVisibility";
|
||||
|
||||
@@ -3,17 +3,28 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/** Enrolled canaries for the render under test. Both audio canaries sit at 0%,
|
||||
* so the default here is "enrolled in nothing" — the state a real user is in. */
|
||||
const enabledCanaries = new Set<string>();
|
||||
vi.mock("../../telemetry/canary", () => ({
|
||||
isCanaryEnabled: (name: string) => enabledCanaries.has(name),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
enabledCanaries.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
@@ -72,6 +83,7 @@ interface RenderHeaderOptions {
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
onRemoveAutomationLane?: (target: string) => void;
|
||||
isAudioTrack?: boolean;
|
||||
}
|
||||
|
||||
function renderHeader(options: RenderHeaderOptions = {}): {
|
||||
@@ -100,7 +112,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
||||
animations={next.animations ?? [POSITION, OPACITY]}
|
||||
currentTime={next.currentTime ?? 0}
|
||||
isTrackHidden={false}
|
||||
isAudioTrack={false}
|
||||
isAudioTrack={next.isAudioTrack ?? false}
|
||||
theme={defaultTimelineTheme}
|
||||
onToggleClipExpanded={vi.fn()}
|
||||
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
|
||||
@@ -625,4 +637,83 @@ describe("TimelineTrackHeader", () => {
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio ids and canary gates", () => {
|
||||
const VOICE: TimelineElement = {
|
||||
id: "voice-1",
|
||||
key: "index.html#voice-1",
|
||||
domId: "voice-1",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
};
|
||||
const VOICE_2: TimelineElement = {
|
||||
...VOICE,
|
||||
id: "voice-2",
|
||||
key: "index.html#voice-2",
|
||||
domId: "voice-2",
|
||||
};
|
||||
|
||||
// The set is pushed straight into the runtime, which compares it against
|
||||
// `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
|
||||
// false for every element, and soloing silences the whole preview.
|
||||
it("solos by bare DOM id, not by the store key", () => {
|
||||
enabledCanaries.add("audio-track-mute");
|
||||
const view = renderHeader({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
click(view.host, "Hear only this");
|
||||
expect([...usePlayerStore.getState().soloed]).toEqual(["voice-1"]);
|
||||
act(() => view.root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("hides the FX button outside the audio-fx-rack canary", () => {
|
||||
const view = renderHeader({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
view.rerender({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// The group-pointer variant WRITES a group, so it needs the groups canary
|
||||
// too — otherwise an unenrolled user creates a group and then has no UI to
|
||||
// manage it.
|
||||
it("hides the group pointer unless BOTH audio canaries are on", () => {
|
||||
const opts = {
|
||||
keyframeClip: VOICE,
|
||||
trackElements: [VOICE, VOICE_2],
|
||||
clipCount: 2,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
};
|
||||
const pointer = (host: HTMLElement) =>
|
||||
host.querySelector('button[aria-label="Effects — group these clips first"]');
|
||||
const view = renderHeader(opts);
|
||||
expect(pointer(view.host)).toBeNull();
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
view.rerender({ ...opts });
|
||||
expect(pointer(view.host)).toBeNull();
|
||||
enabledCanaries.add("audio-groups");
|
||||
view.rerender({ ...opts });
|
||||
expect(pointer(view.host)).not.toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
||||
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
||||
import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
@@ -386,7 +388,10 @@ export function TimelineTrackHeader({
|
||||
// acts on the track's first clip as a pragmatic stand-in for "this track",
|
||||
// the same simplification the mute button doesn't need to make (it patches
|
||||
// every clip on the track at once).
|
||||
const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
|
||||
// A bare DOM id, not the store key: the set lands in the runtime, which
|
||||
// compares it against `el.id` (see `runtimeAudioId`). A track whose first
|
||||
// clip has no DOM id simply has no solo button.
|
||||
const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null;
|
||||
const soloed = usePlayerStore((s) => s.soloed);
|
||||
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||
|
||||
@@ -411,7 +416,10 @@ export function TimelineTrackHeader({
|
||||
const groupUngroupedClips = () => {
|
||||
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
|
||||
if (!doc || !onGroupClips) return;
|
||||
const clipIds = trackElements.map((el) => el.key ?? el.id);
|
||||
// DOM ids, matching the carve picker's other caller — membership is read
|
||||
// back by `resolveAudioGroups`, which only ever sees the document.
|
||||
const clipIds = trackElements.map(runtimeAudioId).filter((id): id is string => id !== null);
|
||||
if (clipIds.length < 2) return;
|
||||
void onGroupClips(clipIds, mintGroupId(doc));
|
||||
};
|
||||
|
||||
@@ -447,7 +455,7 @@ export function TimelineTrackHeader({
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
{singleAudioClip && (
|
||||
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
@@ -457,9 +465,16 @@ export function TimelineTrackHeader({
|
||||
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
||||
/>
|
||||
)}
|
||||
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
|
||||
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
||||
)}
|
||||
{/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
|
||||
a group, so it needs `audio-groups` too — without it a user outside
|
||||
that canary could create a group and then have no UI to manage it. */}
|
||||
{isAudioTrack &&
|
||||
clipCount > 1 &&
|
||||
!isTrackGrouped &&
|
||||
isCanaryEnabled("audio-fx-rack") &&
|
||||
isCanaryEnabled("audio-groups") && (
|
||||
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -38,11 +38,7 @@ import {
|
||||
parseTimelineFromDOM,
|
||||
} from "../lib/timelineDOM";
|
||||
import { normalizeToZones } from "../components/timelineZones";
|
||||
import {
|
||||
setPreviewMediaMuted,
|
||||
setPreviewMediaVolume,
|
||||
setPreviewPlaybackRate,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
@@ -237,8 +233,7 @@ export function useTimelinePlayer() {
|
||||
}, []);
|
||||
const applyPreviewAudioState = useCallback(() => {
|
||||
const { audioMuted, audioVolume } = usePlayerStore.getState();
|
||||
setPreviewMediaMuted(iframeRef.current, audioMuted);
|
||||
setPreviewMediaVolume(iframeRef.current, audioVolume);
|
||||
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* The studio → runtime boundary, which nothing else crosses.
|
||||
*
|
||||
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
||||
* `<sourceFile>#<domId>`; every audio predicate in `@hyperframes/core` keys off
|
||||
* the live document instead. Both halves have their own passing tests — one
|
||||
* with composite keys, one with bare ids — and the mismatch between them lived
|
||||
* in the gap. These parse a real document, take the ids the way the UI does,
|
||||
* and hand them to the real core predicates.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
audioGroupOf,
|
||||
isAudibleUnderSolo,
|
||||
resolveAudioGroups,
|
||||
} from "@hyperframes/core/audio-groups";
|
||||
import { parseTimelineFromDOM } from "./timelineDOM";
|
||||
import { runtimeAudioId } from "./timelineElementHelpers";
|
||||
|
||||
function docWith(body: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument("comp");
|
||||
doc.body.innerHTML = body;
|
||||
return doc;
|
||||
}
|
||||
|
||||
const COMPOSITION = `
|
||||
<div data-composition-id="root" data-duration="30"></div>
|
||||
<audio id="voice-1" data-start="0" data-duration="10" data-audio-group="voiceover"></audio>
|
||||
<audio id="voice-2" data-start="10" data-duration="10" data-audio-group="voiceover"></audio>
|
||||
<audio id="music-bed" data-start="0" data-duration="30"></audio>
|
||||
<hf-audio-group id="voiceover"></hf-audio-group>
|
||||
`;
|
||||
|
||||
describe("solo ids cross into the runtime", () => {
|
||||
it("keeps the soloed clip audible and silences the rest", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const elements = parseTimelineFromDOM(doc, 30);
|
||||
const voice1 = elements.find((el) => el.domId === "voice-1");
|
||||
expect(voice1).toBeDefined();
|
||||
// The store key is NOT the runtime's id space — that is the whole point.
|
||||
expect(voice1?.key).not.toBe("voice-1");
|
||||
|
||||
const soloTargetId = runtimeAudioId(voice1 ?? {});
|
||||
expect(soloTargetId).toBe("voice-1");
|
||||
const soloed = new Set([soloTargetId as string]);
|
||||
|
||||
const audible = (id: string) => {
|
||||
const el = doc.getElementById(id);
|
||||
expect(el).not.toBeNull();
|
||||
return isAudibleUnderSolo(soloed, (el as Element).id, audioGroupOf(el as Element));
|
||||
};
|
||||
expect(audible("voice-1")).toBe(true);
|
||||
expect(audible("music-bed")).toBe(false);
|
||||
// Soloing a member does not open its sibling — group solo is the other button.
|
||||
expect(audible("voice-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("soloing the group opens every member", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const group = resolveAudioGroups(doc)[0];
|
||||
const soloed = new Set([group.id]);
|
||||
for (const id of group.memberIds) {
|
||||
const el = doc.getElementById(id) as Element;
|
||||
expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(true);
|
||||
}
|
||||
const bed = doc.getElementById("music-bed") as Element;
|
||||
expect(isAudibleUnderSolo(soloed, bed.id, audioGroupOf(bed))).toBe(false);
|
||||
});
|
||||
|
||||
it("a composite key matches nothing — the regression this file exists for", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const voice1 = parseTimelineFromDOM(doc, 30).find((el) => el.domId === "voice-1");
|
||||
const soloed = new Set([voice1?.key ?? ""]);
|
||||
const el = doc.getElementById("voice-1") as Element;
|
||||
expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group membership ids cross into the runtime", () => {
|
||||
it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const trackElements = parseTimelineFromDOM(doc, 30).filter(
|
||||
(el) => el.tag.toLowerCase() === "audio",
|
||||
);
|
||||
const clipIds = trackElements.map(runtimeAudioId).filter((id): id is string => id !== null);
|
||||
expect(clipIds).toEqual(["voice-1", "voice-2", "music-bed"]);
|
||||
|
||||
// Same space membership is read back in — a composite key here produces a
|
||||
// group whose members nothing can find.
|
||||
const memberIds = resolveAudioGroups(doc).flatMap((g) => g.memberIds);
|
||||
expect(memberIds.every((id) => doc.getElementById(id) !== null)).toBe(true);
|
||||
for (const id of memberIds) expect(clipIds).toContain(id);
|
||||
});
|
||||
|
||||
it("an element with no DOM id is not groupable or soloable", () => {
|
||||
const doc = docWith(`
|
||||
<div data-composition-id="root" data-duration="10"></div>
|
||||
<audio data-start="0" data-duration="5"></audio>
|
||||
`);
|
||||
const [clip] = parseTimelineFromDOM(doc, 10);
|
||||
expect(clip).toBeDefined();
|
||||
expect(runtimeAudioId(clip)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -346,6 +346,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id space the RUNTIME matches on — a bare DOM id, never a store key.
|
||||
*
|
||||
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
||||
* `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off
|
||||
* the live document: `resolveAudioGroups` collects `member.id`,
|
||||
* `isAudibleUnderSolo` compares `el.id`, `resolveCarveSourceIds` and
|
||||
* `resolveSoloLabel` both go through `getElementById`. Anything crossing into
|
||||
* that space — a solo id, a group membership list, a carve source — has to be
|
||||
* converted here first; a composite key silently matches nothing.
|
||||
*
|
||||
* `null` for a row with no DOM id at all (selector-addressed elements): such an
|
||||
* element cannot be soloed or grouped, because `resolveAudioGroups` skips
|
||||
* members without an `id` and would build a group that is half there.
|
||||
*/
|
||||
export function runtimeAudioId(element: { domId?: string | null }): string | null {
|
||||
return element.domId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
|
||||
* expansion (canvas context menu / LayersPanel), so the reorder commit can
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { IframeWindow } from "./playbackTypes";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
||||
import {
|
||||
getTimelineElementSelector,
|
||||
@@ -142,6 +143,39 @@ export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume:
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Push the `audio-track-mute` canary state into the preview runtime, which
|
||||
* defaults it off (see `window.__hf.setAudioMuteHidden`). Direct call, not a
|
||||
* control message: it is a runtime flag, not a transport command, and the
|
||||
* player host has no equivalent property to set. */
|
||||
function setPreviewMuteHidden(iframe: HTMLIFrameElement | null, enabled: boolean): void {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const win = iframe.contentWindow as
|
||||
| (Window & { __hf?: { setAudioMuteHidden?: (enabled: boolean) => void } })
|
||||
| null;
|
||||
win?.__hf?.setAudioMuteHidden?.(enabled);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the preview runtime has to be told about audio after it loads:
|
||||
* the transport's mute, and the canary flags core cannot resolve for itself.
|
||||
* Called from `applyPreviewAudioState`, which is the path that re-runs after a
|
||||
* preview reload — the runtime comes back with every flag at its default and
|
||||
* nothing else pushes them again.
|
||||
*/
|
||||
export function applyPreviewAudioFlags(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
muted: boolean,
|
||||
volume: number,
|
||||
): void {
|
||||
setPreviewMediaMuted(iframe, muted);
|
||||
// Volume too: the transport comes back at unity after a reload, so a preview
|
||||
// the author had turned down came back loud.
|
||||
setPreviewMediaVolume(iframe, volume);
|
||||
setPreviewMuteHidden(iframe, isCanaryEnabled("audio-track-mute"));
|
||||
}
|
||||
|
||||
export function setPreviewPlaybackRate(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
playbackRate: number,
|
||||
|
||||
Reference in New Issue
Block a user