Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
T
lhk229 4515bbcff6 对账窗口改在 pre-POST flush 之后锚定
查服务端确认了一件推翻先前定性的事实:validate_editor_pixel_art_snap_
placeholder_exists 在处理前检查占位是否已经持久化,既没有同 ID 的已持久化
dialog、也没有同 operation 的稳定 resource 时直接返回 409。所以 POST 前那次
await flush 不是可省的画布同步,而是服务端硬前置,不能取消。

据此更正上一条决策里的错误声明:不是「布局保存失败只降级成 asset-only」,
而是「占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,
客户端因此无法证明该前置已满足,只能提高满足它的概率」。被解除的是客户端侧
「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。

缺陷:首次提交与人工 exact retry 都在这次 flush 之前就算好 submittedAt /
reconcileUntil。该 flush 没有整体上限,慢保存足以在 POST 发出前烧光整个
75 秒窗口,请求带着已过期的 deadline 发出,对账退化成读一次就收尾。

修复:flush 返回、authority 复核通过之后才用
createPerfectPixelReconciliationOperation 重新锚定,按同一 operationId
覆盖账本与 dialog,登记新的 recovery key,随后立即 POST。只覆盖时间字段,
request 与 dialog / operation / task identity 逐字节不变,不产生第二条账本。

保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,
服务端已有带 marker 的占位而 localStorage 跨标签共享,本机若没有账本那条占位
会被 hydrate 成 failed + invalid。provisional 账本正好堵住这个窗口。

同步修正专题文档中已被近几个提交推翻的条款:strict layout save 预算与
revision ACK 前 POST 为零、未收口 operation 不可删除、durable operation
不得被普通删除路径清理。

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

4064 lines
135 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');
}
/**
* 中文注释:孤儿账本条目的收口读取——**一次确定性的读,不轮询**。
*
* `reconcileUntil` 那 75 秒窗口的意义是「结果可能还在飞,值得多读几次」。孤儿账本是上一个
* 会话留下的,发出它的标签页早已不在,服务端要么写完了要么没写;此时需要的是一次能回答
* 「到底落没落」的读,而不是轮询。窗口是否已过期与要不要读无关——恰恰相反,用户重新打开
* 项目几乎必然晚于 75 秒,按窗口短路会让孤儿对账在它唯一的目标场景下完全失效。
*/
async function readPerfectPixelOrphanVerdict(
projectId: string,
operation: PerfectPixelOperationSnapshot,
options: { signal?: AbortSignal } = {},
): Promise<PerfectPixelProjectVerdict> {
const project = await loadEditorProject(projectId, {
signal: options.signal,
deadlineAt: Date.now() + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
});
return inspectPerfectPixelProjectSnapshot(project, operation);
}
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,
);
// 中文注释:这次 flush 不是可省的画布同步,而是服务端的硬前置——POST 到达时若占位
// 从未持久化,`validate_editor_pixel_art_snap_placeholder_exists` 直接 409。改成
// best-effort 之后客户端不再拿到成功 ACK,因此**无法证明**该前置已满足,只能提高
// 满足它的概率:占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失。
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:对账窗口必须锚在 POST 发出的时刻。上面这次 flush 没有整体上限(单次
// PATCH 60 秒 × 最多 4 次尝试),慢保存足以把 75 秒窗口在 POST 之前烧光,让请求
// 带着已过期的 deadline 发出。这里重新锚定——只覆盖时间字段,request、dialog /
// operation / task identity 与预写的那条完全一致,账本按同一 operationId 覆盖,
// 不会产生第二条。
//
// 预写仍然保留在 flush 之前:flush 期间另一标签页可能加载同一项目,此时服务端已有
// 带 marker 的占位,本机若没有账本就会被 hydrate 成 `failed + invalid`。预写的
// provisional 账本正好堵住这个窗口。
const submittedOperation = createPerfectPixelReconciliationOperation(
perfectPixelOperation,
);
perfectPixelOperation = submittedOperation;
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
submittedOperation,
),
);
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
submittedOperation,
);
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
perfectPixelOperation: submittedOperation,
}));
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;
}
// 中文注释:flush 之前继续沿用旧 operation——窗口只能锚在 POST 发出的时刻,而这次
// flush 是服务端硬前置且没有整体上限,先刷新窗口再等它等于把窗口烧在等待上。
// request 与 dialog / operation / task identity 前后完全一致,只有时间字段会在
// flush 之后被重新锚定。
let retriedOperation = 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;
});
}
// 中文注释:UI 先切到处理中,让用户立刻看到重试已经开始;此时挂的仍是旧
// operation,账本里已有的那条与它一致,不需要重写。
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: operation,
}));
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:flush 之后才重新锚定对账窗口,并按同一 operationId 覆盖账本与 dialog。
retriedOperation = createPerfectPixelReconciliationOperation(operation);
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
retriedOperation,
),
);
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
perfectPixelOperation: retriedOperation,
}));
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,
isOrphan: false,
},
]
: [],
);
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
//
// 只要本地还存在同 id 的 dialog 就不走孤儿路径,无论它是什么状态:`generating` /
// `pending-confirmation` 由上面的 dialog 路径负责;`failed` 说明用户可以在原占位手动
// 重试,账本仍归那个占位所有,此时抢先当孤儿读掉并清账本会连带废掉重试能力。
const localDialogIds = new Set(
canvasGenerationDialogs.map((dialog) => dialog.id),
);
for (const [operationId, operation] of readPerfectPixelOperations(
currentUserId,
normalizedProjectId,
)) {
if (localDialogIds.has(operationId)) {
continue;
}
// 中文注释:这里**不能**按 `reconcileUntil` 短路。孤儿的目标场景就是「关掉浏览器、
// 稍后重开」,重开必然晚于 75 秒对账窗口;按窗口跳过等于让这条兜底分支永不生效,
// 而条目还会在本机躺满整个保留期反复被跳过。
recoverableOperations.push({
dialogId: operationId,
operation,
isOrphan: true,
});
}
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, isOrphan } 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 = isOrphan
? await readPerfectPixelOrphanVerdict(
normalizedProjectId,
operation,
{
signal: controller.signal,
},
)
: await reconcilePerfectPixelProject(normalizedProjectId, operation, {
signal: controller.signal,
});
if (
controller.signal.aborted ||
projectId?.trim() !== normalizedProjectId
) {
return;
}
if (isOrphan) {
// 中文注释:孤儿读到任何结论就收口清账本,避免它在保留期内每次加载都被重读。
// 读失败(走下面的 catch)不清——那是「不知道」,不是「知道没有」,留给下次加载。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
if (verdict.kind === 'dialog-missing') {
// 中文注释:服务端当时没有占位可回填,结果只进了素材库——这正是「布局尽力保存
// 失败」的典型结局,用户必须被告知。
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
} else if (verdict.kind === 'applied') {
// 中文注释:服务端已经把结果回填进画布,而本次读到的正是当前项目的权威状态,
// 所以结果本就在用户眼前。静默刷新素材库即可,弹提示只是噪音。
refreshPerfectPixelAssetLibrary();
}
// 中文注释:pending / conflict 意味着什么都没落库,用户无需知道任何事。
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,
],
);
}