Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tests/fixtures/architecture_wire_inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 36 additions & 32 deletions ui/src/components/Sidebar/MixerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<number | null> {
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')
Expand All @@ -48,15 +36,23 @@ export function MixerControls() {
const [mixing, setMixing] = useState(false)
const [mixResult, setMixResult] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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<MixerTrack>) => {
Expand Down Expand Up @@ -121,12 +117,15 @@ export function MixerControls() {
<label className="text-[11px] text-text-muted uppercase tracking-wider mb-1.5 block">
{t('mixer.base')} <span className="normal-case text-text-muted">({t('mixer.fullDuration')})</span>
</label>
<FileUploadZone
<AssetInput
label={t('mixer.dropBase')}
accept=".wav,.mp3,.flac,.ogg,.m4a"
filename={baseTrack.filename}
onFile={f => 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 && (
<div className="flex items-center gap-3 mt-1.5">
Expand Down Expand Up @@ -175,12 +174,17 @@ export function MixerControls() {
<span className="text-[9px] text-text-muted shrink-0">{idx + 1}.</span>
<div className="flex-1 min-w-0">
{!track.path ? (
<FileUploadZone
<AssetInput
label={t('mixer.dropAudio')}
accept=".wav,.mp3,.flac,.ogg,.m4a"
filename={track.filename}
onFile={f => 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))
}}
/>
) : (
<div className="flex items-center gap-1.5 bg-bg-tertiary rounded px-2 py-1">
Expand Down
59 changes: 24 additions & 35 deletions ui/src/components/Sidebar/SfxControls.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,36 +16,24 @@ export function SfxControls() {
const durationSeconds = useStore(s => s.durationSeconds)
const setDurationSeconds = useStore(s => s.setDurationSeconds)
const [videoFilename, setVideoFilename] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const activeWorkspace = useStore(s => s.activeWorkspace)
const videoItems = useWorkspaceOutputs(activeWorkspace, 'video')

const sfxPrompt = ((params as unknown as Record<string, unknown>).MMAudio_prompt as string) || ''
const sfxNegPrompt = ((params as unknown as Record<string, unknown>).MMAudio_neg_prompt as string) || ''
const textWeight = ((params as unknown as Record<string, unknown>).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 (
Expand All @@ -54,15 +43,15 @@ export function SfxControls() {
<label className="text-[11px] text-text-muted uppercase tracking-wider mb-1.5 block">
{t('sfx.videoClip')} <span className="normal-case text-text-muted">({t('chrome.optional')})</span>
</label>
<FileUploadZone
label={uploading ? t('chrome.uploading') : t('sfx.dropVideo')}
accept=".mp4,.webm,.avi,.mov,.mkv"
filename={videoFilename}
onFile={handleVideoUpload}
onClear={() => {
setParam('video_guide' as keyof typeof params, undefined)
setVideoFilename(null)
}}
<AssetInput
label={t('sfx.dropVideo')}
placeholder={t('sfx.dropVideo')}
items={videoItems}
accept=".mp4,.webm,.avi,.mov,.mkv,video/*"
workspaceId={activeWorkspace}
optional
constraints={{ kinds: ['video'], maxCount: 1, optional: true }}
onChoose={chooseVideo}
/>
<p className="text-[9px] text-text-muted mt-1">
{t('sfx.hint')}
Expand Down
75 changes: 75 additions & 0 deletions ui/tests/mixerSfxPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><body></body></html>', { 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(<MixerControls />)
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(<SfxControls />)
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
}
})
Loading