diff --git a/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt b/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt
new file mode 100644
index 000000000..4eac74b8a
--- /dev/null
+++ b/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt
@@ -0,0 +1,399 @@
+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,
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index dce2e8496..119ef2f4e 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -6028,3 +6028,13 @@
- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。
- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
+
+## 2026-08-03 完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时
+
+- 缺陷:处理预算(30 秒)只覆盖到规整为止,持久化阶段完全无界,仅受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,远超客户端 `snapEditorImageToPixelArt` 的 120 秒。客户端因此会在服务端仍在合法工作时先 abort:对账虽然照常执行(abort 不是 `ApiClientError`,`outcomeMayBePersisted` 为真),但它采样的是一个仍在途的操作——占位还在、`confirm_asset_object` 未跑完所以素材库还空,用户照提示核对什么也看不到,重试就用新 `task_id` 造出孤儿 OSS 对象。
+- 决策:给持久化整段套独立预算 `EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION = 60` 秒,用第二个 `tokio::time::timeout_at` 包住从 `persist_editor_generated_image_owned` 到 `complete_editor_canvas_generation` 的全部写入。服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内并留 30 秒余量给网络往返与计时精度。
+- 为什么独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、中途放弃只会留下孤儿对象。取 min 会让「下载越慢、越容易留孤儿」,方向正好反了。
+- 超时必须带标记:这条超时发生在 PUT 已经发出之后,对象可能已落盘也可能没有,正是 `resultPersistenceStarted` 契约要覆盖的未知结果。不带标记客户端会判成确定失败、直接诱使用户重试。handler 内该标记的钉定计数因此由 4 升为 5,注释同步说明第五处是什么——这个升级由既有守卫自己报出来(`left: 5, right: 4`),不是事后补记。
+- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
+- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。
+- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs
index 334f72592..7b3426da1 100644
--- a/server-rs/crates/api-server/src/editor_project.rs
+++ b/server-rs/crates/api-server/src/editor_project.rs
@@ -161,6 +161,13 @@ const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4;
/// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。
const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048;
const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30);
+/// 中文注释:持久化阶段的独立预算。它与上面的处理预算相加就是服务端最坏合法时长,必须小于
+/// 客户端 `snapEditorImageToPixelArt` 的 120 秒超时——否则客户端会在服务端仍在合法工作时
+/// 先 abort,对账采样到仍在途的操作(占位还在、素材库还空),用户照提示核对却什么也看不到,
+/// 重试就造出孤儿对象。30 + 60 = 90,余 30 秒给网络往返。
+/// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、
+/// 中途放弃只会留下孤儿对象。
+const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60);
/// 中文注释:完美像素持久化是非事务的(OSS + asset object → project resource →
/// editor asset → canvas completion)。后段失败时前段记录会保留,而 completion 走
/// `map_editor_project_error` 会被映射成 403 / 404 / 409 / 400,客户端从状态码分不出
@@ -4899,109 +4906,138 @@ pub async fn snap_editor_image_to_pixel_art(
})?;
let task_id = build_prefixed_uuid_id("pixel-art-snap-");
- // 中文注释:尺寸已经在上一步取完,snapped_image 之后不再使用,所以按值移交给 _owned
- // 版本。借用版会在内部 `image.bytes.clone()` 复制一整份输出图片;完美像素是同步路径,
- // 那份复制会和原图在同一时刻共存。
- let persisted = persist_editor_generated_image_owned(
- &state,
- owner_user_id.as_str(),
- task_id.as_str(),
- snapped_image,
- "完美像素",
- None,
- EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
- "pixel-art-snaps",
- "perfect-pixel",
- "result",
- "genarrative",
- )
- .await?;
- let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
- let generated_asset = persist_editor_generated_asset(
- &state,
- PersistEditorGeneratedAssetInput {
- project_id: Some(project_id.clone()),
- owner_user_id: owner_user_id.clone(),
- folder_id: resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id),
- label: resolve_editor_generated_asset_label(payload.asset_label, "完美像素"),
- image_src: image_src.clone(),
- object_key: Some(persisted.object_key.clone()),
- asset_object_id: Some(persisted.asset_object_id.clone()),
- width,
- height,
- prompt: "完美像素".to_string(),
- actual_prompt: None,
- model: EDITOR_PIXEL_ART_SNAP_MODEL.to_string(),
- provider: EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string(),
- task_id: task_id.clone(),
- group_task_id: None,
- group_task_expected_asset_count: None,
- source_resource_id,
- asset_kind,
- generation_inputs: payload.generation_inputs,
- thumbnail_src: None,
- generation_cost_mud_points: 0,
+ let response_task_id = task_id.clone();
+ // 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD
+ // 各自独立计时,加上三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,
+ // 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在
+ // 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。
+ //
+ // 这里独立起算 60 秒,不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,不该因为
+ // 下载慢就被砍预算,中途放弃只会留下孤儿。处理 30 秒 + 持久化 60 秒 = 服务端最坏 90 秒,
+ // 落在客户端 120 秒内并留出 30 秒余量给网络往返。
+ let persistence_started_at = Instant::now();
+ let persistence_deadline = persistence_started_at
+ .checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION)
+ .unwrap_or(persistence_started_at);
+ let (persisted, image_src, resource, asset, completed_project) = tokio::time::timeout_at(
+ tokio::time::Instant::from_std(persistence_deadline),
+ async move {
+ // 中文注释:尺寸已经在上一步取完,snapped_image 之后不再使用,所以按值移交给 _owned
+ // 版本。借用版会在内部 `image.bytes.clone()` 复制一整份输出图片;完美像素是同步路径,
+ // 那份复制会和原图在同一时刻共存。
+ let persisted = persist_editor_generated_image_owned(
+ &state,
+ owner_user_id.as_str(),
+ task_id.as_str(),
+ snapped_image,
+ "完美像素",
+ None,
+ EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
+ "pixel-art-snaps",
+ "perfect-pixel",
+ "result",
+ "genarrative",
+ )
+ .await?;
+ let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
+ let generated_asset = persist_editor_generated_asset(
+ &state,
+ PersistEditorGeneratedAssetInput {
+ project_id: Some(project_id.clone()),
+ owner_user_id: owner_user_id.clone(),
+ folder_id: resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id),
+ label: resolve_editor_generated_asset_label(payload.asset_label, "完美像素"),
+ image_src: image_src.clone(),
+ object_key: Some(persisted.object_key.clone()),
+ asset_object_id: Some(persisted.asset_object_id.clone()),
+ width,
+ height,
+ prompt: "完美像素".to_string(),
+ actual_prompt: None,
+ model: EDITOR_PIXEL_ART_SNAP_MODEL.to_string(),
+ provider: EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string(),
+ task_id: task_id.clone(),
+ group_task_id: None,
+ group_task_expected_asset_count: None,
+ source_resource_id,
+ asset_kind,
+ generation_inputs: payload.generation_inputs,
+ thumbnail_src: None,
+ generation_cost_mud_points: 0,
+ },
+ )
+ .await
+ .map_err(|error| {
+ tracing::warn!(
+ provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
+ task_id = %task_id,
+ object_key = %persisted.object_key,
+ error = %error,
+ "editor_pixel_art_snap_asset_persistence_failed_after_object_put"
+ );
+ error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
+ })?;
+ let resource = generated_asset.resource.ok_or_else(|| {
+ tracing::warn!(
+ provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
+ task_id = %task_id,
+ object_key = %persisted.object_key,
+ "editor_pixel_art_snap_project_resource_missing_after_object_put"
+ );
+ editor_pixel_art_snap_failure(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "完美像素结果未创建项目资源。",
+ )
+ .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
+ })?;
+ let asset = generated_asset.asset.ok_or_else(|| {
+ tracing::warn!(
+ provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
+ task_id = %task_id,
+ object_key = %persisted.object_key,
+ resource_id = %resource.resource_id,
+ "editor_pixel_art_snap_account_asset_missing_after_object_put"
+ );
+ editor_pixel_art_snap_failure(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "完美像素结果未创建账号素材。",
+ )
+ .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
+ })?;
+ // 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经
+ // 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。
+ let completed_project = complete_editor_canvas_generation(
+ &state,
+ owner_user_id.as_str(),
+ Some(project_id.as_str()),
+ Some(&payload.canvas_completion),
+ Some(&resource),
+ )
+ .await
+ .map_err(|error| {
+ tracing::warn!(
+ provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
+ task_id = %task_id,
+ object_key = %persisted.object_key,
+ resource_id = %resource.resource_id,
+ error = %error,
+ "editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence"
+ );
+ error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
+ })?;
+ Ok::<_, AppError>((persisted, image_src, resource, asset, completed_project))
},
)
.await
- .map_err(|error| {
- tracing::warn!(
- provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
- task_id = %task_id,
- object_key = %persisted.object_key,
- error = %error,
- "editor_pixel_art_snap_asset_persistence_failed_after_object_put"
- );
- error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
- })?;
- let resource = generated_asset.resource.ok_or_else(|| {
- tracing::warn!(
- provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
- task_id = %task_id,
- object_key = %persisted.object_key,
- "editor_pixel_art_snap_project_resource_missing_after_object_put"
- );
+ // 中文注释:超时发生在 PUT 已经发出之后,对象可能已经落盘也可能没有——正是契约要覆盖的
+ // 未知结果,必须带标记,否则客户端会把它当确定失败、直接诱使用户重试再造一份。
+ .map_err(|_| {
editor_pixel_art_snap_failure(
- StatusCode::INTERNAL_SERVER_ERROR,
- "完美像素结果未创建项目资源。",
+ StatusCode::GATEWAY_TIMEOUT,
+ "完美像素结果持久化超出处理预算。",
)
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
- })?;
- let asset = generated_asset.asset.ok_or_else(|| {
- tracing::warn!(
- provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
- task_id = %task_id,
- object_key = %persisted.object_key,
- resource_id = %resource.resource_id,
- "editor_pixel_art_snap_account_asset_missing_after_object_put"
- );
- editor_pixel_art_snap_failure(
- StatusCode::INTERNAL_SERVER_ERROR,
- "完美像素结果未创建账号素材。",
- )
- .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
- })?;
- // 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经
- // 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。
- let completed_project = complete_editor_canvas_generation(
- &state,
- owner_user_id.as_str(),
- Some(project_id.as_str()),
- Some(&payload.canvas_completion),
- Some(&resource),
- )
- .await
- .map_err(|error| {
- tracing::warn!(
- provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
- task_id = %task_id,
- object_key = %persisted.object_key,
- resource_id = %resource.resource_id,
- error = %error,
- "editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence"
- );
- error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
- })?;
+ })??;
Ok(json_success_body(
Some(&request_context),
@@ -5012,7 +5048,7 @@ pub async fn snap_editor_image_to_pixel_art(
width,
height,
source_type: "generated",
- task_id,
+ task_id: response_task_id,
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
provider: EDITOR_PIXEL_ART_SNAP_PROVIDER,
resource,
@@ -12162,6 +12198,31 @@ mod tests {
);
}
+ #[test]
+ fn pixel_art_server_worst_case_fits_inside_the_client_timeout() {
+ // 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端
+ // `snapEditorImageToPixelArt` 配的是 120 秒;服务端最坏合法时长是处理预算加持久化
+ // 预算之和——处理段(准入、归属校验、下载、规整)由 processing_deadline 封顶,
+ // 持久化段独立起算由 persistence_deadline 封顶。两者相加必须真小于客户端超时,
+ // 否则客户端会在服务端仍在合法工作时先 abort:对账采样到仍在途的操作,占位还在、
+ // 素材库还空,用户照提示核对却什么也看不到,重试就造出孤儿 OSS 对象。
+ const CLIENT_TIMEOUT: Duration = Duration::from_secs(120);
+ let server_worst_case =
+ EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION;
+
+ assert_eq!(server_worst_case, Duration::from_secs(90));
+ assert!(
+ server_worst_case < CLIENT_TIMEOUT,
+ "server worst case {server_worst_case:?} must stay under the client timeout {CLIENT_TIMEOUT:?}"
+ );
+ // 中文注释:余量不能只是「小于」。网络往返、代理缓冲和客户端计时精度都要吃掉一部分,
+ // 贴着上限等于没有余量。
+ assert!(
+ CLIENT_TIMEOUT - server_worst_case >= Duration::from_secs(30),
+ "client timeout should keep at least 30s of headroom over the server worst case"
+ );
+ }
+
#[test]
fn explicit_pixel_art_snap_contract_preserves_source_metadata() {
let request: EditorPixelArtSnapRequest = serde_json::from_value(json!({
@@ -12455,9 +12516,18 @@ mod tests {
"validate_editor_pixel_art_static_raster",
"snap_editor_pixel_art_strict",
"Some(processing_deadline)",
+ // 中文注释:持久化整段必须先被第二个 timeout_at 包住再开始写。此前这一段
+ // 完全无界,只受 OSS 客户端每请求 120 秒约束,服务端最坏合法时长超过客户端
+ // 超时,客户端会先 abort 而服务端还在合法工作。顺序断言把「预算在前、写入在后」
+ // 钉死:任何把 persist 挪到 timeout_at 之前的改动都会失败。
+ "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION",
+ "tokio::time::timeout_at(",
"persist_editor_generated_image",
"persist_editor_generated_asset",
"complete_editor_canvas_generation",
+ // 中文注释:持久化超时同样发生在 PUT 之后,属于未知结果,必须带标记。
+ "完美像素结果持久化超出处理预算。",
+ "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
],
);
assert_function_occurrence_count(
@@ -12498,14 +12568,17 @@ mod tests {
// 中文注释:第一次 OSS PUT 之后的每条失败路径都必须带 resultPersistenceStarted。
// 服务端持久化非事务,completion 失败经 map_editor_project_error 会变成 4xx,
// 客户端从状态码分不出失败在持久化前还是后;漏标一处,那条路径上的失败就会被前端
- // 判成「结果已知」,用户重试再造一份对象、资源和素材。四处分别对应:账号素材持久化
- // 失败、项目资源缺失、账号素材缺失、画布回填失败。
+ // 判成「结果已知」,用户重试再造一份对象、资源和素材。五处分别对应:账号素材持久化
+ // 失败、项目资源缺失、账号素材缺失、画布回填失败,以及持久化整段超出独立预算——
+ // 最后一处的超时同样发生在 PUT 之后,对象可能已落盘也可能没有,正是契约要覆盖的
+ // 未知结果。(helper 内部另有四处,由 editor_matting_releases_source_buffers_at_oss_boundaries
+ // 单独钉住;调用方拿到的是同一个 AppError,分不出内部走到了哪一步。)
assert_function_occurrence_count(
source,
"pub async fn snap_editor_image_to_pixel_art(",
"async fn validate_editor_background_removal_source",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
- 4,
+ 5,
);
assert_function_contains_in_order(
source,