e1e4f487f5
新增独立 SFX Prompt 助手状态机与请求隔离、单层撤销。 抽取通用音频预设跑马灯并接入 SFX 预设、计数和优化入口。 实现自动/手动时长、Loop、模型展示及同步提交参数冻结。 恢复项目布局状态,补齐回归测试并更新 T4 权威文档与共享决策。
601 lines
18 KiB
TypeScript
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,
|
|
],
|
|
);
|
|
}
|