5c316fc680
将 SFX Prompt 首尾规范化改为 ECMAScript String.trim 语义并同步 Rust 等值实现 更新共享边界 fixture、前后端与 Worker 回归测试 同步权威设计、实施计划、决策日志和 External OpenAPI 描述
385 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|