Skip to content

Commit 0043ae7

Browse files
committed
feat(studio): automate a parameter without reloading the preview
The write path and the volume half of the panel surface. **A commit that persists without reloading.** For attributes the runtime applies to the live graph itself — an FX chain, its automation — a reload would only interrupt playback to reach the state the preview already has. `skipRefresh` and `refreshAfter` were already independent options; this exposes the combination that skips the reload but still re-reads the selection. Both halves are needed, and they were fighting each other. Without the reload, audio no longer chops on an edit. Without the resync, the panel keeps reading the selection snapshot it was built with, so a second edit computes from a pre-edit value and appears to do nothing — deleting one effect made every later delete a no-op. `handleDomAttributeLiveCommit` is untouched and still used for knob dragging, where a per-move re-render is exactly what you do not want. **Volume.** An automated track's slider is disabled, since a level set there would be overwritten by the envelope on the next tick, and the toggle beside it adds or deletes the lane. Adding seeds it with a single point at the level the slider already shows, so automating a track never changes how loud it is. **One shared reader** for both panel sections, which is what surfaced that resolving against an absent chain would have deleted every FX lane the moment someone automated a volume: the volume section does not parse the chain, so "no chain" now means "do not resolve" rather than "drop what cannot be resolved". The toggle itself lives with the FX controls it is shared with, and says `Automated` / `Automate` through the studio's own Tooltip rather than a native browser hover. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5328c31 commit 0043ae7

13 files changed

Lines changed: 422 additions & 25 deletions

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export function StudioRightPanel({
124124
handleDomStyleCommit,
125125
handleDomAttributeCommit,
126126
handleDomAttributeLiveCommit,
127+
handleDomAttributeQuietCommit,
127128
handleDomHtmlAttributeCommit,
128129
handleDomAttributesCommit,
129130
handleDomPathOffsetCommit,
@@ -361,6 +362,7 @@ export function StudioRightPanel({
361362
onSetAttribute={handleDomAttributeCommit}
362363
onSetAttributes={handleDomAttributesCommit}
363364
onSetAttributeLive={handleDomAttributeLiveCommit}
365+
onSetAttributeQuiet={handleDomAttributeQuietCommit}
364366
onApplyColorGradingScope={handleApplyColorGradingScope}
365367
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
366368
onRemoveBackground={handleRemoveBackground}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Reading and editing an element's automation from the property panel.
3+
*
4+
* Shared by the audio FX group (per-effect parameters) and the media section
5+
* (track volume), so both agree on what "automated" means and both write the
6+
* attribute the same way.
7+
*/
8+
9+
import {
10+
HF_AUDIO_AUTOMATION_ATTR,
11+
parseAutomation,
12+
resolveAutomation,
13+
resolveAutomationRange,
14+
serializeAutomation,
15+
type HfAutomation,
16+
} from "@hyperframes/core/audio-automation";
17+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
18+
19+
const EMPTY: HfAutomation = { version: 1, lanes: [] };
20+
21+
/**
22+
* The element's automation as the panel should treat it.
23+
*
24+
* Pass the chain to have it bound: a lane whose effect has been deleted is then
25+
* dropped rather than reported as automating something. Pass `undefined` when
26+
* the caller genuinely does not know the chain — the volume section does not
27+
* parse it — and every lane is preserved instead.
28+
*
29+
* That distinction matters because callers write this value straight back to the
30+
* attribute. Resolving against a chain that was merely unavailable would delete
31+
* every FX lane the moment someone automated the volume.
32+
*
33+
* An unreadable attribute reads as no automation rather than breaking the panel;
34+
* it is left untouched until the author changes something.
35+
*/
36+
export function readPanelAutomation(
37+
raw: string | undefined,
38+
chain: HfAudioFxChain | undefined,
39+
): HfAutomation {
40+
if (!raw) return EMPTY;
41+
try {
42+
const parsed = parseAutomation(raw);
43+
return chain ? resolveAutomation(parsed, chain) : parsed;
44+
} catch {
45+
return EMPTY;
46+
}
47+
}
48+
49+
/** Targets the element currently automates. */
50+
export function automatedTargetsOf(automation: HfAutomation): Set<string> {
51+
return new Set(automation.lanes.map((lane) => lane.target));
52+
}
53+
54+
/**
55+
* Add a lane for `target`, seeded with a single point at `current`.
56+
*
57+
* One point is a constant, so switching a parameter to an envelope does not
58+
* change the sound — it only moves where the value comes from. The author then
59+
* shapes it in the timeline.
60+
*/
61+
export function withSeededLane(
62+
automation: HfAutomation,
63+
target: string,
64+
current: number,
65+
): HfAutomation {
66+
if (automation.lanes.some((lane) => lane.target === target)) return automation;
67+
return { version: 1, lanes: [...automation.lanes, { target, points: [{ t: 0, v: current }] }] };
68+
}
69+
70+
/** Drop one lane, handing its value back to the panel control. */
71+
export function withoutLane(automation: HfAutomation, target: string): HfAutomation {
72+
return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) };
73+
}
74+
75+
/** The attribute value for an automation set; empty when nothing is automated. */
76+
export function automationAttrValue(automation: HfAutomation): string {
77+
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
78+
}
79+
80+
export { HF_AUDIO_AUTOMATION_ATTR, resolveAutomationRange };

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

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "./propertyPanelHelpers";
1313
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
1414
import { FlatToggle } from "./propertyPanelFlatToggle";
15+
import { AutomationToggle } from "./propertyPanelFxControls";
1516

1617
// fallow-ignore-next-line complexity
1718
export function FlatMediaSection({
@@ -22,13 +23,20 @@ export function FlatMediaSection({
2223
onSetAttribute,
2324
onSetHtmlAttribute,
2425
onRemoveBackground,
26+
volumeAutomated,
27+
onAutomateVolume,
28+
onRemoveVolumeAutomation,
2529
}: {
2630
projectDir: string | null;
2731
element: DomEditSelection;
2832
styles: Record<string, string>;
2933
onSetStyle: (prop: string, value: string) => void | Promise<void>;
3034
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
3135
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
36+
/** A volume lane in the timeline drives the level; the slider cannot. */
37+
volumeAutomated?: boolean;
38+
onAutomateVolume?: () => void;
39+
onRemoveVolumeAutomation?: () => void;
3240
onRemoveBackground?: (
3341
inputPath: string,
3442
options: {
@@ -197,15 +205,35 @@ export function FlatMediaSection({
197205
)}
198206
{(isVideo || isAudio) && (
199207
<>
200-
<FlatSlider
201-
label="Volume"
202-
value={volumePercent}
203-
min={0}
204-
max={100}
205-
tier={volumePercent === 100 ? "default" : "explicitCustom"}
206-
displayValue={`${volumePercent}%`}
207-
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
208-
/>
208+
{/* The slider is disabled while a lane owns the level: a value set
209+
here would be overwritten by the envelope on the next tick. The
210+
toggle beside it carries the tooltip. */}
211+
<div
212+
className="hf-volume-row flex items-center gap-1"
213+
data-volume-automated={volumeAutomated ? "" : undefined}
214+
>
215+
<div className="min-w-0 flex-1">
216+
<FlatSlider
217+
label="Volume"
218+
value={volumePercent}
219+
min={0}
220+
max={100}
221+
tier={volumePercent === 100 ? "default" : "explicitCustom"}
222+
displayValue={`${volumePercent}%`}
223+
disabled={volumeAutomated}
224+
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
225+
/>
226+
</div>
227+
<AutomationToggle
228+
paramKey="volume"
229+
label="Volume"
230+
automated={Boolean(volumeAutomated)}
231+
onAutomate={onAutomateVolume ? () => onAutomateVolume() : undefined}
232+
onRemoveAutomation={
233+
onRemoveVolumeAutomation ? () => onRemoveVolumeAutomation() : undefined
234+
}
235+
/>
236+
</div>
209237
<FlatSlider
210238
label="Rate"
211239
value={playbackRate * 100}

packages/studio/src/components/editor/propertyPanelFlatProps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type PropertyPanelFlatProps = Pick<
1616
| "onSetAttribute"
1717
| "onSetAttributes"
1818
| "onSetAttributeLive"
19+
| "onSetAttributeQuiet"
1920
| "onApplyColorGradingScope"
2021
| "onSetHtmlAttribute"
2122
| "onRemoveBackground"

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

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import { useCallback, useEffect, useRef, useState } from "react";
11+
import { Tooltip } from "../ui/Tooltip";
1112
import type {
1213
HfAudioFxDef,
1314
HfAudioFxNumberParam,
@@ -46,9 +47,66 @@ interface ParamRowProps {
4647
/** Fires once when the gesture ends — this is the write that persists. */
4748
onCommit?(key: string, value: number | string): void;
4849
disabled?: boolean;
50+
/**
51+
* A lane in the timeline drives this parameter. The control is disabled
52+
* because a value typed here would be overwritten by the envelope on the next
53+
* tick — the lane is the value now.
54+
*/
55+
automated?: boolean;
56+
/** Add a lane for this parameter, seeded at its current value. */
57+
onAutomate?(key: string): void;
58+
/** Delete this parameter's lane, handing the value back to the control. */
59+
onRemoveAutomation?(key: string): void;
4960
}
5061

51-
export function FxParamRow({ param, value, onChange, onCommit, disabled }: ParamRowProps) {
62+
/**
63+
* The automation toggle for one parameter: adds a lane, or deletes the one that
64+
* already owns the value. Absent for parameters no envelope can drive — a
65+
* WaveShaper curve, a convolution impulse, or a worklet's options.
66+
*/
67+
export function AutomationToggle({
68+
paramKey,
69+
label,
70+
automated,
71+
onAutomate,
72+
onRemoveAutomation,
73+
}: {
74+
paramKey: string;
75+
label: string;
76+
automated: boolean;
77+
onAutomate?(key: string): void;
78+
onRemoveAutomation?(key: string): void;
79+
}) {
80+
if (!onAutomate && !onRemoveAutomation) return null;
81+
return (
82+
<Tooltip label={automated ? "Automated" : "Automate"}>
83+
<button
84+
type="button"
85+
className={`hf-fx-automate w-[16px] flex-shrink-0 rounded-[3px] border font-mono text-[9px] leading-none ${
86+
automated
87+
? "border-panel-accent text-panel-accent"
88+
: "border-panel-border-input text-panel-text-4 hover:text-panel-text-0"
89+
}`}
90+
aria-pressed={automated}
91+
aria-label={automated ? `Remove ${label} automation` : `Automate ${label}`}
92+
onClick={() => (automated ? onRemoveAutomation?.(paramKey) : onAutomate?.(paramKey))}
93+
>
94+
A
95+
</button>
96+
</Tooltip>
97+
);
98+
}
99+
100+
export function FxParamRow({
101+
param,
102+
value,
103+
onChange,
104+
onCommit,
105+
disabled,
106+
automated,
107+
onAutomate,
108+
onRemoveAutomation,
109+
}: ParamRowProps) {
52110
// While dragging, the slider is driven locally. Waiting for the value to come
53111
// back through the element attribute makes the control feel laggy and fights
54112
// the pointer.
@@ -105,9 +163,19 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
105163
const numeric = typeof shown === "number" ? shown : Number(shown);
106164
const current = Number.isFinite(numeric) ? numeric : param.default;
107165

166+
const locked = Boolean(disabled) || Boolean(automated);
167+
108168
return (
109-
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
110-
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-4">
169+
<label
170+
className={`hf-fx-row flex min-h-6 items-center gap-2${automated ? " hf-fx-row-automated" : ""}`}
171+
title={param.hint}
172+
data-automated={automated ? "" : undefined}
173+
>
174+
<span
175+
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
176+
automated ? "text-panel-accent" : "text-panel-text-4"
177+
}`}
178+
>
111179
{param.label}
112180
</span>
113181
<input
@@ -117,7 +185,7 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
117185
max={param.max}
118186
step={(param.max - param.min) / 1000}
119187
value={toSlider(param, current)}
120-
disabled={disabled}
188+
disabled={locked}
121189
aria-label={param.label}
122190
onPointerDown={() => setDragging(true)}
123191
onChange={(e) => handleNumber(fromSlider(param, Number(e.target.value)))}
@@ -132,7 +200,7 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
132200
max={param.max}
133201
step={param.step}
134202
value={display(param, current)}
135-
disabled={disabled}
203+
disabled={locked}
136204
onChange={(e) => {
137205
const next = Number(e.target.value);
138206
if (Number.isFinite(next)) handleNumber(next);
@@ -147,6 +215,13 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
147215
{param.unit}
148216
</span>
149217
) : null}
218+
<AutomationToggle
219+
paramKey={param.key}
220+
label={param.label}
221+
automated={Boolean(automated)}
222+
onAutomate={onAutomate}
223+
onRemoveAutomation={onRemoveAutomation}
224+
/>
150225
</label>
151226
);
152227
}
@@ -157,10 +232,24 @@ interface FxParamsProps {
157232
onChange(params: HfAudioFxParamValues): void;
158233
onCommit?(params: HfAudioFxParamValues): void;
159234
disabled?: boolean;
235+
/** Parameter keys this effect currently has a lane for. */
236+
automatedKeys?: ReadonlySet<string>;
237+
/** Absent when the effect cannot be automated at all, or nothing can write. */
238+
onAutomate?(key: string): void;
239+
onRemoveAutomation?(key: string): void;
160240
}
161241

162242
/** Every knob the effect declares, in registry order. */
163-
export function FxParams({ def, params, onChange, onCommit, disabled }: FxParamsProps) {
243+
export function FxParams({
244+
def,
245+
params,
246+
onChange,
247+
onCommit,
248+
disabled,
249+
automatedKeys,
250+
onAutomate,
251+
onRemoveAutomation,
252+
}: FxParamsProps) {
164253
const set = useCallback(
165254
(key: string, value: number | string) => onChange({ ...params, [key]: value }),
166255
[params, onChange],
@@ -171,16 +260,25 @@ export function FxParams({ def, params, onChange, onCommit, disabled }: FxParams
171260
);
172261
return (
173262
<div className="hf-fx-params space-y-0.5 border-t border-panel-border-input px-1.5 py-1.5">
174-
{def.params.map((p) => (
175-
<FxParamRow
176-
key={p.key}
177-
param={p}
178-
value={params[p.key] ?? p.default}
179-
onChange={set}
180-
onCommit={commit}
181-
disabled={disabled}
182-
/>
183-
))}
263+
{def.params.map((p) => {
264+
// Only a parameter the registry marks automatable has an AudioParam
265+
// behind it for an envelope to write to.
266+
const canAutomate = p.kind === "number" && p.automatable === true;
267+
const automated = automatedKeys?.has(p.key) ?? false;
268+
return (
269+
<FxParamRow
270+
key={p.key}
271+
param={p}
272+
value={params[p.key] ?? p.default}
273+
onChange={set}
274+
onCommit={commit}
275+
disabled={disabled}
276+
automated={automated}
277+
onAutomate={canAutomate && !automated ? onAutomate : undefined}
278+
onRemoveAutomation={canAutomate && automated ? onRemoveAutomation : undefined}
279+
/>
280+
);
281+
})}
184282
</div>
185283
);
186284
}

packages/studio/src/components/editor/propertyPanelTypes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export interface PropertyPanelProps {
5858
value: string | null,
5959
onSettled?: (ok: boolean) => void,
6060
) => void | Promise<void>;
61+
/** Persists without reloading the preview, but re-reads the selection after —
62+
* for attributes the runtime applies to the live graph itself, where a reload
63+
* would only interrupt playback, and where the panel still has to see the
64+
* value it just wrote to compute the next edit from. */
65+
onSetAttributeQuiet?: (attr: string, value: string | null) => void | Promise<void>;
6166
onApplyColorGradingScope?: (
6267
scope: "source-file" | "project",
6368
value: string | null,

0 commit comments

Comments
 (0)