diff --git a/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt b/CUserslinghAppDataLocalTempclaudeC--projects-narrative-Genarrative1f4d2af5-0931-4248-aee7-f6018dfc3969scratchpaddiff.txt new file mode 100644 index 000000000..4eac74b8a --- /dev/null +++ b/CUserslinghAppDataLocalTempclaudeC--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,