mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
Reported as "I removed all effects from the Voiceover group but it still shows automated lanes". The lanes were real and their nodes did exist — they were the CARVE's. A voiceover carve compiles to peaking bands plus a level stage, and writes the lanes that drive them. Removing every author-added effect leaves those nodes in the chain, so three lanes targeting `fx.n1.gain`, `fx.n2.gain`, `fx.n3.gain` kept resolving and kept drawing. They should never have been on the row. Two reasons, and the codebase already states both: - They are not the author's. `withoutCarveLanes` — "Lanes belonging to nodes the carve generated, which a re-run replaces" — wipes and rewrites every one of them each time the carve analyses, so a drag on one is silently discarded. - They are invisible as effects by design. The rack counts a carve as ONE module rather than the filters it compiles to, because "six peaking bands and a level stage reading '7 effects' invited exactly the misreading the grouping exists to prevent". Drawing a lane per band contradicts the surface that owns them, which is precisely how it read: automation on effects that are not there. `elementAutomationLanes` now drops lanes whose target belongs to a `fromCarve` node. Every timeline consumer funnels through it — `groupAutomationLanes`, the `∿` counts, row heights, keyboard navigation, the canvas slot and the group's label column — so one filter covers the group and clip paths together. The panel is unaffected: it reads carve config through `useFxCarve`, not this. Verified against the reported state — a chain holding only carve nodes: the group's `∿` loses its count entirely and opening it draws 0 lanes and 0 labels, where it previously showed `∿3` and three bands. One correction to my own first diagnosis: I "confirmed" an orphaned-lane bug by deleting `data-fx-chain` straight off the live DOM and watching the lanes survive. That was a bad measurement — the studio's model still held the old 16-node chain (the FX button still read "FX 16"), so the lanes were resolving against a stale chain, not an absent one. Orphan filtering works; this was something else. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4324 green.
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
/**
|
|
* Reading an element's automation, shared by the lane UI and the row layout.
|
|
*
|
|
* The attributes are carried on TimelineElement verbatim rather than parsed at
|
|
* the manifest boundary: the lane reads and writes them, and round-tripping
|
|
* through the attribute is what keeps the lane, the property panel and the
|
|
* running audio graph on one source of truth.
|
|
*
|
|
* Both parse the same two attributes: the layout needs the lane count to
|
|
* reserve height, the lanes need the lanes themselves. Parsing is cached by the
|
|
* attribute text so the identity only changes when the text does — the lane's
|
|
* drag draft compares against that identity, and a fresh object on every
|
|
* playhead tick would throw away the drag in progress.
|
|
*/
|
|
|
|
import {
|
|
parseAutomation,
|
|
parseAutomationTarget,
|
|
resolveAutomation,
|
|
resolveAutomationRange,
|
|
type HfAutomation,
|
|
type HfAutomationLane,
|
|
} from "@hyperframes/core/audio-automation";
|
|
import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
import type { TimelineElement } from "../store/playerStore";
|
|
|
|
const EMPTY: HfAutomation = { version: 1, lanes: [] };
|
|
|
|
const chainCache = new Map<string, HfAudioFxChain | null>();
|
|
const automationCache = new Map<string, HfAutomation>();
|
|
const CACHE_LIMIT = 64;
|
|
|
|
/**
|
|
* Parse once per distinct attribute text, keeping the same object until the text
|
|
* changes — the lane compares its drag draft against that identity.
|
|
*
|
|
* Eviction drops the oldest entry rather than clearing the map: clearing would
|
|
* change the identity of every lane's automation at once, and any lane mid-drag
|
|
* would release its draft and jump back to the stored value.
|
|
*/
|
|
function cached<T>(store: Map<string, T>, key: string, build: () => T): T {
|
|
const hit = store.get(key);
|
|
if (hit !== undefined) {
|
|
// Re-insert so the entry counts as recently used.
|
|
store.delete(key);
|
|
store.set(key, hit);
|
|
return hit;
|
|
}
|
|
const value = build();
|
|
if (store.size >= CACHE_LIMIT) {
|
|
const oldest = store.keys().next();
|
|
if (!oldest.done) store.delete(oldest.value);
|
|
}
|
|
store.set(key, value);
|
|
return value;
|
|
}
|
|
|
|
/** The element's FX chain, or null when it has none or it is unreadable. */
|
|
export function elementFxChain(element: TimelineElement): HfAudioFxChain | null {
|
|
const raw = element.fxChain;
|
|
if (!raw) return null;
|
|
return cached(chainCache, raw, () => {
|
|
try {
|
|
return parseAudioFxChain(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The element's automation, bound to its chain the same way preview and the
|
|
* render bind it: a lane whose effect has been deleted is dropped rather than
|
|
* drawn on the wrong axis.
|
|
*/
|
|
export function elementAutomation(element: TimelineElement): HfAutomation {
|
|
const raw = element.automation;
|
|
if (!raw) return EMPTY;
|
|
const chain = elementFxChain(element);
|
|
// Both texts key the entry: the resolved lanes depend on the chain too. Joined
|
|
// through a separator no attribute can contain.
|
|
return cached(automationCache, `${raw}\u0000${element.fxChain ?? ""}`, () => {
|
|
try {
|
|
const resolved = resolveAutomation(parseAutomation(raw), chain ?? undefined);
|
|
return { ...resolved, lanes: orderLanes(resolved.lanes, chain) };
|
|
} catch {
|
|
// Unreadable automation draws no lanes rather than breaking the row.
|
|
return EMPTY;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Lanes in the order they are drawn, one row each. */
|
|
/**
|
|
* The lanes a TIMELINE row should draw — the author's own curves.
|
|
*
|
|
* Lanes belonging to carve-generated nodes are excluded. The carve writes those
|
|
* itself and `withoutCarveLanes` replaces every one of them on each re-run, so
|
|
* they are not the author's to edit: a drag on one is silently discarded the
|
|
* next time the carve analyses. They are also invisible as effects — the rack
|
|
* deliberately counts the carve as ONE module rather than the filters it
|
|
* compiles to — so drawing a lane per band contradicts the surface that owns
|
|
* them, and reads as "automation on effects I removed".
|
|
*/
|
|
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
|
|
const chain = elementFxChain(element);
|
|
const carvePrefixes = (chain?.nodes ?? [])
|
|
.filter((node) => node.fromCarve && node.id)
|
|
.map((node) => `fx.${node.id}.`);
|
|
const lanes = elementAutomation(element).lanes;
|
|
if (carvePrefixes.length === 0) return lanes;
|
|
return lanes.filter((lane) => !carvePrefixes.some((prefix) => lane.target.startsWith(prefix)));
|
|
}
|
|
|
|
/** The frequency the lane's effect sits at, when it has one. */
|
|
function laneFrequency(target: string, chain: HfAudioFxChain | null): number | null {
|
|
const parsed = parseAutomationTarget(target);
|
|
if (!parsed || parsed.kind !== "fx") return null;
|
|
const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
|
|
const freq = node?.params?.["frequency"];
|
|
return typeof freq === "number" ? freq : null;
|
|
}
|
|
|
|
/**
|
|
* Lane order: the audible spectrum, top down, then everything else.
|
|
*
|
|
* A stack of EQ bands is read as a spectrum, so it has to be laid out like one —
|
|
* high at the top, the way every analyser and every EQ curve is drawn. Attribute
|
|
* order is whatever minted the nodes, which for a carve is ascending: exactly
|
|
* upside down. Lanes with no frequency to place them — a level stage, the track's
|
|
* own volume — keep their written order and sit under the bands, like a fader
|
|
* below the EQ section of a channel strip.
|
|
*
|
|
* Sorted here, in the one function both the canvas lanes and the label column
|
|
* read, because a label whose row disagrees with the envelope it names is worse
|
|
* than either order.
|
|
*/
|
|
function orderLanes(lanes: HfAutomationLane[], chain: HfAudioFxChain | null): HfAutomationLane[] {
|
|
const withFreq: { lane: HfAutomationLane; freq: number }[] = [];
|
|
const rest: HfAutomationLane[] = [];
|
|
for (const lane of lanes) {
|
|
const freq = laneFrequency(lane.target, chain);
|
|
if (freq === null) rest.push(lane);
|
|
else withFreq.push({ lane, freq });
|
|
}
|
|
withFreq.sort((a, b) => b.freq - a.freq);
|
|
return [...withFreq.map((e) => e.lane), ...rest];
|
|
}
|
|
|
|
/** A frequency as an author reads it: 400 Hz, 1.6 kHz, 10 kHz. */
|
|
export function formatHz(freq: number): string {
|
|
if (freq < 1000) return `${Math.round(freq)} Hz`;
|
|
const k = freq / 1000;
|
|
return `${k >= 10 ? Math.round(k) : Number(k.toFixed(1))} kHz`;
|
|
}
|
|
|
|
/**
|
|
* What a lane is called in the timeline, as its two lines.
|
|
*
|
|
* `name` is the effect and, when it has one, the frequency it sits at: "Peaking
|
|
* EQ 1.6 kHz". The frequency is what tells two bands apart — three lanes all
|
|
* reading "Peaking EQ" say nothing about which is which — and the effect still
|
|
* has to be named, since a chain mixes filter types and a bare frequency does not
|
|
* say whether it is a bell or a shelf.
|
|
*
|
|
* `param` is which knob the envelope drives, on its own line: a band can carry a
|
|
* gain lane and a Q lane, and stacking the two lines is what keeps a name legible
|
|
* in a column this narrow instead of truncating mid-word.
|
|
*
|
|
* Null when the target does not resolve against the chain — the same condition
|
|
* that stops the lane being drawn at all.
|
|
*/
|
|
export function automationLaneLabelParts(
|
|
target: string,
|
|
chain: HfAudioFxChain | null,
|
|
): { name: string; param: string } | null {
|
|
const range = resolveAutomationRange(target, chain ?? undefined);
|
|
if (!range) return null;
|
|
// The registry's label is "<effect> · <param>", or just "<param>" for volume.
|
|
const parts = range.label.split(" · ");
|
|
const param = parts.at(-1) ?? range.label;
|
|
const effect = parts.length > 1 ? parts.slice(0, -1).join(" · ") : null;
|
|
const freq = laneFrequency(target, chain);
|
|
const name = [effect, freq === null ? null : formatHz(freq)].filter(Boolean).join(" ");
|
|
return { name: name || param, param: name ? param : "" };
|
|
}
|
|
|
|
/**
|
|
* What makes two lanes, on two different clips, the same lane row.
|
|
*
|
|
* A lane is a property over time, not a clip's private strip: four narration slices
|
|
* on one track that each automate a 1 kHz peaking Q belong in ONE row, each drawing
|
|
* its envelope over its own span.
|
|
*
|
|
* The key cannot be the lane target. Targets are `fx.<nodeId>.<param>` and node ids
|
|
* are minted per chain, so `fx.n1.q` on one clip and `fx.n1.q` on another may be
|
|
* different effects entirely — grouping by target would put unrelated envelopes in
|
|
* one row and split matching ones apart. So the key is what identifies the parameter
|
|
* to a reader: the effect, whatever distinguishes it from its siblings (a filter's
|
|
* frequency), and the parameter. Which is exactly what the label already says, so
|
|
* the row's identity and its name cannot drift apart.
|
|
*
|
|
* Null when the target does not resolve, the same condition that stops it drawing.
|
|
*/
|
|
export function laneGroupKey(target: string, chain: HfAudioFxChain | null): string | null {
|
|
return automationLaneLabel(target, chain);
|
|
}
|
|
|
|
/** One clip's envelope inside a shared row: the clip, and the lane it draws. */
|
|
export interface AutomationLaneGroupEntry {
|
|
element: TimelineElement;
|
|
lane: HfAutomationLane;
|
|
}
|
|
|
|
/** A lane row on a track: one property, and every clip that automates it. */
|
|
export interface AutomationLaneGroup {
|
|
/** {@link laneGroupKey} — the row's identity, and also its whole label. */
|
|
key: string;
|
|
/** The label's two lines, as {@link automationLaneLabelParts} splits them. */
|
|
name: string;
|
|
param: string;
|
|
/** In track order. A clip that does not automate this property is simply
|
|
* absent, leaving its stretch of the row empty. */
|
|
entries: AutomationLaneGroupEntry[];
|
|
}
|
|
|
|
/**
|
|
* The lane rows a TRACK shows, unioned over the clips sharing it.
|
|
*
|
|
* Several clips on one row (four narration slices, say) each carry their own
|
|
* chain and their own envelopes. Drawing only the selected clip's made a per-clip
|
|
* lane read as governing the whole row, and swapped which envelopes were visible
|
|
* whenever the selection moved. So the row is keyed by the property — a clip
|
|
* draws into the row for `Peaking EQ 1 kHz · Q` over its own span, and clips that
|
|
* automate nothing there leave it empty.
|
|
*
|
|
* Row order is first-seen: each clip's lanes are already in draw order (the
|
|
* spectrum, top down), so the first clip to carry a property fixes its row and
|
|
* later clips only append properties nobody has shown yet.
|
|
*
|
|
* Non-audio elements contribute nothing, matching `automationLaneCountOf` — the
|
|
* row's reserved height and its drawn lanes have to count the same clips.
|
|
*/
|
|
export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
|
|
const groups = new Map<string, AutomationLaneGroup>();
|
|
for (const element of elements) {
|
|
if (!isAudioTimelineElement(element)) continue;
|
|
const chain = elementFxChain(element);
|
|
for (const lane of elementAutomationLanes(element)) {
|
|
const key = laneGroupKey(lane.target, chain);
|
|
const parts = automationLaneLabelParts(lane.target, chain);
|
|
// Null on both together: an unresolvable target draws no lane either.
|
|
if (!key || !parts) continue;
|
|
const group = groups.get(key);
|
|
if (group) group.entries.push({ element, lane });
|
|
else {
|
|
groups.set(key, {
|
|
key,
|
|
name: parts.name,
|
|
param: parts.param,
|
|
entries: [{ element, lane }],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
/** The whole label on one line, for a tooltip or an accessible name. */
|
|
export function automationLaneLabel(target: string, chain: HfAudioFxChain | null): string | null {
|
|
const parts = automationLaneLabelParts(target, chain);
|
|
if (!parts) return null;
|
|
return parts.param ? `${parts.name} · ${parts.param}` : parts.name;
|
|
}
|