Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts
T
zhaojingyan eb608ac6f2 完善图片画布撤销保护与操作提示 (#89)
为画布历史记录补充操作类型和内容消失保护

增加撤销成功、恢复成功及阻止提示并在三秒后隐藏

补充拖动、上传、生成和替换等操作的历史语义

保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈

补充撤销与恢复规则的定向测试和说明文档

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89
Co-authored-by: oj-afraid-student <1373241747@qq.com>
Co-committed-by: oj-afraid-student <1373241747@qq.com>
2026-07-20 15:19:48 +08:00

2348 lines
74 KiB
TypeScript

import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useMemo,
} 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,
uploadEditorMediaAssetFile,
} from '../../services/image-editor/editorMediaAssetUploadClient';
import type {
EditorAssetSnapshot,
EditorCanvasGenerationCompletionInput,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
editEditorImage,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorSoundEffect,
generateEditorVideo,
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
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,
createQuickEditResultLayer,
createVideoResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
buildCharacterAnimationGenerationInputs,
buildEditGenerationInputs,
buildQuickEditGenerationInputs,
buildUiDesignAssetExtractionGenerationInputs,
buildVideoGenerationInputs,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
} from './ImageCanvasUiAssetExtractionModel';
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
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;
};
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 dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) {
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);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type });
}
async function inlineMediaSourceToEditorGenerationFile(
source: string,
mediaType: EditorMediaAssetUploadType,
uploadId: string,
) {
const fileName = `generation-reference-${uploadId}.${
mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png'
}`;
if (/^data:/iu.test(source)) {
return dataUrlToEditorGenerationFile(source, fileName);
}
const response = await fetch(source);
if (!response.ok) {
throw new Error('读取本地生成参考素材失败');
}
const blob = await response.blob();
return new File([blob], fileName, {
type: blob.type || `${mediaType}/*`,
});
}
async function uploadEditorGenerationInlineMediaSource(
source: string,
mediaType: EditorMediaAssetUploadType,
projectId?: string | null,
) {
const normalizedProjectId = projectId?.trim() || 'unscoped';
const uploadId = createEditorGenerationMediaUploadId();
const uploaded = await uploadEditorMediaAssetFile(
await inlineMediaSourceToEditorGenerationFile(source, mediaType, uploadId),
mediaType,
{
assetKind: `editor_generation_reference_${mediaType}`,
pathSegments: [
'editor',
'generation-references',
normalizedProjectId,
uploadId,
],
entityId: normalizedProjectId,
...(projectId?.trim()
? { metadata: { editor_project_id: projectId.trim() } }
: {}),
},
);
return uploaded.objectKey;
}
export async function resolveEditorGenerationMediaReference(
reference: EditorGenerationMediaReference,
mediaType: EditorMediaAssetUploadType,
projectId?: string | null,
options: EditorGenerationMediaReferenceOptions = {},
) {
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)
: source;
return uploadEditorGenerationInlineMediaSource(
uploadSource,
mediaType,
projectId,
);
}
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;
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;
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;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages(
input: Parameters<typeof generateEditorImage>[0],
references: CharacterReferenceImage[],
projectId?: string | null,
) {
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,
) {
const normalizedDialogId = dialogId?.trim();
if (!normalizedDialogId) {
return false;
}
return project.layers.some((item) => {
if (item.itemType !== 'generation-dialog') {
return false;
}
const dialog =
item.dialog && typeof item.dialog === 'object'
? (item.dialog as Record<string, unknown>)
: null;
return (
dialog?.id === normalizedDialogId &&
(dialog.status === 'generating' ||
typeof dialog.generatedLayerId !== 'string' ||
dialog.generatedLayerId.trim() === '')
);
});
}
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,
) {
const commonReason = warning?.trim();
if (commonReason) {
return commonReason;
}
const sliceReason = sliceWarning?.trim();
return sliceReason
? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}`
: undefined;
}
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,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
projectId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: GenerationSubmissionWorkflowOptions) {
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 addQuickEditResultLayer = useCallback(
(
generated: Parameters<typeof createQuickEditResultLayer>[0]['generated'],
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
mode: QuickEditPanelState['mode'] = 'quick-edit',
options: {
frame?: GenerateDialogState['placeholder'];
dialogId?: string;
title?: string;
} = {},
) => {
if (
options.dialogId &&
!hasCanvasGenerationDialogById(options.dialogId)
) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createQuickEditResultLayer({
generated,
generatedIndex,
sourceLayer,
generationInputs,
mode,
frame: options.frame,
title: options.title,
});
addGeneratedLayersToCanvas([nextLayer]);
selectSingleLayer(mode === 'redraw' ? sourceLayer.id : nextLayer.id);
setActiveSidebarPanel('layers');
if (mode === 'redraw') {
setQuickEditPanel((currentPanel) =>
currentPanel?.sourceLayerId === sourceLayer.id
? {
...currentPanel,
mode: 'redraw',
status: 'idle',
errorMessage: undefined,
}
: currentPanel,
);
} else {
setQuickEditPanel(null);
setActiveTool('select');
if (options.dialogId) {
updateCanvasGenerationDialogById(
options.dialogId,
(currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: false,
generatedLayerId: nextLayer.id,
errorMessage: undefined,
}),
);
}
}
fitLayers([sourceLayer, nextLayer], { captureHistory: false });
},
[
addGeneratedLayersToCanvas,
fitLayers,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
],
);
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,
);
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;
}
const iconSpecReference = dialog.iconSpecReference;
if (!iconSpecReference) {
return;
}
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
});
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceImageSrc = await resolveEditorGenerationMediaReference(
iconSpecReference,
'image',
projectId,
);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
reference,
'image',
projectId,
),
),
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
referenceImageSrc,
...(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 (isQuickEditUnsupportedAssetKind(quickEditSourceLayer)) {
setQuickEditPanel({
...quickEditPanel,
status: 'failed',
errorMessage: '图标类素材不支持快速编辑',
});
return;
}
const panelMode = quickEditPanel.mode ?? 'quick-edit';
const quickEditResultTitle = resolveGenerationAssetLabel(
quickEditPanel.assetLabel,
`${quickEditSourceLayer.title} ${panelMode === 'redraw' ? '重绘' : '快速编辑'}`,
);
const quickEditReferences =
panelMode === 'redraw' || panelMode === 'quick-edit'
? []
: (quickEditPanel.quickEditReferences ?? []);
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 basePrompt =
quickEditPanel.prompt.trim() ||
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
const normalizedPrompt = panelMode === 'redraw' ? basePrompt : basePrompt;
let quickEditDialogId: string | undefined;
const shouldUseCanvasPlaceholder =
panelMode === 'quick-edit' &&
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 needsExtraReferenceImageSrcs =
panelMode === 'quick-edit' ||
panelMode === 'redraw' ||
isVideoQuickEditSource(quickEditSourceLayer);
const extraReferenceImageSrcs = needsExtraReferenceImageSrcs
? 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 (
panelMode === 'quick-edit' &&
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,
quickEditReferences,
);
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 =
panelMode === 'quick-edit' &&
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
? quickEditSelectionState.marks
: [];
let sourceReferenceImageSrc: string;
if (quickEditSelectionMarks.length > 0) {
const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
);
const markedImageDataUrl =
await renderUiDesignAssetExtractionMarkedImage({
source: renderSourceImageSrc,
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
sourceReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
} else {
sourceReferenceImageSrc = await resolveEditorGenerationMediaReference(
quickEditSourceLayer,
'image',
projectId,
);
}
const generationInputs =
panelMode === 'redraw'
? buildEditGenerationInputs(
'重绘提示词',
normalizedPrompt,
quickEditSourceLayer,
)
: buildQuickEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
quickEditReferences,
);
const isCharacterRedraw =
panelMode === 'redraw' &&
quickEditSourceLayer.assetKind === 'character';
const generated =
panelMode === 'quick-edit'
? await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: normalizedPrompt,
sourceImageSrc: sourceReferenceImageSrc,
size: quickEditSize,
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
: {}),
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: quickEditResultTitle,
sourceResourceId: quickEditSourceLayer.resourceId,
targetLayerId: quickEditSourceLayer.id,
}),
onWalletBalanceMayHaveChanged,
)
: await runEditorGenerationWithWalletRefresh(
generateEditorImage({
prompt: normalizedPrompt,
size: quickEditSize,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
kind: isCharacterRedraw ? 'character' : 'quick-edit',
model: normalizedQuickEditModel,
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: quickEditResultTitle,
sourceResourceId: quickEditSourceLayer.resourceId,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: quickEditResultTitle,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
const characterWarningCallback = isCharacterRedraw
? onGenerationWarning
: undefined;
if (isCharacterRedraw) {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
)
) {
if (panelMode === 'quick-edit') {
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
}
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (panelMode === 'quick-edit') {
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
}
return;
}
if (panelMode === 'quick-edit') {
applyQuickEditResultToSourceLayer(
generated,
quickEditSourceLayer,
generationInputs,
quickEditDialogId,
quickEditResultTitle,
);
} else {
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
generationInputs,
panelMode,
quickEditDialogId
? {
frame: quickEditFrame,
dialogId: quickEditDialogId,
title: quickEditResultTitle,
}
: { title: 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),
});
}
}, [
addQuickEditResultLayer,
addVideoResultLayer,
applyQuickEditResultToSourceLayer,
applyProjectSnapshot,
canvasGenerationDialogs,
canvasSize,
getGeneratingDialogPlaceholder,
layers,
openCanvasGenerationDialog,
projectId,
assetFolderId,
upsertGeneratedAsset,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
viewport,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
]);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.mode === 'edit'
? '修改当前图片'
: dialog.mode === 'audio-sound-effect'
? '游戏音效'
: dialog.mode === 'audio-background-music'
? '游戏背景音乐'
: 'AI 生成图片');
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
if (canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: false,
}));
} else {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: dialog.mode === 'edit',
});
}
try {
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorGenerationMediaReference(
submissionPlan.sourceLayer,
'image',
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const editCanvasCompletionPlaceholder =
canvasCompletionPlaceholder ??
buildRightSideCanvasCompletionPlaceholder(
submissionPlan.sourceLayer,
{
width: submissionPlan.sourceLayer.originalWidth,
height: submissionPlan.sourceLayer.originalHeight,
originalWidth: submissionPlan.sourceLayer.originalWidth,
originalHeight: submissionPlan.sourceLayer.originalHeight,
},
);
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
size: `${submissionPlan.sourceLayer.originalWidth}x${submissionPlan.sourceLayer.originalHeight}`,
projectId,
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: submissionPlan.resultTitle,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
...(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 quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId ===
submissionPlan.sourceLayer.id
? quickEditSelectionState.marks
: [];
let referenceImageSrc: string;
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,
});
referenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
} else {
referenceImageSrc = await resolveEditorGenerationMediaReference(
submissionPlan.sourceLayer,
'image',
projectId,
);
}
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
...submissionPlan.editInput,
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
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 === '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;
}
addVideoResultLayer(
generated,
submissionPlan.result.title,
submissionPlan.result.generationInputs,
canvasCompletionPlaceholder,
canvasDialog?.id,
);
} else if (submissionPlan.kind === 'audio') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
submissionPlan.audioKind === 'sound-effect'
? 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,
},
}
: {}),
})
: 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,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addAudioResultLayer(
generated,
submissionPlan.result.title,
submissionPlan.result.generationInputs,
canvasCompletionPlaceholder,
canvasDialog?.id,
);
} else {
const imageGenerationInput =
await normalizeImageGenerationReferenceImages(
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,
);
const characterWarningCallback =
imageGenerationInput.kind === 'character'
? onGenerationWarning
: undefined;
if (imageGenerationInput.kind === 'character') {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
)
) {
return;
}
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return;
}
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: resultTitle,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
}
} catch (error) {
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,
getGeneratingDialogPlaceholder,
layerCounterRef,
layers,
quickEditSelectionState,
rememberImageModel,
projectId,
assetFolderId,
applyProjectSnapshot,
setGenerateDialog,
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 submissionPlan = buildCharacterAnimationSubmissionPlan({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
});
const canvasDialog = characterAnimationDialog;
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);
}
try {
const generationInputs = buildCharacterAnimationGenerationInputs(
submissionPlan.promptText,
characterAnimationSourceLayer,
);
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) {
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: submissionPlan.promptText,
characterAnimationResolution: characterAnimationPanel.resolution,
characterAnimationRatio: characterAnimationPanel.ratio,
characterAnimationFrameCount: characterAnimationPanel.frameCount,
characterAnimationDurationSeconds:
characterAnimationPanel.durationSeconds,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(
error,
'生成角色动画失败',
),
}));
return;
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
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,
],
);
}