diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index 2eadfef597..f6363f5426 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -88,6 +88,8 @@ export interface AutomationRange { unit: string; label: string; scale: "linear" | "log"; + /** Where an empty lane draws its flat line, and what a new point starts at. */ + default: number; } export const VOLUME_RANGE: AutomationRange = { @@ -97,6 +99,7 @@ export const VOLUME_RANGE: AutomationRange = { unit: "", label: "Volume", scale: "linear", + default: 1, }; /** @@ -124,6 +127,7 @@ export function resolveAutomationRange( unit: p.unit, label: `${def?.label ?? node.type} · ${p.label}`, scale: p.scale === "log" && p.min > 0 ? "log" : "linear", + default: p.default, }; } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 68088311f3..8ee3b9c88d 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -124,6 +124,7 @@ export function StudioRightPanel({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit, @@ -361,6 +362,7 @@ export function StudioRightPanel({ onSetAttribute={handleDomAttributeCommit} onSetAttributes={handleDomAttributesCommit} onSetAttributeLive={handleDomAttributeLiveCommit} + onSetAttributeQuiet={handleDomAttributeQuietCommit} onApplyColorGradingScope={handleApplyColorGradingScope} onSetHtmlAttribute={handleDomHtmlAttributeCommit} onRemoveBackground={handleRemoveBackground} diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index dc3f4f7b3f..0b3d963e62 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -13,22 +13,11 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; -import { - HF_AUDIO_FX_ATTR, - parseAudioFxChain, - serializeAudioFxChain, - type HfAudioFxChain, -} from "@hyperframes/core/audio-fx"; -import { - analyseCarveBands, - carveBandsToChain, - HF_AUDIO_CARVE_ATTR, - normalizeCarveSettings, - type HfCarveSettings, -} from "@hyperframes/core/audio-carve"; +import { parseAudioFxChain } from "@hyperframes/core/audio-fx"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import { useVolumeAutomation } from "./useVolumeAutomation"; import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import type { DomEditSelection } from "./domEditing"; -import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview } from "./propertyPanelSections"; @@ -79,6 +68,7 @@ export function PropertyPanelFlat({ onSetAttribute, onSetAttributes, onSetAttributeLive, + onSetAttributeQuiet, onApplyColorGradingScope, onSetHtmlAttribute, onRemoveBackground, @@ -267,6 +257,8 @@ export function PropertyPanelFlat({ const showMotionEffects = gsapEffectHandlers !== null; const showMotionGroup = showMotionTiming || showMotionEffects; + const volumeAutomation = useVolumeAutomation(element, onSetAttribute); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -443,8 +435,7 @@ export function PropertyPanelFlat({ content: ( ), }); @@ -463,6 +454,7 @@ export function PropertyPanelFlat({ onSetAttribute={onSetAttribute} onSetHtmlAttribute={onSetHtmlAttribute} onRemoveBackground={onRemoveBackground} + {...volumeAutomation} /> ), }); @@ -561,112 +553,3 @@ function audioFxSummary(element: DomEditSelection): string { if (carve) parts.push("carve"); return parts.length > 0 ? parts.join(" + ") : "none"; } - -/** - * Bridges the FX panel to the element/attribute world. Chain and carve are - * serialised onto the element the way colour grading carries its config, so - * persistence is an ordinary attribute write with no new server route. - */ -function AudioFxGroup({ - element, - onSetAttribute, - onSetAttributeLive, -}: { - element: DomEditSelection; - onSetAttribute: (attr: string, value: string) => void | Promise; - onSetAttributeLive: (attr: string, value: string | null) => void | Promise; -}) { - const chain = ((): HfAudioFxChain => { - const raw = element.dataAttributes?.["fx-chain"]; - if (!raw) return { version: 1, nodes: [] }; - try { - return parseAudioFxChain(raw); - } catch { - // Show an unreadable chain as empty rather than breaking the panel; the - // attribute is left untouched until the user changes something. - return { version: 1, nodes: [] }; - } - })(); - - const carve = ((): HfCarveSettings | null => { - const raw = element.dataAttributes?.["fx-carve"]; - if (!raw) return null; - try { - return normalizeCarveSettings(JSON.parse(raw)); - } catch { - return null; - } - })(); - - const sourceOptions: AudioTrackOption[] = (() => { - const doc = element.element?.ownerDocument; - if (!doc) return []; - return Array.from(doc.querySelectorAll("audio[id]")) - .filter((a) => a.id !== element.id) - .map((a) => ({ id: a.id, label: a.id })); - })(); - - const [analysing, setAnalysing] = useState(false); - - /** - * Decodes the chosen voice track and turns its spectrum into peaking filters - * on this one. The bands replace any previous carve output but leave - * hand-added effects alone, so re-analysing does not discard other work. - */ - const analyse = async (): Promise => { - if (!carve?.source) return; - const doc = element.element?.ownerDocument; - const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; - const src = voice?.getAttribute("src"); - if (!src) return; - setAnalysing(true); - try { - const res = await fetch(new URL(src, doc!.baseURI).href); - const bytes = await res.arrayBuffer(); - const Ctor = - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctor) return; - const ctx = new Ctor(); - try { - const buffer = await ctx.decodeAudioData(bytes); - const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); - const carved = carveBandsToChain(bands); - // Carve output is tagged so a re-run replaces it instead of stacking. - const kept = chain.nodes.filter((n) => !n.fromCarve); - const next = { - version: 1, - nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], - }; - onSetAttribute(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); - } finally { - void ctx.close().catch(() => undefined); - } - } catch { - // Leave the chain as it was; the button simply re-enables. - } finally { - setAnalysing(false); - } - }; - - return ( - - onSetAttribute(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : "") - } - onChainPreview={(next) => - // Live writes skip the preview refresh, so dragging a knob no longer - // reloads the composition and restarts playback on every pixel. - onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null) - } - carve={carve} - onCarveChange={(next) => - onSetAttribute(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : "") - } - sourceOptions={sourceOptions} - onAnalyseCarve={() => void analyse()} - analysing={analysing} - /> - ); -} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx new file mode 100644 index 0000000000..974215bf8a --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CHAIN = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 900, q: 1.2, poles: "2" } }], +}); + +function mount(dataAttributes: Record) { + // Every write is quiet: persisted without the preview reload that would + // restart every playing track, but with a selection resync so the panel sees + // what it just wrote. + const onSetAttributeQuiet = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const selection = { dataAttributes, element: null } as unknown as DomEditSelection; + act(() => { + createRoot(host).render( + , + ); + }); + return { host, onSetAttributeQuiet }; +} + +const rowFor = (host: HTMLElement, label: string): HTMLElement | null => { + for (const row of Array.from(host.querySelectorAll(".hf-fx-row"))) { + if (row.querySelector(".hf-fx-label")?.textContent === label) return row; + } + return null; +}; + +const parseWrite = (call: unknown[]) => JSON.parse(String(call[1])); + +describe("AudioFxGroup automation", () => { + it("renders the chain's parameters", () => { + const { host } = mount({ "fx-chain": CHAIN }); + expect(rowFor(host, "Cutoff")).toBeTruthy(); + expect(rowFor(host, "Q")).toBeTruthy(); + }); + + it("seeds a new lane at the value the control already holds", () => { + // Switching to an envelope must not change the sound — only where the value + // comes from. The chain has frequency at 900, not the registry default. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + act(() => button.click()); + const [attr, value] = onSetAttributeQuiet.mock.calls[0]; + expect(attr).toBe("data-automation"); + expect(JSON.parse(String(value))).toEqual({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 900 }] }], + }); + }); + + it("keeps lanes it is not touching when adding one", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }], + }), + }); + act(() => (rowFor(host, "Q")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click()); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume", "fx.n1.q"]); + }); + + it("disables a control the timeline already drives", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + const cutoff = rowFor(host, "Cutoff")!; + expect(cutoff.querySelector('input[type="range"]')?.disabled).toBe(true); + expect(cutoff.hasAttribute("data-automated")).toBe(true); + expect( + rowFor(host, "Q")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); + + it("deletes just that lane, handing the value back to the control", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [ + { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }, + { target: "volume", points: [{ t: 0, v: 0.5 }] }, + ], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume"]); + }); + + it("clears the attribute when the last lane goes", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + // Null rather than "": the live path removes an attribute it is given null for. + expect(onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("ignores a lane for an effect that is no longer in the chain", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.gone.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + // Nothing is automated, so every control stays live. + expect( + rowFor(host, "Cutoff")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); +}); + +describe("AudioFxGroup carve", () => { + const carvedChain = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + ], + }); + + const carveOn = JSON.stringify({ + source: "vo", + maxCutDb: 6, + bands: 3, + q: 1.4, + intelligibilityBias: 0.7, + }); + + const carveToggle = (host: HTMLElement): HTMLButtonElement => { + const block = host.querySelector(".hf-fx-carve")!; + return block.querySelector(".hf-fx-bypass") as HTMLButtonElement; + }; + + it("removes the filters it generated when carve is switched off", () => { + // Leaving them behind would keep dipping the bed with no carve to explain it. + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": carvedChain, + "fx-carve": carveOn, + }); + act(() => carveToggle(host).click()); + const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain"); + expect(chainWrite).toBeTruthy(); + const kept = JSON.parse(String(chainWrite![1])).nodes; + expect(kept.map((n: { type: string }) => n.type)).toEqual(["lowpass"]); + // And the carve settings themselves go. + expect(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve")?.[1]).toBeNull(); + }); + + it("leaves a hand-built chain alone when carve is switched off", () => { + const handBuilt = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }], + }); + const { host, onSetAttributeQuiet } = mount({ "fx-chain": handBuilt, "fx-carve": carveOn }); + act(() => carveToggle(host).click()); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false); + }); + + it("writes carve settings live, so enabling it does not reload the preview", () => { + const { host, onSetAttributeQuiet } = mount({ "fx-chain": carvedChain }); + act(() => carveToggle(host).click()); + const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve"); + expect(write).toBeTruthy(); + expect(JSON.parse(String(write![1])).bands).toBeGreaterThan(0); + }); +}); + +describe("AudioFxGroup successive edits", () => { + const three = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + { type: "delay", id: "n3", params: { time: 250, feedback: 0.3, mix: 0.4 } }, + ], + }); + + const removeButtons = (host: HTMLElement) => + Array.from(host.querySelectorAll(".hf-fx-remove")); + + /** + * The panel computes each edit from the attribute it is holding. Written + * without a selection resync, the second delete worked from the pre-delete + * chain and wrote the same result — so after deleting one effect, no further + * delete did anything. + */ + it("deletes a second effect after the first, not from a stale chain", () => { + const first = mount({ "fx-chain": three }); + act(() => removeButtons(first.host)[0]!.click()); + const afterFirst = JSON.parse(String(first.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterFirst.nodes.map((n: { id: string }) => n.id)).toEqual(["n2", "n3"]); + + // The resync hands the panel what it just wrote; the next delete starts there. + const second = mount({ "fx-chain": JSON.stringify(afterFirst) }); + act(() => removeButtons(second.host)[0]!.click()); + const afterSecond = JSON.parse(String(second.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterSecond.nodes.map((n: { id: string }) => n.id)).toEqual(["n3"]); + + const third = mount({ "fx-chain": JSON.stringify(afterSecond) }); + act(() => removeButtons(third.host)[0]!.click()); + // The last one leaves no chain at all. + expect(third.onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("writes with the commit that resyncs the selection, not the silent one", () => { + // Both skip the preview reload; only this one re-reads the selection, which + // is what makes a following edit see the current value. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": three }); + act(() => removeButtons(host)[0]!.click()); + expect(onSetAttributeQuiet).toHaveBeenCalledTimes(1); + expect(onSetAttributeQuiet.mock.calls[0][0]).toBe("data-fx-chain"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx new file mode 100644 index 0000000000..ac34e39c95 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -0,0 +1,218 @@ +/** + * The audio FX panel's bridge to the element/attribute world. + * + * Chain, carve and automation are all serialised onto the element the way colour + * grading carries its config, so persistence is an ordinary attribute write with + * no new server route. Split out of PropertyPanelFlat, which is at its size + * budget, and self-contained enough to test on its own. + */ + +import { useState } from "react"; +import { + HF_AUDIO_FX_ATTR, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "@hyperframes/core/audio-fx"; +import { + analyseCarveBands, + carveBandsToChain, + HF_AUDIO_CARVE_ATTR, + normalizeCarveSettings, + type HfCarveSettings, +} from "@hyperframes/core/audio-carve"; +import { fxAutomationTarget, type HfAutomation } from "@hyperframes/core/audio-automation"; +import { + automatedTargetsOf, + automationAttrValue, + HF_AUDIO_AUTOMATION_ATTR, + readPanelAutomation, + resolveAutomationRange, + withoutLane, + withSeededLane, +} from "./propertyPanelAutomation"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Rate the carve source is decoded at. Analysis is self-consistent because it + * reads the decoded buffer's own rate, so this only has to be a sane audio rate. + */ +const DECODE_SAMPLE_RATE = 48000; +import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js"; + +/** + * Bridges the FX panel to the element/attribute world. Chain and carve are + * serialised onto the element the way colour grading carries its config, so + * persistence is an ordinary attribute write with no new server route. + */ +export function AudioFxGroup({ + element, + onSetAttributeQuiet, +}: { + element: DomEditSelection; + /** + * Every write here is quiet: it persists to the source and skips the preview + * reload, because the runtime applies chain and automation edits to the + * running graph — a reload would only interrupt the audio to reach the same + * state, which is heard as the track chopping. + * + * It does re-read the selection afterwards, which this panel depends on: each + * edit is computed from the current attribute, so without the resync a second + * edit would work from a pre-edit value and appear to do nothing. + */ + onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise; +}) { + const chain = ((): HfAudioFxChain => { + const raw = element.dataAttributes?.["fx-chain"]; + if (!raw) return { version: 1, nodes: [] }; + try { + return parseAudioFxChain(raw); + } catch { + // Show an unreadable chain as empty rather than breaking the panel; the + // attribute is left untouched until the user changes something. + return { version: 1, nodes: [] }; + } + })(); + + const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain); + const automatedTargets = automatedTargetsOf(automation); + + // Written through the live path on purpose. It persists to the source just + // like the refreshing one, but skips the preview reload — and a reload + // restarts every playing track, which is heard as the audio chopping. The + // runtime follows the attribute and swaps the graph in place instead. + const writeAutomation = (next: HfAutomation): void => { + void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null); + }; + + /** + * Start automating one effect parameter. + * + * The lane is seeded with a single point at the value the control currently + * holds, so switching to an envelope never changes the sound — it only moves + * where the value comes from. The author then adds points in the timeline. + */ + const automateParam = (nodeId: string, paramKey: string): void => { + const target = fxAutomationTarget(nodeId, paramKey); + const node = chain.nodes.find((n) => n.id === nodeId); + const range = resolveAutomationRange(target, chain); + if (!node || !range) return; + const raw = node.params?.[paramKey]; + writeAutomation( + withSeededLane(automation, target, typeof raw === "number" ? raw : range.default), + ); + }; + + /** Stop automating it, handing the value back to the panel control. */ + const removeParamAutomation = (nodeId: string, paramKey: string): void => { + writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey))); + }; + + const carve = ((): HfCarveSettings | null => { + const raw = element.dataAttributes?.["fx-carve"]; + if (!raw) return null; + try { + return normalizeCarveSettings(JSON.parse(raw)); + } catch { + return null; + } + })(); + + const sourceOptions: AudioTrackOption[] = (() => { + const doc = element.element?.ownerDocument; + if (!doc) return []; + return Array.from(doc.querySelectorAll("audio[id]")) + .filter((a) => a.id !== element.id) + .map((a) => ({ id: a.id, label: a.id })); + })(); + + const [analysing, setAnalysing] = useState(false); + + /** + * Decodes the chosen voice track and turns its spectrum into peaking filters + * on this one. The bands replace any previous carve output but leave + * hand-added effects alone, so re-analysing does not discard other work. + */ + const analyse = async (): Promise => { + if (!carve?.source) return; + const doc = element.element?.ownerDocument; + const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; + const src = voice?.getAttribute("src"); + if (!src) return; + setAnalysing(true); + try { + const res = await fetch(new URL(src, doc!.baseURI).href); + const bytes = await res.arrayBuffer(); + // Decoded in an OfflineAudioContext, not a live one. Opening a second + // output device mid-playback makes the running track glitch while the + // hardware is reconfigured; an offline context touches no device. + const Ctor = + window.OfflineAudioContext ?? + (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!Ctor) return; + const decoder = new Ctor(1, 1, DECODE_SAMPLE_RATE); + const buffer = await decoder.decodeAudioData(bytes); + const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); + const carved = carveBandsToChain(bands); + // Carve output is tagged so a re-run replaces it instead of stacking. + const kept = chain.nodes.filter((n) => !n.fromCarve); + const next = { + version: 1, + nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], + }; + // Live, like every other chain write: the runtime swaps the graph in + // place, so a reload would only interrupt the audio to reach the same + // filters. + onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); + } catch { + // Leave the chain as it was; the button simply re-enables. + } finally { + setAnalysing(false); + } + }; + + return ( + + // Live for the same reason as automation above: adding, removing or + // bypassing an effect is applied to the running graph, so a reload would + // only interrupt the audio to reach the same state. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + onChainPreview={(next) => + // Live writes skip the preview refresh, so dragging a knob no longer + // reloads the composition and restarts playback on every pixel. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + carve={carve} + onCarveChange={(next) => { + // Turning carve off drops the filters it generated; leaving them behind + // would keep dipping the bed with no carve to explain it. + if (!next) { + const kept = chain.nodes.filter((n) => !n.fromCarve); + if (kept.length !== chain.nodes.length) { + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null, + ); + } + } + void onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null); + }} + sourceOptions={sourceOptions} + onAnalyseCarve={() => void analyse()} + analysing={analysing} + /> + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts new file mode 100644 index 0000000000..d940eedfab --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -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 { + 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 }; diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 1850a48905..997483e136 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -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({ @@ -22,6 +23,9 @@ export function FlatMediaSection({ onSetAttribute, onSetHtmlAttribute, onRemoveBackground, + volumeAutomated, + onAutomateVolume, + onRemoveVolumeAutomation, }: { projectDir: string | null; element: DomEditSelection; @@ -29,6 +33,10 @@ export function FlatMediaSection({ onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + /** A volume lane in the timeline drives the level; the slider cannot. */ + volumeAutomated?: boolean; + onAutomateVolume?: () => void; + onRemoveVolumeAutomation?: () => void; onRemoveBackground?: ( inputPath: string, options: { @@ -197,15 +205,35 @@ export function FlatMediaSection({ )} {(isVideo || isAudio) && ( <> - 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. */} +
+
+ void onSetAttribute("volume", formatNumericValue(next / 100))} + /> +
+ onAutomateVolume() : undefined} + onRemoveAutomation={ + onRemoveVolumeAutomation ? () => onRemoveVolumeAutomation() : undefined + } + /> +
+ + + ); +} + +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. @@ -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 ( - ); } @@ -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; + /** 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], @@ -171,16 +260,25 @@ export function FxParams({ def, params, onChange, onCommit, disabled }: FxParams ); return (
- {def.params.map((p) => ( - - ))} + {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 ( + + ); + })}
); } diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index b77cae9201..f278ec82ec 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -45,6 +45,9 @@ function mount(overrides: Partial[0]> = {}) { onAnalyseCarve={overrides.onAnalyseCarve ?? noop} analysing={overrides.analysing} disabled={overrides.disabled} + automatedTargets={overrides.automatedTargets} + onAutomateParam={overrides.onAutomateParam} + onRemoveParamAutomation={overrides.onRemoveParamAutomation} />, ); return { host, onChainChange, onChainPreview, onCarveChange }; @@ -246,3 +249,132 @@ describe("FxSection carve", () => { } }); }); + +describe("automation in the panel", () => { + const automatable = (chain: HfAudioFxChain, over = {}) => + mount({ + chain, + automatedTargets: new Set(), + onAutomateParam: vi.fn(), + onRemoveParamAutomation: vi.fn(), + ...over, + }); + + const idChain = (type: string, id = "n1"): HfAudioFxChain => ({ + version: 1, + nodes: [{ type, id, enabled: true, params: defaultAudioFxParams(type) }], + }); + + const rowFor = (host: HTMLElement, label: string): HTMLElement | null => { + for (const row of Array.from(host.querySelectorAll(".hf-fx-row"))) { + if (row.querySelector(".hf-fx-label")?.textContent === label) return row; + } + return null; + }; + + it("offers an automate button only for parameters an envelope can drive", () => { + // Saturate: `output` is a make-up gain, but the curve's type and threshold + // are rebuilt wholesale and cannot be scheduled. + const { host } = automatable(idChain("saturate")); + expect(rowFor(host, "Output")?.querySelector(".hf-fx-automate")).toBeTruthy(); + expect(rowFor(host, "Threshold")?.querySelector(".hf-fx-automate")).toBeNull(); + }); + + it("offers nothing for a worklet effect, which exposes no AudioParams", () => { + const { host } = automatable(idChain("compressor")); + expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0); + }); + + it("asks to automate a parameter by node id and key", () => { + const onAutomateParam = vi.fn(); + const { host } = automatable(idChain("lowpass", "n7"), { onAutomateParam }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + expect(button.hasAttribute("title")).toBe(false); + act(() => button.click()); + expect(onAutomateParam).toHaveBeenCalledWith("n7", "frequency"); + }); + + it("disables an automated control, since a value typed here would be overwritten", () => { + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + }); + const row = rowFor(host, "Cutoff")!; + expect(row.querySelector('input[type="range"]')?.disabled).toBe(true); + expect(row.querySelector('input[type="number"]')?.disabled).toBe(true); + expect(row.hasAttribute("data-automated")).toBe(true); + // A sibling parameter on the same effect stays editable. + const q = rowFor(host, "Q")!; + expect(q.querySelector('input[type="range"]')?.disabled).toBe(false); + }); + + it("turns the automated parameter's button into a delete", () => { + const onRemoveParamAutomation = vi.fn(); + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + onRemoveParamAutomation, + }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + expect(button.getAttribute("aria-pressed")).toBe("true"); + expect(button.getAttribute("aria-label")).toMatch(/remove/i); + // The wording lives in the Tooltip component, which only renders its bubble + // on hover; the button itself carries no native title hover. + expect(button.hasAttribute("title")).toBe(false); + act(() => button.click()); + expect(onRemoveParamAutomation).toHaveBeenCalledWith("n1", "frequency"); + }); + + it("shows the wording in a tooltip bubble, not a native browser hover", async () => { + vi.useFakeTimers(); + try { + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + // Tooltip positions itself from the trigger's box and gives up on a 0x0 + // one, which is every element in happy-dom. + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 300, + left: 100, + top: 300, + right: 116, + bottom: 316, + width: 16, + height: 16, + toJSON: () => ({}), + } as DOMRect); + // React synthesises pointer-enter from pointerover and delegates focus via + // focusin; focus is also how a keyboard user reaches the same tooltip. + act(() => { + button.dispatchEvent(new Event("focusin", { bubbles: true })); + }); + act(() => { + vi.advanceTimersByTime(600); + }); + const bubble = document.querySelector('[role="tooltip"]'); + expect(bubble?.textContent).toBe("Automated"); + } finally { + vi.useRealTimers(); + } + }); + + it("cannot automate a node with no id, which a lane could not address", () => { + const { host } = automatable({ + version: 1, + nodes: [{ type: "lowpass", enabled: true, params: defaultAudioFxParams("lowpass") }], + }); + expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0); + }); + + it("gives a newly added effect an id, so its parameters can be automated", () => { + const onChainChange = vi.fn(); + const { host } = mount({ chain: { version: 1, nodes: [] }, onChainChange }); + const add = host.querySelector(".hf-fx-add") as HTMLButtonElement; + act(() => add.click()); + const item = Array.from(host.querySelectorAll(".hf-fx-add-item")).find( + (b) => b.textContent === "Low-pass", + )!; + act(() => item.click()); + expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 16e60ea5af..d7c97946cb 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -13,12 +13,15 @@ import { defaultAudioFxParams, getAudioFxDef, HF_AUDIO_FX, + mintAudioFxNodeId, type HfAudioFxChain, + type HfAudioFxDef, type HfAudioFxGroup, type HfAudioFxNode, type HfAudioFxParamValues, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; +import { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParams, FxParamRow } from "./propertyPanelFxControls.js"; const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"]; @@ -37,6 +40,9 @@ export interface AudioTrackOption { interface FxNodeRowProps { node: HfAudioFxNode; index: number; + automatedTargets?: ReadonlySet; + onAutomateParam?(nodeId: string, paramKey: string): void; + onRemoveParamAutomation?(nodeId: string, paramKey: string): void; open: boolean; /** Last in the chain, so it cannot move further down. */ last: boolean; @@ -142,10 +148,73 @@ function FxNodeHeader({ ); } +/** + * Which of an effect's knobs already have a lane. + * + * A lane addresses a node by id, so a node the panel has not yet given one + * cannot be automated at all. Adding an effect mints the id, so this only + * affects chains written before ids existed. + */ +function automatedKeysOf( + node: HfAudioFxNode, + params: readonly { key: string }[], + automatedTargets: ReadonlySet | undefined, +): Set { + if (!node.id || !automatedTargets) return new Set(); + const nodeId = node.id; + return new Set( + params.filter((p) => automatedTargets.has(fxAutomationTarget(nodeId, p.key))).map((p) => p.key), + ); +} + +/** An open effect's knobs, with whatever automation surface applies to them. */ +function FxNodeParams({ + node, + def, + index, + disabled, + automatedTargets, + onUpdate, + onPreview, + onAutomateParam, + onRemoveParamAutomation, +}: { + node: HfAudioFxNode; + def: HfAudioFxDef; + index: number; + disabled: boolean; + automatedTargets?: ReadonlySet; + onUpdate(index: number, patch: Partial): void; + onPreview(index: number, params: HfAudioFxParamValues): void; + onAutomateParam?(nodeId: string, paramKey: string): void; + onRemoveParamAutomation?(nodeId: string, paramKey: string): void; +}) { + const nodeId = node.id; + return ( + onPreview(index, params)} + onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })} + automatedKeys={automatedKeysOf(node, def.params, automatedTargets)} + onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined} + onRemoveAutomation={ + nodeId && onRemoveParamAutomation + ? (key) => onRemoveParamAutomation(nodeId, key) + : undefined + } + /> + ); +} + /** One effect in the chain: its header controls, and its knobs when open. */ function FxNodeRow({ node, index, + automatedTargets, + onAutomateParam, + onRemoveParamAutomation, open, last, disabled, @@ -176,12 +245,16 @@ function FxNodeRow({ onRemove={() => onRemove(index)} /> {open ? ( - onPreview(index, params)} - onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })} + index={index} + disabled={Boolean(disabled) || bypassed} + automatedTargets={automatedTargets} + onUpdate={onUpdate} + onPreview={onPreview} + onAutomateParam={onAutomateParam} + onRemoveParamAutomation={onRemoveParamAutomation} /> ) : null} @@ -190,6 +263,12 @@ function FxNodeRow({ export interface FxSectionProps { chain: HfAudioFxChain; + /** Targets this track already automates, as `fx..` strings. */ + automatedTargets?: ReadonlySet; + /** Add a lane for one effect parameter, seeded at its current value. */ + onAutomateParam?(nodeId: string, paramKey: string): void; + /** Delete one effect parameter's lane. */ + onRemoveParamAutomation?(nodeId: string, paramKey: string): void; /** Structural edits and gesture-end writes; this is the one that persists. */ onChainChange(chain: HfAudioFxChain): void; /** Continuous updates while a control is being dragged. */ @@ -206,6 +285,9 @@ export interface FxSectionProps { export function FxSection({ chain, + automatedTargets, + onAutomateParam, + onRemoveParamAutomation, onChainChange, onChainPreview, carve, @@ -240,11 +322,14 @@ export function FxSection({ const addEffect = useCallback( (type: string) => { - mutate([...chain.nodes, { type, enabled: true, params: defaultAudioFxParams(type) }]); + mutate([ + ...chain.nodes, + { type, id: mintAudioFxNodeId(chain), enabled: true, params: defaultAudioFxParams(type) }, + ]); setOpenNode(chain.nodes.length); setAdding(false); }, - [chain.nodes, mutate], + [chain, mutate], ); const updateNode = useCallback( @@ -287,6 +372,9 @@ export function FxSection({ key={`${node.type}-${i}`} node={node} index={i} + automatedTargets={automatedTargets} + onAutomateParam={onAutomateParam} + onRemoveParamAutomation={onRemoveParamAutomation} open={openNode === i} last={i === chain.nodes.length - 1} disabled={disabled} diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index 935ceb3f67..2756d0d00a 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -58,6 +58,11 @@ export interface PropertyPanelProps { value: string | null, onSettled?: (ok: boolean) => void, ) => void | Promise; + /** 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; onApplyColorGradingScope?: ( scope: "source-file" | "project", value: string | null, diff --git a/packages/studio/src/components/editor/useVolumeAutomation.test.tsx b/packages/studio/src/components/editor/useVolumeAutomation.test.tsx new file mode 100644 index 0000000000..580bdbc25f --- /dev/null +++ b/packages/studio/src/components/editor/useVolumeAutomation.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { useVolumeAutomation, type VolumeAutomationBinding } from "./useVolumeAutomation"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function bind(dataAttributes: Record) { + const onSetAttribute = vi.fn(); + const captured: { current: VolumeAutomationBinding | null } = { current: null }; + function Probe() { + captured.current = useVolumeAutomation( + { dataAttributes } as unknown as DomEditSelection, + onSetAttribute, + ); + return null; + } + const host = document.createElement("div"); + document.body.append(host); + act(() => { + createRoot(host).render(); + }); + if (!captured.current) throw new Error("hook never ran"); + return { binding: captured.current, onSetAttribute }; +} + +const volumeLane = (v: number) => + JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] }); + +describe("useVolumeAutomation", () => { + it("reports an unautomated track", () => { + expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false); + }); + + it("reports a track with a volume lane", () => { + expect(bind({ volume: "0.55", automation: volumeLane(0.2) }).binding.volumeAutomated).toBe( + true, + ); + }); + + it("does not count an FX lane as automating the volume", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }); + expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false); + }); + + it("seeds a new lane at the level the slider already shows", () => { + // Automating a track must not change how loud it is. + const { binding, onSetAttribute } = bind({ volume: "0.55" }); + act(() => binding.onAutomateVolume()); + expect(onSetAttribute).toHaveBeenCalledWith( + "data-automation", + JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }), + ); + }); + + it("treats a missing data-volume as unity", () => { + const { binding, onSetAttribute } = bind({}); + act(() => binding.onAutomateVolume()); + expect(JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes[0].points[0].v).toBe(1); + }); + + it("keeps FX lanes when adding the volume one", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }); + const { binding, onSetAttribute } = bind({ volume: "0.4", automation }); + act(() => binding.onAutomateVolume()); + expect( + JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes.map( + (l: { target: string }) => l.target, + ), + ).toEqual(["fx.n1.frequency", "volume"]); + }); + + it("deletes only the volume lane", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [ + { target: "volume", points: [{ t: 0, v: 0.2 }] }, + { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }, + ], + }); + const { binding, onSetAttribute } = bind({ volume: "0.4", automation }); + act(() => binding.onRemoveVolumeAutomation()); + expect( + JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes.map( + (l: { target: string }) => l.target, + ), + ).toEqual(["fx.n1.frequency"]); + }); + + it("clears the attribute when the volume lane was the only one", () => { + const { binding, onSetAttribute } = bind({ volume: "0.4", automation: volumeLane(0.2) }); + act(() => binding.onRemoveVolumeAutomation()); + expect(onSetAttribute).toHaveBeenCalledWith("data-automation", ""); + }); + + it("reads an unreadable attribute as no automation", () => { + expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false); + }); +}); diff --git a/packages/studio/src/components/editor/useVolumeAutomation.ts b/packages/studio/src/components/editor/useVolumeAutomation.ts new file mode 100644 index 0000000000..477d98a0b9 --- /dev/null +++ b/packages/studio/src/components/editor/useVolumeAutomation.ts @@ -0,0 +1,44 @@ +/** + * The volume lane's state and edits for the media section. + * + * Volume lives in a different panel section from the FX chain, but is automated + * the same way, so it reads and writes through the same helper the FX group uses + * rather than a second interpretation of the attribute. + */ + +import { VOLUME_TARGET } from "@hyperframes/core/audio-automation"; +import type { DomEditSelection } from "./domEditingTypes"; +import { + automationAttrValue, + HF_AUDIO_AUTOMATION_ATTR, + readPanelAutomation, + withoutLane, + withSeededLane, +} from "./propertyPanelAutomation"; + +export interface VolumeAutomationBinding { + volumeAutomated: boolean; + onAutomateVolume: () => void; + onRemoveVolumeAutomation: () => void; +} + +export function useVolumeAutomation( + element: DomEditSelection, + onSetAttribute: (attr: string, value: string) => void | Promise, +): VolumeAutomationBinding { + // The chain is not needed to resolve a volume lane — volume is always a valid + // target — so this deliberately does not parse it. + const automation = readPanelAutomation(element.dataAttributes?.["automation"], undefined); + const write = (next: Parameters[0]): void => { + void onSetAttribute(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next)); + }; + const current = Number(element.dataAttributes?.["volume"] ?? "1"); + return { + volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET), + // Seeded at the level the slider already shows, so automating the track does + // not change how loud it is. + onAutomateVolume: () => + write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)), + onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)), + }; +} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index f89e27e915..b12134fd84 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -15,6 +15,7 @@ export interface DomEditActionsValue extends Pick< | "handleDomStyleCommit" | "handleDomAttributeCommit" | "handleDomAttributeLiveCommit" + | "handleDomAttributeQuietCommit" | "handleDomHtmlAttributeCommit" | "handleDomAttributesCommit" | "handleDomPathOffsetCommit" @@ -139,6 +140,7 @@ export function DomEditProvider({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit, @@ -227,6 +229,7 @@ export function DomEditProvider({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit, @@ -296,6 +299,7 @@ export function DomEditProvider({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit, diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts index 0c3e7a39c7..d4e76c2cb8 100644 --- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts @@ -284,6 +284,27 @@ export function useDomEditAttributeCommits({ [commitDataAttribute], ); + /** + * Persist without reloading the preview, but re-read the selection afterwards. + * + * For attributes the runtime applies to the live graph itself — an audio FX + * chain, its automation — a reload would only interrupt playback to reach the + * state the preview already has. The resync is still needed: without it 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. + */ + const handleDomAttributeQuietCommit = useCallback( + async (attr: string, value: string | null) => { + await commitDataAttribute(attr, value, { + label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`, + coalescePrefix: "attr-quiet", + skipRefresh: true, + refreshAfter: true, + }); + }, + [commitDataAttribute], + ); + const handleDomHtmlAttributeCommit = useCallback( async (attr: string, value: string | null) => { if (!domEditSelection) return; @@ -343,6 +364,7 @@ export function useDomEditAttributeCommits({ return { handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, }; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7a02be735d..12192452f4 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -374,6 +374,7 @@ export function useDomEditCommits({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomTextCommit, @@ -437,6 +438,7 @@ export function useDomEditCommits({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomTextCommit, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index efec011f95..02ecbe4f78 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -222,6 +222,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomTextCommit, @@ -484,6 +485,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index 7111356279..dbfe9551db 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -148,6 +148,7 @@ export function useDomEditTextCommits({ const { handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, } = useDomEditAttributeCommits({ @@ -474,6 +475,7 @@ export function useDomEditTextCommits({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomTextCommit, diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx new file mode 100644 index 0000000000..ae1b9884bf --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -0,0 +1,471 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { TimelineAutomationLane, automationTargets } from "./TimelineAutomationLane"; +import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; +import { + resolveAutomationRange, + VOLUME_RANGE, + type HfAutomation, +} from "@hyperframes/core/audio-automation"; + +const chain: HfAudioFxChain = { + version: 1, + nodes: [ + { type: "lowpass", id: "n1", enabled: true, params: {} }, + // No id: the panel has not touched it, so nothing can address it. + { type: "peaking", enabled: true, params: {} }, + // Worklet-backed: no AudioParams to schedule. + { type: "compressor", id: "n3", enabled: true, params: {} }, + ], +}; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function renderRerenderable(node: React.ReactElement): { + container: HTMLElement; + rerender(next: React.ReactElement): void; +} { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { + container: host, + rerender: (next) => { + act(() => { + root.render(next); + }); + }, + }; +} + +function render(node: React.ReactElement): { container: HTMLElement } { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { container: host }; +} + +/** + * Mount inside a wrapper so propagation can be observed from a real ancestor. + * A listener on React's own root node is no test of it: two native listeners on + * one element both run regardless of stopPropagation. + */ +function renderNested(node: React.ReactElement): { + container: HTMLElement; + ancestor: HTMLElement; +} { + const ancestor = document.createElement("div"); + const host = document.createElement("div"); + ancestor.append(host); + document.body.append(ancestor); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { container: host, ancestor }; +} + +/** happy-dom has no pointer-event constructors wired to React's synthetic ones, + * so events are dispatched as plain typed events with the coordinates React + * reads off them. */ +function fire( + el: Element, + type: string, + init: { clientX?: number; clientY?: number; button?: number } = {}, +): void { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, { clientX: 0, clientY: 0, button: 0, pointerId: 1, ...init }); + act(() => { + el.dispatchEvent(event); + }); +} + +/** Slack the lane insets its drawing by, so an end point is not half clipped. */ +const PAD = 9; + +const EMPTY: HfAutomation = { version: 1, lanes: [] }; + +const ramp: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 4, v: 0 }, + ], + }, + ], +}; + +function laneProps(over: Partial[0]> = {}) { + const target = over.target ?? "volume"; + return { + duration: 4, + widthPx: 400, + leftPx: 100, + topPx: 28, + automation: EMPTY, + accentColor: "#0af", + playheadSec: null, + onPreview: vi.fn(), + onCommit: vi.fn(), + ...over, + target, + range: over.range ?? resolveAutomationRange(target, chain) ?? VOLUME_RANGE, + }; +} + +/** happy-dom gives every element a zero-size box; the lane maps pointers + * through it, so tests that click need a real one. */ +function stubBox(el: Element, box: { left: number; top: number; width: number; height: number }) { + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + ...box, + right: box.left + box.width, + bottom: box.top + box.height, + x: box.left, + y: box.top, + toJSON: () => ({}), + } as DOMRect); +} + +describe("automationTargets", () => { + it("offers volume plus every addressable automatable knob", () => { + const targets = automationTargets(chain).map((t) => t.target); + expect(targets[0]).toBe("volume"); + expect(targets).toContain("fx.n1.frequency"); + expect(targets).toContain("fx.n1.q"); + }); + + it("skips a node with no id — a lane could not address it stably", () => { + expect(automationTargets(chain).some((t) => t.target.includes("peaking"))).toBe(false); + }); + + it("skips a worklet effect, which exposes no AudioParams", () => { + expect(automationTargets(chain).some((t) => t.target.startsWith("fx.n3."))).toBe(false); + }); + + it("offers just the fader for a track with no chain", () => { + expect(automationTargets(null).map((t) => t.target)).toEqual(["volume"]); + }); + + it("labels an fx target with its effect and knob", () => { + const found = automationTargets(chain).find((t) => t.target === "fx.n1.frequency"); + expect(found?.label).toMatch(/Cutoff/); + expect(found?.range.scale).toBe("log"); + }); +}); + +describe("TimelineAutomationLane", () => { + it("draws a point per breakpoint", () => { + const { container } = render(); + expect(container.querySelectorAll("circle").length).toBe(2); + }); + + it("draws a dimmed flat line when the lane is empty", () => { + const { container } = render(); + expect(container.querySelectorAll("circle").length).toBe(0); + const path = container.querySelector("path"); + expect(Number(path?.getAttribute("opacity"))).toBeLessThan(0.5); + }); + + it("keeps an end point clear of the lane's edges", () => { + // A point at t=0 drawn at x=0 is half outside the svg and unclickable; the + // lane insets its drawing so both ends are whole. + const ends: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 4, v: 0 }, + ], + }, + ], + }; + const { container } = render(); + const svg = container.querySelector("svg")!; + const points = Array.from(container.querySelectorAll("[data-automation-point]")); + const radius = Number(points[0]!.getAttribute("r")); + const first = Number(points[0]!.getAttribute("cx")); + const last = Number(points[1]!.getAttribute("cx")); + const svgWidth = Number(svg.getAttribute("width")); + expect(first).toBeGreaterThanOrEqual(radius); + expect(last).toBeLessThanOrEqual(svgWidth - radius); + // Wider than the clip by the padding on both sides, so clip time still + // lines up with screen position. + expect(svgWidth).toBe(400 + PAD * 2); + }); + + it("shows the parameter name in full, never clamped to a narrow gutter", () => { + // A clip starting at zero leaves no gutter; the label used to be clamped to + // 60px there and read "Low-pass ...". + const { container } = render( + , + ); + const name = container.querySelector(".hf-automation-name")!; + expect(name.textContent).toBe("Low-pass · Cutoff"); + expect(name.className).not.toMatch(/truncate/); + expect(name.style.maxWidth).toBe(""); + }); + + it("names the parameter it draws, rather than offering a control to swap it", () => { + const { container } = render( + , + ); + expect(container.querySelector("select")).toBeNull(); + expect(container.querySelector(".hf-automation-name")?.textContent).toMatch(/Cutoff/); + }); + + it("adds a point on double-click, at the value the pointer was at", () => { + const onCommit = vi.fn(); + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + // Half way across, and at the very top of the lane => t=2, v=1. + fire(svg, "dblclick", { clientX: PAD + 200, clientY: 6 }); + expect(onCommit).toHaveBeenCalledTimes(1); + const lane = onCommit.mock.calls[0][0].lanes[0]; + expect(lane.target).toBe("volume"); + // Seeded at 0 so the envelope has somewhere to come from. + expect(lane.points.length).toBe(2); + expect(lane.points[1].t).toBeCloseTo(2, 5); + expect(lane.points[1].v).toBeCloseTo(1, 2); + }); + + it("previews while dragging and persists once on release", () => { + const onPreview = vi.fn(); + const onCommit = vi.fn(); + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + // Grab the first point, at x=0 / top of the lane. + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 100, clientY: 42 }); + fire(svg, "pointermove", { clientX: 120, clientY: 40 }); + expect(onPreview).toHaveBeenCalledTimes(2); + expect(onCommit).not.toHaveBeenCalled(); + fire(svg, "pointerup", { clientX: 120, clientY: 40 }); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it("moves the dragged point on screen without waiting for the prop", () => { + // The live write skips the preview refresh on purpose, so `automation` does + // not change under the pointer. Before the draft state existed the circle + // stayed put and only the audio moved. + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + const cyBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cy")); + const cxBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx")); + + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 160, clientY: 40 }); + + const dragged = container.querySelectorAll("circle")[0]!; + expect(Number(dragged.getAttribute("cy"))).toBeGreaterThan(cyBefore + 10); + expect(Number(dragged.getAttribute("cx"))).toBeGreaterThan(cxBefore + 100); + }); + + it("keeps the dragged position after release, rather than snapping back", () => { + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 160, clientY: 40 }); + const during = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx")); + fire(svg, "pointerup", { clientX: 160, clientY: 40 }); + expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo( + during, + 5, + ); + }); + + it("follows the prop again once the store catches up", () => { + const { container, rerender } = renderRerenderable( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 160, clientY: 40 }); + fire(svg, "pointerup", { clientX: 160, clientY: 40 }); + // The persisted edit lands and the store hands back a different envelope; + // the lane must defer to it instead of holding the stale draft forever. + const persisted: HfAutomation = { + version: 1, + lanes: [{ target: "volume", points: [{ t: 3, v: 0.25 }] }], + }; + rerender(); + const circles = container.querySelectorAll("circle"); + expect(circles.length).toBe(1); + expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0); + }); + + it("keeps lane order when editing, so the view does not switch parameters", () => { + // The displayed lane defaults to the first one. Moving the edited lane to + // the end of the list swapped the lane out from under the pointer on the + // first edit — a 4-point filter sweep became a 2-point one mid-gesture. + const onCommit = vi.fn(); + const two: HfAutomation = { + version: 1, + lanes: [ + { + target: "fx.n1.frequency", + points: [ + { t: 0, v: 400 }, + { t: 4, v: 8000 }, + ], + }, + { target: "volume", points: [{ t: 0, v: 1 }] }, + ], + }; + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "dblclick", { clientX: 200, clientY: 20 }); + const next: HfAutomation = onCommit.mock.calls[0][0]; + expect(next.lanes.map((l) => l.target)).toEqual(["fx.n1.frequency", "volume"]); + expect(next.lanes[0]!.points.length).toBe(3); + }); + + it("appends a lane that did not exist yet", () => { + const onCommit = vi.fn(); + const only: HfAutomation = { + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }; + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "dblclick", { clientX: 200, clientY: 20 }); + expect(onCommit.mock.calls[0][0].lanes.map((l: { target: string }) => l.target)).toEqual([ + "volume", + "fx.n1.frequency", + ]); + }); + + it("removes a point on right-click", () => { + const onCommit = vi.fn(); + const { container } = render( + , + ); + fire(container.querySelectorAll("circle")[0]!, "contextmenu"); + expect(onCommit.mock.calls[0][0].lanes[0].points.length).toBe(1); + }); + + it("drops the lane entirely once its last point is removed", () => { + const onCommit = vi.fn(); + const single: HfAutomation = { + version: 1, + lanes: [{ target: "volume", points: [{ t: 1, v: 0.5 }] }], + }; + const { container } = render( + , + ); + fire(container.querySelector("circle")!, "contextmenu"); + expect(onCommit.mock.calls[0][0].lanes).toEqual([]); + }); + + it("writes nothing when read-only, and lets the press through to select", () => { + const onCommit = vi.fn(); + const onPreview = vi.fn(); + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "dblclick", { clientX: 200, clientY: 6 }); + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 100, clientY: 42 }); + fire(container.querySelectorAll("circle")[0]!, "contextmenu"); + expect(onCommit).not.toHaveBeenCalled(); + expect(onPreview).not.toHaveBeenCalled(); + }); + + it("selects the clip when pressed read-only, the only route to editing it", () => { + // The lane sits below the clip bar, so the timeline's own selection handler + // never sees this press. Without selecting here the lane could never be + // made editable at all. + const onSelect = vi.fn(); + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "pointerdown", { clientX: 40, clientY: 24 }); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("owns a press it cannot act on too, so the timeline does not scrub under it", () => { + const { container, ancestor } = renderNested( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + let reachedAncestor = false; + ancestor.addEventListener("pointerdown", () => { + reachedAncestor = true; + }); + fire(svg, "pointerdown", { clientX: 40, clientY: 24 }); + expect(reachedAncestor).toBe(false); + }); + + it("owns the press once live, so a double-click is not eaten by the timeline", () => { + const { container, ancestor } = renderNested( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + let reachedAncestor = false; + ancestor.addEventListener("pointerdown", () => { + reachedAncestor = true; + }); + fire(svg, "pointerdown", { clientX: 200, clientY: 24 }); + expect(reachedAncestor).toBe(false); + }); + + it("maps a log-read knob so its geometric middle sits mid-lane", () => { + const sweep: HfAutomation = { + version: 1, + lanes: [ + { + target: "fx.n1.frequency", + points: [ + { t: 0, v: 100 }, + { t: 4, v: 20000 }, + ], + }, + ], + }; + const { container } = render( + , + ); + const circles = container.querySelectorAll("circle"); + // 100 Hz is the range floor and 20 kHz its ceiling, so the two points sit at + // the lane's bottom and top. + const ys = Array.from(circles).map((c) => Number(c.getAttribute("cy"))); + expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx new file mode 100644 index 0000000000..c982edca91 --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -0,0 +1,544 @@ +/** + * Breakpoint automation over an audio clip, edited the way a DAW edits it: + * double-click the line to add a point, drag one to shape it, right-click a + * point to remove it. + * + * The lane knows nothing about any particular effect. Which parameters it can + * offer, their ranges, units and whether they read logarithmically all come + * from the FX registry, so an effect gained upstream needs no change here — the + * same principle the property panel's controls follow. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + fxAutomationTarget, + resolveAutomationRange, + sampleAutomationLane, + VOLUME_RANGE, + VOLUME_TARGET, + type AutomationRange, + type HfAutomation, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { getTimelineLaneTop } from "./timelineLayout"; +import type { TimelineElement } from "../store/playerStore"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; + +/** Points nearer than this in clip seconds are the same point, not two. */ +const POINT_MERGE_SEC = 0.02; +/** Hit radius for grabbing a point, in px. */ +const GRAB_PX = 7; +/** Samples used to draw a segment the eye should see as curved. */ +const DRAW_SAMPLES = 64; +/** + * Slack on each side of the envelope, so a point sitting exactly at the clip's + * start or end is drawn whole instead of half outside the lane. Wide enough for + * the grab circle plus its stroke. + */ +const PAD_X = GRAB_PX + 2; + +export interface AutomationTargetOption { + target: string; + label: string; + range: AutomationRange; +} + +/** + * Everything this clip could automate: its fader, then each automatable knob of + * each effect in its chain. Effects with no chain node id are skipped — a lane + * has nothing stable to address them by (the panel mints ids as it adds nodes). + */ +export function automationTargets(chain: HfAudioFxChain | null): AutomationTargetOption[] { + const out: AutomationTargetOption[] = [ + { target: VOLUME_TARGET, label: "Volume", range: VOLUME_RANGE }, + ]; + for (const node of chain?.nodes ?? []) { + out.push(...nodeTargets(node, chain)); + } + return out; +} + +/** One effect's automatable knobs. Empty for a node no lane could address. */ +function nodeTargets( + node: HfAudioFxChain["nodes"][number], + chain: HfAudioFxChain | null, +): AutomationTargetOption[] { + const nodeId = node.id; + const def = nodeId ? getAudioFxDef(node.type) : undefined; + if (!nodeId || !def) return []; + const out: AutomationTargetOption[] = []; + for (const param of def.params) { + if (param.kind !== "number" || !param.automatable) continue; + const target = fxAutomationTarget(nodeId, param.key); + const range = resolveAutomationRange(target, chain ?? undefined); + if (range) out.push({ target, label: range.label, range }); + } + return out; +} + +/** Value → 0..1 up the lane, honouring a log-read knob's own scale. */ +function toUnit(range: AutomationRange, value: number): number { + const { min, max } = range; + if (max <= min) return 0; + if (range.scale === "log" && min > 0 && value > 0) { + return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min)); + } + return (value - min) / (max - min); +} + +function fromUnit(range: AutomationRange, unit: number): number { + const t = Math.min(1, Math.max(0, unit)); + const { min, max } = range; + if (range.scale === "log" && min > 0) { + return Math.exp(Math.log(min) + t * (Math.log(max) - Math.log(min))); + } + return min + t * (max - min); +} + +function formatValue(range: AutomationRange, value: number): string { + const decimals = range.step >= 1 ? 0 : range.step >= 0.1 ? 1 : 2; + const shown = + range.unit === "" && range.max === 1 ? `${Math.round(value * 100)}%` : value.toFixed(decimals); + return range.unit ? `${shown} ${range.unit}` : shown; +} + +function laneFor(automation: HfAutomation, target: string): HfAutomationLane { + return automation.lanes.find((l) => l.target === target) ?? { target, points: [] }; +} + +/** + * Replace one lane in place, dropping it when it has no points left. + * + * Order is preserved deliberately. A lane with no explicitly chosen parameter + * shows whichever comes first, so moving the edited one to the end would switch + * the lane out from under the pointer on the first edit. + */ +function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation { + const empty = lane.points.length === 0; + const exists = automation.lanes.some((l) => l.target === lane.target); + const lanes = automation.lanes + .map((l) => (l.target === lane.target ? lane : l)) + .filter((l) => l.points.length > 0); + if (!exists && !empty) lanes.push(lane); + return { version: 1, lanes }; +} + +export interface TimelineAutomationLaneProps { + /** Clip-local duration the lane spans. */ + duration: number; + widthPx: number; + leftPx: number; + topPx: number; + automation: HfAutomation; + /** Which lane of that automation this row draws. */ + target: string; + /** Axis, unit and label for the target, resolved against the chain. */ + range: AutomationRange; + accentColor: string; + /** Clip-local seconds of the playhead, or null when it is outside the clip. */ + playheadSec: number | null; + /** Continuous write while dragging; does not persist. */ + onPreview(automation: HfAutomation): void; + /** Gesture-end write; this is the one that persists and lands in undo. */ + onCommit(automation: HfAutomation): void; + /** Editing writes to the selected element, so an unselected clip is read-only. */ + readOnly?: boolean; + /** Called when a read-only lane is pressed: selects the clip so it goes live. */ + onSelect?(): void; +} + +export function TimelineAutomationLane({ + duration, + widthPx, + leftPx, + topPx, + automation, + target, + range, + accentColor, + playheadSec, + onPreview, + onCommit, + readOnly, + onSelect, +}: TimelineAutomationLaneProps) { + const stored = laneFor(automation, target); + + const svgRef = useRef(null); + const [dragIndex, setDragIndex] = useState(null); + const [hint, setHint] = useState(null); + + /** + * Points as the user is shaping them, before the edit has come back around. + * + * A live write sets the preview attribute but deliberately skips the refresh — + * that is what keeps dragging from reloading the composition and restarting + * playback. So the automation prop does not move under the pointer, and + * without a local draft the point would not either. + */ + const [draft, setDraft] = useState<{ points: HfAutomationPoint[]; basedOn: HfAutomation } | null>( + null, + ); + const lane: HfAutomationLane = useMemo( + () => (draft ? { target, points: draft.points } : stored), + [draft, target, stored], + ); + + // The draft is released when the automation it was drawn over actually + // changes — the persisted edit landing, or an edit from elsewhere. Releasing + // it merely because the drag ended would snap the point back to where it + // started for as long as the write takes to come around. + useEffect(() => { + if (draft && draft.basedOn !== automation) setDraft(null); + }, [automation, draft]); + + // A different parameter is a different envelope; the draft does not carry over. + useEffect(() => { + setDraft(null); + }, [target]); + + const h = AUTOMATION_LANE_H; + const pad = 6; + const inner = h - pad * 2; + // Drawing is inset by PAD_X and the svg is widened to match, so screen + // position still lines up with clip time — the lane just has margins. + const xOf = useCallback( + (t: number): number => PAD_X + (duration > 0 ? (t / duration) * widthPx : 0), + [duration, widthPx], + ); + const yOf = useCallback( + (v: number): number => pad + (1 - toUnit(range, v)) * inner, + [range, inner], + ); + + /** Pointer position as a clip-local time and a parameter value. */ + const pointAt = useCallback( + (clientX: number, clientY: number): { t: number; v: number } => { + const box = svgRef.current?.getBoundingClientRect(); + if (!box || box.width <= 0) return { t: 0, v: range.default ?? range.min }; + const t = Math.min( + duration, + Math.max(0, ((clientX - box.left - PAD_X) / widthPx) * duration), + ); + const unit = 1 - (clientY - box.top - pad) / inner; + return { t, v: fromUnit(range, unit) }; + }, + [duration, inner, range, widthPx], + ); + + /** + * The line the lane draws. A flat line at the current value stands in for a + * lane with no points, so the first click has something to land on. + */ + const path = useMemo(() => { + if (lane.points.length === 0) { + const y = yOf(range.default ?? (range.min + range.max) / 2); + return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`; + } + const pts: string[] = []; + const first = lane.points[0]!; + pts.push(`M ${PAD_X} ${yOf(first.v)}`); + pts.push(`L ${xOf(first.t)} ${yOf(first.v)}`); + for (let i = 0; i + 1 < lane.points.length; i += 1) { + const a = lane.points[i]!; + const b = lane.points[i + 1]!; + if (!a.curve && range.scale === "linear") { + pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`); + continue; + } + // Curved or log-read: sample it, or the drawing would lie about the + // envelope the audio thread is going to play. + for (let k = 1; k <= DRAW_SAMPLES; k += 1) { + const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES; + pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`); + } + } + const last = lane.points[lane.points.length - 1]!; + pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`); + return pts.join(" "); + }, [lane, range, widthPx, xOf, yOf]); + + const commitPoints = useCallback( + (points: HfAutomationLane["points"], persist: boolean): void => { + // Draw from the draft immediately; the write is what eventually agrees. + setDraft({ points, basedOn: automation }); + const next = withLane(automation, { target, points }); + if (persist) onCommit(next); + else onPreview(next); + }, + [automation, target, onCommit, onPreview], + ); + + /** Index of a point under the pointer, or null. */ + const hitIndex = useCallback( + (clientX: number, clientY: number): number | null => { + const box = svgRef.current?.getBoundingClientRect(); + if (!box) return null; + const px = clientX - box.left; + const py = clientY - box.top; + for (let i = 0; i < lane.points.length; i += 1) { + const p = lane.points[i]!; + if (Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i; + } + return null; + }, + [lane, xOf, yOf], + ); + + const onPointerDown = useCallback( + (e: ReactPointerEvent): void => { + if (e.button !== 0) return; + // The lane owns this region either way. Letting a press through starts the + // timeline's own gesture (scrub / marquee / clip drag), which then eats the + // rest of the sequence — including the second half of a double-click. + e.stopPropagation(); + if (readOnly) { + // The lane sits below the clip bar, so the timeline's selection handler + // never sees this press; selecting here is the only way in. + onSelect?.(); + return; + } + const index = hitIndex(e.clientX, e.clientY); + if (index === null) return; + e.preventDefault(); + (e.target as Element).setPointerCapture?.(e.pointerId); + setDragIndex(index); + }, + [hitIndex, readOnly, onSelect], + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent): void => { + if (dragIndex === null) return; + e.stopPropagation(); + const { t, v } = pointAt(e.clientX, e.clientY); + const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p)); + // Re-sort so dragging a point past a neighbour behaves, and keep the + // dragged one addressable by following where it landed. + const moved = next[dragIndex]!; + next.sort((a, b) => a.t - b.t); + setDragIndex(next.indexOf(moved)); + setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`); + commitPoints(next, false); + }, + [dragIndex, lane, pointAt, range, commitPoints], + ); + + const endDrag = useCallback( + (e: ReactPointerEvent): void => { + if (dragIndex === null) return; + e.stopPropagation(); + setDragIndex(null); + setHint(null); + commitPoints(lane.points, true); + }, + [dragIndex, lane, commitPoints], + ); + + const onDoubleClick = useCallback( + (e: ReactPointerEvent): void => { + if (readOnly) return; + e.stopPropagation(); + e.preventDefault(); + const { t, v } = pointAt(e.clientX, e.clientY); + const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC); + // A lane's first point alone would be a constant, which is not what + // clicking an empty lane means: seed the far end at the same value so the + // envelope has somewhere to go. + const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : []; + commitPoints( + [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t), + true, + ); + }, + [lane, pointAt, commitPoints, readOnly], + ); + + const removeAt = useCallback( + (index: number): void => { + if (readOnly) return; + commitPoints( + lane.points.filter((_, i) => i !== index), + true, + ); + }, + [lane, commitPoints, readOnly], + ); + + const currentValue = + lane.points.length > 0 && playheadSec !== null + ? sampleAutomationLane(lane, playheadSec, range.scale) + : null; + + return ( +
+ {/* Name at the lane's top-left, like a DAW's lane header. Shown in full — + a clip starting at zero leaves no gutter to clamp it into — and + click-through, so it can sit over the envelope without blocking it. */} +
+ {range.label} +
+ + + + {readOnly + ? "Click to select this clip, then double-click to add a point" + : "Double-click to add a point, drag to shape, right-click a point to remove"} + + + {/* Mid rail, so a value reads against something. */} + + + {lane.points.map((p, i) => ( + { + e.preventDefault(); + e.stopPropagation(); + removeAt(i); + }} + /> + ))} + {playheadSec !== null && currentValue !== null ? ( + + ) : null} + + + {hint ? ( +
+ {hint} +
+ ) : null} +
+ ); +} + +export interface TimelineAutomationLaneSlotProps { + element: TimelineElement; + isSelected: boolean; + lanes: UseAutomationLanesResult; + pps: number; + /** Keyframe lanes already stacked above, which automation sits under. */ + laneCount: number; + accentColor: string; + /** Composition-time playhead; the slot converts it to clip-local. */ + currentTime: number; +} + +/** + * Every automated parameter on this clip, one lane per row — the way a DAW + * stacks them, so two envelopes can be read and edited without swapping a + * control to see either. + */ +export function TimelineAutomationLaneSlot({ + element, + isSelected, + lanes, + pps, + laneCount, + accentColor, + currentTime, +}: TimelineAutomationLaneSlotProps) { + const bound = lanes.bind(element, isSelected); + if (bound.lanes.length === 0) return null; + const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; + const top = getTimelineLaneTop(laneCount); + return ( + <> + {bound.lanes.map((lane, index) => { + const range = resolveAutomationRange(lane.target, bound.chain ?? undefined); + // A lane whose target no longer resolves was already dropped upstream; + // this is belt and braces so a row can never draw on the wrong axis. + if (!range) return null; + return ( + + ); + })} + + ); +} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index bd4de63b88..1119f02d68 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -3,6 +3,8 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import { useAutomationLanes } from "./useAutomationLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; @@ -106,6 +108,7 @@ export function TimelineLanes({ // a CSS `#id` selector, so they come out here and the prefix stays plain. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const automationLanes = useAutomationLanes(); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); @@ -542,8 +545,22 @@ export function TimelineLanes({ Promise.resolve(false) } suppressClickRef={suppressClickRef} + footer={ + showsLanes && isAudioTimelineElement(el) ? ( + + ) : null + } /> ); + // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx index f54064a931..28a83bdbad 100644 --- a/packages/studio/src/player/components/TimelinePropertyLanes.tsx +++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx @@ -1,4 +1,4 @@ -import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react"; +import { useMemo, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject } from "react"; import { classifyPropertyGroup, type GsapAnimation, @@ -33,6 +33,12 @@ export interface TimelinePropertyLanesProps { onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void; onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise; suppressClickRef?: RefObject; + /** + * Rendered after the keyframe lanes, inside this wrapper. An audio clip's + * automation lane lives here so it shares the same disclosure — and so the + * header caret's `aria-controls` covers it too. + */ + footer?: ReactNode; } /** @@ -193,6 +199,7 @@ export function TimelinePropertyLanes({ onContextMenuKeyframe, onMoveKeyframe, suppressClickRef, + footer, }: TimelinePropertyLanesProps) { // Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and // a fresh keyframesData literal per lane) on every render would re-render every @@ -256,6 +263,7 @@ export function TimelinePropertyLanes({ /> ))} + {footer} ); } diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts new file mode 100644 index 0000000000..20b93bcb13 Binary files /dev/null and b/packages/studio/src/player/components/automationLaneData.ts differ diff --git a/packages/studio/src/player/components/automationLaneHeight.ts b/packages/studio/src/player/components/automationLaneHeight.ts new file mode 100644 index 0000000000..3ad43c4282 --- /dev/null +++ b/packages/studio/src/player/components/automationLaneHeight.ts @@ -0,0 +1,11 @@ +/** + * Height of one audio automation lane. + * + * Its own module because both the row layout and the lane itself need it, and + * putting it in either would have the layout importing a component or the + * component's constant living somewhere it is not used. + * + * Taller than a keyframe lane because it carries a value axis rather than a row + * of diamonds: a fader envelope drawn 28px high cannot be aimed. + */ +export const AUTOMATION_LANE_H = 48; diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 853f812566..682f0f50f1 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,3 +1,4 @@ +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import type { ZoomMode } from "../store/playerStore"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; @@ -86,6 +87,8 @@ export const TRACKS_LEFT_PAD = 48; export interface TimelineTrackHeightClip { clipId: string; laneCount: number; + /** Audio automation lanes shown when expanded, reserved at their own height. */ + automationLaneCount?: number; } type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[]; @@ -101,12 +104,15 @@ export function trackHeights( ): number[] { return tracks.map((clips) => { let laneCount = 0; - if (expandedClipIds) { - for (const clip of clips) { - if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount); - } + let automationLanes = 0; + for (const clip of clips) { + if (!expandedClipIds?.has(clip.clipId)) continue; + laneCount = Math.max(laneCount, clip.laneCount); + automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0); } - return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H; + return ( + TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H + ); }); } diff --git a/packages/studio/src/player/components/useAutomationLanes.test.tsx b/packages/studio/src/player/components/useAutomationLanes.test.tsx new file mode 100644 index 0000000000..2003035ea2 --- /dev/null +++ b/packages/studio/src/player/components/useAutomationLanes.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { useAutomationLanes, type AutomationLaneBinding } from "./useAutomationLanes"; +import type { TimelineElement } from "../store/playerStore"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** Bind one element through the hook and hand back what the lane would get. */ +function bindOnce(element: TimelineElement): AutomationLaneBinding { + let captured: AutomationLaneBinding | null = null; + function Probe() { + captured = useAutomationLanes().bind(element, true); + return null; + } + const host = document.createElement("div"); + document.body.append(host); + act(() => { + createRoot(host).render(); + }); + if (!captured) throw new Error("bind never ran"); + return captured; +} + +const el = (over: Partial = {}): TimelineElement => ({ + id: "music", + key: "music", + tag: "audio", + start: 0, + duration: 12, + track: 10, + ...over, +}); + +const CHAIN = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }], +}); + +describe("useAutomationLanes", () => { + it("drops a lane whose effect is no longer in the chain", () => { + // n1 was deleted from the chain but its lane survived in the attribute. + // Drawn as-is it landed on the volume axis, with points off the lane and + // a selector that had no option for it. + const automation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "fx.n1.speed", + points: [ + { t: 0, v: 0.4 }, + { t: 12, v: 6 }, + ], + }, + { target: "volume", points: [{ t: 0, v: 0.55 }] }, + ], + }); + const bound = bindOnce(el({ automation, fxChain: CHAIN })); + expect(bound.automation.lanes.map((l) => l.target)).toEqual(["volume"]); + }); + + it("keeps a lane whose effect is still there", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "fx.n2.frequency", + points: [ + { t: 0, v: 400 }, + { t: 4, v: 8000 }, + ], + }, + ], + }); + const bound = bindOnce(el({ automation, fxChain: CHAIN })); + expect(bound.lanes.map((l) => l.target)).toEqual(["fx.n2.frequency"]); + }); + + it("gives one lane per automated parameter, in draw order", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [ + { target: "volume", points: [{ t: 0, v: 0.5 }] }, + { target: "fx.n2.frequency", points: [{ t: 0, v: 400 }] }, + { target: "fx.n2.q", points: [{ t: 0, v: 1 }] }, + ], + }); + const bound = bindOnce(el({ automation, fxChain: CHAIN })); + expect(bound.lanes.map((l) => l.target)).toEqual(["volume", "fx.n2.frequency", "fx.n2.q"]); + }); + + it("reads an element with neither attribute as an empty volume lane", () => { + const bound = bindOnce(el()); + expect(bound.lanes).toEqual([]); + expect(bound.chain).toBeNull(); + }); + + it("is read-only without an edit session, whatever the selection", () => { + // No DomEditProvider in this tree — the bare player case. + expect(bindOnce(el({ automation: undefined })).readOnly).toBe(true); + }); + + it("survives an unreadable attribute instead of breaking the row", () => { + const bound = bindOnce(el({ automation: "{not json", fxChain: "{also not}" })); + expect(bound.automation.lanes).toEqual([]); + expect(bound.chain).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts new file mode 100644 index 0000000000..0e23ce8336 --- /dev/null +++ b/packages/studio/src/player/components/useAutomationLanes.ts @@ -0,0 +1,85 @@ +/** + * Writes for the timeline's audio automation lanes. + * + * Kept out of TimelineLanes so that component does not grow another concern. + * Reading lives in `automationLaneData`, shared with the row layout, which needs + * the lane count to reserve height. + * + * Edits go to the *selected* element, because that is what the attribute commit + * path targets. An unselected clip still draws its envelopes — they are just + * read only, which is also what stops a stray drag from editing the wrong track. + */ + +import { useCallback, useMemo } from "react"; +import { + HF_AUDIO_AUTOMATION_ATTR, + serializeAutomation, + type HfAutomation, + type HfAutomationLane, +} from "@hyperframes/core/audio-automation"; +import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; +import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import type { TimelineElement } from "../store/playerStore"; +import { elementAutomation, elementFxChain } from "./automationLaneData"; + +export interface AutomationLaneBinding { + automation: HfAutomation; + /** One entry per lane, in draw order — each gets its own row. */ + lanes: HfAutomationLane[]; + chain: HfAudioFxChain | null; + /** Continuous write while dragging; does not persist. */ + onPreview(next: HfAutomation): void; + /** Gesture-end write; this is the one that persists and lands in undo. */ + onCommit(next: HfAutomation): void; + /** + * Select this clip, which is what makes its lanes editable. A lane calls this + * instead of writing when it is read-only — pressing the lane is the only + * route in, since lanes sit below the clip bar where the timeline's own + * selection handler never sees them. + */ + onSelect(): void; + readOnly: boolean; +} + +export interface UseAutomationLanesResult { + bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding; +} + +export function useAutomationLanes(): UseAutomationLanesResult { + // Optional: the player also runs outside Studio, where there is no edit + // session. There the lanes render read-only, which is the right fallback. + const domEdit = useDomEditActionsContextOptional(); + + const bind = useCallback( + (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => { + const chain = elementFxChain(element); + const automation = elementAutomation(element); + + const write = (next: HfAutomation, persist: boolean): void => { + if (!domEdit || !isSelected) return; + const value = next.lanes.length > 0 ? serializeAutomation(next) : ""; + if (persist) void domEdit.handleDomAttributeCommit(HF_AUDIO_AUTOMATION_ATTR, value); + // Dragging a point writes live: no preview refresh, so the composition + // does not reload and restart playback on every pixel. + else void domEdit.handleDomAttributeLiveCommit(HF_AUDIO_AUTOMATION_ATTR, value || null); + }; + + return { + automation, + lanes: automation.lanes, + chain, + onPreview: (next) => write(next, false), + onCommit: (next) => write(next, true), + // Deliberately not awaited before an edit: the commit handlers close + // over the selection as it was when they were built, so writing in the + // same tick would land on whichever element was selected before. + // Selecting is its own gesture; the lane goes live after it. + onSelect: () => void domEdit?.handleTimelineElementSelect(element), + readOnly: !domEdit || !isSelected, + }; + }, + [domEdit], + ); + + return useMemo(() => ({ bind }), [bind]); +} diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 0455293994..bfa1170ea9 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -1,6 +1,8 @@ import { useMemo, useRef } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { animationLaneGroups } from "./TimelinePropertyLanes"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { elementAutomationLanes } from "./automationLaneData"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; @@ -85,7 +87,15 @@ function useTimelineRowHeights( ); if (!active) return []; const clipId = active.key ?? active.id; - return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }]; + return [ + { + clipId, + laneCount: laneCounts.get(clipId) ?? 0, + automationLaneCount: isAudioTimelineElement(active) + ? elementAutomationLanes(active).length + : 0, + }, + ]; }); const rowHeights = trackHeights(heightTracks, expandedClipIds); return { diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index a8730caa4b..b430a9889a 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -138,6 +138,10 @@ export function createTimelineElementFromManifestClip(params: { if (hostEl.hasAttribute("data-hidden")) entry.hidden = true; const timelineRole = hostEl.getAttribute("data-timeline-role"); if (timelineRole) entry.timelineRole = timelineRole; + const fxChain = hostEl.getAttribute("data-fx-chain"); + if (fxChain) entry.fxChain = fxChain; + const automation = hostEl.getAttribute("data-automation"); + if (automation) entry.automation = automation; entry.zIndex = readTimelineElementZIndex(hostEl); } if (clip.assetUrl) entry.src = clip.assetUrl; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 26323e458b..28edc29e76 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -58,6 +58,11 @@ export interface TimelineElement { playbackRate?: number; sourceDuration?: number; volume?: number; + /** Verbatim `data-fx-chain` / `data-automation`, when set. Kept raw: the lane + * reads and writes the attribute, which is what keeps it, the property panel + * and the running audio graph on one source of truth. */ + fxChain?: string; + automation?: string; /** Path from data-composition-src — identifies sub-composition elements */ compositionSrc?: string; /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */