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 + } +})