Files
Genarrative/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.ts
T
lhk229 e1e4f487f5 实现 SFX V2 T4 生成面板交互
新增独立 SFX Prompt 助手状态机与请求隔离、单层撤销。

抽取通用音频预设跑马灯并接入 SFX 预设、计数和优化入口。

实现自动/手动时长、Loop、模型展示及同步提交参数冻结。

恢复项目布局状态,补齐回归测试并更新 T4 权威文档与共享决策。
2026-08-07 03:49:04 +00:00

601 lines
18 KiB
TypeScript

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<string, ActiveSoundEffectOperation>(),
);
const errorMessagesRef = useRef(new Map<string, string>());
const knownDialogIdsRef = useRef(new Set<string>());
const scopeKey = useMemo(
() => resolveScopeKey(currentUserId, projectId),
[currentUserId, projectId],
);
const committedScopeKeyRef = useRef(scopeKey);
const [dialogStates, setDialogStates] = useState<
Record<string, SoundEffectPromptAssistDialogState>
>({});
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<SoundEffectPromptAssistActionResult> => {
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,
],
);
}