281c84b7bf
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>
462 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|