2fa2f245c1
账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在 用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局 保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。 改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留 perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是 同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证 ——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变 失败语义的 preferLatestGenerationDialogs。 由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历 内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清 账本。 本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、 配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户 删掉重来即可。跨设备不再自动收口是已知且接受的代价。 布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性 判死;写入侧不再产生新的内联账本。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3964 lines
129 KiB
TypeScript
3964 lines
129 KiB
TypeScript
import {
|
||
type Dispatch,
|
||
type MutableRefObject,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type SetStateAction,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import {
|
||
ApiClientError,
|
||
isGatewayUnknownOutcomeError,
|
||
} from '../../services/apiClient';
|
||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||
import {
|
||
createEditorProjectResource,
|
||
type EditorAssetSnapshot,
|
||
type EditorPixelArtSnapInput,
|
||
type EditorPixelArtSnapResult,
|
||
type EditorProjectLayerSnapshot,
|
||
type EditorProjectResourceSnapshot,
|
||
type EditorProjectSnapshot,
|
||
loadEditorProject,
|
||
splitEditorIconSpritesheet,
|
||
} from '../../services/image-editor/editorProjectClient';
|
||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||
import {
|
||
findCanvasGenerationDialogRecords,
|
||
isUnresolvedCanvasGenerationDialogRecord,
|
||
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||
} from './ImageCanvasEditorModel';
|
||
import type {
|
||
CanvasGenerationDialogState,
|
||
CanvasHistoryAction,
|
||
CanvasLayer,
|
||
CanvasTool,
|
||
CanvasViewport,
|
||
CharacterAnimationPanelState,
|
||
CharacterReferenceImage,
|
||
CropExpandPanelState,
|
||
CropExpandResizeHandle,
|
||
GenerateDialogState,
|
||
ImageContextMenuState,
|
||
PerfectPixelOperationSnapshot,
|
||
PublicationMaterialsWorkflowId,
|
||
QuickEditPanelState,
|
||
SidebarPanel,
|
||
SpecFormValues,
|
||
SpecGenerationType,
|
||
} from './ImageCanvasEditorTypes';
|
||
import {
|
||
appendCharacterReference,
|
||
appendGenerationReference,
|
||
appendPublicationReference,
|
||
assignCharacterSpecReference,
|
||
assignIconSpecReference,
|
||
assignUiDesignSpecReference,
|
||
closeGenerateComposerDialog,
|
||
createAudioRedrawGenerationDialogDraft,
|
||
createBackgroundMusicGenerationDialogDraft,
|
||
createCharacterAnimationGenerationDialogDraft,
|
||
createCharacterGenerationDialogDraft,
|
||
createEditDialogDraft,
|
||
createGenerateDialogDraft,
|
||
createIconGenerationDialogDraft,
|
||
createPublicationGenerationDialogDraft,
|
||
createQuickEditGenerationDialogDraft,
|
||
createQuickEditPanelDraft,
|
||
createRedrawPanelDraft,
|
||
createSameSourceGenerationDialogDraft,
|
||
createSoundEffectGenerationDialogDraft,
|
||
createSpecDialogDraft,
|
||
createUiDesignGenerationDialogDraft,
|
||
createVideoGenerationDialogDraft,
|
||
createVideoRedrawGenerationDialogDraft,
|
||
hideGeneratedLayerComposerAfterBlur,
|
||
isCharacterSpecReferenceLayer,
|
||
isIconSpecReferenceLayer,
|
||
updateCharacterAnimationDurationPanel,
|
||
updateIconDescriptionsTextInDialog,
|
||
updateSpecFormDialogValue,
|
||
} from './ImageCanvasGenerationDialogModel';
|
||
import {
|
||
calculateCharacterAnimationPrice,
|
||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
DEFAULT_IMAGE_MODEL,
|
||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||
IMAGE_MODEL_GPT_IMAGE_2,
|
||
isCanvasGenerationDialog,
|
||
isQuickEditUnsupportedAssetKind,
|
||
normalizeEditorImageModel,
|
||
resizeGenerationPlaceholderToImageSelection,
|
||
resolveEditorImageGenerationPixelSize,
|
||
} from './ImageCanvasGenerationModel';
|
||
import {
|
||
centerViewportOnPlacement,
|
||
chooseGenerationPlacement,
|
||
} from './ImageCanvasGenerationPlacementModel';
|
||
import { resolveQuickEditFocusViewport } from './ImageCanvasOverlayModel';
|
||
import {
|
||
buildCropExpandInsetsFromFrame,
|
||
removeImageBackground,
|
||
renderCropExpandImage,
|
||
snapImageToPerfectPixels,
|
||
} from './ImageCanvasRasterEditModel';
|
||
import {
|
||
createUiAssetExtractionDraftMark,
|
||
createUiAssetExtractionState,
|
||
normalizeUiAssetExtractionMark,
|
||
type UiAssetExtractionState,
|
||
type UiAssetExtractionTool,
|
||
updateUiAssetExtractionDraftMark,
|
||
} from './ImageCanvasUiAssetExtractionModel';
|
||
import {
|
||
forgetPerfectPixelOperation,
|
||
readPerfectPixelOperations,
|
||
savePerfectPixelOperation,
|
||
} from './perfectPixelOperationStore';
|
||
import {
|
||
applyQueuedEditorGenerationProject,
|
||
createEditorGenerationMediaUploadId,
|
||
resolveEditorGenerationMediaReference,
|
||
useImageCanvasGenerationSubmissionWorkflow,
|
||
} from './useImageCanvasGenerationSubmissionWorkflow';
|
||
import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry';
|
||
|
||
type CanvasSize = { width: number; height: number };
|
||
|
||
type CropExpandResizeDragState = {
|
||
pointerId: number;
|
||
handle: CropExpandResizeHandle;
|
||
sourceLayerId: string;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startScale: number;
|
||
startFrame: CropExpandPanelState['frame'];
|
||
ratio: CropExpandPanelState['ratio'];
|
||
};
|
||
|
||
type CanvasGenerationDialogUpdater = (
|
||
dialog: CanvasGenerationDialogState,
|
||
) => CanvasGenerationDialogState | null;
|
||
|
||
type RememberedImageGenerationOptions = {
|
||
model: string;
|
||
aspectRatio: string;
|
||
imageSize: string;
|
||
};
|
||
|
||
function dataUrlToImageFile(dataUrl: string, fileName: string) {
|
||
const [header = '', payload = ''] = dataUrl.split(',');
|
||
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
|
||
const type = mimeMatch?.[1] ?? 'image/png';
|
||
const isBase64 = Boolean(mimeMatch?.[2]);
|
||
const binary = isBase64
|
||
? typeof atob === 'function'
|
||
? atob(payload)
|
||
: Buffer.from(payload, 'base64').toString('binary')
|
||
: decodeURIComponent(payload);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
return new File([bytes], fileName, { type });
|
||
}
|
||
|
||
function createProjectResourceSnapshotFromLayer(
|
||
projectId: string,
|
||
layer: CanvasLayer,
|
||
): EditorProjectResourceSnapshot | null {
|
||
const imageSrc = layer.objectKey?.trim()
|
||
? `/${layer.objectKey.trim().replace(/^\/+/u, '')}`
|
||
: layer.src.trim();
|
||
if (!imageSrc) {
|
||
return null;
|
||
}
|
||
return {
|
||
resourceId: layer.resourceId,
|
||
projectId,
|
||
label: layer.title,
|
||
imageSrc,
|
||
objectKey: layer.objectKey,
|
||
assetObjectId: layer.assetObjectId,
|
||
width: layer.originalWidth,
|
||
height: layer.originalHeight,
|
||
sourceType: layer.sourceType,
|
||
prompt: layer.prompt,
|
||
actualPrompt: layer.actualPrompt,
|
||
model: layer.model,
|
||
provider: layer.provider,
|
||
taskId: layer.taskId,
|
||
durationSeconds: layer.durationSeconds,
|
||
sourceResourceId: layer.sourceResourceId,
|
||
assetKind: layer.assetKind,
|
||
generationInputs: layer.generationInputs,
|
||
};
|
||
}
|
||
|
||
function createProjectLayerSnapshotFromLayer(
|
||
layer: CanvasLayer,
|
||
): EditorProjectLayerSnapshot {
|
||
return {
|
||
layerId: layer.id,
|
||
resourceId: layer.resourceId,
|
||
title: layer.title,
|
||
x: layer.x,
|
||
y: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
originalWidth: layer.originalWidth,
|
||
originalHeight: layer.originalHeight,
|
||
zIndex: layer.zIndex,
|
||
sourceType: layer.sourceType,
|
||
sourceResourceId: layer.sourceResourceId,
|
||
assetKind: layer.assetKind,
|
||
mediaType: layer.mediaType,
|
||
objectKey: layer.objectKey,
|
||
assetObjectId: layer.assetObjectId,
|
||
durationSeconds: layer.durationSeconds,
|
||
};
|
||
}
|
||
|
||
// 中文注释:源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整
|
||
// 上传;confirm 成功后已经有稳定 objectKey,operation 请求随即形成。
|
||
//
|
||
// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一
|
||
// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。
|
||
//
|
||
// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待
|
||
// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。
|
||
export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000;
|
||
|
||
const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000;
|
||
const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000];
|
||
|
||
export type PerfectPixelProjectVerdict =
|
||
| {
|
||
kind: 'applied';
|
||
project: EditorProjectSnapshot;
|
||
resource: EditorProjectResourceSnapshot;
|
||
}
|
||
| {
|
||
kind: 'dialog-missing';
|
||
project: EditorProjectSnapshot;
|
||
resource: EditorProjectResourceSnapshot;
|
||
}
|
||
| { kind: 'pending'; project: EditorProjectSnapshot | null }
|
||
| {
|
||
kind: 'conflict';
|
||
project: EditorProjectSnapshot;
|
||
message: string;
|
||
};
|
||
|
||
export function inspectPerfectPixelProjectSnapshot(
|
||
project: EditorProjectSnapshot,
|
||
operation: Pick<PerfectPixelOperationSnapshot, 'operationId' | 'taskId'>,
|
||
): PerfectPixelProjectVerdict {
|
||
const matchingResources = project.resources.filter(
|
||
(resource) => resource.taskId?.trim() === operation.taskId,
|
||
);
|
||
const matchingDialogs = findCanvasGenerationDialogRecords(
|
||
project,
|
||
operation.operationId,
|
||
);
|
||
|
||
if (matchingResources.length > 1) {
|
||
return {
|
||
kind: 'conflict',
|
||
project,
|
||
message: '权威项目中存在重复的完美像素任务资源,无法自动确认结果。',
|
||
};
|
||
}
|
||
if (matchingDialogs.length > 1) {
|
||
return {
|
||
kind: 'conflict',
|
||
project,
|
||
message: '权威项目中存在重复的完美像素占位,无法自动确认结果。',
|
||
};
|
||
}
|
||
const dialog = matchingDialogs[0] ?? null;
|
||
const resource = matchingResources[0] ?? null;
|
||
if (!resource) {
|
||
if (!dialog || isUnresolvedCanvasGenerationDialogRecord(dialog)) {
|
||
return { kind: 'pending', project };
|
||
}
|
||
return {
|
||
kind: 'conflict',
|
||
project,
|
||
message: '完美像素占位已收口,但权威项目缺少对应任务资源。',
|
||
};
|
||
}
|
||
if (!dialog) {
|
||
return { kind: 'dialog-missing', project, resource };
|
||
}
|
||
if (isUnresolvedCanvasGenerationDialogRecord(dialog)) {
|
||
return { kind: 'pending', project };
|
||
}
|
||
const generatedLayerId =
|
||
typeof dialog.generatedLayerId === 'string'
|
||
? dialog.generatedLayerId.trim()
|
||
: '';
|
||
const matchingLayers = project.layers.filter(
|
||
(layer) => layer.layerId === generatedLayerId,
|
||
);
|
||
if (
|
||
!generatedLayerId ||
|
||
matchingLayers.length !== 1 ||
|
||
matchingLayers[0]?.resourceId !== resource.resourceId
|
||
) {
|
||
return {
|
||
kind: 'conflict',
|
||
project,
|
||
message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。',
|
||
};
|
||
}
|
||
return { kind: 'applied', project, resource };
|
||
}
|
||
|
||
function createPerfectPixelReconciliationOperation(
|
||
operation: PerfectPixelOperationSnapshot,
|
||
startedAt = Date.now(),
|
||
): PerfectPixelOperationSnapshot {
|
||
return {
|
||
...operation,
|
||
submittedAt: startedAt,
|
||
reconcileUntil: startedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||
};
|
||
}
|
||
|
||
function waitForPerfectPixelReconciliationDelay(
|
||
delayMs: number,
|
||
signal?: AbortSignal,
|
||
) {
|
||
if (delayMs <= 0) {
|
||
return Promise.resolve();
|
||
}
|
||
return new Promise<void>((resolve, reject) => {
|
||
const finish = () => {
|
||
signal?.removeEventListener('abort', abort);
|
||
resolve();
|
||
};
|
||
const timer = setTimeout(finish, delayMs);
|
||
const abort = () => {
|
||
clearTimeout(timer);
|
||
signal?.removeEventListener('abort', abort);
|
||
reject(signal?.reason ?? new DOMException('操作已取消', 'AbortError'));
|
||
};
|
||
if (signal?.aborted) {
|
||
abort();
|
||
return;
|
||
}
|
||
signal?.addEventListener('abort', abort, { once: true });
|
||
});
|
||
}
|
||
|
||
async function reconcilePerfectPixelProject(
|
||
projectId: string,
|
||
operation: PerfectPixelOperationSnapshot,
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<PerfectPixelProjectVerdict> {
|
||
let attempt = 0;
|
||
let hasAttemptedRead = false;
|
||
let latestProject: EditorProjectSnapshot | null = null;
|
||
while (!options.signal?.aborted) {
|
||
const remainingMs = operation.reconcileUntil - Date.now();
|
||
if (remainingMs <= 0 && hasAttemptedRead) {
|
||
return { kind: 'pending', project: latestProject };
|
||
}
|
||
const delayMs =
|
||
PERFECT_PIXEL_RECONCILIATION_DELAYS_MS[
|
||
Math.min(attempt, PERFECT_PIXEL_RECONCILIATION_DELAYS_MS.length - 1)
|
||
] ?? 5_000;
|
||
if (delayMs > 0) {
|
||
await waitForPerfectPixelReconciliationDelay(
|
||
Math.min(delayMs, remainingMs),
|
||
options.signal,
|
||
);
|
||
}
|
||
const remainingAfterDelayMs = operation.reconcileUntil - Date.now();
|
||
if (remainingAfterDelayMs <= 0 && hasAttemptedRead) {
|
||
return { kind: 'pending', project: latestProject };
|
||
}
|
||
try {
|
||
hasAttemptedRead = true;
|
||
const readStartedAt = Date.now();
|
||
const remainingAtReadMs = operation.reconcileUntil - readStartedAt;
|
||
const readDeadlineAt =
|
||
remainingAtReadMs > 0
|
||
? Math.min(
|
||
operation.reconcileUntil,
|
||
readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||
)
|
||
: readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS;
|
||
latestProject = await loadEditorProject(projectId, {
|
||
signal: options.signal,
|
||
deadlineAt: readDeadlineAt,
|
||
});
|
||
const verdict = inspectPerfectPixelProjectSnapshot(
|
||
latestProject,
|
||
operation,
|
||
);
|
||
if (verdict.kind !== 'pending') {
|
||
return verdict;
|
||
}
|
||
} catch (error) {
|
||
if (options.signal?.aborted) {
|
||
throw error;
|
||
}
|
||
}
|
||
attempt += 1;
|
||
}
|
||
throw options.signal?.reason ?? new DOMException('操作已取消', 'AbortError');
|
||
}
|
||
|
||
function resolveConfirmedPerfectPixelAsset(
|
||
result: EditorPixelArtSnapResult | null,
|
||
operation: PerfectPixelOperationSnapshot,
|
||
resource: EditorProjectResourceSnapshot,
|
||
): EditorAssetSnapshot | null {
|
||
if (
|
||
!result ||
|
||
result.taskId !== operation.taskId ||
|
||
result.resource.taskId?.trim() !== operation.taskId ||
|
||
result.resource.resourceId !== resource.resourceId ||
|
||
result.resource.projectId !== resource.projectId ||
|
||
result.asset.taskId?.trim() !== operation.taskId ||
|
||
(resource.assetId && result.asset.assetId !== resource.assetId)
|
||
) {
|
||
return null;
|
||
}
|
||
return result.asset;
|
||
}
|
||
|
||
async function withPerfectPixelSourcePreparationBudget<T>(
|
||
work: Promise<T>,
|
||
deadlineAt: number,
|
||
timeoutMessage: string,
|
||
// 中文注释:到期时必须真的 abort,而不只是停止 await。这一段会往 OSS 写实体,
|
||
// 被放弃的上传若继续跑完并 confirm,就留下一份不可见的孤儿对象,而用户重试还会再写一份。
|
||
abortOnTimeout?: AbortController,
|
||
): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
work,
|
||
new Promise<never>((_resolve, reject) => {
|
||
timer = setTimeout(
|
||
() => {
|
||
abortOnTimeout?.abort(new Error(timeoutMessage));
|
||
reject(new Error(timeoutMessage));
|
||
},
|
||
Math.max(0, deadlineAt - Date.now()),
|
||
);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer !== undefined) {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
}
|
||
|
||
function preserveSourceLayerInProjectSnapshot(
|
||
project: EditorProjectSnapshot,
|
||
sourceLayer: CanvasLayer,
|
||
): EditorProjectSnapshot {
|
||
if (
|
||
project.layers.some((layer) => layer.layerId === sourceLayer.id) ||
|
||
sourceLayer.resourceId.startsWith('generation-dialog:')
|
||
) {
|
||
return project;
|
||
}
|
||
const sourceResource = project.resources.some(
|
||
(resource) => resource.resourceId === sourceLayer.resourceId,
|
||
)
|
||
? null
|
||
: createProjectResourceSnapshotFromLayer(project.projectId, sourceLayer);
|
||
return {
|
||
...project,
|
||
resources: sourceResource
|
||
? [...project.resources, sourceResource]
|
||
: project.resources,
|
||
layers: [
|
||
createProjectLayerSnapshotFromLayer(sourceLayer),
|
||
...project.layers,
|
||
],
|
||
};
|
||
}
|
||
|
||
function createPerfectPixelOperationId() {
|
||
const randomUuid = globalThis.crypto?.randomUUID?.();
|
||
if (randomUuid) {
|
||
return `perfect-pixel-${randomUuid}`;
|
||
}
|
||
return `perfect-pixel-${Date.now().toString(36)}-${Math.random()
|
||
.toString(36)
|
||
.slice(2)}`;
|
||
}
|
||
|
||
function perfectPixelAuthorityKey(
|
||
ownerUserId: string | null | undefined,
|
||
projectId: string | null | undefined,
|
||
) {
|
||
const normalizedProjectId = projectId?.trim();
|
||
return normalizedProjectId
|
||
? `${ownerUserId?.trim() ?? ''}:${normalizedProjectId}`
|
||
: null;
|
||
}
|
||
|
||
function perfectPixelRecoveryKey(
|
||
ownerUserId: string | null | undefined,
|
||
projectId: string,
|
||
operation: Pick<
|
||
PerfectPixelOperationSnapshot,
|
||
'operationId' | 'reconcileUntil'
|
||
>,
|
||
) {
|
||
return `${ownerUserId?.trim() ?? ''}:${projectId}:${operation.operationId}:${operation.reconcileUntil}`;
|
||
}
|
||
|
||
function isSameUnsettledPerfectPixelOperation(
|
||
dialog: CanvasGenerationDialogState,
|
||
operationId: string,
|
||
) {
|
||
return (
|
||
dialog.perfectPixelOperation?.operationId === operationId &&
|
||
(dialog.status === 'generating' || dialog.status === 'pending-confirmation')
|
||
);
|
||
}
|
||
|
||
function findSourceGenerationDialog(
|
||
dialogs: CanvasGenerationDialogState[],
|
||
sourceLayer: CanvasLayer,
|
||
) {
|
||
return [...dialogs].reverse().find(
|
||
(dialog) =>
|
||
dialog.generatedLayerId === sourceLayer.id ||
|
||
// sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。
|
||
(!dialog.generatedLayerId &&
|
||
dialog.sourceLayerId === sourceLayer.id &&
|
||
isGenerationDialogModeCompatibleWithSourceLayer(
|
||
dialog.mode,
|
||
sourceLayer,
|
||
)),
|
||
);
|
||
}
|
||
|
||
function isGenerationDialogModeCompatibleWithSourceLayer(
|
||
mode: CanvasGenerationDialogState['mode'],
|
||
sourceLayer: CanvasLayer,
|
||
) {
|
||
if (mode === 'quick-edit') {
|
||
return false;
|
||
}
|
||
if (sourceLayer.assetKind === 'character') {
|
||
return mode === 'character';
|
||
}
|
||
if (sourceLayer.assetKind === 'character-animation') {
|
||
return mode === 'character-animation';
|
||
}
|
||
if (sourceLayer.assetKind === 'ui-design') {
|
||
return mode === 'ui-design';
|
||
}
|
||
if (sourceLayer.assetKind === 'publication-material') {
|
||
return mode === 'publication';
|
||
}
|
||
if (
|
||
sourceLayer.assetKind === 'icon' ||
|
||
sourceLayer.assetKind === 'icon-spritesheet'
|
||
) {
|
||
return mode === 'icon';
|
||
}
|
||
if (
|
||
sourceLayer.assetKind === 'spec' ||
|
||
sourceLayer.assetKind === 'icon-spec'
|
||
) {
|
||
return mode === 'spec';
|
||
}
|
||
if (sourceLayer.assetKind === 'video') {
|
||
return mode === 'video';
|
||
}
|
||
if (sourceLayer.assetKind === 'sound-effect') {
|
||
return mode === 'audio-sound-effect';
|
||
}
|
||
if (sourceLayer.assetKind === 'background-music') {
|
||
return mode === 'audio-background-music';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function findSourceAnimationLayer(
|
||
layers: CanvasLayer[],
|
||
sourceLayer: CanvasLayer,
|
||
) {
|
||
if (sourceLayer.assetKind !== 'character-animation') {
|
||
return null;
|
||
}
|
||
const sourceResourceId = sourceLayer.sourceResourceId?.trim();
|
||
if (!sourceResourceId) {
|
||
return null;
|
||
}
|
||
return (
|
||
layers.find(
|
||
(layer) =>
|
||
layer.resourceId === sourceResourceId &&
|
||
layer.assetKind === 'character',
|
||
) ?? null
|
||
);
|
||
}
|
||
|
||
function getCanvasToolForGenerationMode(
|
||
mode: CanvasGenerationDialogState['mode'],
|
||
): CanvasTool {
|
||
if (mode === 'video') {
|
||
return 'video';
|
||
}
|
||
if (mode === 'audio-sound-effect' || mode === 'audio-background-music') {
|
||
return 'music';
|
||
}
|
||
if (mode === 'character' || mode === 'character-animation') {
|
||
return 'character';
|
||
}
|
||
if (mode === 'icon') {
|
||
return 'icon';
|
||
}
|
||
if (mode === 'publication') {
|
||
return 'publication';
|
||
}
|
||
if (mode === 'ui-design') {
|
||
return 'ui-design';
|
||
}
|
||
if (mode === 'spec') {
|
||
return 'spec';
|
||
}
|
||
return 'generate';
|
||
}
|
||
|
||
const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
|
||
'genarrative.imageCanvas.lastCharacterSpecReference';
|
||
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
|
||
'genarrative.imageCanvas.lastIconSpecReference';
|
||
const INVALID_CHARACTER_SPEC_WARNING =
|
||
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
|
||
const INVALID_ICON_SPEC_WARNING =
|
||
'选择的图片不是图标规范图,请选择生成规范里的图标规范。';
|
||
|
||
function normalizeRememberedImageGenerationOptions({
|
||
model,
|
||
aspectRatio,
|
||
imageSize,
|
||
}: Partial<RememberedImageGenerationOptions>): RememberedImageGenerationOptions {
|
||
const normalizedModel = normalizeEditorImageModel(model);
|
||
const dimensionOptions =
|
||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||
normalizedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
|
||
const aspectRatios = dimensionOptions.aspectRatios as readonly string[];
|
||
const imageSizes = dimensionOptions.imageSizes as readonly string[];
|
||
|
||
return {
|
||
model: normalizedModel,
|
||
aspectRatio:
|
||
aspectRatio && aspectRatios.includes(aspectRatio)
|
||
? aspectRatio
|
||
: (dimensionOptions.aspectRatios[0] ?? '1:1'),
|
||
imageSize:
|
||
imageSize && imageSizes.includes(imageSize)
|
||
? imageSize
|
||
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||
dimensionOptions.imageSizes[0] ??
|
||
'1K'),
|
||
};
|
||
}
|
||
|
||
function applyRememberedImageGenerationOptions(
|
||
draft: Omit<CanvasGenerationDialogState, 'id'>,
|
||
options: RememberedImageGenerationOptions,
|
||
): Omit<CanvasGenerationDialogState, 'id'> {
|
||
return resizeGenerationPlaceholderToImageSelection({
|
||
...draft,
|
||
...normalizeRememberedImageGenerationOptions(options),
|
||
}) as Omit<CanvasGenerationDialogState, 'id'>;
|
||
}
|
||
|
||
function shouldRememberImageGenerationOptions(
|
||
dialog: GenerateDialogState | null,
|
||
): dialog is GenerateDialogState & {
|
||
mode: 'generate' | 'character' | 'icon';
|
||
} {
|
||
return (
|
||
dialog?.mode === 'generate' ||
|
||
dialog?.mode === 'character' ||
|
||
dialog?.mode === 'icon'
|
||
);
|
||
}
|
||
|
||
function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) {
|
||
const nextLine = `对${markNumber}号红色圈选框里的内容做以下修改:`;
|
||
const trimmedPrompt = prompt.trimEnd();
|
||
return trimmedPrompt ? `${trimmedPrompt}\n${nextLine}` : nextLine;
|
||
}
|
||
|
||
function getLocalGenerationReferenceStorage() {
|
||
if (typeof window === 'undefined') {
|
||
return null;
|
||
}
|
||
try {
|
||
return window.localStorage;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function readOptionalCachedString(
|
||
record: Record<string, unknown>,
|
||
key: keyof CharacterReferenceImage,
|
||
) {
|
||
const value = record[key];
|
||
return typeof value === 'string' ? value : undefined;
|
||
}
|
||
|
||
function readOptionalCachedNullableString(
|
||
record: Record<string, unknown>,
|
||
key: keyof CharacterReferenceImage,
|
||
) {
|
||
const value = record[key];
|
||
if (value === null) {
|
||
return null;
|
||
}
|
||
return typeof value === 'string' ? value : undefined;
|
||
}
|
||
|
||
function readCachedGenerationReference(
|
||
key: string,
|
||
currentUserId?: string | null,
|
||
): CharacterReferenceImage | null {
|
||
const storage = getLocalGenerationReferenceStorage();
|
||
if (!storage) {
|
||
return null;
|
||
}
|
||
try {
|
||
const rawValue = storage.getItem(key);
|
||
if (!rawValue) {
|
||
return null;
|
||
}
|
||
const parsedValue: unknown = JSON.parse(rawValue);
|
||
if (!parsedValue || typeof parsedValue !== 'object') {
|
||
return null;
|
||
}
|
||
const record = parsedValue as Record<string, unknown>;
|
||
const normalizedCurrentUserId = currentUserId?.trim();
|
||
const ownerUserId =
|
||
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
|
||
if (normalizedCurrentUserId && ownerUserId !== normalizedCurrentUserId) {
|
||
storage.removeItem(key);
|
||
return null;
|
||
}
|
||
const id = readOptionalCachedString(record, 'id');
|
||
const label = readOptionalCachedString(record, 'label');
|
||
const src = readOptionalCachedString(record, 'src');
|
||
if (!id || !label || !src) {
|
||
return null;
|
||
}
|
||
const reference: CharacterReferenceImage = { id, label, src };
|
||
const mediaType = readOptionalCachedString(record, 'mediaType');
|
||
if (
|
||
mediaType === 'image' ||
|
||
mediaType === 'video' ||
|
||
mediaType === 'audio'
|
||
) {
|
||
reference.mediaType = mediaType;
|
||
}
|
||
const mimeType = readOptionalCachedString(record, 'mimeType');
|
||
if (mimeType) {
|
||
reference.mimeType = mimeType;
|
||
}
|
||
const sizeBytes = record.sizeBytes;
|
||
if (typeof sizeBytes === 'number') {
|
||
reference.sizeBytes = sizeBytes;
|
||
}
|
||
const durationSeconds = record.durationSeconds;
|
||
if (typeof durationSeconds === 'number') {
|
||
reference.durationSeconds = durationSeconds;
|
||
}
|
||
for (const keyName of [
|
||
'objectKey',
|
||
'assetObjectId',
|
||
'resourceId',
|
||
'sourceAssetId',
|
||
] as const) {
|
||
const value = readOptionalCachedNullableString(record, keyName);
|
||
if (value !== undefined) {
|
||
reference[keyName] = value;
|
||
}
|
||
}
|
||
return reference;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeCachedGenerationReference(
|
||
key: string,
|
||
reference: CharacterReferenceImage,
|
||
currentUserId?: string | null,
|
||
) {
|
||
const storage = getLocalGenerationReferenceStorage();
|
||
if (!storage) {
|
||
return;
|
||
}
|
||
try {
|
||
const ownerUserId = currentUserId?.trim();
|
||
storage.setItem(
|
||
key,
|
||
JSON.stringify(ownerUserId ? { ...reference, ownerUserId } : reference),
|
||
);
|
||
} catch {
|
||
// 中文注释:本地缓存只提升下次新建素材的便捷性,写入失败不阻断生成流程。
|
||
}
|
||
}
|
||
|
||
function createCharacterAnimationPanelFromDialog(
|
||
dialog: CanvasGenerationDialogState | null,
|
||
): CharacterAnimationPanelState | null {
|
||
if (dialog?.mode !== 'character-animation' || !dialog.sourceLayerId) {
|
||
return null;
|
||
}
|
||
return {
|
||
sourceLayerId: dialog.sourceLayerId,
|
||
promptText: dialog.prompt,
|
||
assetLabel: dialog.assetLabel,
|
||
resolution: dialog.characterAnimationResolution ?? '480p',
|
||
ratio: dialog.characterAnimationRatio ?? 'same',
|
||
frameCount: dialog.characterAnimationFrameCount ?? 32,
|
||
durationSeconds: dialog.characterAnimationDurationSeconds ?? 4,
|
||
status:
|
||
dialog.status === 'generating'
|
||
? 'generating'
|
||
: dialog.status === 'failed'
|
||
? 'failed'
|
||
: dialog.characterAnimationResult
|
||
? 'completed'
|
||
: 'idle',
|
||
errorMessage: dialog.errorMessage,
|
||
result: dialog.characterAnimationResult,
|
||
};
|
||
}
|
||
|
||
function applyCharacterAnimationPanelToDialog(
|
||
dialog: CanvasGenerationDialogState,
|
||
panel: CharacterAnimationPanelState | null,
|
||
): CanvasGenerationDialogState {
|
||
if (dialog.mode !== 'character-animation') {
|
||
return dialog;
|
||
}
|
||
if (!panel) {
|
||
return {
|
||
...dialog,
|
||
composerOpen: false,
|
||
};
|
||
}
|
||
return {
|
||
...dialog,
|
||
prompt: panel.promptText,
|
||
assetLabel: panel.assetLabel,
|
||
characterAnimationResolution: panel.resolution,
|
||
characterAnimationRatio: panel.ratio,
|
||
characterAnimationFrameCount: panel.frameCount,
|
||
characterAnimationDurationSeconds: panel.durationSeconds,
|
||
characterAnimationResult: panel.result,
|
||
status:
|
||
panel.status === 'generating'
|
||
? 'generating'
|
||
: panel.status === 'failed'
|
||
? 'failed'
|
||
: 'idle',
|
||
errorMessage: panel.errorMessage,
|
||
};
|
||
}
|
||
|
||
type GenerationWorkflowOptions = {
|
||
layers: CanvasLayer[];
|
||
canvasSize: CanvasSize;
|
||
viewport: CanvasViewport;
|
||
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
|
||
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
|
||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||
layerCounterRef: MutableRefObject<number>;
|
||
generateDialog: GenerateDialogState | null;
|
||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||
openCanvasGenerationDialog: (
|
||
dialog: Omit<CanvasGenerationDialogState, 'id'> & { id?: string },
|
||
) => string;
|
||
activateCanvasGenerationDialog: (
|
||
targetDialog: CanvasGenerationDialogState,
|
||
) => void;
|
||
updateCanvasGenerationDialogById: (
|
||
dialogId: string,
|
||
updater: CanvasGenerationDialogUpdater,
|
||
) => void;
|
||
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
|
||
archiveActiveCanvasGenerationDialog: () => void;
|
||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||
getGeneratingDialogPlaceholder: (
|
||
dialog: GenerateDialogState,
|
||
) => GenerateDialogState['placeholder'];
|
||
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
|
||
selectSingleLayer: (layerId: string | null) => void;
|
||
fitLayers: (
|
||
targetLayers?: CanvasLayer[],
|
||
options?: { captureHistory?: boolean },
|
||
) => void;
|
||
captureCanvasHistory: (action: CanvasHistoryAction) => void;
|
||
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
|
||
projectId?: string | null;
|
||
currentUserId?: string | null;
|
||
assetFolderId?: string | null;
|
||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||
applyProjectSnapshot?: (
|
||
project: EditorProjectSnapshot,
|
||
action?: CanvasHistoryAction,
|
||
) => void;
|
||
applyProjectSnapshotWithoutHistory?: (
|
||
project: EditorProjectSnapshot,
|
||
) => boolean | void;
|
||
flushProjectPersistence?: (options?: {
|
||
preferLatestGenerationDialogs?: boolean;
|
||
}) => Promise<void>;
|
||
refreshAssetLibrary?: () => Promise<unknown> | void;
|
||
onWalletBalanceMayHaveChanged?: () => void;
|
||
};
|
||
|
||
export function useImageCanvasGenerationWorkflow({
|
||
layers,
|
||
canvasSize,
|
||
viewport,
|
||
setViewport,
|
||
setLayers,
|
||
canvasGenerationDialogs,
|
||
layerCounterRef,
|
||
generateDialog,
|
||
setGenerateDialog,
|
||
openCanvasGenerationDialog,
|
||
activateCanvasGenerationDialog,
|
||
updateCanvasGenerationDialogById,
|
||
hasCanvasGenerationDialogById,
|
||
archiveActiveCanvasGenerationDialog,
|
||
removeCanvasGenerationDialogsByLayerId,
|
||
getGeneratingDialogPlaceholder,
|
||
appendCanvasLayersWithResources,
|
||
selectSingleLayer,
|
||
fitLayers,
|
||
captureCanvasHistory,
|
||
setActiveTool,
|
||
setActiveSidebarPanel,
|
||
setMetadataLayer,
|
||
setImageContextMenu,
|
||
persistGeneratedAsset,
|
||
persistUpdatedLayerResource,
|
||
projectId,
|
||
currentUserId,
|
||
assetFolderId,
|
||
upsertGeneratedAsset,
|
||
applyProjectSnapshot,
|
||
applyProjectSnapshotWithoutHistory,
|
||
flushProjectPersistence,
|
||
refreshAssetLibrary,
|
||
onWalletBalanceMayHaveChanged,
|
||
}: GenerationWorkflowOptions) {
|
||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||
const [taskListRefreshKey, setTaskListRefreshKey] = useState(0);
|
||
const refreshTaskListForQueuedGeneration = useCallback(() => {
|
||
setIsTaskSidebarOpen(true);
|
||
setTaskListRefreshKey((key) => key + 1);
|
||
}, []);
|
||
const refreshTaskList = useCallback(() => {
|
||
setTaskListRefreshKey((key) => key + 1);
|
||
}, []);
|
||
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
|
||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||
const [
|
||
splittingIconSpritesheetLayerIds,
|
||
setSplittingIconSpritesheetLayerIds,
|
||
] = useState<Set<string>>(() => new Set());
|
||
const perfectPixelLayerIdsRef = useRef(new Set<string>());
|
||
// 中文注释:本会话仍在执行的 inline 占位 id。到期清理只该针对**别人**留下的孤儿,
|
||
// 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是
|
||
// requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到
|
||
// 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。
|
||
//
|
||
// ownership 内部保留同步 Set,并通过 version 把 claim / release 通知给 React。调用方不得
|
||
// 直接改 Set,否则 finally 释放后到期 effect 不会重新判定。
|
||
const activeInlineGenerationDialogOwnership =
|
||
useInlineGenerationPlaceholderOwnership();
|
||
const {
|
||
claim: claimActiveInlineGenerationDialog,
|
||
release: releaseActiveInlineGenerationDialog,
|
||
has: hasActiveInlineGenerationDialog,
|
||
} = activeInlineGenerationDialogOwnership;
|
||
const perfectPixelRecoveryControllersRef = useRef(
|
||
new Map<string, AbortController>(),
|
||
);
|
||
const observedPerfectPixelRecoveryKeysRef = useRef(new Set<string>());
|
||
const perfectPixelRecoveryAuthorityRef = useRef<string | null>(null);
|
||
const perfectPixelLiveAuthorityRef = useRef(
|
||
perfectPixelAuthorityKey(currentUserId, projectId),
|
||
);
|
||
const generationWorkflowMountedRef = useRef(true);
|
||
perfectPixelLiveAuthorityRef.current = perfectPixelAuthorityKey(
|
||
currentUserId,
|
||
projectId,
|
||
);
|
||
const isPerfectPixelAuthorityCurrent = useCallback(
|
||
(authority: string | null) =>
|
||
generationWorkflowMountedRef.current &&
|
||
authority !== null &&
|
||
perfectPixelLiveAuthorityRef.current === authority,
|
||
[],
|
||
);
|
||
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
|
||
() => new Set(),
|
||
);
|
||
const pendingPerfectPixelLayerIds = useMemo(() => {
|
||
const layerIds = new Set<string>();
|
||
for (const dialog of canvasGenerationDialogs) {
|
||
if (
|
||
dialog.status !== 'pending-confirmation' ||
|
||
!dialog.perfectPixelOperation
|
||
) {
|
||
continue;
|
||
}
|
||
if (dialog.sourceLayerId) {
|
||
layerIds.add(dialog.sourceLayerId);
|
||
}
|
||
const sourceResourceId =
|
||
dialog.perfectPixelOperation.request.sourceResourceId?.trim();
|
||
if (sourceResourceId) {
|
||
for (const layer of layers) {
|
||
if (layer.resourceId === sourceResourceId) {
|
||
layerIds.add(layer.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return layerIds;
|
||
}, [canvasGenerationDialogs, layers]);
|
||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||
useState(false);
|
||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
|
||
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
|
||
useState(false);
|
||
const [
|
||
isPickingGenerationReferenceFromCanvas,
|
||
setIsPickingGenerationReferenceFromCanvas,
|
||
] = useState(false);
|
||
const [
|
||
isPickingQuickEditReferenceFromCanvas,
|
||
setIsPickingQuickEditReferenceFromCanvas,
|
||
] = useState(false);
|
||
const [
|
||
isPickingCharacterSpecFromCanvas,
|
||
setIsPickingCharacterSpecFromCanvas,
|
||
] = useState(false);
|
||
const [
|
||
isPickingCharacterReferenceFromCanvas,
|
||
setIsPickingCharacterReferenceFromCanvas,
|
||
] = useState(false);
|
||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
|
||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||
useState(false);
|
||
const [isUiDesignSpecMenuOpen, setIsUiDesignSpecMenuOpen] = useState(false);
|
||
const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] =
|
||
useState(false);
|
||
const [isMusicMenuOpen, setIsMusicMenuOpen] = useState(false);
|
||
const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false);
|
||
const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] =
|
||
useState(false);
|
||
const [
|
||
isPickingPublicationReferenceFromCanvas,
|
||
setIsPickingPublicationReferenceFromCanvas,
|
||
] = useState(false);
|
||
const [quickEditPanel, setQuickEditPanel] =
|
||
useState<QuickEditPanelState | null>(null);
|
||
const [generationWarning, setGenerationWarning] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [generationWarningVersion, setGenerationWarningVersion] = useState(0);
|
||
const showGenerationWarning = useCallback((warning: string) => {
|
||
setGenerationWarning(warning);
|
||
setGenerationWarningVersion((version) => version + 1);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!generationWarning) {
|
||
return;
|
||
}
|
||
|
||
const timer = window.setTimeout(() => {
|
||
setGenerationWarning(null);
|
||
}, 3_000);
|
||
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [generationWarning, generationWarningVersion]);
|
||
const [cropExpandPanel, setCropExpandPanel] =
|
||
useState<CropExpandPanelState | null>(null);
|
||
const [uiAssetExtractionState, setUiAssetExtractionState] =
|
||
useState<UiAssetExtractionState | null>(null);
|
||
const [quickEditSelectionState, setQuickEditSelectionState] =
|
||
useState<UiAssetExtractionState | null>(null);
|
||
const quickEditSelectionStateRef = useRef<UiAssetExtractionState | null>(
|
||
null,
|
||
);
|
||
const cropExpandPanelRef = useRef<CropExpandPanelState | null>(null);
|
||
const cropExpandResizeDragRef = useRef<CropExpandResizeDragState | null>(
|
||
null,
|
||
);
|
||
const uiAssetExtractionMarkCounterRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (canvasGenerationDialogs.length > previousTaskCountRef.current) {
|
||
setIsTaskSidebarOpen(true);
|
||
}
|
||
previousTaskCountRef.current = canvasGenerationDialogs.length;
|
||
}, [canvasGenerationDialogs.length]);
|
||
const quickEditSelectionMarkCounterRef = useRef(0);
|
||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||
useState<CharacterAnimationPanelState | null>(null);
|
||
const [rememberedImageOptions, setRememberedImageOptions] =
|
||
useState<RememberedImageGenerationOptions>(() =>
|
||
normalizeRememberedImageGenerationOptions({ model: DEFAULT_IMAGE_MODEL }),
|
||
);
|
||
const [initialCharacterSpecReference] =
|
||
useState<CharacterReferenceImage | null>(() =>
|
||
readCachedGenerationReference(
|
||
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
|
||
currentUserId,
|
||
),
|
||
);
|
||
const [initialIconSpecReference] = useState<CharacterReferenceImage | null>(
|
||
() =>
|
||
readCachedGenerationReference(
|
||
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
|
||
currentUserId,
|
||
),
|
||
);
|
||
const currentUserIdRef = useRef(currentUserId);
|
||
const lastCharacterSpecReferenceRef = useRef<CharacterReferenceImage | null>(
|
||
initialCharacterSpecReference,
|
||
);
|
||
const lastIconSpecReferenceRef = useRef<CharacterReferenceImage | null>(
|
||
initialIconSpecReference,
|
||
);
|
||
const characterAnimationDialog =
|
||
isCanvasGenerationDialog(generateDialog) &&
|
||
generateDialog.mode === 'character-animation'
|
||
? generateDialog
|
||
: null;
|
||
const characterAnimationDialogPanel = createCharacterAnimationPanelFromDialog(
|
||
characterAnimationDialog,
|
||
);
|
||
const effectiveCharacterAnimationPanel =
|
||
characterAnimationDialogPanel ?? characterAnimationPanel;
|
||
|
||
cropExpandPanelRef.current = cropExpandPanel;
|
||
quickEditSelectionStateRef.current = quickEditSelectionState;
|
||
|
||
useEffect(() => {
|
||
currentUserIdRef.current = currentUserId;
|
||
lastCharacterSpecReferenceRef.current = readCachedGenerationReference(
|
||
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
|
||
currentUserId,
|
||
);
|
||
lastIconSpecReferenceRef.current = readCachedGenerationReference(
|
||
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
|
||
currentUserId,
|
||
);
|
||
}, [currentUserId]);
|
||
|
||
const updateQuickEditSelectionState = useCallback(
|
||
(
|
||
updater: (
|
||
currentState: UiAssetExtractionState | null,
|
||
) => UiAssetExtractionState | null,
|
||
) => {
|
||
const nextState = updater(quickEditSelectionStateRef.current);
|
||
quickEditSelectionStateRef.current = nextState;
|
||
setQuickEditSelectionState(nextState);
|
||
return nextState;
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
generateDialog?.mode === 'character' &&
|
||
generateDialog.characterSpecReference
|
||
) {
|
||
lastCharacterSpecReferenceRef.current =
|
||
generateDialog.characterSpecReference;
|
||
writeCachedGenerationReference(
|
||
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
|
||
generateDialog.characterSpecReference,
|
||
currentUserIdRef.current,
|
||
);
|
||
return;
|
||
}
|
||
if (generateDialog?.mode === 'icon' && generateDialog.iconSpecReference) {
|
||
lastIconSpecReferenceRef.current = generateDialog.iconSpecReference;
|
||
writeCachedGenerationReference(
|
||
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
|
||
generateDialog.iconSpecReference,
|
||
currentUserIdRef.current,
|
||
);
|
||
return;
|
||
}
|
||
if (
|
||
generateDialog?.mode === 'ui-design' &&
|
||
generateDialog.uiDesignSpecReference
|
||
) {
|
||
lastIconSpecReferenceRef.current = generateDialog.uiDesignSpecReference;
|
||
writeCachedGenerationReference(
|
||
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
|
||
generateDialog.uiDesignSpecReference,
|
||
currentUserIdRef.current,
|
||
);
|
||
}
|
||
}, [generateDialog]);
|
||
|
||
useEffect(() => {
|
||
if (!shouldRememberImageGenerationOptions(generateDialog)) {
|
||
return;
|
||
}
|
||
const nextOptions = normalizeRememberedImageGenerationOptions({
|
||
model: generateDialog.imageModel,
|
||
aspectRatio: generateDialog.aspectRatio,
|
||
imageSize: generateDialog.imageSize,
|
||
});
|
||
setRememberedImageOptions((currentOptions) =>
|
||
currentOptions.model === nextOptions.model &&
|
||
currentOptions.aspectRatio === nextOptions.aspectRatio &&
|
||
currentOptions.imageSize === nextOptions.imageSize
|
||
? currentOptions
|
||
: nextOptions,
|
||
);
|
||
}, [generateDialog]);
|
||
|
||
const quickEditSourceLayer = quickEditPanel
|
||
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
||
null)
|
||
: null;
|
||
const cropExpandSourceLayer = cropExpandPanel
|
||
? (layers.find((layer) => layer.id === cropExpandPanel.sourceLayerId) ??
|
||
null)
|
||
: null;
|
||
const uiAssetExtractionSourceLayer = uiAssetExtractionState
|
||
? (layers.find(
|
||
(layer) => layer.id === uiAssetExtractionState.sourceLayerId,
|
||
) ?? null)
|
||
: null;
|
||
const quickEditSelectionSourceLayer =
|
||
quickEditPanel?.status !== 'generating' && quickEditSelectionState
|
||
? (layers.find(
|
||
(layer) => layer.id === quickEditSelectionState.sourceLayerId,
|
||
) ?? null)
|
||
: null;
|
||
const characterAnimationSourceLayer = effectiveCharacterAnimationPanel
|
||
? (layers.find(
|
||
(layer) => layer.id === effectiveCharacterAnimationPanel.sourceLayerId,
|
||
) ?? null)
|
||
: null;
|
||
const characterAnimationPrice = effectiveCharacterAnimationPanel
|
||
? calculateCharacterAnimationPrice(
|
||
CHARACTER_ANIMATION_MODEL,
|
||
effectiveCharacterAnimationPanel.resolution,
|
||
effectiveCharacterAnimationPanel.durationSeconds,
|
||
)
|
||
: 0;
|
||
const closeGenerationTransientState = useCallback(() => {
|
||
setIsSpecMenuOpen(false);
|
||
setIsGenerationReferenceMenuOpen(false);
|
||
setIsPickingQuickEditReferenceFromCanvas(false);
|
||
setIsCharacterSpecMenuOpen(false);
|
||
setIsCharacterReferenceMenuOpen(false);
|
||
setIsPickingGenerationReferenceFromCanvas(false);
|
||
setIsPickingCharacterSpecFromCanvas(false);
|
||
setIsPickingCharacterReferenceFromCanvas(false);
|
||
setIsIconSpecMenuOpen(false);
|
||
setIsPickingIconSpecFromCanvas(false);
|
||
setIsUiDesignSpecMenuOpen(false);
|
||
setIsPickingUiDesignSpecFromCanvas(false);
|
||
setIsMusicMenuOpen(false);
|
||
setIsPublicationMenuOpen(false);
|
||
setIsPublicationReferenceMenuOpen(false);
|
||
setIsPickingPublicationReferenceFromCanvas(false);
|
||
setUiAssetExtractionState(null);
|
||
setQuickEditSelectionState(null);
|
||
setImageContextMenu(null);
|
||
}, [setImageContextMenu]);
|
||
|
||
const openPlacedCanvasGenerationDialog = useCallback(
|
||
(
|
||
draft: Omit<CanvasGenerationDialogState, 'id'> & {
|
||
id?: string;
|
||
},
|
||
) => {
|
||
const draftPlaceholder = draft.placeholder;
|
||
if (!draftPlaceholder) {
|
||
return {
|
||
dialogId: openCanvasGenerationDialog(draft),
|
||
placeholder: undefined,
|
||
};
|
||
}
|
||
// 中文注释:所有画布生成入口统一先走 placement 模型,避免新占位压住已有图层或生成占位。
|
||
const placement = chooseGenerationPlacement({
|
||
canvasSize,
|
||
viewport,
|
||
frame: draftPlaceholder,
|
||
layers,
|
||
generationDialogs: canvasGenerationDialogs,
|
||
});
|
||
const dialogId = openCanvasGenerationDialog({
|
||
...draft,
|
||
placeholder: placement,
|
||
});
|
||
setViewport(
|
||
centerViewportOnPlacement({
|
||
canvasSize,
|
||
viewport,
|
||
placement,
|
||
}),
|
||
);
|
||
return { dialogId, placeholder: placement };
|
||
},
|
||
[
|
||
canvasGenerationDialogs,
|
||
canvasSize,
|
||
layers,
|
||
openCanvasGenerationDialog,
|
||
setViewport,
|
||
viewport,
|
||
],
|
||
);
|
||
|
||
const activateCanvasGenerationEntry = useCallback(
|
||
(activeTool: CanvasTool) => {
|
||
closeGenerationTransientState();
|
||
setActiveTool(activeTool);
|
||
selectSingleLayer(null);
|
||
setQuickEditPanel(null);
|
||
setCropExpandPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
setUiAssetExtractionState(null);
|
||
setQuickEditSelectionState(null);
|
||
},
|
||
[closeGenerationTransientState, selectSingleLayer, setActiveTool],
|
||
);
|
||
|
||
const openGenerateDialog = useCallback(() => {
|
||
openPlacedCanvasGenerationDialog(
|
||
applyRememberedImageGenerationOptions(
|
||
createGenerateDialogDraft({ canvasSize, viewport }),
|
||
rememberedImageOptions,
|
||
),
|
||
);
|
||
activateCanvasGenerationEntry('generate');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
rememberedImageOptions,
|
||
viewport,
|
||
]);
|
||
|
||
const openSpecDialog = useCallback(
|
||
(specType: SpecGenerationType) => {
|
||
openPlacedCanvasGenerationDialog(
|
||
createSpecDialogDraft({ canvasSize, viewport, specType }),
|
||
);
|
||
activateCanvasGenerationEntry('generate');
|
||
},
|
||
[
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
],
|
||
);
|
||
|
||
const openCharacterAnimationPanel = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
const draft = createCharacterAnimationGenerationDialogDraft({
|
||
canvasSize,
|
||
viewport,
|
||
layer,
|
||
});
|
||
if (!draft) {
|
||
return;
|
||
}
|
||
openPlacedCanvasGenerationDialog(draft);
|
||
activateCanvasGenerationEntry('character');
|
||
},
|
||
[
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
],
|
||
);
|
||
|
||
const openCharacterGenerationDialog = useCallback(() => {
|
||
const draft = applyRememberedImageGenerationOptions(
|
||
createCharacterGenerationDialogDraft({
|
||
canvasSize,
|
||
viewport,
|
||
imageModel: rememberedImageOptions.model,
|
||
}),
|
||
rememberedImageOptions,
|
||
);
|
||
openPlacedCanvasGenerationDialog({
|
||
...draft,
|
||
characterSpecReference:
|
||
lastCharacterSpecReferenceRef.current ?? draft.characterSpecReference,
|
||
});
|
||
activateCanvasGenerationEntry('character');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
rememberedImageOptions,
|
||
viewport,
|
||
]);
|
||
|
||
const openIconGenerationDialog = useCallback(() => {
|
||
const draft = applyRememberedImageGenerationOptions(
|
||
createIconGenerationDialogDraft({
|
||
canvasSize,
|
||
viewport,
|
||
imageModel: rememberedImageOptions.model,
|
||
}),
|
||
rememberedImageOptions,
|
||
);
|
||
openPlacedCanvasGenerationDialog({
|
||
...draft,
|
||
iconSpecReference:
|
||
lastIconSpecReferenceRef.current ?? draft.iconSpecReference,
|
||
});
|
||
activateCanvasGenerationEntry('icon');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
rememberedImageOptions,
|
||
viewport,
|
||
]);
|
||
|
||
const openPublicationGenerationDialog = useCallback(
|
||
(workflowId: PublicationMaterialsWorkflowId) => {
|
||
openPlacedCanvasGenerationDialog(
|
||
createPublicationGenerationDialogDraft({
|
||
canvasSize,
|
||
viewport,
|
||
workflowId,
|
||
}),
|
||
);
|
||
activateCanvasGenerationEntry('publication');
|
||
},
|
||
[
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
],
|
||
);
|
||
|
||
const openVideoGenerationDialog = useCallback(() => {
|
||
openPlacedCanvasGenerationDialog(
|
||
createVideoGenerationDialogDraft({ canvasSize, viewport }),
|
||
);
|
||
activateCanvasGenerationEntry('video');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
]);
|
||
|
||
const openUiDesignGenerationDialog = useCallback(() => {
|
||
const draft = createUiDesignGenerationDialogDraft({
|
||
canvasSize,
|
||
viewport,
|
||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||
});
|
||
openPlacedCanvasGenerationDialog({
|
||
...draft,
|
||
uiDesignSpecReference:
|
||
lastIconSpecReferenceRef.current ?? draft.uiDesignSpecReference,
|
||
});
|
||
activateCanvasGenerationEntry('ui-design');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
]);
|
||
|
||
const openSoundEffectGenerationDialog = useCallback(() => {
|
||
openPlacedCanvasGenerationDialog(
|
||
createSoundEffectGenerationDialogDraft({ canvasSize, viewport }),
|
||
);
|
||
activateCanvasGenerationEntry('music');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
]);
|
||
|
||
const openBackgroundMusicGenerationDialog = useCallback(() => {
|
||
openPlacedCanvasGenerationDialog(
|
||
createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }),
|
||
);
|
||
activateCanvasGenerationEntry('music');
|
||
}, [
|
||
activateCanvasGenerationEntry,
|
||
canvasSize,
|
||
openPlacedCanvasGenerationDialog,
|
||
viewport,
|
||
]);
|
||
|
||
const openEditDialog = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
setMetadataLayer(null);
|
||
setImageContextMenu(null);
|
||
setQuickEditPanel(null);
|
||
setCropExpandPanel(null);
|
||
setUiAssetExtractionState(null);
|
||
archiveActiveCanvasGenerationDialog();
|
||
setGenerateDialog(createEditDialogDraft(sourceLayer));
|
||
setActiveTool('generate');
|
||
},
|
||
[
|
||
archiveActiveCanvasGenerationDialog,
|
||
setActiveTool,
|
||
setGenerateDialog,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
],
|
||
);
|
||
|
||
const createQuickEditModeDraft = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
const sourceDialog = findSourceGenerationDialog(
|
||
canvasGenerationDialogs,
|
||
sourceLayer,
|
||
);
|
||
return createQuickEditPanelDraft(sourceLayer, {
|
||
imageModel: sourceDialog?.imageModel,
|
||
aspectRatio: sourceDialog?.aspectRatio,
|
||
imageSize: sourceDialog?.imageSize,
|
||
});
|
||
},
|
||
[canvasGenerationDialogs],
|
||
);
|
||
|
||
const openQuickEditPanel = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
|
||
return;
|
||
}
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setUiAssetExtractionState(null);
|
||
setCropExpandPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
const quickEditDraft = createQuickEditModeDraft(sourceLayer);
|
||
archiveActiveCanvasGenerationDialog();
|
||
setGenerateDialog(null);
|
||
setQuickEditPanel(quickEditDraft);
|
||
selectSingleLayer(sourceLayer.id);
|
||
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
|
||
initialTool: null,
|
||
model: DEFAULT_IMAGE_MODEL,
|
||
});
|
||
quickEditSelectionStateRef.current = nextSelectionState;
|
||
setQuickEditSelectionState(nextSelectionState);
|
||
quickEditSelectionMarkCounterRef.current = 0;
|
||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||
setActiveTool('generate');
|
||
},
|
||
[
|
||
archiveActiveCanvasGenerationDialog,
|
||
canvasSize,
|
||
createQuickEditModeDraft,
|
||
selectSingleLayer,
|
||
setActiveTool,
|
||
setGenerateDialog,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
setQuickEditPanel,
|
||
setViewport,
|
||
],
|
||
);
|
||
|
||
const updateSourceLayer = useCallback(
|
||
(
|
||
sourceLayerId: string,
|
||
updater: (layer: CanvasLayer) => CanvasLayer,
|
||
options: { fit?: boolean; persist?: boolean } = {},
|
||
) => {
|
||
const sourceLayer = layers.find((layer) => layer.id === sourceLayerId);
|
||
if (!sourceLayer) {
|
||
return;
|
||
}
|
||
const updatedLayer = updater(sourceLayer);
|
||
captureCanvasHistory({ type: 'replace-image', count: 1 });
|
||
setLayers((currentLayers) =>
|
||
currentLayers.map((layer) =>
|
||
layer.id === sourceLayerId ? updatedLayer : layer,
|
||
),
|
||
);
|
||
if (options.persist !== false) {
|
||
persistUpdatedLayerResource?.(updatedLayer);
|
||
}
|
||
selectSingleLayer(sourceLayerId);
|
||
if (options.fit !== false) {
|
||
fitLayers([updatedLayer], { captureHistory: false });
|
||
}
|
||
},
|
||
[
|
||
captureCanvasHistory,
|
||
fitLayers,
|
||
layers,
|
||
persistUpdatedLayerResource,
|
||
selectSingleLayer,
|
||
setLayers,
|
||
],
|
||
);
|
||
|
||
const openRedrawPanel = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setCropExpandPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
setUiAssetExtractionState(null);
|
||
setQuickEditSelectionState(null);
|
||
if (sourceLayer.mediaType === 'audio') {
|
||
const audioDraft = createAudioRedrawGenerationDialogDraft(sourceLayer);
|
||
if (!audioDraft) {
|
||
return;
|
||
}
|
||
setQuickEditPanel(null);
|
||
selectSingleLayer(sourceLayer.id);
|
||
openCanvasGenerationDialog(audioDraft);
|
||
setActiveTool('music');
|
||
return;
|
||
}
|
||
if (sourceLayer.mediaType === 'video') {
|
||
const videoDraft = createVideoRedrawGenerationDialogDraft(sourceLayer);
|
||
if (!videoDraft) {
|
||
return;
|
||
}
|
||
setQuickEditPanel(null);
|
||
selectSingleLayer(sourceLayer.id);
|
||
openCanvasGenerationDialog(videoDraft);
|
||
setActiveTool('video');
|
||
return;
|
||
}
|
||
if (
|
||
sourceLayer.mediaType === undefined ||
|
||
sourceLayer.mediaType === 'image' ||
|
||
sourceLayer.assetKind === 'character-animation'
|
||
) {
|
||
const sourceDialog = findSourceGenerationDialog(
|
||
canvasGenerationDialogs,
|
||
sourceLayer,
|
||
);
|
||
const sourceAnimationLayer = findSourceAnimationLayer(
|
||
layers,
|
||
sourceLayer,
|
||
);
|
||
const sameSourceDraft = createSameSourceGenerationDialogDraft({
|
||
sourceLayer,
|
||
canvasSize,
|
||
viewport,
|
||
sourceDialog,
|
||
mode: 'redraw',
|
||
sourceAnimationLayer,
|
||
});
|
||
if (sameSourceDraft) {
|
||
openPlacedCanvasGenerationDialog(sameSourceDraft);
|
||
setQuickEditPanel(null);
|
||
selectSingleLayer(null);
|
||
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
|
||
return;
|
||
}
|
||
if (sourceLayer.assetKind === 'character-animation') {
|
||
showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
|
||
return;
|
||
}
|
||
const redrawPanel = createRedrawPanelDraft(sourceLayer, {
|
||
imageModel: sourceDialog?.imageModel,
|
||
aspectRatio: sourceDialog?.aspectRatio,
|
||
imageSize: sourceDialog?.imageSize,
|
||
});
|
||
const redrawSize = resolveEditorImageGenerationPixelSize({
|
||
model: redrawPanel.model,
|
||
aspectRatio: redrawPanel.aspectRatio,
|
||
imageSize: redrawPanel.imageSize,
|
||
});
|
||
openPlacedCanvasGenerationDialog({
|
||
...createQuickEditGenerationDialogDraft({
|
||
sourceLayer,
|
||
prompt: redrawPanel.prompt,
|
||
model: redrawPanel.model,
|
||
aspectRatio: redrawPanel.aspectRatio,
|
||
imageSize: redrawPanel.imageSize,
|
||
frame: {
|
||
width: redrawSize.width,
|
||
height: redrawSize.height,
|
||
},
|
||
}),
|
||
composerOpen: true,
|
||
});
|
||
setQuickEditPanel(null);
|
||
selectSingleLayer(null);
|
||
setActiveTool('generate');
|
||
return;
|
||
}
|
||
},
|
||
[
|
||
openCanvasGenerationDialog,
|
||
openPlacedCanvasGenerationDialog,
|
||
selectSingleLayer,
|
||
setActiveTool,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
showGenerationWarning,
|
||
canvasGenerationDialogs,
|
||
canvasSize,
|
||
layers,
|
||
viewport,
|
||
],
|
||
);
|
||
|
||
const openCropExpandPanel = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setUiAssetExtractionState(null);
|
||
archiveActiveCanvasGenerationDialog();
|
||
setGenerateDialog(null);
|
||
setQuickEditPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
setCropExpandPanel({
|
||
sourceLayerId: sourceLayer.id,
|
||
frame: {
|
||
x: sourceLayer.x,
|
||
y: sourceLayer.y,
|
||
width: sourceLayer.width,
|
||
height: sourceLayer.height,
|
||
},
|
||
ratio: 'free',
|
||
status: 'idle',
|
||
});
|
||
selectSingleLayer(sourceLayer.id);
|
||
setActiveTool('select');
|
||
},
|
||
[
|
||
archiveActiveCanvasGenerationDialog,
|
||
selectSingleLayer,
|
||
setActiveTool,
|
||
setGenerateDialog,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
],
|
||
);
|
||
|
||
const startCropExpandFrameResize = useCallback(
|
||
(
|
||
event: ReactPointerEvent<HTMLButtonElement>,
|
||
handle: CropExpandResizeHandle,
|
||
) => {
|
||
const currentPanel = cropExpandPanelRef.current;
|
||
if (!currentPanel) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
cropExpandResizeDragRef.current = {
|
||
pointerId: event.pointerId,
|
||
handle,
|
||
sourceLayerId: currentPanel.sourceLayerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
startScale:
|
||
Number.isFinite(viewport.scale) && viewport.scale > 0
|
||
? viewport.scale
|
||
: 1,
|
||
startFrame: currentPanel.frame,
|
||
ratio: currentPanel.ratio,
|
||
};
|
||
selectSingleLayer(currentPanel.sourceLayerId);
|
||
},
|
||
[selectSingleLayer, viewport.scale],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
const dragState = cropExpandResizeDragRef.current;
|
||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const deltaX =
|
||
(event.clientX - dragState.startClientX) / dragState.startScale;
|
||
const deltaY =
|
||
(event.clientY - dragState.startClientY) / dragState.startScale;
|
||
const nextFrame = resizeCropExpandFrame({
|
||
frame: dragState.startFrame,
|
||
handle: dragState.handle,
|
||
deltaX,
|
||
deltaY,
|
||
ratio: dragState.ratio,
|
||
});
|
||
setCropExpandPanel((currentPanel) =>
|
||
currentPanel?.sourceLayerId === dragState.sourceLayerId
|
||
? {
|
||
...currentPanel,
|
||
frame: nextFrame,
|
||
status:
|
||
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
}
|
||
: currentPanel,
|
||
);
|
||
};
|
||
const handlePointerUp = (event: PointerEvent) => {
|
||
const dragState = cropExpandResizeDragRef.current;
|
||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
cropExpandResizeDragRef.current = null;
|
||
};
|
||
|
||
window.addEventListener('pointermove', handlePointerMove);
|
||
window.addEventListener('pointerup', handlePointerUp);
|
||
window.addEventListener('pointercancel', handlePointerUp);
|
||
return () => {
|
||
window.removeEventListener('pointermove', handlePointerMove);
|
||
window.removeEventListener('pointerup', handlePointerUp);
|
||
window.removeEventListener('pointercancel', handlePointerUp);
|
||
};
|
||
}, [setCropExpandPanel]);
|
||
|
||
const submitCropExpand = useCallback(async () => {
|
||
if (!cropExpandPanel || !cropExpandSourceLayer) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const insets = buildCropExpandInsetsFromFrame({
|
||
layer: cropExpandSourceLayer,
|
||
frame: cropExpandPanel.frame,
|
||
});
|
||
setCropExpandPanel({
|
||
...cropExpandPanel,
|
||
status: 'processing',
|
||
errorMessage: undefined,
|
||
});
|
||
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||
cropExpandSourceLayer.objectKey?.trim() || cropExpandSourceLayer.src,
|
||
);
|
||
const result = await renderCropExpandImage({
|
||
source: sourceImageSrc,
|
||
insets,
|
||
});
|
||
layerCounterRef.current += 1;
|
||
const cropExpandIndex = layerCounterRef.current;
|
||
const cropExpandTitle = `${cropExpandSourceLayer.title} 裁扩`;
|
||
const cropExpandProjectId = projectId?.trim();
|
||
let cropExpandResourceId = `local-resource-crop-expand-${cropExpandIndex}`;
|
||
let cropExpandImageSrc = result.imageSrc;
|
||
let cropExpandObjectKey: string | null = null;
|
||
let cropExpandAssetObjectId: string | null = null;
|
||
let cropExpandSourceResourceId: string | null =
|
||
cropExpandSourceLayer.resourceId;
|
||
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
|
||
if (cropExpandProjectId) {
|
||
const cropExpandUploadId = createEditorGenerationMediaUploadId();
|
||
const cropExpandFile = dataUrlToImageFile(
|
||
result.imageSrc,
|
||
`crop-expand-${cropExpandIndex}-${cropExpandUploadId}.png`,
|
||
);
|
||
const uploadedCropExpand = await uploadEditorMediaAssetFile(
|
||
cropExpandFile,
|
||
'image',
|
||
{
|
||
assetKind: 'editor_crop_expand_image',
|
||
pathSegments: [
|
||
'editor',
|
||
'crop-expand',
|
||
cropExpandProjectId,
|
||
cropExpandUploadId,
|
||
],
|
||
entityId: cropExpandProjectId,
|
||
metadata: {
|
||
editor_project_id: cropExpandProjectId,
|
||
source_resource_id: cropExpandSourceLayer.resourceId,
|
||
},
|
||
},
|
||
);
|
||
const cropExpandResource = await createEditorProjectResource(
|
||
cropExpandProjectId,
|
||
{
|
||
imageSrc: uploadedCropExpand.legacyPublicPath,
|
||
objectKey: uploadedCropExpand.objectKey,
|
||
assetObjectId: uploadedCropExpand.assetObjectId,
|
||
width: result.width,
|
||
height: result.height,
|
||
sourceType: 'generated',
|
||
prompt: cropExpandSourceLayer.prompt,
|
||
actualPrompt: cropExpandSourceLayer.actualPrompt,
|
||
model: cropExpandSourceLayer.model,
|
||
provider: cropExpandSourceLayer.provider,
|
||
taskId: cropExpandSourceLayer.taskId,
|
||
sourceResourceId: cropExpandSourceLayer.resourceId,
|
||
assetKind: cropExpandSourceLayer.assetKind,
|
||
generationInputs: cropExpandSourceLayer.generationInputs,
|
||
},
|
||
);
|
||
cropExpandResourceId = cropExpandResource.resourceId;
|
||
cropExpandImageSrc = cropExpandResource.imageSrc;
|
||
cropExpandObjectKey =
|
||
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
|
||
cropExpandAssetObjectId =
|
||
cropExpandResource.assetObjectId ?? uploadedCropExpand.assetObjectId;
|
||
cropExpandSourceResourceId =
|
||
cropExpandResource.sourceResourceId ??
|
||
cropExpandSourceLayer.resourceId;
|
||
cropExpandAssetKind =
|
||
(cropExpandResource.assetKind as CanvasLayer['assetKind']) ??
|
||
cropExpandSourceLayer.assetKind;
|
||
}
|
||
const nextLayer: CanvasLayer = {
|
||
...cropExpandSourceLayer,
|
||
id: `layer-crop-expand-${cropExpandIndex}`,
|
||
resourceId: cropExpandResourceId,
|
||
title: cropExpandTitle,
|
||
src: cropExpandImageSrc,
|
||
x: cropExpandSourceLayer.x + cropExpandSourceLayer.width + 32,
|
||
y: cropExpandSourceLayer.y,
|
||
width: result.width,
|
||
height: result.height,
|
||
originalWidth: result.width,
|
||
originalHeight: result.height,
|
||
zIndex: cropExpandIndex + 10,
|
||
sourceType: 'generated',
|
||
sourceResourceId: cropExpandSourceResourceId,
|
||
objectKey: cropExpandObjectKey,
|
||
assetObjectId: cropExpandAssetObjectId,
|
||
sourceAssetId: null,
|
||
assetKind: cropExpandAssetKind,
|
||
};
|
||
captureCanvasHistory({ type: 'expand-image', count: 1 });
|
||
appendCanvasLayersWithResources([nextLayer]);
|
||
persistGeneratedAsset?.(nextLayer);
|
||
selectSingleLayer(nextLayer.id);
|
||
fitLayers([cropExpandSourceLayer, nextLayer], { captureHistory: false });
|
||
setCropExpandPanel(null);
|
||
setActiveSidebarPanel('layers');
|
||
} catch (error) {
|
||
setCropExpandPanel({
|
||
...cropExpandPanel,
|
||
status: 'failed',
|
||
errorMessage:
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '裁扩图片失败',
|
||
});
|
||
}
|
||
}, [
|
||
cropExpandPanel,
|
||
cropExpandSourceLayer,
|
||
appendCanvasLayersWithResources,
|
||
captureCanvasHistory,
|
||
fitLayers,
|
||
layerCounterRef,
|
||
persistGeneratedAsset,
|
||
projectId,
|
||
selectSingleLayer,
|
||
setActiveSidebarPanel,
|
||
]);
|
||
|
||
const removeSelectedLayerBackground = useCallback(
|
||
async (sourceLayer: CanvasLayer) => {
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setCropExpandPanel(null);
|
||
setQuickEditPanel(null);
|
||
const assetLabel = `${sourceLayer.title} 去背景`;
|
||
const backgroundRemovalDialog =
|
||
projectId && applyProjectSnapshot
|
||
? createQuickEditGenerationDialogDraft({
|
||
sourceLayer,
|
||
prompt: '去除背景',
|
||
status: 'generating',
|
||
frame: {
|
||
width: sourceLayer.width,
|
||
height: sourceLayer.height,
|
||
},
|
||
})
|
||
: null;
|
||
const backgroundRemovalPlacement = backgroundRemovalDialog
|
||
? openPlacedCanvasGenerationDialog({
|
||
...backgroundRemovalDialog,
|
||
composerOpen: false,
|
||
})
|
||
: undefined;
|
||
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
|
||
const applyBackgroundRemovalProjectSnapshot =
|
||
projectId && applyProjectSnapshot
|
||
? (project: EditorProjectSnapshot) => {
|
||
applyProjectSnapshot(
|
||
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
|
||
);
|
||
}
|
||
: undefined;
|
||
try {
|
||
const sourceImageSrc = await resolveEditorGenerationMediaReference(
|
||
sourceLayer,
|
||
'image',
|
||
projectId,
|
||
);
|
||
const result = await removeImageBackground({
|
||
sourceImageSrc,
|
||
projectId,
|
||
targetLayerId: sourceLayer.id,
|
||
assetKind: sourceLayer.assetKind,
|
||
generationInputs: sourceLayer.generationInputs,
|
||
assetFolderId,
|
||
assetLabel,
|
||
sourceResourceId: sourceLayer.resourceId,
|
||
...(backgroundRemovalPlacement?.placeholder
|
||
? {
|
||
canvasCompletion: {
|
||
dialogId: backgroundRemovalDialogId,
|
||
title: assetLabel,
|
||
placeholder: backgroundRemovalPlacement.placeholder,
|
||
},
|
||
}
|
||
: {}),
|
||
});
|
||
await applyQueuedEditorGenerationProject(
|
||
result,
|
||
projectId,
|
||
(project) =>
|
||
applyProjectSnapshot?.(project, {
|
||
type: 'remove-background',
|
||
count: 1,
|
||
}),
|
||
refreshTaskListForQueuedGeneration,
|
||
onWalletBalanceMayHaveChanged,
|
||
showGenerationWarning,
|
||
backgroundRemovalDialogId,
|
||
applyBackgroundRemovalProjectSnapshot
|
||
? (project) =>
|
||
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
|
||
: undefined,
|
||
);
|
||
} catch (error) {
|
||
if (backgroundRemovalDialogId) {
|
||
updateCanvasGenerationDialogById(
|
||
backgroundRemovalDialogId,
|
||
(dialog) => ({
|
||
...dialog,
|
||
status: 'failed',
|
||
errorMessage:
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '去除背景失败',
|
||
}),
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
[
|
||
applyProjectSnapshot,
|
||
assetFolderId,
|
||
onWalletBalanceMayHaveChanged,
|
||
openPlacedCanvasGenerationDialog,
|
||
projectId,
|
||
refreshTaskListForQueuedGeneration,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
showGenerationWarning,
|
||
updateCanvasGenerationDialogById,
|
||
],
|
||
);
|
||
|
||
// 中文注释:两条文案对应两种**不同的事实**,不能共用。
|
||
// 「未落入画布」:服务端 completion 返回 Ok(None)——占位在处理期间被删掉,服务端画布上
|
||
// 没有结果图层,结果只在素材库。
|
||
// 「未落入当前画布」:服务端已经完成回填,画布上**有**结果图层,只是本地占位因其它
|
||
// 权威更新而不存在,当前页面不套用快照,所以本地看不到;重新加载即可见。
|
||
// 早先这两种情况共用前一条文案,会让「结果其实已在画布上」的用户以为只进了素材库,
|
||
// 进而重做一遍——正是本链路要消除的重复创建。
|
||
const PERFECT_PIXEL_ASSET_ONLY_NOTICE =
|
||
'完美像素结果已保存到素材库,画布占位已不存在。';
|
||
const PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE =
|
||
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。';
|
||
|
||
const refreshPerfectPixelAssetLibrary = useCallback(() => {
|
||
try {
|
||
void Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined);
|
||
} catch {
|
||
// 中文注释:素材列表只是终态后的 best-effort 投影刷新,不能反向改变项目事实 verdict。
|
||
}
|
||
}, [refreshAssetLibrary]);
|
||
|
||
const settleLivePerfectPixelVerdict = useCallback(
|
||
(
|
||
dialogId: string,
|
||
operation: PerfectPixelOperationSnapshot,
|
||
verdict: PerfectPixelProjectVerdict,
|
||
result: EditorPixelArtSnapResult | null,
|
||
) => {
|
||
if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') {
|
||
return false;
|
||
}
|
||
// 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的
|
||
// 对账 GET,并占住保留期名额。
|
||
forgetPerfectPixelOperation(
|
||
currentUserId,
|
||
operation.request.projectId,
|
||
operation.operationId,
|
||
);
|
||
const confirmedAsset = resolveConfirmedPerfectPixelAsset(
|
||
result,
|
||
operation,
|
||
verdict.resource,
|
||
);
|
||
if (confirmedAsset) {
|
||
upsertGeneratedAsset?.(confirmedAsset);
|
||
}
|
||
if (verdict.kind === 'dialog-missing') {
|
||
if (hasCanvasGenerationDialogById(dialogId)) {
|
||
updateCanvasGenerationDialogById(dialogId, () => null);
|
||
}
|
||
refreshPerfectPixelAssetLibrary();
|
||
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
|
||
return true;
|
||
}
|
||
if (!hasCanvasGenerationDialogById(dialogId)) {
|
||
refreshPerfectPixelAssetLibrary();
|
||
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
|
||
return true;
|
||
}
|
||
applyProjectSnapshot?.(verdict.project, {
|
||
type: 'perfect-pixel',
|
||
count: 1,
|
||
});
|
||
refreshPerfectPixelAssetLibrary();
|
||
setActiveTool('select');
|
||
setActiveSidebarPanel('layers');
|
||
return true;
|
||
},
|
||
[
|
||
applyProjectSnapshot,
|
||
currentUserId,
|
||
hasCanvasGenerationDialogById,
|
||
refreshPerfectPixelAssetLibrary,
|
||
setActiveSidebarPanel,
|
||
setActiveTool,
|
||
showGenerationWarning,
|
||
updateCanvasGenerationDialogById,
|
||
upsertGeneratedAsset,
|
||
],
|
||
);
|
||
|
||
const snapSelectedLayerToPerfectPixels = useCallback(
|
||
async (sourceLayer: CanvasLayer) => {
|
||
const normalizedProjectId = projectId?.trim();
|
||
const isStaticRasterLayer =
|
||
(sourceLayer.mediaType === undefined ||
|
||
sourceLayer.mediaType === 'image') &&
|
||
sourceLayer.assetKind !== 'character-animation';
|
||
const existingOperation = canvasGenerationDialogs.find(
|
||
(dialog) =>
|
||
dialog.perfectPixelOperation &&
|
||
(dialog.sourceLayerId === sourceLayer.id ||
|
||
(Boolean(dialog.perfectPixelOperation.request.sourceResourceId) &&
|
||
dialog.perfectPixelOperation.request.sourceResourceId ===
|
||
sourceLayer.resourceId)) &&
|
||
(dialog.status === 'generating' ||
|
||
dialog.status === 'pending-confirmation' ||
|
||
dialog.status === 'failed'),
|
||
);
|
||
if (
|
||
!isStaticRasterLayer ||
|
||
!normalizedProjectId ||
|
||
!applyProjectSnapshot ||
|
||
!flushProjectPersistence ||
|
||
perfectPixelLayerIdsRef.current.has(sourceLayer.id) ||
|
||
Boolean(existingOperation)
|
||
) {
|
||
if (existingOperation) {
|
||
activateCanvasGenerationDialog(existingOperation);
|
||
showGenerationWarning(
|
||
'该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。',
|
||
);
|
||
return;
|
||
}
|
||
if (
|
||
isStaticRasterLayer &&
|
||
(!normalizedProjectId ||
|
||
!applyProjectSnapshot ||
|
||
!flushProjectPersistence)
|
||
) {
|
||
showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
|
||
}
|
||
return;
|
||
}
|
||
const operationAuthority = perfectPixelAuthorityKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
|
||
perfectPixelLayerIdsRef.current.add(sourceLayer.id);
|
||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.add(sourceLayer.id);
|
||
return nextLayerIds;
|
||
});
|
||
closeGenerationTransientState();
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setCropExpandPanel(null);
|
||
setQuickEditPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
|
||
let perfectPixelDialogId: string | undefined;
|
||
let perfectPixelOperation: PerfectPixelOperationSnapshot | undefined;
|
||
let perfectPixelPostAttempted = false;
|
||
try {
|
||
const assetLabel = `${sourceLayer.title} · 完美像素`;
|
||
const placement = openPlacedCanvasGenerationDialog({
|
||
...createQuickEditGenerationDialogDraft({
|
||
sourceLayer,
|
||
prompt: '完美像素',
|
||
assetLabel,
|
||
status: 'generating',
|
||
frame: {
|
||
width: sourceLayer.width,
|
||
height: sourceLayer.height,
|
||
},
|
||
}),
|
||
id: createPerfectPixelOperationId(),
|
||
composerOpen: false,
|
||
// 中文注释:源引用尚未解析、operation 快照也尚未形成时先沿用 legacy 会话标记;
|
||
// 这一阶段退出不会发 POST,孤儿可按 TTL 清理。稳定 request 写入后会立即移除该
|
||
// 标记,durable operation 改由刷新后的 GET-only 恢复收口,绝不能再被 TTL 删除。
|
||
requiresLiveSession: true,
|
||
});
|
||
perfectPixelDialogId = placement.dialogId;
|
||
// 中文注释:紧挨着创建注册,中间不能有 await——否则会留出一个「占位已存在但尚未
|
||
// 登记归属」的窗口,到期清理正好可以在那里把它删掉。
|
||
claimActiveInlineGenerationDialog(perfectPixelDialogId);
|
||
if (!placement.placeholder) {
|
||
throw new Error('无法创建完美像素处理占位');
|
||
}
|
||
|
||
const sourcePreparationDeadlineAt =
|
||
Date.now() + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS;
|
||
const sourcePreparationAbort = new AbortController();
|
||
const sourceImageSrc = await withPerfectPixelSourcePreparationBudget(
|
||
resolveEditorGenerationMediaReference(
|
||
sourceLayer,
|
||
'image',
|
||
normalizedProjectId,
|
||
{
|
||
signal: sourcePreparationAbort.signal,
|
||
uploadId: perfectPixelDialogId,
|
||
},
|
||
),
|
||
sourcePreparationDeadlineAt,
|
||
'完美像素源图准备超时。',
|
||
sourcePreparationAbort,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
const sourceResourceId = sourceLayer.resourceId.trim();
|
||
const request: EditorPixelArtSnapInput = {
|
||
sourceImageSrc,
|
||
projectId: normalizedProjectId,
|
||
...(sourceResourceId &&
|
||
!sourceResourceId.startsWith('local-resource-') &&
|
||
!sourceResourceId.startsWith('generation-dialog:')
|
||
? { sourceResourceId }
|
||
: {}),
|
||
...(sourceLayer.assetKind
|
||
? { assetKind: sourceLayer.assetKind }
|
||
: {}),
|
||
...(sourceLayer.generationInputs
|
||
? {
|
||
generationInputs: {
|
||
fields: sourceLayer.generationInputs.fields.map((field) => ({
|
||
...field,
|
||
})),
|
||
references: sourceLayer.generationInputs.references.map(
|
||
(reference) => ({ ...reference }),
|
||
),
|
||
},
|
||
}
|
||
: {}),
|
||
...(assetFolderId ? { assetFolderId } : {}),
|
||
assetLabel,
|
||
canvasCompletion: {
|
||
dialogId: perfectPixelDialogId,
|
||
title: assetLabel,
|
||
placeholder: { ...placement.placeholder },
|
||
},
|
||
};
|
||
const operationSubmittedAt = Date.now();
|
||
perfectPixelOperation = {
|
||
version: 1,
|
||
kind: 'perfect-pixel',
|
||
operationId: perfectPixelDialogId,
|
||
taskId: `pixel-art-snap-${perfectPixelDialogId}`,
|
||
request,
|
||
submittedAt: operationSubmittedAt,
|
||
reconcileUntil:
|
||
operationSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||
};
|
||
observedPerfectPixelRecoveryKeysRef.current.add(
|
||
perfectPixelRecoveryKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
perfectPixelOperation,
|
||
),
|
||
);
|
||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||
...dialog,
|
||
requiresLiveSession: undefined,
|
||
perfectPixelOperationId: perfectPixelDialogId,
|
||
perfectPixelOperation,
|
||
}));
|
||
// 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端
|
||
// 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样
|
||
// 把布局校验失败升级成完美像素的硬阻断。
|
||
savePerfectPixelOperation(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
perfectPixelOperation,
|
||
);
|
||
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
|
||
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
|
||
await flushProjectPersistence({
|
||
preferLatestGenerationDialogs: true,
|
||
}).catch(() => undefined);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
perfectPixelPostAttempted = true;
|
||
const result = await snapImageToPerfectPixels(
|
||
perfectPixelOperation.request,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
// 中文注释:同步 POST 的响应体不是提交事实。即使它带 project / asset,也可能是网关
|
||
// 断连前后的时点快照;只让项目 GET 决定终态。对账继续使用 POST 前已持久化的绝对
|
||
// deadline,响应返回不能替同一次 operation 续期。
|
||
const verdict = await reconcilePerfectPixelProject(
|
||
normalizedProjectId,
|
||
perfectPixelOperation,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
if (
|
||
settleLivePerfectPixelVerdict(
|
||
perfectPixelDialogId,
|
||
perfectPixelOperation,
|
||
verdict,
|
||
result,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||
...dialog,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
verdict.kind === 'conflict'
|
||
? verdict.message
|
||
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
|
||
}));
|
||
} catch (error) {
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
// 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析
|
||
// 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向
|
||
// 谎报。
|
||
//
|
||
// 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。OSS PUT 与
|
||
// asset object / project resource / editor asset / canvas completion 的单笔数据库
|
||
// 事务仍是跨系统边界;procedure future 被本地 timeout/drop 也不能撤销远端事务。
|
||
// 因而带响应的 4xx 可能发生在 OSS 已写但数据库整笔失败之后,而网关异常也可能发生在
|
||
// 数据库已经整笔提交之后。服务端从第一次 OSS PUT 起置 `resultPersistenceStarted`,
|
||
// 客户端只对这类失败和完全无响应的失败做对账——常见的纯校验 400 / 排队 503 /
|
||
// 处理预算 504 不会白白多次读取,也不会被附上不适用的提示。
|
||
const persistenceMayHaveStarted =
|
||
error instanceof ApiClientError &&
|
||
(error.details as { resultPersistenceStarted?: unknown } | null)
|
||
?.resultPersistenceStarted === true;
|
||
const outcomeMayBePersisted =
|
||
perfectPixelPostAttempted &&
|
||
Boolean(perfectPixelDialogId) &&
|
||
// 中文注释:拿到 HTTP 响应不等于服务端明确表态过。Pingora 网关在上游超时或断连时
|
||
// 会自己合成 502 / 504,那种响应只有 code / message、没有 details,因此既不是
|
||
// transport 异常也拿不到 resultPersistenceStarted——按原判据会被当成确定失败,
|
||
// 而此时 api-server 可能已经走完 OSS PUT 与落库。必须算作未知结果去对账。
|
||
(!(error instanceof ApiClientError) ||
|
||
persistenceMayHaveStarted ||
|
||
isGatewayUnknownOutcomeError(error));
|
||
let reconciledMessage: string | undefined;
|
||
let keepPendingConfirmation = false;
|
||
if (
|
||
outcomeMayBePersisted &&
|
||
perfectPixelDialogId &&
|
||
perfectPixelOperation
|
||
) {
|
||
const verdict = await reconcilePerfectPixelProject(
|
||
normalizedProjectId,
|
||
perfectPixelOperation,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
if (
|
||
settleLivePerfectPixelVerdict(
|
||
perfectPixelDialogId,
|
||
perfectPixelOperation,
|
||
verdict,
|
||
null,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
keepPendingConfirmation = true;
|
||
reconciledMessage =
|
||
verdict.kind === 'conflict'
|
||
? verdict.message
|
||
: '权威项目尚未出现可确认的完美像素结果。';
|
||
}
|
||
const serverMessage =
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: undefined;
|
||
// 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。
|
||
// 结果未知时必须保留 operation 快照并停在 pending-confirmation;这既不会把在途结果
|
||
// 谎报成失败,也不会开放一条会创建新 identity 的普通重提路径。
|
||
const errorMessage = reconciledMessage
|
||
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。`
|
||
: perfectPixelOperation && !perfectPixelPostAttempted
|
||
? `完美像素请求尚未发出:${serverMessage ?? '提交前准备失败'} 请在原占位重试,或删除占位后重来。`
|
||
: (serverMessage ?? '完美像素处理失败');
|
||
if (
|
||
perfectPixelDialogId &&
|
||
hasCanvasGenerationDialogById(perfectPixelDialogId)
|
||
) {
|
||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||
...dialog,
|
||
status: keepPendingConfirmation ? 'pending-confirmation' : 'failed',
|
||
errorMessage,
|
||
}));
|
||
} else {
|
||
// 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自
|
||
// 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。
|
||
showGenerationWarning(errorMessage);
|
||
if (perfectPixelDialogId) {
|
||
forgetPerfectPixelOperation(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
perfectPixelDialogId,
|
||
);
|
||
}
|
||
}
|
||
} finally {
|
||
if (perfectPixelDialogId) {
|
||
releaseActiveInlineGenerationDialog(perfectPixelDialogId);
|
||
}
|
||
perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
|
||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||
if (!currentLayerIds.has(sourceLayer.id)) {
|
||
return currentLayerIds;
|
||
}
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.delete(sourceLayer.id);
|
||
return nextLayerIds;
|
||
});
|
||
}
|
||
},
|
||
[
|
||
applyProjectSnapshot,
|
||
assetFolderId,
|
||
activateCanvasGenerationDialog,
|
||
canvasGenerationDialogs,
|
||
closeGenerationTransientState,
|
||
currentUserId,
|
||
flushProjectPersistence,
|
||
hasCanvasGenerationDialogById,
|
||
isPerfectPixelAuthorityCurrent,
|
||
openPlacedCanvasGenerationDialog,
|
||
projectId,
|
||
claimActiveInlineGenerationDialog,
|
||
releaseActiveInlineGenerationDialog,
|
||
setCharacterAnimationPanel,
|
||
setCropExpandPanel,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
setQuickEditPanel,
|
||
showGenerationWarning,
|
||
settleLivePerfectPixelVerdict,
|
||
updateCanvasGenerationDialogById,
|
||
],
|
||
);
|
||
|
||
const retryPerfectPixelOperation = useCallback(
|
||
async (dialogId: string) => {
|
||
const normalizedDialogId = dialogId.trim();
|
||
const normalizedProjectId = projectId?.trim();
|
||
const dialog = canvasGenerationDialogs.find(
|
||
(candidate) => candidate.id === normalizedDialogId,
|
||
);
|
||
const operation = dialog?.perfectPixelOperation;
|
||
if (
|
||
!normalizedDialogId ||
|
||
!normalizedProjectId ||
|
||
!dialog ||
|
||
!operation ||
|
||
operation.operationId !== normalizedDialogId ||
|
||
operation.request.projectId !== normalizedProjectId ||
|
||
operation.request.canvasCompletion.dialogId !== normalizedDialogId ||
|
||
operation.taskId !== `pixel-art-snap-${normalizedDialogId}` ||
|
||
(dialog.status !== 'pending-confirmation' &&
|
||
dialog.status !== 'failed') ||
|
||
!flushProjectPersistence ||
|
||
!applyProjectSnapshot
|
||
) {
|
||
showGenerationWarning(
|
||
'完美像素操作快照无效或当前状态不可重试,未发送请求。',
|
||
);
|
||
return;
|
||
}
|
||
const operationAuthority = perfectPixelAuthorityKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
const lockKey = dialog.sourceLayerId ?? normalizedDialogId;
|
||
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
|
||
return;
|
||
}
|
||
const retriedOperation =
|
||
createPerfectPixelReconciliationOperation(operation);
|
||
let postAttempted = false;
|
||
perfectPixelLayerIdsRef.current.add(lockKey);
|
||
claimActiveInlineGenerationDialog(normalizedDialogId);
|
||
try {
|
||
if (dialog.sourceLayerId) {
|
||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.add(dialog.sourceLayerId!);
|
||
return nextLayerIds;
|
||
});
|
||
}
|
||
observedPerfectPixelRecoveryKeysRef.current.add(
|
||
perfectPixelRecoveryKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
retriedOperation,
|
||
),
|
||
);
|
||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||
...current,
|
||
status: 'generating',
|
||
errorMessage: undefined,
|
||
requiresLiveSession: undefined,
|
||
perfectPixelOperationId: normalizedDialogId,
|
||
perfectPixelOperation: retriedOperation,
|
||
}));
|
||
savePerfectPixelOperation(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
retriedOperation,
|
||
);
|
||
await flushProjectPersistence({
|
||
preferLatestGenerationDialogs: true,
|
||
}).catch(() => undefined);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
postAttempted = true;
|
||
const result = await snapImageToPerfectPixels(retriedOperation.request);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
const verdict = await reconcilePerfectPixelProject(
|
||
normalizedProjectId,
|
||
retriedOperation,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
if (
|
||
settleLivePerfectPixelVerdict(
|
||
normalizedDialogId,
|
||
retriedOperation,
|
||
verdict,
|
||
result,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||
...current,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
verdict.kind === 'conflict'
|
||
? verdict.message
|
||
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
|
||
}));
|
||
} catch (error) {
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
const persistenceMayHaveStarted =
|
||
error instanceof ApiClientError &&
|
||
(error.details as { resultPersistenceStarted?: unknown } | null)
|
||
?.resultPersistenceStarted === true;
|
||
const outcomeMayBePersisted =
|
||
postAttempted &&
|
||
(!(error instanceof ApiClientError) ||
|
||
persistenceMayHaveStarted ||
|
||
isGatewayUnknownOutcomeError(error));
|
||
if (outcomeMayBePersisted) {
|
||
const verdict = await reconcilePerfectPixelProject(
|
||
normalizedProjectId,
|
||
retriedOperation,
|
||
);
|
||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||
return;
|
||
}
|
||
if (
|
||
settleLivePerfectPixelVerdict(
|
||
normalizedDialogId,
|
||
retriedOperation,
|
||
verdict,
|
||
null,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||
...current,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
verdict.kind === 'conflict'
|
||
? verdict.message
|
||
: '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。',
|
||
}));
|
||
return;
|
||
}
|
||
const errorMessage =
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '完美像素处理失败';
|
||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||
...current,
|
||
status: 'failed',
|
||
errorMessage: postAttempted
|
||
? errorMessage
|
||
: `完美像素请求尚未发出:${errorMessage} 请在原占位重试同一操作。`,
|
||
}));
|
||
} finally {
|
||
releaseActiveInlineGenerationDialog(normalizedDialogId);
|
||
perfectPixelLayerIdsRef.current.delete(lockKey);
|
||
if (dialog.sourceLayerId) {
|
||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||
if (!currentLayerIds.has(dialog.sourceLayerId!)) {
|
||
return currentLayerIds;
|
||
}
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.delete(dialog.sourceLayerId!);
|
||
return nextLayerIds;
|
||
});
|
||
}
|
||
}
|
||
},
|
||
[
|
||
applyProjectSnapshot,
|
||
canvasGenerationDialogs,
|
||
claimActiveInlineGenerationDialog,
|
||
currentUserId,
|
||
flushProjectPersistence,
|
||
isPerfectPixelAuthorityCurrent,
|
||
projectId,
|
||
releaseActiveInlineGenerationDialog,
|
||
showGenerationWarning,
|
||
settleLivePerfectPixelVerdict,
|
||
updateCanvasGenerationDialogById,
|
||
],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const normalizedProjectId = projectId?.trim();
|
||
const recoveryAuthority = perfectPixelAuthorityKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
);
|
||
if (perfectPixelRecoveryAuthorityRef.current !== recoveryAuthority) {
|
||
for (const controller of perfectPixelRecoveryControllersRef.current.values()) {
|
||
controller.abort();
|
||
}
|
||
perfectPixelRecoveryControllersRef.current.clear();
|
||
observedPerfectPixelRecoveryKeysRef.current.clear();
|
||
perfectPixelRecoveryAuthorityRef.current = recoveryAuthority;
|
||
}
|
||
if (!normalizedProjectId || !applyProjectSnapshotWithoutHistory) {
|
||
for (const controller of perfectPixelRecoveryControllersRef.current.values()) {
|
||
controller.abort();
|
||
}
|
||
perfectPixelRecoveryControllersRef.current.clear();
|
||
return;
|
||
}
|
||
const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) =>
|
||
dialog.perfectPixelOperation &&
|
||
(dialog.status === 'generating' ||
|
||
dialog.status === 'pending-confirmation')
|
||
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
|
||
: [],
|
||
);
|
||
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
|
||
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
|
||
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
|
||
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
|
||
const recoverableDialogIds = new Set(
|
||
canvasGenerationDialogs.map((dialog) => dialog.id),
|
||
);
|
||
const nowMs = Date.now();
|
||
for (const [operationId, operation] of readPerfectPixelOperations(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
)) {
|
||
if (
|
||
recoverableDialogIds.has(operationId) ||
|
||
operation.reconcileUntil <= nowMs
|
||
) {
|
||
continue;
|
||
}
|
||
recoverableOperations.push({ dialogId: operationId, operation });
|
||
}
|
||
const recoveryKeys = new Set(
|
||
recoverableOperations.map(({ operation }) =>
|
||
perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation),
|
||
),
|
||
);
|
||
for (const [
|
||
key,
|
||
controller,
|
||
] of perfectPixelRecoveryControllersRef.current) {
|
||
if (!recoveryKeys.has(key)) {
|
||
controller.abort();
|
||
perfectPixelRecoveryControllersRef.current.delete(key);
|
||
}
|
||
}
|
||
|
||
for (const { dialogId, operation } of recoverableOperations) {
|
||
const recoveryKey = perfectPixelRecoveryKey(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
operation,
|
||
);
|
||
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
|
||
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
|
||
if (hasActiveInlineGenerationDialog(dialogId)) {
|
||
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
|
||
continue;
|
||
}
|
||
if (
|
||
observedPerfectPixelRecoveryKeysRef.current.has(recoveryKey) ||
|
||
perfectPixelRecoveryControllersRef.current.has(recoveryKey)
|
||
) {
|
||
continue;
|
||
}
|
||
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
|
||
const controller = new AbortController();
|
||
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
|
||
void (async () => {
|
||
if (operation.request.projectId !== normalizedProjectId) {
|
||
forgetPerfectPixelOperation(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
operation.operationId,
|
||
);
|
||
updateCanvasGenerationDialogById(dialogId, (current) => {
|
||
if (
|
||
!isSameUnsettledPerfectPixelOperation(
|
||
current,
|
||
operation.operationId,
|
||
)
|
||
) {
|
||
return current;
|
||
}
|
||
// 中文注释:标记快照失效时必须同时丢弃 operation,与 hydrateCanvasGenerationDialog
|
||
// 的口径一致。两者并存会造出「重试按钮因 invalid 而消失、快照却还挂着」的矛盾态,
|
||
// 用户既重试不了也看不懂占位为什么还在。
|
||
const { perfectPixelOperation: _discarded, ...rest } = current;
|
||
return {
|
||
...rest,
|
||
status: 'failed' as const,
|
||
perfectPixelOperationInvalid: true,
|
||
errorMessage:
|
||
'完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。',
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
const verdict = await reconcilePerfectPixelProject(
|
||
normalizedProjectId,
|
||
operation,
|
||
{
|
||
signal: controller.signal,
|
||
},
|
||
);
|
||
if (
|
||
controller.signal.aborted ||
|
||
projectId?.trim() !== normalizedProjectId
|
||
) {
|
||
return;
|
||
}
|
||
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
|
||
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
|
||
forgetPerfectPixelOperation(
|
||
currentUserId,
|
||
normalizedProjectId,
|
||
operation.operationId,
|
||
);
|
||
}
|
||
if (verdict.kind === 'applied') {
|
||
if (!hasCanvasGenerationDialogById(dialogId)) {
|
||
refreshPerfectPixelAssetLibrary();
|
||
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
|
||
return;
|
||
}
|
||
const applied = applyProjectSnapshotWithoutHistory(verdict.project);
|
||
if (applied === false) {
|
||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||
isSameUnsettledPerfectPixelOperation(
|
||
current,
|
||
operation.operationId,
|
||
)
|
||
? {
|
||
...current,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
'项目已有更新版本,本次恢复结果未覆盖当前画布,请继续核对。',
|
||
}
|
||
: current,
|
||
);
|
||
return;
|
||
}
|
||
refreshPerfectPixelAssetLibrary();
|
||
return;
|
||
}
|
||
if (verdict.kind === 'dialog-missing') {
|
||
if (hasCanvasGenerationDialogById(dialogId)) {
|
||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||
isSameUnsettledPerfectPixelOperation(
|
||
current,
|
||
operation.operationId,
|
||
)
|
||
? null
|
||
: current,
|
||
);
|
||
}
|
||
refreshPerfectPixelAssetLibrary();
|
||
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
|
||
return;
|
||
}
|
||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
|
||
? {
|
||
...current,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
verdict.kind === 'conflict'
|
||
? verdict.message
|
||
: '完美像素结果仍待确认。系统不会在刷新后自动重复提交。',
|
||
}
|
||
: current,
|
||
);
|
||
try {
|
||
void Promise.resolve(flushProjectPersistence?.()).catch(
|
||
() => undefined,
|
||
);
|
||
} catch {
|
||
// 中文注释:恢复只允许 GET;pending 状态的布局回写失败不能触发 POST 补偿。
|
||
}
|
||
})()
|
||
.catch((error: unknown) => {
|
||
if (
|
||
!controller.signal.aborted &&
|
||
hasCanvasGenerationDialogById(dialogId)
|
||
) {
|
||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||
isSameUnsettledPerfectPixelOperation(
|
||
current,
|
||
operation.operationId,
|
||
)
|
||
? {
|
||
...current,
|
||
status: 'pending-confirmation',
|
||
errorMessage:
|
||
error instanceof Error && error.message.trim()
|
||
? `完美像素恢复读取失败:${error.message}`
|
||
: '完美像素恢复读取失败,结果仍待确认。',
|
||
}
|
||
: current,
|
||
);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (
|
||
perfectPixelRecoveryControllersRef.current.get(recoveryKey) ===
|
||
controller
|
||
) {
|
||
perfectPixelRecoveryControllersRef.current.delete(recoveryKey);
|
||
}
|
||
});
|
||
}
|
||
}, [
|
||
applyProjectSnapshotWithoutHistory,
|
||
canvasGenerationDialogs,
|
||
currentUserId,
|
||
flushProjectPersistence,
|
||
hasActiveInlineGenerationDialog,
|
||
hasCanvasGenerationDialogById,
|
||
projectId,
|
||
refreshPerfectPixelAssetLibrary,
|
||
showGenerationWarning,
|
||
updateCanvasGenerationDialogById,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const recoveryControllers = perfectPixelRecoveryControllersRef.current;
|
||
generationWorkflowMountedRef.current = true;
|
||
return () => {
|
||
generationWorkflowMountedRef.current = false;
|
||
for (const controller of recoveryControllers.values()) {
|
||
controller.abort();
|
||
}
|
||
recoveryControllers.clear();
|
||
};
|
||
}, []);
|
||
|
||
const splitSelectedIconSpritesheet = useCallback(
|
||
async (sourceLayer: CanvasLayer) => {
|
||
if (
|
||
sourceLayer.assetKind !== 'icon-spritesheet' ||
|
||
!projectId ||
|
||
!applyProjectSnapshot ||
|
||
splittingIconSpritesheetLayerIdsRef.current.has(sourceLayer.id)
|
||
) {
|
||
return;
|
||
}
|
||
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
|
||
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.add(sourceLayer.id);
|
||
return nextLayerIds;
|
||
});
|
||
closeGenerationTransientState();
|
||
setImageContextMenu(null);
|
||
setMetadataLayer(null);
|
||
setCropExpandPanel(null);
|
||
setQuickEditPanel(null);
|
||
try {
|
||
const result = await splitEditorIconSpritesheet({
|
||
projectId,
|
||
sourceLayerId: sourceLayer.id,
|
||
sourceResourceId: sourceLayer.resourceId,
|
||
assetFolderId,
|
||
canvasCompletion: {
|
||
title: '拆分图集',
|
||
placeholder: {
|
||
x: sourceLayer.x,
|
||
y: sourceLayer.y,
|
||
width: sourceLayer.width,
|
||
height: sourceLayer.height,
|
||
originalWidth: sourceLayer.originalWidth,
|
||
originalHeight: sourceLayer.originalHeight,
|
||
},
|
||
},
|
||
});
|
||
applyProjectSnapshot(result.project, {
|
||
type: 'split-atlas',
|
||
count: 1,
|
||
});
|
||
setActiveTool('select');
|
||
setActiveSidebarPanel('layers');
|
||
} catch (error) {
|
||
window.alert(
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '拆分图集失败',
|
||
);
|
||
} finally {
|
||
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
|
||
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
|
||
if (!currentLayerIds.has(sourceLayer.id)) {
|
||
return currentLayerIds;
|
||
}
|
||
const nextLayerIds = new Set(currentLayerIds);
|
||
nextLayerIds.delete(sourceLayer.id);
|
||
return nextLayerIds;
|
||
});
|
||
}
|
||
},
|
||
[
|
||
applyProjectSnapshot,
|
||
assetFolderId,
|
||
closeGenerationTransientState,
|
||
projectId,
|
||
setActiveSidebarPanel,
|
||
setActiveTool,
|
||
setCropExpandPanel,
|
||
setImageContextMenu,
|
||
setMetadataLayer,
|
||
setQuickEditPanel,
|
||
],
|
||
);
|
||
|
||
const pickCharacterSpecFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
if (!isCharacterSpecReferenceLayer(layer)) {
|
||
showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
|
||
return;
|
||
}
|
||
setGenerationWarning(null);
|
||
setGenerateDialog((currentDialog) => {
|
||
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
|
||
if (
|
||
nextDialog?.mode === 'character' &&
|
||
nextDialog.characterSpecReference
|
||
) {
|
||
lastCharacterSpecReferenceRef.current =
|
||
nextDialog.characterSpecReference;
|
||
}
|
||
return nextDialog;
|
||
});
|
||
setIsPickingCharacterSpecFromCanvas(false);
|
||
setIsCharacterSpecMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[setGenerateDialog, setImageContextMenu, showGenerationWarning],
|
||
);
|
||
|
||
const pickGenerationReferenceFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
appendGenerationReference(currentDialog, layer),
|
||
);
|
||
setIsPickingGenerationReferenceFromCanvas(false);
|
||
setIsGenerationReferenceMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[setGenerateDialog, setImageContextMenu],
|
||
);
|
||
|
||
const pickQuickEditReferenceFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
appendGenerationReference(currentDialog, layer),
|
||
);
|
||
setIsPickingQuickEditReferenceFromCanvas(false);
|
||
setIsGenerationReferenceMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[setGenerateDialog, setImageContextMenu],
|
||
);
|
||
|
||
const pickCharacterReferenceFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
appendCharacterReference(currentDialog, layer),
|
||
);
|
||
setIsPickingCharacterReferenceFromCanvas(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[setGenerateDialog, setImageContextMenu],
|
||
);
|
||
|
||
const pickIconSpecFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
if (!isIconSpecReferenceLayer(layer)) {
|
||
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
|
||
return;
|
||
}
|
||
setGenerationWarning(null);
|
||
const nextDialog = assignIconSpecReference(generateDialog, layer);
|
||
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
|
||
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
|
||
}
|
||
setGenerateDialog(nextDialog);
|
||
if (nextDialog === generateDialog) {
|
||
return;
|
||
}
|
||
setIsPickingIconSpecFromCanvas(false);
|
||
setIsIconSpecMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[
|
||
generateDialog,
|
||
setGenerateDialog,
|
||
setImageContextMenu,
|
||
showGenerationWarning,
|
||
],
|
||
);
|
||
|
||
const pickUiDesignSpecFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
if (!isIconSpecReferenceLayer(layer)) {
|
||
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
|
||
return;
|
||
}
|
||
setGenerationWarning(null);
|
||
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
|
||
if (
|
||
nextDialog?.mode === 'ui-design' &&
|
||
nextDialog.uiDesignSpecReference
|
||
) {
|
||
lastIconSpecReferenceRef.current = nextDialog.uiDesignSpecReference;
|
||
}
|
||
setGenerateDialog(nextDialog);
|
||
if (nextDialog === generateDialog) {
|
||
return;
|
||
}
|
||
setIsPickingUiDesignSpecFromCanvas(false);
|
||
setIsUiDesignSpecMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[
|
||
generateDialog,
|
||
setGenerateDialog,
|
||
setImageContextMenu,
|
||
showGenerationWarning,
|
||
],
|
||
);
|
||
|
||
const pickPublicationReferenceFromLayer = useCallback(
|
||
(layer: CanvasLayer) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
appendPublicationReference(currentDialog, layer),
|
||
);
|
||
setIsPickingPublicationReferenceFromCanvas(false);
|
||
setIsPublicationReferenceMenuOpen(false);
|
||
setImageContextMenu(null);
|
||
},
|
||
[setGenerateDialog, setImageContextMenu],
|
||
);
|
||
|
||
const updateIconDescriptionsText = useCallback(
|
||
(value: string) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
updateIconDescriptionsTextInDialog(currentDialog, value),
|
||
);
|
||
},
|
||
[setGenerateDialog],
|
||
);
|
||
|
||
const rememberImageModel = useCallback((model: string) => {
|
||
setRememberedImageOptions((currentOptions) =>
|
||
normalizeRememberedImageGenerationOptions({
|
||
...currentOptions,
|
||
model,
|
||
}),
|
||
);
|
||
}, []);
|
||
const generationSubmissionWorkflow =
|
||
useImageCanvasGenerationSubmissionWorkflow({
|
||
layers,
|
||
canvasSize,
|
||
viewport,
|
||
layerCounterRef,
|
||
quickEditPanel,
|
||
quickEditSourceLayer,
|
||
quickEditSelectionState,
|
||
canvasGenerationDialogs,
|
||
setQuickEditPanel,
|
||
characterAnimationPanel: effectiveCharacterAnimationPanel,
|
||
characterAnimationDialog,
|
||
characterAnimationSourceLayer,
|
||
setCharacterAnimationPanel,
|
||
setGenerateDialog,
|
||
openCanvasGenerationDialog,
|
||
updateCanvasGenerationDialogById,
|
||
hasCanvasGenerationDialogById,
|
||
getGeneratingDialogPlaceholder,
|
||
appendCanvasLayersWithResources,
|
||
captureCanvasHistory,
|
||
updateSourceLayer,
|
||
selectSingleLayer,
|
||
fitLayers,
|
||
setActiveTool,
|
||
setActiveSidebarPanel,
|
||
rememberImageModel,
|
||
projectId,
|
||
assetFolderId,
|
||
upsertGeneratedAsset,
|
||
applyProjectSnapshot,
|
||
onQueuedGenerationTask: refreshTaskListForQueuedGeneration,
|
||
onWalletBalanceMayHaveChanged,
|
||
onGenerationWarning: showGenerationWarning,
|
||
});
|
||
|
||
const setEffectiveCharacterAnimationPanel: Dispatch<
|
||
SetStateAction<CharacterAnimationPanelState | null>
|
||
> = useCallback(
|
||
(updater) => {
|
||
setGenerateDialog((currentDialog) => {
|
||
if (
|
||
!isCanvasGenerationDialog(currentDialog) ||
|
||
currentDialog.mode !== 'character-animation'
|
||
) {
|
||
return currentDialog;
|
||
}
|
||
const currentPanel =
|
||
createCharacterAnimationPanelFromDialog(currentDialog);
|
||
const nextPanel =
|
||
typeof updater === 'function' ? updater(currentPanel) : updater;
|
||
return applyCharacterAnimationPanelToDialog(currentDialog, nextPanel);
|
||
});
|
||
setCharacterAnimationPanel((currentPanel) => {
|
||
if (characterAnimationDialog) {
|
||
return currentPanel;
|
||
}
|
||
return typeof updater === 'function' ? updater(currentPanel) : updater;
|
||
});
|
||
},
|
||
[characterAnimationDialog, setGenerateDialog],
|
||
);
|
||
const {
|
||
extractUiDesignAssets: submitUiDesignAssetExtraction,
|
||
submitCharacterAnimation,
|
||
submitIconSpritesheetGeneration,
|
||
submitImageGeneration,
|
||
submitQuickEdit,
|
||
} = generationSubmissionWorkflow;
|
||
|
||
const extractUiDesignAssets = useCallback(
|
||
(sourceLayer: CanvasLayer) => {
|
||
if (sourceLayer.assetKind !== 'ui-design') {
|
||
return;
|
||
}
|
||
closeGenerationTransientState();
|
||
setMetadataLayer(null);
|
||
setQuickEditPanel(null);
|
||
setCropExpandPanel(null);
|
||
setCharacterAnimationPanel(null);
|
||
selectSingleLayer(sourceLayer.id);
|
||
setActiveTool('select');
|
||
setUiAssetExtractionState(
|
||
createUiAssetExtractionState(sourceLayer.id, {
|
||
initialTool: 'rect',
|
||
model: DEFAULT_IMAGE_MODEL,
|
||
}),
|
||
);
|
||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||
},
|
||
[
|
||
canvasSize,
|
||
closeGenerationTransientState,
|
||
selectSingleLayer,
|
||
setActiveTool,
|
||
setCharacterAnimationPanel,
|
||
setCropExpandPanel,
|
||
setMetadataLayer,
|
||
setQuickEditPanel,
|
||
setViewport,
|
||
],
|
||
);
|
||
|
||
const changeUiAssetExtractionTool = useCallback(
|
||
(tool: UiAssetExtractionTool | null) => {
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState
|
||
? {
|
||
...currentState,
|
||
tool: currentState.tool === tool ? null : tool,
|
||
draftMark: null,
|
||
status:
|
||
currentState.status === 'failed' ? 'idle' : currentState.status,
|
||
errorMessage:
|
||
currentState.status === 'failed'
|
||
? undefined
|
||
: currentState.errorMessage,
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const changeUiAssetExtractionModel = useCallback(
|
||
(model: string) => {
|
||
const normalizedModel = normalizeEditorImageModel(model);
|
||
rememberImageModel(normalizedModel);
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState
|
||
? {
|
||
...currentState,
|
||
model: normalizedModel,
|
||
status:
|
||
currentState.status === 'failed' ? 'idle' : currentState.status,
|
||
errorMessage:
|
||
currentState.status === 'failed'
|
||
? undefined
|
||
: currentState.errorMessage,
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[rememberImageModel],
|
||
);
|
||
|
||
const appendUiAssetExtractionReferences = useCallback(
|
||
(references: CharacterReferenceImage[]) => {
|
||
if (!references.length) {
|
||
return;
|
||
}
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState
|
||
? {
|
||
...currentState,
|
||
references: [...currentState.references, ...references],
|
||
status:
|
||
currentState.status === 'failed' ? 'idle' : currentState.status,
|
||
errorMessage:
|
||
currentState.status === 'failed'
|
||
? undefined
|
||
: currentState.errorMessage,
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const removeUiAssetExtractionReference = useCallback(
|
||
(referenceId: string) => {
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState
|
||
? {
|
||
...currentState,
|
||
references: currentState.references.filter(
|
||
(reference) => reference.id !== referenceId,
|
||
),
|
||
status:
|
||
currentState.status === 'failed' ? 'idle' : currentState.status,
|
||
errorMessage:
|
||
currentState.status === 'failed'
|
||
? undefined
|
||
: currentState.errorMessage,
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const changeQuickEditSelectionTool = useCallback(
|
||
(tool: UiAssetExtractionTool | null) => {
|
||
updateQuickEditSelectionState((currentState) =>
|
||
currentState
|
||
? {
|
||
...currentState,
|
||
tool: currentState.tool === tool ? null : tool,
|
||
draftMark: null,
|
||
status: 'idle',
|
||
errorMessage: undefined,
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[updateQuickEditSelectionState],
|
||
);
|
||
|
||
const startUiAssetExtractionPointer = useCallback(
|
||
(point: { x: number; y: number }) => {
|
||
setUiAssetExtractionState((currentState) => {
|
||
if (
|
||
!currentState ||
|
||
!currentState.tool ||
|
||
currentState.status === 'extracting'
|
||
) {
|
||
return currentState;
|
||
}
|
||
uiAssetExtractionMarkCounterRef.current += 1;
|
||
return {
|
||
...currentState,
|
||
draftMark: createUiAssetExtractionDraftMark({
|
||
tool: currentState.tool,
|
||
id: `ui-extraction-mark-${uiAssetExtractionMarkCounterRef.current}`,
|
||
point,
|
||
}),
|
||
status: 'idle',
|
||
errorMessage: undefined,
|
||
};
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const startQuickEditSelectionPointer = useCallback(
|
||
(point: { x: number; y: number }) => {
|
||
updateQuickEditSelectionState((currentState) => {
|
||
if (!currentState || !currentState.tool) {
|
||
return currentState;
|
||
}
|
||
quickEditSelectionMarkCounterRef.current += 1;
|
||
return {
|
||
...currentState,
|
||
draftMark: createUiAssetExtractionDraftMark({
|
||
tool: currentState.tool,
|
||
id: `quick-edit-selection-mark-${quickEditSelectionMarkCounterRef.current}`,
|
||
point,
|
||
}),
|
||
status: 'idle',
|
||
errorMessage: undefined,
|
||
};
|
||
});
|
||
},
|
||
[updateQuickEditSelectionState],
|
||
);
|
||
|
||
const moveUiAssetExtractionPointer = useCallback(
|
||
(point: { x: number; y: number }) => {
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState?.draftMark
|
||
? {
|
||
...currentState,
|
||
draftMark: updateUiAssetExtractionDraftMark(
|
||
currentState.draftMark,
|
||
point,
|
||
),
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const moveQuickEditSelectionPointer = useCallback(
|
||
(point: { x: number; y: number }) => {
|
||
updateQuickEditSelectionState((currentState) =>
|
||
currentState?.draftMark
|
||
? {
|
||
...currentState,
|
||
draftMark: updateUiAssetExtractionDraftMark(
|
||
currentState.draftMark,
|
||
point,
|
||
),
|
||
}
|
||
: currentState,
|
||
);
|
||
},
|
||
[updateQuickEditSelectionState],
|
||
);
|
||
|
||
const endUiAssetExtractionPointer = useCallback(() => {
|
||
setUiAssetExtractionState((currentState) => {
|
||
if (!currentState?.draftMark) {
|
||
return currentState;
|
||
}
|
||
const normalizedMark = normalizeUiAssetExtractionMark(
|
||
currentState.draftMark,
|
||
);
|
||
return {
|
||
...currentState,
|
||
draftMark: null,
|
||
marks: normalizedMark
|
||
? [...currentState.marks, normalizedMark]
|
||
: currentState.marks,
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const cancelUiAssetExtractionPointer = useCallback(() => {
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState?.draftMark
|
||
? {
|
||
...currentState,
|
||
draftMark: null,
|
||
}
|
||
: currentState,
|
||
);
|
||
}, []);
|
||
|
||
const endQuickEditSelectionPointer = useCallback(() => {
|
||
const currentSelectionState = quickEditSelectionStateRef.current;
|
||
if (!currentSelectionState?.draftMark) {
|
||
return;
|
||
}
|
||
const normalizedMark = normalizeUiAssetExtractionMark(
|
||
currentSelectionState.draftMark,
|
||
);
|
||
if (!normalizedMark) {
|
||
updateQuickEditSelectionState(() => ({
|
||
...currentSelectionState,
|
||
draftMark: null,
|
||
}));
|
||
return;
|
||
}
|
||
const nextMarkNumber = currentSelectionState.marks.length + 1;
|
||
updateQuickEditSelectionState(() => ({
|
||
...currentSelectionState,
|
||
draftMark: null,
|
||
marks: [...currentSelectionState.marks, normalizedMark],
|
||
}));
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel?.sourceLayerId === currentSelectionState.sourceLayerId
|
||
? {
|
||
...currentPanel,
|
||
status:
|
||
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
prompt: appendQuickEditSelectionPrompt(
|
||
currentPanel.prompt,
|
||
nextMarkNumber,
|
||
),
|
||
}
|
||
: currentPanel,
|
||
);
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog?.mode === 'quick-edit' &&
|
||
currentDialog.sourceLayerId === currentSelectionState.sourceLayerId
|
||
? {
|
||
...currentDialog,
|
||
status:
|
||
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
|
||
errorMessage:
|
||
currentDialog.status === 'failed'
|
||
? undefined
|
||
: currentDialog.errorMessage,
|
||
prompt: appendQuickEditSelectionPrompt(
|
||
currentDialog.prompt,
|
||
nextMarkNumber,
|
||
),
|
||
composerOpen: true,
|
||
}
|
||
: currentDialog,
|
||
);
|
||
}, [setGenerateDialog, setQuickEditPanel, updateQuickEditSelectionState]);
|
||
|
||
const cancelQuickEditSelectionPointer = useCallback(() => {
|
||
updateQuickEditSelectionState((currentState) =>
|
||
currentState?.draftMark
|
||
? {
|
||
...currentState,
|
||
draftMark: null,
|
||
}
|
||
: currentState,
|
||
);
|
||
}, [updateQuickEditSelectionState]);
|
||
|
||
useEffect(() => {
|
||
if (!quickEditSelectionState) {
|
||
return;
|
||
}
|
||
const hasActiveQuickEditDialog =
|
||
generateDialog?.mode === 'quick-edit' &&
|
||
generateDialog.sourceLayerId === quickEditSelectionState.sourceLayerId;
|
||
if (!hasActiveQuickEditDialog && !quickEditPanel) {
|
||
quickEditSelectionStateRef.current = null;
|
||
setQuickEditSelectionState(null);
|
||
}
|
||
}, [generateDialog, quickEditPanel, quickEditSelectionState]);
|
||
|
||
const cancelUiAssetExtraction = useCallback(() => {
|
||
setUiAssetExtractionState(null);
|
||
}, []);
|
||
|
||
const submitUiAssetExtraction = useCallback(async () => {
|
||
const currentState = uiAssetExtractionState;
|
||
const sourceLayer = uiAssetExtractionSourceLayer;
|
||
if (!currentState || !sourceLayer || !currentState.marks.length) {
|
||
return;
|
||
}
|
||
setUiAssetExtractionState({
|
||
...currentState,
|
||
status: 'extracting',
|
||
errorMessage: undefined,
|
||
});
|
||
try {
|
||
await submitUiDesignAssetExtraction(sourceLayer, {
|
||
marks: currentState.marks,
|
||
model: currentState.model,
|
||
references: currentState.references,
|
||
suppressAlert: true,
|
||
});
|
||
setUiAssetExtractionState(null);
|
||
} catch (error) {
|
||
setUiAssetExtractionState((latestState) =>
|
||
latestState?.sourceLayerId === currentState.sourceLayerId
|
||
? {
|
||
...latestState,
|
||
status: 'failed',
|
||
errorMessage:
|
||
error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '提取素材失败',
|
||
}
|
||
: latestState,
|
||
);
|
||
}
|
||
}, [
|
||
submitUiDesignAssetExtraction,
|
||
uiAssetExtractionSourceLayer,
|
||
uiAssetExtractionState,
|
||
]);
|
||
|
||
const updateSpecFormValue = useCallback(
|
||
(key: keyof SpecFormValues, value: string) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
updateSpecFormDialogValue(currentDialog, key, value),
|
||
);
|
||
},
|
||
[setGenerateDialog],
|
||
);
|
||
|
||
const updateCharacterAnimationDuration = useCallback(
|
||
(frameCountValue: string) => {
|
||
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
|
||
(item) => String(item.frameCount) === frameCountValue,
|
||
);
|
||
if (option) {
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog?.mode === 'character-animation'
|
||
? {
|
||
...currentDialog,
|
||
characterAnimationFrameCount: option.frameCount,
|
||
characterAnimationDurationSeconds: option.durationSeconds,
|
||
status:
|
||
currentDialog.status === 'failed'
|
||
? 'idle'
|
||
: currentDialog.status,
|
||
errorMessage:
|
||
currentDialog.status === 'failed'
|
||
? undefined
|
||
: currentDialog.errorMessage,
|
||
}
|
||
: currentDialog,
|
||
);
|
||
}
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
updateCharacterAnimationDurationPanel(currentPanel, frameCountValue),
|
||
);
|
||
},
|
||
[setGenerateDialog],
|
||
);
|
||
|
||
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
|
||
setGenerateDialog((currentDialog) =>
|
||
hideGeneratedLayerComposerAfterBlur(currentDialog),
|
||
);
|
||
}, [setGenerateDialog]);
|
||
|
||
const closeGenerateComposer = useCallback(() => {
|
||
setGenerateDialog(closeGenerateComposerDialog);
|
||
setActiveTool('select');
|
||
}, [setActiveTool, setGenerateDialog]);
|
||
|
||
const clearDeletedLayerGenerationState = useCallback(
|
||
(targetLayerId: string) => {
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
||
);
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
quickEditReferences: (
|
||
currentPanel.quickEditReferences ?? []
|
||
).filter(
|
||
(reference) => reference.id !== `canvas-${targetLayerId}`,
|
||
),
|
||
}
|
||
: currentPanel,
|
||
);
|
||
setCropExpandPanel((currentPanel) =>
|
||
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
||
);
|
||
setUiAssetExtractionState((currentState) =>
|
||
currentState?.sourceLayerId === targetLayerId ? null : currentState,
|
||
);
|
||
setQuickEditSelectionState((currentState) =>
|
||
currentState?.sourceLayerId === targetLayerId ? null : currentState,
|
||
);
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
||
);
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog?.mode === 'edit' &&
|
||
currentDialog.sourceLayerId === targetLayerId
|
||
? null
|
||
: currentDialog,
|
||
);
|
||
removeCanvasGenerationDialogsByLayerId(targetLayerId);
|
||
},
|
||
[removeCanvasGenerationDialogsByLayerId, setGenerateDialog],
|
||
);
|
||
|
||
return useMemo(
|
||
() => ({
|
||
quickEditPanel,
|
||
setQuickEditPanel,
|
||
quickEditSourceLayer,
|
||
cropExpandPanel,
|
||
setCropExpandPanel,
|
||
cropExpandSourceLayer,
|
||
uiAssetExtractionState,
|
||
uiAssetExtractionSourceLayer,
|
||
quickEditSelectionState,
|
||
quickEditSelectionSourceLayer,
|
||
changeUiAssetExtractionTool,
|
||
changeUiAssetExtractionModel,
|
||
appendUiAssetExtractionReferences,
|
||
removeUiAssetExtractionReference,
|
||
changeQuickEditSelectionTool,
|
||
startUiAssetExtractionPointer,
|
||
startQuickEditSelectionPointer,
|
||
moveUiAssetExtractionPointer,
|
||
moveQuickEditSelectionPointer,
|
||
endUiAssetExtractionPointer,
|
||
endQuickEditSelectionPointer,
|
||
cancelUiAssetExtractionPointer,
|
||
cancelQuickEditSelectionPointer,
|
||
cancelUiAssetExtraction,
|
||
submitUiAssetExtraction,
|
||
characterAnimationPanel: effectiveCharacterAnimationPanel,
|
||
setCharacterAnimationPanel: setEffectiveCharacterAnimationPanel,
|
||
characterAnimationSourceLayer,
|
||
characterAnimationPrice,
|
||
isSpecMenuOpen,
|
||
setIsSpecMenuOpen,
|
||
isGenerationReferenceMenuOpen,
|
||
setIsGenerationReferenceMenuOpen,
|
||
isCharacterSpecMenuOpen,
|
||
setIsCharacterSpecMenuOpen,
|
||
isCharacterReferenceMenuOpen,
|
||
setIsCharacterReferenceMenuOpen,
|
||
isPickingGenerationReferenceFromCanvas,
|
||
setIsPickingGenerationReferenceFromCanvas,
|
||
isPickingQuickEditReferenceFromCanvas,
|
||
setIsPickingQuickEditReferenceFromCanvas,
|
||
isPickingCharacterSpecFromCanvas,
|
||
setIsPickingCharacterSpecFromCanvas,
|
||
isPickingCharacterReferenceFromCanvas,
|
||
setIsPickingCharacterReferenceFromCanvas,
|
||
isIconSpecMenuOpen,
|
||
setIsIconSpecMenuOpen,
|
||
isPickingIconSpecFromCanvas,
|
||
setIsPickingIconSpecFromCanvas,
|
||
isUiDesignSpecMenuOpen,
|
||
setIsUiDesignSpecMenuOpen,
|
||
isPickingUiDesignSpecFromCanvas,
|
||
setIsPickingUiDesignSpecFromCanvas,
|
||
isMusicMenuOpen,
|
||
setIsMusicMenuOpen,
|
||
isPublicationMenuOpen,
|
||
setIsPublicationMenuOpen,
|
||
isPublicationReferenceMenuOpen,
|
||
setIsPublicationReferenceMenuOpen,
|
||
isPickingPublicationReferenceFromCanvas,
|
||
setIsPickingPublicationReferenceFromCanvas,
|
||
generationWarning,
|
||
generationWarningVersion,
|
||
showGenerationWarning,
|
||
clearGenerationWarning: () => setGenerationWarning(null),
|
||
openGenerateDialog,
|
||
openSpecDialog,
|
||
openCharacterAnimationPanel,
|
||
openCharacterGenerationDialog,
|
||
openIconGenerationDialog,
|
||
openPublicationGenerationDialog,
|
||
openVideoGenerationDialog,
|
||
openUiDesignGenerationDialog,
|
||
openSoundEffectGenerationDialog,
|
||
openBackgroundMusicGenerationDialog,
|
||
openEditDialog,
|
||
openQuickEditPanel,
|
||
openRedrawPanel,
|
||
openCropExpandPanel,
|
||
startCropExpandFrameResize,
|
||
removeSelectedLayerBackground,
|
||
snapSelectedLayerToPerfectPixels,
|
||
retryPerfectPixelOperation,
|
||
activeInlineGenerationDialogOwnership,
|
||
perfectPixelLayerIds,
|
||
pendingPerfectPixelLayerIds,
|
||
splitSelectedIconSpritesheet,
|
||
splittingIconSpritesheetLayerIds,
|
||
taskListRefreshKey,
|
||
refreshTaskList,
|
||
isTaskSidebarOpen,
|
||
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
|
||
extractUiDesignAssets,
|
||
pickCharacterSpecFromLayer,
|
||
pickGenerationReferenceFromLayer,
|
||
pickQuickEditReferenceFromLayer,
|
||
pickCharacterReferenceFromLayer,
|
||
pickIconSpecFromLayer,
|
||
pickUiDesignSpecFromLayer,
|
||
pickPublicationReferenceFromLayer,
|
||
submitIconSpritesheetGeneration,
|
||
submitQuickEdit,
|
||
submitCropExpand,
|
||
submitImageGeneration,
|
||
updateSpecFormValue,
|
||
updateIconDescriptionsText,
|
||
updateCharacterAnimationDuration,
|
||
rememberImageModel,
|
||
submitCharacterAnimation,
|
||
hideGeneratedLayerPanelAfterBlur,
|
||
closeGenerateComposer,
|
||
clearDeletedLayerGenerationState,
|
||
}),
|
||
[
|
||
effectiveCharacterAnimationPanel,
|
||
characterAnimationPrice,
|
||
characterAnimationSourceLayer,
|
||
clearDeletedLayerGenerationState,
|
||
closeGenerateComposer,
|
||
cropExpandPanel,
|
||
cropExpandSourceLayer,
|
||
uiAssetExtractionState,
|
||
uiAssetExtractionSourceLayer,
|
||
quickEditSelectionState,
|
||
quickEditSelectionSourceLayer,
|
||
changeUiAssetExtractionTool,
|
||
changeUiAssetExtractionModel,
|
||
appendUiAssetExtractionReferences,
|
||
removeUiAssetExtractionReference,
|
||
changeQuickEditSelectionTool,
|
||
startUiAssetExtractionPointer,
|
||
startQuickEditSelectionPointer,
|
||
moveUiAssetExtractionPointer,
|
||
moveQuickEditSelectionPointer,
|
||
endUiAssetExtractionPointer,
|
||
endQuickEditSelectionPointer,
|
||
cancelUiAssetExtractionPointer,
|
||
cancelQuickEditSelectionPointer,
|
||
cancelUiAssetExtraction,
|
||
submitUiAssetExtraction,
|
||
setEffectiveCharacterAnimationPanel,
|
||
hideGeneratedLayerPanelAfterBlur,
|
||
isCharacterReferenceMenuOpen,
|
||
isCharacterSpecMenuOpen,
|
||
isIconSpecMenuOpen,
|
||
isGenerationReferenceMenuOpen,
|
||
isPickingCharacterReferenceFromCanvas,
|
||
isPickingCharacterSpecFromCanvas,
|
||
isPickingGenerationReferenceFromCanvas,
|
||
isPickingQuickEditReferenceFromCanvas,
|
||
isPickingIconSpecFromCanvas,
|
||
isPickingUiDesignSpecFromCanvas,
|
||
isUiDesignSpecMenuOpen,
|
||
isMusicMenuOpen,
|
||
isTaskSidebarOpen,
|
||
taskListRefreshKey,
|
||
refreshTaskList,
|
||
isPublicationMenuOpen,
|
||
isPublicationReferenceMenuOpen,
|
||
isSpecMenuOpen,
|
||
isPickingPublicationReferenceFromCanvas,
|
||
generationWarning,
|
||
generationWarningVersion,
|
||
activeInlineGenerationDialogOwnership,
|
||
openBackgroundMusicGenerationDialog,
|
||
openCharacterAnimationPanel,
|
||
openCharacterGenerationDialog,
|
||
openCropExpandPanel,
|
||
openEditDialog,
|
||
extractUiDesignAssets,
|
||
openGenerateDialog,
|
||
openIconGenerationDialog,
|
||
openPublicationGenerationDialog,
|
||
openRedrawPanel,
|
||
openUiDesignGenerationDialog,
|
||
openQuickEditPanel,
|
||
openSpecDialog,
|
||
startCropExpandFrameResize,
|
||
openSoundEffectGenerationDialog,
|
||
openVideoGenerationDialog,
|
||
pickCharacterReferenceFromLayer,
|
||
pickCharacterSpecFromLayer,
|
||
pickGenerationReferenceFromLayer,
|
||
pickQuickEditReferenceFromLayer,
|
||
pickIconSpecFromLayer,
|
||
pickPublicationReferenceFromLayer,
|
||
pickUiDesignSpecFromLayer,
|
||
quickEditPanel,
|
||
quickEditSourceLayer,
|
||
removeSelectedLayerBackground,
|
||
retryPerfectPixelOperation,
|
||
snapSelectedLayerToPerfectPixels,
|
||
perfectPixelLayerIds,
|
||
pendingPerfectPixelLayerIds,
|
||
splitSelectedIconSpritesheet,
|
||
splittingIconSpritesheetLayerIds,
|
||
submitCharacterAnimation,
|
||
submitCropExpand,
|
||
submitIconSpritesheetGeneration,
|
||
submitImageGeneration,
|
||
submitQuickEdit,
|
||
updateCharacterAnimationDuration,
|
||
updateIconDescriptionsText,
|
||
updateSpecFormValue,
|
||
rememberImageModel,
|
||
showGenerationWarning,
|
||
],
|
||
);
|
||
}
|