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
3 changes: 2 additions & 1 deletion ui/src/features/studio/sfxCommandSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AppState } from '../../stores/useStore'
import type { GenerationSubmissionContext } from './generationProvenance'
import {
createStudioSfxGenerationCommand,
neutralizeStudioSfxFormResidue,
projectStudioSfxFormParams,
type StudioSfxGenerationCommand,
} from './sfxGenerationSpec'
Expand Down Expand Up @@ -69,7 +70,7 @@ export async function prepareStudioSfxSubmission(
// known video/H3 controls alongside the sfx fields. Project only that
// explicit form residue; the command builder remains closed for direct
// Wizard/MCP envelopes and rejects every other unknown key.
snapshotParams = projectStudioSfxFormParams(snapshotParams)
snapshotParams = neutralizeStudioSfxFormResidue(projectStudioSfxFormParams(snapshotParams))
assertSameSfxForm(before, current())
await canonicalVideoReference(snapshotParams)
assertSameSfxForm(before, current())
Expand Down
16 changes: 16 additions & 0 deletions ui/src/features/studio/sfxGenerationSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,19 @@ export function projectStudioSfxFormParams(params: Record<string, unknown>): Rec
return Object.fromEntries([...paramKeys, 'workspace', 'provenance']
.filter(key => params[key] !== undefined).map(key => [key, params[key]]))
}

/**
* The SFX textarea owns `MMAudio_prompt`. Speech/Music leftovers stay in
* `prompt` because audio sub-tabs share one Studio params map. A blank SFX
* box must not admit those lyrics as the sound description.
*/
export function neutralizeStudioSfxFormResidue(params: Record<string, unknown>): Record<string, unknown> {
const native = typeof params.MMAudio_prompt === 'string' ? params.MMAudio_prompt : ''
if (!native.trim()) {
const next = { ...params }
delete next.prompt
delete next.MMAudio_prompt
return next
}
return { ...params, prompt: native, MMAudio_prompt: native }
}
4 changes: 3 additions & 1 deletion ui/src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5040,7 +5040,9 @@ export const useStore = create<AppState>((set, get) => {
const sfxModel = params.model_type as string
params.MMAudio_setting = 1
params._mmaudio_variant = sfxModel === 'mmaudio_nsfw' ? 'nsfw' : 'v2'
params.prompt = params.MMAudio_prompt ?? params.prompt
// SFX owns MMAudio_prompt. Do not admit leftover Speech/Music lyrics
// from the shared `prompt` field when the SFX box was never filled.
params.prompt = typeof params.MMAudio_prompt === 'string' ? params.MMAudio_prompt : ''
params.sfx_mode = true
params.duration_seconds = state.durationSeconds
params.video_length = 0
Expand Down
43 changes: 43 additions & 0 deletions ui/tests/sfxGenerationCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const {
pendingSfxGenerationCommands,
submitSfxGenerationCommand,
} = await import('../src/api/sfxGenerationCommands.ts')
const {
neutralizeStudioSfxFormResidue,
projectStudioSfxFormParams,
} = await import('../src/features/studio/sfxGenerationSpec.ts')

const originalFetch = globalThis.fetch

Expand Down Expand Up @@ -204,3 +208,42 @@ test('an explicitly blank prompt alias cannot override the other literal', () =>
}
}
})

test('Studio SFX form residue drops leftover Speech/Music lyrics when the SFX box is empty', () => {
const leftover = {
workspace: 'sfx-output',
prompt: 'Speaker 1: leftover speech lyrics for a song',
model_type: 'mmaudio_v2',
duration_seconds: 15,
generation_mode: 'audio',
_audio_sub_mode: 'sfx',
image_mode: 0,
video_length: 0,
MMAudio_setting: 1,
sfx_mode: true,
num_inference_steps: 25,
}
const cleaned = neutralizeStudioSfxFormResidue(projectStudioSfxFormParams(leftover))
assert.equal('prompt' in cleaned, false)
assert.equal('MMAudio_prompt' in cleaned, false)
assert.throws(
() => createStudioSfxGenerationCommand(cleaned, 'leftover-speech'),
/literal sound description is required/,
)

const authored = neutralizeStudioSfxFormResidue(projectStudioSfxFormParams({
...leftover,
MMAudio_prompt: 'rain on tin',
}))
const command = createStudioSfxGenerationCommand(authored, 'authored-sfx')
assert.equal(command.input.params.MMAudio_prompt, 'rain on tin')
assert.equal(command.input.params.prompt, 'rain on tin')
})

test('direct SFX envelopes still admit a prompt-only MCP payload', () => {
const params = { ...baseParams('mcp-prompt-only') }
delete params.MMAudio_prompt
const command = createStudioSfxGenerationCommand(params, 'mcp-prompt-only')
assert.equal(command.input.params.prompt, params.prompt)
assert.equal(command.input.params.MMAudio_prompt, undefined)
})
22 changes: 22 additions & 0 deletions ui/tests/studioSfxCommandPresentation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,25 @@ test('store SFX submission keeps defaults and literal prompt while ignoring stal
useStore.setState(before)
}
})

test('Studio SFX submission rejects leftover Speech lyrics when MMAudio_prompt is empty', { concurrency: false }, async () => {
const leftover = {
...baseParams('leftover-speech'),
prompt: 'Speaker 1: leftover speech that must not become SFX',
}
delete leftover.MMAudio_prompt
leftover.duration_seconds = 15
const state = formState(leftover)
let generationCalls = 0
globalThis.fetch = (async input => {
if (String(input).endsWith('/generation/commands')) generationCalls += 1
return jsonResponse({})
}) as typeof fetch

const prepared = await prepareStudioSfxSubmission(leftover, state, () => state, {
actor: 'user', commandId: 'sfx-leftover-speech',
})
await assert.rejects(prepared.submit(), /literal sound description is required/)
assert.equal(generationCalls, 0)
assert.deepEqual(pendingSfxGenerationCommands(), [])
})