From 6fd78e10efdbb4f225dbcac5ad418d5ed200b08f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 31 Jul 2026 02:16:10 -0700 Subject: [PATCH] feat(studio): VST FX panel section behind STUDIO_VST_ENABLED The audio element's FX section: plugin chain rows, the native editor handoff, and the "make room for voiceover" carve control that calls POST /vst/carve and writes the resulting bands into the chain file. Off by default. STUDIO_VST_ENABLED (VITE_STUDIO_ENABLE_VST=true) gates the whole section, so this is unreachable in a normal studio session until the pipeline has been exercised more broadly. --- .../src/components/StudioRightPanel.test.tsx | 222 +++++ .../src/components/StudioRightPanel.tsx | 54 +- .../components/editor/PropertyPanel.test.tsx | 46 + .../src/components/editor/PropertyPanel.tsx | 29 +- .../components/editor/propertyPanelTypes.ts | 9 +- .../editor/propertyPanelVstCarveSection.tsx | 220 +++++ .../editor/propertyPanelVstPluginRows.tsx | 198 ++++ .../editor/propertyPanelVstSection.test.tsx | 849 ++++++++++++++++++ .../editor/propertyPanelVstSection.tsx | 449 +++++++++ .../src/components/studioBackgroundRemoval.ts | 60 ++ 10 files changed, 2082 insertions(+), 54 deletions(-) create mode 100644 packages/studio/src/components/StudioRightPanel.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelVstCarveSection.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelVstPluginRows.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelVstSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelVstSection.tsx create mode 100644 packages/studio/src/components/studioBackgroundRemoval.ts diff --git a/packages/studio/src/components/StudioRightPanel.test.tsx b/packages/studio/src/components/StudioRightPanel.test.tsx new file mode 100644 index 0000000000..f81bdeb109 --- /dev/null +++ b/packages/studio/src/components/StudioRightPanel.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +// +// Task 13b regression test: before this fix, `StudioRightPanel` never passed +// a `vstHost` prop to `PropertyPanel` at all (it defaulted to `null` there), +// so the FX panel always rendered its "not available" install-hint state. +// This test proves the panel now forwards the ONE shared `useVstHost()` +// instance (threaded through `NLEContext`) rather than either omitting it or +// creating a second, independent connection of its own. +// +// Every context `StudioRightPanel` reads is mocked to the minimal shape it +// actually destructures (mirroring PropertyPanel.test.tsx's established +// pattern for this codebase), and the real `PropertyPanel` + heavier tab +// panels are replaced with lightweight stand-ins so this test exercises only +// the prop-forwarding wiring, not their internals. + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { VstHostApi } from "./editor/propertyPanelVstSection"; +import type { SdkSessionPublicationResult } from "../utils/sdkEditTransaction"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let capturedVstHost: VstHostApi | null | undefined; + +vi.mock("./editor/PropertyPanel", () => ({ + PropertyPanel: (props: { vstHost?: VstHostApi | null }) => { + capturedVstHost = props.vstHost; + return null; + }, +})); + +vi.mock("./DesignPanelPromoteProvider", () => ({ + DesignPanelPromoteProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("../contexts/StudioContext", async () => { + const actual = await vi.importActual( + "../contexts/StudioContext", + ); + return { + ...actual, + useStudioShellContext: () => ({ + previewIframeRef: { current: null }, + projectId: "proj-1", + activeCompPath: "index.html", + showToast: vi.fn(), + compositionDimensions: null, + waitForPendingDomEditSaves: async () => {}, + renderQueue: { + jobs: [], + isRendering: false, + loadError: null, + actionError: null, + dismissActionError: () => {}, + reloadRenders: () => {}, + deleteRender: () => {}, + cancelRender: () => {}, + clearCompleted: () => {}, + startRender: async () => {}, + }, + }), + useStudioPlaybackContext: () => ({ captionEditMode: false, refreshKey: 0 }), + }; +}); + +vi.mock("../contexts/PanelLayoutContext", async () => { + const actual = await vi.importActual( + "../contexts/PanelLayoutContext", + ); + return { + ...actual, + usePanelLayoutContext: () => ({ + rightWidth: 320, + setRightWidth: () => {}, + rightPanelTab: "design", + setRightPanelTab: () => {}, + rightInspectorPanes: { design: true, layers: false }, + toggleRightInspectorPane: () => {}, + handlePanelResizeStart: () => {}, + handlePanelResizeMove: () => {}, + handlePanelResizeEnd: () => {}, + }), + }; +}); + +vi.mock("../contexts/FileManagerContext", async () => { + const actual = await vi.importActual( + "../contexts/FileManagerContext", + ); + return { + ...actual, + useFileManagerContext: () => ({ + assets: [], + fontAssets: [], + projectDir: "", + handleImportFiles: async () => {}, + handleImportFonts: async () => {}, + refreshFileTree: async () => {}, + readProjectFile: async () => "", + writeProjectFile: async () => {}, + fileTree: [], + }), + }; +}); + +vi.mock("../contexts/DomEditContext", async () => { + const actual = await vi.importActual( + "../contexts/DomEditContext", + ); + return { + ...actual, + useDomEditContext: () => ({ + domEditSelection: null, + domEditGroupSelections: [], + copiedAgentPrompt: null, + clearDomSelection: () => {}, + handleUngroupSelection: () => {}, + handleGroupSelection: () => {}, + handleDomStyleCommit: () => {}, + handleDomAttributeCommit: async () => {}, + handleDomAttributeLiveCommit: () => {}, + handleDomHtmlAttributeCommit: async () => {}, + handleDomAttributesCommit: async () => {}, + handleDomPathOffsetCommit: () => {}, + handleDomBoxSizeCommit: () => {}, + handleDomRotationCommit: () => {}, + handleDomTextCommit: () => {}, + handleDomTextFieldStyleCommit: () => {}, + handleDomAddTextField: () => {}, + handleDomRemoveTextField: () => {}, + handleAskAgent: () => {}, + selectedGsapAnimations: [], + gsapMultipleTimelines: false, + gsapUnsupportedTimelinePattern: null, + handleGsapUpdateProperty: () => {}, + handleGsapUpdateMeta: () => {}, + handleGsapDeleteAnimation: () => {}, + handleGsapAddAnimation: () => {}, + handleGsapAddProperty: () => {}, + handleGsapRemoveProperty: () => {}, + handleGsapUpdateFromProperty: () => {}, + handleGsapAddFromProperty: () => {}, + handleGsapRemoveFromProperty: () => {}, + commitAnimatedProperty: () => {}, + commitAnimatedProperties: () => {}, + handleSetArcPath: () => {}, + handleUpdateArcSegment: () => {}, + handleUnroll: () => {}, + handleUpdateKeyframeEase: () => {}, + handleSetAllKeyframeEases: () => {}, + handleGsapAddKeyframe: () => {}, + handleGsapRemoveKeyframe: () => {}, + handleGsapConvertToKeyframes: () => {}, + }), + }; +}); + +const fakeVstHost = { + api: { + registry: [], + scan: async () => {}, + openEditor: () => {}, + setParam: () => {}, + loadChain: async () => ({ trackIndex: 0, sampleRate: 48000, stable: true }), + getState: async () => [], + } satisfies VstHostApi, + status: "ready" as const, + installHint: null, + ensureStarted: async () => {}, + onPcmFrame: () => () => {}, + sendTransport: () => {}, + onDisconnect: () => () => {}, + onChainLoaded: () => () => {}, +}; + +// StudioRightPanel only imports `useNLEContext` from this module — no need to +// load the real NLEProvider (and its useTimelinePlayer/useVstHost chain) for +// this test. +vi.mock("./nle/NLEContext", () => ({ + useNLEContext: () => ({ vstHost: fakeVstHost }), +})); + +afterEach(() => { + document.body.innerHTML = ""; + capturedVstHost = undefined; + vi.resetModules(); +}); + +describe("StudioRightPanel — vstHost wiring", () => { + // The dynamic import below pulls in StudioRightPanel's full (heavy) module + // graph the first time this file runs; under a full-suite run alongside + // hundreds of other test files that can exceed vitest's default 5s. + it("forwards the shared vstHost.api (not null) into PropertyPanel", async () => { + const { StudioRightPanel } = await import("./StudioRightPanel"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(StudioRightPanel, { + designPanelActive: true, + sdkSession: null, + publishSdkSession: (): SdkSessionPublicationResult => "published", + reloadPreview: () => {}, + domEditSaveTimestampRef: { current: 0 }, + recordEdit: async () => {}, + }), + ); + await Promise.resolve(); + }); + + expect(capturedVstHost).toBe(fakeVstHost.api); + expect(capturedVstHost).not.toBeNull(); + + act(() => root.unmount()); + host.remove(); + }, 15000); +}); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index b55aac3056..c155ab35fe 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -17,11 +17,12 @@ import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/us import { useSlideshowTabState } from "../hooks/useSlideshowTabState"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; +import { useNLEContext } from "./nle/NLEContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; import { useDomEditContext } from "../contexts/DomEditContext"; import { usePlayerStore } from "../player"; -import { waitForMediaJob } from "./studioMediaJobs"; +import { removeBackgroundViaApi } from "./studioBackgroundRemoval"; import { applyColorGradingScopeUpdate, EMPTY_COLOR_GRADING_SCOPE_RESULT, @@ -113,6 +114,11 @@ export function StudioRightPanel({ renderQueue, } = useStudioShellContext(); const { captionEditMode, refreshKey } = useStudioPlaybackContext(); + // Single sidecar connection for the whole shell (see NLEContext's `vstHost` + // doc-comment) — the FX panel consumes the SAME useVstHost() instance the + // live-playback path (useVstPreview, mounted in NLEProvider) streams + // through, never a second independent WebSocket. + const { vstHost } = useNLEContext(); const { domEditSelection, @@ -281,49 +287,19 @@ export function StudioRightPanel({ ); const handleRemoveBackground = useCallback( - // fallow-ignore-next-line complexity - async ( + ( inputPath: string, options: { createBackgroundPlate?: boolean; quality?: "fast" | "balanced" | "best"; onProgress?: (progress: BackgroundRemovalProgress) => void; }, - ) => { - const response = await fetch( - `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - inputPath, - createBackgroundPlate: options.createBackgroundPlate === true, - quality: options.quality ?? "balanced", - }), - }, - ); - const data = (await response.json().catch(() => ({}))) as { - jobId?: string; - error?: string; - }; - if (!response.ok || !data.jobId) { - throw new Error(data.error || `Background removal failed (${response.status})`); - } - showToast("Removing background...", "info"); - backgroundRemovalAbortRef.current?.abort(); - const controller = new AbortController(); - backgroundRemovalAbortRef.current = controller; - try { - const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal); - await refreshFileTree(); - showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info"); - return result; - } finally { - if (backgroundRemovalAbortRef.current === controller) { - backgroundRemovalAbortRef.current = null; - } - } - }, + ) => + removeBackgroundViaApi(projectId, inputPath, options, { + refreshFileTree, + showToast, + abortRef: backgroundRemovalAbortRef, + }), [projectId, refreshFileTree, showToast], ); const handleHideAllSelected = () => { @@ -406,6 +382,8 @@ export function StudioRightPanel({ recordingState={recordingState} recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} + vstHost={vstHost.api} + domEditSaveTimestampRef={domEditSaveTimestampRef} /> ); diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 82098f3774..e9adfa10ee 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -674,6 +674,52 @@ function audioElement() { }; } +describe("PropertyPanel — STUDIO_VST_ENABLED gate", () => { + it( + "does not render the VST FX section for an audio element by default (flag off)", + async () => { + const { host, root } = await renderPanel(false, audioElement() as never); + expect(host.querySelector('[data-vst-install-hint="true"]')).toBeNull(); + expect(host.querySelector("[data-vst-add-effect]")).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "renders the VST FX section for an audio element once STUDIO_VST_ENABLED is on", + async () => { + vi.doMock("./manualEditingAvailability", async () => { + const actual = await vi.importActual( + "./manualEditingAvailability", + ); + return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false, STUDIO_VST_ENABLED: true }; + }); + vi.resetModules(); + const { PropertyPanel } = await import("./PropertyPanel"); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const props = { + element: audioElement(), + assets: [], + onSetStyle: vi.fn(), + onSetText: vi.fn(), + onSetAttributeLive: vi.fn(), + } as unknown as PropertyPanelProps; + act(() => { + root.render(); + }); + expect( + host.querySelector('[data-vst-install-hint="true"]') || + host.querySelector("[data-vst-add-effect]"), + ).not.toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); + // All FlatGroup titles currently mounted (open row + every collapsed row). function flatGroupTitles(host: HTMLElement): string[] { const open = Array.from( diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index c23359ab97..14214bd50f 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -20,13 +20,14 @@ import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes"; import { resolveEditingSections } from "@hyperframes/core/editing"; import { MediaSection } from "./propertyPanelMediaSection"; +import { VstSection } from "./propertyPanelVstSection"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; import { domEditSelectionToFacts } from "./domEditingLayers"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; import { KeyframeNavigation } from "./KeyframeNavigation"; -import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./manualEditingAvailability"; +import { STUDIO_FLAT_INSPECTOR_ENABLED, STUDIO_VST_ENABLED } from "./manualEditingAvailability"; import { PropertyPanelFlat } from "./PropertyPanelFlat"; import { createGsapLivePreview } from "./gsapLivePreview"; import { usePlayerStore, liveTime } from "../../player"; @@ -36,20 +37,6 @@ import { GestureRecordPanelButton } from "./GestureRecordControl"; import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; -// Re-export helpers that external consumers import from this module -export { - buildInsetClipPathSides, - buildStrokeStyleUpdates, - buildStrokeWidthStyleUpdates, - getCssFilterFunctionPx, - getClipPathInsetPx, - inferBoxShadowPreset, - inferClipPathPreset, - normalizePanelPxValue, - parseInsetClipPathSides, - setCssFilterFunctionPx, -} from "./propertyPanelHelpers"; - // fallow-ignore-next-line complexity export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) { const { @@ -109,6 +96,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro recordingState, recordingDuration, onToggleRecording, + vstHost = null, + domEditSaveTimestampRef, } = props; const styles = element?.computedStyles ?? EMPTY_STYLES; const { showToast } = useStudioShellContext(); @@ -379,6 +368,16 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro /> )} + {STUDIO_VST_ENABLED && sections.vstFx && ( + + )} + {sections.layout && (
}>
diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index 4bb9ed6c10..c3b987686d 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -1,8 +1,9 @@ -import type { RefObject } from "react"; +import type { MutableRefObject, RefObject } from "react"; import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import type { DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; +import type { VstHostApi } from "./propertyPanelVstSection"; export interface BackgroundRemovalProgress { status: "processing" | "complete" | "failed"; @@ -139,4 +140,10 @@ export interface PropertyPanelProps { recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; onToggleRecording?: () => void; + /** Task 12's `useVstHost` supplies the real client; `null`/omitted renders the install hint. */ + vstHost?: VstHostApi | null; + /** Shared timestamp ref — written by any studio save (code tab, timeline, + * DOM edits, VST chain persistence). Used to suppress file-change echoes + * so the preview doesn't reload after our own saves. */ + domEditSaveTimestampRef?: MutableRefObject; } diff --git a/packages/studio/src/components/editor/propertyPanelVstCarveSection.tsx b/packages/studio/src/components/editor/propertyPanelVstCarveSection.tsx new file mode 100644 index 0000000000..5088b013dc --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelVstCarveSection.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState, type MutableRefObject } from "react"; +import type { DomEditSelection } from "./domEditing"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { + appendCarveBands, + isRecord, + projectRelativeAssetPath, + type CarveBand, + type ChainFileJson, +} from "../../utils/vstChainFile"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { chainFilePath, readChainFile, writeChainFile } from "./propertyPanelVstShared"; + +/** Maps the carve amount slider (0-100) to the sidecar's `maxCutDb` — 0 -> 2 dB + * (subtle), 100 -> 6 dB (aggressive). */ +function amountToMaxCutDb(amount: number): number { + return 2 + (amount / 100) * 4; +} + +/** POSTs to `/vst/carve` and validates the response shape. Null on any failure + * (network, non-2xx, or malformed body) — the caller treats that as "no-op". */ +async function fetchCarveBands( + projectId: string, + musicPath: string, + voicePath: string, + maxCutDb: number, +): Promise { + const res = await fetch("/api/vst/carve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, musicPath, voicePath, maxCutDb }), + }); + if (!res.ok) return null; + const body: unknown = await res.json().catch(() => null); + return isRecord(body) && Array.isArray(body.bands) ? (body.bands as CarveBand[]) : null; +} + +interface CarveRequest { + projectId: string; + element: DomEditSelection; + musicSrc: string | undefined; + voiceSrc: string | undefined; + carveAmount: number; +} + +interface CarveResult { + chain: ChainFileJson; + path: string; +} + +/** Runs the full carve pipeline (resolve asset paths → fetch bands → merge + * into the chain file → write it back). Null on any failure — the caller + * treats that as "no-op" and leaves the panel open. */ +async function runCarve(req: CarveRequest): Promise { + const musicSub = req.musicSrc ? projectRelativeAssetPath(req.musicSrc) : null; + const voSub = req.voiceSrc ? projectRelativeAssetPath(req.voiceSrc) : null; + if (!musicSub || !voSub) return null; + + const bands = await fetchCarveBands( + req.projectId, + musicSub, + voSub, + amountToMaxCutDb(req.carveAmount), + ); + if (!bands) return null; + + const path = chainFilePath(req.element); + const existing = await readChainFile(req.projectId, path); + const nextChain = appendCarveBands(existing, bands); + const ok = await writeChainFile(req.projectId, path, nextChain); + if (!ok) return null; + + return { chain: nextChain, path }; +} + +export interface VstCarveSectionProps { + projectId: string; + element: DomEditSelection; + trackId: string; + busy: boolean; + setBusy: (busy: boolean) => void; + onSetAttribute: (attr: string, value: string) => void | Promise; + /** Called with the freshly-written chain right after the PUT succeeds, so + * the parent can update its `chain` state and re-seed the visible param + * sliders (the amount slider must be reflected immediately — see the + * "re-applying carve" regression test). */ + onChainWritten: (chain: ChainFileJson) => void; + /** Stamped after the write so the studio's file-watcher treats it as our + * own save and skips reloading the preview — every other save path in + * this panel does this too. */ + domEditSaveTimestampRef?: MutableRefObject; +} + +/** The "Make room for voiceover" carve control: renders nothing when there's + * no other audio track eligible as the voiceover source. */ +export function VstCarveSection({ + projectId, + element, + trackId, + busy, + setBusy, + onSetAttribute, + onChainWritten, + domEditSaveTimestampRef, +}: VstCarveSectionProps) { + const elements = usePlayerStore((s) => s.elements); + const [carveOpen, setCarveOpen] = useState(false); + const [carveAmount, setCarveAmount] = useState(50); + + // Other audio tracks eligible as the voiceover source (exclude this track). + const voCandidates = elements.filter( + (el) => el.id !== trackId && isAudioTimelineElement(el) && Boolean(el.src), + ); + const defaultVoId = + voCandidates.find((el) => el.timelineRole === "voiceover")?.id ?? voCandidates[0]?.id ?? ""; + const [carveVoId, setCarveVoId] = useState(defaultVoId); + // Keep the selection valid as tracks change / the panel first opens. + useEffect(() => { + if (!voCandidates.some((el) => el.id === carveVoId)) setCarveVoId(defaultVoId); + }, [carveVoId, defaultVoId, voCandidates]); + + if (voCandidates.length === 0) return null; + + const handleCarve = async () => { + if (busy) return; + const music = elements.find((el) => el.id === trackId); + const vo = voCandidates.find((el) => el.id === carveVoId); + setBusy(true); + try { + const result = await runCarve({ + projectId, + element, + musicSrc: music?.src, + voiceSrc: vo?.src, + carveAmount, + }); + if (!result) return; + if (domEditSaveTimestampRef) domEditSaveTimestampRef.current = Date.now(); + // Seed the visible param sliders from what was just written (handled by + // the parent via `onChainWritten`). The sidecar-polling seed effect is + // keyed on the chain's STRUCTURE (formats + paths, deliberately — so + // ordinary knob drags don't re-seed and clobber), but re-running carve + // at a different amount keeps the same PeakFilter structure and only + // changes gains — the effect never re-fires, and the rows kept + // displaying the PREVIOUS run's values ("the amount slider does + // nothing" when judged by the displayed numbers, even though the file + // and the audio were right). + onChainWritten(result.chain); + await onSetAttribute("vst-chain", result.path); + usePlayerStore.getState().bumpVstChainRevision(); + setCarveOpen(false); + } finally { + setBusy(false); + } + }; + + return ( +
+ {!carveOpen ? ( + + ) : ( +
+ + +
+ + +
+
+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelVstPluginRows.tsx b/packages/studio/src/components/editor/propertyPanelVstPluginRows.tsx new file mode 100644 index 0000000000..a3993ba96f --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelVstPluginRows.tsx @@ -0,0 +1,198 @@ +import { type MutableRefObject } from "react"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { isPluginEnabled, type ChainFileJson } from "../../utils/vstChainFile"; +import { + humanizeParam, + paramRange, + writeChainFile, + type VstHostApi, +} from "./propertyPanelVstShared"; + +export interface VstPluginRowsProps { + /** Non-null and non-empty-checked by the caller — this component only + * renders once a chain file has actually loaded for the element. */ + chain: ChainFileJson; + chainPath: string; + projectId: string; + trackId: string; + vstHost: VstHostApi; + /** Editable parameter values per plugin (null for external plugins, whose + * state is opaque — they keep the native editor). */ + paramsByPlugin: (Record | null)[]; + busy: boolean; + setBusy: (busy: boolean) => void; + setChain: (chain: ChainFileJson) => void; + /** Stamped after every chain-file write so the studio's file-watcher + * treats it as our own save and skips reloading the preview. */ + domEditSaveTimestampRef?: MutableRefObject; + onParamChange: (pluginIndex: number, param: string, value: number) => void; + onOpenEditor: (index: number) => void; + onRemovePlugin: (index: number) => void; +} + +/** Renders the "Disable all/Enable all" button plus one row per plugin + * (bypass toggle, native editor launcher, remove, and — for built-ins — + * the live parameter sliders). */ +export function VstPluginRows({ + chain, + chainPath, + projectId, + trackId, + vstHost, + paramsByPlugin, + busy, + setBusy, + setChain, + domEditSaveTimestampRef, + onParamChange, + onOpenEditor, + onRemovePlugin, +}: VstPluginRowsProps) { + // Shared write path for the bypass toggles: persist the rewritten chain and + // poke the preview to hot-reload it (bypass is applied by the sidecar's + // processing board, so an audible change requires the chain reload). + const persistChainRewrite = async (nextChain: ChainFileJson): Promise => { + if (busy) return; + setBusy(true); + try { + const ok = await writeChainFile(projectId, chainPath, nextChain); + if (!ok) return; + if (domEditSaveTimestampRef) domEditSaveTimestampRef.current = Date.now(); + setChain(nextChain); + usePlayerStore.getState().bumpVstChainRevision(); + } finally { + setBusy(false); + } + }; + + const handleTogglePlugin = async (index: number) => { + await persistChainRewrite({ + version: 1, + plugins: chain.plugins.map((plugin, i) => + i === index ? { ...plugin, enabled: !isPluginEnabled(plugin) } : plugin, + ), + }); + }; + + const anyEnabled = chain.plugins.some(isPluginEnabled); + + const handleToggleAll = async () => { + if (chain.plugins.length === 0) return; + // Any enabled -> disable all (quick "hear it dry" A/B); all disabled -> + // re-enable all. + await persistChainRewrite({ + version: 1, + plugins: chain.plugins.map((plugin) => ({ ...plugin, enabled: !anyEnabled })), + }); + }; + + if (chain.plugins.length === 0) { + return
No effects in this chain.
; + } + + return ( + <> + + + {chain.plugins.map((plugin, index) => { + const params = paramsByPlugin[index] ?? null; + const paramNames = params ? Object.keys(params) : []; + const pluginEnabled = isPluginEnabled(plugin); + return ( +
+
+ + {plugin.name} + {pluginEnabled ? "" : " (off)"} + + + {/* Built-in effects have no native editor window (show_editor + is a VST3/AU-only concept) — offering the button would do + nothing. Only external plugins get it. */} + {plugin.format !== "builtin" && ( + + )} + +
+ + {paramNames.length > 0 && ( +
+ {paramNames.map((name) => { + const [min, max, step] = paramRange(name); + const value = params?.[name] ?? 0; + return ( + + ); + })} +
+ )} +
+ ); + })} + + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelVstSection.test.tsx b/packages/studio/src/components/editor/propertyPanelVstSection.test.tsx new file mode 100644 index 0000000000..540b31e0bf --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelVstSection.test.tsx @@ -0,0 +1,849 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { VstSection, type VstHostApi } from "./propertyPanelVstSection"; +import type { DomEditSelection } from "./domEditing"; +import { + parseChainFile, + serializeChainFile, + type CarveBand, + type ChainFileJson, +} from "../../utils/vstChainFile"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { renderInto, setupReactActEnvironment } from "./testRenderUtils"; + +setupReactActEnvironment(); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeAudioElement(overrides: Partial = {}): DomEditSelection { + const el = document.createElement("audio"); + return { + element: el, + id: "vo-1", + selector: "#vo-1", + label: "VO 1", + tagName: "audio", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 0, height: 0 }, + textContent: "", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + }, + ...overrides, + } as DomEditSelection; +} + +function makeVstHost(overrides: Partial = {}): VstHostApi { + return { + registry: [], + scan: vi.fn(async () => {}), + openEditor: vi.fn(), + setParam: vi.fn(), + loadChain: vi.fn(async () => ({ trackIndex: 0, sampleRate: 48000, stable: true })), + getState: vi.fn(async () => []), + ...overrides, + }; +} + +function requestUrl(input: Parameters[0]): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function flushAsyncWork(): Promise { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +/** Picks option index `i` in the "Add effect" { + const picked = available[Number(e.target.value)]; + if (picked) void handleAddPlugin(picked); + }} + className="h-8 w-full rounded-md bg-panel-input px-2.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover disabled:cursor-not-allowed disabled:opacity-50" + > + + {available.map((entry, i) => ( + + ))} + + )} + + {chainPath && chain && ( + setEditorOpen(true)} + onRemovePlugin={(index) => void handleRemovePlugin(index)} + /> + )} + + +
+
+ ); +} diff --git a/packages/studio/src/components/studioBackgroundRemoval.ts b/packages/studio/src/components/studioBackgroundRemoval.ts new file mode 100644 index 0000000000..097edfa642 --- /dev/null +++ b/packages/studio/src/components/studioBackgroundRemoval.ts @@ -0,0 +1,60 @@ +import { waitForMediaJob } from "./studioMediaJobs"; +import type { + BackgroundRemovalProgress, + BackgroundRemovalResult, +} from "./editor/propertyPanelTypes"; + +/** + * POST a background-removal job for `inputPath`, then poll it to completion + * via `waitForMediaJob`. Aborts any in-flight removal tracked by + * `abortRef` before starting a new one (only one removal proceeds at a time). + */ +// fallow-ignore-next-line complexity +export async function removeBackgroundViaApi( + projectId: string, + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + deps: { + refreshFileTree: () => Promise; + showToast: (message: string, kind: "info" | "error") => void; + abortRef: { current: AbortController | null }; + }, +): Promise { + const response = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + inputPath, + createBackgroundPlate: options.createBackgroundPlate === true, + quality: options.quality ?? "balanced", + }), + }, + ); + const data = (await response.json().catch(() => ({}))) as { + jobId?: string; + error?: string; + }; + if (!response.ok || !data.jobId) { + throw new Error(data.error || `Background removal failed (${response.status})`); + } + deps.showToast("Removing background...", "info"); + deps.abortRef.current?.abort(); + const controller = new AbortController(); + deps.abortRef.current = controller; + try { + const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal); + await deps.refreshFileTree(); + deps.showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info"); + return result; + } finally { + if (deps.abortRef.current === controller) { + deps.abortRef.current = null; + } + } +}