import type { CanvasGenerationDialogState, GenerateDialogState, } from './ImageCanvasEditorTypes'; export const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS = 2048; const SOUND_EFFECT_PROMPT_OPERATION_ID_PREFIX = 'sound-effect-prompt-operation'; export type SoundEffectPromptValidationResult = | { ok: true; prompt: string; charCount: number; } | { ok: false; prompt: string; charCount: number; reason: 'empty' | 'too-long'; }; export type SoundEffectPromptOperationStatus = 'optimizing' | 'submitting'; export type SoundEffectPromptDialogState = { dialogId: string; status: 'idle' | SoundEffectPromptOperationStatus; operationId: string | null; undoPromptSnapshot: string | null; temporaryPromptSnapshot: string | null; }; export type SoundEffectPromptOperation = { dialogId: string; operationId: string; status: SoundEffectPromptOperationStatus; canonicalPrompt: string; }; type InternalSoundEffectPromptDialogState = SoundEffectPromptDialogState & { activeCanonicalPrompt: string | null; }; export type SoundEffectGenerationDialogState = CanvasGenerationDialogState & { mode: 'audio-sound-effect'; }; export function toSoundEffectGenerationDialog( dialog: GenerateDialogState | null, ): SoundEffectGenerationDialogState | null { if (!dialog || dialog.mode !== 'audio-sound-effect' || !dialog.id) { return null; } return { ...dialog, id: dialog.id, mode: 'audio-sound-effect' }; } /** * 按 JavaScript `String.trim()` 删除首尾 ECMAScript 空白和行终止符。内部空白、组合字符、 * ZWJ、U+200B 及其它 code point 均原样保留;首尾 U+FEFF 会被删除,U+0085 则保留。 */ export function canonicalizeSoundEffectPrompt(prompt: string) { return prompt.trim(); } export function countSoundEffectPromptCodePoints(prompt: string) { return Array.from(canonicalizeSoundEffectPrompt(prompt)).length; } export function validateSoundEffectPrompt( prompt: string, ): SoundEffectPromptValidationResult { const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt); const charCount = Array.from(canonicalPrompt).length; if (charCount === 0) { return { ok: false, prompt: canonicalPrompt, charCount, reason: 'empty', }; } if (charCount > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) { return { ok: false, prompt: canonicalPrompt, charCount, reason: 'too-long', }; } return { ok: true, prompt: canonicalPrompt, charCount }; } export function canOptimizeSoundEffectPrompt(prompt: string) { return validateSoundEffectPrompt(prompt).ok; } export function canGenerateSoundEffectFromPrompt(prompt: string) { return validateSoundEffectPrompt(prompt).ok; } function createIdleSoundEffectPromptDialogState( dialogId: string, ): InternalSoundEffectPromptDialogState { return { dialogId, status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; } function toPublicSoundEffectPromptDialogState( state: InternalSoundEffectPromptDialogState, ): SoundEffectPromptDialogState { return { dialogId: state.dialogId, status: state.status, operationId: state.operationId, undoPromptSnapshot: state.undoPromptSnapshot, temporaryPromptSnapshot: state.temporaryPromptSnapshot, }; } /** * SFX Prompt 的纯状态机。operation 在调用方第一次 await 前同步取得;模型只保存 * dialog 级临时状态,不把快照、错误或预设 UI 状态写入画布 layout。 */ export function createSoundEffectPromptStateModel() { const dialogStates = new Map(); let operationSequence = 0; const readInternalState = (dialogId: string) => dialogStates.get(dialogId) ?? createIdleSoundEffectPromptDialogState(dialogId); const saveState = (state: InternalSoundEffectPromptDialogState) => { dialogStates.set(state.dialogId, state); return toPublicSoundEffectPromptDialogState(state); }; const getDialogState = (dialogId: string) => toPublicSoundEffectPromptDialogState(readInternalState(dialogId)); const beginOperation = ({ dialogId, status, prompt, }: { dialogId: string; status: SoundEffectPromptOperationStatus; prompt: string; }) => { const currentState = readInternalState(dialogId); const validation = validateSoundEffectPrompt(prompt); const canonicalPrompt = validation.prompt; if (currentState.status === status && currentState.operationId !== null) { return { started: false as const, reason: 'duplicate' as const, canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, operation: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } if (currentState.status !== 'idle') { return { started: false as const, reason: 'locked' as const, canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, operation: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } if (!validation.ok) { return { started: false as const, reason: 'ineligible' as const, canonicalPrompt, operation: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } operationSequence += 1; const operation: SoundEffectPromptOperation = { dialogId, operationId: `${SOUND_EFFECT_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`, status, canonicalPrompt, }; const isAiOperation = status === 'optimizing'; const nextState: InternalSoundEffectPromptDialogState = { ...currentState, status, operationId: operation.operationId, undoPromptSnapshot: isAiOperation ? null : currentState.undoPromptSnapshot, temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null, activeCanonicalPrompt: canonicalPrompt, }; return { started: true as const, reason: 'started' as const, canonicalPrompt, operation, state: saveState(nextState), }; }; const isCurrentOperation = ( operation: SoundEffectPromptOperation, state: InternalSoundEffectPromptDialogState, ) => state.dialogId === operation.dialogId && state.operationId === operation.operationId && state.status === operation.status; const resolveAiOperation = ( operation: SoundEffectPromptOperation, prompt: string, ) => { const currentState = readInternalState(operation.dialogId); if ( operation.status !== 'optimizing' || !isCurrentOperation(operation, currentState) ) { return { applied: false, prompt: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } const validation = validateSoundEffectPrompt(prompt); if (!validation.ok) { const failedState: InternalSoundEffectPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: false, prompt: null, state: saveState(failedState), }; } const completedState: InternalSoundEffectPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: currentState.temporaryPromptSnapshot, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: validation.prompt, state: saveState(completedState), }; }; const rejectOperation = (operation: SoundEffectPromptOperation) => { const currentState = readInternalState(operation.dialogId); if (!isCurrentOperation(operation, currentState)) { return { applied: false, prompt: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } const isAiOperation = operation.status === 'optimizing'; const failedState: InternalSoundEffectPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: isAiOperation ? null : currentState.undoPromptSnapshot, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: currentState.activeCanonicalPrompt, state: saveState(failedState), }; }; const completeSubmittingOperation = ( operation: SoundEffectPromptOperation, ) => { const currentState = readInternalState(operation.dialogId); if ( operation.status !== 'submitting' || !isCurrentOperation(operation, currentState) ) { return { applied: false, prompt: null, state: toPublicSoundEffectPromptDialogState(currentState), }; } const completedState: InternalSoundEffectPromptDialogState = { ...currentState, status: 'idle', operationId: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: currentState.activeCanonicalPrompt ?? operation.canonicalPrompt, state: saveState(completedState), }; }; const preparePreset = (dialogId: string, prompt: string) => { const currentState = readInternalState(dialogId); const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt); if (currentState.status !== 'idle') { return { applied: false, prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, state: toPublicSoundEffectPromptDialogState(currentState), }; } const nextState: InternalSoundEffectPromptDialogState = { ...currentState, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: canonicalPrompt, state: saveState(nextState), }; }; const swapUndoSnapshot = (dialogId: string, prompt: string) => { const currentState = readInternalState(dialogId); if ( currentState.status !== 'idle' || currentState.undoPromptSnapshot === null ) { return { applied: false, prompt, state: toPublicSoundEffectPromptDialogState(currentState), }; } const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt); const nextPrompt = currentState.undoPromptSnapshot; const nextState: InternalSoundEffectPromptDialogState = { ...currentState, undoPromptSnapshot: canonicalPrompt, }; return { applied: true, prompt: nextPrompt, state: saveState(nextState), }; }; const closeDialog = (dialogId: string) => dialogStates.delete(dialogId); const reset = () => dialogStates.clear(); return { getDialogState, beginOperation, resolveAiOperation, rejectOperation, completeSubmittingOperation, preparePreset, swapUndoSnapshot, closeDialog, reset, }; }