diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx new file mode 100644 index 000000000..3fc6544b7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx @@ -0,0 +1,2821 @@ +/* eslint-disable react-refresh/only-export-components -- The surface exports its host callback contracts and deterministic fixture helpers for integration tests. */ + +import './assetCanvasSurface.css'; + +import { + type CanvasHistoryAction, + type CanvasHistorySnapshot, + type CanvasLayer, + type CanvasViewport, + createMinimapModel, + fitViewportToLayers, + type ImageCanvasDraft, + type ImageCanvasDraftCanvas, + type ImageCanvasGenerationProgress, + type ImageCanvasGenerationProgressPhase, + type ImageCanvasGenerationServiceIdentityConfirmation, + type ImageCanvasHostScope, + type ImageCanvasMediaRef, + moveViewportFromMinimapPointer, + moveViewportFromPan, + removeCanvasLayers, + resizeCanvasLayerBounds, + resolveLayerPointerSelection, + resolveViewportFromWheel, + scaleViewportFromScreenPoint, + transformCanvasLayers, +} from '@genarrative/image-canvas-core'; +import { + CanvasChromeButton, + CanvasToolbar, + CanvasToolbarDivider, + CanvasToolbarGroup, + CanvasViewport as SharedCanvasViewport, + CanvasWorld, + LayerRenderer, + Minimap, + useCanvasHistory, + ZoomControls, +} from '@genarrative/image-canvas-react'; +import { + ArrowLeft, + Check, + ImagePlus, + Maximize2, + Minus, + Plus, + Redo2, + RotateCcw, + Save, + Sparkles, + Trash2, + Undo2, + Upload, + X, +} from 'lucide-react'; +import { + type ChangeEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { useWalletStore } from '../../stores/useWalletStore'; +import type { + LocalAssetCommittedEvent, + TauriImageCanvasHostAdapter, +} from './tauriImageCanvasHostAdapter'; + +export type AssetCanvasLifecycleState = + | { kind: 'canvas.creating' } + | { kind: 'canvas.editing'; dirty: boolean } + | { + kind: 'canvas.saving'; + stage: 'draft' | 'staging' | 'committing' | 'projecting'; + } + | { kind: 'canvas.recovering' } + | { + kind: 'canvas.generating'; + phase: ImageCanvasGenerationProgressPhase; + } + | { + kind: 'canvas.failed'; + operation: + | 'generation' + | 'draft-save' + | 'asset-commit' + | 'recovery' + | 'cancellation'; + code: string; + message: string; + reconciliationRequired: boolean; + }; + +export type AssetCanvasSaveAttempt = { + saveAttemptId: string; + sessionId: string; + projectId: string; + draftId: string; + commitId: string; +}; + +export type AssetCanvasExitResult = { + draftId: string; + disposition: 'kept' | 'discarded'; +}; + +export type AssetCanvasCommitNotification = { + source: 'command' | 'event'; + projectPath: string; + projectId: string; + draftId: string; + commitId: string; + assetId: string; + manifest: GameCreationAppManifest; + projectRevision: number; + committedProjectRevision: number; + eventId?: string; +}; + +export const ASSET_CANVAS_KIND_OPTIONS = [ + { value: 'game-art', label: '普通游戏美术' }, + { value: 'icon-spec', label: '统一视觉规范' }, + { value: 'ui-prototype', label: '游戏界面原型' }, + { value: 'art-spritesheet', label: '核心美术图集' }, +] as const; + +type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef }; + +type PendingGenerationIdentity = { + saveAttemptId: string; + intentId: string; + generationId: string; + idempotencyKey: string; + commitId: string; + commitIdempotencyKey: string; +}; + +type DragState = + | { + kind: 'pan'; + pointerId: number; + startClientX: number; + startClientY: number; + startViewport: CanvasViewport; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: boolean; + } + | { + kind: 'move'; + pointerId: number; + startClientX: number; + startClientY: number; + startLayers: RuntimeCanvasLayer[]; + targetIds: string[]; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: boolean; + } + | { + kind: 'resize'; + pointerId: number; + startClientX: number; + startClientY: number; + startLayers: RuntimeCanvasLayer[]; + layerId: string; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: false; + }; + +function equalSelectionIds(left: string[], right: string[]) { + return ( + left.length === right.length && + left.every((selectionId, index) => selectionId === right[index]) + ); +} + +function draftMatchesScope( + draft: ImageCanvasDraft, + scope: ImageCanvasHostScope, +) { + return ( + draft.projectId === scope.projectId && + draft.draftId === scope.draftId && + draft.intent === scope.intent && + draft.sourceAssetId === (scope.sourceAssetId ?? null) + ); +} + +export function shouldApplyAssetCanvasDraftCandidate({ + current, + candidate, + minimumRevision, + scope, +}: { + current: ImageCanvasDraft; + candidate: ImageCanvasDraft; + minimumRevision: number; + scope: ImageCanvasHostScope; +}) { + if ( + !draftMatchesScope(candidate, scope) || + candidate.revision < minimumRevision || + candidate.revision < current.revision + ) { + return false; + } + if (candidate.revision > current.revision) { + return true; + } + return JSON.stringify(candidate) === JSON.stringify(current); +} + +const generationPhaseLabels: Record< + ImageCanvasGenerationProgressPhase, + string +> = { + 'confirmation-required': '正在确认生成请求', + 'generation-accepted': '请求已受理,正在排队', + 'generation-running': '正在生成图片', + 'remote-completed': '图片已生成,正在准备下载', + 'media-downloaded': '图片已下载,正在保存到项目', + 'asset-durable-committed': '图片已保存到项目', + 'manifest-projected': '资源已登记,正在更新画布', + 'layout-ready': '资源布局已更新', + selected: '新资源已选中', + failed: '图片生成失败', + 'reconciliation-required': '正在等待原任务恢复', +}; + +const generationPhaseProgress: Record< + ImageCanvasGenerationProgressPhase, + number +> = { + 'confirmation-required': 4, + 'generation-accepted': 12, + 'generation-running': 40, + 'remote-completed': 64, + 'media-downloaded': 84, + 'asset-durable-committed': 100, + 'manifest-projected': 100, + 'layout-ready': 100, + selected: 100, + failed: 0, + 'reconciliation-required': 55, +}; + +function generationFailureTitle(code: string) { + if (code === 'authentication-required') return '登录已失效'; + if (code === 'insufficient-mud-points') return '泥点余额不足'; + if (code === 'platform-service-configuration') return '平台生成服务暂不可用'; + if (code === 'reconciliation-required') return '原生成任务需要恢复'; + return '图片生成未完成'; +} + +export function assetCanvasFailurePresentation( + failure: Extract, +) { + if (failure.operation === 'draft-save') { + return { + ariaLabel: '草稿保存失败', + kicker: '草稿保存', + title: + failure.code === 'draft-revision-conflict' + ? '草稿已在其它窗口更新' + : '草稿暂未保存', + }; + } + if (failure.operation === 'asset-commit') { + return { + ariaLabel: '素材提交未完成', + kicker: '素材提交', + title: failure.reconciliationRequired + ? '素材结果需要安全对账' + : '素材提交未完成', + }; + } + if (failure.operation === 'recovery') { + return { + ariaLabel: '原任务恢复未完成', + kicker: '安全恢复', + title: failure.reconciliationRequired + ? '原任务需要对账' + : '原任务恢复未完成', + }; + } + if (failure.operation === 'cancellation') { + return { + ariaLabel: '草稿处置失败', + kicker: '草稿处置', + title: '未能完成草稿处置', + }; + } + return { + ariaLabel: '图片生成失败', + kicker: 'AI 图片生成', + title: generationFailureTitle(failure.code), + }; +} + +function generationRecoveryStateLabel(state: string) { + if (state === 'prepared') return '请求已冻结,尚未确认受理结果'; + if (state === 'accepted') return '平台已受理'; + if (state === 'running') return '平台处理中'; + if (state === 'reconciliation-required') return '等待安全对账'; + return '等待恢复'; +} + +export type RenderAssetCanvasImage = (input: { + layers: RuntimeCanvasLayer[]; + backgroundColor: string; + mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; + quality: number | null; +}) => Promise; + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('图片预览无法用于导出')); + image.src = src; + }); +} + +export async function renderAssetCanvasImage({ + layers, + backgroundColor, + mediaType, + quality, +}: Parameters[0]): Promise { + const visible = layers.filter((layer) => !layer.hidden); + if (!visible.length) throw new Error('画布中没有可导出的图片'); + const minX = Math.floor(Math.min(...visible.map((layer) => layer.x))); + const minY = Math.floor(Math.min(...visible.map((layer) => layer.y))); + const maxX = Math.ceil( + Math.max(...visible.map((layer) => layer.x + layer.width)), + ); + const maxY = Math.ceil( + Math.max(...visible.map((layer) => layer.y + layer.height)), + ); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + if (width > 16_384 || height > 16_384 || width * height > 268_435_456) { + throw new Error('导出尺寸超过素材画布上限'); + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('当前 WebView 不支持画布导出'); + context.fillStyle = backgroundColor; + context.fillRect(0, 0, width, height); + for (const layer of [...visible].sort((a, b) => a.zIndex - b.zIndex)) { + const image = await loadImage(layer.src); + context.save(); + const centerX = layer.x - minX + layer.width / 2; + const centerY = layer.y - minY + layer.height / 2; + context.translate(centerX, centerY); + context.scale(layer.flipX ? -1 : 1, layer.flipY ? -1 : 1); + context.drawImage( + image, + -layer.width / 2, + -layer.height / 2, + layer.width, + layer.height, + ); + context.restore(); + } + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (value) => + value ? resolve(value) : reject(new Error('图片导出编码失败')), + mediaType, + quality ?? undefined, + ); + }); + if (blob.type && blob.type !== mediaType) { + throw new Error('当前 WebView 不支持所选导出格式'); + } + return new Uint8Array(await blob.arrayBuffer()); +} + +function draftCanvasFromRuntime( + layers: RuntimeCanvasLayer[], + viewport: CanvasViewport, + backgroundColor: string, + selectedLayerIds: string[], +): ImageCanvasDraftCanvas { + const visibleSelected = selectedLayerIds.filter((id) => + layers.some((layer) => layer.id === id && !layer.hidden), + ); + return { + viewport: { ...viewport }, + backgroundColor, + layers: layers.map((layer) => ({ + layerId: layer.id, + resourceId: layer.resourceId, + title: layer.title, + mediaRef: layer.mediaRef, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + groupId: layer.groupId ?? null, + hidden: Boolean(layer.hidden), + locked: Boolean(layer.locked), + flipX: Boolean(layer.flipX), + flipY: Boolean(layer.flipY), + })), + selectedLayerIds: visibleSelected, + primarySelectedLayerId: visibleSelected.at(-1) ?? null, + }; +} + +function mediaTypeForFile(file: File) { + if (file.type === 'image/png') return 'image/png' as const; + if (file.type === 'image/jpeg') return 'image/jpeg' as const; + if (file.type === 'image/webp') return 'image/webp' as const; + return null; +} + +export function AssetCanvasSurface({ + host, + scope, + sessionId, + expectedHostRevision, + initialAssetName = '画布素材', + initialAssetKind = 'game-art', + onCancel, + onCommitted, + onSaveAttempt, + renderImage = renderAssetCanvasImage, +}: { + host: TauriImageCanvasHostAdapter; + scope: ImageCanvasHostScope; + sessionId: string; + expectedHostRevision: string; + initialAssetName?: string; + initialAssetKind?: string; + onCancel?: (result: AssetCanvasExitResult) => void; + onCommitted?: (input: AssetCanvasCommitNotification) => void; + onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void; + renderImage?: RenderAssetCanvasImage; +}) { + const resolvedInitialAssetName = initialAssetName.trim() || '画布素材'; + const resolvedInitialAssetKind = initialAssetKind.trim() || 'game-art'; + const stableScope = useMemo( + () => ({ + projectId: scope.projectId, + draftId: scope.draftId, + intent: scope.intent, + sourceAssetId: scope.sourceAssetId, + }), + [scope.draftId, scope.intent, scope.projectId, scope.sourceAssetId], + ); + const [lifecycle, setLifecycle] = useState({ + kind: 'canvas.recovering', + }); + const [draft, setDraft] = useState(null); + const [layers, setLayers] = useState([]); + const [viewport, setViewport] = useState({ + x: 0, + y: 0, + scale: 0.5, + }); + const [backgroundColor, setBackgroundColor] = useState('#f4f4f5'); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const [notice, setNotice] = useState(''); + const [assetName, setAssetName] = useState(resolvedInitialAssetName); + const [assetKind, setAssetKind] = useState(resolvedInitialAssetKind); + const [exportMediaType, setExportMediaType] = useState< + 'image/png' | 'image/jpeg' | 'image/webp' + >('image/png'); + const [generationDialog, setGenerationDialog] = useState< + 'edit' | 'confirm' | null + >(null); + const [generationPrompt, setGenerationPrompt] = useState(''); + const [generationAspectRatio, setGenerationAspectRatio] = useState< + '1:1' | '2:3' | '3:2' | '9:16' | '16:9' + >('1:1'); + const [generationImageSize, setGenerationImageSize] = useState< + '0.5K' | '1K' | '2K' + >('1K'); + const [generationReferenceResourceIds, setGenerationReferenceResourceIds] = + useState([]); + const [exitDialogOpen, setExitDialogOpen] = useState(false); + const [exitActionPending, setExitActionPending] = useState(false); + const [serviceIdentityConfirmations, setServiceIdentityConfirmations] = + useState([]); + const [serviceIdentityDialogOpen, setServiceIdentityDialogOpen] = + useState(false); + const [serviceIdentityPending, setServiceIdentityPending] = useState(false); + const [serviceIdentityError, setServiceIdentityError] = useState(''); + const [documentVersion, setDocumentVersion] = useState(0); + const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); + const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const viewportElementRef = useRef(null); + const importInputRef = useRef(null); + const layersRef = useRef(layers); + const viewportRef = useRef(viewport); + const backgroundRef = useRef(backgroundColor); + const selectionRef = useRef(selectedLayerIds); + const draftRef = useRef(draft); + const lifecycleRef = useRef(lifecycle); + const documentVersionRef = useRef(documentVersion); + const persistedDocumentVersionRef = useRef(0); + const minimumDraftRevisionRef = useRef(draft?.revision ?? 0); + const epochRef = useRef(0); + const dragRef = useRef(null); + const saveQueueRef = useRef>(Promise.resolve()); + const savePromiseRef = useRef | null>(null); + const hostRevisionRef = useRef(expectedHostRevision); + const previewUrlsRef = useRef(new Set()); + const deliveredEventsRef = useRef(new Set()); + const pendingCommitRef = useRef<{ + commitId: string; + idempotencyKey: string; + documentVersion: number; + } | null>(null); + const pendingGenerationRef = useRef(null); + const generationStartingRef = useRef(false); + const generationFocusEpochRef = useRef(0); + const generationStopButtonRef = useRef(null); + const modalInitialFocusRef = useRef(null); + const generationDialogRef = useRef(generationDialog); + const exitDialogOpenRef = useRef(exitDialogOpen); + const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen); + const modalOpen = + generationDialog !== null || exitDialogOpen || serviceIdentityDialogOpen; + const backgroundInteractionLocked = + lifecycle.kind !== 'canvas.editing' || modalOpen; + const backgroundInteractionLockedRef = useRef(backgroundInteractionLocked); + const onWalletBalanceMayHaveChanged = useWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const assetSettingsDisabled = backgroundInteractionLocked; + const inheritedUnknownAssetKind = + stableScope.intent === 'refine' && + !ASSET_CANVAS_KIND_OPTIONS.some((option) => option.value === assetKind); + + layersRef.current = layers; + viewportRef.current = viewport; + backgroundRef.current = backgroundColor; + selectionRef.current = selectedLayerIds; + draftRef.current = draft; + lifecycleRef.current = lifecycle; + generationDialogRef.current = generationDialog; + exitDialogOpenRef.current = exitDialogOpen; + serviceIdentityDialogOpenRef.current = serviceIdentityDialogOpen; + backgroundInteractionLockedRef.current = backgroundInteractionLocked; + documentVersionRef.current = documentVersion; + + const markDirty = useCallback(() => { + if (backgroundInteractionLockedRef.current) return; + setDocumentVersion((value) => { + const next = value + 1; + documentVersionRef.current = next; + return next; + }); + setLifecycle({ kind: 'canvas.editing', dirty: true }); + }, []); + + const applyDraftCandidate = useCallback( + (candidate: ImageCanvasDraft) => { + const current = draftRef.current; + if ( + !current || + !shouldApplyAssetCanvasDraftCandidate({ + current, + candidate, + minimumRevision: minimumDraftRevisionRef.current, + scope: stableScope, + }) + ) { + return false; + } + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + candidate.revision, + ); + draftRef.current = candidate; + setDraft(candidate); + return true; + }, + [stableScope], + ); + + const applyGenerationProgressRevision = useCallback( + (progress: ImageCanvasGenerationProgress) => { + const revision = progress.draftRevision; + const currentDraft = draftRef.current; + if ( + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 || + !currentDraft || + revision <= currentDraft.revision || + revision <= minimumDraftRevisionRef.current + ) { + return; + } + const epoch = epochRef.current; + void host.project.loadDraft(stableScope).then((loaded) => { + if ( + epoch !== epochRef.current || + loaded.status !== 'ok' || + !loaded.value + ) { + return; + } + applyDraftCandidate(loaded.value); + }); + }, + [applyDraftCandidate, host.project, stableScope], + ); + + const canvasHistoryRefs = useMemo( + () => ({ + layersRef, + viewportRef, + selectedLayerIdsRef: selectionRef, + }), + [], + ); + const canvasHistorySetters = useMemo( + () => ({ + setLayers: (nextLayers: CanvasLayer[]) => + setLayers(nextLayers as RuntimeCanvasLayer[]), + setViewport, + setSelectedLayerIds, + }), + [], + ); + const { + canUndo, + canRedo, + getCanvasHistorySnapshot, + captureCanvasHistory, + undoCanvasChange, + redoCanvasChange, + resetCanvasHistory, + } = useCanvasHistory({ + refs: canvasHistoryRefs, + setters: canvasHistorySetters, + allowContentRemovalOnRestore: true, + }); + const captureHistory = useCallback( + (action: CanvasHistoryAction, snapshot?: CanvasHistorySnapshot) => + captureCanvasHistory(action, snapshot ? { snapshot } : undefined), + [captureCanvasHistory], + ); + const undo = useCallback(() => { + if (backgroundInteractionLockedRef.current) return; + if (undoCanvasChange().status === 'success') markDirty(); + }, [markDirty, undoCanvasChange]); + const redo = useCallback(() => { + if (backgroundInteractionLockedRef.current) return; + if (redoCanvasChange().status === 'success') markDirty(); + }, [markDirty, redoCanvasChange]); + + const hydrateDraft = useCallback( + async (nextDraft: ImageCanvasDraft, epoch: number) => { + const runtimeLayers = await Promise.all( + nextDraft.canvas.layers.map(async (layer) => { + const preview = await host.readMediaPreview({ + scope: stableScope, + mediaRef: layer.mediaRef, + }); + if (preview.status !== 'ok') { + throw new Error( + preview.status === 'failed' || + preview.status === 'unsupported-capability' + ? preview.message + : '素材媒体读取发生冲突', + ); + } + if (epoch !== epochRef.current) { + URL.revokeObjectURL(preview.value.previewUrl); + throw new Error('stale-asset-canvas-epoch'); + } + previewUrlsRef.current.add(preview.value.previewUrl); + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local' as const, + title: layer.title, + src: preview.value.previewUrl, + mediaType: 'image' as const, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded' as const, + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + } satisfies RuntimeCanvasLayer; + }), + ); + if (epoch !== epochRef.current) return; + minimumDraftRevisionRef.current = nextDraft.revision; + draftRef.current = nextDraft; + setDraft(nextDraft); + setLayers(runtimeLayers); + setViewport(nextDraft.canvas.viewport); + setBackgroundColor(nextDraft.canvas.backgroundColor); + setSelectedLayerIds(nextDraft.canvas.selectedLayerIds); + resetCanvasHistory(); + documentVersionRef.current = 0; + persistedDocumentVersionRef.current = 0; + setDocumentVersion(0); + }, + [host, resetCanvasHistory, stableScope], + ); + + useEffect(() => { + const previewUrls = previewUrlsRef.current; + const epoch = epochRef.current + 1; + epochRef.current = epoch; + saveQueueRef.current = Promise.resolve(); + savePromiseRef.current = null; + pendingCommitRef.current = null; + pendingGenerationRef.current = null; + generationStartingRef.current = false; + dragRef.current = null; + minimumDraftRevisionRef.current = 0; + generationFocusEpochRef.current += 1; + hostRevisionRef.current = expectedHostRevision; + deliveredEventsRef.current.clear(); + setLifecycle({ kind: 'canvas.recovering' }); + setNotice(''); + setServiceIdentityConfirmations([]); + setServiceIdentityDialogOpen(false); + setServiceIdentityPending(false); + setServiceIdentityError(''); + let unlisten: (() => void) | undefined; + void (async () => { + const nextUnlisten = await host.subscribeCommitted((event) => { + if ( + epoch !== epochRef.current || + event.projectId !== stableScope.projectId || + event.draftId !== stableScope.draftId || + deliveredEventsRef.current.has(event.eventId) + ) { + return; + } + deliveredEventsRef.current.add(event.eventId); + onCommitted?.({ + source: 'event', + projectPath: event.projectPath, + projectId: event.projectId, + draftId: event.draftId, + commitId: event.commitId, + assetId: event.asset.id, + manifest: event.manifest, + projectRevision: event.committedProjectRevision, + committedProjectRevision: event.committedProjectRevision, + eventId: event.eventId, + }); + }); + if (epoch !== epochRef.current) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + const recovery = await host.recover(); + if (epoch !== epochRef.current) return; + if (recovery.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: recovery.status === 'failed' ? recovery.code : recovery.status, + message: + recovery.status === 'failed' + ? recovery.message + : '素材画布恢复发生冲突', + reconciliationRequired: + recovery.status === 'failed' && + recovery.code === 'reconciliation-required', + }); + return; + } + hostRevisionRef.current = String(recovery.value.projectRevision); + const loaded = await host.project.loadDraft(stableScope); + if (epoch !== epochRef.current) return; + let nextDraft: ImageCanvasDraft; + if (loaded.status === 'ok' && loaded.value) { + nextDraft = loaded.value; + } else if (loaded.status === 'ok') { + setLifecycle({ kind: 'canvas.creating' }); + const created = await host.project.createDraft(stableScope); + if (epoch !== epochRef.current) return; + if (created.status !== 'ok') { + throw new Error( + created.status === 'failed' + ? created.message + : '素材画布草稿创建冲突', + ); + } + nextDraft = created.value; + } else { + throw new Error( + loaded.status === 'failed' ? loaded.message : '素材画布草稿读取冲突', + ); + } + await hydrateDraft(nextDraft, epoch); + if (epoch !== epochRef.current) return; + const recoveryFocusEpoch = generationFocusEpochRef.current; + const result = await host.generation.recoverImages({ + scope: stableScope, + onProgress: (progress) => { + if ( + epoch === epochRef.current && + recoveryFocusEpoch === generationFocusEpochRef.current + ) { + applyGenerationProgressRevision(progress); + setNotice(`正在恢复图片生成:${progress.phase}`); + } + }, + }); + if ( + epoch !== epochRef.current || + recoveryFocusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '原生成任务恢复发生冲突', + reconciliationRequired: + result.status === 'failed' && + result.code === 'reconciliation-required', + }); + return; + } + if (result.value.resumedGenerationIds.length) { + setNotice( + `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, + ); + } + setServiceIdentityConfirmations( + result.value.serviceIdentityConfirmations, + ); + setServiceIdentityDialogOpen( + result.value.serviceIdentityConfirmations.length > 0, + ); + setServiceIdentityError(''); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + void onWalletBalanceMayHaveChanged(); + })().catch((error: unknown) => { + if ( + epoch === epochRef.current && + !( + error instanceof Error && error.message === 'stale-asset-canvas-epoch' + ) + ) { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: 'canvas-open-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + } + }); + return () => { + epochRef.current += 1; + generationFocusEpochRef.current += 1; + unlisten?.(); + for (const url of previewUrls) URL.revokeObjectURL(url); + previewUrls.clear(); + }; + }, [ + applyGenerationProgressRevision, + expectedHostRevision, + host, + hydrateDraft, + onCommitted, + onWalletBalanceMayHaveChanged, + stableScope, + sessionId, + recoveryReloadToken, + ]); + + const confirmCurrentGenerationServiceIdentity = useCallback(async () => { + const confirmation = serviceIdentityConfirmations[0]; + if ( + !confirmation || + serviceIdentityPending || + lifecycleRef.current.kind !== 'canvas.editing' || + !serviceIdentityDialogOpenRef.current || + generationDialogRef.current !== null || + exitDialogOpenRef.current + ) { + return; + } + const epoch = epochRef.current; + const focusEpoch = generationFocusEpochRef.current; + setServiceIdentityPending(true); + setServiceIdentityError(''); + const confirmed = await host.confirmGenerationServiceIdentity({ + scope: stableScope, + confirmation, + }); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (confirmed.status !== 'ok') { + setServiceIdentityPending(false); + setServiceIdentityError( + confirmed.status === 'failed' + ? confirmed.message + : '服务身份确认发生冲突,请重新打开画布后再试', + ); + return; + } + const recovery = await host.generation.recoverImages({ + scope: stableScope, + onProgress: (progress) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current + ) { + applyGenerationProgressRevision(progress); + setNotice(`正在恢复图片生成:${progress.phase}`); + } + }, + }); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + setServiceIdentityPending(false); + if (recovery.status !== 'ok') { + setServiceIdentityError( + recovery.status === 'failed' + ? recovery.message + : '原生成 operation 恢复发生冲突', + ); + return; + } + setServiceIdentityConfirmations( + recovery.value.serviceIdentityConfirmations, + ); + setServiceIdentityDialogOpen( + recovery.value.serviceIdentityConfirmations.length > 0, + ); + setServiceIdentityError(''); + if (recovery.value.resumedGenerationIds.length) { + setNotice( + `已安全恢复 ${recovery.value.resumedGenerationIds.length} 个原生成 operation`, + ); + } + void onWalletBalanceMayHaveChanged(); + }, [ + applyGenerationProgressRevision, + host, + onWalletBalanceMayHaveChanged, + serviceIdentityConfirmations, + serviceIdentityPending, + stableScope, + ]); + + useEffect(() => { + const element = viewportElementRef.current; + if (!element) return undefined; + const update = () => + setCanvasSize({ + width: element.clientWidth || 900, + height: element.clientHeight || 640, + }); + update(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + } + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [draft]); + + const persistDraft = + useCallback(async (): Promise => { + const epoch = epochRef.current; + const requestedVersion = documentVersionRef.current; + const task = saveQueueRef.current.then(async () => { + const currentDraft = draftRef.current; + if (!currentDraft || epoch !== epochRef.current) return null; + const result = await host.project.updateDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + status: 'editing', + canvas: draftCanvasFromRuntime( + layersRef.current, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: currentDraft.generations, + }); + if (epoch !== epochRef.current) return null; + if (result.status === 'conflict') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return null; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' ? result.message : '草稿保存失败', + reconciliationRequired: false, + }); + return null; + } + applyDraftCandidate(result.value); + persistedDocumentVersionRef.current = Math.max( + persistedDocumentVersionRef.current, + requestedVersion, + ); + if ( + requestedVersion === documentVersionRef.current && + lifecycleRef.current.kind === 'canvas.editing' + ) { + setLifecycle({ kind: 'canvas.editing', dirty: false }); + } + return result.value; + }); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, [applyDraftCandidate, host.project, stableScope]); + + useEffect(() => { + if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { + return undefined; + } + const timer = window.setTimeout(() => void persistDraft(), 180); + return () => window.clearTimeout(timer); + }, [documentVersion, draft, lifecycle, persistDraft]); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + if (backgroundInteractionLockedRef.current) { + dragRef.current = null; + return; + } + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (drag.kind === 'pan') { + const nextViewport = moveViewportFromPan(drag, { + x: event.clientX, + y: event.clientY, + }); + if ( + nextViewport.x === drag.startViewport.x && + nextViewport.y === drag.startViewport.y && + nextViewport.scale === drag.startViewport.scale + ) { + if (drag.changed) { + setViewport({ ...drag.startViewport }); + } + drag.changed = false; + return; + } + drag.changed = true; + setViewport(nextViewport); + return; + } + const deltaX = + (event.clientX - drag.startClientX) / viewportRef.current.scale; + const deltaY = + (event.clientY - drag.startClientY) / viewportRef.current.scale; + if (drag.kind === 'move') { + if ( + (deltaX === 0 && deltaY === 0) || + !drag.startLayers.some( + (layer) => drag.targetIds.includes(layer.id) && !layer.locked, + ) + ) { + if (drag.changed) { + setLayers(drag.startLayers.map((layer) => ({ ...layer }))); + } + drag.changed = false; + return; + } + drag.changed = true; + const transforms = new Map( + drag.startLayers + .filter((layer) => drag.targetIds.includes(layer.id)) + .map((layer) => [ + layer.id, + { x: layer.x + deltaX, y: layer.y + deltaY }, + ]), + ); + setLayers( + transformCanvasLayers( + drag.startLayers, + transforms, + ) as RuntimeCanvasLayer[], + ); + } else { + const layer = drag.startLayers.find((item) => item.id === drag.layerId); + if (!layer) return; + const bounds = resizeCanvasLayerBounds({ + initial: layer, + handle: 'bottom-right', + deltaX, + deltaY, + preserveAspectRatio: !event.shiftKey, + minSize: 8, + }); + if ( + bounds.x === layer.x && + bounds.y === layer.y && + bounds.width === layer.width && + bounds.height === layer.height + ) { + if (drag.changed) { + setLayers(drag.startLayers.map((item) => ({ ...item }))); + } + drag.changed = false; + return; + } + drag.changed = true; + setLayers( + transformCanvasLayers( + drag.startLayers, + new Map([[drag.layerId, bounds]]), + ) as RuntimeCanvasLayer[], + ); + } + }; + const onUp = (event: PointerEvent) => { + if (backgroundInteractionLockedRef.current) { + dragRef.current = null; + return; + } + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + if (drag.changed) { + captureHistory(drag.historyAction, drag.historySnapshot); + } + if (drag.changed || drag.selectionChanged) { + markDirty(); + } + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, [captureHistory, markDirty]); + + const handleImport = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ''; + const currentDraft = draftRef.current; + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + !currentDraft || + !files.length + ) + return; + const epoch = epochRef.current; + const images = await Promise.all( + files.map(async (file) => { + const mediaType = mediaTypeForFile(file); + if (!mediaType) throw new Error('只支持 PNG、JPEG 和 WebP 图片'); + return { + name: file.name, + mediaType, + bytes: new Uint8Array(await file.arrayBuffer()), + }; + }), + ); + if ( + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + return; + } + const imported = await host.asset.importImages({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + images, + }); + if ( + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + if (imported.status === 'ok') { + for (const image of imported.value) + URL.revokeObjectURL(image.previewUrl); + } + return; + } + if (imported.status !== 'ok') { + setNotice( + imported.status === 'failed' + ? imported.message + : '本地图片导入发生冲突', + ); + return; + } + captureHistory({ type: 'upload-image', count: files.length }); + const baseZ = layersRef.current.reduce( + (value, layer) => Math.max(value, layer.zIndex), + -1, + ); + const additions = imported.value.map((image, index) => { + previewUrlsRef.current.add(image.previewUrl); + const source = images[index]; + const layerId = crypto.randomUUID(); + return { + id: layerId, + resourceId: image.resourceId ?? `draft:${layerId}`, + resourcePersistenceState: 'self-contained-local' as const, + title: source?.name ?? `图片 ${index + 1}`, + src: image.previewUrl, + mediaType: 'image' as const, + x: 5800 + index * 36, + y: 5800 + index * 36, + width: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelWidth + : 480, + height: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelHeight + : 320, + originalWidth: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelWidth + : 480, + originalHeight: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelHeight + : 320, + zIndex: baseZ + index + 1, + sourceType: 'uploaded' as const, + groupId: null, + hidden: false, + locked: false, + flipX: false, + flipY: false, + mediaRef: image.mediaRef, + } satisfies RuntimeCanvasLayer; + }); + setLayers((current) => [...current, ...additions]); + setSelectedLayerIds(additions.map((layer) => layer.id)); + markDirty(); + setNotice(`已导入 ${additions.length} 张图片`); + }, + [captureHistory, host.asset, markDirty, stableScope], + ); + + const deleteSelected = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + !selectionRef.current.length + ) + return; + captureHistory({ + type: 'delete-image', + count: selectionRef.current.length, + layerIds: [...selectionRef.current], + }); + setLayers( + (current) => + removeCanvasLayers( + current, + selectionRef.current, + ) as RuntimeCanvasLayer[], + ); + setSelectedLayerIds([]); + markDirty(); + }, [captureHistory, markDirty]); + + const saveAsset = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + savePromiseRef.current + ) { + return savePromiseRef.current ?? undefined; + } + const saveEpoch = epochRef.current; + const pending = + pendingCommitRef.current?.documentVersion === documentVersion + ? pendingCommitRef.current + : { + commitId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + documentVersion, + }; + pendingCommitRef.current = pending; + onSaveAttempt?.({ + saveAttemptId: crypto.randomUUID(), + sessionId, + projectId: stableScope.projectId, + draftId: stableScope.draftId, + commitId: pending.commitId, + }); + const task = (async () => { + const epoch = saveEpoch; + if (!draftRef.current) return; + if (documentVersionRef.current !== persistedDocumentVersionRef.current) { + setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); + const persisted = await persistDraft(); + if (!persisted) return; + } + if (epoch !== epochRef.current || !draftRef.current) return; + setLifecycle({ kind: 'canvas.saving', stage: 'staging' }); + const bytes = await renderImage({ + layers: layersRef.current, + backgroundColor: backgroundRef.current, + mediaType: exportMediaType, + quality: exportMediaType === 'image/png' ? null : 0.92, + }); + if (epoch !== epochRef.current) return; + setLifecycle({ kind: 'canvas.saving', stage: 'committing' }); + const result = await host.completion.commitImage({ + scope: stableScope, + expectedHostRevision: hostRevisionRef.current, + expectedDraftRevision: draftRef.current.revision, + commitId: pending.commitId, + idempotencyKey: pending.idempotencyKey, + name: assetName, + assetKind, + referenceResourceIds: + stableScope.intent === 'refine' && draftRef.current.sourceResourceId + ? [draftRef.current.sourceResourceId] + : [], + mediaType: exportMediaType, + bytes, + }); + if (epoch !== epochRef.current) return; + if (result.status !== 'ok') { + const code = result.status === 'failed' ? result.code : result.status; + if (code === 'rolled-back') { + pendingCommitRef.current = null; + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'asset-commit', + code, + message: + result.status === 'failed' + ? result.message + : '素材保存发生 revision 冲突', + reconciliationRequired: code === 'reconciliation-required', + }); + return; + } + hostRevisionRef.current = result.value.hostRevision; + if (draftRef.current) { + const next = { + ...draftRef.current, + revision: result.value.draftRevision, + status: 'committed' as const, + }; + applyDraftCandidate(next); + } + setLifecycle({ kind: 'canvas.saving', stage: 'projecting' }); + const manifest = result.value.manifest as + | GameCreationAppManifest + | undefined; + if (manifest) { + if (result.value.eventId) { + deliveredEventsRef.current.add(result.value.eventId); + } + onCommitted?.({ + source: 'command', + projectPath: host.projectPath, + projectId: result.value.projectId ?? stableScope.projectId, + draftId: stableScope.draftId, + commitId: result.value.commitId ?? pending.commitId, + assetId: result.value.assetId ?? result.value.resourceId, + manifest, + projectRevision: Number(result.value.hostRevision), + committedProjectRevision: + result.value.committedProjectRevision ?? + Number(result.value.hostRevision), + eventId: result.value.eventId, + }); + } + pendingCommitRef.current = null; + persistedDocumentVersionRef.current = documentVersionRef.current; + setNotice( + result.value.commitStatus === 'already-committed' + ? '素材已提交,本次返回原幂等结果' + : '素材已保存到项目 assets/ 并登记 manifest', + ); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + })().catch((error: unknown) => { + if (epochRef.current !== saveEpoch) return; + setLifecycle({ + kind: 'canvas.failed', + operation: 'asset-commit', + code: 'canvas-save-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + }); + savePromiseRef.current = task.finally(() => { + savePromiseRef.current = null; + }); + return savePromiseRef.current; + }, [ + applyDraftCandidate, + assetKind, + assetName, + documentVersion, + exportMediaType, + host.completion, + host.projectPath, + onCommitted, + onSaveAttempt, + persistDraft, + renderImage, + sessionId, + stableScope, + ]); + + const discardCanvas = useCallback(() => { + const currentDraft = draftRef.current; + if ( + !currentDraft || + !['canvas.editing', 'canvas.failed'].includes( + lifecycleRef.current.kind, + ) || + !exitDialogOpenRef.current || + generationDialogRef.current !== null || + serviceIdentityDialogOpenRef.current + ) { + return; + } + setExitActionPending(true); + const epoch = epochRef.current; + void host.project + .discardDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + }) + .then((result) => { + if (epoch !== epochRef.current) return; + if (result.status === 'ok') { + setExitDialogOpen(false); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'discarded', + }); + return; + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'cancellation', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '素材画布取消发生 revision 冲突', + reconciliationRequired: false, + }); + setExitDialogOpen(false); + }) + .catch((error: unknown) => { + if (epoch !== epochRef.current) return; + setLifecycle({ + kind: 'canvas.failed', + operation: 'cancellation', + code: 'canvas-cancel-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + setExitDialogOpen(false); + }) + .finally(() => { + if (epoch === epochRef.current) setExitActionPending(false); + }); + }, [host.project, onCancel, stableScope]); + + const keepDraftAndExit = useCallback(() => { + if ( + exitActionPending || + !['canvas.editing', 'canvas.failed'].includes( + lifecycleRef.current.kind, + ) || + !exitDialogOpenRef.current || + generationDialogRef.current !== null || + serviceIdentityDialogOpenRef.current + ) { + return; + } + const currentDraft = draftRef.current; + if (!currentDraft) return; + setExitActionPending(true); + const epoch = epochRef.current; + void persistDraft() + .then((persisted) => { + if (epoch !== epochRef.current || !persisted) return; + setExitDialogOpen(false); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }) + .finally(() => { + if (epoch === epochRef.current) setExitActionPending(false); + }); + }, [exitActionPending, onCancel, persistDraft, stableScope.draftId]); + + const requestCanvasExit = useCallback(() => { + const currentDraft = draftRef.current; + if ( + lifecycleRef.current.kind === 'canvas.saving' || + lifecycleRef.current.kind === 'canvas.generating' + ) { + return; + } + if (!currentDraft) { + if ( + lifecycleRef.current.kind === 'canvas.failed' && + lifecycleRef.current.operation === 'recovery' + ) { + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + } + return; + } + if (documentVersionRef.current !== persistedDocumentVersionRef.current) { + setExitDialogOpen(true); + return; + } + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }, [onCancel, stableScope.draftId]); + + const openGenerationDialog = useCallback(() => { + const currentDraft = draftRef.current; + if ( + !currentDraft || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + return; + } + const selectedReferences = layersRef.current + .filter((layer) => selectionRef.current.includes(layer.id)) + .map((layer) => layer.resourceId); + if ( + stableScope.intent === 'refine' && + currentDraft.sourceResourceId && + !selectedReferences.includes(currentDraft.sourceResourceId) + ) { + selectedReferences.push(currentDraft.sourceResourceId); + } + pendingGenerationRef.current = { + saveAttemptId: crypto.randomUUID(), + intentId: crypto.randomUUID(), + generationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + commitId: crypto.randomUUID(), + commitIdempotencyKey: crypto.randomUUID(), + }; + setGenerationReferenceResourceIds([...new Set(selectedReferences)]); + setGenerationDialog('edit'); + setNotice(''); + }, [stableScope.intent]); + + const closeGenerationDialog = useCallback(() => { + if (generationStartingRef.current) return; + pendingGenerationRef.current = null; + setGenerationDialog(null); + }, []); + + const showGenerationConfirmation = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + generationDialogRef.current !== 'edit' || + exitDialogOpenRef.current || + serviceIdentityDialogOpenRef.current + ) { + return; + } + if (!generationPrompt.trim()) { + setNotice('请先填写图片提示词'); + return; + } + setGenerationDialog('confirm'); + }, [generationPrompt]); + + const confirmGeneration = useCallback(() => { + if ( + generationStartingRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + generationDialogRef.current !== 'confirm' || + exitDialogOpenRef.current || + serviceIdentityDialogOpenRef.current + ) { + return; + } + const identity = pendingGenerationRef.current; + const initialDraft = draftRef.current; + const prompt = generationPrompt.trim(); + if (!identity || !initialDraft || !prompt) return; + generationStartingRef.current = true; + const epoch = epochRef.current; + const focusEpoch = generationFocusEpochRef.current + 1; + generationFocusEpochRef.current = focusEpoch; + const frozenReferences = [...generationReferenceResourceIds]; + const frozenAspectRatio = generationAspectRatio; + const frozenImageSize = generationImageSize; + const frozenAssetKind = assetKind; + const frozenAssetName = assetName; + const needsDraftPersist = + documentVersionRef.current !== persistedDocumentVersionRef.current; + setGenerationDialog(null); + dragRef.current = null; + setLifecycle({ + kind: 'canvas.generating', + phase: 'confirmation-required', + }); + const task = (async () => { + if (needsDraftPersist) { + const persisted = await persistDraft(); + if (!persisted) return; + } + const currentDraft = draftRef.current; + if ( + !currentDraft || + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + onSaveAttempt?.({ + saveAttemptId: identity.saveAttemptId, + sessionId, + projectId: stableScope.projectId, + draftId: stableScope.draftId, + commitId: identity.commitId, + }); + const onProgress = (progress: ImageCanvasGenerationProgress) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current && + progress.intentId === identity.intentId && + progress.generationId === identity.generationId + ) { + applyGenerationProgressRevision(progress); + setLifecycle({ kind: 'canvas.generating', phase: progress.phase }); + setNotice(progress.errorCode ?? ''); + } + }; + const result = await host.generation.generateImage({ + scope: stableScope, + expectedHostRevision: hostRevisionRef.current, + expectedDraftRevision: currentDraft.revision, + intentId: identity.intentId, + generationId: identity.generationId, + idempotencyKey: identity.idempotencyKey, + commitId: identity.commitId, + commitIdempotencyKey: identity.commitIdempotencyKey, + prompt, + aspectRatio: frozenAspectRatio, + imageSize: frozenImageSize, + assetKind: frozenAssetKind, + assetName: frozenAssetName, + referenceResourceIds: frozenReferences, + onProgress, + }); + void onWalletBalanceMayHaveChanged(); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: + result.status === 'failed' && + result.code === 'reconciliation-required' + ? 'recovery' + : 'generation', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '图片生成发生 revision 冲突', + reconciliationRequired: + result.status === 'failed' && + result.code === 'reconciliation-required', + }); + return; + } + const { commit } = result.value; + hostRevisionRef.current = commit.hostRevision; + if (draftRef.current) { + const nextDraft = { + ...draftRef.current, + revision: commit.draftRevision, + status: 'committed' as const, + generations: [ + ...draftRef.current.generations.filter( + (record) => record.generationId !== identity.generationId, + ), + result.value.generation, + ], + }; + applyDraftCandidate(nextDraft); + } + if (!deliveredEventsRef.current.has(commit.eventId)) { + deliveredEventsRef.current.add(commit.eventId); + onCommitted?.({ + source: 'command', + projectPath: host.projectPath, + projectId: commit.projectId, + draftId: stableScope.draftId, + commitId: commit.commitId, + assetId: commit.assetId, + manifest: commit.manifest as GameCreationAppManifest, + projectRevision: Number(commit.hostRevision), + committedProjectRevision: commit.committedProjectRevision, + eventId: commit.eventId, + }); + } + pendingGenerationRef.current = null; + persistedDocumentVersionRef.current = documentVersionRef.current; + setLifecycle({ kind: 'canvas.editing', dirty: false }); + setNotice('AI 图片已正式提交并进入资源总览'); + })().catch((error: unknown) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current + ) { + setLifecycle({ + kind: 'canvas.failed', + operation: 'generation', + code: 'canvas-generation-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + } + }); + void task.finally(() => { + if (generationFocusEpochRef.current === focusEpoch) { + generationStartingRef.current = false; + } + }); + }, [ + applyDraftCandidate, + applyGenerationProgressRevision, + assetKind, + assetName, + generationAspectRatio, + generationImageSize, + generationPrompt, + generationReferenceResourceIds, + host.generation, + host.projectPath, + onCommitted, + onSaveAttempt, + onWalletBalanceMayHaveChanged, + persistDraft, + sessionId, + stableScope, + ]); + + const reopenGenerationAfterFailure = useCallback( + (dialog: 'edit' | 'confirm') => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'generation' + ) + return; + pendingGenerationRef.current = { + saveAttemptId: crypto.randomUUID(), + intentId: crypto.randomUUID(), + generationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + commitId: crypto.randomUUID(), + commitIdempotencyKey: crypto.randomUUID(), + }; + generationStartingRef.current = false; + setNotice(''); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + setGenerationDialog(dialog); + }, + [], + ); + + const retryDraftSaveAfterFailure = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'draft-save' + ) { + return; + } + setLifecycle({ kind: 'canvas.editing', dirty: true }); + }, []); + + const continueAfterCancellationFailure = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'cancellation' + ) { + return; + } + setExitDialogOpen(false); + setLifecycle({ + kind: 'canvas.editing', + dirty: documentVersionRef.current !== persistedDocumentVersionRef.current, + }); + }, []); + + const retryCanvasRecovery = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + !['recovery', 'asset-commit', 'draft-save'].includes( + lifecycleRef.current.operation, + ) + ) { + return; + } + setLifecycle({ kind: 'canvas.recovering' }); + setRecoveryReloadToken((value) => value + 1); + }, []); + + const stopWaitingForGeneration = useCallback(() => { + generationFocusEpochRef.current += 1; + pendingGenerationRef.current = null; + setGenerationDialog(null); + setNotice('生成仍会在后台使用原 operation 安全对账'); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }, [onCancel, stableScope.draftId]); + + const minimapModel = useMemo( + () => createMinimapModel({ layers, viewport, canvasSize }), + [canvasSize, layers, viewport], + ); + const generationInteractionLocked = lifecycle.kind === 'canvas.generating'; + const activeServiceIdentityConfirmation = + serviceIdentityConfirmations[0] ?? null; + + useEffect(() => { + if (generationInteractionLocked) { + dragRef.current = null; + generationStopButtonRef.current?.focus(); + } + }, [generationInteractionLocked]); + + useEffect(() => { + if (modalOpen) { + modalInitialFocusRef.current?.focus(); + } + }, [generationDialog, modalOpen, serviceIdentityDialogOpen]); + + const failurePresentation = + lifecycle.kind === 'canvas.failed' + ? assetCanvasFailurePresentation(lifecycle) + : null; + + if ( + (!draft && lifecycle.kind !== 'canvas.failed') || + lifecycle.kind === 'canvas.recovering' || + lifecycle.kind === 'canvas.creating' + ) { + return ( +
+ + {lifecycle.kind === 'canvas.recovering' + ? '正在恢复画布…' + : '正在创建画布…'} + +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + {activeServiceIdentityConfirmation ? ( + + ) : null} + + + +
+ + + + +
+ void handleImport(event)} + /> +
+ + {generationDialog ? ( +
+
+
+ + {generationDialog === 'edit' ? 'AI 图片生成' : '确认图片生成'} + +
+ {generationDialog === 'edit' ? ( +
+