From 2ffa3a07c1078b490c659e9c9b89da87fd76df65 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 08:00:45 +0000 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AF=AF=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=9A=84=E4=B8=B4=E6=97=B6=E8=AF=8A=E6=96=AD=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf397644c 的 git add -A 把一个文件名被路径转义搞坏的临时文件扫进了仓库, 其内容带行尾空白,导致 CI 的 git diff --check base...HEAD 以 exit 2 失败。 该文件是会话中一次工具调用把 Windows 绝对路径当成文件名产生的产物,与本分支 的改动无关。 Co-Authored-By: Claude Opus 5 --- ...931-4248-aee7-f6018dfc3969scratchpaddiff.txt | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt diff --git a/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt b/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt deleted file mode 100644 index 4eac74b8a..000000000 --- a/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt +++ /dev/null @@ -1,399 +0,0 @@ -diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx -index b1a423324..d8434e0e1 100644 ---- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx -+++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx -@@ -1,6 +1,7 @@ - import { - Crop, - Download, -+ Grid2X2, - ImageOff, - Loader2, - PersonStanding, -@@ -22,6 +23,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = { - onOpenRedrawPanel: (layer: CanvasLayer) => void; - onOpenCropExpandPanel: (layer: CanvasLayer) => void; - onRemoveBackground: (layer: CanvasLayer) => void; -+ onPerfectPixel: (layer: CanvasLayer) => void; -+ isPerfectPixelProcessing: boolean; - isSplittingIconSpritesheet: boolean; - isPersistingAssetKind: boolean; - onSplitIconSpritesheet: (layer: CanvasLayer) => void; -@@ -37,6 +40,8 @@ export function ImageCanvasSelectedLayerToolbarView({ - onOpenRedrawPanel, - onOpenCropExpandPanel, - onRemoveBackground, -+ onPerfectPixel, -+ isPerfectPixelProcessing, - isSplittingIconSpritesheet, - isPersistingAssetKind, - onSplitIconSpritesheet, -@@ -120,6 +125,47 @@ export function ImageCanvasSelectedLayerToolbarView({ - icon={ImageOff} - onClick={() => onRemoveBackground(selectedLayer)} - /> -+ -+ ) : ( -+ -+ ) -+ } -+ // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 -+ // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 -+ // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 -+ // 与相邻的拆分图集按钮保持同一套门禁。 -+ disabled={isPersistingAssetKind || isPerfectPixelProcessing} -+ aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} -+ onClick={() => onPerfectPixel(selectedLayer)} -+ > -+ -+ {isPersistingAssetKind -+ ? '保存中' -+ : isPerfectPixelProcessing -+ ? '处理中' -+ : '完美像素'} -+ -+ - - ) : null} - {selectedLayer.assetKind === 'icon-spritesheet' ? ( -diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts -index e40b1e752..43b404216 100644 ---- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts -+++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts -@@ -10,6 +10,7 @@ import { - useState, - } from 'react'; - -+import { ApiClientError } from '../../services/apiClient'; - import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; - import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; - import { -@@ -18,9 +19,11 @@ import { - type EditorProjectLayerSnapshot, - type EditorProjectResourceSnapshot, - type EditorProjectSnapshot, -+ loadEditorProject, - splitEditorIconSpritesheet, - } from '../../services/image-editor/editorProjectClient'; - import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel'; -+import { isCanvasGenerationDialogLayoutItem } from './ImageCanvasEditorModel'; - import type { - CanvasGenerationDialogState, - CanvasHistoryAction, -@@ -93,6 +96,7 @@ import { - buildCropExpandInsetsFromFrame, - removeImageBackground, - renderCropExpandImage, -+ snapImageToPerfectPixels, - } from './ImageCanvasRasterEditModel'; - import { - createUiAssetExtractionDraftMark, -@@ -628,6 +632,8 @@ type GenerationWorkflowOptions = { - project: EditorProjectSnapshot, - action?: CanvasHistoryAction, - ) => void; -+ flushProjectPersistence?: () => Promise; -+ refreshAssetLibrary?: () => Promise | void; - onWalletBalanceMayHaveChanged?: () => void; - }; - -@@ -662,6 +668,8 @@ export function useImageCanvasGenerationWorkflow({ - assetFolderId, - upsertGeneratedAsset, - applyProjectSnapshot, -+ flushProjectPersistence, -+ refreshAssetLibrary, - onWalletBalanceMayHaveChanged, - }: GenerationWorkflowOptions) { - const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false); -@@ -677,6 +685,10 @@ export function useImageCanvasGenerationWorkflow({ - const splittingIconSpritesheetLayerIdsRef = useRef(new Set()); - const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] = - useState>(() => new Set()); -+ const perfectPixelLayerIdsRef = useRef(new Set()); -+ const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState>( -+ () => new Set(), -+ ); - const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); - const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = - useState(false); -@@ -1734,6 +1746,241 @@ export function useImageCanvasGenerationWorkflow({ - ], - ); - -+ const snapSelectedLayerToPerfectPixels = useCallback( -+ async (sourceLayer: CanvasLayer) => { -+ const normalizedProjectId = projectId?.trim(); -+ const isStaticRasterLayer = -+ (sourceLayer.mediaType === undefined || -+ sourceLayer.mediaType === 'image') && -+ sourceLayer.assetKind !== 'character-animation'; -+ if ( -+ !isStaticRasterLayer || -+ !normalizedProjectId || -+ !applyProjectSnapshot || -+ !flushProjectPersistence || -+ perfectPixelLayerIdsRef.current.has(sourceLayer.id) -+ ) { -+ if ( -+ isStaticRasterLayer && -+ (!normalizedProjectId || -+ !applyProjectSnapshot || -+ !flushProjectPersistence) -+ ) { -+ showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。'); -+ } -+ 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 perfectPixelPostAttempted = false; -+ try { -+ const assetLabel = `${sourceLayer.title} · 完美像素`; -+ const placement = openPlacedCanvasGenerationDialog({ -+ ...createQuickEditGenerationDialogDraft({ -+ sourceLayer, -+ prompt: '完美像素', -+ assetLabel, -+ status: 'generating', -+ frame: { -+ width: sourceLayer.width, -+ height: sourceLayer.height, -+ }, -+ }), -+ composerOpen: false, -+ // 中文注释:完美像素是同步 HTTP,服务端不建 durable job(见 snap_editor_image_to_pixel_art -+ // 无 enqueue / spawn)。刷新掉本页会话后,没有任何东西会把这个占位推向终态,而 -+ // 服务端 409 门禁又要求占位必须先落库,所以只能在读取侧收口:置位后由项目首次 -+ // 加载时的 dropDeadInlineGenerationPlaceholders 清掉。 -+ requiresLiveSession: true, -+ }); -+ perfectPixelDialogId = placement.dialogId; -+ if (!placement.placeholder) { -+ throw new Error('无法创建完美像素处理占位'); -+ } -+ -+ const sourceImageSrc = await resolveEditorGenerationMediaReference( -+ sourceLayer, -+ 'image', -+ normalizedProjectId, -+ ); -+ await flushProjectPersistence(); -+ const sourceResourceId = sourceLayer.resourceId.trim(); -+ perfectPixelPostAttempted = true; -+ const result = await snapImageToPerfectPixels({ -+ sourceImageSrc, -+ projectId: normalizedProjectId, -+ sourceResourceId: -+ sourceResourceId && -+ !sourceResourceId.startsWith('local-resource-') && -+ !sourceResourceId.startsWith('generation-dialog:') -+ ? sourceResourceId -+ : undefined, -+ assetKind: sourceLayer.assetKind, -+ generationInputs: sourceLayer.generationInputs, -+ assetFolderId, -+ assetLabel, -+ canvasCompletion: { -+ dialogId: perfectPixelDialogId, -+ title: assetLabel, -+ placeholder: placement.placeholder, -+ }, -+ }); -+ upsertGeneratedAsset?.(result.asset); -+ if (!result.project) { -+ if (hasCanvasGenerationDialogById(perfectPixelDialogId)) { -+ updateCanvasGenerationDialogById(perfectPixelDialogId, () => null); -+ showGenerationWarning( -+ '完美像素结果已保存到素材库,画布占位已不存在。', -+ ); -+ } -+ return; -+ } -+ if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { -+ return; -+ } -+ applyProjectSnapshot(result.project, { -+ type: 'perfect-pixel', -+ count: 1, -+ }); -+ setActiveTool('select'); -+ setActiveSidebarPanel('layers'); -+ } catch (error) { -+ // 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析 -+ // 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向 -+ // 谎报。 -+ // -+ // 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。服务端持久化 -+ // 是非事务的(OSS + asset object → project resource → editor asset → canvas -+ // completion),而 completion 失败走 map_editor_project_error 会被映射成 -+ // 403 / 404 / 409 / 400,带响应的 4xx 同样可能发生在对象、资源和账号素材全部落库 -+ // 之后。状态码分不出持久化阶段,所以服务端在第一次 OSS PUT 之后的失败路径上置 -+ // `resultPersistenceStarted`,客户端只对这类失败和完全无响应的失败做对账——常见的 -+ // 纯校验 400 / 排队 503 / 预算 504 不会白白多两次读取,也不会被附上不适用的提示。 -+ const persistenceMayHaveStarted = -+ error instanceof ApiClientError && -+ (error.details as { resultPersistenceStarted?: unknown } | null) -+ ?.resultPersistenceStarted === true; -+ const outcomeMayBePersisted = -+ perfectPixelPostAttempted && -+ Boolean(perfectPixelDialogId) && -+ (!(error instanceof ApiClientError) || persistenceMayHaveStarted); -+ let reconciledMessage: string | undefined; -+ if (outcomeMayBePersisted && perfectPixelDialogId) { -+ // 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET -+ // 项目却让用户去核对素材库,他看到的仍是旧列表。 -+ // -+ // 但不能因为调了它就在文案里断言「素材库已刷新」:refreshAssetLibrary 在 -+ // `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时 -+ // 弹登录框、其余一律吞掉,返回 `Promise` 不带成败信号;它本身还是可选 -+ // prop。刷新成功时用户白赚一份新列表,失败时文案必须仍然成立,所以下面只给 -+ // 「请确认」的指令,不给「已刷新」的事实断言。 -+ const [reconciled] = await Promise.all([ -+ loadEditorProject(normalizedProjectId).catch(() => null), -+ Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined), -+ ]); -+ if (reconciled) { -+ const placeholderSurvived = reconciled.layers.some( -+ (item) => -+ isCanvasGenerationDialogLayoutItem(item) && -+ (item as { dialog?: { id?: unknown } }).dialog?.id === -+ perfectPixelDialogId, -+ ); -+ if (!placeholderSurvived) { -+ // 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉, -+ // 或者用户在请求期间主动删除。必须再查本地:本地占位也没了就是用户删的, -+ // 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有 -+ // 这道检查,对账路径不能漏。 -+ if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { -+ return; -+ } -+ applyProjectSnapshot(reconciled, { -+ type: 'perfect-pixel', -+ count: 1, -+ }); -+ setActiveTool('select'); -+ setActiveSidebarPanel('layers'); -+ return; -+ } -+ // 中文注释:占位仍在,画布没收到结果。这里刻意不调 applyProjectSnapshot: -+ // 传给本 hook 的是 ImageCanvasEditorView 的 applyGeneratedProjectSnapshot, -+ // 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受 -+ // 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白 -+ // 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。 -+ reconciledMessage = '画布未收到完美像素结果。'; -+ } else { -+ reconciledMessage = '权威项目快照读取失败,无法确认完美像素结果。'; -+ } -+ } -+ const serverMessage = -+ error instanceof Error && error.message.trim() -+ ? error.message -+ : undefined; -+ // 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。 -+ // 持久化非事务,对象与账号素材可能已落库而只有画布回填没完成,所以必须让用户先核对 -+ // 素材库再决定,而不是直接重试。尾句只下指令、不断言素材库已经刷新——上面那次刷新 -+ // 可能是 no-op 或被静默吞掉,断言会让用户对着旧列表判定「没有派生图,可以重试」。 -+ const errorMessage = reconciledMessage -+ ? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请确认素材库是否已生成派生图,再决定是否重试。` -+ : (serverMessage ?? '完美像素处理失败'); -+ if ( -+ perfectPixelDialogId && -+ hasCanvasGenerationDialogById(perfectPixelDialogId) -+ ) { -+ updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({ -+ ...dialog, -+ status: 'failed', -+ errorMessage, -+ })); -+ } else if (!perfectPixelDialogId) { -+ showGenerationWarning(errorMessage); -+ } -+ } finally { -+ 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, -+ closeGenerationTransientState, -+ flushProjectPersistence, -+ hasCanvasGenerationDialogById, -+ openPlacedCanvasGenerationDialog, -+ refreshAssetLibrary, -+ projectId, -+ setActiveSidebarPanel, -+ setActiveTool, -+ setCharacterAnimationPanel, -+ setCropExpandPanel, -+ setImageContextMenu, -+ setMetadataLayer, -+ setQuickEditPanel, -+ showGenerationWarning, -+ updateCanvasGenerationDialogById, -+ upsertGeneratedAsset, -+ ], -+ ); -+ - const splitSelectedIconSpritesheet = useCallback( - async (sourceLayer: CanvasLayer) => { - if ( -@@ -2576,6 +2823,8 @@ export function useImageCanvasGenerationWorkflow({ - openCropExpandPanel, - startCropExpandFrameResize, - removeSelectedLayerBackground, -+ snapSelectedLayerToPerfectPixels, -+ perfectPixelLayerIds, - splitSelectedIconSpritesheet, - splittingIconSpritesheetLayerIds, - taskListRefreshKey, -@@ -2679,6 +2928,8 @@ export function useImageCanvasGenerationWorkflow({ - quickEditPanel, - quickEditSourceLayer, - removeSelectedLayerBackground, -+ snapSelectedLayerToPerfectPixels, -+ perfectPixelLayerIds, - splitSelectedIconSpritesheet, - splittingIconSpritesheetLayerIds, - submitCharacterAnimation,