移除误提交的临时诊断文件
Project CI / Repository checks (pull_request) Successful in 59s
Project CI / Frontend tests (pull_request) Successful in 2m39s
Project CI / Backend tests (pull_request) Successful in 3m46s
Project CI / Native shell tests (pull_request) Failing after 5m29s

cf397644c 的 git add -A 把一个文件名被路径转义搞坏的临时文件扫进了仓库,
其内容带行尾空白,导致 CI 的 git diff --check base...HEAD 以 exit 2 失败。
该文件是会话中一次工具调用把 Windows 绝对路径当成文件名产生的产物,与本分支
的改动无关。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:00:45 +00:00
parent ed7c5c3a80
commit 2ffa3a07c1
@@ -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)}
/>
+ <PlatformIconButton
+ className="image-canvas-editor__floating-toolbar-text-button"
+ // 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
+ // 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
+ label={
+ isPersistingAssetKind
+ ? '完美像素等待素材类型保存'
+ : isPerfectPixelProcessing
+ ? '完美像素处理中'
+ : '完美像素'
+ }
+ title={
+ isPersistingAssetKind
+ ? '完美像素等待素材类型保存'
+ : isPerfectPixelProcessing
+ ? '完美像素处理中'
+ : '自动识别并规整像素网格'
+ }
+ icon={
+ isPersistingAssetKind || isPerfectPixelProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Grid2X2 className="h-4 w-4" />
+ )
+ }
+ // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
+ // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
+ // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
+ // 与相邻的拆分图集按钮保持同一套门禁。
+ disabled={isPersistingAssetKind || isPerfectPixelProcessing}
+ aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
+ onClick={() => onPerfectPixel(selectedLayer)}
+ >
+ <span>
+ {isPersistingAssetKind
+ ? '保存中'
+ : isPerfectPixelProcessing
+ ? '处理中'
+ : '完美像素'}
+ </span>
+ </PlatformIconButton>
</>
) : 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<void>;
+ refreshAssetLibrary?: () => Promise<unknown> | 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<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
+ const perfectPixelLayerIdsRef = useRef(new Set<string>());
+ const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
+ () => 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<void>` 不带成败信号;它本身还是可选
+ // 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,