Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@
"withLane",
],
},
// propertyPanelAutomation is the shared reader for both panel sections; the
// FX group that consumes these two lands one PR upstack, so a per-PR audit
// against the merge base sees them as unused.
{
"file": "packages/studio/src/components/editor/propertyPanelAutomation.ts",
"exports": ["automatedTargetsOf", "resolveAutomationRange"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export function StudioRightPanel({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
Expand Down Expand Up @@ -361,6 +362,7 @@ export function StudioRightPanel({
onSetAttribute={handleDomAttributeCommit}
onSetAttributes={handleDomAttributesCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
onSetAttributeQuiet={handleDomAttributeQuietCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onRemoveBackground={handleRemoveBackground}
Expand Down
80 changes: 80 additions & 0 deletions packages/studio/src/components/editor/propertyPanelAutomation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Reading and editing an element's automation from the property panel.
*
* Shared by the audio FX group (per-effect parameters) and the media section
* (track volume), so both agree on what "automated" means and both write the
* attribute the same way.
*/

import {
HF_AUDIO_AUTOMATION_ATTR,
parseAutomation,
resolveAutomation,
resolveAutomationRange,
serializeAutomation,
type HfAutomation,
} from "@hyperframes/core/audio-automation";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";

const EMPTY: HfAutomation = { version: 1, lanes: [] };

/**
* The element's automation as the panel should treat it.
*
* Pass the chain to have it bound: a lane whose effect has been deleted is then
* dropped rather than reported as automating something. Pass `undefined` when
* the caller genuinely does not know the chain — the volume section does not
* parse it — and every lane is preserved instead.
*
* That distinction matters because callers write this value straight back to the
* attribute. Resolving against a chain that was merely unavailable would delete
* every FX lane the moment someone automated the volume.
*
* An unreadable attribute reads as no automation rather than breaking the panel;
* it is left untouched until the author changes something.
*/
export function readPanelAutomation(
raw: string | undefined,
chain: HfAudioFxChain | undefined,
): HfAutomation {
if (!raw) return EMPTY;
try {
const parsed = parseAutomation(raw);
return chain ? resolveAutomation(parsed, chain) : parsed;
} catch {
return EMPTY;
}
}

/** Targets the element currently automates. */
export function automatedTargetsOf(automation: HfAutomation): Set<string> {
return new Set(automation.lanes.map((lane) => lane.target));
}

/**
* Add a lane for `target`, seeded with a single point at `current`.
*
* One point is a constant, so switching a parameter to an envelope does not
* change the sound — it only moves where the value comes from. The author then
* shapes it in the timeline.
*/
export function withSeededLane(
automation: HfAutomation,
target: string,
current: number,
): HfAutomation {
if (automation.lanes.some((lane) => lane.target === target)) return automation;
return { version: 1, lanes: [...automation.lanes, { target, points: [{ t: 0, v: current }] }] };
}

/** Drop one lane, handing its value back to the panel control. */
export function withoutLane(automation: HfAutomation, target: string): HfAutomation {
return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) };
}

/** The attribute value for an automation set; empty when nothing is automated. */
export function automationAttrValue(automation: HfAutomation): string {
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
}

export { HF_AUDIO_AUTOMATION_ATTR, resolveAutomationRange };
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./propertyPanelHelpers";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import { AutomationToggle } from "./propertyPanelFxControls";

// fallow-ignore-next-line complexity
export function FlatMediaSection({
Expand All @@ -22,13 +23,20 @@ export function FlatMediaSection({
onSetAttribute,
onSetHtmlAttribute,
onRemoveBackground,
volumeAutomated,
onAutomateVolume,
onRemoveVolumeAutomation,
}: {
projectDir: string | null;
element: DomEditSelection;
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
/** A volume lane in the timeline drives the level; the slider cannot. */
volumeAutomated?: boolean;
onAutomateVolume?: () => void;
onRemoveVolumeAutomation?: () => void;
onRemoveBackground?: (
inputPath: string,
options: {
Expand Down Expand Up @@ -197,15 +205,35 @@ export function FlatMediaSection({
)}
{(isVideo || isAudio) && (
<>
<FlatSlider
label="Volume"
value={volumePercent}
min={0}
max={100}
tier={volumePercent === 100 ? "default" : "explicitCustom"}
displayValue={`${volumePercent}%`}
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
/>
{/* The slider is disabled while a lane owns the level: a value set
here would be overwritten by the envelope on the next tick. The
toggle beside it carries the tooltip. */}
<div
className="hf-volume-row flex items-center gap-1"
data-volume-automated={volumeAutomated ? "" : undefined}
>
<div className="min-w-0 flex-1">
<FlatSlider
label="Volume"
value={volumePercent}
min={0}
max={100}
tier={volumePercent === 100 ? "default" : "explicitCustom"}
displayValue={`${volumePercent}%`}
disabled={volumeAutomated}
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
/>
</div>
<AutomationToggle
paramKey="volume"
label="Volume"
automated={Boolean(volumeAutomated)}
onAutomate={onAutomateVolume ? () => onAutomateVolume() : undefined}
onRemoveAutomation={
onRemoveVolumeAutomation ? () => onRemoveVolumeAutomation() : undefined
}
/>
</div>
<FlatSlider
label="Rate"
value={playbackRate * 100}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type PropertyPanelFlatProps = Pick<
| "onSetAttribute"
| "onSetAttributes"
| "onSetAttributeLive"
| "onSetAttributeQuiet"
| "onApplyColorGradingScope"
| "onSetHtmlAttribute"
| "onRemoveBackground"
Expand Down
130 changes: 114 additions & 16 deletions packages/studio/src/components/editor/propertyPanelFxControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { useCallback, useEffect, useRef, useState } from "react";
import { Tooltip } from "../ui/Tooltip";
import type {
HfAudioFxDef,
HfAudioFxNumberParam,
Expand Down Expand Up @@ -46,9 +47,66 @@ interface ParamRowProps {
/** Fires once when the gesture ends — this is the write that persists. */
onCommit?(key: string, value: number | string): void;
disabled?: boolean;
/**
* A lane in the timeline drives this parameter. The control is disabled
* because a value typed here would be overwritten by the envelope on the next
* tick — the lane is the value now.
*/
automated?: boolean;
/** Add a lane for this parameter, seeded at its current value. */
onAutomate?(key: string): void;
/** Delete this parameter's lane, handing the value back to the control. */
onRemoveAutomation?(key: string): void;
}

export function FxParamRow({ param, value, onChange, onCommit, disabled }: ParamRowProps) {
/**
* The automation toggle for one parameter: adds a lane, or deletes the one that
* already owns the value. Absent for parameters no envelope can drive — a
* WaveShaper curve, a convolution impulse, or a worklet's options.
*/
export function AutomationToggle({
paramKey,
label,
automated,
onAutomate,
onRemoveAutomation,
}: {
paramKey: string;
label: string;
automated: boolean;
onAutomate?(key: string): void;
onRemoveAutomation?(key: string): void;
}) {
if (!onAutomate && !onRemoveAutomation) return null;
return (
<Tooltip label={automated ? "Automated" : "Automate"}>
<button
type="button"
className={`hf-fx-automate w-[16px] flex-shrink-0 rounded-[3px] border font-mono text-[9px] leading-none ${
automated
? "border-panel-accent text-panel-accent"
: "border-panel-border-input text-panel-text-4 hover:text-panel-text-0"
}`}
aria-pressed={automated}
aria-label={automated ? `Remove ${label} automation` : `Automate ${label}`}
onClick={() => (automated ? onRemoveAutomation?.(paramKey) : onAutomate?.(paramKey))}
>
A
</button>
</Tooltip>
);
}

export function FxParamRow({
param,
value,
onChange,
onCommit,
disabled,
automated,
onAutomate,
onRemoveAutomation,
}: ParamRowProps) {
// While dragging, the slider is driven locally. Waiting for the value to come
// back through the element attribute makes the control feel laggy and fights
// the pointer.
Expand Down Expand Up @@ -105,9 +163,19 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
const numeric = typeof shown === "number" ? shown : Number(shown);
const current = Number.isFinite(numeric) ? numeric : param.default;

const locked = Boolean(disabled) || Boolean(automated);

return (
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-4">
<label
className={`hf-fx-row flex min-h-6 items-center gap-2${automated ? " hf-fx-row-automated" : ""}`}
title={param.hint}
data-automated={automated ? "" : undefined}
>
<span
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
automated ? "text-panel-accent" : "text-panel-text-4"
}`}
>
{param.label}
</span>
<input
Expand All @@ -117,7 +185,7 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
max={param.max}
step={(param.max - param.min) / 1000}
value={toSlider(param, current)}
disabled={disabled}
disabled={locked}
aria-label={param.label}
onPointerDown={() => setDragging(true)}
onChange={(e) => handleNumber(fromSlider(param, Number(e.target.value)))}
Expand All @@ -132,7 +200,7 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
max={param.max}
step={param.step}
value={display(param, current)}
disabled={disabled}
disabled={locked}
onChange={(e) => {
const next = Number(e.target.value);
if (Number.isFinite(next)) handleNumber(next);
Expand All @@ -147,6 +215,13 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
{param.unit}
</span>
) : null}
<AutomationToggle
paramKey={param.key}
label={param.label}
automated={Boolean(automated)}
onAutomate={onAutomate}
onRemoveAutomation={onRemoveAutomation}
/>
</label>
);
}
Expand All @@ -157,10 +232,24 @@ interface FxParamsProps {
onChange(params: HfAudioFxParamValues): void;
onCommit?(params: HfAudioFxParamValues): void;
disabled?: boolean;
/** Parameter keys this effect currently has a lane for. */
automatedKeys?: ReadonlySet<string>;
/** Absent when the effect cannot be automated at all, or nothing can write. */
onAutomate?(key: string): void;
onRemoveAutomation?(key: string): void;
}

/** Every knob the effect declares, in registry order. */
export function FxParams({ def, params, onChange, onCommit, disabled }: FxParamsProps) {
export function FxParams({
def,
params,
onChange,
onCommit,
disabled,
automatedKeys,
onAutomate,
onRemoveAutomation,
}: FxParamsProps) {
const set = useCallback(
(key: string, value: number | string) => onChange({ ...params, [key]: value }),
[params, onChange],
Expand All @@ -171,16 +260,25 @@ export function FxParams({ def, params, onChange, onCommit, disabled }: FxParams
);
return (
<div className="hf-fx-params space-y-0.5 border-t border-panel-border-input px-1.5 py-1.5">
{def.params.map((p) => (
<FxParamRow
key={p.key}
param={p}
value={params[p.key] ?? p.default}
onChange={set}
onCommit={commit}
disabled={disabled}
/>
))}
{def.params.map((p) => {
// Only a parameter the registry marks automatable has an AudioParam
// behind it for an envelope to write to.
const canAutomate = p.kind === "number" && p.automatable === true;
const automated = automatedKeys?.has(p.key) ?? false;
return (
<FxParamRow
key={p.key}
param={p}
value={params[p.key] ?? p.default}
onChange={set}
onCommit={commit}
disabled={disabled}
automated={automated}
onAutomate={canAutomate && !automated ? onAutomate : undefined}
onRemoveAutomation={canAutomate && automated ? onRemoveAutomation : undefined}
/>
);
})}
</div>
);
}
5 changes: 5 additions & 0 deletions packages/studio/src/components/editor/propertyPanelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface PropertyPanelProps {
value: string | null,
onSettled?: (ok: boolean) => void,
) => void | Promise<void>;
/** Persists without reloading the preview, but re-reads the selection after —
* for attributes the runtime applies to the live graph itself, where a reload
* would only interrupt playback, and where the panel still has to see the
* value it just wrote to compute the next edit from. */
onSetAttributeQuiet?: (attr: string, value: string | null) => void | Promise<void>;
onApplyColorGradingScope?: (
scope: "source-file" | "project",
value: string | null,
Expand Down
Loading
Loading