diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 7b423963e..20c15483f 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -43,6 +43,11 @@ import { generateShape, type AutomationShapeId } from "./automationShapes"; import { simplifyPoints } from "./automationSimplify"; import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection"; import { defaultTimelineTheme } from "./timelineTheme"; +import { groupAutomationLanes, isCarveLane } from "./automationLaneData"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import type { TimelineElement } from "../store/playerStore"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; /** * Drawn radius of a breakpoint. @@ -63,11 +68,6 @@ const LANE_BORDER = defaultTimelineTheme.rowBorder; * but the wrong value is not in it. */ type SelectionBox = { t0: number; t1: number; v0: number; v1: number }; import { getTimelineLaneTop } from "./timelineLayout"; -import { groupAutomationLanes } from "./automationLaneData"; -import { isAudioTimelineElement } from "../../utils/timelineInspector"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import type { TimelineElement } from "../store/playerStore"; -import type { UseAutomationLanesResult } from "./useAutomationLanes"; /** Is this breakpoint inside the selection box? The rule itself is shared with * Delete and with the group drag, so what is drawn as caught is exactly what @@ -587,7 +587,11 @@ function ClipAutomationLanes({ onCommit={bound.onCommit} onSelect={bound.onSelect} snapTimes={snapTimes} - readOnly={bound.readOnly} + // The carve owns its own envelopes and rewrites them on every + // re-run, so a drag would be silently discarded — shown, but not + // editable. Per LANE, not per binding: a carved bed can carry the + // author's own volume curve beside the carve's bands. + readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)} rangeSelection={ bound.selection?.target === lane.target ? { diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index d9f823952..9e0fbfb7c 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -15,7 +15,7 @@ import { runtimeAudioId } from "../lib/timelineElementHelpers"; import { isCanaryEnabled } from "../../telemetry/canary"; import { TimelineFxButton } from "./TimelineFxButton"; import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; -import { groupAutomationLanes } from "./automationLaneData"; +import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { clipTimingStart } from "../../hooks/gsapShared"; import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow"; @@ -265,6 +265,7 @@ function AutomationLaneHeaderRow({ gutterBackground, columnWidth, onRemove, + isCarve, }: { /** The lane the ACTIVE clip draws in this row, or null when it draws none — * the row belongs to the property, and a clip may be absent from it. */ @@ -287,6 +288,9 @@ function AutomationLaneHeaderRow({ gutterBackground: string; columnWidth: number; onRemove?: (target: string) => void; + /** The carve owns this envelope and rewrites it on every re-run, so it is + * shown but not the author's to edit or delete. */ + isCarve?: boolean; }) { return (
lane.key), ); - const automationRows = groupAutomationLanes(trackElements).map((group) => ({ - key: group.key, - label: group.key, - name: group.name, - param: group.param, - target: - group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane - .target ?? null, - })); + const automationRows = groupAutomationLanes(trackElements).map((group) => { + const active = group.entries.find( + (entry) => (entry.element.key ?? entry.element.id) === activeKey, + ); + return { + key: group.key, + label: group.key, + name: group.name, + param: group.param, + target: active?.lane.target ?? null, + // Every entry in a row is the same parameter, so the first answers for the + // row when the active clip is absent from it. + isCarve: (() => { + const entry = active ?? group.entries[0]; + return entry ? isCarveLane(entry.lane.target, elementFxChain(entry.element)) : false; + })(), + }; + }); // Automation counts as something to disclose: gating the caret on tweens alone // left an audio clip's envelopes unreachable, since the track could not expand. const disclosable = lanes.length > 0 || automationRows.length > 0; @@ -683,6 +700,7 @@ export function TimelineTrackHeader({ gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)} columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin} onRemove={onRemoveAutomationLane} + isCarve={row.isCarve} /> ))}
diff --git a/packages/studio/src/player/components/automationLaneData.test.ts b/packages/studio/src/player/components/automationLaneData.test.ts index 8f1e011ab..113e93d2d 100644 --- a/packages/studio/src/player/components/automationLaneData.test.ts +++ b/packages/studio/src/player/components/automationLaneData.test.ts @@ -8,6 +8,7 @@ import { groupAutomationLanes, laneGroupKey, elementFxChain, + isCarveLane, } from "./automationLaneData"; import type { TimelineElement } from "../store/timelineElement"; @@ -323,20 +324,33 @@ describe("carve-generated lanes", () => { }), }); - it("keeps the author's lanes and drops the carve's", () => { + // These lanes used to be withheld. A bed whose EVERY lane is the carve's — + // which is what a plain voiceover carve produces — then showed no automation + // at all and no control to reveal any, so the carve looked like it had done + // nothing. The ducking curve is what a carve IS; it is shown, and marked + // read-only because the carve rewrites it on each analysis. + it("draws the carve's lanes alongside the author's", () => { const targets = elementAutomationLanes(carved()).map((lane) => lane.target); - expect(targets).toEqual(["fx.n2.gain", "volume"]); + expect(targets).toContain("fx.n1.gain"); + expect(targets).toContain("fx.n2.gain"); + expect(targets).toContain("volume"); }); - it("does not count a carve band as a timeline row", () => { + it("counts a carve band as a timeline row, so the row reserves its height", () => { const rows = groupAutomationLanes([carved()]); - expect(rows).toHaveLength(2); - expect(rows.every((row) => !row.entries.some((e) => e.lane.target === "fx.n1.gain"))).toBe( - true, - ); + expect(rows).toHaveLength(3); }); - it("leaves an element with no carve untouched", () => { + it("tells a carve's lane from the author's on the same element", () => { + const chain = elementFxChain(carved()); + expect(isCarveLane("fx.n1.gain", chain)).toBe(true); + // n2 is a hand-built peaking band with the same parameter — only the + // `fromCarve` tag separates them. + expect(isCarveLane("fx.n2.gain", chain)).toBe(false); + expect(isCarveLane("volume", chain)).toBe(false); + }); + + it("treats every lane as the author's when nothing is carve-tagged", () => { const plain = { ...carved(), fxChain: JSON.stringify({ @@ -345,5 +359,6 @@ describe("carve-generated lanes", () => { }), }; expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain"); + expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false); }); }); diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts index 39cdfd5f1..4683b237e 100644 --- a/packages/studio/src/player/components/automationLaneData.ts +++ b/packages/studio/src/player/components/automationLaneData.ts @@ -91,26 +91,32 @@ export function elementAutomation(element: TimelineElement): HfAutomation { }); } +/** + * Is this lane one the CARVE wrote, rather than the author? + * + * The carve compiles to tagged nodes and rewrites their envelopes on every + * re-run (`withoutCarveLanes` replaces each one), so a drag on such a lane is + * silently discarded the next time it analyses. Read-only rather than hidden: + * the ducking curve is what a carve IS, and the whole reason to look at a + * carved bed in the timeline is to see where it makes room. Hiding them left a + * bed whose every lane was the carve's showing no automation at all and no + * control to reveal any — which reads as the carve having done nothing. + */ +export function isCarveLane(target: string, chain: HfAudioFxChain | null): boolean { + return (chain?.nodes ?? []).some( + (node) => node.fromCarve && node.id && target.startsWith(`fx.${node.id}.`), + ); +} + /** Lanes in the order they are drawn, one row each. */ /** - * The lanes a TIMELINE row should draw — the author's own curves. + * The lanes a TIMELINE row should draw. * - * 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". + * Every lane the element carries, the carve's included — see `isCarveLane` for + * why those are shown read-only instead of withheld. */ 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))); + return elementAutomation(element).lanes; } /** The frequency the lane's effect sits at, when it has one. */