import type { CanvasGenerationDialogState, GenerateDialogState, } from './ImageCanvasEditorTypes'; export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200; export const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS = BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS * 10; export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1; export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2; const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u; const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u; const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u; const BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX = 'background-music-prompt-operation'; export type BackgroundMusicPromptOperationStatus = | 'completing' | 'simplifying' | 'submitting'; export type BackgroundMusicPromptDialogStatus = | 'idle' | BackgroundMusicPromptOperationStatus; export type BackgroundMusicPromptOperation = { dialogId: string; operationId: string; status: BackgroundMusicPromptOperationStatus; canonicalPrompt: string; }; export type BackgroundMusicPromptDialogState = { dialogId: string; status: BackgroundMusicPromptDialogStatus; operationId: string | null; undoPromptSnapshot: string | null; temporaryPromptSnapshot: string | null; }; export type BackgroundMusicPromptOperationStartResult = { started: boolean; reason: 'started' | 'duplicate' | 'ineligible' | 'locked'; canonicalPrompt: string; operation: BackgroundMusicPromptOperation | null; state: BackgroundMusicPromptDialogState; }; export type BackgroundMusicPromptOperationResolution = { applied: boolean; prompt: string | null; state: BackgroundMusicPromptDialogState; }; export type BackgroundMusicPromptBoundaryResult = { applied: boolean; prompt: string; state: BackgroundMusicPromptDialogState; }; type InternalBackgroundMusicPromptDialogState = BackgroundMusicPromptDialogState & { activeCanonicalPrompt: string | null; }; export type BackgroundMusicPromptStateModel = ReturnType< typeof createBackgroundMusicPromptStateModel >; export type BackgroundMusicGenerationDialogState = CanvasGenerationDialogState & { mode: 'audio-background-music'; }; /** * `GenerateDialogState` 是单一对象类型而不是可判别联合,`mode === 'audio-background-music'` * 只窄化属性读取,展开后 `mode` 与可选 `id` 仍是宽类型。助手动作全部按 dialog ID 生效, * 所以消费前必须先收窄出带稳定 ID 的 BGM dialog,不能把可选 `id` 直接传下去。 */ export function toBackgroundMusicGenerationDialog( dialog: GenerateDialogState | null, ): BackgroundMusicGenerationDialogState | null { if (!dialog || dialog.mode !== 'audio-background-music' || !dialog.id) { return null; } return { ...dialog, id: dialog.id, mode: 'audio-background-music' }; } export function canonicalizeBackgroundMusicPrompt(prompt: string) { return prompt .replace(LEADING_UNICODE_WHITE_SPACE, '') .replace(TRAILING_UNICODE_WHITE_SPACE, ''); } export function countPromptCodePoints(prompt: string) { return Array.from(canonicalizeBackgroundMusicPrompt(prompt)).length; } export function countEffectivePromptCodePoints(prompt: string) { let effectiveCodePoints = 0; for (const codePoint of canonicalizeBackgroundMusicPrompt(prompt)) { if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) { effectiveCodePoints += 1; } } return effectiveCodePoints; } export function canGenerateBackgroundMusicFromPrompt(prompt: string) { return ( countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS && countEffectivePromptCodePoints(prompt) >= BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS ); } export function canCompleteBackgroundMusicPrompt(prompt: string) { return ( countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS && countEffectivePromptCodePoints(prompt) >= BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS ); } export function canSimplifyBackgroundMusicPrompt(prompt: string) { const codePointCount = countPromptCodePoints(prompt); return ( codePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS && codePointCount <= BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS && countEffectivePromptCodePoints(prompt) >= BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS ); } function createIdleBackgroundMusicPromptDialogState( dialogId: string, ): InternalBackgroundMusicPromptDialogState { return { dialogId, status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; } function toPublicBackgroundMusicPromptDialogState( state: InternalBackgroundMusicPromptDialogState, ): BackgroundMusicPromptDialogState { return { dialogId: state.dialogId, status: state.status, operationId: state.operationId, undoPromptSnapshot: state.undoPromptSnapshot, temporaryPromptSnapshot: state.temporaryPromptSnapshot, }; } function canStartBackgroundMusicPromptOperation( status: BackgroundMusicPromptOperationStatus, canonicalPrompt: string, ) { if (status === 'completing') { return canCompleteBackgroundMusicPrompt(canonicalPrompt); } if (status === 'simplifying') { return canSimplifyBackgroundMusicPrompt(canonicalPrompt); } return canGenerateBackgroundMusicFromPrompt(canonicalPrompt); } /** * Keeps dialog-scoped Prompt workflow state outside React. All operation claims * happen synchronously, so callers can store one model instance in a ref and * decide whether to issue a request before their first await. */ export function createBackgroundMusicPromptStateModel() { const dialogStates = new Map< string, InternalBackgroundMusicPromptDialogState >(); let operationSequence = 0; const readInternalState = (dialogId: string) => dialogStates.get(dialogId) ?? createIdleBackgroundMusicPromptDialogState(dialogId); const saveState = (state: InternalBackgroundMusicPromptDialogState) => { dialogStates.set(state.dialogId, state); return toPublicBackgroundMusicPromptDialogState(state); }; const getDialogState = (dialogId: string): BackgroundMusicPromptDialogState => toPublicBackgroundMusicPromptDialogState(readInternalState(dialogId)); const beginOperation = ({ dialogId, status, prompt, }: { dialogId: string; status: BackgroundMusicPromptOperationStatus; prompt: string; }): BackgroundMusicPromptOperationStartResult => { const currentState = readInternalState(dialogId); const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt); if (currentState.status === status && currentState.operationId !== null) { return { started: false, reason: 'duplicate', canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, operation: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } if (currentState.status === 'submitting') { return { started: false, reason: 'locked', canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, operation: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } if (!canStartBackgroundMusicPromptOperation(status, canonicalPrompt)) { return { started: false, reason: 'ineligible', canonicalPrompt, operation: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } operationSequence += 1; const operation: BackgroundMusicPromptOperation = { dialogId, operationId: `${BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`, status, canonicalPrompt, }; const isAiOperation = status === 'completing' || status === 'simplifying'; const nextState: InternalBackgroundMusicPromptDialogState = { ...currentState, status, operationId: operation.operationId, undoPromptSnapshot: isAiOperation ? null : currentState.undoPromptSnapshot, temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null, activeCanonicalPrompt: canonicalPrompt, }; return { started: true, reason: 'started', canonicalPrompt, operation, state: saveState(nextState), }; }; const isCurrentOperation = ( operation: BackgroundMusicPromptOperation, state: InternalBackgroundMusicPromptDialogState, ) => state.dialogId === operation.dialogId && state.operationId === operation.operationId && state.status === operation.status; const resolveAiOperation = ( operation: BackgroundMusicPromptOperation, prompt: string, ): BackgroundMusicPromptOperationResolution => { const currentState = readInternalState(operation.dialogId); if ( operation.status === 'submitting' || !isCurrentOperation(operation, currentState) ) { return { applied: false, prompt: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt); if (!canGenerateBackgroundMusicFromPrompt(canonicalPrompt)) { const failedState: InternalBackgroundMusicPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: false, prompt: null, state: saveState(failedState), }; } const completedState: InternalBackgroundMusicPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: currentState.temporaryPromptSnapshot, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: canonicalPrompt, state: saveState(completedState), }; }; const rejectOperation = ( operation: BackgroundMusicPromptOperation, ): BackgroundMusicPromptOperationResolution => { const currentState = readInternalState(operation.dialogId); if (!isCurrentOperation(operation, currentState)) { return { applied: false, prompt: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } const isAiOperation = operation.status !== 'submitting'; const failedState: InternalBackgroundMusicPromptDialogState = { ...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: BackgroundMusicPromptOperation, ): BackgroundMusicPromptOperationResolution => { const currentState = readInternalState(operation.dialogId); if ( operation.status !== 'submitting' || !isCurrentOperation(operation, currentState) ) { return { applied: false, prompt: null, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } const submittedPrompt = currentState.activeCanonicalPrompt ?? operation.canonicalPrompt; const completedState: InternalBackgroundMusicPromptDialogState = { ...currentState, status: 'idle', operationId: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: submittedPrompt, state: saveState(completedState), }; }; const preparePreset = ( dialogId: string, prompt: string, ): BackgroundMusicPromptBoundaryResult => { const currentState = readInternalState(dialogId); const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt); if (currentState.status === 'submitting') { return { applied: false, prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } const nextState: InternalBackgroundMusicPromptDialogState = { ...currentState, status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, activeCanonicalPrompt: null, }; return { applied: true, prompt: canonicalPrompt, state: saveState(nextState), }; }; const swapUndoSnapshot = ( dialogId: string, prompt: string, ): BackgroundMusicPromptBoundaryResult => { const currentState = readInternalState(dialogId); if ( currentState.status !== 'idle' || currentState.undoPromptSnapshot === null ) { return { applied: false, prompt, state: toPublicBackgroundMusicPromptDialogState(currentState), }; } const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt); const nextPrompt = currentState.undoPromptSnapshot; const nextState: InternalBackgroundMusicPromptDialogState = { ...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, }; }