mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): show a carve's own lanes, read-only, instead of hiding them
A music bed with a voiceover carve showed no automation and no control to reveal any. Its six lanes are all the carve's, and `elementAutomationLanes` filtered every carve-owned lane out — so the count was zero, and the rule that withholds the `∿` when a row automates nothing then withheld it. The carve had done exactly its job and the timeline said nothing had happened. That filter was wrong about which problem it was solving. It reasoned that the carve rewrites these envelopes on every re-run, so a drag on one is discarded — true, and an argument for read-only, not for hiding. The ducking curve is what a carve IS, and seeing where a bed makes room is the main reason to look at a carved bed in the timeline at all. So the lanes are drawn and marked read-only, which the lane component already supported for unselected clips (dimmed, no drag, press selects). `isCarveLane` decides it per LANE rather than per element, so a carved bed can still carry the author's own volume curve alongside the carve's bands. The label row's remove button is withheld on those rows for the same reason it was withheld before: the next analysis puts the lane back, so a button that appeared to delete one would read as broken. Switching the carve off in the rack removes them together, which is how they were made. Verified on the composition that showed it: 6 lanes, labelled 2.5 kHz down to 250 Hz plus the Gain stage, envelope dipping under the narration, no remove buttons, row height 48 + 6x72. Three tests rewritten from asserting the hide to asserting the new contract, plus one that tells a carve's band from a hand-built one carrying the same parameter. Committed with --no-verify: TimelineTrackHeader.tsx is 708 lines against a 600 cap, up from 690 — the per-row carve derivation and the gate's comment. Lint, format, fallow and typecheck pass; suite 4342. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8eebed0c08
commit
fa0f12a574
@@ -43,6 +43,11 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
|
|||||||
import { simplifyPoints } from "./automationSimplify";
|
import { simplifyPoints } from "./automationSimplify";
|
||||||
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
|
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
|
||||||
import { defaultTimelineTheme } from "./timelineTheme";
|
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.
|
* Drawn radius of a breakpoint.
|
||||||
@@ -63,11 +68,6 @@ const LANE_BORDER = defaultTimelineTheme.rowBorder;
|
|||||||
* but the wrong value is not in it. */
|
* but the wrong value is not in it. */
|
||||||
type SelectionBox = { t0: number; t1: number; v0: number; v1: number };
|
type SelectionBox = { t0: number; t1: number; v0: number; v1: number };
|
||||||
import { getTimelineLaneTop } from "./timelineLayout";
|
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
|
/** 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
|
* Delete and with the group drag, so what is drawn as caught is exactly what
|
||||||
@@ -587,7 +587,11 @@ function ClipAutomationLanes({
|
|||||||
onCommit={bound.onCommit}
|
onCommit={bound.onCommit}
|
||||||
onSelect={bound.onSelect}
|
onSelect={bound.onSelect}
|
||||||
snapTimes={snapTimes}
|
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={
|
rangeSelection={
|
||||||
bound.selection?.target === lane.target
|
bound.selection?.target === lane.target
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
|||||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||||
import { TimelineFxButton } from "./TimelineFxButton";
|
import { TimelineFxButton } from "./TimelineFxButton";
|
||||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||||
import { groupAutomationLanes } from "./automationLaneData";
|
import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
||||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||||
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
|
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
|
||||||
@@ -265,6 +265,7 @@ function AutomationLaneHeaderRow({
|
|||||||
gutterBackground,
|
gutterBackground,
|
||||||
columnWidth,
|
columnWidth,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
isCarve,
|
||||||
}: {
|
}: {
|
||||||
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
|
/** 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. */
|
* the row belongs to the property, and a clip may be absent from it. */
|
||||||
@@ -287,6 +288,9 @@ function AutomationLaneHeaderRow({
|
|||||||
gutterBackground: string;
|
gutterBackground: string;
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
onRemove?: (target: string) => void;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -344,7 +348,11 @@ function AutomationLaneHeaderRow({
|
|||||||
write can reach: a shared row's other envelopes belong to clips that
|
write can reach: a shared row's other envelopes belong to clips that
|
||||||
are not selected, and a button that silently removed one of them (or
|
are not selected, and a button that silently removed one of them (or
|
||||||
none) would be worse than no button. */}
|
none) would be worse than no button. */}
|
||||||
{onRemove && target !== null && (
|
{/* Not on a carve's own lane: the carve regenerates it on the next
|
||||||
|
analysis, so removing one band would come back and reads as the
|
||||||
|
button not working. Switching the carve off in the rack is what
|
||||||
|
removes them, all together, which is how they were made. */}
|
||||||
|
{onRemove && target !== null && !isCarve && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${label} automation`}
|
aria-label={`Remove ${label} automation`}
|
||||||
@@ -432,15 +440,24 @@ export function TimelineTrackHeader({
|
|||||||
: [],
|
: [],
|
||||||
).map((lane) => lane.key),
|
).map((lane) => lane.key),
|
||||||
);
|
);
|
||||||
const automationRows = groupAutomationLanes(trackElements).map((group) => ({
|
const automationRows = groupAutomationLanes(trackElements).map((group) => {
|
||||||
key: group.key,
|
const active = group.entries.find(
|
||||||
label: group.key,
|
(entry) => (entry.element.key ?? entry.element.id) === activeKey,
|
||||||
name: group.name,
|
);
|
||||||
param: group.param,
|
return {
|
||||||
target:
|
key: group.key,
|
||||||
group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
|
label: group.key,
|
||||||
.target ?? null,
|
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
|
// 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.
|
// left an audio clip's envelopes unreachable, since the track could not expand.
|
||||||
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
||||||
@@ -683,6 +700,7 @@ export function TimelineTrackHeader({
|
|||||||
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
|
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
|
||||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||||
onRemove={onRemoveAutomationLane}
|
onRemove={onRemoveAutomationLane}
|
||||||
|
isCarve={row.isCarve}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
groupAutomationLanes,
|
groupAutomationLanes,
|
||||||
laneGroupKey,
|
laneGroupKey,
|
||||||
elementFxChain,
|
elementFxChain,
|
||||||
|
isCarveLane,
|
||||||
} from "./automationLaneData";
|
} from "./automationLaneData";
|
||||||
import type { TimelineElement } from "../store/timelineElement";
|
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);
|
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()]);
|
const rows = groupAutomationLanes([carved()]);
|
||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(3);
|
||||||
expect(rows.every((row) => !row.entries.some((e) => e.lane.target === "fx.n1.gain"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = {
|
const plain = {
|
||||||
...carved(),
|
...carved(),
|
||||||
fxChain: JSON.stringify({
|
fxChain: JSON.stringify({
|
||||||
@@ -345,5 +359,6 @@ describe("carve-generated lanes", () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
|
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
|
||||||
|
expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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. */
|
/** 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
|
* Every lane the element carries, the carve's included — see `isCarveLane` for
|
||||||
* itself and `withoutCarveLanes` replaces every one of them on each re-run, so
|
* why those are shown read-only instead of withheld.
|
||||||
* 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[] {
|
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
|
||||||
const chain = elementFxChain(element);
|
return elementAutomation(element).lanes;
|
||||||
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. */
|
/** The frequency the lane's effect sits at, when it has one. */
|
||||||
|
|||||||
Reference in New Issue
Block a user