Skip to content

Commit 12c59bc

Browse files
vanceingallsclaude
andcommitted
feat(studio): show every automated knob at the playhead, and carve as one module
An automated parameter has two values: the number sitting in the chain, which is only the seed a lane replaced, and the number the envelope is on right now. The second is the true one, so the panel shows it — on the carve rack's readouts and on every effect's own fader and number field. A rack that showed the seed stood still while the carve was audibly working. Off the clip it keeps sampling rather than falling back to the stored number: a lane holds its first value backwards and its last forwards, so before the clip starts it already knows what it will open on, and the stored seed is a value nothing will ever play. Showing it made the fader jump the moment the clip came under the playhead. The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop deliberately keeps frames out of the store, so a panel watching only the store would sit still for a whole take. PropertyPanel had that subscription inline; it is now one shared hook with two callers. Readouts reserve the width their parameter can need rather than what its current value takes, because an updating value one character narrower shunted everything after it sideways 30 times a second. The carve's effects are presented as one module: an author switched on a carve, and the peaking filters plus the level stage are how it is built, not six things to remove one at a time. Opening it lists every member's settings as readouts, since strength is what sets them. No carve control is offered on a track another track already carves against — that track is the voice, not the bed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 457cde3 commit 12c59bc

18 files changed

Lines changed: 1800 additions & 180 deletions

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,21 @@ export function StudioRightPanel({
327327
},
328328
[projectId, refreshFileTree, showToast],
329329
);
330+
331+
/**
332+
* A dial being dragged writes to the preview and stops there.
333+
*
334+
* Every one of these panels previews on each pointermove and commits on
335+
* release. Persisting the moves too put a fragment of the drag in the undo
336+
* stack — and since those writes race, history could not coalesce them
337+
* reliably, so undo took back a sliver of the gesture rather than the gesture.
338+
* The release's own commit is what reaches the file and the undo stack.
339+
*/
340+
const setAttributeWhileDragging = useCallback(
341+
(attr: string, value: string | null) =>
342+
handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
343+
[handleDomAttributeLiveCommit],
344+
);
330345
const handleHideAllSelected = () => {
331346
const { elements } = usePlayerStore.getState();
332347
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
@@ -361,7 +376,7 @@ export function StudioRightPanel({
361376
onSetStyle={handleDomStyleCommit}
362377
onSetAttribute={handleDomAttributeCommit}
363378
onSetAttributes={handleDomAttributesCommit}
364-
onSetAttributeLive={handleDomAttributeLiveCommit}
379+
onSetAttributeLive={setAttributeWhileDragging}
365380
onSetAttributeQuiet={handleDomAttributeQuietCommit}
366381
onApplyColorGradingScope={handleApplyColorGradingScope}
367382
onSetHtmlAttribute={handleDomHtmlAttributeCommit}

packages/studio/src/components/TimelineToolbar.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,49 @@ describe("TimelineToolbar — motion path endpoints", () => {
9999
act(() => root.unmount());
100100
});
101101
});
102+
103+
describe("TimelineToolbar — keyframes on audio tracks", () => {
104+
const clip = (tag: string) => ({
105+
id: "bgm",
106+
key: "bgm",
107+
tag,
108+
start: 0,
109+
duration: 10,
110+
track: 1,
111+
});
112+
113+
/** A session whose selection would otherwise offer the keyframe toggle. */
114+
function sessionFor(tag: string) {
115+
usePlayerStore.setState({ elements: [clip(tag)], selectedElementId: "bgm", currentTime: 1 });
116+
const element = document.createElement(tag);
117+
element.id = "bgm";
118+
return {
119+
domEditSelection: makeSelection("Element", element),
120+
selectedGsapAnimations: [],
121+
handleGsapAddAnimation: vi.fn(),
122+
handleGsapConvertToKeyframes: vi.fn(),
123+
handleGsapRemoveKeyframe: vi.fn(),
124+
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
125+
}
126+
127+
it("offers no keyframe toggle for an audio clip", () => {
128+
// An audio clip has no box on the canvas, so there is nothing to move or fade —
129+
// and pressing this seeded a tween from the position properties, which put a
130+
// position lane on a track that has no position. Audio is automated instead.
131+
const { host, root } = renderToolbar(sessionFor("audio"));
132+
const button = host.querySelector<HTMLButtonElement>(
133+
'button[aria-label="Add keyframe at playhead"]',
134+
);
135+
expect(button?.disabled).toBe(true);
136+
act(() => root.unmount());
137+
});
138+
139+
it("still offers it for a visual clip", () => {
140+
const { host, root } = renderToolbar(sessionFor("div"));
141+
const button = host.querySelector<HTMLButtonElement>(
142+
'button[aria-label="Add keyframe at playhead"]',
143+
);
144+
expect(button?.disabled).toBe(false);
145+
act(() => root.unmount());
146+
});
147+
});

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,32 @@ function resolveKeyframeToggleState(
8686
};
8787
}
8888

89+
/**
90+
* Can this element be keyframed at all?
91+
*
92+
* An audio clip cannot. It has no box on the canvas, so there is nothing to move,
93+
* scale or fade — and "add a keyframe" on one seeds a tween from the position
94+
* properties, which produced a position lane on a track that has no position. Audio
95+
* is automated instead: volume and effect parameters, on their own lanes.
96+
*/
97+
function isKeyframeable(element: TimelineElement | undefined): boolean {
98+
return element?.tag !== "audio";
99+
}
100+
89101
function useKeyframeToggle(session?: DomEditSessionSlice) {
90102
const currentTime = usePlayerStore((s) => s.currentTime);
103+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
104+
const elements = usePlayerStore((s) => s.elements);
91105
const sessionRef = useRef(session);
92106
sessionRef.current = session;
93107

94108
const onToggle = useEnableKeyframes(
95109
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
96110
);
97111

112+
const selected = elements.find((element) => (element.key ?? element.id) === selectedElementId);
113+
if (!isKeyframeable(selected)) return { ...NO_KEYFRAME_TOGGLE, onToggle: undefined };
114+
98115
const toggleState = resolveKeyframeToggleState(session, currentTime);
99116

100117
return {

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2-
import { memo, useEffect, useMemo, useRef, useState } from "react";
2+
import { memo, useMemo, useRef, useState } from "react";
33
import { Move } from "../../icons/SystemIcons";
44
import { InspectorHeaderActions } from "./InspectorHeaderActions";
55
import { useStudioShellContext } from "../../contexts/StudioContext";
@@ -29,7 +29,8 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
2929
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./manualEditingAvailability";
3030
import { PropertyPanelFlat } from "./PropertyPanelFlat";
3131
import { createGsapLivePreview } from "./gsapLivePreview";
32-
import { usePlayerStore, liveTime } from "../../player";
32+
import { usePlayerStore } from "../../player";
33+
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
3334
import { TimingSection } from "./propertyPanelTimingSection";
3435
import { type PropertyPanelProps } from "./propertyPanelHelpers";
3536
import { GestureRecordPanelButton } from "./GestureRecordControl";
@@ -114,31 +115,14 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
114115
const { showToast } = useStudioShellContext();
115116
const [clipboardCopied, setClipboardCopied] = useState(false);
116117
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
117-
const storeTime = usePlayerStore((s) => s.currentTime);
118-
const isPlaying = usePlayerStore((s) => s.isPlaying);
119118
const timelineElements = usePlayerStore((s) => s.elements);
120119
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
121120
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
122121
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
123-
const liveTimeRef = useRef(storeTime);
124-
const [, forceRender] = useState(0);
125-
useEffect(() => {
126-
if (!isPlaying) return;
127-
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
128-
const unsub = liveTime.subscribe((t) => {
129-
liveTimeRef.current = t;
130-
if (!timerId)
131-
timerId = setTimeout(() => {
132-
timerId = 0;
133-
forceRender((v) => v + 1);
134-
}, 33);
135-
});
136-
return () => {
137-
unsub();
138-
if (timerId) clearTimeout(timerId);
139-
};
140-
}, [isPlaying]);
141-
const currentTime = isPlaying ? liveTimeRef.current : storeTime;
122+
// Live during playback, the store's when paused — see the hook. Shared with the
123+
// audio FX panel, which follows the playhead for the same reason: a value the
124+
// timeline drives has to be shown moving, not frozen at what the attribute says.
125+
const currentTime = useLivePlayheadTime();
142126
const cacheElementKey = element?.id ?? element?.selector ?? "";
143127
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
144128

packages/studio/src/components/editor/PropertyPanelFlat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { slugifyDesignInput } from "../../utils/designInputTracking";
66
import { isTextEditableSelection } from "./domEditing";
77
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
88
import { formatPxMetricValue } from "./propertyPanelHelpers";
9+
import { audioFxSummary } from "./audioFxSummary";
910
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
1011
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
1112
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
@@ -14,8 +15,7 @@ import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
1415
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
1516
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
1617
import { isCanaryEnabled } from "../../telemetry/canary";
17-
import { audioFxSummary } from "./audioFxSummary";
18-
import { AudioFxGroup } from "./propertyPanelAudioFxGroup";
18+
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
1919
import { useVolumeAutomation } from "./useVolumeAutomation";
2020
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
2121
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from "vitest";
2+
import { audioFxSummary } from "./audioFxSummary";
3+
import type { DomEditSelection } from "./domEditingTypes";
4+
5+
const el = (dataAttributes: Record<string, string>): DomEditSelection =>
6+
({ dataAttributes }) as unknown as DomEditSelection;
7+
8+
const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
9+
10+
describe("audioFxSummary", () => {
11+
it("counts a carve as one module, not as the filters behind it", () => {
12+
// Six bands and a level stage reading "7 effects" is the misreading the
13+
// grouping exists to prevent.
14+
const summary = audioFxSummary(
15+
el({
16+
"fx-chain": chain([
17+
{ type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
18+
{ type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1600 } },
19+
{ type: "gain", id: "n3", fromCarve: true, params: { gain: -6 } },
20+
]),
21+
"fx-carve": JSON.stringify({ source: "vo", strength: 0.25 }),
22+
}),
23+
);
24+
expect(summary).toBe("carve");
25+
});
26+
27+
it("counts hand-built effects alongside the module", () => {
28+
expect(
29+
audioFxSummary(
30+
el({
31+
"fx-chain": chain([
32+
{ type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
33+
{ type: "lowpass", id: "n2", params: { frequency: 8000 } },
34+
{ type: "delay", id: "n3", params: { time: 200 } },
35+
]),
36+
}),
37+
),
38+
).toBe("2 effects + carve");
39+
});
40+
41+
it("says how many when there is no carve", () => {
42+
expect(audioFxSummary(el({ "fx-chain": chain([{ type: "lowpass", id: "n1" }]) }))).toBe(
43+
"1 effect",
44+
);
45+
});
46+
47+
it("names a carve that is on but has not compiled to filters yet", () => {
48+
// Switching it on with no voice chosen leaves the control in this section with
49+
// nothing behind it; the summary should still say the section holds one.
50+
expect(audioFxSummary(el({ "fx-carve": JSON.stringify({ source: "", strength: 0.25 }) }))).toBe(
51+
"carve",
52+
);
53+
});
54+
55+
it("ignores bypassed effects, as it always did", () => {
56+
expect(
57+
audioFxSummary(
58+
el({
59+
"fx-chain": chain([
60+
{ type: "lowpass", id: "n1", enabled: false },
61+
{ type: "delay", id: "n2" },
62+
]),
63+
}),
64+
),
65+
).toBe("1 effect");
66+
});
67+
68+
it("says none for a track with neither", () => {
69+
expect(audioFxSummary(el({}))).toBe("none");
70+
});
71+
72+
it("says so when the chain cannot be read", () => {
73+
expect(audioFxSummary(el({ "fx-chain": "{not json" }))).toBe("unreadable");
74+
});
75+
});
Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
/**
22
* What the collapsed Audio FX group says it holds.
33
*
4-
* Its own module because `PropertyPanelFlat.tsx` is at the repo's 600-line
5-
* budget, and this is the piece with no dependency on the panel around it.
4+
* It has to describe the rack the author would see on opening it, which counts a
5+
* carve as one module rather than as the filters it compiles to. Six peaking
6+
* bands and a level stage reading "7 effects" invited exactly the misreading the
7+
* grouping exists to prevent — that they are seven things to manage.
68
*/
79

810
import { parseAudioFxChain } from "@hyperframes/core/audio-fx";
9-
import type { DomEditSelection } from "./domEditing";
11+
import type { DomEditSelection } from "./domEditingTypes";
1012

11-
/** Chain length at a glance, so the collapsed group says whether anything is on. */
1213
export function audioFxSummary(element: DomEditSelection): string {
1314
const raw = element.dataAttributes?.["fx-chain"];
14-
const carve = element.dataAttributes?.["fx-carve"];
15-
let count = 0;
15+
const carveAttr = element.dataAttributes?.["fx-carve"];
16+
let handBuilt = 0;
17+
let carveNodes = 0;
1618
if (raw) {
1719
try {
18-
count = parseAudioFxChain(raw).nodes.filter((n) => n.enabled !== false).length;
20+
for (const node of parseAudioFxChain(raw).nodes) {
21+
if (node.enabled === false) continue;
22+
if (node.fromCarve) carveNodes += 1;
23+
else handBuilt += 1;
24+
}
1925
} catch {
2026
return "unreadable";
2127
}
2228
}
2329
const parts: string[] = [];
24-
if (count > 0) parts.push(`${count} effect${count === 1 ? "" : "s"}`);
25-
if (carve) parts.push("carve");
30+
if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
31+
// One name for the module however many filters are behind it. Named when the
32+
// carve is switched on at all, because the control is in this section whether or
33+
// not it has compiled to anything yet.
34+
if (carveNodes > 0 || carveAttr) parts.push("carve");
2635
return parts.length > 0 ? parts.join(" + ") : "none";
2736
}

0 commit comments

Comments
 (0)