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