Files
Genarrative/src/components/image-editor/ImageCanvasSoundEffectPromptModel.ts
T
lhk229 5c316fc680 统一SFX提示词 trim 规范化
将 SFX Prompt 首尾规范化改为 ECMAScript String.trim 语义并同步 Rust 等值实现

更新共享边界 fixture、前后端与 Worker 回归测试

同步权威设计、实施计划、决策日志和 External OpenAPI 描述
2026-08-07 07:27:52 +00:00

385 lines
11 KiB
TypeScript

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<string, InternalSoundEffectPromptDialogState>();
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,
};
}