mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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>
210 lines
8.1 KiB
TypeScript
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 };
|
|
}
|