Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts
T
k88936 8abeb333bb
Project CI / Repository checks (push) Successful in 56s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 4m26s
Project CI / Native shell tests (push) Successful in 15m12s
统一画布快速编辑白名单与入口行为 (#140)
- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑

- 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验

其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画
  > 布迁移摘要同步及最终零残留复核。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-12 14:46:24 +08:00

2869 lines
95 KiB
TypeScript

import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { getExternalGenerationJobStatus } from '../../services/external-generation';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
type EditorMediaAssetUploadType,
uploadEditorMediaAssetObjectFile,
} from '../../services/image-editor/editorMediaAssetUploadClient';
import type {
EditorAssetSnapshot,
EditorCanvasGenerationCompletionInput,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
editEditorImage,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorScene,
generateEditorSoundEffect,
generateEditorVideo,
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import {
findCanvasGenerationDialogRecords,
isUnresolvedCanvasGenerationDialogRecord,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
CharacterReferenceImage,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
createCharacterAnimationResultLayer,
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createVideoResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
buildCharacterAnimationGenerationInputs,
buildQuickEditGenerationInputs,
buildUiDesignAssetExtractionGenerationInputs,
buildVideoGenerationInputs,
createCanvasLayerReference,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveImageGenerationDialogPrompt,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
} from './ImageCanvasUiAssetExtractionModel';
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist';
import type { SoundEffectPromptAssistController } from './useImageCanvasSoundEffectPromptAssist';
type CanvasSize = { width: number; height: number };
const EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX =
'图集已生成,但自动拆分未完成:';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type UiDesignAssetExtractionOptions = {
marks?: UiAssetExtractionMark[];
model?: string;
references?: CharacterReferenceImage[];
suppressAlert?: boolean;
};
type EditorGenerationMediaReference = {
src: string;
objectKey?: string | null;
resourceId?: string | null;
sourceAssetId?: string | null;
};
type EditorGenerationMediaReferenceOptions = {
allowRegisteredIds?: boolean;
requireImageObjectReference?: boolean;
// 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次
// 上传生成随机路径,避免并发参考图互相覆盖。
uploadId?: string;
// 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及
// ticket → PUT → confirm,不能只在最外层停止 await。
signal?: AbortSignal;
};
let editorGenerationUploadFallbackCounter = 0;
export function createEditorGenerationMediaUploadId() {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID();
}
if (typeof cryptoApi?.getRandomValues === 'function') {
const words = cryptoApi.getRandomValues(new Uint32Array(4));
return Array.from(words, (word) => word.toString(16).padStart(8, '0')).join(
'',
);
}
editorGenerationUploadFallbackCounter += 1;
return `${Date.now().toString(36)}-${editorGenerationUploadFallbackCounter.toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function resolveEditorGenerationMediaReferenceSource(
reference: EditorGenerationMediaReference,
allowRegisteredIds: boolean,
) {
const resourceId = reference.resourceId?.trim();
return (
reference.objectKey?.trim() ||
(allowRegisteredIds &&
resourceId &&
!resourceId.startsWith('local-resource-') &&
!resourceId.startsWith('generation-dialog:')
? resourceId
: '') ||
(allowRegisteredIds ? reference.sourceAssetId?.trim() : '') ||
reference.src.trim()
);
}
function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) {
signal?.throwIfAborted();
}
function dataUrlToEditorGenerationFile(
dataUrl: string,
fileName: string,
signal?: AbortSignal,
) {
throwIfEditorGenerationMediaUploadAborted(signal);
const [header = '', payload = ''] = dataUrl.split(',');
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
if (!mimeMatch) {
throw new Error('生成参考图不是有效的 Data URL');
}
const type = mimeMatch[1] ?? 'image/png';
const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload);
throwIfEditorGenerationMediaUploadAborted(signal);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
throwIfEditorGenerationMediaUploadAborted(signal);
return new File([bytes], fileName, { type });
}
async function inlineMediaSourceToEditorGenerationFile(
source: string,
mediaType: EditorMediaAssetUploadType,
uploadId: string,
signal?: AbortSignal,
) {
throwIfEditorGenerationMediaUploadAborted(signal);
const fileName = `generation-reference-${uploadId}.${
mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png'
}`;
if (/^data:/iu.test(source)) {
return dataUrlToEditorGenerationFile(source, fileName, signal);
}
const response = await fetch(source, { signal });
throwIfEditorGenerationMediaUploadAborted(signal);
if (!response.ok) {
throw new Error('读取本地生成参考素材失败');
}
const blob = await response.blob();
throwIfEditorGenerationMediaUploadAborted(signal);
return new File([blob], fileName, {
type: blob.type || `${mediaType}/*`,
});
}
async function uploadEditorGenerationInlineMediaSource(
source: string,
mediaType: EditorMediaAssetUploadType,
projectId?: string | null,
signal?: AbortSignal,
stableUploadId?: string | null,
) {
const normalizedProjectId = projectId?.trim() || 'unscoped';
const uploadId =
stableUploadId?.trim() || createEditorGenerationMediaUploadId();
const uploaded = await uploadEditorMediaAssetObjectFile(
await inlineMediaSourceToEditorGenerationFile(
source,
mediaType,
uploadId,
signal,
),
mediaType,
{
assetKind: `editor_generation_reference_${mediaType}`,
pathSegments: [
'editor',
'generation-references',
normalizedProjectId,
uploadId,
],
entityId: normalizedProjectId,
signal,
...(projectId?.trim()
? { metadata: { editor_project_id: projectId.trim() } }
: {}),
},
);
return uploaded.objectKey;
}
export async function resolveEditorGenerationMediaReference(
reference: EditorGenerationMediaReference,
mediaType: EditorMediaAssetUploadType,
projectId?: string | null,
options: EditorGenerationMediaReferenceOptions = {},
) {
throwIfEditorGenerationMediaUploadAborted(options.signal);
const resourceId = reference.resourceId?.trim();
const hasRegisteredReference =
options.allowRegisteredIds !== false &&
((resourceId &&
!resourceId.startsWith('local-resource-') &&
!resourceId.startsWith('generation-dialog:')) ||
reference.sourceAssetId?.trim());
const source = resolveEditorGenerationMediaReferenceSource(
reference,
options.allowRegisteredIds !== false,
);
const inlineSource = /^(?:data|blob):/iu.test(source);
const imageSourceRequiresUpload =
options.requireImageObjectReference !== false &&
mediaType === 'image' &&
!reference.objectKey?.trim() &&
!hasRegisteredReference;
if (!inlineSource && !imageSourceRequiresUpload) {
return source;
}
const uploadSource =
imageSourceRequiresUpload && !inlineSource
? await resolveEditorImageReferenceDataUrl(source, options.signal)
: source;
throwIfEditorGenerationMediaUploadAborted(options.signal);
return uploadEditorGenerationInlineMediaSource(
uploadSource,
mediaType,
projectId,
options.signal,
options.uploadId,
);
}
async function uploadEditorGenerationImageDataUrl(
dataUrl: string,
projectId?: string | null,
) {
return uploadEditorGenerationInlineMediaSource(dataUrl, 'image', projectId);
}
const EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS = 1600;
const EDITOR_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000;
const EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS = 650;
function getCanvasCompletionPlaceholderSizeFromPlan({
sourceLayer,
outputSize,
}: {
sourceLayer: CanvasLayer;
outputSize: string | null | undefined;
}) {
const match = /^(\d+)x(\d+)$/i.exec(outputSize?.trim() ?? '');
if (!match) {
return {
width: sourceLayer.originalWidth,
height: sourceLayer.originalHeight,
};
}
const width = Number(match[1]);
const height = Number(match[2]);
return {
width:
Number.isFinite(width) && width > 0 ? width : sourceLayer.originalWidth,
height:
Number.isFinite(height) && height > 0
? height
: sourceLayer.originalHeight,
};
}
type GenerationSubmissionWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
viewport: CanvasViewport;
layerCounterRef: MutableRefObject<number>;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditSelectionState: UiAssetExtractionState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationDialog: CanvasGenerationDialogState | null;
characterAnimationSourceLayer: CanvasLayer | null;
setCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => string;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
activeCanvasGenerationDialogId?: string | null;
backgroundMusicPromptAssist: BackgroundMusicPromptAssistController;
soundEffectPromptAssist: SoundEffectPromptAssistController;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
updateSourceLayer: (
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options?: { fit?: boolean; persist?: boolean },
) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
projectId?: string | null;
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
onGenerationWarning?: (message: string) => void;
};
async function normalizeGenerationReferenceImages<
T extends { referenceImageSrcs?: string[] },
>(
input: T,
references: CharacterReferenceImage[],
projectId?: string | null,
): Promise<T> {
if (!input.referenceImageSrcs?.length) {
return input;
}
return {
...input,
referenceImageSrcs: await Promise.all(
input.referenceImageSrcs.map((referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
references[index]
? { ...references[index], src: referenceImageSrc }
: { src: referenceImageSrc },
'image',
projectId,
),
),
),
};
}
function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
if (dialog.mode === 'spec') {
return dialog.specReference ? [dialog.specReference] : [];
}
if (dialog.mode === 'character') {
return [
...(dialog.characterSpecReference ? [dialog.characterSpecReference] : []),
...(dialog.characterReferences ?? []),
];
}
if (dialog.mode === 'ui-design') {
return [
...(dialog.uiDesignSpecReference ? [dialog.uiDesignSpecReference] : []),
...(dialog.generationReferences ?? []),
];
}
if (dialog.mode === 'publication') {
return dialog.publicationReferences ?? [];
}
return dialog.generationReferences ?? [];
}
async function normalizeVideoGenerationReferences(
input: Parameters<typeof generateEditorVideo>[0],
references: CharacterReferenceImage[],
projectId?: string | null,
) {
const normalizeSources = async (
sources: string[] | undefined,
mediaType: EditorMediaAssetUploadType,
) => {
if (!sources?.length) {
return undefined;
}
const matchingReferences = references.filter(
(reference) =>
(reference.mediaType ?? 'image') ===
(mediaType === 'image' ? 'image' : mediaType),
);
return Promise.all(
sources.map((source, index) =>
resolveEditorGenerationMediaReference(
matchingReferences[index]
? { ...matchingReferences[index], src: source }
: { src: source },
mediaType,
projectId,
{ allowRegisteredIds: false },
),
),
);
};
const [referenceImageSrcs, referenceVideoSrcs, referenceAudioSrcs] =
await Promise.all([
normalizeSources(input.referenceImageSrcs, 'image'),
normalizeSources(input.referenceVideoSrcs, 'video'),
normalizeSources(input.referenceAudioSrcs, 'audio'),
]);
return {
...input,
...(referenceImageSrcs ? { referenceImageSrcs } : {}),
...(referenceVideoSrcs ? { referenceVideoSrcs } : {}),
...(referenceAudioSrcs ? { referenceAudioSrcs } : {}),
};
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function queuedStateFromResponse(
response: { queueState?: ExternalGenerationJobStatusRecord | null } | null,
) {
return response?.queueState ?? null;
}
function projectHasUnresolvedGenerationDialog(
project: EditorProjectSnapshot,
dialogId: string | null | undefined,
) {
return findCanvasGenerationDialogRecords(project, dialogId).some(
isUnresolvedCanvasGenerationDialogRecord,
);
}
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
callback?.();
}
function notifyEditorGenerationWarning(
warning: string | null | undefined,
callback?: (message: string) => void,
) {
const reason = warning?.trim();
if (!reason) {
return;
}
callback?.(reason);
}
function resolveEditorGenerationWarningMessage(
warning: string | null | undefined,
sliceWarning: string | null | undefined,
) {
// 风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存,
// 这里按“通用在前、拆分在后”拼接成单条提示,不允许其中任何一条被丢弃。
const commonReason = warning?.trim();
const sliceReason = sliceWarning?.trim();
const prefixedSliceReason = sliceReason
? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}`
: undefined;
if (commonReason && prefixedSliceReason) {
return `${commonReason} ${prefixedSliceReason}`;
}
return commonReason || prefixedSliceReason;
}
async function runEditorGenerationWithWalletRefresh<T>(
operation: Promise<T>,
onWalletBalanceMayHaveChanged?: () => void,
): Promise<T> {
try {
const response = await operation;
if (
!queuedStateFromResponse(
response as { queueState?: ExternalGenerationJobStatusRecord | null },
)
) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
return response;
} catch (error) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
throw error;
}
}
async function waitForEditorGenerationQueue(
queueState: ExternalGenerationJobStatusRecord,
): Promise<ExternalGenerationJobStatusRecord | null> {
const startedAt = Date.now();
let current = queueState;
while (current.status === 'queued' || current.status === 'running') {
if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) {
return null;
}
await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS);
current = (await getExternalGenerationJobStatus(current.operationId)).job;
}
if (current.status === 'failed') {
throw new Error(current.error?.trim() || '生成任务失败');
}
return current;
}
export async function applyQueuedEditorGenerationProject(
response: { queueState?: ExternalGenerationJobStatusRecord | null },
projectId: string | null | undefined,
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
onQueuedGenerationTask?: () => void,
onWalletBalanceMayHaveChanged?: () => void,
onGenerationWarning?: (message: string) => void,
completionDialogId?: string | null,
transformProjectSnapshot?: (
project: EditorProjectSnapshot,
) => EditorProjectSnapshot,
) {
const queueState = queuedStateFromResponse(response);
if (!queueState) {
return false;
}
onQueuedGenerationTask?.();
let terminalState: ExternalGenerationJobStatusRecord | null = null;
try {
terminalState = await waitForEditorGenerationQueue(queueState);
} finally {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
if (!terminalState) {
return true;
}
notifyEditorGenerationWarning(terminalState.warning, onGenerationWarning);
if (projectId && applyProjectSnapshot) {
const applyLoadedProject = (project: EditorProjectSnapshot) => {
applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project);
};
const project = await loadEditorProject(projectId);
applyLoadedProject(project);
if (projectHasUnresolvedGenerationDialog(project, completionDialogId)) {
await delay(EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS);
applyLoadedProject(await loadEditorProject(projectId));
}
}
return true;
}
function positiveCanvasSize(value: number | null | undefined, fallback = 1) {
const resolved =
Number.isFinite(value) && (value ?? 0) > 0 ? value : fallback;
return Math.max(1, Math.round(resolved ?? 1));
}
function buildRightSideCanvasCompletionPlaceholder(
sourceLayer: CanvasLayer,
size?: {
width?: number;
height?: number;
originalWidth?: number;
originalHeight?: number;
},
): EditorCanvasGenerationCompletionInput['placeholder'] {
const originalWidth = positiveCanvasSize(
size?.originalWidth ?? size?.width,
sourceLayer.originalWidth || sourceLayer.width || 1,
);
const originalHeight = positiveCanvasSize(
size?.originalHeight ?? size?.height,
sourceLayer.originalHeight || sourceLayer.height || 1,
);
const width = positiveCanvasSize(size?.width, originalWidth);
const height = positiveCanvasSize(size?.height, originalHeight);
return {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width,
height,
originalWidth,
originalHeight,
};
}
function isVideoQuickEditSource(layer: CanvasLayer) {
return layer.mediaType === 'video' || layer.assetKind === 'video';
}
export function useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
canvasGenerationDialogs,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
activeCanvasGenerationDialogId,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: GenerationSubmissionWorkflowOptions) {
const currentBackgroundMusicSubmissionScopeRef = useRef({
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
activeCanvasGenerationDialogId,
version: 0,
mounted: false,
});
useLayoutEffect(() => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const scopeChanged =
!Object.is(currentScope.currentUserId, currentUserId ?? null) ||
!Object.is(currentScope.projectId, projectId ?? null);
currentBackgroundMusicSubmissionScopeRef.current = {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
activeCanvasGenerationDialogId,
version: scopeChanged ? currentScope.version + 1 : currentScope.version,
mounted: true,
};
return () => {
currentBackgroundMusicSubmissionScopeRef.current.mounted = false;
};
}, [activeCanvasGenerationDialogId, currentUserId, projectId]);
const isBackgroundMusicSubmissionUiTargetCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
dialogId: string;
scopeVersion: number;
}) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(scope.dialogId);
return (
currentScope.mounted &&
currentScope.version === scope.scopeVersion &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId) &&
currentDialog?.mode === 'audio-background-music'
);
},
[getCanvasGenerationDialogById],
);
const isSoundEffectSubmissionUiTargetCurrent = useCallback(
(scope: {
currentUserId: string | null;
projectId: string | null;
dialogId: string;
scopeVersion: number;
}) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(scope.dialogId);
return (
currentScope.mounted &&
currentScope.version === scope.scopeVersion &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId) &&
currentDialog?.mode === 'audio-sound-effect'
);
},
[getCanvasGenerationDialogById],
);
const isBackgroundMusicSubmissionAccountCurrent = useCallback(
(scope: { currentUserId: string | null }) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
return (
currentScope.mounted &&
Object.is(scope.currentUserId, currentScope.currentUserId)
);
},
[],
);
const isBackgroundMusicSubmissionProjectCurrent = useCallback(
(scope: { currentUserId: string | null; projectId: string | null }) => {
const currentScope = currentBackgroundMusicSubmissionScopeRef.current;
return (
currentScope.mounted &&
Object.is(scope.currentUserId, currentScope.currentUserId) &&
Object.is(scope.projectId, currentScope.projectId)
);
},
[],
);
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
captureCanvasHistory({
type: 'generate-image',
count: nextLayers.length,
});
appendCanvasLayersWithResources(nextLayers);
nextLayers.forEach((layer) => {
const asset = layer.generatedAssetSnapshot;
if (asset) {
upsertGeneratedAsset?.(asset);
}
});
},
[
appendCanvasLayersWithResources,
captureCanvasHistory,
upsertGeneratedAsset,
],
);
const addGeneratedResultLayer = useCallback(
(
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
options: {
sourceLayer?: CanvasLayer;
frame?: GenerateDialogState['placeholder'];
assetKind?: CanvasLayer['assetKind'];
title?: string;
dialogId?: string;
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
if (
options.dialogId &&
!hasCanvasGenerationDialogById(options.dialogId)
) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createGeneratedResultLayer({
generated,
generatedIndex,
canvasSize,
viewport,
sourceLayer: options.sourceLayer,
frame: options.frame,
assetKind: options.assetKind,
title: options.title,
generationInputs: options.generationInputs,
});
addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (options.sourceLayer) {
setGenerateDialog(null);
setActiveTool('select');
} else if (options.dialogId) {
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
errorMessage: undefined,
}));
}
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer], { captureHistory: false });
}
},
[
addGeneratedLayersToCanvas,
canvasSize,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
const applyQuickEditResultToSourceLayer = useCallback(
(
generated: Parameters<
typeof applyImageEditResultToSourceLayer
>[0]['generated'],
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
dialogId?: string,
title?: string,
) => {
updateSourceLayer(
sourceLayer.id,
(layer) => ({
...applyImageEditResultToSourceLayer({
generated,
sourceLayer: layer,
generationInputs,
}),
...(title ? { title } : {}),
}),
{ fit: false, persist: false },
);
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, () => null);
}
},
[
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
updateSourceLayer,
],
);
const addIconSpritesheetResultLayers = useCallback(
(
generated: Parameters<
typeof createIconSpritesheetResultLayers
>[0]['generated'],
iconResults: Parameters<
typeof createIconSpritesheetResultLayers
>[0]['iconResults'],
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
options: {
spritesheetTitle?: string;
} = {},
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return [];
}
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
generated,
iconResults,
startIndex,
canvasSize,
viewport,
generationInputs,
frame,
spritesheetTitle: options.spritesheetTitle,
});
if (!nextLayers.length) {
return [];
}
layerCounterRef.current += nextLayers.length;
addGeneratedLayersToCanvas(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayers[0]?.id,
errorMessage: undefined,
}));
}
setActiveTool('select');
return nextLayers;
},
[
addGeneratedLayersToCanvas,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
const addVideoResultLayer = useCallback(
(
generated: Parameters<typeof createVideoResultLayer>[0]['generated'],
title: string,
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
options: {
sourceLayer?: CanvasLayer;
assetKind?: CanvasLayer['assetKind'];
} = {},
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createVideoResultLayer({
generated,
generatedIndex,
title,
canvasSize,
viewport,
generationInputs,
frame,
sourceLayer: options.sourceLayer,
assetKind: options.assetKind,
});
addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
errorMessage: undefined,
}));
}
setActiveTool('select');
},
[
addGeneratedLayersToCanvas,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
const addAudioResultLayer = useCallback(
(
generated: Parameters<typeof createAudioResultLayer>[0]['generated'],
title: string,
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createAudioResultLayer({
generated,
generatedIndex,
title,
canvasSize,
viewport,
generationInputs,
frame,
});
addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
errorMessage: undefined,
}));
}
setActiveTool('select');
},
[
addGeneratedLayersToCanvas,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
const addCharacterAnimationResultLayer = useCallback(
(
generated: Parameters<
typeof createCharacterAnimationResultLayer
>[0]['generated'],
title: string,
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
options: {
sourceLayer?: CanvasLayer;
} = {},
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createCharacterAnimationResultLayer({
generated,
generatedIndex,
title,
canvasSize,
viewport,
generationInputs,
frame,
sourceLayer: options.sourceLayer,
});
if (!nextLayer) {
return;
}
addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
characterAnimationResult: generated,
errorMessage: undefined,
}));
}
setActiveTool('select');
},
[
addGeneratedLayersToCanvas,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
const extractUiDesignAssets = useCallback(
async (
sourceLayer: CanvasLayer,
options: UiDesignAssetExtractionOptions = {},
) => {
if (sourceLayer.assetKind !== 'ui-design') {
return;
}
try {
let sourceImageSrc: string;
if (options.marks?.length) {
const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
const markedImageDataUrl =
await renderUiDesignAssetExtractionMarkedImage({
source: renderSourceImageSrc,
marks: options.marks,
});
sourceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
} else {
sourceImageSrc = await resolveEditorGenerationMediaReference(
sourceLayer,
'image',
projectId,
);
}
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
sourceLayer,
options.references,
{
model: options.model ?? DEFAULT_IMAGE_MODEL,
imageSize: resolveUiAssetExtractionGenerationPlan(
options.marks?.length ?? 0,
).imageSize,
},
);
const referenceImageSrcs = await Promise.all(
(options.references ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
reference,
'image',
projectId,
),
),
);
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
options.marks?.length ?? 0,
);
const extractionSize = resolveEditorImageGenerationPixelSize({
model: options.model ?? DEFAULT_IMAGE_MODEL,
aspectRatio: extractionPlan.aspectRatio,
imageSize: extractionPlan.imageSize,
});
const extractionFrame = {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: extractionSize.width,
height: extractionSize.height,
originalWidth: extractionSize.width,
originalHeight: extractionSize.height,
};
const generated = await runEditorGenerationWithWalletRefresh(
extractEditorUiDesignAssets({
sourceImageSrc,
model: normalizeEditorImageModel(
options.model ?? DEFAULT_IMAGE_MODEL,
),
screenColor,
segModel,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs,
assetFolderId,
spritesheetLabel: `${sourceLayer.title} 素材图集`,
aspectRatio: extractionPlan.aspectRatio,
imageSize: extractionPlan.imageSize,
...(projectId
? {
canvasCompletion: {
title: `${sourceLayer.title} 素材图集`,
placeholder: extractionFrame,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
resolveEditorGenerationWarningMessage(
generated.warning?.reason,
generated.sliceWarning?.reason,
),
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
return;
}
const nextLayers = addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs ?? [],
generationInputs,
extractionFrame,
undefined,
{
spritesheetTitle: `${sourceLayer.title} 素材图集`,
},
);
if (nextLayers.length) {
fitLayers([sourceLayer, ...nextLayers], { captureHistory: false });
}
} catch (error) {
if (options.suppressAlert) {
throw error;
}
window.alert(
error instanceof Error && error.message.trim()
? error.message
: '提取素材失败',
);
}
},
[
addIconSpritesheetResultLayers,
applyProjectSnapshot,
fitLayers,
projectId,
assetFolderId,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
],
);
const submitIconSpritesheetGeneration = useCallback(
async (dialog: GenerateDialogState) => {
if (dialog.mode !== 'icon') {
return;
}
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
const setSubmittingIconDialog = (
nextDialog: CanvasGenerationDialogState,
) => {
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
};
const submissionPlan = buildIconSpritesheetGenerationSubmissionPlan(
dialog,
layerCounterRef.current + 1,
);
if (submissionPlan.ok === false) {
if (canvasDialog) {
setSubmittingIconDialog({
...canvasDialog,
status: 'failed',
composerOpen: true,
errorMessage: submissionPlan.errorMessage,
});
}
return;
}
if (!canvasDialog) {
return;
}
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
});
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
reference,
'image',
projectId,
),
),
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: submissionPlan.resultTitle,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title: submissionPlan.resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
rememberImageModel(submissionPlan.rememberImageModel);
notifyEditorGenerationWarning(
resolveEditorGenerationWarningMessage(
generated.warning?.reason,
generated.sliceWarning?.reason,
),
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
return;
}
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs ?? [],
submissionPlan.generationInputs,
canvasCompletionPlaceholder,
canvasDialog.id,
{ spritesheetTitle: submissionPlan.resultTitle },
);
} catch (error) {
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
},
[
addIconSpritesheetResultLayers,
applyProjectSnapshot,
getGeneratingDialogPlaceholder,
layerCounterRef,
rememberImageModel,
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
],
);
const submitQuickEdit = useCallback(async () => {
if (!quickEditPanel || !quickEditSourceLayer) {
return;
}
if (!isQuickEditSupportedLayer(quickEditSourceLayer)) {
setQuickEditPanel({
...quickEditPanel,
status: 'failed',
errorMessage: '当前素材类型不支持快速编辑',
});
return;
}
const quickEditResultTitle = resolveGenerationAssetLabel(
quickEditPanel.assetLabel,
`${quickEditSourceLayer.title} 快速编辑`,
);
const quickEditReferences: CharacterReferenceImage[] = [];
const quickEditAspectRatio =
quickEditPanel.aspectRatio ??
inferEditorImageAspectRatio(
quickEditSourceLayer.originalWidth,
quickEditSourceLayer.originalHeight,
);
const quickEditImageSize =
quickEditPanel.imageSize ??
inferEditorImageSizeLabel(
quickEditSourceLayer.originalWidth,
quickEditSourceLayer.originalHeight,
);
const normalizedQuickEditModel = normalizeEditorImageModel(
quickEditPanel.model,
);
const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer)
? {
width: quickEditSourceLayer.originalWidth,
height: quickEditSourceLayer.originalHeight,
}
: resolveEditorImageGenerationPixelSize({
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
});
const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`;
const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片';
let quickEditDialogId: string | undefined;
const shouldUseCanvasPlaceholder =
isVideoQuickEditSource(quickEditSourceLayer);
if (shouldUseCanvasPlaceholder) {
const quickEditDraft = createQuickEditGenerationDialogDraft({
sourceLayer: quickEditSourceLayer,
prompt: normalizedPrompt,
status: 'generating',
references: quickEditReferences,
model: quickEditPanel.model,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
frame: {
width: quickEditOutputSize.width,
height: quickEditOutputSize.height,
},
assetLabel: quickEditResultTitle,
});
const quickEditPlacement = quickEditDraft.placeholder
? chooseGenerationPlacement({
canvasSize,
viewport,
frame: quickEditDraft.placeholder,
layers,
generationDialogs: canvasGenerationDialogs,
})
: quickEditDraft.placeholder;
quickEditDialogId = openCanvasGenerationDialog({
...quickEditDraft,
// 中文注释:视频快速编辑仍落到独立视频结果占位,图片快速编辑直接替换源图。
placeholder: quickEditPlacement,
});
setQuickEditPanel(null);
selectSingleLayer(null);
} else {
setQuickEditPanel({
...quickEditPanel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
size: quickEditSize,
prompt: normalizedPrompt,
status: 'generating',
errorMessage: undefined,
});
}
try {
const extraReferenceImageSrcs = await Promise.all(
quickEditReferences.map((reference) =>
resolveEditorGenerationMediaReference(reference, 'image', projectId),
),
);
const quickEditFrame = quickEditDialogId
? getGeneratingDialogPlaceholder({
id: quickEditDialogId,
mode: 'quick-edit',
prompt: normalizedPrompt,
status: 'generating',
})
: undefined;
const quickEditCanvasCompletionPlaceholder =
quickEditFrame ??
buildRightSideCanvasCompletionPlaceholder(quickEditSourceLayer, {
width: quickEditOutputSize.width,
height: quickEditOutputSize.height,
originalWidth: quickEditOutputSize.width,
originalHeight: quickEditOutputSize.height,
});
if (isVideoQuickEditSource(quickEditSourceLayer)) {
const videoModel = DEFAULT_VIDEO_MODEL;
const videoResolution = '480p' as const;
const videoDurationSeconds = 4 as const;
const sourceVideoSrc = await resolveEditorGenerationMediaReference(
quickEditSourceLayer,
'video',
projectId,
{ allowRegisteredIds: false },
);
const generationInputs = buildVideoGenerationInputs(
normalizedPrompt,
[createCanvasLayerReference(quickEditSourceLayer)],
{
model: videoModel,
aspectRatio: '16:9',
resolution: videoResolution,
durationSeconds: videoDurationSeconds,
sound: 'off',
},
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorVideo({
prompt: normalizedPrompt,
model: videoModel,
aspectRatio: '16:9',
durationSeconds: videoDurationSeconds,
resolution: videoResolution,
mode: 'std',
sound: 'off',
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
: {}),
referenceVideoSrcs: [sourceVideoSrc],
projectId,
generationInputs,
sourceResourceId: quickEditSourceLayer.resourceId,
assetKind: quickEditSourceLayer.assetKind,
assetFolderId,
assetLabel: quickEditResultTitle,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: quickEditResultTitle,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addVideoResultLayer(
generated,
quickEditResultTitle,
generationInputs,
quickEditFrame,
quickEditDialogId,
{
sourceLayer: quickEditSourceLayer,
assetKind: quickEditSourceLayer.assetKind,
},
);
return;
}
const quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
? quickEditSelectionState.marks
: [];
const sourceReferenceId =
resolveRegisteredEditorReferenceId(quickEditSourceLayer);
let markedReferenceImageSrc: string | null = null;
if (quickEditSelectionMarks.length > 0) {
const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
);
const markedImageDataUrl =
await renderUiDesignAssetExtractionMarkedImage({
source: renderSourceImageSrc,
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
}
const imageEditReferenceSrcs = [
...extraReferenceImageSrcs,
...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []),
];
const generationInputs = buildQuickEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
quickEditReferences,
{
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
},
);
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: normalizedPrompt,
sourceReferenceId,
size: quickEditSize,
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
...(imageEditReferenceSrcs.length
? { referenceImageSrcs: imageEditReferenceSrcs }
: {}),
projectId,
generationInputs,
assetFolderId,
assetLabel: quickEditResultTitle,
targetLayerId: quickEditSourceLayer.id,
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
return;
}
applyQuickEditResultToSourceLayer(
generated,
quickEditSourceLayer,
generationInputs,
quickEditDialogId,
quickEditResultTitle,
);
} catch (error) {
if (quickEditDialogId) {
updateCanvasGenerationDialogById(
quickEditDialogId,
(currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'failed',
composerOpen: false,
errorMessage: resolveImageGenerationErrorMessage(error),
}),
);
}
setQuickEditPanel({
...quickEditPanel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
size: quickEditSize,
prompt: normalizedPrompt,
status: 'failed',
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
}, [
addVideoResultLayer,
applyQuickEditResultToSourceLayer,
applyProjectSnapshot,
canvasGenerationDialogs,
canvasSize,
getGeneratingDialogPlaceholder,
layers,
openCanvasGenerationDialog,
projectId,
assetFolderId,
upsertGeneratedAsset,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
viewport,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
]);
const resetProjectBackedGenerationDialog = useCallback(
(canvasDialog: CanvasGenerationDialogState | null) => {
if (!canvasDialog || !projectId) {
return false;
}
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return true;
},
[projectId, updateCanvasGenerationDialogById],
);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
const backgroundMusicDialog =
canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null;
const soundEffectDialog =
canvasDialog?.mode === 'audio-sound-effect' ? canvasDialog : null;
if (dialog.mode === 'audio-background-music' && !backgroundMusicDialog) {
return;
}
if (dialog.mode === 'audio-sound-effect' && !soundEffectDialog) {
return;
}
const backgroundMusicClaim = backgroundMusicDialog
? backgroundMusicPromptAssist.beginSubmission(backgroundMusicDialog.id)
: null;
if (backgroundMusicDialog && !backgroundMusicClaim) {
return;
}
const soundEffectClaim = soundEffectDialog
? soundEffectPromptAssist.beginSubmission(soundEffectDialog.id)
: null;
if (soundEffectDialog && !soundEffectClaim) {
const validation = validateSoundEffectPrompt(soundEffectDialog.prompt);
if (!validation.ok) {
updateCanvasGenerationDialogById(
soundEffectDialog.id,
(currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...currentDialog,
prompt: validation.prompt,
status: 'failed',
composerOpen: true,
errorMessage:
validation.reason === 'empty'
? '音效描述不能为空'
: '音效描述不能超过 2048 个字符',
}
: currentDialog,
);
}
return;
}
const backgroundMusicSubmission =
backgroundMusicDialog && backgroundMusicClaim
? {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
dialogId: backgroundMusicDialog.id,
operation: backgroundMusicClaim.operation,
prompt: backgroundMusicClaim.prompt,
scopeVersion:
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const soundEffectSubmission =
soundEffectDialog && soundEffectClaim
? {
currentUserId: currentUserId ?? null,
projectId: projectId ?? null,
dialogId: soundEffectDialog.id,
...soundEffectClaim,
scopeVersion:
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const normalizedPrompt =
backgroundMusicSubmission?.prompt ??
soundEffectSubmission?.prompt ??
resolveImageGenerationDialogPrompt(dialog);
if (
!backgroundMusicSubmission &&
!soundEffectSubmission &&
canvasDialog
) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else if (!backgroundMusicSubmission && !soundEffectSubmission) {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: dialog.mode === 'edit',
});
}
let backgroundMusicSubmissionAccepted = false;
let backgroundMusicSubmissionUiOwned = false;
let backgroundMusicQueueAcceptedWhileCurrentAndOpen = false;
let soundEffectSubmissionAccepted = false;
let soundEffectSubmissionUiOwned = false;
let soundEffectQueueAcceptedWhileCurrentAndOpen = false;
try {
const dialogForSubmission = soundEffectSubmission
? {
...dialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds: soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
}
: dialog;
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog: dialogForSubmission,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt,
});
if (submissionPlan.kind === 'edit') {
const sourceReferenceId = resolveRegisteredEditorReferenceId(
submissionPlan.sourceLayer,
);
const editPlaceholderSize =
getCanvasCompletionPlaceholderSizeFromPlan({
sourceLayer: submissionPlan.sourceLayer,
outputSize: submissionPlan.editInput.size,
});
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const editCanvasCompletionPlaceholder =
canvasCompletionPlaceholder ??
buildRightSideCanvasCompletionPlaceholder(
submissionPlan.sourceLayer,
{
width: editPlaceholderSize.width,
height: editPlaceholderSize.height,
originalWidth: editPlaceholderSize.width,
originalHeight: editPlaceholderSize.height,
},
);
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceReferenceId,
...submissionPlan.editInput,
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: submissionPlan.resultTitle,
...(projectId && editCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.resultTitle,
placeholder: editCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
return;
}
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
title: submissionPlan.resultTitle,
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'quick-edit') {
const sourceReferenceId = resolveRegisteredEditorReferenceId(
submissionPlan.sourceLayer,
);
const quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId ===
submissionPlan.sourceLayer.id
? quickEditSelectionState.marks
: [];
let markedReferenceImageSrc: string | null = null;
if (quickEditSelectionMarks.length > 0) {
const renderSourceImageSrc =
await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.objectKey?.trim() ||
submissionPlan.sourceLayer.src,
);
const markedImageDataUrl =
await renderUiDesignAssetExtractionMarkedImage({
source: renderSourceImageSrc,
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
}
const normalizedReferenceImageSrcs = await Promise.all(
(submissionPlan.editInput.referenceImageSrcs ?? []).map(
(referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
dialog.generationReferences?.[index]
? {
...dialog.generationReferences[index],
src: referenceImageSrc,
}
: { src: referenceImageSrc },
'image',
projectId,
),
),
);
const referenceImageSrcs = [
...normalizedReferenceImageSrcs,
...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []),
];
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceReferenceId,
...submissionPlan.editInput,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
targetLayerId: submissionPlan.sourceLayer.id,
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
applyQuickEditResultToSourceLayer(
generated,
submissionPlan.sourceLayer,
submissionPlan.result.generationInputs,
canvasDialog?.id,
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput = submissionPlan.input;
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpec({
...iconSpecInput,
projectId,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
canvasDialog?.id,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs:
generated.asset?.generationInputs ??
submissionPlan.result.generationInputs,
});
} else if (submissionPlan.kind === 'video') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const videoGenerationInput = await normalizeVideoGenerationReferences(
submissionPlan.input,
dialog.generationReferences ?? [],
projectId,
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorVideo({
...videoGenerationInput,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
assetKind: 'video',
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addVideoResultLayer(
generated,
submissionPlan.result.title,
submissionPlan.result.generationInputs,
canvasCompletionPlaceholder,
canvasDialog?.id,
);
} else if (submissionPlan.kind === 'audio') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
// 直连回包的钱包刷新由共享 wrapper 触发,而 wrapper 不持有本次 submission scope。
// 这里补上与排队路径和 BGM 分支同一道账号门禁:切号后迟到的旧回包不得刷新新账号钱包。
const refreshSoundEffectWalletBalance =
soundEffectSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
isBackgroundMusicSubmissionAccountCurrent(
soundEffectSubmission,
)
) {
onWalletBalanceMayHaveChanged();
}
}
: onWalletBalanceMayHaveChanged;
const generated =
submissionPlan.audioKind === 'sound-effect'
? await runEditorGenerationWithWalletRefresh(
generateEditorSoundEffect({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
refreshSoundEffectWalletBalance,
)
: await generateEditorBackgroundMusic({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
});
const isBackgroundMusic =
submissionPlan.audioKind === 'background-music';
const isSoundEffect = submissionPlan.audioKind === 'sound-effect';
const backgroundMusicQueueState = isBackgroundMusic
? queuedStateFromResponse(generated)
: null;
const soundEffectQueueState = isSoundEffect
? queuedStateFromResponse(generated)
: null;
if (isBackgroundMusic && backgroundMusicSubmission) {
backgroundMusicSubmissionAccepted = true;
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
);
backgroundMusicQueueAcceptedWhileCurrentAndOpen =
Boolean(backgroundMusicQueueState) &&
currentScope.activeCanvasGenerationDialogId ===
backgroundMusicSubmission.dialogId &&
currentDialog?.composerOpen !== false;
backgroundMusicSubmissionUiOwned =
backgroundMusicPromptAssist.finishSubmission({
operation: backgroundMusicSubmission.operation,
accepted: true,
});
if (
backgroundMusicQueueState &&
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
updateCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
(currentDialog) =>
currentDialog.mode === 'audio-background-music'
? {
...currentDialog,
prompt: backgroundMusicSubmission.prompt,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}
: currentDialog,
);
}
if (
!backgroundMusicQueueState &&
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
}
if (isSoundEffect && soundEffectSubmission) {
soundEffectSubmissionAccepted = true;
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
);
soundEffectQueueAcceptedWhileCurrentAndOpen =
Boolean(soundEffectQueueState) &&
currentScope.activeCanvasGenerationDialogId ===
soundEffectSubmission.dialogId &&
currentDialog?.composerOpen !== false;
soundEffectSubmissionUiOwned =
soundEffectPromptAssist.finishSubmission({
operation: soundEffectSubmission.operation,
accepted: true,
});
if (
soundEffectQueueState &&
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission)
) {
updateCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
(currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...currentDialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds:
soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}
: currentDialog,
);
}
}
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
backgroundMusicSubmission && applyProjectSnapshot
? (projectSnapshot) => {
if (
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
applyProjectSnapshot(projectSnapshot);
}
}
: soundEffectSubmission && applyProjectSnapshot
? (projectSnapshot) => {
if (
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(
soundEffectSubmission,
)
) {
applyProjectSnapshot(projectSnapshot);
}
}
: applyProjectSnapshot,
backgroundMusicSubmission && onQueuedGenerationTask
? () => {
if (
isBackgroundMusicSubmissionProjectCurrent(
backgroundMusicSubmission,
)
) {
onQueuedGenerationTask();
}
}
: soundEffectSubmission && onQueuedGenerationTask
? () => {
if (
isBackgroundMusicSubmissionProjectCurrent(
soundEffectSubmission,
)
) {
onQueuedGenerationTask();
}
}
: onQueuedGenerationTask,
backgroundMusicSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
onWalletBalanceMayHaveChanged();
}
}
: soundEffectSubmission && onWalletBalanceMayHaveChanged
? () => {
if (
isBackgroundMusicSubmissionAccountCurrent(
soundEffectSubmission,
)
) {
onWalletBalanceMayHaveChanged();
}
}
: onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (
backgroundMusicSubmission &&
(!backgroundMusicSubmissionUiOwned ||
!isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
))
) {
return;
}
if (
soundEffectSubmission &&
(!soundEffectSubmissionUiOwned ||
!isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission))
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addAudioResultLayer(
generated,
submissionPlan.result.title,
submissionPlan.result.generationInputs,
canvasCompletionPlaceholder,
canvasDialog?.id,
);
} else if (submissionPlan.kind === 'scene') {
const sceneGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorScene({
...sceneGenerationInput,
projectId,
generationInputs: submissionPlan.input.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
canvasDialog?.id,
)
) {
return;
}
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) upsertGeneratedAsset?.(generated.asset);
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) return;
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
} else {
const imageGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const resultTitle =
submissionPlan.result.title ??
`生成图片 ${layerCounterRef.current + 1}`;
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorImage({
...imageGenerationInput,
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: resultTitle,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
}
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (resetProjectBackedGenerationDialog(canvasDialog)) {
return;
}
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: resultTitle,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
}
} catch (error) {
if (backgroundMusicSubmission) {
if (!backgroundMusicSubmissionAccepted) {
backgroundMusicSubmissionUiOwned =
backgroundMusicPromptAssist.finishSubmission({
operation: backgroundMusicSubmission.operation,
accepted: false,
});
if (
isBackgroundMusicSubmissionAccountCurrent(
backgroundMusicSubmission,
)
) {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
}
if (
backgroundMusicSubmissionUiOwned &&
isBackgroundMusicSubmissionUiTargetCurrent(
backgroundMusicSubmission,
)
) {
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
);
const shouldReopenComposer =
currentScope.activeCanvasGenerationDialogId ===
backgroundMusicSubmission.dialogId &&
(currentDialog?.composerOpen !== false ||
backgroundMusicQueueAcceptedWhileCurrentAndOpen);
updateCanvasGenerationDialogById(
backgroundMusicSubmission.dialogId,
(latestDialog) =>
latestDialog.mode === 'audio-background-music'
? {
...latestDialog,
prompt: backgroundMusicSubmission.prompt,
status: 'failed',
composerOpen: shouldReopenComposer
? true
: latestDialog.composerOpen,
errorMessage: resolveImageGenerationErrorMessage(error),
}
: latestDialog,
);
}
} else if (soundEffectSubmission) {
if (!soundEffectSubmissionAccepted) {
soundEffectSubmissionUiOwned =
soundEffectPromptAssist.finishSubmission({
operation: soundEffectSubmission.operation,
accepted: false,
});
}
if (
soundEffectSubmissionUiOwned &&
isSoundEffectSubmissionUiTargetCurrent(soundEffectSubmission)
) {
const currentScope =
currentBackgroundMusicSubmissionScopeRef.current;
const currentDialog = getCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
);
const shouldReopenComposer =
currentScope.activeCanvasGenerationDialogId ===
soundEffectSubmission.dialogId &&
(currentDialog?.composerOpen !== false ||
soundEffectQueueAcceptedWhileCurrentAndOpen);
updateCanvasGenerationDialogById(
soundEffectSubmission.dialogId,
(latestDialog) =>
latestDialog.mode === 'audio-sound-effect'
? {
...latestDialog,
prompt: soundEffectSubmission.prompt,
soundDurationMode: soundEffectSubmission.durationMode,
soundDurationSeconds:
soundEffectSubmission.manualDurationSeconds,
soundLoop: soundEffectSubmission.loop,
status: 'failed',
composerOpen: shouldReopenComposer
? true
: latestDialog.composerOpen,
errorMessage: resolveImageGenerationErrorMessage(error),
}
: latestDialog,
);
}
} else if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, () => ({
...canvasDialog,
prompt: normalizedPrompt,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
}));
} else {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
}
},
[
addGeneratedResultLayer,
addAudioResultLayer,
addVideoResultLayer,
applyQuickEditResultToSourceLayer,
backgroundMusicPromptAssist,
currentUserId,
getCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
isBackgroundMusicSubmissionAccountCurrent,
isBackgroundMusicSubmissionProjectCurrent,
isBackgroundMusicSubmissionUiTargetCurrent,
isSoundEffectSubmissionUiTargetCurrent,
layerCounterRef,
layers,
quickEditSelectionState,
rememberImageModel,
resetProjectBackedGenerationDialog,
projectId,
assetFolderId,
applyProjectSnapshot,
setGenerateDialog,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
],
);
const submitCharacterAnimation = useCallback(async () => {
if (!characterAnimationPanel || !characterAnimationSourceLayer) {
return;
}
if (
characterAnimationPanel.status === 'generating' ||
(characterAnimationDialog?.mode === 'character-animation' &&
characterAnimationDialog.status === 'generating')
) {
return;
}
const canvasDialog = characterAnimationDialog;
try {
const submissionPlan = buildCharacterAnimationSubmissionPlan({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
});
if (canvasDialog?.mode === 'character-animation') {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: submissionPlan.promptText,
characterAnimationResolution: characterAnimationPanel.resolution,
characterAnimationRatio: characterAnimationPanel.ratio,
characterAnimationFrameCount: characterAnimationPanel.frameCount,
characterAnimationDurationSeconds:
characterAnimationPanel.durationSeconds,
assetLabel: submissionPlan.resultTitle,
characterAnimationResult: undefined,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}));
}
const nextPanel = {
...characterAnimationPanel,
promptText: submissionPlan.promptText,
assetLabel: submissionPlan.resultTitle,
status: 'generating' as const,
errorMessage: undefined,
result: undefined,
};
if (!canvasDialog) {
setCharacterAnimationPanel(nextPanel);
}
const generationInputs = buildCharacterAnimationGenerationInputs(
submissionPlan.promptText,
characterAnimationSourceLayer,
{
resolution: characterAnimationPanel.resolution,
ratio: characterAnimationPanel.ratio,
frameCount: characterAnimationPanel.frameCount,
durationSeconds: characterAnimationPanel.durationSeconds,
},
);
const canvasCompletionPlaceholder = canvasDialog
? getGeneratingDialogPlaceholder(canvasDialog)
: undefined;
const sourceImageSrc = await resolveEditorGenerationMediaReference(
characterAnimationSourceLayer,
'image',
projectId,
{
allowRegisteredIds: false,
requireImageObjectReference: true,
},
);
const result = await runEditorGenerationWithWalletRefresh(
generateEditorCharacterAnimation({
...submissionPlan.input,
sourceImageSrc,
projectId,
generationInputs,
sourceResourceId: characterAnimationSourceLayer.resourceId,
assetFolderId,
...(canvasDialog && projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title: submissionPlan.resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (result.project && applyProjectSnapshot) {
applyProjectSnapshot(result.project);
return;
}
if (canvasDialog) {
if (projectId && !result.resource) {
throw new Error('角色动作画布完成缺少正式项目资源。');
}
if (!projectId && !result.asset) {
throw new Error('角色动作生成缺少正式账号素材。');
}
addCharacterAnimationResultLayer(
result,
submissionPlan.resultTitle,
generationInputs,
canvasCompletionPlaceholder,
canvasDialog.id,
{
sourceLayer: characterAnimationSourceLayer,
},
);
return;
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
status: 'completed',
result,
}
: currentPanel,
);
} catch (error) {
if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: characterAnimationPanel.promptText.trim(),
characterAnimationResolution: characterAnimationPanel.resolution,
characterAnimationRatio: characterAnimationPanel.ratio,
characterAnimationFrameCount: characterAnimationPanel.frameCount,
characterAnimationDurationSeconds:
characterAnimationPanel.durationSeconds,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(
error,
'生成角色动画失败',
),
}));
return;
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
promptText: characterAnimationPanel.promptText.trim(),
status: 'failed',
errorMessage: resolveImageGenerationErrorMessage(
error,
'生成角色动画失败',
),
}
: currentPanel,
);
}
}, [
addCharacterAnimationResultLayer,
assetFolderId,
characterAnimationDialog,
characterAnimationPanel,
characterAnimationSourceLayer,
getGeneratingDialogPlaceholder,
applyProjectSnapshot,
projectId,
setCharacterAnimationPanel,
updateCanvasGenerationDialogById,
onWalletBalanceMayHaveChanged,
onQueuedGenerationTask,
]);
return useMemo(
() => ({
submitIconSpritesheetGeneration,
extractUiDesignAssets,
submitQuickEdit,
submitImageGeneration,
submitCharacterAnimation,
}),
[
submitCharacterAnimation,
extractUiDesignAssets,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
],
);
}