Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
T
lhk229 8aea7364b6
Project CI / Repository checks (pull_request) Successful in 1m12s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 13m24s
回退完美像素原尺寸恢复
直接持久化逻辑分辨率 PNG 并使用最终实际尺寸
升级手动完美像素算法版本并拦截跨版本重放孤儿对象
补充非整除网格和前端权威对账回归测试
同步完美像素契约、决策记录与排障说明
2026-08-10 04:52:18 +00:00

4681 lines
158 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 {
CanvasGenerationAction,
CanvasGenerationDialogState,
CanvasGenerationInputReference,
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,
createSceneGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
createVideoRedrawGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
isCharacterSpecReferenceLayer,
isIconSpecReferenceLayer,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
appendLimitedImageReferences,
buildDeterministicGenerationInputs,
calculateCharacterAnimationPrice,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageGenerationPixelSize,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} 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 type { CanvasGenerationDialogDraft } from './useCanvasGenerationDialogs';
import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry';
type CanvasSize = { width: number; height: number };
function getCanvasGenerationRedrawWarning({
hasParameterFallbacks,
unavailableReferenceCount,
isNormalizedGenerationInputs,
}: {
hasParameterFallbacks: boolean;
unavailableReferenceCount: number;
isNormalizedGenerationInputs: boolean;
}) {
const hasUnavailableReferences = unavailableReferenceCount > 0;
if (hasParameterFallbacks && hasUnavailableReferences) {
return `${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`;
}
if (hasParameterFallbacks) {
return CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING;
}
if (!isNormalizedGenerationInputs) {
return '已按旧版数据恢复,部分参数可能使用当前默认值。';
}
if (hasUnavailableReferences) {
return '部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。';
}
return null;
}
const NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION: Partial<
Record<CanvasGenerationAction, string>
> = {
'character-animation.generate':
'原角色图层不在当前画布,当前角色动作面板无法重新选择来源角色,无法改造。',
'ui-design.extract-assets':
'原 UI 设计图不在当前画布,当前素材提取面板无法重新选择来源设计图,无法改造。',
};
function getMissingNonReplaceableSourceWarning({
action,
references,
unavailableReferences,
}: {
action: CanvasGenerationAction;
references: CanvasGenerationInputReference[];
unavailableReferences: CanvasGenerationInputReference[];
}) {
const warning = NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION[action];
if (!warning) {
return null;
}
const unavailableSourceKeys = new Set(
unavailableReferences
.filter((reference) => reference.id === 'source')
.map((reference) => `${reference.refType}:${reference.refId}`),
);
const hasAvailableSource = references
.filter((reference) => reference.id === 'source')
.some(
(reference) =>
!unavailableSourceKeys.has(`${reference.refType}:${reference.refId}`),
);
return hasAvailableSource ? null : warning;
}
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,
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: layer.imageSequenceDurationMs,
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,
};
}
// 中文注释:源图可能需要 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 === 'scene') {
return mode === 'scene';
}
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
);
}
const CANVAS_TOOL_BY_GENERATION_MODE = {
generate: 'generate',
spec: 'spec',
character: 'character',
icon: 'icon',
publication: 'publication',
'ui-design': 'ui-design',
'quick-edit': 'generate',
'character-animation': 'character',
video: 'video',
'audio-sound-effect': 'music',
'audio-background-music': 'music',
scene: 'scene',
} as const satisfies Record<CanvasGenerationDialogState['mode'], CanvasTool>;
function getCanvasToolForGenerationMode(
mode: CanvasGenerationDialogState['mode'],
): CanvasTool {
return CANVAS_TOOL_BY_GENERATION_MODE[mode] ?? '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' | 'scene';
} {
return (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
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>>;
hasPendingImageReferenceUploads?: boolean;
openCanvasGenerationDialog: (dialog: CanvasGenerationDialogDraft) => string;
activateCanvasGenerationDialog: (
targetDialog: CanvasGenerationDialogState,
) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
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,
hasPendingImageReferenceUploads = false,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
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 backgroundMusicPromptAssist =
useImageCanvasBackgroundMusicPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
const soundEffectPromptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
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, setQuickEditPanelState] =
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, setUiAssetExtractionStateValue] =
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);
const referenceUploadContextCounterRef = useRef(0);
const hasPendingImageReferenceUploadsRef = useRef(
hasPendingImageReferenceUploads,
);
hasPendingImageReferenceUploadsRef.current = hasPendingImageReferenceUploads;
const setQuickEditPanel = useCallback<
Dispatch<SetStateAction<QuickEditPanelState | null>>
>(
(updater) => {
setQuickEditPanelState((currentPanel) => {
const nextPanel =
typeof updater === 'function' ? updater(currentPanel) : updater;
if (
hasPendingImageReferenceUploadsRef.current &&
currentPanel?.referenceUploadContextId !==
nextPanel?.referenceUploadContextId
) {
showGenerationWarning(
'参考图正在上传,请等待上传完成后再切换或关闭生成面板',
);
return currentPanel;
}
return nextPanel;
});
},
[showGenerationWarning],
);
const setUiAssetExtractionState = useCallback<
Dispatch<SetStateAction<UiAssetExtractionState | null>>
>(
(updater) => {
setUiAssetExtractionStateValue((currentState) => {
const nextState =
typeof updater === 'function' ? updater(currentState) : updater;
if (
hasPendingImageReferenceUploadsRef.current &&
currentState?.referenceUploadContextId !==
nextState?.referenceUploadContextId
) {
showGenerationWarning(
'参考图正在上传,请等待上传完成后再切换或关闭生成面板',
);
return currentState;
}
return nextState;
});
},
[showGenerationWarning],
);
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, setUiAssetExtractionState]);
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,
setQuickEditPanel,
setUiAssetExtractionState,
],
);
const openGenerateDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
applyRememberedImageGenerationOptions(
createGenerateDialogDraft({ canvasSize, viewport }),
rememberedImageOptions,
),
);
activateCanvasGenerationEntry('generate');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
rememberedImageOptions,
viewport,
]);
const openSceneGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createSceneGenerationDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('scene');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
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,
setQuickEditPanel,
setUiAssetExtractionState,
],
);
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);
referenceUploadContextCounterRef.current += 1;
const quickEditDraft = {
...createQuickEditModeDraft(sourceLayer),
referenceUploadContextId: `quick-edit-${referenceUploadContextCounterRef.current}`,
};
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,
setUiAssetExtractionState,
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);
const decodedGenerationInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
const hasInvalidVersionedGenerationInputs = Boolean(
sourceLayer.generationInputsHydrationState === 'invalid-versioned' ||
sourceLayer.generationInputs &&
(sourceLayer.generationInputs.version !== undefined ||
sourceLayer.generationInputs.action !== undefined) &&
!decodedGenerationInputs.ok,
);
if (hasInvalidVersionedGenerationInputs) {
showGenerationWarning(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
return;
}
const normalizedGenerationInputs = decodedGenerationInputs.ok
? decodedGenerationInputs.inputs
: null;
const isNormalizedGenerationInputs = normalizedGenerationInputs !== null;
const hasParameterFallbacks = decodedGenerationInputs.ok
? decodedGenerationInputs.warnings.length > 0
: false;
const normalizedSourceLayer = normalizedGenerationInputs
? { ...sourceLayer, generationInputs: normalizedGenerationInputs }
: sourceLayer;
const unavailableReferences = isNormalizedGenerationInputs
? getUnavailableNormalizedGenerationInputReferences(
normalizedSourceLayer,
layers,
)
: [];
const missingNonReplaceableSourceWarning = normalizedGenerationInputs
? getMissingNonReplaceableSourceWarning({
action: normalizedGenerationInputs.action,
references: normalizedGenerationInputs.references,
unavailableReferences,
})
: null;
if (missingNonReplaceableSourceWarning) {
showGenerationWarning(missingNonReplaceableSourceWarning);
return;
}
if (
isNormalizedGenerationInputs &&
normalizedGenerationInputs.action === 'ui-design.extract-assets'
) {
const sourceReference = normalizedGenerationInputs.references.find(
(reference) => reference.id === 'source',
);
// 同一 resourceId/sourceAssetId 的画布副本引用同一份媒体数据;UI 素材提取的
// 框选覆盖层落在任一副本均可,恢复不依赖原 placement 的坐标、尺寸或层级,故按
// 当前图层顺序取首个匹配项即可,无需为此持久化 canvas layer identity。
const extractionSource = sourceReference
? layers.find((layer) =>
sourceReference.refType === 'project-resource'
? layer.resourceId === sourceReference.refId
: layer.sourceAssetId === sourceReference.refId,
)
: null;
if (!extractionSource) {
showGenerationWarning(
NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION[
'ui-design.extract-assets'
]!,
);
return;
}
const modelField = normalizedGenerationInputs.fields.find(
(field) => field.id === 'model',
);
const extractionReferences = normalizedGenerationInputs.references
.filter((reference) => reference.id === 'reference')
.flatMap((reference) => {
const layer = layers.find((candidate) =>
reference.refType === 'project-resource'
? candidate.resourceId === reference.refId
: candidate.sourceAssetId === reference.refId,
);
return layer ? [createCanvasLayerReference(layer)] : [];
});
setUiAssetExtractionState({
...createUiAssetExtractionState(extractionSource.id, {
initialTool: 'rect',
model:
typeof modelField?.value === 'string'
? modelField.value
: DEFAULT_IMAGE_MODEL,
}),
references: extractionReferences,
});
setQuickEditPanel(null);
selectSingleLayer(extractionSource.id);
setActiveTool('select');
const redrawWarning = getCanvasGenerationRedrawWarning({
hasParameterFallbacks,
unavailableReferenceCount: unavailableReferences.length,
isNormalizedGenerationInputs,
});
if (redrawWarning) {
showGenerationWarning(redrawWarning);
}
return;
}
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
normalizedSourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
normalizedSourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer: normalizedSourceLayer,
canvasSize,
viewport,
sourceDialog,
mode: 'redraw',
sourceAnimationLayer,
availableLayers: layers,
});
if (sameSourceDraft) {
openPlacedCanvasGenerationDialog(sameSourceDraft);
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
const redrawWarning = getCanvasGenerationRedrawWarning({
hasParameterFallbacks,
unavailableReferenceCount: unavailableReferences.length,
isNormalizedGenerationInputs,
});
if (redrawWarning) {
showGenerationWarning(redrawWarning);
}
return;
}
if (isNormalizedGenerationInputs) {
showGenerationWarning('该生成配方当前无法恢复,无法改造。');
return;
}
if (sourceLayer.mediaType === 'audio') {
const audioDraft = createAudioRedrawGenerationDialogDraft(sourceLayer);
if (!audioDraft) {
return;
}
setQuickEditPanel(null);
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(audioDraft);
setActiveTool('music');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
if (sourceLayer.mediaType === 'video') {
const videoDraft = createVideoRedrawGenerationDialogDraft(sourceLayer);
if (!videoDraft) {
return;
}
setQuickEditPanel(null);
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(videoDraft);
setActiveTool('video');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
if (
sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image' ||
sourceLayer.assetKind === 'character-animation'
) {
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');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
},
[
openCanvasGenerationDialog,
openPlacedCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
setUiAssetExtractionState,
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,
setQuickEditPanel,
setUiAssetExtractionState,
],
);
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: buildDeterministicGenerationInputs(
'image.crop-expand',
cropExpandSourceLayer,
),
},
);
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,
generationInputs: buildDeterministicGenerationInputs(
'image.crop-expand',
cropExpandSourceLayer,
),
};
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: buildDeterministicGenerationInputs(
'image.remove-background',
sourceLayer,
),
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,
setQuickEditPanel,
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 perfectPixelOperationId = createPerfectPixelOperationId();
const placement = openPlacedCanvasGenerationDialog({
...createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: '完美像素',
assetLabel,
status: 'generating',
frame: {
width: sourceLayer.width,
height: sourceLayer.height,
},
}),
id: perfectPixelOperationId,
composerOpen: false,
// 中文注释:marker 必须在占位诞生这一刻就写上,不能等到 operation 形成。源图解析 /
// 直传最长 90 秒,期间占位是 `generating` 且尚无账本;删除确认判据若按账本判,用户
// 在这段窗口里删一个**免费**操作会看到「已消耗的泥点不会返还」。
//
// 与 id 同值:`operationId === dialogId` 是这条链路的既有不变式。万一 id 撞车被
// `openCanvasGenerationDialog` 换掉,marker 会暂时指向旧值——此时它仍然只被用作
// 「这是一次完美像素操作」的存在性标记(不弹确认),随后写 request 时会按真实
// dialogId 纠正,不影响任何判等。
perfectPixelOperationId,
// 中文注释:源引用尚未解析、operation 快照也尚未形成时先沿用 legacy 会话标记;
// 这一阶段退出不会发 POST,会话内到期清理照常生效(`inlineGenerationPlaceholderExpiryAt`
// 看的是 `perfectPixelOperation` 而非 marker)。稳定 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 }
: {}),
generationInputs: buildDeterministicGenerationInputs(
'image.perfect-pixel',
sourceLayer,
),
...(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 operationResultAlreadyExists =
error instanceof ApiClientError &&
(error.details as { operationResultAlreadyExists?: unknown } | null)
?.operationResultAlreadyExists === true;
const outcomeMayBePersisted =
perfectPixelPostAttempted &&
Boolean(perfectPixelDialogId) &&
// 中文注释:拿到 HTTP 响应不等于服务端明确表态过。Pingora 网关在上游超时或断连时
// 会自己合成 502 / 504,那种响应只有 code / message、没有 details,因此既不是
// transport 异常也拿不到 resultPersistenceStarted——按原判据会被当成确定失败,
// 而此时 api-server 可能已经走完 OSS PUT 与落库。必须算作未知结果去对账。
(!(error instanceof ApiClientError) ||
persistenceMayHaveStarted ||
// 中文注释:跨算法版本护栏在本请求 PUT 前发现同 operation 已有权威结果;它不应
// 冒充 resultPersistenceStarted,但同样必须读取项目事实完成收口。
operationResultAlreadyExists ||
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 的普通重提路径。
//
// 这里曾有第三条分支「operation 已形成但 POST 未发出」。严格布局保存通道删除后它已
// 不可达:从 operation 赋值到 `perfectPixelPostAttempted = true` 之间只剩 Set.add、
// 纯函数、状态更新器,以及 `await flush(...).catch(() => undefined)` 与内部吞异常的
// `savePerfectPixelOperation`,没有任何语句能抛。留着它只会让人以为该失败态另有提示。
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。`
: (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 operationResultAlreadyExists =
error instanceof ApiClientError &&
(error.details as { operationResultAlreadyExists?: unknown } | null)
?.operationResultAlreadyExists === true;
const outcomeMayBePersisted =
postAttempted &&
(!(error instanceof ApiClientError) ||
persistenceMayHaveStarted ||
operationResultAlreadyExists ||
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
: '完美像素处理失败';
// 中文注释:文案不再按 `postAttempted` 分档——严格布局保存通道删除后,从进入 try 到
// `postAttempted = true` 之间已经没有任何能抛异常的语句(flush 带 `.catch`、
// `savePerfectPixelOperation` 内部吞异常,其余是纯函数与状态更新器),所以走到这里
// 时 POST 必然已经发出过。
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'failed',
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) {
// 中文注释:只有 applied / dialog-missing 这两个**正向终态**才算读到了事实,才能
// 清账本。`pending` / `conflict` 不等于「服务端已经停下」——浏览器关掉不会中止
// 服务端处理,api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能
// 还在跑。此刻单次 GET 没看见 resource 就清账,服务端稍后落库便再无对账凭据,
// 用户也永远等不到「结果已进素材库」的提示。留给下次加载重读,保留期与条数上限
// 会兜住残留。读失败(走下面的 catch)同理不清。
if (verdict.kind === 'dialog-missing') {
// 中文注释:服务端当时没有占位可回填,结果只进了素材库——这正是「布局尽力保存
// 失败」的典型结局,用户必须被告知。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
} else if (verdict.kind === 'applied') {
// 中文注释:服务端已经把结果回填进画布,而本次读到的正是当前项目的权威状态,
// 所以结果本就在用户眼前。静默刷新素材库即可,弹提示只是噪音。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
refreshPerfectPixelAssetLibrary();
}
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 rejectReferenceMutationWhileUploadPending = useCallback(() => {
if (!hasPendingImageReferenceUploads) {
return false;
}
showGenerationWarning(
'参考图正在上传,请等待上传完成后再调整参考图或切换模型',
);
return true;
}, [hasPendingImageReferenceUploads, showGenerationWarning]);
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
if (!isCharacterSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
return;
}
setGenerationWarning(null);
let rejectedLimit: number | null = null;
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode === 'character') {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.characterReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count > limit) {
rejectedLimit = limit;
return currentDialog;
}
}
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
if (
nextDialog?.mode === 'character' &&
nextDialog.characterSpecReference
) {
lastCharacterSpecReferenceRef.current =
nextDialog.characterSpecReference;
}
return nextDialog;
});
if (rejectedLimit !== null) {
showGenerationWarning(
`角色主图需预留 1 个槽位,请先将角色参考图减少到 ${rejectedLimit} `,
);
return;
}
setIsPickingCharacterSpecFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickGenerationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
let rejectedLimit: number | null = null;
setGenerateDialog((currentDialog) => {
if (
currentDialog &&
currentDialog.mode !== 'video' &&
(layer.mediaType ?? 'image') === 'image'
) {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.generationReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count >= limit) {
rejectedLimit = limit;
return currentDialog;
}
}
return appendGenerationReference(currentDialog, layer);
});
if (rejectedLimit !== null) {
showGenerationWarning(`参考图最多允许 ${rejectedLimit} `);
return;
}
setIsPickingGenerationReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickQuickEditReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
let rejectedLimit: number | null = null;
setGenerateDialog((currentDialog) => {
if (
currentDialog?.mode === 'quick-edit' &&
(layer.mediaType ?? 'image') === 'image'
) {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.generationReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count >= limit) {
rejectedLimit = limit;
return currentDialog;
}
}
return appendGenerationReference(currentDialog, layer);
});
if (rejectedLimit !== null) {
showGenerationWarning(`快速编辑参考图最多允许 ${rejectedLimit} `);
return;
}
setIsPickingQuickEditReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
let rejectedLimit: number | null = null;
setGenerateDialog((currentDialog) => {
if (
currentDialog?.mode === 'character' &&
(layer.mediaType ?? 'image') === 'image'
) {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.characterReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count >= limit) {
rejectedLimit = limit;
return currentDialog;
}
}
return appendCharacterReference(currentDialog, layer);
});
if (rejectedLimit !== null) {
showGenerationWarning(`角色参考图最多允许 ${rejectedLimit} `);
return;
}
setIsPickingCharacterReferenceFromCanvas(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
if (!isIconSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
let rejectedLimit: number | null = null;
let didAssign = false;
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode === 'icon') {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.generationReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count > limit) {
rejectedLimit = limit;
return currentDialog;
}
}
const nextDialog = assignIconSpecReference(currentDialog, layer);
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
didAssign = true;
}
return nextDialog;
});
if (rejectedLimit !== null) {
showGenerationWarning(
`图标规范图需预留 1 个槽位,请先将参考图减少到 ${rejectedLimit} `,
);
return;
}
if (!didAssign) {
return;
}
setIsPickingIconSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
if (!isIconSpecReferenceLayer(layer)) {
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
let rejectedLimit: number | null = null;
let didAssign = false;
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode === 'ui-design') {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.generationReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count > limit) {
rejectedLimit = limit;
return currentDialog;
}
}
const nextDialog = assignUiDesignSpecReference(currentDialog, layer);
if (
nextDialog?.mode === 'ui-design' &&
nextDialog.uiDesignSpecReference
) {
lastIconSpecReferenceRef.current = nextDialog.uiDesignSpecReference;
didAssign = true;
}
return nextDialog;
});
if (rejectedLimit !== null) {
showGenerationWarning(
`UI设计规范图需预留 1 个槽位,请先将参考图减少到 ${rejectedLimit} `,
);
return;
}
if (!didAssign) {
return;
}
setIsPickingUiDesignSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickPublicationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
let rejectedLimit: number | null = null;
setGenerateDialog((currentDialog) => {
if (
currentDialog?.mode === 'publication' &&
(layer.mediaType ?? 'image') === 'image'
) {
const limit = resolveDialogExtraImageReferenceLimit(currentDialog);
const count = (currentDialog.publicationReferences ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
if (count >= limit) {
rejectedLimit = limit;
return currentDialog;
}
}
return appendPublicationReference(currentDialog, layer);
});
if (rejectedLimit !== null) {
showGenerationWarning(`宣发参考图最多允许 ${rejectedLimit} `);
return;
}
setIsPickingPublicationReferenceFromCanvas(false);
setIsPublicationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[
rejectReferenceMutationWhileUploadPending,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
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,
getCanvasGenerationDialogById,
activeCanvasGenerationDialogId: isCanvasGenerationDialog(generateDialog)
? generateDialog.id
: null,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
projectId,
currentUserId,
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: submitIconSpritesheetGenerationRequest,
submitImageGeneration: submitImageGenerationRequest,
submitQuickEdit: submitQuickEditRequest,
} = generationSubmissionWorkflow;
const rejectSubmissionWhileReferenceUploadPending = useCallback(() => {
if (!hasPendingImageReferenceUploadsRef.current) {
return false;
}
showGenerationWarning('参考图正在上传,请等待上传完成后再提交生成');
return true;
}, [showGenerationWarning]);
const submitImageGeneration = useCallback(
async (dialog: GenerateDialogState) => {
if (rejectSubmissionWhileReferenceUploadPending()) {
return;
}
await submitImageGenerationRequest(dialog);
},
[rejectSubmissionWhileReferenceUploadPending, submitImageGenerationRequest],
);
const submitIconSpritesheetGeneration = useCallback(
async (dialog: GenerateDialogState) => {
if (rejectSubmissionWhileReferenceUploadPending()) {
return;
}
await submitIconSpritesheetGenerationRequest(dialog);
},
[
rejectSubmissionWhileReferenceUploadPending,
submitIconSpritesheetGenerationRequest,
],
);
const submitQuickEdit = useCallback(async () => {
if (rejectSubmissionWhileReferenceUploadPending()) {
return;
}
await submitQuickEditRequest();
}, [rejectSubmissionWhileReferenceUploadPending, submitQuickEditRequest]);
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');
referenceUploadContextCounterRef.current += 1;
setUiAssetExtractionState({
...createUiAssetExtractionState(sourceLayer.id, {
initialTool: 'rect',
model: DEFAULT_IMAGE_MODEL,
}),
referenceUploadContextId: `ui-extraction-${referenceUploadContextCounterRef.current}`,
});
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
},
[
canvasSize,
closeGenerationTransientState,
selectSingleLayer,
setActiveTool,
setCharacterAnimationPanel,
setCropExpandPanel,
setMetadataLayer,
setQuickEditPanel,
setUiAssetExtractionState,
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,
);
},
[setUiAssetExtractionState],
);
const changeUiAssetExtractionModel = useCallback(
(model: string) => {
if (rejectReferenceMutationWhileUploadPending()) {
return;
}
const normalizedModel = normalizeEditorImageModel(model);
const referenceLimit = resolveExtraImageReferenceLimit(
normalizedModel,
UI_EXTRA_REFERENCE_LIMIT,
1,
);
if (
uiAssetExtractionState &&
uiAssetExtractionState.references.length > referenceLimit
) {
showGenerationWarning(
`当前已有 ${uiAssetExtractionState.references.length} 张参考图,${normalizedModel} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
model: normalizedModel,
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
rememberImageModel(normalizedModel);
},
[
rejectReferenceMutationWhileUploadPending,
rememberImageModel,
setUiAssetExtractionState,
showGenerationWarning,
uiAssetExtractionState,
],
);
const appendUiAssetExtractionReferences = useCallback(
(references: CharacterReferenceImage[]) => {
if (!references.length) {
return;
}
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
references: appendLimitedImageReferences(
currentState.references,
references,
resolveExtraImageReferenceLimit(
currentState.model,
UI_EXTRA_REFERENCE_LIMIT,
1,
),
),
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[setUiAssetExtractionState],
);
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,
);
},
[setUiAssetExtractionState],
);
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,
};
});
},
[setUiAssetExtractionState],
);
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,
);
},
[setUiAssetExtractionState],
);
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,
};
});
}, [setUiAssetExtractionState]);
const cancelUiAssetExtractionPointer = useCallback(() => {
setUiAssetExtractionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: null,
}
: currentState,
);
}, [setUiAssetExtractionState]);
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);
}, [setUiAssetExtractionState]);
const submitUiAssetExtraction = useCallback(async () => {
if (rejectSubmissionWhileReferenceUploadPending()) {
return;
}
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,
);
}
}, [
rejectSubmissionWhileReferenceUploadPending,
setUiAssetExtractionState,
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,
setQuickEditPanel,
setUiAssetExtractionState,
],
);
return useMemo(
() => ({
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
cropExpandPanel,
setCropExpandPanel,
cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
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,
openSceneGenerationDialog,
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,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
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,
openSceneGenerationDialog,
openIconGenerationDialog,
openPublicationGenerationDialog,
openRedrawPanel,
openUiDesignGenerationDialog,
openQuickEditPanel,
openSpecDialog,
startCropExpandFrameResize,
openSoundEffectGenerationDialog,
openVideoGenerationDialog,
pickCharacterReferenceFromLayer,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
pickUiDesignSpecFromLayer,
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
removeSelectedLayerBackground,
retryPerfectPixelOperation,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
updateCharacterAnimationDuration,
updateIconDescriptionsText,
updateSpecFormValue,
rememberImageModel,
showGenerationWarning,
],
);
}