Files
hyperframes/packages/studio/src/player/components/useTimelineTrackDerivations.ts
T
Vance IngallsandClaude Opus 5 7393095be8 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>
2026-08-20 16:39:39 -07:00

210 lines
8.1 KiB
TypeScript

import { useMemo } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { isCanaryEnabled } from "../../telemetry/canary";
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
/** One resolved audio group, positioned in the row order. */
export interface TimelineTrackGroupInfo {
id: string;
label: string;
/**
* Synthetic sort key for the group's own row — the same fractional-key
* convention sub-composition expansion already uses (see
* timelineTrackDisplay.ts): the first (lowest) member track's number minus
* 0.5, so it slots in immediately above that member.
*/
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). */
hidden: boolean;
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
fxChain?: string;
}
interface GroupMembership {
trackToGroupId: Map<number, string>;
memberTracksByGroup: Map<string, number[]>;
labelByGroup: Map<string, string>;
volumeByGroup: Map<string, number>;
hiddenByGroup: Map<string, boolean>;
fxChainByGroup: Map<string, string | undefined>;
}
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
const trackToGroupId = new Map<number, string>();
const memberTracksByGroup = new Map<string, number[]>();
const labelByGroup = new Map<string, string>();
const volumeByGroup = new Map<string, number>();
const hiddenByGroup = new Map<string, boolean>();
const fxChainByGroup = new Map<string, string | undefined>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
trackToGroupId.set(trackNum, owner.audioGroup);
if (!labelByGroup.has(owner.audioGroup)) {
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
memberTracksByGroup.set(owner.audioGroup, members);
}
return {
trackToGroupId,
memberTracksByGroup,
labelByGroup,
volumeByGroup,
hiddenByGroup,
fxChainByGroup,
};
}
/** One group's resolved row info, built once the first time its id is seen. */
function buildGroupInfo(
groupId: string,
fallbackTrackNum: number,
membership: GroupMembership,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
): TimelineTrackGroupInfo {
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
);
const fxChain = membership.fxChainByGroup.get(groupId);
return {
id: groupId,
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 } : {}),
};
}
/**
* Push a group's synthetic anchor row plus its members' rows, contiguously.
*
* A collapsed group emits its anchor and nothing else. `buildTimelineLogicalRows`
* already stops at the anchor when collapsed, so leaving the member rows in
* `tracks` left row geometry reserving a full row each for rows that then
* rendered as `null` — a header trailed by its members' worth of blank,
* unreachable dead space. Membership still lands in `trackGroupOf`: a collapsed
* member is hidden, not ungrouped.
*/
function emitGroupRows(
info: TimelineTrackGroupInfo,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
tracks: [number, TimelineElement[]][],
expanded: boolean,
): void {
tracks.push([info.anchorKey, []]);
for (const member of info.memberTracks) {
trackGroupOf.set(member, info);
if (expanded) tracks.push([member, rawByTrack.get(member) ?? []]);
}
}
/**
* Pull each group's members out of raw ascending track order and re-emit them
* contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
* their position; a group's members move up to sit under its anchor even when
* other (ungrouped) tracks were interleaved between them.
*/
function groupTimelineTracks(
rawTracks: [number, TimelineElement[]][],
expandedGroupIds: ReadonlySet<string>,
): {
tracks: [number, TimelineElement[]][];
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const membership = resolveGroupMembership(rawTracks);
const rawByTrack = new Map(rawTracks);
const groups: TimelineTrackGroupInfo[] = [];
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
const emitted = new Set<string>();
const tracks: [number, TimelineElement[]][] = [];
for (const [trackNum, elements] of rawTracks) {
const groupId = membership.trackToGroupId.get(trackNum);
if (!groupId) {
tracks.push([trackNum, elements]);
continue;
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
groups.push(info);
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, expandedGroupIds.has(groupId));
}
return { tracks, groups, trackGroupOf };
}
/**
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
* clip grouping (`tracks`, group-aware order), per-lane visual styles, the
* matching `trackOrder`, and audio-group membership. Extracted from
* Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
* expanded display element set exactly as before.
*/
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
trackOrder: number[];
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const rawTracks = useMemo(() => {
const map = new Map<number, TimelineElement[]>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
list.push(el);
map.set(el.track, list);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const expandedGroupIds = usePlayerStore((s) => s.expandedGroupIds);
const { tracks, groups, trackGroupOf } = useMemo(() => {
if (!isCanaryEnabled("audio-groups")) {
return {
tracks: rawTracks,
groups: [],
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
};
}
return groupTimelineTracks(rawTracks, expandedGroupIds);
}, [rawTracks, expandedGroupIds]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
for (const [trackNum, els] of tracks) {
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
}
return map;
}, [tracks]);
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
}