Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
T
lhk229 c9db36b177 补齐完美像素第三处静默分支并区分两条结果文案
链路复查发现两处。

遗留:成功路径拿到 result.project 后,本地占位已被删除时直接 return。不套用是
对的(删除意图胜出),但什么都不说。同一形状此前已修两处,这是第三处;既有用例
只断言「不套用」,没断言「要说话」,所以没挡住。

新引入:上次让「对账已收口 + 本地已删」沿用了「只在素材库」的文案。两种情况事实
不同——快照里没有 dialog 时服务端画布确实没有结果;dialog 已收口时服务端画布上
有结果,只是本地未同步,重新加载即可见。混用会让用户以为画布上没有而重做。

两条文案抽成常量按事实分派。

第一次回归验证只有 1 条变红,暴露出「对账已收口 + 本地已删」这条分支根本没有
用例;补上后再破坏,2 条同时变红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:00:40 +00:00

2998 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type Dispatch,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
ApiClientError,
isGatewayUnknownOutcomeError,
} from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import {
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
splitEditorIconSpritesheet,
} from '../../services/image-editor/editorProjectClient';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import {
findCanvasGenerationDialogRecord,
isUnresolvedCanvasGenerationDialogRecord,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
CharacterReferenceImage,
CropExpandPanelState,
CropExpandResizeHandle,
GenerateDialogState,
ImageContextMenuState,
PublicationMaterialsWorkflowId,
QuickEditPanelState,
SidebarPanel,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendGenerationReference,
appendPublicationReference,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createAudioRedrawGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditGenerationDialogDraft,
createQuickEditPanelDraft,
createRedrawPanelDraft,
createSameSourceGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
createVideoRedrawGenerationDialogDraft,
hideGeneratedLayerComposerAfterBlur,
isCharacterSpecReferenceLayer,
isIconSpecReferenceLayer,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
} from './ImageCanvasGenerationModel';
import {
centerViewportOnPlacement,
chooseGenerationPlacement,
} from './ImageCanvasGenerationPlacementModel';
import { resolveQuickEditFocusViewport } from './ImageCanvasOverlayModel';
import {
buildCropExpandInsetsFromFrame,
removeImageBackground,
renderCropExpandImage,
snapImageToPerfectPixels,
} from './ImageCanvasRasterEditModel';
import {
createUiAssetExtractionDraftMark,
createUiAssetExtractionState,
normalizeUiAssetExtractionMark,
type UiAssetExtractionState,
type UiAssetExtractionTool,
updateUiAssetExtractionDraftMark,
} from './ImageCanvasUiAssetExtractionModel';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
type CanvasSize = { width: number; height: number };
type CropExpandResizeDragState = {
pointerId: number;
handle: CropExpandResizeHandle;
sourceLayerId: string;
startClientX: number;
startClientY: number;
startScale: number;
startFrame: CropExpandPanelState['frame'];
ratio: CropExpandPanelState['ratio'];
};
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type RememberedImageGenerationOptions = {
model: string;
aspectRatio: string;
imageSize: string;
};
function dataUrlToImageFile(dataUrl: string, fileName: string) {
const [header = '', payload = ''] = dataUrl.split(',');
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
const type = mimeMatch?.[1] ?? 'image/png';
const isBase64 = Boolean(mimeMatch?.[2]);
const binary = isBase64
? typeof atob === 'function'
? atob(payload)
: Buffer.from(payload, 'base64').toString('binary')
: 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 });
}
function createProjectResourceSnapshotFromLayer(
projectId: string,
layer: CanvasLayer,
): EditorProjectResourceSnapshot | null {
const imageSrc = layer.objectKey?.trim()
? `/${layer.objectKey.trim().replace(/^\/+/u, '')}`
: layer.src.trim();
if (!imageSrc) {
return null;
}
return {
resourceId: layer.resourceId,
projectId,
label: layer.title,
imageSrc,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
};
}
function createProjectLayerSnapshotFromLayer(
layer: CanvasLayer,
): EditorProjectLayerSnapshot {
return {
layerId: layer.id,
resourceId: layer.resourceId,
title: layer.title,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
sourceType: layer.sourceType,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
mediaType: layer.mediaType,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
durationSeconds: layer.durationSeconds,
};
}
function preserveSourceLayerInProjectSnapshot(
project: EditorProjectSnapshot,
sourceLayer: CanvasLayer,
): EditorProjectSnapshot {
if (
project.layers.some((layer) => layer.layerId === sourceLayer.id) ||
sourceLayer.resourceId.startsWith('generation-dialog:')
) {
return project;
}
const sourceResource = project.resources.some(
(resource) => resource.resourceId === sourceLayer.resourceId,
)
? null
: createProjectResourceSnapshotFromLayer(project.projectId, sourceLayer);
return {
...project,
resources: sourceResource
? [...project.resources, sourceResource]
: project.resources,
layers: [
createProjectLayerSnapshotFromLayer(sourceLayer),
...project.layers,
],
};
}
function findSourceGenerationDialog(
dialogs: CanvasGenerationDialogState[],
sourceLayer: CanvasLayer,
) {
return [...dialogs]
.reverse()
.find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
// sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。
(!dialog.generatedLayerId &&
dialog.sourceLayerId === sourceLayer.id &&
isGenerationDialogModeCompatibleWithSourceLayer(
dialog.mode,
sourceLayer,
)),
);
}
function isGenerationDialogModeCompatibleWithSourceLayer(
mode: CanvasGenerationDialogState['mode'],
sourceLayer: CanvasLayer,
) {
if (mode === 'quick-edit') {
return false;
}
if (sourceLayer.assetKind === 'character') {
return mode === 'character';
}
if (sourceLayer.assetKind === 'character-animation') {
return mode === 'character-animation';
}
if (sourceLayer.assetKind === 'ui-design') {
return mode === 'ui-design';
}
if (sourceLayer.assetKind === 'publication-material') {
return mode === 'publication';
}
if (
sourceLayer.assetKind === 'icon' ||
sourceLayer.assetKind === 'icon-spritesheet'
) {
return mode === 'icon';
}
if (
sourceLayer.assetKind === 'spec' ||
sourceLayer.assetKind === 'icon-spec'
) {
return mode === 'spec';
}
if (sourceLayer.assetKind === 'video') {
return mode === 'video';
}
if (sourceLayer.assetKind === 'sound-effect') {
return mode === 'audio-sound-effect';
}
if (sourceLayer.assetKind === 'background-music') {
return mode === 'audio-background-music';
}
return true;
}
function findSourceAnimationLayer(
layers: CanvasLayer[],
sourceLayer: CanvasLayer,
) {
if (sourceLayer.assetKind !== 'character-animation') {
return null;
}
const sourceResourceId = sourceLayer.sourceResourceId?.trim();
if (!sourceResourceId) {
return null;
}
return (
layers.find(
(layer) =>
layer.resourceId === sourceResourceId &&
layer.assetKind === 'character',
) ?? null
);
}
function getCanvasToolForGenerationMode(
mode: CanvasGenerationDialogState['mode'],
): CanvasTool {
if (mode === 'video') {
return 'video';
}
if (mode === 'audio-sound-effect' || mode === 'audio-background-music') {
return 'music';
}
if (mode === 'character' || mode === 'character-animation') {
return 'character';
}
if (mode === 'icon') {
return 'icon';
}
if (mode === 'publication') {
return 'publication';
}
if (mode === 'ui-design') {
return 'ui-design';
}
if (mode === 'spec') {
return 'spec';
}
return 'generate';
}
const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastCharacterSpecReference';
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastIconSpecReference';
const INVALID_CHARACTER_SPEC_WARNING =
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
const INVALID_ICON_SPEC_WARNING =
'选择的图片不是图标规范图,请选择生成规范里的图标规范。';
function normalizeRememberedImageGenerationOptions({
model,
aspectRatio,
imageSize,
}: Partial<RememberedImageGenerationOptions>): RememberedImageGenerationOptions {
const normalizedModel = normalizeEditorImageModel(model);
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
normalizedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
const aspectRatios = dimensionOptions.aspectRatios as readonly string[];
const imageSizes = dimensionOptions.imageSizes as readonly string[];
return {
model: normalizedModel,
aspectRatio:
aspectRatio && aspectRatios.includes(aspectRatio)
? aspectRatio
: (dimensionOptions.aspectRatios[0] ?? '1:1'),
imageSize:
imageSize && imageSizes.includes(imageSize)
? imageSize
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0] ??
'1K'),
};
}
function applyRememberedImageGenerationOptions(
draft: Omit<CanvasGenerationDialogState, 'id'>,
options: RememberedImageGenerationOptions,
): Omit<CanvasGenerationDialogState, 'id'> {
return resizeGenerationPlaceholderToImageSelection({
...draft,
...normalizeRememberedImageGenerationOptions(options),
}) as Omit<CanvasGenerationDialogState, 'id'>;
}
function shouldRememberImageGenerationOptions(
dialog: GenerateDialogState | null,
): dialog is GenerateDialogState & {
mode: 'generate' | 'character' | 'icon';
} {
return (
dialog?.mode === 'generate' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon'
);
}
function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) {
const nextLine = `${markNumber}号红色圈选框里的内容做以下修改:`;
const trimmedPrompt = prompt.trimEnd();
return trimmedPrompt ? `${trimmedPrompt}\n${nextLine}` : nextLine;
}
function getLocalGenerationReferenceStorage() {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
}
function readOptionalCachedString(
record: Record<string, unknown>,
key: keyof CharacterReferenceImage,
) {
const value = record[key];
return typeof value === 'string' ? value : undefined;
}
function readOptionalCachedNullableString(
record: Record<string, unknown>,
key: keyof CharacterReferenceImage,
) {
const value = record[key];
if (value === null) {
return null;
}
return typeof value === 'string' ? value : undefined;
}
function readCachedGenerationReference(
key: string,
currentUserId?: string | null,
): CharacterReferenceImage | null {
const storage = getLocalGenerationReferenceStorage();
if (!storage) {
return null;
}
try {
const rawValue = storage.getItem(key);
if (!rawValue) {
return null;
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return null;
}
const record = parsedValue as Record<string, unknown>;
const normalizedCurrentUserId = currentUserId?.trim();
const ownerUserId =
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
if (normalizedCurrentUserId && ownerUserId !== normalizedCurrentUserId) {
storage.removeItem(key);
return null;
}
const id = readOptionalCachedString(record, 'id');
const label = readOptionalCachedString(record, 'label');
const src = readOptionalCachedString(record, 'src');
if (!id || !label || !src) {
return null;
}
const reference: CharacterReferenceImage = { id, label, src };
const mediaType = readOptionalCachedString(record, 'mediaType');
if (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio'
) {
reference.mediaType = mediaType;
}
const mimeType = readOptionalCachedString(record, 'mimeType');
if (mimeType) {
reference.mimeType = mimeType;
}
const sizeBytes = record.sizeBytes;
if (typeof sizeBytes === 'number') {
reference.sizeBytes = sizeBytes;
}
const durationSeconds = record.durationSeconds;
if (typeof durationSeconds === 'number') {
reference.durationSeconds = durationSeconds;
}
for (const keyName of [
'objectKey',
'assetObjectId',
'resourceId',
'sourceAssetId',
] as const) {
const value = readOptionalCachedNullableString(record, keyName);
if (value !== undefined) {
reference[keyName] = value;
}
}
return reference;
} catch {
return null;
}
}
function writeCachedGenerationReference(
key: string,
reference: CharacterReferenceImage,
currentUserId?: string | null,
) {
const storage = getLocalGenerationReferenceStorage();
if (!storage) {
return;
}
try {
const ownerUserId = currentUserId?.trim();
storage.setItem(
key,
JSON.stringify(ownerUserId ? { ...reference, ownerUserId } : reference),
);
} catch {
// 中文注释:本地缓存只提升下次新建素材的便捷性,写入失败不阻断生成流程。
}
}
function createCharacterAnimationPanelFromDialog(
dialog: CanvasGenerationDialogState | null,
): CharacterAnimationPanelState | null {
if (dialog?.mode !== 'character-animation' || !dialog.sourceLayerId) {
return null;
}
return {
sourceLayerId: dialog.sourceLayerId,
promptText: dialog.prompt,
assetLabel: dialog.assetLabel,
resolution: dialog.characterAnimationResolution ?? '480p',
ratio: dialog.characterAnimationRatio ?? 'same',
frameCount: dialog.characterAnimationFrameCount ?? 32,
durationSeconds: dialog.characterAnimationDurationSeconds ?? 4,
status:
dialog.status === 'generating'
? 'generating'
: dialog.status === 'failed'
? 'failed'
: dialog.characterAnimationResult
? 'completed'
: 'idle',
errorMessage: dialog.errorMessage,
result: dialog.characterAnimationResult,
};
}
function applyCharacterAnimationPanelToDialog(
dialog: CanvasGenerationDialogState,
panel: CharacterAnimationPanelState | null,
): CanvasGenerationDialogState {
if (dialog.mode !== 'character-animation') {
return dialog;
}
if (!panel) {
return {
...dialog,
composerOpen: false,
};
}
return {
...dialog,
prompt: panel.promptText,
assetLabel: panel.assetLabel,
characterAnimationResolution: panel.resolution,
characterAnimationRatio: panel.ratio,
characterAnimationFrameCount: panel.frameCount,
characterAnimationDurationSeconds: panel.durationSeconds,
characterAnimationResult: panel.result,
status:
panel.status === 'generating'
? 'generating'
: panel.status === 'failed'
? 'failed'
: 'idle',
errorMessage: panel.errorMessage,
};
}
type GenerationWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
viewport: CanvasViewport;
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
canvasGenerationDialogs: CanvasGenerationDialogState[];
layerCounterRef: MutableRefObject<number>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => string;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
projectId?: string | null;
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
flushProjectPersistence?: () => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
};
export function useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
setViewport,
setLayers,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
}: GenerationWorkflowOptions) {
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
const [taskListRefreshKey, setTaskListRefreshKey] = useState(0);
const refreshTaskListForQueuedGeneration = useCallback(() => {
setIsTaskSidebarOpen(true);
setTaskListRefreshKey((key) => key + 1);
}, []);
const refreshTaskList = useCallback(() => {
setTaskListRefreshKey((key) => key + 1);
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
const perfectPixelLayerIdsRef = useRef(new Set<string>());
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
() => new Set(),
);
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
useState(false);
const [
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
] = useState(false);
const [
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
] = useState(false);
const [
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
] = useState(false);
const [
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
] = useState(false);
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
useState(false);
const [isUiDesignSpecMenuOpen, setIsUiDesignSpecMenuOpen] = useState(false);
const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] =
useState(false);
const [isMusicMenuOpen, setIsMusicMenuOpen] = useState(false);
const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false);
const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] =
useState(false);
const [
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
] = useState(false);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const [generationWarningVersion, setGenerationWarningVersion] = useState(0);
const showGenerationWarning = useCallback((warning: string) => {
setGenerationWarning(warning);
setGenerationWarningVersion((version) => version + 1);
}, []);
useEffect(() => {
if (!generationWarning) {
return;
}
const timer = window.setTimeout(() => {
setGenerationWarning(null);
}, 3_000);
return () => {
window.clearTimeout(timer);
};
}, [generationWarning, generationWarningVersion]);
const [cropExpandPanel, setCropExpandPanel] =
useState<CropExpandPanelState | null>(null);
const [uiAssetExtractionState, setUiAssetExtractionState] =
useState<UiAssetExtractionState | null>(null);
const [quickEditSelectionState, setQuickEditSelectionState] =
useState<UiAssetExtractionState | null>(null);
const quickEditSelectionStateRef = useRef<UiAssetExtractionState | null>(
null,
);
const cropExpandPanelRef = useRef<CropExpandPanelState | null>(null);
const cropExpandResizeDragRef = useRef<CropExpandResizeDragState | null>(
null,
);
const uiAssetExtractionMarkCounterRef = useRef(0);
useEffect(() => {
if (canvasGenerationDialogs.length > previousTaskCountRef.current) {
setIsTaskSidebarOpen(true);
}
previousTaskCountRef.current = canvasGenerationDialogs.length;
}, [canvasGenerationDialogs.length]);
const quickEditSelectionMarkCounterRef = useRef(0);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(null);
const [rememberedImageOptions, setRememberedImageOptions] =
useState<RememberedImageGenerationOptions>(() =>
normalizeRememberedImageGenerationOptions({ model: DEFAULT_IMAGE_MODEL }),
);
const [initialCharacterSpecReference] =
useState<CharacterReferenceImage | null>(() =>
readCachedGenerationReference(
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
),
);
const [initialIconSpecReference] = useState<CharacterReferenceImage | null>(
() =>
readCachedGenerationReference(
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
),
);
const currentUserIdRef = useRef(currentUserId);
const lastCharacterSpecReferenceRef = useRef<CharacterReferenceImage | null>(
initialCharacterSpecReference,
);
const lastIconSpecReferenceRef = useRef<CharacterReferenceImage | null>(
initialIconSpecReference,
);
const characterAnimationDialog =
isCanvasGenerationDialog(generateDialog) &&
generateDialog.mode === 'character-animation'
? generateDialog
: null;
const characterAnimationDialogPanel = createCharacterAnimationPanelFromDialog(
characterAnimationDialog,
);
const effectiveCharacterAnimationPanel =
characterAnimationDialogPanel ?? characterAnimationPanel;
cropExpandPanelRef.current = cropExpandPanel;
quickEditSelectionStateRef.current = quickEditSelectionState;
useEffect(() => {
currentUserIdRef.current = currentUserId;
lastCharacterSpecReferenceRef.current = readCachedGenerationReference(
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
);
lastIconSpecReferenceRef.current = readCachedGenerationReference(
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
);
}, [currentUserId]);
const updateQuickEditSelectionState = useCallback(
(
updater: (
currentState: UiAssetExtractionState | null,
) => UiAssetExtractionState | null,
) => {
const nextState = updater(quickEditSelectionStateRef.current);
quickEditSelectionStateRef.current = nextState;
setQuickEditSelectionState(nextState);
return nextState;
},
[],
);
useEffect(() => {
if (
generateDialog?.mode === 'character' &&
generateDialog.characterSpecReference
) {
lastCharacterSpecReferenceRef.current =
generateDialog.characterSpecReference;
writeCachedGenerationReference(
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
generateDialog.characterSpecReference,
currentUserIdRef.current,
);
return;
}
if (generateDialog?.mode === 'icon' && generateDialog.iconSpecReference) {
lastIconSpecReferenceRef.current = generateDialog.iconSpecReference;
writeCachedGenerationReference(
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
generateDialog.iconSpecReference,
currentUserIdRef.current,
);
return;
}
if (
generateDialog?.mode === 'ui-design' &&
generateDialog.uiDesignSpecReference
) {
lastIconSpecReferenceRef.current = generateDialog.uiDesignSpecReference;
writeCachedGenerationReference(
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
generateDialog.uiDesignSpecReference,
currentUserIdRef.current,
);
}
}, [generateDialog]);
useEffect(() => {
if (!shouldRememberImageGenerationOptions(generateDialog)) {
return;
}
const nextOptions = normalizeRememberedImageGenerationOptions({
model: generateDialog.imageModel,
aspectRatio: generateDialog.aspectRatio,
imageSize: generateDialog.imageSize,
});
setRememberedImageOptions((currentOptions) =>
currentOptions.model === nextOptions.model &&
currentOptions.aspectRatio === nextOptions.aspectRatio &&
currentOptions.imageSize === nextOptions.imageSize
? currentOptions
: nextOptions,
);
}, [generateDialog]);
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
: null;
const cropExpandSourceLayer = cropExpandPanel
? (layers.find((layer) => layer.id === cropExpandPanel.sourceLayerId) ??
null)
: null;
const uiAssetExtractionSourceLayer = uiAssetExtractionState
? (layers.find(
(layer) => layer.id === uiAssetExtractionState.sourceLayerId,
) ?? null)
: null;
const quickEditSelectionSourceLayer =
quickEditPanel?.status !== 'generating' && quickEditSelectionState
? (layers.find(
(layer) => layer.id === quickEditSelectionState.sourceLayerId,
) ?? null)
: null;
const characterAnimationSourceLayer = effectiveCharacterAnimationPanel
? (layers.find(
(layer) => layer.id === effectiveCharacterAnimationPanel.sourceLayerId,
) ?? null)
: null;
const characterAnimationPrice = effectiveCharacterAnimationPanel
? calculateCharacterAnimationPrice(
CHARACTER_ANIMATION_MODEL,
effectiveCharacterAnimationPanel.resolution,
effectiveCharacterAnimationPanel.durationSeconds,
)
: 0;
const closeGenerationTransientState = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsPickingQuickEditReferenceFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setIsPickingUiDesignSpecFromCanvas(false);
setIsMusicMenuOpen(false);
setIsPublicationMenuOpen(false);
setIsPublicationReferenceMenuOpen(false);
setIsPickingPublicationReferenceFromCanvas(false);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
setImageContextMenu(null);
}, [setImageContextMenu]);
const openPlacedCanvasGenerationDialog = useCallback(
(draft: Omit<CanvasGenerationDialogState, 'id'>) => {
const draftPlaceholder = draft.placeholder;
if (!draftPlaceholder) {
return {
dialogId: openCanvasGenerationDialog(draft),
placeholder: undefined,
};
}
// 中文注释:所有画布生成入口统一先走 placement 模型,避免新占位压住已有图层或生成占位。
const placement = chooseGenerationPlacement({
canvasSize,
viewport,
frame: draftPlaceholder,
layers,
generationDialogs: canvasGenerationDialogs,
});
const dialogId = openCanvasGenerationDialog({
...draft,
placeholder: placement,
});
setViewport(
centerViewportOnPlacement({
canvasSize,
viewport,
placement,
}),
);
return { dialogId, placeholder: placement };
},
[
canvasGenerationDialogs,
canvasSize,
layers,
openCanvasGenerationDialog,
setViewport,
viewport,
],
);
const activateCanvasGenerationEntry = useCallback(
(activeTool: CanvasTool) => {
closeGenerationTransientState();
setActiveTool(activeTool);
selectSingleLayer(null);
setQuickEditPanel(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
},
[closeGenerationTransientState, selectSingleLayer, setActiveTool],
);
const openGenerateDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
applyRememberedImageGenerationOptions(
createGenerateDialogDraft({ canvasSize, viewport }),
rememberedImageOptions,
),
);
activateCanvasGenerationEntry('generate');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
rememberedImageOptions,
viewport,
]);
const openSpecDialog = useCallback(
(specType: SpecGenerationType) => {
openPlacedCanvasGenerationDialog(
createSpecDialogDraft({ canvasSize, viewport, specType }),
);
activateCanvasGenerationEntry('generate');
},
[
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
],
);
const openCharacterAnimationPanel = useCallback(
(layer: CanvasLayer) => {
const draft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer,
});
if (!draft) {
return;
}
openPlacedCanvasGenerationDialog(draft);
activateCanvasGenerationEntry('character');
},
[
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
],
);
const openCharacterGenerationDialog = useCallback(() => {
const draft = applyRememberedImageGenerationOptions(
createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: rememberedImageOptions.model,
}),
rememberedImageOptions,
);
openPlacedCanvasGenerationDialog({
...draft,
characterSpecReference:
lastCharacterSpecReferenceRef.current ?? draft.characterSpecReference,
});
activateCanvasGenerationEntry('character');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
rememberedImageOptions,
viewport,
]);
const openIconGenerationDialog = useCallback(() => {
const draft = applyRememberedImageGenerationOptions(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: rememberedImageOptions.model,
}),
rememberedImageOptions,
);
openPlacedCanvasGenerationDialog({
...draft,
iconSpecReference:
lastIconSpecReferenceRef.current ?? draft.iconSpecReference,
});
activateCanvasGenerationEntry('icon');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
rememberedImageOptions,
viewport,
]);
const openPublicationGenerationDialog = useCallback(
(workflowId: PublicationMaterialsWorkflowId) => {
openPlacedCanvasGenerationDialog(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId,
}),
);
activateCanvasGenerationEntry('publication');
},
[
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
],
);
const openVideoGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createVideoGenerationDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('video');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openUiDesignGenerationDialog = useCallback(() => {
const draft = createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
openPlacedCanvasGenerationDialog({
...draft,
uiDesignSpecReference:
lastIconSpecReferenceRef.current ?? draft.uiDesignSpecReference,
});
activateCanvasGenerationEntry('ui-design');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openSoundEffectGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createSoundEffectGenerationDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('music');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openBackgroundMusicGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('music');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openEditDialog = useCallback(
(sourceLayer: CanvasLayer) => {
setMetadataLayer(null);
setImageContextMenu(null);
setQuickEditPanel(null);
setCropExpandPanel(null);
setUiAssetExtractionState(null);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(createEditDialogDraft(sourceLayer));
setActiveTool('generate');
},
[
archiveActiveCanvasGenerationDialog,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setMetadataLayer,
],
);
const createQuickEditModeDraft = useCallback(
(sourceLayer: CanvasLayer) => {
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
);
return createQuickEditPanelDraft(sourceLayer, {
imageModel: sourceDialog?.imageModel,
aspectRatio: sourceDialog?.aspectRatio,
imageSize: sourceDialog?.imageSize,
});
},
[canvasGenerationDialogs],
);
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
return;
}
setImageContextMenu(null);
setMetadataLayer(null);
setUiAssetExtractionState(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
const quickEditDraft = createQuickEditModeDraft(sourceLayer);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(null);
setQuickEditPanel(quickEditDraft);
selectSingleLayer(sourceLayer.id);
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
initialTool: null,
model: DEFAULT_IMAGE_MODEL,
});
quickEditSelectionStateRef.current = nextSelectionState;
setQuickEditSelectionState(nextSelectionState);
quickEditSelectionMarkCounterRef.current = 0;
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
setActiveTool('generate');
},
[
archiveActiveCanvasGenerationDialog,
canvasSize,
createQuickEditModeDraft,
selectSingleLayer,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
setViewport,
],
);
const updateSourceLayer = useCallback(
(
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options: { fit?: boolean; persist?: boolean } = {},
) => {
const sourceLayer = layers.find((layer) => layer.id === sourceLayerId);
if (!sourceLayer) {
return;
}
const updatedLayer = updater(sourceLayer);
captureCanvasHistory({ type: 'replace-image', count: 1 });
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === sourceLayerId ? updatedLayer : layer,
),
);
if (options.persist !== false) {
persistUpdatedLayerResource?.(updatedLayer);
}
selectSingleLayer(sourceLayerId);
if (options.fit !== false) {
fitLayers([updatedLayer], { captureHistory: false });
}
},
[
captureCanvasHistory,
fitLayers,
layers,
persistUpdatedLayerResource,
selectSingleLayer,
setLayers,
],
);
const openRedrawPanel = useCallback(
(sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
if (sourceLayer.mediaType === 'audio') {
const audioDraft = createAudioRedrawGenerationDialogDraft(sourceLayer);
if (!audioDraft) {
return;
}
setQuickEditPanel(null);
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(audioDraft);
setActiveTool('music');
return;
}
if (sourceLayer.mediaType === 'video') {
const videoDraft = createVideoRedrawGenerationDialogDraft(sourceLayer);
if (!videoDraft) {
return;
}
setQuickEditPanel(null);
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(videoDraft);
setActiveTool('video');
return;
}
if (
sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image' ||
sourceLayer.assetKind === 'character-animation'
) {
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
sourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode: 'redraw',
sourceAnimationLayer,
});
if (sameSourceDraft) {
openPlacedCanvasGenerationDialog(sameSourceDraft);
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
return;
}
if (sourceLayer.assetKind === 'character-animation') {
showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
return;
}
const redrawPanel = createRedrawPanelDraft(sourceLayer, {
imageModel: sourceDialog?.imageModel,
aspectRatio: sourceDialog?.aspectRatio,
imageSize: sourceDialog?.imageSize,
});
const redrawSize = resolveEditorImageGenerationPixelSize({
model: redrawPanel.model,
aspectRatio: redrawPanel.aspectRatio,
imageSize: redrawPanel.imageSize,
});
openPlacedCanvasGenerationDialog({
...createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: redrawPanel.prompt,
model: redrawPanel.model,
aspectRatio: redrawPanel.aspectRatio,
imageSize: redrawPanel.imageSize,
frame: {
width: redrawSize.width,
height: redrawSize.height,
},
}),
composerOpen: true,
});
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool('generate');
return;
}
},
[
openCanvasGenerationDialog,
openPlacedCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
setImageContextMenu,
setMetadataLayer,
showGenerationWarning,
canvasGenerationDialogs,
canvasSize,
layers,
viewport,
],
);
const openCropExpandPanel = useCallback(
(sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
setUiAssetExtractionState(null);
archiveActiveCanvasGenerationDialog();
setGenerateDialog(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(null);
setCropExpandPanel({
sourceLayerId: sourceLayer.id,
frame: {
x: sourceLayer.x,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
},
ratio: 'free',
status: 'idle',
});
selectSingleLayer(sourceLayer.id);
setActiveTool('select');
},
[
archiveActiveCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setMetadataLayer,
],
);
const startCropExpandFrameResize = useCallback(
(
event: ReactPointerEvent<HTMLButtonElement>,
handle: CropExpandResizeHandle,
) => {
const currentPanel = cropExpandPanelRef.current;
if (!currentPanel) {
return;
}
event.preventDefault();
event.stopPropagation();
cropExpandResizeDragRef.current = {
pointerId: event.pointerId,
handle,
sourceLayerId: currentPanel.sourceLayerId,
startClientX: event.clientX,
startClientY: event.clientY,
startScale:
Number.isFinite(viewport.scale) && viewport.scale > 0
? viewport.scale
: 1,
startFrame: currentPanel.frame,
ratio: currentPanel.ratio,
};
selectSingleLayer(currentPanel.sourceLayerId);
},
[selectSingleLayer, viewport.scale],
);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const dragState = cropExpandResizeDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const deltaX =
(event.clientX - dragState.startClientX) / dragState.startScale;
const deltaY =
(event.clientY - dragState.startClientY) / dragState.startScale;
const nextFrame = resizeCropExpandFrame({
frame: dragState.startFrame,
handle: dragState.handle,
deltaX,
deltaY,
ratio: dragState.ratio,
});
setCropExpandPanel((currentPanel) =>
currentPanel?.sourceLayerId === dragState.sourceLayerId
? {
...currentPanel,
frame: nextFrame,
status:
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
);
};
const handlePointerUp = (event: PointerEvent) => {
const dragState = cropExpandResizeDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
cropExpandResizeDragRef.current = null;
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [setCropExpandPanel]);
const submitCropExpand = useCallback(async () => {
if (!cropExpandPanel || !cropExpandSourceLayer) {
return;
}
try {
const insets = buildCropExpandInsetsFromFrame({
layer: cropExpandSourceLayer,
frame: cropExpandPanel.frame,
});
setCropExpandPanel({
...cropExpandPanel,
status: 'processing',
errorMessage: undefined,
});
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
cropExpandSourceLayer.objectKey?.trim() || cropExpandSourceLayer.src,
);
const result = await renderCropExpandImage({
source: sourceImageSrc,
insets,
});
layerCounterRef.current += 1;
const cropExpandIndex = layerCounterRef.current;
const cropExpandTitle = `${cropExpandSourceLayer.title} 裁扩`;
const cropExpandProjectId = projectId?.trim();
let cropExpandResourceId = `local-resource-crop-expand-${cropExpandIndex}`;
let cropExpandImageSrc = result.imageSrc;
let cropExpandObjectKey: string | null = null;
let cropExpandAssetObjectId: string | null = null;
let cropExpandSourceResourceId: string | null =
cropExpandSourceLayer.resourceId;
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
if (cropExpandProjectId) {
const cropExpandUploadId = createEditorGenerationMediaUploadId();
const cropExpandFile = dataUrlToImageFile(
result.imageSrc,
`crop-expand-${cropExpandIndex}-${cropExpandUploadId}.png`,
);
const uploadedCropExpand = await uploadEditorMediaAssetFile(
cropExpandFile,
'image',
{
assetKind: 'editor_crop_expand_image',
pathSegments: [
'editor',
'crop-expand',
cropExpandProjectId,
cropExpandUploadId,
],
entityId: cropExpandProjectId,
metadata: {
editor_project_id: cropExpandProjectId,
source_resource_id: cropExpandSourceLayer.resourceId,
},
},
);
const cropExpandResource = await createEditorProjectResource(
cropExpandProjectId,
{
imageSrc: uploadedCropExpand.legacyPublicPath,
objectKey: uploadedCropExpand.objectKey,
assetObjectId: uploadedCropExpand.assetObjectId,
width: result.width,
height: result.height,
sourceType: 'generated',
prompt: cropExpandSourceLayer.prompt,
actualPrompt: cropExpandSourceLayer.actualPrompt,
model: cropExpandSourceLayer.model,
provider: cropExpandSourceLayer.provider,
taskId: cropExpandSourceLayer.taskId,
sourceResourceId: cropExpandSourceLayer.resourceId,
assetKind: cropExpandSourceLayer.assetKind,
generationInputs: cropExpandSourceLayer.generationInputs,
},
);
cropExpandResourceId = cropExpandResource.resourceId;
cropExpandImageSrc = cropExpandResource.imageSrc;
cropExpandObjectKey =
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
cropExpandAssetObjectId =
cropExpandResource.assetObjectId ?? uploadedCropExpand.assetObjectId;
cropExpandSourceResourceId =
cropExpandResource.sourceResourceId ??
cropExpandSourceLayer.resourceId;
cropExpandAssetKind =
(cropExpandResource.assetKind as CanvasLayer['assetKind']) ??
cropExpandSourceLayer.assetKind;
}
const nextLayer: CanvasLayer = {
...cropExpandSourceLayer,
id: `layer-crop-expand-${cropExpandIndex}`,
resourceId: cropExpandResourceId,
title: cropExpandTitle,
src: cropExpandImageSrc,
x: cropExpandSourceLayer.x + cropExpandSourceLayer.width + 32,
y: cropExpandSourceLayer.y,
width: result.width,
height: result.height,
originalWidth: result.width,
originalHeight: result.height,
zIndex: cropExpandIndex + 10,
sourceType: 'generated',
sourceResourceId: cropExpandSourceResourceId,
objectKey: cropExpandObjectKey,
assetObjectId: cropExpandAssetObjectId,
sourceAssetId: null,
assetKind: cropExpandAssetKind,
};
captureCanvasHistory({ type: 'expand-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
persistGeneratedAsset?.(nextLayer);
selectSingleLayer(nextLayer.id);
fitLayers([cropExpandSourceLayer, nextLayer], { captureHistory: false });
setCropExpandPanel(null);
setActiveSidebarPanel('layers');
} catch (error) {
setCropExpandPanel({
...cropExpandPanel,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '裁扩图片失败',
});
}
}, [
cropExpandPanel,
cropExpandSourceLayer,
appendCanvasLayersWithResources,
captureCanvasHistory,
fitLayers,
layerCounterRef,
persistGeneratedAsset,
projectId,
selectSingleLayer,
setActiveSidebarPanel,
]);
const removeSelectedLayerBackground = useCallback(
async (sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
const assetLabel = `${sourceLayer.title} 去背景`;
const backgroundRemovalDialog =
projectId && applyProjectSnapshot
? createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: '去除背景',
status: 'generating',
frame: {
width: sourceLayer.width,
height: sourceLayer.height,
},
})
: null;
const backgroundRemovalPlacement = backgroundRemovalDialog
? openPlacedCanvasGenerationDialog({
...backgroundRemovalDialog,
composerOpen: false,
})
: undefined;
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
const applyBackgroundRemovalProjectSnapshot =
projectId && applyProjectSnapshot
? (project: EditorProjectSnapshot) => {
applyProjectSnapshot(
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
);
}
: undefined;
try {
const sourceImageSrc = await resolveEditorGenerationMediaReference(
sourceLayer,
'image',
projectId,
);
const result = await removeImageBackground({
sourceImageSrc,
projectId,
targetLayerId: sourceLayer.id,
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
assetFolderId,
assetLabel,
sourceResourceId: sourceLayer.resourceId,
...(backgroundRemovalPlacement?.placeholder
? {
canvasCompletion: {
dialogId: backgroundRemovalDialogId,
title: assetLabel,
placeholder: backgroundRemovalPlacement.placeholder,
},
}
: {}),
});
await applyQueuedEditorGenerationProject(
result,
projectId,
(project) =>
applyProjectSnapshot?.(project, {
type: 'remove-background',
count: 1,
}),
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
showGenerationWarning,
backgroundRemovalDialogId,
applyBackgroundRemovalProjectSnapshot
? (project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
: undefined,
);
} catch (error) {
if (backgroundRemovalDialogId) {
updateCanvasGenerationDialogById(
backgroundRemovalDialogId,
(dialog) => ({
...dialog,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '去除背景失败',
}),
);
}
throw error;
}
},
[
applyProjectSnapshot,
assetFolderId,
onWalletBalanceMayHaveChanged,
openPlacedCanvasGenerationDialog,
projectId,
refreshTaskListForQueuedGeneration,
setImageContextMenu,
setMetadataLayer,
showGenerationWarning,
updateCanvasGenerationDialogById,
],
);
// 中文注释:两条文案对应两种**不同的事实**,不能共用。
// 「未落入画布」:服务端 completion 返回 Ok(None)——占位在处理期间被删掉,服务端画布上
// 没有结果图层,结果只在素材库。
// 「未落入当前画布」:服务端已经完成回填,画布上**有**结果图层,只是本地占位已被用户删除、
// 按删除意图不套用快照,所以本地看不到;重新加载即可见。
// 早先这两种情况共用前一条文案,会让「结果其实已在画布上」的用户以为只进了素材库,
// 进而重做一遍——正是本链路要消除的重复创建。
const PERFECT_PIXEL_ASSET_ONLY_NOTICE =
'完美像素结果已保存到素材库,画布占位已不存在。';
const PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE =
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。';
const snapSelectedLayerToPerfectPixels = useCallback(
async (sourceLayer: CanvasLayer) => {
const normalizedProjectId = projectId?.trim();
const isStaticRasterLayer =
(sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image') &&
sourceLayer.assetKind !== 'character-animation';
if (
!isStaticRasterLayer ||
!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence ||
perfectPixelLayerIdsRef.current.has(sourceLayer.id)
) {
if (
isStaticRasterLayer &&
(!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence)
) {
showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
}
return;
}
perfectPixelLayerIdsRef.current.add(sourceLayer.id);
setPerfectPixelLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(sourceLayer.id);
return nextLayerIds;
});
closeGenerationTransientState();
setImageContextMenu(null);
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(null);
let perfectPixelDialogId: string | undefined;
let perfectPixelPostAttempted = false;
try {
const assetLabel = `${sourceLayer.title} · 完美像素`;
const placement = openPlacedCanvasGenerationDialog({
...createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: '完美像素',
assetLabel,
status: 'generating',
frame: {
width: sourceLayer.width,
height: sourceLayer.height,
},
}),
composerOpen: false,
// 中文注释:完美像素是同步 HTTP,服务端不建 durable job(见 snap_editor_image_to_pixel_art
// 无 enqueue / spawn)。刷新掉本页会话后,没有任何东西会把这个占位推向终态,而
// 服务端 409 门禁又要求占位必须先落库,所以只能在读取侧收口:置位后由项目首次
// 加载时的 dropDeadInlineGenerationPlaceholders 清掉。
requiresLiveSession: true,
});
perfectPixelDialogId = placement.dialogId;
if (!placement.placeholder) {
throw new Error('无法创建完美像素处理占位');
}
const sourceImageSrc = await resolveEditorGenerationMediaReference(
sourceLayer,
'image',
normalizedProjectId,
);
await flushProjectPersistence();
const sourceResourceId = sourceLayer.resourceId.trim();
perfectPixelPostAttempted = true;
const result = await snapImageToPerfectPixels({
sourceImageSrc,
projectId: normalizedProjectId,
sourceResourceId:
sourceResourceId &&
!sourceResourceId.startsWith('local-resource-') &&
!sourceResourceId.startsWith('generation-dialog:')
? sourceResourceId
: undefined,
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
assetFolderId,
assetLabel,
canvasCompletion: {
dialogId: perfectPixelDialogId,
title: assetLabel,
placeholder: placement.placeholder,
},
});
upsertGeneratedAsset?.(result.asset);
if (!result.project) {
// 中文注释:提示不能挂在「占位还在」这个条件里。占位已被删除时结果同样已经落库,
// 而且此时用户更需要知道——他删掉的是占位,不是已经生成的素材。原先提示与移除
// 绑在同一个 if 里,占位不在就整段静默,用户不知道素材库多了一份。
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
}
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
// 中文注释:不套用是对的(删除意图胜出),但不能静默。服务端已经完成并把结果落到了
// 画布上,只是本地按用户的删除意图没有同步——必须说清楚,否则用户既看不到结果、
// 也不知道它已经存在,会再做一遍。这是本链路同一形状的第三处,前两处已修。
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
applyProjectSnapshot(result.project, {
type: 'perfect-pixel',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
// 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析
// 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向
// 谎报。
//
// 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。服务端持久化
// 是非事务的(OSS + asset object → project resource → editor asset → canvas
// completion),而 completion 失败走 map_editor_project_error 会被映射成
// 403 / 404 / 409 / 400,带响应的 4xx 同样可能发生在对象、资源和账号素材全部落库
// 之后。状态码分不出持久化阶段,所以服务端在第一次 OSS PUT 之后的失败路径上置
// `resultPersistenceStarted`,客户端只对这类失败和完全无响应的失败做对账——常见的
// 纯校验 400 / 排队 503 / 预算 504 不会白白多两次读取,也不会被附上不适用的提示。
const persistenceMayHaveStarted =
error instanceof ApiClientError &&
(error.details as { resultPersistenceStarted?: unknown } | null)
?.resultPersistenceStarted === true;
const outcomeMayBePersisted =
perfectPixelPostAttempted &&
Boolean(perfectPixelDialogId) &&
// 中文注释:拿到 HTTP 响应不等于服务端明确表态过。Pingora 网关在上游超时或断连时
// 会自己合成 502 / 504,那种响应只有 code / message、没有 details,因此既不是
// transport 异常也拿不到 resultPersistenceStarted——按原判据会被当成确定失败,
// 而此时 api-server 可能已经走完 OSS PUT 与落库。必须算作未知结果去对账。
(!(error instanceof ApiClientError) ||
persistenceMayHaveStarted ||
isGatewayUnknownOutcomeError(error));
let reconciledMessage: string | undefined;
if (outcomeMayBePersisted && perfectPixelDialogId) {
// 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET
// 项目却让用户去核对素材库,他看到的仍是旧列表。
//
// 但不能因为调了它就在文案里断言「素材库已刷新」:refreshAssetLibrary 在
// `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时
// 弹登录框、其余一律吞掉,返回 `Promise<void>` 不带成败信号;它本身还是可选
// prop。刷新成功时用户白赚一份新列表,失败时文案必须仍然成立,所以下面只给
// 「请确认」的指令,不给「已刷新」的事实断言。
const [reconciled] = await Promise.all([
loadEditorProject(normalizedProjectId).catch(() => null),
Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
]);
if (reconciled) {
// 中文注释:判据必须看 dialog 的 status / generatedLayerId**不能**看它在不在。
// 服务端成功回填时保留该 dialog 并就地改写(置 status: "idle"、写 generatedLayerId),
// 按「在不在」判会把真成功判成失败——用户会被告知「画布未收到结果」,然后重做一遍
// 造出第二份。三种服务端状态各有对应处置,见下。
const reconciledDialog = findCanvasGenerationDialogRecord(
reconciled,
perfectPixelDialogId,
);
if (!reconciledDialog) {
// 中文注释:占位在权威快照里不存在——只可能是它在处理期间被删掉了(本会话或另一
// 标签页),服务端 complete_editor_canvas_generation 因此返回 Ok(None):资源与
// 账号素材已落库,但画布上没有结果图层。不能套用这份快照当成功,它根本不含结果。
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
updateCanvasGenerationDialogById(
perfectPixelDialogId,
() => null,
);
}
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
if (!isUnresolvedCanvasGenerationDialogRecord(reconciledDialog)) {
// 中文注释:服务端已经完成并回填,只是响应没回来。这是真成功。
// 但本地占位若已被用户删除,删除意图仍然胜出:不套用快照、不写历史。
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
applyProjectSnapshot(reconciled, {
type: 'perfect-pixel',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
return;
}
// 中文注释:占位仍未收口,画布确实没收到结果。这里刻意不调 applyProjectSnapshot
// 传给本 hook 的是 ImageCanvasEditorView 的 applyGeneratedProjectSnapshot
// 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受
// 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白
// 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。
reconciledMessage = '画布未收到完美像素结果。';
} else {
reconciledMessage = '权威项目快照读取失败,无法确认完美像素结果。';
}
}
const serverMessage =
error instanceof Error && error.message.trim()
? error.message
: undefined;
// 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。
// 持久化非事务,对象与账号素材可能已落库而只有画布回填没完成,所以必须让用户先核对
// 素材库再决定,而不是直接重试。尾句只下指令、不断言素材库已经刷新——上面那次刷新
// 可能是 no-op 或被静默吞掉,断言会让用户对着旧列表判定「没有派生图,可以重试」。
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请确认素材库是否已生成派生图,再决定是否重试。`
: (serverMessage ?? '完美像素处理失败');
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)
) {
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
status: 'failed',
errorMessage,
}));
} else {
// 中文注释:占位不存在时同样要说话。除了「占位还没建起来就失败」,还有一种是
// 用户在请求期间主动删掉了生成中占位(`requestRemoveCanvasGenerationDialog`
// 对 generating 会先弹确认,是产品支持的流程)。此时 dialogId 有值但占位已经
// 不在,没有地方挂错误——早先这里只判 `!perfectPixelDialogId`,该分支整条
// 落空,errorMessage 被算出来又丢掉,界面零反馈。丢的还不只是失败提示:对账
// 得出的「请确认素材库是否已生成派生图」也在同一句里,用户会在毫不知情的
// 情况下重试,再造一份 OSS 对象与素材。
showGenerationWarning(errorMessage);
}
} finally {
perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
setPerfectPixelLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(sourceLayer.id)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(sourceLayer.id);
return nextLayerIds;
});
}
},
[
applyProjectSnapshot,
assetFolderId,
closeGenerationTransientState,
flushProjectPersistence,
hasCanvasGenerationDialogById,
openPlacedCanvasGenerationDialog,
refreshAssetLibrary,
projectId,
setActiveSidebarPanel,
setActiveTool,
setCharacterAnimationPanel,
setCropExpandPanel,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
showGenerationWarning,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
],
);
const splitSelectedIconSpritesheet = useCallback(
async (sourceLayer: CanvasLayer) => {
if (
sourceLayer.assetKind !== 'icon-spritesheet' ||
!projectId ||
!applyProjectSnapshot ||
splittingIconSpritesheetLayerIdsRef.current.has(sourceLayer.id)
) {
return;
}
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(sourceLayer.id);
return nextLayerIds;
});
closeGenerationTransientState();
setImageContextMenu(null);
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
try {
const result = await splitEditorIconSpritesheet({
projectId,
sourceResourceId: sourceLayer.resourceId,
assetFolderId,
canvasCompletion: {
title: '拆分图集',
placeholder: {
x: sourceLayer.x,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
originalWidth: sourceLayer.originalWidth,
originalHeight: sourceLayer.originalHeight,
},
},
});
applyProjectSnapshot(result.project, {
type: 'split-atlas',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
window.alert(
error instanceof Error && error.message.trim()
? error.message
: '拆分图集失败',
);
} finally {
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(sourceLayer.id)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(sourceLayer.id);
return nextLayerIds;
});
}
},
[
applyProjectSnapshot,
assetFolderId,
closeGenerationTransientState,
projectId,
setActiveSidebarPanel,
setActiveTool,
setCropExpandPanel,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
],
);
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isCharacterSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
return;
}
setGenerationWarning(null);
setGenerateDialog((currentDialog) => {
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
if (
nextDialog?.mode === 'character' &&
nextDialog.characterSpecReference
) {
lastCharacterSpecReferenceRef.current =
nextDialog.characterSpecReference;
}
return nextDialog;
});
setIsPickingCharacterSpecFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu, showGenerationWarning],
);
const pickGenerationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendGenerationReference(currentDialog, layer),
);
setIsPickingGenerationReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickQuickEditReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendGenerationReference(currentDialog, layer),
);
setIsPickingQuickEditReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendCharacterReference(currentDialog, layer),
);
setIsPickingCharacterReferenceFromCanvas(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
const nextDialog = assignIconSpecReference(generateDialog, layer);
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
}
setGenerateDialog(nextDialog);
if (nextDialog === generateDialog) {
return;
}
setIsPickingIconSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
},
[
generateDialog,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
if (
nextDialog?.mode === 'ui-design' &&
nextDialog.uiDesignSpecReference
) {
lastIconSpecReferenceRef.current = nextDialog.uiDesignSpecReference;
}
setGenerateDialog(nextDialog);
if (nextDialog === generateDialog) {
return;
}
setIsPickingUiDesignSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[
generateDialog,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickPublicationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendPublicationReference(currentDialog, layer),
);
setIsPickingPublicationReferenceFromCanvas(false);
setIsPublicationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const updateIconDescriptionsText = useCallback(
(value: string) => {
setGenerateDialog((currentDialog) =>
updateIconDescriptionsTextInDialog(currentDialog, value),
);
},
[setGenerateDialog],
);
const rememberImageModel = useCallback((model: string) => {
setRememberedImageOptions((currentOptions) =>
normalizeRememberedImageGenerationOptions({
...currentOptions,
model,
}),
);
}, []);
const generationSubmissionWorkflow =
useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
canvasGenerationDialogs,
setQuickEditPanel,
characterAnimationPanel: effectiveCharacterAnimationPanel,
characterAnimationDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
projectId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask: refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
onGenerationWarning: showGenerationWarning,
});
const setEffectiveCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
> = useCallback(
(updater) => {
setGenerateDialog((currentDialog) => {
if (
!isCanvasGenerationDialog(currentDialog) ||
currentDialog.mode !== 'character-animation'
) {
return currentDialog;
}
const currentPanel =
createCharacterAnimationPanelFromDialog(currentDialog);
const nextPanel =
typeof updater === 'function' ? updater(currentPanel) : updater;
return applyCharacterAnimationPanelToDialog(currentDialog, nextPanel);
});
setCharacterAnimationPanel((currentPanel) => {
if (characterAnimationDialog) {
return currentPanel;
}
return typeof updater === 'function' ? updater(currentPanel) : updater;
});
},
[characterAnimationDialog, setGenerateDialog],
);
const {
extractUiDesignAssets: submitUiDesignAssetExtraction,
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
} = generationSubmissionWorkflow;
const extractUiDesignAssets = useCallback(
(sourceLayer: CanvasLayer) => {
if (sourceLayer.assetKind !== 'ui-design') {
return;
}
closeGenerationTransientState();
setMetadataLayer(null);
setQuickEditPanel(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
selectSingleLayer(sourceLayer.id);
setActiveTool('select');
setUiAssetExtractionState(
createUiAssetExtractionState(sourceLayer.id, {
initialTool: 'rect',
model: DEFAULT_IMAGE_MODEL,
}),
);
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
},
[
canvasSize,
closeGenerationTransientState,
selectSingleLayer,
setActiveTool,
setCharacterAnimationPanel,
setCropExpandPanel,
setMetadataLayer,
setQuickEditPanel,
setViewport,
],
);
const changeUiAssetExtractionTool = useCallback(
(tool: UiAssetExtractionTool | null) => {
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
tool: currentState.tool === tool ? null : tool,
draftMark: null,
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const changeUiAssetExtractionModel = useCallback(
(model: string) => {
const normalizedModel = normalizeEditorImageModel(model);
rememberImageModel(normalizedModel);
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
model: normalizedModel,
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[rememberImageModel],
);
const appendUiAssetExtractionReferences = useCallback(
(references: CharacterReferenceImage[]) => {
if (!references.length) {
return;
}
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
references: [...currentState.references, ...references],
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const removeUiAssetExtractionReference = useCallback(
(referenceId: string) => {
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
references: currentState.references.filter(
(reference) => reference.id !== referenceId,
),
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const changeQuickEditSelectionTool = useCallback(
(tool: UiAssetExtractionTool | null) => {
updateQuickEditSelectionState((currentState) =>
currentState
? {
...currentState,
tool: currentState.tool === tool ? null : tool,
draftMark: null,
status: 'idle',
errorMessage: undefined,
}
: currentState,
);
},
[updateQuickEditSelectionState],
);
const startUiAssetExtractionPointer = useCallback(
(point: { x: number; y: number }) => {
setUiAssetExtractionState((currentState) => {
if (
!currentState ||
!currentState.tool ||
currentState.status === 'extracting'
) {
return currentState;
}
uiAssetExtractionMarkCounterRef.current += 1;
return {
...currentState,
draftMark: createUiAssetExtractionDraftMark({
tool: currentState.tool,
id: `ui-extraction-mark-${uiAssetExtractionMarkCounterRef.current}`,
point,
}),
status: 'idle',
errorMessage: undefined,
};
});
},
[],
);
const startQuickEditSelectionPointer = useCallback(
(point: { x: number; y: number }) => {
updateQuickEditSelectionState((currentState) => {
if (!currentState || !currentState.tool) {
return currentState;
}
quickEditSelectionMarkCounterRef.current += 1;
return {
...currentState,
draftMark: createUiAssetExtractionDraftMark({
tool: currentState.tool,
id: `quick-edit-selection-mark-${quickEditSelectionMarkCounterRef.current}`,
point,
}),
status: 'idle',
errorMessage: undefined,
};
});
},
[updateQuickEditSelectionState],
);
const moveUiAssetExtractionPointer = useCallback(
(point: { x: number; y: number }) => {
setUiAssetExtractionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: updateUiAssetExtractionDraftMark(
currentState.draftMark,
point,
),
}
: currentState,
);
},
[],
);
const moveQuickEditSelectionPointer = useCallback(
(point: { x: number; y: number }) => {
updateQuickEditSelectionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: updateUiAssetExtractionDraftMark(
currentState.draftMark,
point,
),
}
: currentState,
);
},
[updateQuickEditSelectionState],
);
const endUiAssetExtractionPointer = useCallback(() => {
setUiAssetExtractionState((currentState) => {
if (!currentState?.draftMark) {
return currentState;
}
const normalizedMark = normalizeUiAssetExtractionMark(
currentState.draftMark,
);
return {
...currentState,
draftMark: null,
marks: normalizedMark
? [...currentState.marks, normalizedMark]
: currentState.marks,
};
});
}, []);
const cancelUiAssetExtractionPointer = useCallback(() => {
setUiAssetExtractionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: null,
}
: currentState,
);
}, []);
const endQuickEditSelectionPointer = useCallback(() => {
const currentSelectionState = quickEditSelectionStateRef.current;
if (!currentSelectionState?.draftMark) {
return;
}
const normalizedMark = normalizeUiAssetExtractionMark(
currentSelectionState.draftMark,
);
if (!normalizedMark) {
updateQuickEditSelectionState(() => ({
...currentSelectionState,
draftMark: null,
}));
return;
}
const nextMarkNumber = currentSelectionState.marks.length + 1;
updateQuickEditSelectionState(() => ({
...currentSelectionState,
draftMark: null,
marks: [...currentSelectionState.marks, normalizedMark],
}));
setQuickEditPanel((currentPanel) =>
currentPanel?.sourceLayerId === currentSelectionState.sourceLayerId
? {
...currentPanel,
status:
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
prompt: appendQuickEditSelectionPrompt(
currentPanel.prompt,
nextMarkNumber,
),
}
: currentPanel,
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'quick-edit' &&
currentDialog.sourceLayerId === currentSelectionState.sourceLayerId
? {
...currentDialog,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
prompt: appendQuickEditSelectionPrompt(
currentDialog.prompt,
nextMarkNumber,
),
composerOpen: true,
}
: currentDialog,
);
}, [setGenerateDialog, setQuickEditPanel, updateQuickEditSelectionState]);
const cancelQuickEditSelectionPointer = useCallback(() => {
updateQuickEditSelectionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: null,
}
: currentState,
);
}, [updateQuickEditSelectionState]);
useEffect(() => {
if (!quickEditSelectionState) {
return;
}
const hasActiveQuickEditDialog =
generateDialog?.mode === 'quick-edit' &&
generateDialog.sourceLayerId === quickEditSelectionState.sourceLayerId;
if (!hasActiveQuickEditDialog && !quickEditPanel) {
quickEditSelectionStateRef.current = null;
setQuickEditSelectionState(null);
}
}, [generateDialog, quickEditPanel, quickEditSelectionState]);
const cancelUiAssetExtraction = useCallback(() => {
setUiAssetExtractionState(null);
}, []);
const submitUiAssetExtraction = useCallback(async () => {
const currentState = uiAssetExtractionState;
const sourceLayer = uiAssetExtractionSourceLayer;
if (!currentState || !sourceLayer || !currentState.marks.length) {
return;
}
setUiAssetExtractionState({
...currentState,
status: 'extracting',
errorMessage: undefined,
});
try {
await submitUiDesignAssetExtraction(sourceLayer, {
marks: currentState.marks,
model: currentState.model,
references: currentState.references,
suppressAlert: true,
});
setUiAssetExtractionState(null);
} catch (error) {
setUiAssetExtractionState((latestState) =>
latestState?.sourceLayerId === currentState.sourceLayerId
? {
...latestState,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '提取素材失败',
}
: latestState,
);
}
}, [
submitUiDesignAssetExtraction,
uiAssetExtractionSourceLayer,
uiAssetExtractionState,
]);
const updateSpecFormValue = useCallback(
(key: keyof SpecFormValues, value: string) => {
setGenerateDialog((currentDialog) =>
updateSpecFormDialogValue(currentDialog, key, value),
);
},
[setGenerateDialog],
);
const updateCharacterAnimationDuration = useCallback(
(frameCountValue: string) => {
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
(item) => String(item.frameCount) === frameCountValue,
);
if (option) {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character-animation'
? {
...currentDialog,
characterAnimationFrameCount: option.frameCount,
characterAnimationDurationSeconds: option.durationSeconds,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
);
}
setCharacterAnimationPanel((currentPanel) =>
updateCharacterAnimationDurationPanel(currentPanel, frameCountValue),
);
},
[setGenerateDialog],
);
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
hideGeneratedLayerComposerAfterBlur(currentDialog),
);
}, [setGenerateDialog]);
const closeGenerateComposer = useCallback(() => {
setGenerateDialog(closeGenerateComposerDialog);
setActiveTool('select');
}, [setActiveTool, setGenerateDialog]);
const clearDeletedLayerGenerationState = useCallback(
(targetLayerId: string) => {
setQuickEditPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
quickEditReferences: (
currentPanel.quickEditReferences ?? []
).filter(
(reference) => reference.id !== `canvas-${targetLayerId}`,
),
}
: currentPanel,
);
setCropExpandPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setUiAssetExtractionState((currentState) =>
currentState?.sourceLayerId === targetLayerId ? null : currentState,
);
setQuickEditSelectionState((currentState) =>
currentState?.sourceLayerId === targetLayerId ? null : currentState,
);
setCharacterAnimationPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'edit' &&
currentDialog.sourceLayerId === targetLayerId
? null
: currentDialog,
);
removeCanvasGenerationDialogsByLayerId(targetLayerId);
},
[removeCanvasGenerationDialogsByLayerId, setGenerateDialog],
);
return useMemo(
() => ({
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
cropExpandPanel,
setCropExpandPanel,
cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
moveUiAssetExtractionPointer,
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
submitUiAssetExtraction,
characterAnimationPanel: effectiveCharacterAnimationPanel,
setCharacterAnimationPanel: setEffectiveCharacterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPrice,
isSpecMenuOpen,
setIsSpecMenuOpen,
isGenerationReferenceMenuOpen,
setIsGenerationReferenceMenuOpen,
isCharacterSpecMenuOpen,
setIsCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
isIconSpecMenuOpen,
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
isUiDesignSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
isMusicMenuOpen,
setIsMusicMenuOpen,
isPublicationMenuOpen,
setIsPublicationMenuOpen,
isPublicationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
generationWarning,
generationWarningVersion,
showGenerationWarning,
clearGenerationWarning: () => setGenerationWarning(null),
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openIconGenerationDialog,
openPublicationGenerationDialog,
openVideoGenerationDialog,
openUiDesignGenerationDialog,
openSoundEffectGenerationDialog,
openBackgroundMusicGenerationDialog,
openEditDialog,
openQuickEditPanel,
openRedrawPanel,
openCropExpandPanel,
startCropExpandFrameResize,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
taskListRefreshKey,
refreshTaskList,
isTaskSidebarOpen,
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
pickPublicationReferenceFromLayer,
submitIconSpritesheetGeneration,
submitQuickEdit,
submitCropExpand,
submitImageGeneration,
updateSpecFormValue,
updateIconDescriptionsText,
updateCharacterAnimationDuration,
rememberImageModel,
submitCharacterAnimation,
hideGeneratedLayerPanelAfterBlur,
closeGenerateComposer,
clearDeletedLayerGenerationState,
}),
[
effectiveCharacterAnimationPanel,
characterAnimationPrice,
characterAnimationSourceLayer,
clearDeletedLayerGenerationState,
closeGenerateComposer,
cropExpandPanel,
cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
moveUiAssetExtractionPointer,
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
submitUiAssetExtraction,
setEffectiveCharacterAnimationPanel,
hideGeneratedLayerPanelAfterBlur,
isCharacterReferenceMenuOpen,
isCharacterSpecMenuOpen,
isIconSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPickingCharacterReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
isUiDesignSpecMenuOpen,
isMusicMenuOpen,
isTaskSidebarOpen,
taskListRefreshKey,
refreshTaskList,
isPublicationMenuOpen,
isPublicationReferenceMenuOpen,
isSpecMenuOpen,
isPickingPublicationReferenceFromCanvas,
generationWarning,
generationWarningVersion,
openBackgroundMusicGenerationDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openCropExpandPanel,
openEditDialog,
extractUiDesignAssets,
openGenerateDialog,
openIconGenerationDialog,
openPublicationGenerationDialog,
openRedrawPanel,
openUiDesignGenerationDialog,
openQuickEditPanel,
openSpecDialog,
startCropExpandFrameResize,
openSoundEffectGenerationDialog,
openVideoGenerationDialog,
pickCharacterReferenceFromLayer,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
pickUiDesignSpecFromLayer,
quickEditPanel,
quickEditSourceLayer,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
updateCharacterAnimationDuration,
updateIconDescriptionsText,
updateSpecFormValue,
rememberImageModel,
showGenerationWarning,
],
);
}