From acfd425ec4e3f05e01d484def0e3c5c9f671894d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 12:39:15 -0700 Subject: [PATCH] fix(studio): keep the carve's own lanes out of the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/TimelineGroupLaneLabels.tsx | 8 +- .../components/automationLaneData.test.ts | 79 +++++++++++++++++++ .../player/components/automationLaneData.ts | 19 ++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx index c3558b193..62e1b8847 100644 --- a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx +++ b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx @@ -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) => { diff --git a/packages/studio/src/player/components/automationLaneData.test.ts b/packages/studio/src/player/components/automationLaneData.test.ts index b1caf3047..8f1e011ab 100644 --- a/packages/studio/src/player/components/automationLaneData.test.ts +++ b/packages/studio/src/player/components/automationLaneData.test.ts @@ -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"); + }); +}); diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts index 01a35df66..39cdfd5f1 100644 --- a/packages/studio/src/player/components/automationLaneData.ts +++ b/packages/studio/src/player/components/automationLaneData.ts @@ -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. */