fix(studio,core,engine): close the defects a max-effort review found in the fixes

A review of the five fix commits found eleven real defects, including a
regression one of them introduced. Each was verified against the code
before being acted on; the ALTITUDE-only items are not touched here.

REGRESSION, from "group rows survive a collapse". Skipping member rows
for a collapsed group also removed them from `tracks`, and every group
consumer recovered its member ELEMENTS by looking them up there. Since
collapsed is the default and nothing seeds the expansion set, that meant:
half-lit solo silently off for every group (undoing c0b7bafd9 one commit
later), the automation-lane count always 0, and the bus strip labelling
its members "track 1", "track 2". Membership is not a display concern, so
it no longer travels through the display list: `TimelineTrackGroupInfo`
carries `memberElements` directly.

Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the
booked automation — an AudioParam value write inside a live curve throws,
and this runs inside `schedulePlayback`, whose catch turns a throw into
`return null`: the MEMBER would have silently dropped out of the pass.
Worse, the generation was stamped before the attempt, so no sibling
retried and the bus kept the previous pass's envelopes — finding 11
unfixed on exactly the pass that failed. Now: clear first, stamp only on
success, and isolate the call. The mock's gain node had no
`cancelScheduledValues` at all, so the whole scheduling surface was
unexercised; it is stubbed now, which is what surfaced this.

`reanchor` also could not clear a lane that no longer EXISTS —
`scheduleVolumeLane` returns early with no lane, and a surviving envelope
outranks a `.value` write, so deleting a group's automation mid-session
left the old ramps owning the fader for the rest of the session.

The preview fader applied `data-volume` unclamped while the render clamps
to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at
unity, `-1` previewed with inverted polarity and rendered silent. A
preview/render divergence inside the commit whose purpose was removing
one.

Pitch shift. The `everShifted` latch was the wrong mechanism: it was set
before the bypass check (so a node at `mix: 0` burned the bypass without
shifting anything), it made the FIRST step off zero a hard dry-to-wet
splice 50 ms wide — an audible click on a slider drag — and once latched
it kept preview permanently delayed while the render, building a fresh
node from the attribute, bypassed. Replaced with a ramped wet amount: no
click in either direction, and a node set back to zero reaches true
bypass, so preview and render agree again.

Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers`
was caught one frame up and not rethrown, so the carve's auto-group still
saw success and persisted `sources: [groupId]` for a group that was never
written — the exact failure the throw was added to prevent. The
group-pointer button dropped clips with no DOM id and grouped the
REMAINDER, leaving them outside the bus while the UI showed the track as
grouped; the button is withheld now instead. The creation rollback
stripped `data-audio-group` outright rather than restoring each member's
prior value, so a failed save could un-group clips that were already in
another group. `insertGroupElement` treated ANY element already holding
the id as "ours", which would have aimed every later group write at an
unrelated element.

`setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`.
Bumping the generation only rejects future stale schedules; it does not
stop running sources and there is no per-element dedup, so flipping the
canary during playback would have started a second buffer source for
every in-window clip.

`invalidateGroupInfoCache` was missed by the DOM-edit path: the rack
reaches `<hf-audio-group>` through the DOM editor, not through the
timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one
chokepoint every attribute write passes — so this does not stay a
per-caller obligation.

Both defects in the ffmpeg-header test are mine: it early-returned
instead of skipping when ffmpeg is absent (reporting green having
asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout
as a requirement, which would fail on a legal canonical header the parser
also handles.

Also: the group-degradation note is no longer dropped when the outer mix
degrades too, and a malformed doc comment (two stacked openers) is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:39 -07:00
co-authored by Claude Opus 5
parent 07203f59d1
commit 7393095be8
19 changed files with 325 additions and 66 deletions
@@ -32,6 +32,7 @@ import {
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
import {
collectCarveCandidates,
CARVE_ABORTED,
isPromiseLike,
resolveNextCarveSettings,
} from "./useFxCarveGrouping.js";
@@ -425,6 +426,7 @@ export function useFxCarve(
// awaited: see resolveNextCarveSettings's own contract.
const resolved = resolveNextCarveSettings(nextRaw, doc, onAutoGroupCarveSources);
const next = isPromiseLike(resolved) ? await resolved : resolved;
if (next === CARVE_ABORTED) return;
// Which of the carve's settings moved. One event per change with the action
// named, rather than a single "carve touched" — enabling a carve and nudging
// its strength are different decisions and the interesting question (do
@@ -30,6 +30,12 @@ export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as { then?: unknown })?.then === "function";
}
/**
* "The auto-group failed, do not persist this carve" — distinct from a
* legitimate `null`, which means the carve was deliberately cleared.
*/
export const CARVE_ABORTED = Symbol("carve-aborted");
/**
* Plural voiceover carve, always against a group — normative, not a
* suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
@@ -71,8 +77,16 @@ export function resolveNextCarveSettings(
nextRaw: HfCarveSettings | null,
doc: Document | undefined,
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
): HfCarveSettings | Promise<HfCarveSettings> | null {
return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
): HfCarveSettings | Promise<HfCarveSettings | typeof CARVE_ABORTED> | null {
const resolved = nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
// A failed auto-group resolves to the sentinel rather than rejecting: the
// write has already toasted, and the caller's job is simply not to persist a
// carve whose `sources` name a group that was never written — which reads, at
// playback, as a carve that silently stops ducking. Caught here rather than
// in the caller so the synchronous branch above stays synchronous.
return isPromiseLike(resolved)
? resolved.catch((): typeof CARVE_ABORTED => CARVE_ABORTED)
: resolved;
}
export interface CarveCandidate {
@@ -28,21 +28,47 @@ import {
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;
if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId);
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_-]+$/;
@@ -63,7 +89,17 @@ const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
* buys nothing.
*/
function insertGroupElement(html: string, groupId: string): string {
if (readTagSnippetByTarget(html, { id: groupId }) !== undefined) return html;
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`);
}
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`;
@@ -124,6 +160,7 @@ export async function createAudioGroupAndAssignMembers({
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);
reseekPreviewRuntime(previewIframe);
@@ -191,7 +228,7 @@ export async function createAudioGroupAndAssignMembers({
// 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);
patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath, priorGroups);
if (createdLiveGroupElement) {
previewIframe?.contentDocument?.getElementById(groupId)?.remove();
}
@@ -263,6 +300,12 @@ export function useAudioGroupCarveAssignment({
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;
}
},
[
@@ -115,8 +115,11 @@ describe("useAudioGroupCarveAssignment", () => {
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 () => {
// 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" })]);
@@ -126,7 +129,7 @@ describe("useAudioGroupCarveAssignment", () => {
});
await act(async () => {
await assign(["voice-1", "voice-gone"], "voiceover");
await expect(assign(["voice-1", "voice-gone"], "voiceover")).rejects.toThrow("voice-gone");
});
expect(writes.size).toBe(0);
@@ -9,6 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
// ── Types ──
@@ -63,6 +65,17 @@ function setOrRemovePreviewAttribute(
} else {
el.setAttribute(fullAttr, value);
}
// Every DOM-edit attribute write funnels through here, which is the only
// place that can catch a group edit made from the rack rather than from the
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
// editor, and that path never went near the timeline's own writers. The group
// scan is cached against the preview Document, and group edits are live
// patches so that document is never replaced; a stale entry is re-read on
// every manifest tick, so the header's preset button then builds on the old
// chain and discards the rack's edit.
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
invalidateGroupInfoCache(el.ownerDocument);
}
}
function findPreviewAttributeElement(
@@ -1,4 +1,3 @@
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
@@ -23,7 +22,6 @@ interface TimelineGroupRowProps {
rowKey: number;
group: TimelineTrackGroupInfo;
logicalRow: TimelineLogicalRow;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
top: number;
height: number;
virtualized: boolean;
@@ -42,7 +40,6 @@ export function TimelineGroupRow({
rowKey,
group,
logicalRow,
tracks,
top,
height,
virtualized,
@@ -54,11 +51,13 @@ export function TimelineGroupRow({
toggleGroupExpanded,
toggleLaneOwnerExpanded,
}: TimelineGroupRowProps) {
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
// From the group, NOT from `tracks`: a collapsed group emits no member rows
// into the display list, and every one of these reads silently degraded to
// empty in that (default) state — half-lit solo went dark, the lane count
// read 0, and the bus strip fell back to "track 1", "track 2".
const memberElements = group.memberElements;
const memberLabels = group.memberTracks.map((track, i) => {
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
const owner = memberElements.find((el) => el.track === track && el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
});
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
@@ -163,7 +163,6 @@ export function TimelineLanes({
rowKey={rowKey}
group={group}
logicalRow={groupLogicalRow}
tracks={tracks}
top={rowGeometry.getRowTop(row)}
height={rowGeometry.getRowHeight(row)}
virtualized={rowsVirtualized}
@@ -413,14 +413,22 @@ export function TimelineTrackHeader({
const openClipFxRack = (clip: TimelineElement) => {
void domEditActions?.handleTimelineElementSelect(clip);
};
// DOM ids, matching the carve picker's other caller — membership is read back
// by `resolveAudioGroups`, which only ever sees the document. A clip with no
// DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
// one cannot be grouped WHOLE — and grouping the rest would quietly leave
// those clips outside the bus, past every fader, mute and effect, while the
// UI showed the track as grouped. The button is withheld instead of acting on
// a subset, which is also why the carve path's loud guard cannot catch this:
// the unresolvable ids were filtered out before the call.
const groupableClipIds = trackElements.map(runtimeAudioId);
const canGroupWholeTrack =
groupableClipIds.length >= 2 && groupableClipIds.every((id) => id !== null);
const groupUngroupedClips = () => {
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
if (!doc || !onGroupClips) return;
// 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));
if (!canGroupWholeTrack) return;
void onGroupClips(groupableClipIds as string[], mintGroupId(doc));
};
return (
@@ -471,6 +479,7 @@ export function TimelineTrackHeader({
{isAudioTrack &&
clipCount > 1 &&
!isTrackGrouped &&
canGroupWholeTrack &&
isCanaryEnabled("audio-fx-rack") &&
isCanaryEnabled("audio-groups") && (
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
@@ -315,8 +315,10 @@ export function buildTimelineLogicalRows({
items: [],
});
if (expandedLaneOwnerIds.has(group.id)) {
const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
for (const laneGroup of groupAutomationLanes(memberElements)) {
// The group's own member list, not `trackMap`: a COLLAPSED group can have
// its lane shelf open, and its members are absent from the display list —
// so looking them up there emitted zero lane rows for exactly that case.
for (const laneGroup of groupAutomationLanes(group.memberElements)) {
rows.push({
id: `${groupRowId}::${laneGroup.key}`,
kind: "row",
@@ -16,6 +16,17 @@ export interface TimelineTrackGroupInfo {
anchorKey: number;
/** Member track numbers, ascending. */
memberTracks: number[];
/**
* Every clip under this group, in member-track order INDEPENDENT of whether
* the group is expanded.
*
* Collapsing a group stops emitting its member rows into `tracks`, so anything
* that recovered member elements by looking them up there got an empty list in
* the default (collapsed) state silently disabling half-lit solo, the
* automation-lane count, and the bus strip's member labels. Membership is not
* a display concern, so it does not travel through the display list.
*/
memberElements: TimelineElement[];
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
volume: number;
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
@@ -70,6 +81,7 @@ function buildGroupInfo(
groupId: string,
fallbackTrackNum: number,
membership: GroupMembership,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
): TimelineTrackGroupInfo {
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
@@ -80,6 +92,7 @@ function buildGroupInfo(
label: membership.labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
memberTracks,
memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
volume: membership.volumeByGroup.get(groupId) ?? 1,
hidden: membership.hiddenByGroup.get(groupId) ?? false,
...(fxChain ? { fxChain } : {}),
@@ -139,7 +152,7 @@ function groupTimelineTracks(
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const info = buildGroupInfo(groupId, trackNum, membership);
const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
groups.push(info);
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, expandedGroupIds.has(groupId));
}
@@ -88,6 +88,18 @@ describe("collapsed audio groups", () => {
unmount();
});
// Membership is not a display concern. Half-lit solo, the automation-lane
// count and the bus strip's member labels all read the group's members, and
// all three silently degraded to empty when those were recovered from the
// display list — which a collapsed group does not appear in. Collapsed is the
// default, so that was every group until someone opened it.
it("carries its member elements even while collapsed", () => {
const { layout, unmount } = renderGrouped();
expect(layout.trackOrder).toEqual([-0.5]); // collapsed: no member rows
expect(layout.groups[0]!.memberElements.map((el) => el.id)).toEqual(["voice-1", "voice-2"]);
unmount();
});
it("emits the member rows once the group is expanded", () => {
usePlayerStore.setState({ expandedGroupIds: new Set(["voiceover"]) });
const { layout, unmount } = renderGrouped();