diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index dc3f4f7b3f..cf86039fd2 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, onSetAttributeQuiet ?? onSetAttributeLive); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -443,7 +435,7 @@ export function PropertyPanelFlat({ content: ( ), @@ -463,6 +455,7 @@ export function PropertyPanelFlat({ onSetAttribute={onSetAttribute} onSetHtmlAttribute={onSetHtmlAttribute} onRemoveBackground={onRemoveBackground} + {...volumeAutomation} /> ), }); @@ -561,112 +554,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..de4153689b --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -0,0 +1,358 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { afterEach, 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" } }], +}); + +// Each mount appends its tracks to the document; without clearing, a later +// "only one audio track" case would still find the previous test's sibling. +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** + * A selected `