diff --git a/ui/src/features/studio/actions.ts b/ui/src/features/studio/actions.ts index a6887a0d..d5429eab 100644 --- a/ui/src/features/studio/actions.ts +++ b/ui/src/features/studio/actions.ts @@ -13,6 +13,7 @@ import type { PrepareVideoCommand, QueueSfxPackCommand, } from './commands' +import { newSfxGenerationIntentId } from '../../api/sfxGenerationCommands' import { generationProvenancePayload, type GenerationSubmissionContext, @@ -127,6 +128,13 @@ export async function selectAudioModel( return selectedModel?.name || selected } +function sfxClipSubmissionContext( + context?: GenerationSubmissionContext, +): GenerationSubmissionContext | undefined { + if (!context) return undefined + return { ...context, commandId: newSfxGenerationIntentId() } +} + export async function queueSfxPack( action: QueueSfxPackCommand, context?: GenerationSubmissionContext, @@ -140,7 +148,10 @@ export async function queueSfxPack( for (const clip of action.clips) { applySfxClip(clip, negative) const before = new Set(useStore.getState().jobs) - await useStore.getState().startGeneration(undefined, context) + // Wizard issues one commandId for the pack capability. Reusing it as + // every clip's generation.sfx intent_id 409s distinct prompts and + // silently replays the first receipt when prompts match. + await useStore.getState().startGeneration(undefined, sfxClipSubmissionContext(context)) const created = useStore.getState().jobs.find(job => !before.has(job)) if (!created) throw new Error(`HocusPocus no encoló el efecto ${clip.name}.`) if (created.status === 'failed') throw new Error(created.error || created.message || `Falló ${clip.name}.`) diff --git a/ui/tests/studioSfxAction.test.mjs b/ui/tests/studioSfxAction.test.mjs index 01a33f5c..4af0e55a 100644 --- a/ui/tests/studioSfxAction.test.mjs +++ b/ui/tests/studioSfxAction.test.mjs @@ -9,7 +9,7 @@ window.matchMedia = () => ({ matches: false }) const { parseAgentTurn } = await import('../src/features/agent/agentActions.ts') const { getCapability } = await import('../src/features/agent/capabilityRegistry.ts') -const { prepareAudio } = await import('../src/features/studio/actions.ts') +const { prepareAudio, queueSfxPack } = await import('../src/features/studio/actions.ts') const { useStore } = await import('../src/stores/useStore.ts') const guide = '/api/v1/file/guide.mp4?workspace=source' const literal = ' Rain on a tin roof.\nKeep this exact. ' @@ -145,3 +145,31 @@ for (const settleWithFailure of [false, true]) { } }) } + +test('queue_sfx_pack mints a unique generation.sfx intent for each clip', async () => { + await withStudio(async () => { + const seen = [] + useStore.setState({ + jobs: [], + loadModelOptions: async () => undefined, + startGeneration: async (_scheduled, context) => { + seen.push(context?.commandId) + const job = { id: `job-${seen.length}`, status: 'queued' } + useStore.setState(state => ({ jobs: [job, ...state.jobs] })) + }, + }) + await queueSfxPack({ + type: 'queue_sfx_pack', + confirm: true, + clips: [ + { name: 'hit', prompt: 'impact', durationSeconds: 2 }, + { name: 'whoosh', prompt: 'whoosh', durationSeconds: 3 }, + { name: 'hit-again', prompt: 'impact', durationSeconds: 2 }, + ], + modelType: 'mmaudio_v2', + }, { actor: 'wizard', capability: 'queue_sfx_pack', commandId: 'pack-intent' }) + assert.equal(seen.length, 3) + assert.equal(new Set(seen).size, 3) + assert.ok(seen.every(id => typeof id === 'string' && id.length > 0 && id !== 'pack-intent')) + }) +})