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. */