Files
Genarrative/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts
T
lhk229 281c84b7bf
Project CI / Frontend tests (push) Failing after 2m11s
Project CI / Backend tests (push) Successful in 3m33s
Project CI / Native shell tests (push) Successful in 13m57s
Project CI / Repository checks (push) Successful in 1m15s
BGM生成路径优化 (#142)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/142
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-06 17:54:40 +08:00

462 lines
14 KiB
TypeScript

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,
};
}