完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时

处理预算只覆盖到规整为止,持久化阶段无界,仅受 OSS 客户端每请求 120 秒约束,
PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长
超过 270 秒,远大于客户端 120 秒。客户端因此会在服务端仍在合法工作时 abort,
对账采样到仍在途的操作——占位还在、素材库还空,用户照提示核对什么也看不到,
重试就造出孤儿 OSS 对象。

给持久化整段套独立预算 60 秒,服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内
留 30 秒余量。独立起算而不与处理预算取 min:持久化已付出 OSS PUT 的代价,因
下载慢被砍预算、中途放弃只会留下孤儿,取 min 方向正好反了。

超时发生在 PUT 之后,对象可能已落盘,必须带 resultPersistenceStarted;handler
内该标记计数由 4 升为 5,由既有守卫自己报出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 06:13:16 +00:00
parent cb5fdb5c94
commit cf397644c7
3 changed files with 583 additions and 101 deletions
@@ -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)}
/>
+ <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,
@@ -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`
+174 -101
View File
@@ -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,