fix(studio): keep the carve's own lanes out of the timeline

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.
This commit is contained in:
Vance Ingalls
2026-08-20 02:20:00 -07:00
parent 8b9690e054
commit 57540dcace
3 changed files with 103 additions and 3 deletions
@@ -13,7 +13,11 @@
*/
import { sampleAutomationLane } from "@hyperframes/core/audio-automation";
import { automationLaneLabelParts, elementAutomation, elementFxChain } from "./automationLaneData";
import {
automationLaneLabelParts,
elementAutomationLanes,
elementFxChain,
} from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { TimelineElement } from "../store/playerStore";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
@@ -40,7 +44,7 @@ export function TimelineGroupLaneLabels({
// which is precisely the failure this number exists to prevent.
const currentTime = useLivePlayheadTime();
const chain = elementFxChain(groupElement);
const lanes = elementAutomation(groupElement).lanes;
const lanes = elementAutomationLanes(groupElement);
return (
<>
{lanes.map((lane, index) => {
@@ -4,6 +4,7 @@ import {
automationLaneLabel,
automationLaneLabelParts,
elementAutomation,
elementAutomationLanes,
groupAutomationLanes,
laneGroupKey,
elementFxChain,
@@ -268,3 +269,81 @@ describe("groupAutomationLanes", () => {
expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
});
});
// A carve writes its own filter bands AND the lanes that drive them, and
// `withoutCarveLanes` replaces every one of them on each re-run — so a drag on
// one is discarded the next time the carve analyses. The rack also counts the
// carve as ONE module rather than the filters it compiles to, so a lane per
// band reads as "automation on effects I removed", which is exactly how it was
// reported.
describe("carve-generated lanes", () => {
const carved = (): TimelineElement => ({
id: "vo",
tag: "audio",
start: 0,
duration: 5,
track: 0,
fxChain: JSON.stringify({
version: 1,
nodes: [
{
type: "peaking",
id: "n1",
fromCarve: true,
params: { frequency: 160, gain: -6, q: 1.4 },
},
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -3, q: 1 } },
],
}),
automation: JSON.stringify({
version: 1,
lanes: [
{
target: "fx.n1.gain",
points: [
{ t: 0, v: 0 },
{ t: 1, v: -4 },
],
},
{
target: "fx.n2.gain",
points: [
{ t: 0, v: 0 },
{ t: 1, v: -2 },
],
},
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.5 },
],
},
],
}),
});
it("keeps the author's lanes and drops the carve's", () => {
const targets = elementAutomationLanes(carved()).map((lane) => lane.target);
expect(targets).toEqual(["fx.n2.gain", "volume"]);
});
it("does not count a carve band as a timeline row", () => {
const rows = groupAutomationLanes([carved()]);
expect(rows).toHaveLength(2);
expect(rows.every((row) => !row.entries.some((e) => e.lane.target === "fx.n1.gain"))).toBe(
true,
);
});
it("leaves an element with no carve untouched", () => {
const plain = {
...carved(),
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "n1", params: { frequency: 160, gain: -6, q: 1.4 } }],
}),
};
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
});
});
@@ -92,8 +92,25 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
}
/** 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[] {
return elementAutomation(element).lanes;
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. */