From 1dadd554e564d87b79ed5903dc421fa13e523046 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:41:22 +0200 Subject: [PATCH] feat(studio): bind mixer tracks and optional SFX video with the picker Base and overlay mixer slots stay independent. SFX video remains optional and clears explicitly. Catalog choices store server paths and duration from metadata, without re-uploading. --- .../fixtures/architecture_wire_inventory.json | 6 ++ ui/src/components/Sidebar/MixerControls.tsx | 68 +++++++++-------- ui/src/components/Sidebar/SfxControls.tsx | 59 ++++++--------- ui/tests/mixerSfxPicker.test.tsx | 75 +++++++++++++++++++ 4 files changed, 141 insertions(+), 67 deletions(-) create mode 100644 ui/tests/mixerSfxPicker.test.tsx diff --git a/tests/fixtures/architecture_wire_inventory.json b/tests/fixtures/architecture_wire_inventory.json index 18550aad9..50a70ba07 100644 --- a/tests/fixtures/architecture_wire_inventory.json +++ b/tests/fixtures/architecture_wire_inventory.json @@ -487,6 +487,12 @@ "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/mixerSfxPicker.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/recastRestylePicker.test.tsx", "target": "ui/src/stores/useStore.ts", diff --git a/ui/src/components/Sidebar/MixerControls.tsx b/ui/src/components/Sidebar/MixerControls.tsx index 7fc2c8f3c..aad7e32a3 100644 --- a/ui/src/components/Sidebar/MixerControls.tsx +++ b/ui/src/components/Sidebar/MixerControls.tsx @@ -2,8 +2,10 @@ import { useState } from 'react' import { Plus, Trash2, Play, ArrowRight } from 'lucide-react' import { useStore } from '../../stores/useStore' import { useUiTranslation } from '../../i18n' -import { FileUploadZone } from '../shared/FileUploadZone' import * as api from '../../api/client' +import type { ApiOutput } from '../../api/outputs' +import { AssetInput } from '../../features/asset-picker/AssetInput.tsx' +import { applyChosenStudioMedia, studioMediaPath, useWorkspaceOutputs } from '../../lib/studioAssetPick.ts' interface MixerTrack { id: number @@ -20,20 +22,6 @@ function emptyTrack(): MixerTrack { return { id: _nextTrackId++, filename: null, path: null, startTime: 0, volume: 100, durationSec: null } } -function getAudioDuration(file: File): Promise { - return new Promise(resolve => { - const url = URL.createObjectURL(file) - const audio = new Audio() - audio.addEventListener('loadedmetadata', () => { - const dur = audio.duration - URL.revokeObjectURL(url) - resolve(Number.isFinite(dur) ? Math.round(dur * 10) / 10 : null) - }) - audio.addEventListener('error', () => { URL.revokeObjectURL(url); resolve(null) }) - audio.src = url - }) -} - export function MixerControls() { const { t } = useUiTranslation('studio') const { t: tCommon } = useUiTranslation('common') @@ -48,15 +36,23 @@ export function MixerControls() { const [mixing, setMixing] = useState(false) const [mixResult, setMixResult] = useState(null) const [error, setError] = useState(null) + const audioItems = useWorkspaceOutputs(activeWorkspace, 'audio') - const handleFileUpload = async (file: File, track: MixerTrack, update: (t: MixerTrack) => void) => { - try { - const result = await api.uploadImage(file) - const dur = await getAudioDuration(file) - update({ ...track, filename: file.name, path: result.path, durationSec: dur }) - } catch (e) { - console.error('Upload failed:', e) + const chooseTrack = (item: ApiOutput | null, track: MixerTrack, update: (next: MixerTrack) => void) => { + if (!item) { + update({ ...emptyTrack(), id: track.id, volume: track.volume }) + return } + const capturedId = track.id + applyChosenStudioMedia(item, next => { + update({ + ...track, + id: capturedId, + filename: item.name, + path: studioMediaPath(item), + durationSec: next.duration > 0 ? Math.round(next.duration * 10) / 10 : null, + }) + }) } const updateOverlay = (id: number, partial: Partial) => { @@ -121,12 +117,15 @@ export function MixerControls() { - handleFileUpload(f, baseTrack, setBaseTrack)} - onClear={() => setBaseTrack({ ...emptyTrack(), volume: 100 })} + placeholder={t('mixer.dropBase')} + items={audioItems} + accept=".wav,.mp3,.flac,.ogg,.m4a,audio/*" + workspaceId={activeWorkspace} + optional={Boolean(baseTrack.path)} + constraints={{ kinds: ['audio'], maxCount: 1, optional: true }} + onChoose={item => chooseTrack(item, baseTrack, setBaseTrack)} /> {baseTrack.path && (
@@ -175,12 +174,17 @@ export function MixerControls() { {idx + 1}.
{!track.path ? ( - handleFileUpload(f, track, t => updateOverlay(track.id, t))} - onClear={() => removeOverlay(track.id)} + placeholder={t('mixer.dropAudio')} + items={audioItems} + accept=".wav,.mp3,.flac,.ogg,.m4a,audio/*" + workspaceId={activeWorkspace} + constraints={{ kinds: ['audio'], maxCount: 1, optional: false }} + onChoose={item => { + if (!item) return + chooseTrack(item, track, next => updateOverlay(track.id, next)) + }} /> ) : (
diff --git a/ui/src/components/Sidebar/SfxControls.tsx b/ui/src/components/Sidebar/SfxControls.tsx index 87c8d1b8f..7c2d70e55 100644 --- a/ui/src/components/Sidebar/SfxControls.tsx +++ b/ui/src/components/Sidebar/SfxControls.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' import { useStore } from '../../stores/useStore' import { useUiTranslation } from '../../i18n' -import { FileUploadZone } from '../shared/FileUploadZone' -import * as api from '../../api/client' +import type { ApiOutput } from '../../api/outputs' +import { AssetInput } from '../../features/asset-picker/AssetInput.tsx' +import { applyChosenStudioMedia, studioMediaPath, useWorkspaceOutputs } from '../../lib/studioAssetPick.ts' /** * SFX mode controls for MMAudio — sound effects generation. @@ -15,36 +16,24 @@ export function SfxControls() { const durationSeconds = useStore(s => s.durationSeconds) const setDurationSeconds = useStore(s => s.setDurationSeconds) const [videoFilename, setVideoFilename] = useState(null) - const [uploading, setUploading] = useState(false) + const activeWorkspace = useStore(s => s.activeWorkspace) + const videoItems = useWorkspaceOutputs(activeWorkspace, 'video') const sfxPrompt = ((params as unknown as Record).MMAudio_prompt as string) || '' const sfxNegPrompt = ((params as unknown as Record).MMAudio_neg_prompt as string) || '' const textWeight = ((params as unknown as Record).sfx_text_weight as number) ?? 1.0 - const handleVideoUpload = async (file: File) => { - setUploading(true) - try { - const result = await api.uploadImage(file) - setParam('video_guide' as keyof typeof params, result.path) - setVideoFilename(file.name) - - // Try to get video duration - const url = URL.createObjectURL(file) - const video = document.createElement('video') - video.preload = 'metadata' - video.onloadedmetadata = () => { - if (Number.isFinite(video.duration) && video.duration > 0) { - setDurationSeconds(Math.round(video.duration * 10) / 10) - } - URL.revokeObjectURL(url) - } - video.onerror = () => URL.revokeObjectURL(url) - video.src = url - } catch (e) { - console.error('Upload failed:', e) - } finally { - setUploading(false) + const chooseVideo = (item: ApiOutput | null) => { + if (!item) { + setParam('video_guide' as keyof typeof params, undefined) + setVideoFilename(null) + return } + setParam('video_guide' as keyof typeof params, studioMediaPath(item)) + setVideoFilename(item.name) + applyChosenStudioMedia(item, next => { + if (next.duration > 0) setDurationSeconds(Math.round(next.duration * 10) / 10) + }) } return ( @@ -54,15 +43,15 @@ export function SfxControls() { - { - setParam('video_guide' as keyof typeof params, undefined) - setVideoFilename(null) - }} +

{t('sfx.hint')} diff --git a/ui/tests/mixerSfxPicker.test.tsx b/ui/tests/mixerSfxPicker.test.tsx new file mode 100644 index 000000000..8c69ea54e --- /dev/null +++ b/ui/tests/mixerSfxPicker.test.tsx @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import React from 'react' +import { JSDOM } from 'jsdom' + +function installDom() { + const dom = new JSDOM('', { url: 'http://localhost/' }) + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLInputElement: dom.window.HTMLInputElement, + Event: dom.window.Event, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { observe() {} disconnect() {} }, + }) + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator }) +} + +installDom() + +function mockFetch() { + return async (input: RequestInfo | URL) => { + const requestUrl = typeof input === 'string' ? input : (input as Request).url || String(input) + if (requestUrl.includes('/api/v1/assets')) { + return new Response(JSON.stringify({ total: 0, assets: [] }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }) + } +} + +test('Mixer base track exposes dual-origin AssetInput without opening the explorer', { concurrency: false }, async () => { + const { render, cleanup } = await import('@testing-library/react') + const { MixerControls } = await import('../src/components/Sidebar/MixerControls.tsx') + const { useStore } = await import('../src/stores/useStore.ts') + const previousFetch = globalThis.fetch + globalThis.fetch = mockFetch() + useStore.setState({ activeWorkspace: 'default' } as never) + try { + render() + const library = [...document.querySelectorAll('button')].filter(button => button.textContent?.includes('From HocusPocus')) + const device = [...document.querySelectorAll('button')].filter(button => button.textContent?.includes('From my computer')) + assert.ok(library.length >= 1) + assert.ok(device.length >= 1) + assert.equal(document.querySelector('[data-testid="asset-explorer"]'), null) + } finally { + cleanup() + document.body.innerHTML = '' + globalThis.fetch = previousFetch + } +}) + +test('SFX optional video slot exposes dual-origin AssetInput without opening the explorer', { concurrency: false }, async () => { + const { render, cleanup } = await import('@testing-library/react') + const { SfxControls } = await import('../src/components/Sidebar/SfxControls.tsx') + const { useStore } = await import('../src/stores/useStore.ts') + const previousFetch = globalThis.fetch + globalThis.fetch = mockFetch() + useStore.setState({ activeWorkspace: 'default', params: {}, durationSeconds: 8 } as never) + try { + render() + const library = [...document.querySelectorAll('button')].filter(button => button.textContent?.includes('From HocusPocus')) + const device = [...document.querySelectorAll('button')].filter(button => button.textContent?.includes('From my computer')) + assert.ok(library.length >= 1) + assert.ok(device.length >= 1) + assert.equal(document.querySelector('[data-testid="asset-explorer"]'), null) + } finally { + cleanup() + document.body.innerHTML = '' + globalThis.fetch = previousFetch + } +})