Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
T
lhk229 2fa2f245c1
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 8m36s
完美像素请求账本移出项目布局
账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在
用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局
保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。

改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留
perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是
同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证
——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变
失败语义的 preferLatestGenerationDialogs。

由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历
内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清
账本。

本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、
配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户
删掉重来即可。跨设备不再自动收口是已知且接受的代价。

布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性
判死;写入侧不再产生新的内联账本。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 05:55:06 +00:00

3964 lines
129 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 EditorPixelArtSnapInput,
type EditorPixelArtSnapResult,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
splitEditorIconSpritesheet,
} from '../../services/image-editor/editorProjectClient';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import {
findCanvasGenerationDialogRecords,
isUnresolvedCanvasGenerationDialogRecord,
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
CharacterReferenceImage,
CropExpandPanelState,
CropExpandResizeHandle,
GenerateDialogState,
ImageContextMenuState,
PerfectPixelOperationSnapshot,
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 {
forgetPerfectPixelOperation,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry';
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,
};
}
// 中文注释:源图可能需要 ticket → PUT → confirm90 秒预算和同一 AbortSignal 覆盖完整
// 上传;confirm 成功后已经有稳定 objectKeyoperation 请求随即形成。
//
// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一
// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。
//
// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待
// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。
export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000;
const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000;
const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000];
export type PerfectPixelProjectVerdict =
| {
kind: 'applied';
project: EditorProjectSnapshot;
resource: EditorProjectResourceSnapshot;
}
| {
kind: 'dialog-missing';
project: EditorProjectSnapshot;
resource: EditorProjectResourceSnapshot;
}
| { kind: 'pending'; project: EditorProjectSnapshot | null }
| {
kind: 'conflict';
project: EditorProjectSnapshot;
message: string;
};
export function inspectPerfectPixelProjectSnapshot(
project: EditorProjectSnapshot,
operation: Pick<PerfectPixelOperationSnapshot, 'operationId' | 'taskId'>,
): PerfectPixelProjectVerdict {
const matchingResources = project.resources.filter(
(resource) => resource.taskId?.trim() === operation.taskId,
);
const matchingDialogs = findCanvasGenerationDialogRecords(
project,
operation.operationId,
);
if (matchingResources.length > 1) {
return {
kind: 'conflict',
project,
message: '权威项目中存在重复的完美像素任务资源,无法自动确认结果。',
};
}
if (matchingDialogs.length > 1) {
return {
kind: 'conflict',
project,
message: '权威项目中存在重复的完美像素占位,无法自动确认结果。',
};
}
const dialog = matchingDialogs[0] ?? null;
const resource = matchingResources[0] ?? null;
if (!resource) {
if (!dialog || isUnresolvedCanvasGenerationDialogRecord(dialog)) {
return { kind: 'pending', project };
}
return {
kind: 'conflict',
project,
message: '完美像素占位已收口,但权威项目缺少对应任务资源。',
};
}
if (!dialog) {
return { kind: 'dialog-missing', project, resource };
}
if (isUnresolvedCanvasGenerationDialogRecord(dialog)) {
return { kind: 'pending', project };
}
const generatedLayerId =
typeof dialog.generatedLayerId === 'string'
? dialog.generatedLayerId.trim()
: '';
const matchingLayers = project.layers.filter(
(layer) => layer.layerId === generatedLayerId,
);
if (
!generatedLayerId ||
matchingLayers.length !== 1 ||
matchingLayers[0]?.resourceId !== resource.resourceId
) {
return {
kind: 'conflict',
project,
message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。',
};
}
return { kind: 'applied', project, resource };
}
function createPerfectPixelReconciliationOperation(
operation: PerfectPixelOperationSnapshot,
startedAt = Date.now(),
): PerfectPixelOperationSnapshot {
return {
...operation,
submittedAt: startedAt,
reconcileUntil: startedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
};
}
function waitForPerfectPixelReconciliationDelay(
delayMs: number,
signal?: AbortSignal,
) {
if (delayMs <= 0) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const finish = () => {
signal?.removeEventListener('abort', abort);
resolve();
};
const timer = setTimeout(finish, delayMs);
const abort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
reject(signal?.reason ?? new DOMException('操作已取消', 'AbortError'));
};
if (signal?.aborted) {
abort();
return;
}
signal?.addEventListener('abort', abort, { once: true });
});
}
async function reconcilePerfectPixelProject(
projectId: string,
operation: PerfectPixelOperationSnapshot,
options: { signal?: AbortSignal } = {},
): Promise<PerfectPixelProjectVerdict> {
let attempt = 0;
let hasAttemptedRead = false;
let latestProject: EditorProjectSnapshot | null = null;
while (!options.signal?.aborted) {
const remainingMs = operation.reconcileUntil - Date.now();
if (remainingMs <= 0 && hasAttemptedRead) {
return { kind: 'pending', project: latestProject };
}
const delayMs =
PERFECT_PIXEL_RECONCILIATION_DELAYS_MS[
Math.min(attempt, PERFECT_PIXEL_RECONCILIATION_DELAYS_MS.length - 1)
] ?? 5_000;
if (delayMs > 0) {
await waitForPerfectPixelReconciliationDelay(
Math.min(delayMs, remainingMs),
options.signal,
);
}
const remainingAfterDelayMs = operation.reconcileUntil - Date.now();
if (remainingAfterDelayMs <= 0 && hasAttemptedRead) {
return { kind: 'pending', project: latestProject };
}
try {
hasAttemptedRead = true;
const readStartedAt = Date.now();
const remainingAtReadMs = operation.reconcileUntil - readStartedAt;
const readDeadlineAt =
remainingAtReadMs > 0
? Math.min(
operation.reconcileUntil,
readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
)
: readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS;
latestProject = await loadEditorProject(projectId, {
signal: options.signal,
deadlineAt: readDeadlineAt,
});
const verdict = inspectPerfectPixelProjectSnapshot(
latestProject,
operation,
);
if (verdict.kind !== 'pending') {
return verdict;
}
} catch (error) {
if (options.signal?.aborted) {
throw error;
}
}
attempt += 1;
}
throw options.signal?.reason ?? new DOMException('操作已取消', 'AbortError');
}
function resolveConfirmedPerfectPixelAsset(
result: EditorPixelArtSnapResult | null,
operation: PerfectPixelOperationSnapshot,
resource: EditorProjectResourceSnapshot,
): EditorAssetSnapshot | null {
if (
!result ||
result.taskId !== operation.taskId ||
result.resource.taskId?.trim() !== operation.taskId ||
result.resource.resourceId !== resource.resourceId ||
result.resource.projectId !== resource.projectId ||
result.asset.taskId?.trim() !== operation.taskId ||
(resource.assetId && result.asset.assetId !== resource.assetId)
) {
return null;
}
return result.asset;
}
async function withPerfectPixelSourcePreparationBudget<T>(
work: Promise<T>,
deadlineAt: number,
timeoutMessage: string,
// 中文注释:到期时必须真的 abort,而不只是停止 await。这一段会往 OSS 写实体,
// 被放弃的上传若继续跑完并 confirm,就留下一份不可见的孤儿对象,而用户重试还会再写一份。
abortOnTimeout?: AbortController,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
work,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => {
abortOnTimeout?.abort(new Error(timeoutMessage));
reject(new Error(timeoutMessage));
},
Math.max(0, deadlineAt - Date.now()),
);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
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 createPerfectPixelOperationId() {
const randomUuid = globalThis.crypto?.randomUUID?.();
if (randomUuid) {
return `perfect-pixel-${randomUuid}`;
}
return `perfect-pixel-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2)}`;
}
function perfectPixelAuthorityKey(
ownerUserId: string | null | undefined,
projectId: string | null | undefined,
) {
const normalizedProjectId = projectId?.trim();
return normalizedProjectId
? `${ownerUserId?.trim() ?? ''}:${normalizedProjectId}`
: null;
}
function perfectPixelRecoveryKey(
ownerUserId: string | null | undefined,
projectId: string,
operation: Pick<
PerfectPixelOperationSnapshot,
'operationId' | 'reconcileUntil'
>,
) {
return `${ownerUserId?.trim() ?? ''}:${projectId}:${operation.operationId}:${operation.reconcileUntil}`;
}
function isSameUnsettledPerfectPixelOperation(
dialog: CanvasGenerationDialogState,
operationId: string,
) {
return (
dialog.perfectPixelOperation?.operationId === operationId &&
(dialog.status === 'generating' || dialog.status === 'pending-confirmation')
);
}
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'> & { id?: string },
) => string;
activateCanvasGenerationDialog: (
targetDialog: CanvasGenerationDialogState,
) => void;
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;
applyProjectSnapshotWithoutHistory?: (
project: EditorProjectSnapshot,
) => boolean | void;
flushProjectPersistence?: (options?: {
preferLatestGenerationDialogs?: boolean;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
};
export function useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
setViewport,
setLayers,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
applyProjectSnapshotWithoutHistory,
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>());
// 中文注释:本会话仍在执行的 inline 占位 id。到期清理只该针对**别人**留下的孤儿,
// 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是
// requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到
// 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。
//
// ownership 内部保留同步 Set,并通过 version 把 claim / release 通知给 React。调用方不得
// 直接改 Set,否则 finally 释放后到期 effect 不会重新判定。
const activeInlineGenerationDialogOwnership =
useInlineGenerationPlaceholderOwnership();
const {
claim: claimActiveInlineGenerationDialog,
release: releaseActiveInlineGenerationDialog,
has: hasActiveInlineGenerationDialog,
} = activeInlineGenerationDialogOwnership;
const perfectPixelRecoveryControllersRef = useRef(
new Map<string, AbortController>(),
);
const observedPerfectPixelRecoveryKeysRef = useRef(new Set<string>());
const perfectPixelRecoveryAuthorityRef = useRef<string | null>(null);
const perfectPixelLiveAuthorityRef = useRef(
perfectPixelAuthorityKey(currentUserId, projectId),
);
const generationWorkflowMountedRef = useRef(true);
perfectPixelLiveAuthorityRef.current = perfectPixelAuthorityKey(
currentUserId,
projectId,
);
const isPerfectPixelAuthorityCurrent = useCallback(
(authority: string | null) =>
generationWorkflowMountedRef.current &&
authority !== null &&
perfectPixelLiveAuthorityRef.current === authority,
[],
);
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
() => new Set(),
);
const pendingPerfectPixelLayerIds = useMemo(() => {
const layerIds = new Set<string>();
for (const dialog of canvasGenerationDialogs) {
if (
dialog.status !== 'pending-confirmation' ||
!dialog.perfectPixelOperation
) {
continue;
}
if (dialog.sourceLayerId) {
layerIds.add(dialog.sourceLayerId);
}
const sourceResourceId =
dialog.perfectPixelOperation.request.sourceResourceId?.trim();
if (sourceResourceId) {
for (const layer of layers) {
if (layer.resourceId === sourceResourceId) {
layerIds.add(layer.id);
}
}
}
}
return layerIds;
}, [canvasGenerationDialogs, layers]);
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'> & {
id?: string;
},
) => {
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 refreshPerfectPixelAssetLibrary = useCallback(() => {
try {
void Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined);
} catch {
// 中文注释:素材列表只是终态后的 best-effort 投影刷新,不能反向改变项目事实 verdict。
}
}, [refreshAssetLibrary]);
const settleLivePerfectPixelVerdict = useCallback(
(
dialogId: string,
operation: PerfectPixelOperationSnapshot,
verdict: PerfectPixelProjectVerdict,
result: EditorPixelArtSnapResult | null,
) => {
if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') {
return false;
}
// 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的
// 对账 GET,并占住保留期名额。
forgetPerfectPixelOperation(
currentUserId,
operation.request.projectId,
operation.operationId,
);
const confirmedAsset = resolveConfirmedPerfectPixelAsset(
result,
operation,
verdict.resource,
);
if (confirmedAsset) {
upsertGeneratedAsset?.(confirmedAsset);
}
if (verdict.kind === 'dialog-missing') {
if (hasCanvasGenerationDialogById(dialogId)) {
updateCanvasGenerationDialogById(dialogId, () => null);
}
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return true;
}
if (!hasCanvasGenerationDialogById(dialogId)) {
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return true;
}
applyProjectSnapshot?.(verdict.project, {
type: 'perfect-pixel',
count: 1,
});
refreshPerfectPixelAssetLibrary();
setActiveTool('select');
setActiveSidebarPanel('layers');
return true;
},
[
applyProjectSnapshot,
currentUserId,
hasCanvasGenerationDialogById,
refreshPerfectPixelAssetLibrary,
setActiveSidebarPanel,
setActiveTool,
showGenerationWarning,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
],
);
const snapSelectedLayerToPerfectPixels = useCallback(
async (sourceLayer: CanvasLayer) => {
const normalizedProjectId = projectId?.trim();
const isStaticRasterLayer =
(sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image') &&
sourceLayer.assetKind !== 'character-animation';
const existingOperation = canvasGenerationDialogs.find(
(dialog) =>
dialog.perfectPixelOperation &&
(dialog.sourceLayerId === sourceLayer.id ||
(Boolean(dialog.perfectPixelOperation.request.sourceResourceId) &&
dialog.perfectPixelOperation.request.sourceResourceId ===
sourceLayer.resourceId)) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation' ||
dialog.status === 'failed'),
);
if (
!isStaticRasterLayer ||
!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence ||
perfectPixelLayerIdsRef.current.has(sourceLayer.id) ||
Boolean(existingOperation)
) {
if (existingOperation) {
activateCanvasGenerationDialog(existingOperation);
showGenerationWarning(
'该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。',
);
return;
}
if (
isStaticRasterLayer &&
(!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence)
) {
showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
}
return;
}
const operationAuthority = perfectPixelAuthorityKey(
currentUserId,
normalizedProjectId,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
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 perfectPixelOperation: PerfectPixelOperationSnapshot | 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,
},
}),
id: createPerfectPixelOperationId(),
composerOpen: false,
// 中文注释:源引用尚未解析、operation 快照也尚未形成时先沿用 legacy 会话标记;
// 这一阶段退出不会发 POST,孤儿可按 TTL 清理。稳定 request 写入后会立即移除该
// 标记,durable operation 改由刷新后的 GET-only 恢复收口,绝不能再被 TTL 删除。
requiresLiveSession: true,
});
perfectPixelDialogId = placement.dialogId;
// 中文注释:紧挨着创建注册,中间不能有 await——否则会留出一个「占位已存在但尚未
// 登记归属」的窗口,到期清理正好可以在那里把它删掉。
claimActiveInlineGenerationDialog(perfectPixelDialogId);
if (!placement.placeholder) {
throw new Error('无法创建完美像素处理占位');
}
const sourcePreparationDeadlineAt =
Date.now() + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS;
const sourcePreparationAbort = new AbortController();
const sourceImageSrc = await withPerfectPixelSourcePreparationBudget(
resolveEditorGenerationMediaReference(
sourceLayer,
'image',
normalizedProjectId,
{
signal: sourcePreparationAbort.signal,
uploadId: perfectPixelDialogId,
},
),
sourcePreparationDeadlineAt,
'完美像素源图准备超时。',
sourcePreparationAbort,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
const sourceResourceId = sourceLayer.resourceId.trim();
const request: EditorPixelArtSnapInput = {
sourceImageSrc,
projectId: normalizedProjectId,
...(sourceResourceId &&
!sourceResourceId.startsWith('local-resource-') &&
!sourceResourceId.startsWith('generation-dialog:')
? { sourceResourceId }
: {}),
...(sourceLayer.assetKind
? { assetKind: sourceLayer.assetKind }
: {}),
...(sourceLayer.generationInputs
? {
generationInputs: {
fields: sourceLayer.generationInputs.fields.map((field) => ({
...field,
})),
references: sourceLayer.generationInputs.references.map(
(reference) => ({ ...reference }),
),
},
}
: {}),
...(assetFolderId ? { assetFolderId } : {}),
assetLabel,
canvasCompletion: {
dialogId: perfectPixelDialogId,
title: assetLabel,
placeholder: { ...placement.placeholder },
},
};
const operationSubmittedAt = Date.now();
perfectPixelOperation = {
version: 1,
kind: 'perfect-pixel',
operationId: perfectPixelDialogId,
taskId: `pixel-art-snap-${perfectPixelDialogId}`,
request,
submittedAt: operationSubmittedAt,
reconcileUntil:
operationSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
};
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
perfectPixelOperation,
),
);
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
requiresLiveSession: undefined,
perfectPixelOperationId: perfectPixelDialogId,
perfectPixelOperation,
}));
// 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端
// 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样
// 把布局校验失败升级成完美像素的硬阻断。
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelOperation,
);
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
perfectPixelPostAttempted = true;
const result = await snapImageToPerfectPixels(
perfectPixelOperation.request,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:同步 POST 的响应体不是提交事实。即使它带 project / asset,也可能是网关
// 断连前后的时点快照;只让项目 GET 决定终态。对账继续使用 POST 前已持久化的绝对
// deadline,响应返回不能替同一次 operation 续期。
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
perfectPixelOperation,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
if (
settleLivePerfectPixelVerdict(
perfectPixelDialogId,
perfectPixelOperation,
verdict,
result,
)
) {
return;
}
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
status: 'pending-confirmation',
errorMessage:
verdict.kind === 'conflict'
? verdict.message
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
}));
} catch (error) {
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析
// 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向
// 谎报。
//
// 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。OSS PUT 与
// asset object / project resource / editor asset / canvas completion 的单笔数据库
// 事务仍是跨系统边界;procedure future 被本地 timeout/drop 也不能撤销远端事务。
// 因而带响应的 4xx 可能发生在 OSS 已写但数据库整笔失败之后,而网关异常也可能发生在
// 数据库已经整笔提交之后。服务端从第一次 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;
let keepPendingConfirmation = false;
if (
outcomeMayBePersisted &&
perfectPixelDialogId &&
perfectPixelOperation
) {
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
perfectPixelOperation,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
if (
settleLivePerfectPixelVerdict(
perfectPixelDialogId,
perfectPixelOperation,
verdict,
null,
)
) {
return;
}
keepPendingConfirmation = true;
reconciledMessage =
verdict.kind === 'conflict'
? verdict.message
: '权威项目尚未出现可确认的完美像素结果。';
}
const serverMessage =
error instanceof Error && error.message.trim()
? error.message
: undefined;
// 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。
// 结果未知时必须保留 operation 快照并停在 pending-confirmation;这既不会把在途结果
// 谎报成失败,也不会开放一条会创建新 identity 的普通重提路径。
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。`
: perfectPixelOperation && !perfectPixelPostAttempted
? `完美像素请求尚未发出:${serverMessage ?? '提交前准备失败'} 请在原占位重试,或删除占位后重来。`
: (serverMessage ?? '完美像素处理失败');
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)
) {
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
status: keepPendingConfirmation ? 'pending-confirmation' : 'failed',
errorMessage,
}));
} else {
// 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自
// 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。
showGenerationWarning(errorMessage);
if (perfectPixelDialogId) {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelDialogId,
);
}
}
} finally {
if (perfectPixelDialogId) {
releaseActiveInlineGenerationDialog(perfectPixelDialogId);
}
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,
activateCanvasGenerationDialog,
canvasGenerationDialogs,
closeGenerationTransientState,
currentUserId,
flushProjectPersistence,
hasCanvasGenerationDialogById,
isPerfectPixelAuthorityCurrent,
openPlacedCanvasGenerationDialog,
projectId,
claimActiveInlineGenerationDialog,
releaseActiveInlineGenerationDialog,
setCharacterAnimationPanel,
setCropExpandPanel,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
showGenerationWarning,
settleLivePerfectPixelVerdict,
updateCanvasGenerationDialogById,
],
);
const retryPerfectPixelOperation = useCallback(
async (dialogId: string) => {
const normalizedDialogId = dialogId.trim();
const normalizedProjectId = projectId?.trim();
const dialog = canvasGenerationDialogs.find(
(candidate) => candidate.id === normalizedDialogId,
);
const operation = dialog?.perfectPixelOperation;
if (
!normalizedDialogId ||
!normalizedProjectId ||
!dialog ||
!operation ||
operation.operationId !== normalizedDialogId ||
operation.request.projectId !== normalizedProjectId ||
operation.request.canvasCompletion.dialogId !== normalizedDialogId ||
operation.taskId !== `pixel-art-snap-${normalizedDialogId}` ||
(dialog.status !== 'pending-confirmation' &&
dialog.status !== 'failed') ||
!flushProjectPersistence ||
!applyProjectSnapshot
) {
showGenerationWarning(
'完美像素操作快照无效或当前状态不可重试,未发送请求。',
);
return;
}
const operationAuthority = perfectPixelAuthorityKey(
currentUserId,
normalizedProjectId,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
const lockKey = dialog.sourceLayerId ?? normalizedDialogId;
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
return;
}
const retriedOperation =
createPerfectPixelReconciliationOperation(operation);
let postAttempted = false;
perfectPixelLayerIdsRef.current.add(lockKey);
claimActiveInlineGenerationDialog(normalizedDialogId);
try {
if (dialog.sourceLayerId) {
setPerfectPixelLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(dialog.sourceLayerId!);
return nextLayerIds;
});
}
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
retriedOperation,
),
);
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: retriedOperation,
}));
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
postAttempted = true;
const result = await snapImageToPerfectPixels(retriedOperation.request);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
retriedOperation,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
if (
settleLivePerfectPixelVerdict(
normalizedDialogId,
retriedOperation,
verdict,
result,
)
) {
return;
}
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'pending-confirmation',
errorMessage:
verdict.kind === 'conflict'
? verdict.message
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
}));
} catch (error) {
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
const persistenceMayHaveStarted =
error instanceof ApiClientError &&
(error.details as { resultPersistenceStarted?: unknown } | null)
?.resultPersistenceStarted === true;
const outcomeMayBePersisted =
postAttempted &&
(!(error instanceof ApiClientError) ||
persistenceMayHaveStarted ||
isGatewayUnknownOutcomeError(error));
if (outcomeMayBePersisted) {
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
retriedOperation,
);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
if (
settleLivePerfectPixelVerdict(
normalizedDialogId,
retriedOperation,
verdict,
null,
)
) {
return;
}
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'pending-confirmation',
errorMessage:
verdict.kind === 'conflict'
? verdict.message
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
}));
return;
}
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: '完美像素处理失败';
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'failed',
errorMessage: postAttempted
? errorMessage
: `完美像素请求尚未发出:${errorMessage} 请在原占位重试同一操作。`,
}));
} finally {
releaseActiveInlineGenerationDialog(normalizedDialogId);
perfectPixelLayerIdsRef.current.delete(lockKey);
if (dialog.sourceLayerId) {
setPerfectPixelLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(dialog.sourceLayerId!)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(dialog.sourceLayerId!);
return nextLayerIds;
});
}
}
},
[
applyProjectSnapshot,
canvasGenerationDialogs,
claimActiveInlineGenerationDialog,
currentUserId,
flushProjectPersistence,
isPerfectPixelAuthorityCurrent,
projectId,
releaseActiveInlineGenerationDialog,
showGenerationWarning,
settleLivePerfectPixelVerdict,
updateCanvasGenerationDialogById,
],
);
useEffect(() => {
const normalizedProjectId = projectId?.trim();
const recoveryAuthority = perfectPixelAuthorityKey(
currentUserId,
normalizedProjectId,
);
if (perfectPixelRecoveryAuthorityRef.current !== recoveryAuthority) {
for (const controller of perfectPixelRecoveryControllersRef.current.values()) {
controller.abort();
}
perfectPixelRecoveryControllersRef.current.clear();
observedPerfectPixelRecoveryKeysRef.current.clear();
perfectPixelRecoveryAuthorityRef.current = recoveryAuthority;
}
if (!normalizedProjectId || !applyProjectSnapshotWithoutHistory) {
for (const controller of perfectPixelRecoveryControllersRef.current.values()) {
controller.abort();
}
perfectPixelRecoveryControllersRef.current.clear();
return;
}
const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) =>
dialog.perfectPixelOperation &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
: [],
);
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
const recoverableDialogIds = new Set(
canvasGenerationDialogs.map((dialog) => dialog.id),
);
const nowMs = Date.now();
for (const [operationId, operation] of readPerfectPixelOperations(
currentUserId,
normalizedProjectId,
)) {
if (
recoverableDialogIds.has(operationId) ||
operation.reconcileUntil <= nowMs
) {
continue;
}
recoverableOperations.push({ dialogId: operationId, operation });
}
const recoveryKeys = new Set(
recoverableOperations.map(({ operation }) =>
perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation),
),
);
for (const [
key,
controller,
] of perfectPixelRecoveryControllersRef.current) {
if (!recoveryKeys.has(key)) {
controller.abort();
perfectPixelRecoveryControllersRef.current.delete(key);
}
}
for (const { dialogId, operation } of recoverableOperations) {
const recoveryKey = perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
operation,
);
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
if (hasActiveInlineGenerationDialog(dialogId)) {
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
continue;
}
if (
observedPerfectPixelRecoveryKeysRef.current.has(recoveryKey) ||
perfectPixelRecoveryControllersRef.current.has(recoveryKey)
) {
continue;
}
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
const controller = new AbortController();
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
void (async () => {
if (operation.request.projectId !== normalizedProjectId) {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
updateCanvasGenerationDialogById(dialogId, (current) => {
if (
!isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
)
) {
return current;
}
// 中文注释:标记快照失效时必须同时丢弃 operation,与 hydrateCanvasGenerationDialog
// 的口径一致。两者并存会造出「重试按钮因 invalid 而消失、快照却还挂着」的矛盾态,
// 用户既重试不了也看不懂占位为什么还在。
const { perfectPixelOperation: _discarded, ...rest } = current;
return {
...rest,
status: 'failed' as const,
perfectPixelOperationInvalid: true,
errorMessage:
'完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。',
};
});
return;
}
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
operation,
{
signal: controller.signal,
},
);
if (
controller.signal.aborted ||
projectId?.trim() !== normalizedProjectId
) {
return;
}
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
}
if (verdict.kind === 'applied') {
if (!hasCanvasGenerationDialogById(dialogId)) {
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
const applied = applyProjectSnapshotWithoutHistory(verdict.project);
if (applied === false) {
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
)
? {
...current,
status: 'pending-confirmation',
errorMessage:
'项目已有更新版本,本次恢复结果未覆盖当前画布,请继续核对。',
}
: current,
);
return;
}
refreshPerfectPixelAssetLibrary();
return;
}
if (verdict.kind === 'dialog-missing') {
if (hasCanvasGenerationDialogById(dialogId)) {
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
)
? null
: current,
);
}
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
? {
...current,
status: 'pending-confirmation',
errorMessage:
verdict.kind === 'conflict'
? verdict.message
: '完美像素结果仍待确认。系统不会在刷新后自动重复提交。',
}
: current,
);
try {
void Promise.resolve(flushProjectPersistence?.()).catch(
() => undefined,
);
} catch {
// 中文注释:恢复只允许 GET;pending 状态的布局回写失败不能触发 POST 补偿。
}
})()
.catch((error: unknown) => {
if (
!controller.signal.aborted &&
hasCanvasGenerationDialogById(dialogId)
) {
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
)
? {
...current,
status: 'pending-confirmation',
errorMessage:
error instanceof Error && error.message.trim()
? `完美像素恢复读取失败:${error.message}`
: '完美像素恢复读取失败,结果仍待确认。',
}
: current,
);
}
})
.finally(() => {
if (
perfectPixelRecoveryControllersRef.current.get(recoveryKey) ===
controller
) {
perfectPixelRecoveryControllersRef.current.delete(recoveryKey);
}
});
}
}, [
applyProjectSnapshotWithoutHistory,
canvasGenerationDialogs,
currentUserId,
flushProjectPersistence,
hasActiveInlineGenerationDialog,
hasCanvasGenerationDialogById,
projectId,
refreshPerfectPixelAssetLibrary,
showGenerationWarning,
updateCanvasGenerationDialogById,
]);
useEffect(() => {
const recoveryControllers = perfectPixelRecoveryControllersRef.current;
generationWorkflowMountedRef.current = true;
return () => {
generationWorkflowMountedRef.current = false;
for (const controller of recoveryControllers.values()) {
controller.abort();
}
recoveryControllers.clear();
};
}, []);
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,
sourceLayerId: sourceLayer.id,
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,
retryPerfectPixelOperation,
activeInlineGenerationDialogOwnership,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
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,
activeInlineGenerationDialogOwnership,
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,
retryPerfectPixelOperation,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
updateCharacterAnimationDuration,
updateIconDescriptionsText,
updateSpecFormValue,
rememberImageModel,
showGenerationWarning,
],
);
}