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 @@ -631,6 +631,12 @@
"classification": "behavior",
"reason": "Imports the public Zustand facade; it should survive slice extraction unchanged."
},
{
"file": "ui/tests/studioSubmissionFailure.test.ts",
"target": "ui/src/stores/useStore.ts",
"classification": "behavior",
"reason": "Imports the public Zustand facade; it should survive slice extraction unchanged."
},
{
"file": "ui/tests/tabFilterSearch.test.tsx",
"target": "ui/src/stores/useStore.ts",
Expand Down
8 changes: 5 additions & 3 deletions ui/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,11 @@ export function Sidebar() {
<div className="flex-1 min-w-0">
<ModelSelector />
</div>
<div className="shrink-0">
<GenerateButton />
</div>
{!(isAudio && audioSubMode === 'mixer') && (
<div className="shrink-0">
<GenerateButton />
</div>
)}
</div>
</div>
)}
Expand Down
6 changes: 6 additions & 0 deletions ui/src/features/studio/studioSubmission.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as api from '../../api/client'
import i18n from '../../i18n'

type Preparation = typeof import('./imageCommandSubmission').prepareStudioSubmission
type Inputs = Parameters<Preparation>
Expand Down Expand Up @@ -33,6 +34,11 @@ export async function prepareStudioSubmission(
const implementation = await import('./sfxCommandSubmission')
return await implementation.prepareStudioSfxSubmission(params, before, current, context, referenceErrors)
}
if (before.generationMode === 'audio') {
// Mixer is ffmpeg-only. An unknown Audio tab must not inherit leftover
// Speech/Music/SFX params and POST them to the legacy GPU endpoint.
throw new Error(i18n.t('studio:commands.audioNotGenerative'))
}
} catch (error) {
return { params, submit: () => Promise.reject(error) }
}
Expand Down
3 changes: 2 additions & 1 deletion ui/src/i18n/locales/en/studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -939,7 +939,8 @@
"panelUnavailable": "The image request could not be shown. Open Studio and try again.",
"pendingInvalid": "The saved submission could not be read. It has been preserved for recovery.",
"referenceFailed": "A selected reference could not be prepared. No image was submitted.",
"conflictingGuides": "Conflicting image guides are selected. Choose one control image and mask before generating."
"conflictingGuides": "Conflicting image guides are selected. Choose one control image and mask before generating.",
"audioNotGenerative": "This Audio tab does not start a generation. Use Speech, Music or SFX, or Mix & Save in Mixer."
},
"speechCommands": {
"prepared": "Speech request ready",
Expand Down
3 changes: 2 additions & 1 deletion ui/src/i18n/locales/es/studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -939,7 +939,8 @@
"panelUnavailable": "No se pudo mostrar la solicitud de imagen. Abre Studio y vuelve a intentarlo.",
"pendingInvalid": "No se pudo leer la solicitud guardada. Se ha conservado para recuperarla.",
"referenceFailed": "No se pudo preparar una referencia seleccionada. No se ha enviado ninguna imagen.",
"conflictingGuides": "Hay guías de imagen distintas seleccionadas. Elige una imagen de control y una máscara antes de generar."
"conflictingGuides": "Hay guías de imagen distintas seleccionadas. Elige una imagen de control y una máscara antes de generar.",
"audioNotGenerative": "Esta pestaña de Audio no inicia una generación. Usa Voz, Música o SFX, o Mezclar y guardar en el Mezclador."
},
"speechCommands": {
"prepared": "Solicitud de voz preparada",
Expand Down
3 changes: 3 additions & 0 deletions ui/src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4121,6 +4121,9 @@ export const useStore = create<AppState>((set, get) => {
isGenerating: false,
startGeneration: async (scheduledPrompt, submissionContext) => {
const initialState = get()
if (initialState.generationMode === 'audio' && initialState.audioSubMode === 'mixer') {
throw new Error(i18n.t('studio:commands.audioNotGenerative'))
}

// Studio Prompt Scheduler: each non-empty line becomes its own normal
// generation request. Submitting the requests one at a time preserves the
Expand Down
78 changes: 61 additions & 17 deletions ui/tests/studioSubmissionFailure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,71 @@ test('a missing image chunk produces a failed submission for the existing job er
assert.equal(requests, 0)
})

for (const mode of ['audio', 'video']) {
test(`${mode} submits through its native API without loading image code`, async () => {
const params = { prompt: 'literal\nsecond line', generation_mode: mode, workspace: 'command-qa' }
const before = state(mode, params)
let loads = 0
let sent: unknown
globalThis.fetch = async (_url, options) => {
sent = JSON.parse(String(options?.body))
return new Response(JSON.stringify({ job_id: 'native-job', status: 'queued' }), { status: 200 })
test('video submits through its native API without loading image code', async () => {
const params = { prompt: 'literal\nsecond line', generation_mode: 'video', workspace: 'command-qa' }
const before = state('video', params)
let loads = 0
let sent: unknown
globalThis.fetch = async (_url, options) => {
sent = JSON.parse(String(options?.body))
return new Response(JSON.stringify({ job_id: 'native-job', status: 'queued' }), { status: 200 })
}
const submission = await prepareStudioSubmission(params, before, () => before, undefined, [], async () => {
loads += 1
throw new Error('Image chunk unavailable')
})
const result = await submission.submit()
assert.equal(result.job_id, 'native-job')
assert.equal(loads, 0)
assert.deepEqual(sent, params)
})

function audioState(subMode: string, params: Record<string, unknown>) {
return { ...state('audio', params), audioSubMode: subMode } as Parameters<typeof prepareStudioSubmission>[1]
}

for (const subMode of ['mixer', '', undefined]) {
test(`audio ${subMode === undefined ? 'without a sub-mode' : `tab ${JSON.stringify(subMode)}`} does not fall back to the legacy GPU endpoint`, async () => {
const params = {
prompt: 'leftover speech lyrics',
model_type: 'kugelaudio_0_open',
generation_mode: 'audio',
workspace: 'command-qa',
_audio_sub_mode: subMode ?? 'mixer',
}
const submission = await prepareStudioSubmission(params, before, () => before, undefined, [], async () => {
loads += 1
throw new Error('Image chunk unavailable')
})
const result = await submission.submit()
assert.equal(result.job_id, 'native-job')
assert.equal(loads, 0)
assert.deepEqual(sent, params)
const before = subMode === undefined ? state('audio', params) : audioState(subMode, params)
let requests = 0
globalThis.fetch = async () => { requests += 1; throw new Error('Unexpected POST') }
const submission = await prepareStudioSubmission(params, before, () => before)
await assert.rejects(submission.submit, /does not start a generation|no inicia una generación/)
assert.equal(requests, 0)
})
}

test('startGeneration refuses Mixer before creating a job or POSTing', { concurrency: false }, async () => {
const { useStore } = await import('../src/stores/useStore.ts')
const before = useStore.getState()
let requests = 0
globalThis.fetch = async () => { requests += 1; throw new Error('Unexpected POST') }
useStore.setState({
generationMode: 'audio',
audioSubMode: 'mixer',
params: { ...before.params, model_type: 'kugelaudio_0_open', prompt: 'leftover speech lyrics' },
jobs: [],
llmStatus: { ...before.llmStatus, loaded: false },
})
try {
await assert.rejects(
() => useStore.getState().startGeneration(),
/does not start a generation|no inicia una generación/,
)
assert.equal(useStore.getState().jobs.length, 0)
assert.equal(requests, 0)
} finally {
useStore.setState(before)
}
})

test('legacy image control and mask fields are translated in the detached V2 snapshot', async () => {
const params = { workspace: 'command-qa', prompt: 'Use this control image literally', model_type: 'pi_flux2',
resolution: '512x512', num_inference_steps: 4, seed: 42, guidance_scale: 1,
Expand Down
Loading