import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { type EditorSoundEffectDurationMode, SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, } from '../../../packages/shared/src/contracts/editorAudio'; import { optimizeEditorSoundEffectPrompt } from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; import { canonicalizeSoundEffectPrompt, countSoundEffectPromptCodePoints, createSoundEffectPromptStateModel, type SoundEffectPromptDialogState, type SoundEffectPromptOperation, validateSoundEffectPrompt, } from './ImageCanvasSoundEffectPromptModel'; const DEFAULT_MANUAL_DURATION_SECONDS = 5; type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; type ActiveSoundEffectOperation = { operation: SoundEffectPromptOperation; abortController: AbortController | null; scopeKey: string; }; export type SoundEffectPromptAssistDialogState = SoundEffectPromptDialogState & { errorMessage: string | null; }; export type SoundEffectPromptAssistActionResult = { started: boolean; applied: boolean; reason: | 'applied' | 'duplicate' | 'ineligible' | 'locked' | 'stale' | 'failed'; errorMessage: string | null; }; export type SoundEffectPromptSubmissionClaim = { operation: SoundEffectPromptOperation; prompt: string; durationMode: EditorSoundEffectDurationMode; manualDurationSeconds: number; loop: boolean; }; export type SoundEffectPromptAssistController = ReturnType< typeof useImageCanvasSoundEffectPromptAssist >; export type SoundEffectPromptAssistComposerController = Pick< SoundEffectPromptAssistController, 'getDialogState' | 'optimizePrompt' | 'preparePreset' | 'undoPrompt' >; function isSoundEffectDialog( dialog: CanvasGenerationDialogState | undefined, ): dialog is CanvasGenerationDialogState & { mode: 'audio-sound-effect' } { return dialog?.mode === 'audio-sound-effect'; } function isAbortError(error: unknown) { return ( (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError') || (error instanceof Error && error.name === 'AbortError') ); } function resolvePromptAssistErrorMessage(error: unknown) { if (isAbortError(error)) { return null; } if (error instanceof Error && error.message.trim()) { return error.message.trim(); } return 'AI 暂时无法优化游戏音效描述,请稍后重试'; } function invalidPromptAssistResponseError() { return new Error('AI 返回的游戏音效描述无效,请稍后重试'); } function resolveScopeKey( currentUserId: string | null | undefined, projectId: string | null | undefined, ) { return JSON.stringify([currentUserId ?? null, projectId ?? null]); } function normalizeManualDuration(value: number | null | undefined) { if (typeof value !== 'number' || !Number.isFinite(value)) { return DEFAULT_MANUAL_DURATION_SECONDS; } return Math.min( SOUND_EFFECT_DURATION_MAX_SECONDS, Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, value), ); } export function useImageCanvasSoundEffectPromptAssist({ canvasGenerationDialogs, getCanvasGenerationDialogById, updateCanvasGenerationDialogById, currentUserId, projectId, }: { canvasGenerationDialogs: CanvasGenerationDialogState[]; getCanvasGenerationDialogById: ( dialogId: string, ) => CanvasGenerationDialogState | undefined; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; currentUserId?: string | null; projectId?: string | null; }) { const modelRef = useRef(createSoundEffectPromptStateModel()); const activeOperationsRef = useRef( new Map(), ); const errorMessagesRef = useRef(new Map()); const knownDialogIdsRef = useRef(new Set()); const scopeKey = useMemo( () => resolveScopeKey(currentUserId, projectId), [currentUserId, projectId], ); const committedScopeKeyRef = useRef(scopeKey); const [dialogStates, setDialogStates] = useState< Record >({}); const publishDialogState = useCallback((dialogId: string) => { const nextState: SoundEffectPromptAssistDialogState = { ...modelRef.current.getDialogState(dialogId), errorMessage: errorMessagesRef.current.get(dialogId) ?? null, }; setDialogStates((currentStates) => ({ ...currentStates, [dialogId]: nextState, })); return nextState; }, []); const updateDialogPrompt = useCallback( (dialogId: string, prompt: string) => { updateCanvasGenerationDialogById(dialogId, (dialog) => dialog.mode === 'audio-sound-effect' ? { ...dialog, prompt, status: dialog.status === 'failed' ? 'idle' : dialog.status, errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, } : dialog, ); }, [updateCanvasGenerationDialogById], ); const clearActiveOperation = useCallback( ( dialogId: string, options: { abort: boolean; removeDialogState: boolean }, ) => { const activeOperation = activeOperationsRef.current.get(dialogId); if (activeOperation) { if (options.abort) { activeOperation.abortController?.abort(); } modelRef.current.rejectOperation(activeOperation.operation); activeOperationsRef.current.delete(dialogId); } errorMessagesRef.current.delete(dialogId); if (options.removeDialogState) { modelRef.current.closeDialog(dialogId); setDialogStates((currentStates) => { if (!(dialogId in currentStates)) { return currentStates; } const nextStates = { ...currentStates }; delete nextStates[dialogId]; return nextStates; }); return; } if (activeOperation) { publishDialogState(dialogId); } }, [publishDialogState], ); const discardStaleOperation = useCallback( (operation: SoundEffectPromptOperation) => { if ( modelRef.current.getDialogState(operation.dialogId).operationId !== operation.operationId ) { return; } modelRef.current.rejectOperation(operation); activeOperationsRef.current.delete(operation.dialogId); publishDialogState(operation.dialogId); }, [publishDialogState], ); useEffect(() => { const currentDialogIds = new Set( canvasGenerationDialogs .filter(isSoundEffectDialog) .map((dialog) => dialog.id), ); for (const dialogId of knownDialogIdsRef.current) { if (!currentDialogIds.has(dialogId)) { clearActiveOperation(dialogId, { abort: true, removeDialogState: true, }); } } for (const [dialogId, activeOperation] of activeOperationsRef.current) { if (activeOperation.operation.status === 'submitting') { continue; } const dialog = getCanvasGenerationDialogById(dialogId); if (!isSoundEffectDialog(dialog) || dialog.composerOpen === false) { clearActiveOperation(dialogId, { abort: true, removeDialogState: !isSoundEffectDialog(dialog), }); } } knownDialogIdsRef.current = currentDialogIds; }, [ canvasGenerationDialogs, clearActiveOperation, getCanvasGenerationDialogById, ]); useLayoutEffect(() => { if (Object.is(committedScopeKeyRef.current, scopeKey)) { return; } committedScopeKeyRef.current = scopeKey; for (const activeOperation of activeOperationsRef.current.values()) { activeOperation.abortController?.abort(); } activeOperationsRef.current.clear(); errorMessagesRef.current.clear(); modelRef.current.reset(); knownDialogIdsRef.current = new Set( canvasGenerationDialogs .filter(isSoundEffectDialog) .map((dialog) => dialog.id), ); setDialogStates({}); }, [canvasGenerationDialogs, scopeKey]); useEffect( () => () => { for (const activeOperation of activeOperationsRef.current.values()) { activeOperation.abortController?.abort(); } activeOperationsRef.current.clear(); }, [], ); const optimizePrompt = useCallback( async (dialogId: string): Promise => { const dialog = getCanvasGenerationDialogById(dialogId); if (!isSoundEffectDialog(dialog) || dialog.composerOpen === false) { return { started: false, applied: false, reason: 'stale', errorMessage: null, }; } const canonicalPrompt = canonicalizeSoundEffectPrompt(dialog.prompt); updateDialogPrompt(dialogId, canonicalPrompt); const start = modelRef.current.beginOperation({ dialogId, status: 'optimizing', prompt: canonicalPrompt, }); if (!start.started || !start.operation) { publishDialogState(dialogId); return { started: false, applied: false, reason: start.reason, errorMessage: null, }; } const abortController = new AbortController(); const operation = start.operation; const operationScopeKey = committedScopeKeyRef.current; activeOperationsRef.current.set(dialogId, { operation, abortController, scopeKey: operationScopeKey, }); errorMessagesRef.current.delete(dialogId); publishDialogState(dialogId); try { const response = await optimizeEditorSoundEffectPrompt( { currentPrompt: operation.canonicalPrompt }, { signal: abortController.signal }, ); const activeOperation = activeOperationsRef.current.get(dialogId); const latestDialog = getCanvasGenerationDialogById(dialogId); if ( activeOperation?.operation.operationId !== operation.operationId || !Object.is(activeOperation.scopeKey, operationScopeKey) || !Object.is(operationScopeKey, committedScopeKeyRef.current) ) { discardStaleOperation(operation); return { started: true, applied: false, reason: 'stale', errorMessage: null, }; } if ( !isSoundEffectDialog(latestDialog) || latestDialog.composerOpen === false ) { clearActiveOperation(dialogId, { abort: false, removeDialogState: !isSoundEffectDialog(latestDialog), }); return { started: true, applied: false, reason: 'stale', errorMessage: null, }; } const validation = validateSoundEffectPrompt(response.prompt); if ( !validation.ok || !Number.isInteger(response.charCount) || response.charCount !== countSoundEffectPromptCodePoints(validation.prompt) ) { throw invalidPromptAssistResponseError(); } const resolution = modelRef.current.resolveAiOperation( operation, validation.prompt, ); activeOperationsRef.current.delete(dialogId); if (!resolution.applied || !resolution.prompt) { throw invalidPromptAssistResponseError(); } errorMessagesRef.current.delete(dialogId); updateDialogPrompt(dialogId, resolution.prompt); publishDialogState(dialogId); return { started: true, applied: true, reason: 'applied', errorMessage: null, }; } catch (error) { const activeOperation = activeOperationsRef.current.get(dialogId); if ( activeOperation?.operation.operationId !== operation.operationId || !Object.is(activeOperation.scopeKey, operationScopeKey) || !Object.is(operationScopeKey, committedScopeKeyRef.current) ) { discardStaleOperation(operation); return { started: true, applied: false, reason: 'stale', errorMessage: null, }; } const latestDialog = getCanvasGenerationDialogById(dialogId); if ( !isSoundEffectDialog(latestDialog) || latestDialog.composerOpen === false ) { clearActiveOperation(dialogId, { abort: false, removeDialogState: !isSoundEffectDialog(latestDialog), }); return { started: true, applied: false, reason: 'stale', errorMessage: null, }; } const resolution = modelRef.current.rejectOperation(operation); activeOperationsRef.current.delete(dialogId); if (resolution.applied && resolution.prompt) { updateDialogPrompt(dialogId, resolution.prompt); } const errorMessage = resolvePromptAssistErrorMessage(error); if (errorMessage) { errorMessagesRef.current.set(dialogId, errorMessage); } else { errorMessagesRef.current.delete(dialogId); } publishDialogState(dialogId); return { started: true, applied: false, reason: errorMessage ? 'failed' : 'stale', errorMessage, }; } finally { const activeOperation = activeOperationsRef.current.get(dialogId); if ( activeOperation?.operation.operationId === operation.operationId && Object.is(activeOperation.scopeKey, operationScopeKey) ) { activeOperationsRef.current.delete(dialogId); } } }, [ clearActiveOperation, discardStaleOperation, getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt, ], ); const preparePreset = useCallback( (dialogId: string) => { const dialog = getCanvasGenerationDialogById(dialogId); if (!isSoundEffectDialog(dialog) || dialog.composerOpen === false) { return null; } const prepared = modelRef.current.preparePreset(dialogId, dialog.prompt); if (!prepared.applied) { publishDialogState(dialogId); return null; } updateDialogPrompt(dialogId, prepared.prompt); errorMessagesRef.current.delete(dialogId); publishDialogState(dialogId); return prepared.prompt; }, [getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt], ); const undoPrompt = useCallback( (dialogId: string) => { const dialog = getCanvasGenerationDialogById(dialogId); if (!isSoundEffectDialog(dialog) || dialog.composerOpen === false) { return false; } const canonicalPrompt = canonicalizeSoundEffectPrompt(dialog.prompt); updateDialogPrompt(dialogId, canonicalPrompt); const swapped = modelRef.current.swapUndoSnapshot( dialogId, canonicalPrompt, ); if (!swapped.applied) { return false; } errorMessagesRef.current.delete(dialogId); updateDialogPrompt(dialogId, swapped.prompt); publishDialogState(dialogId); return true; }, [getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt], ); const beginSubmission = useCallback( (dialogId: string): SoundEffectPromptSubmissionClaim | null => { const dialog = getCanvasGenerationDialogById(dialogId); if (!isSoundEffectDialog(dialog) || dialog.composerOpen === false) { return null; } const start = modelRef.current.beginOperation({ dialogId, status: 'submitting', prompt: dialog.prompt, }); if (start.reason !== 'duplicate') { updateDialogPrompt(dialogId, start.canonicalPrompt); } if (!start.started || !start.operation) { publishDialogState(dialogId); return null; } const operationScopeKey = committedScopeKeyRef.current; activeOperationsRef.current.set(dialogId, { operation: start.operation, abortController: null, scopeKey: operationScopeKey, }); errorMessagesRef.current.delete(dialogId); publishDialogState(dialogId); return { operation: start.operation, prompt: start.canonicalPrompt, durationMode: dialog.soundDurationMode === 'auto' ? 'auto' : 'manual', manualDurationSeconds: normalizeManualDuration( dialog.soundDurationSeconds, ), loop: dialog.soundLoop === true, }; }, [getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt], ); const finishSubmission = useCallback( ({ operation, accepted, }: { operation: SoundEffectPromptOperation; accepted: boolean; }) => { const activeOperation = activeOperationsRef.current.get( operation.dialogId, ); if ( activeOperation?.operation.operationId !== operation.operationId || !Object.is(activeOperation.scopeKey, committedScopeKeyRef.current) ) { return false; } const resolution = accepted ? modelRef.current.completeSubmittingOperation(operation) : modelRef.current.rejectOperation(operation); activeOperationsRef.current.delete(operation.dialogId); if (!resolution.applied) { return false; } publishDialogState(operation.dialogId); return true; }, [publishDialogState], ); const getDialogState = useCallback( (dialogId: string) => dialogStates[dialogId] ?? { ...modelRef.current.getDialogState(dialogId), errorMessage: errorMessagesRef.current.get(dialogId) ?? null, }, [dialogStates], ); return useMemo( () => ({ dialogStates, getDialogState, optimizePrompt, preparePreset, undoPrompt, beginSubmission, finishSubmission, }), [ beginSubmission, dialogStates, finishSubmission, getDialogState, optimizePrompt, preparePreset, undoPrompt, ], ); }