From 8aea7364b6801abbfcf0cd2b9bcdc2fe68f7e488 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 10 Aug 2026 04:45:02 +0000 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E5=8E=9F=E5=B0=BA=E5=AF=B8=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直接持久化逻辑分辨率 PNG 并使用最终实际尺寸 升级手动完美像素算法版本并拦截跨版本重放孤儿对象 补充非整除网格和前端权威对账回归测试 同步完美像素契约、决策记录与排障说明 --- .../shared-memory/decision-log.md | 10 ++ docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 12 ++- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 +- ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 8 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 10 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 8 +- .../crates/api-server/src/editor_project.rs | 95 +++++++++++++++++-- .../platform-image/src/pixel_art_snapper.rs | 63 +++++++----- .../useImageCanvasGenerationWorkflow.test.tsx | 36 +++++-- .../useImageCanvasGenerationWorkflow.ts | 12 +++ 11 files changed, 209 insertions(+), 60 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cd7fcc2f7..2d0b6eb37 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6896,3 +6896,13 @@ - 一致性:`start-tauri-dev.mjs` 是端口选择权威,最终端口通过 Tauri CLI `--config` 覆盖 `build.devUrl`,通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,并通过 Vite CLI `--port` 启动严格监听。父启动器选定端口后,子启动器只允许严格使用同一端口,配套后端漂移必须跳过该预留端口,竞态占用必须失败关闭。 - 安全边界:动态端口不恢复旧 Vite 复用。无法证明 worktree 归属的监听器仍不复用、不主动终止;同用户多 worktree 通过段内漂移并行,不通过共享未知服务并行。 - 验证:公共端口映射、Linux 默认槽位与段内漂移、非 Linux 兼容漂移、Tauri 动态配置、启动前预检、进程树收束、AGC typecheck / 配置门禁、编码检查和差异检查必须通过。 + +## 2026-08-10 完美像素最终持久化逻辑分辨率 PNG + +- 背景:SpriteFusion Pixel Snapper 上游在网格采样后直接输出 `(列切线数 - 1) × (行切线数 - 1)` 的逻辑图,每个检测单元恰好对应一个输出像素。Genarrative 首版在此后增加了 nearest 输入尺寸恢复;当输入宽高不能被逻辑列数、行数整除时,最近邻只能把同一逻辑像素分配到 `floor / ceil` 数量不等的目标列或行,最终物理像素块宽窄不一。已有 `128 → 64` 的整数倍测试没有覆盖该问题。 +- 决策:生成请求勾选 `style="pixelArt"` 与已有图片手动 `POST /api/editor/images/pixel-art-snaps` 共用同一输出语义:snapper 直接编码并持久化唯一的逻辑分辨率 PNG,不再 nearest 恢复到源图、RGBA 输入、业务交付或 generation dialog 占位尺寸。成功输出宽高固定为 `(columns.len() - 1) × (rows.len() - 1)`,允许与输入、交付和占位尺寸不同;响应、project resource、账号素材和结果 layer 一律记录最终 PNG 的实际宽高。 +- 保留边界:普通图片和角色在规整前执行的 Lanczos 交付尺寸归一继续保留;角色 / 图标的平底网格分析源与透明 RGBA 采样源仍必须同尺寸,Alpha 覆盖、Alpha 加权 RGB、二值 Alpha、P30 步长估算、确定性采样、输入上限、deadline、strict 无网格拒绝和失败降级尺寸守卫全部不变。这些约束保护输入坐标系、资源安全或失败路径,不构成成功输出与输入同尺寸的承诺。 +- 持久化边界:数量增量保持不变。普通图片只保存一张最终逻辑主图;角色保留一张 provider 原图与一张最终透明逻辑主图;图标保留一张 provider 原图、一张最终透明逻辑图集和原有成功切片;手动完美像素只保存一张最终逻辑 PNG。不得另外保存输入尺寸恢复版、像素化前后双份主图、预览、诊断或报告,不修改 asset kind、队列类型、数据库 schema、路由或请求 / 响应字段形状。 +- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。同一稳定 operation 已有结果时,candidate object key 相同才继续既有 exact replay;key 不同或既有稳定资源缺 key 时,必须在 preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,由客户端 GET 权威项目收口,不得冒充本次请求已经设置 `resultPersistenceStarted`。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 +- 历史边界:本条覆盖 2026-07-28 首发决策中“逻辑结果 nearest 恢复交付尺寸 / 逻辑图不持久化”和 2026-07-30 手动入口中“右侧新增同尺寸 PNG / 不保存逻辑低分辨率图”的旧口径;旧条目作为历史记录保留,不回写改造。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 68dd5e621..17c3f8367 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4470,3 +4470,11 @@ - 原因:`normalize_generated_asset_folder_id` 只处理 `project`、旧 `folder-*` 和默认目录 ID 的兼容映射,不读取 SpacetimeDB;真正的 `require_owned_asset_folder` 位于生成结果持久化末端。把“失败会退款”误当成副作用补偿,漏掉退款不能撤销 provider 请求与 OSS PUT。 - 处理:所有付费编辑器生成在队列 enqueue 前和 worker / inline 执行前复用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选项目及归一化目录;读取失败和归属不匹配一律失败关闭。helper 返回 canonical 项目与目录并覆写后续入队 / worker / 原子准备使用的 payload,不能校验 trim 后的项目却持久化原始空白值。角色图片、角色动作、图标 spritesheet 与 UI 提取省略目录时按实际默认目录预检;默认目录允许尚未创建,自定义目录必须存在且 owned。预检不替代最终 procedure 复验,也不保证跨外部调用的目录锁定。 - 验证:源码顺序回归必须覆盖图片生成、图片修改、图标 spritesheet、UI 设计图提取、视频、角色动作、SFX 与 BGM 的 enqueue / direct 两层,证明纯本地格式和 `data:` / `blob:` 稳定引用门禁先执行,canonical target 在预检后写回 payload,远端引用解析、generation input rebuild、扣费、入队、provider 与 OSS 均留在预检之后;模块侧扫描证明预检只调用 runtime identity、项目、目录只读校验且不含 insert / update / delete,并覆盖带空白项目、`project`、旧 `folder-*`、默认目录 ID、自定义目录与 `None` 归一化。 + +## 非整除 nearest 会让逻辑像素块宽窄不一(2026-08-10) + +- 现象:像素规整后的图片虽然保持了源图宽高,放大观察却能看到相邻逻辑块占用的物理列数或行数不同,表现为部分块更宽、部分块更窄;整数倍样例看起来正常,换一张网格数不能整除输入尺寸的图才复现。 +- 原因:逻辑图宽高为检测后的列数、行数。把 `C × R` 的逻辑图用 nearest 恢复到 `W × H` 时,只要 `W % C != 0` 或 `H % R != 0`,目标栅格就只能在不同逻辑像素间分配 `floor / ceil` 数量的列或行;nearest 能避免混色,却不能让非整数缩放后的块严格等大。只用 `128 × 128 → 64 × 64 → 128 × 128` 这类整数倍测试会掩盖问题。 +- 处理:最终资产直接编码一格一像素的逻辑分辨率 PNG,不再执行输入 / 交付尺寸 nearest 恢复,也不要求成功输出与源图或占位尺寸相等。普通图片和角色的前置 Lanczos 交付尺寸归一仍用于确定检测输入;平底网格源与透明 RGBA 源仍必须同尺寸,不能把“取消输出同尺寸”误解为放开两个内部采样坐标系。 +- 验证:使用至少一组逻辑列数或行数不能整除输入尺寸的图片,断言输出宽高等于切线数减一而不是输入宽高;同时核对响应、project resource、账号素材和结果 layer 都记录最终 PNG 实际尺寸,且只持久化一个最终 PNG,没有输入尺寸恢复版、诊断图或额外资源。失败降级用例继续验证 Alpha / 交付尺寸守卫,不应因成功输出改为逻辑分辨率而删除。 +- 关联:`server-rs/crates/platform-image/src/pixel_art_snapper.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f1b999b56..c13da07e1 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -52,10 +52,10 @@ - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。 - 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。 -- 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 -- 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 +- snapper 在内存中把每对相邻横纵切线围成的采样单元各压成一个输出像素,最终直接编码并持久化唯一的逻辑分辨率 PNG;不得再用 nearest 或其它插值把逻辑图恢复到当前 RGBA 输入尺寸或业务交付尺寸。普通图片和角色仍先执行既有 Lanczos 交付尺寸归一,无法安全归一时仍保留 provider 实际尺寸;图标输入仍以已持久化平底原图的实际尺寸为准。这些规则只定义网格检测与 RGBA 采样输入,不构成成功输出与输入同尺寸的承诺。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 +- 像素模式的持久化增量必须为零:普通图片仍只上传一张最终逻辑分辨率主图;角色仍只保留原有 provider 原图与一张最终透明逻辑分辨率主图;图标仍只保留原有 provider 原图、一张最终透明逻辑分辨率图集和实际成功的切片。禁止另存输入尺寸恢复版、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 -- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建一张逻辑分辨率 PNG 派生结果;派生 PNG 的内在宽高取最终网格列数和行数,可以与源图及占位尺寸不同。音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 - 裁扩创建资源时,未携带 `sourceResourceId` 仍允许保存无正式来源引用的确定性结果;一旦携带该 ID,api-server 必须确认它属于当前 owner 和当前项目,否则以 `400` 拒绝,禁止同时持久化悬空 / 越权 `sourceResourceId` 和空来源配方。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 @@ -65,8 +65,9 @@ - B 层的边界是可验证的、且已确认只服务完美像素:`requiresLiveSession: true` 全仓仅有一处置位(完美像素提交路径),`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点也都在完美像素的提交、重试与恢复上。直接体量:`perfectPixelOperationStore.ts` 203 行(测试 228 行)、`useInlineGenerationPlaceholderExpiry.ts` 140 行(测试 401 行)、`hydratePerfectPixelOperation` 128 行,加上工作流里的恢复 effect 与窗口锚定,生产代码约 1100 行、测试约 2500 行(后两个数字是估算,前面几个是实测)。同为免费、同步、无 durable job 的手动图集拆分只用约 85 行客户端代码(失败即报错,`taskId` 用随机 UUID,无幂等、无对账),是本仓库对同类问题的既有廉价答案;完美像素额外的 B 层是**特例而非范式**,不得据它给其它链路加同样的机制。 - 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 - 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 -- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 +- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对唯一的逻辑分辨率 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;resource、asset、响应与图层使用该 PNG 的实际宽高,不要求与源图或占位尺寸相等,也不得另存输入尺寸恢复版、诊断图或前后对比图。 - strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 +- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。若 owner-scoped 项目快照中同一稳定 resource 已存在,candidate object key 相同才继续 exact replay;key 不同或既有 resource 缺 key 时,后端必须在 preflight / OSS PUT 前返回 `operationResultAlreadyExists=true`,前端 initial 与 retry 两条 catch 都按稳定 task GET 项目对账。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避 preflight 到提交之间的跨版本 TOCTOU。 - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 - unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 - 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 @@ -179,7 +180,7 @@ - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图进入改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 -- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 使用 nearest 把逻辑网格恢复到同一输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。 +- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色仍先完成 Lanczos 交付尺寸归一,snapper 随后直接把一格一像素的逻辑网格编码为最终 PNG,不再 nearest 恢复到输入或交付尺寸,也不得执行其它后置尺寸插值。最终 PNG 宽高可以与规整输入不同,响应与资源快照按实际 PNG 尺寸记录。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 @@ -198,6 +199,7 @@ - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 +- `details.operationResultAlreadyExists = true` 是另一种必须 GET-only 收口的明确结果:它说明同一稳定 operation 的旧权威资源已经存在、当前请求尚未 PUT。客户端不得把它显示为普通失败、不得自动 POST 重放,也不得据此声称当前请求的持久化已开始。 - 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 - 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,其新请求配方固定为 `version: 2 / action: image.perfect-pixel / fields: []`。账本外层版本不升级;嵌套 generationInputs 以共享严格白名单恢复,V2 完整接受稳定 `id`、有限数字、布尔值和可省略 `label`,旧 `{fields,references}` 请求继续按原形恢复以维持历史 fingerprint,未知键或畸形 V2 失败关闭。`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7a16e1d33..ab6f8a876 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-23` +更新时间:`2026-08-10` ## 后端主线 @@ -252,7 +252,9 @@ npm run check:server-rs-ddd ## 外部服务与资产 - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 +- 完美像素成功输出尺寸:生成风格与手动 strict 入口都把每对相邻横纵切线围成的单元各采样成一个输出像素,并直接编码唯一的 `(columns.len() - 1) × (rows.len() - 1)` 逻辑分辨率 PNG;不得再 nearest 恢复到源图、RGBA 输入、业务交付或 generation dialog 占位尺寸。普通图片和角色的前置 Lanczos 交付尺寸归一继续保留,角色 / 图标的平底网格源与透明 RGBA 采样源仍必须同尺寸;这些是处理输入坐标系约束,不是最终输出同尺寸约束。响应、project resource、账号素材和结果 layer 的宽高全部读取最终 PNG 实际值。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 +- 完美像素跨算法版本重放:逻辑分辨率输出使用 `perfect-pixel-v2` 指纹。来源解析必须按稳定 resource ID 保留“无既有资源 / 有资源但缺 object key / 有有效 key”三态;纯 prepare 得到 candidate key 后,相同 key 才允许继续原有 exact replay,不同或缺 key 必须在 preflight 与 OSS PUT 前返回 `409` 且附 `operationResultAlreadyExists=true`,客户端据此 GET 项目权威快照。该错误不得附 `resultPersistenceStarted`。这个 API 护栏不提供数据库 reservation;滚动发布前仍须排空旧算法实例,避免 v1/v2 在读取与最终提交之间并发穿透。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 @@ -659,6 +661,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。编辑器生成完成使用候选布局的 `expected_revision` 执行 CAS,冲突时 object/resource/asset/binding/canvas/job/receipt 整笔回滚;queue 路径同时受 `job_id + worker_id + lease_token` 栅栏保护。调用方只能刷新权威项目后重算布局候选,不得重跑 provider 或更换原 operation。 - 完美像素 completion:同步处理成功后按当前 revision 重新读取权威 dialog;目标 dialog 存在时只写入一个派生 layer 并关联结果,目标 dialog 的删除已先持久化时跳过 layer / dialog 写回,不得按请求快照重建占位。该分支不属于 external job completion,允许此前已成功创建的 resource / asset 保留。 +- 完美像素 completion 的 placeholder 宽高只参与占位与落点,不约束结果媒体尺寸;结果 layer 必须使用最终逻辑分辨率 PNG 对应 resource 的实际宽高,不能因其与 placeholder 或源图不同而拒绝、拉伸或重采样。 - 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。 ### `editor_canvas_layout_migration` @@ -674,7 +677,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成媒体资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json`、序列媒体结果字段和历史 `public_showcase_enabled`。`asset_kind` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,有效类型按 `override ?? resource default` 计算,不能通过新增资源行模拟标签修改。`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,前者数组顺序是唯一帧序;角色动作预览视频是独立视频资源,不在最终序列资源行重复保存路径。“动作(原始视频)”只作为 `asset_kind = video` 的 provider 中间产物保存,其项目资源与账号素材都必须保持 `generation_inputs_json = NULL`;完整生成 / 重放输入只属于最终 `asset_kind = character-animation` 的序列资源和素材。`generation_inputs_json` 只保存用户可见生成 / 重放输入。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组、类型覆盖和资源引用以 `editor_canvas_layer` 为权威,媒体业务真相仍由资源表持有;生成器对象以 `editor_canvas_generation_dialog` 为权威。legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取;唯一缺资源例外是经过稳定站内路径校验的历史 `local-* + generated + image-sequence` 自包含图层,active 后只能续存同一不可变扩展。新写入不再把素材生成输入快照或正式序列帧结果作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 - 生成链路的新 resource ID 必须按 owner + operation kind + operation ID + stable slot 派生,只能在统一结果 procedure 中创建或完整比较;普通“同媒体复用”不得把它替换成另一随机 ID。 -- 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 +- 完美像素资源:处理成功时只允许一个逻辑分辨率最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源。resource 与同源 `editor_asset` 的尺寸都取最终 PNG 实际值,允许与源图、交付尺寸和占位尺寸不同;不得另存输入尺寸恢复版、诊断图或前后对比图。请求省略素材文件夹时结果落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与稳定指针;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。前端改造只在当前画布图层中匹配引用并取得运行时媒体类型,不新增 owner-only 工程资源 / 素材库 resolver;面板直接上传引用和已移出画布的引用均不恢复。可重新选择的引用由前端留空槽位、提示并交给提交门禁校验;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时在点击恢复路径显示明确错误并拒绝,运行期来源变化时再次校验。有效 V2 的引用缺失不得触发 legacy adapter。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index b3d683ac7..8e16122ff 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -1,5 +1,7 @@ # 图片画布结构化持久化与迁移回滚方案 +更新时间:`2026-08-10` + ## 1. 背景与目标 图片画布当前把全部图层和生成对话框整体序列化到 `editor_canvas.layers_json`。该字段接近原 256 KiB 上限时,资源登记仍可成功,而布局保存会返回 `413`;任务完成后重新读取后端旧快照,会把前端尚未持久化的参考图和生成结果从画布移除。 @@ -76,7 +78,7 @@ worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备 `POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId` 的 `canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。 -端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。 +端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、诊断图或前后对比图冒充结果。处理成功时,snapper 直接编码并 PUT 唯一一张 `(columns.len() - 1) × (rows.len() - 1)` 逻辑分辨率 PNG,不再 nearest 恢复到源图或交付尺寸,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。结果 resource、asset、响应与 layer 使用最终 PNG 的实际宽高,不要求与源图或 generation dialog 占位尺寸相等,也不得另存输入尺寸恢复版。 该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。 @@ -87,6 +89,8 @@ worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备 3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口; 4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。 +generation dialog 的 placeholder 宽高只参与占位与完成落点,不是结果媒体尺寸约束;completion 必须使用最终 PNG 资源的实际宽高构造结果 layer,不能为了匹配 placeholder 再缩放或拒绝逻辑分辨率结果。 + ## 4. 存量迁移 迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等: @@ -128,6 +132,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端 - structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。 - worker completion 已使用 durable receipt 与统一原子提交;V2 布局 CAS、object/resource/asset/binding、job 终态和 receipt 在同一 procedure 结果内返回。故障注入必须证明资产校验失败与 canvas revision 冲突均为零部分写入,成功后重放不新增记录、事件或 revision。 - structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。 -- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。 +- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个逻辑分辨率最终 PNG、至多一个 project resource 和一个账号素材,所有宽高均取该 PNG 实际值且允许与源图 / 占位不同。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。 - 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。 - 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 2b9545e5a..9689ffba1 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-08-07` +更新时间:`2026-08-10` ## 背景 @@ -75,11 +75,11 @@ - 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。 - 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。图标链路使用「每个图标素材均为像素风格」,**不得**使用「画面为像素风格」——图集生成后要按纯色抠像,绿幕底必须保持平整,画面级像素化要求会与同一段提示词里的「纯色背景必须平整无纹理、无渐变」互相拆台;一张图内是多个彼此分离的素材,需要逐个点名,避免模型只把其中一部分做成像素块。注入发生在 `build_editor_icon_spritesheet_prompt` 返回之后,该函数签名和输出契约不变。约束句只随工程化提示词写入**原图 spritesheet** 的 `editor_project_resource` prompt 列;透明结果的 prompt 列是 `"去除纯色背景"`,自动拆分的切片是 `"自动拆分图集"`,两者都不含约束句。与普通图片和角色形象不同,本链路**响应体**的 `prompt` 字段返回的也是含约束句的工程化提示词,而不是用户输入——图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),因此调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。以上 prompt 列写入与响应字段规则都是既有行为,与 `web/master` 一致,本次只是让被回传的模板多了一行。不新增 OSS PUT、项目资源、图集画布项或切片画布项。尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏),等实测。 -- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。 +- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;这两份内部输入仍必须同尺寸。规整结果按一格一输出像素直接编码为逻辑分辨率透明 spritesheet,不再经过独立的最终尺寸处理,成功后才进入原有连通域自动拆分。 - 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 -- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,即前述平底 provider 原图的实际尺寸。图标链路不执行角色链路的前置 Lanczos 交付尺寸归一,nearest 也不是规整后的独立交付尺寸恢复。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 -- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。 +- snapper 直接把逻辑分辨率图编码为最终透明 spritesheet;不得再用 nearest 或其它插值恢复到前述平底 provider 原图尺寸。图标链路继续不执行角色链路的前置 Lanczos 交付尺寸归一,但最终透明图集宽高仍可以与规整输入不同。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。开启时透明图集本身就是唯一的逻辑分辨率 PNG;禁止另存输入尺寸恢复版、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与 provider 原图或生成占位尺寸相等。 - 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`。BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,都统一只保留 provider 原图且不拆分,像素规整不运行;像素规整自身失败但透明图仍通过完整解码和尺寸守卫时,才保留该透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达可信透明图成功后的自动拆分失败。 ## 去背与保存 @@ -103,7 +103,7 @@ - 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 -- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 +- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,直接持久化逻辑分辨率透明图集;该图集可以与规整输入尺寸不同,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 - 图标素材生成可以上传普通附加参考图;提交时图标主规范图走 `referenceId`,且只提交当前 owner 的项目资源 ID 或素材 ID。普通附加参考图单独走 `referenceImageSrcs`,继续允许稳定 `objectKey` / 项目资源 ID / 素材 ID,但禁止 Data URL / Blob URL。两类引用都写入 `generationInputs.references`,不得用普通附加参考图协议放宽主规范边界。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 6bc6a21b3..aeffa21ca 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-08-07` +更新时间:`2026-08-10` ## 背景 @@ -69,8 +69,8 @@ - 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。 - 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。 -- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,该输入已经是前述 Lanczos 归一后的交付尺寸,或尺寸归一无法安全执行时保留的 provider 实际尺寸。nearest 不是新的交付尺寸归一,规整完成后也不再执行第二次 Lanczos 或其它尺寸恢复。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 -- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。禁止上传逻辑低分辨率图、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。 +- snapper 把每对相邻横纵切线围成的采样单元各压成一个输出像素,并把这张逻辑分辨率图直接编码为最终透明 PNG;不得再用 nearest 或其它插值恢复到当前 RGBA 输入尺寸。前述 Lanczos 交付尺寸归一继续保留,它只定义网格分析源与 RGBA 采样源的共同坐标系;两份内部输入仍必须同尺寸,但最终 PNG 宽高可以与该输入不同。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。开启时透明主图本身就是唯一的逻辑分辨率 PNG;禁止另存输入尺寸恢复版、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与生成交付尺寸或占位尺寸相等。 - 本功能不修改 BgFilter `flat` 调用、`cross_check=on`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图的既有收口且不运行像素规整;像素规整自身失败时保留已成功的透明图并继续原有持久化,通过通用 `warning` 非致命提示,不退款。 - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: @@ -119,7 +119,7 @@ - `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 - 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。 -- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 +- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,直接持久化逻辑分辨率透明 PNG;该 PNG 可以与规整输入尺寸不同,最终 OSS PUT、项目资源和画布图层数量不得增加。 - 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4b98fa4e6..b263aa632 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -201,10 +201,11 @@ const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs( /// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于 /// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; +const EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL: &str = "operationResultAlreadyExists"; const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; -const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1"; +const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v2"; static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, @@ -3402,8 +3403,8 @@ where None }; - // 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行,snapper 会把结果 - // 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。 + // 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行;snapper 直接输出 + // 检测到的逻辑像素图,因此最终文件尺寸允许小于规整前的交付尺寸。 if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt { let (pixel_art_image, pixel_art_error) = snap_editor_pixel_art_or_original(image, request_context.external_call_deadline()) @@ -6036,6 +6037,8 @@ struct EditorPixelArtSourceResolution { asset_kind: Option, generation_input_reference: Option, existing_result_generation_inputs: Option>, + // 外层 Some 表示稳定结果资源已存在;内层 None 保留“记录存在但缺 object_key”的损坏形状。 + existing_result_object_key: Option>, } fn resolve_editor_pixel_art_persisted_generation_inputs( @@ -6119,14 +6122,17 @@ async fn resolve_editor_pixel_art_source_for_owner( expected_result_resource_id: &str, expected_result_task_id: &str, ) -> Result { - let existing_result_generation_inputs = project + let existing_stable_resource = project .resources .iter() - .find(|resource| { - resource.resource_id.trim() == expected_result_resource_id - && resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) - }) - .map(|resource| resource.generation_inputs.clone()); + .find(|resource| resource.resource_id.trim() == expected_result_resource_id); + let existing_result = existing_stable_resource.filter(|resource| { + resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) + }); + let existing_result_generation_inputs = + existing_result.map(|resource| resource.generation_inputs.clone()); + let existing_result_object_key = existing_stable_resource + .map(|resource| normalize_optional_string(resource.object_key.clone())); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, @@ -6326,9 +6332,27 @@ async fn resolve_editor_pixel_art_source_for_owner( asset_kind, generation_input_reference, existing_result_generation_inputs, + existing_result_object_key, }) } +fn ensure_editor_pixel_art_existing_result_matches_candidate_object_key( + existing_result_object_key: Option>, + candidate_object_key: &str, +) -> Result<(), AppError> { + let Some(existing_result_object_key) = existing_result_object_key else { + return Ok(()); + }; + if existing_result_object_key == Some(candidate_object_key) { + return Ok(()); + } + Err(editor_pixel_art_snap_failure( + StatusCode::CONFLICT, + "同一完美像素操作已有其它权威结果,请先读取项目状态对账。", + ) + .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true))) +} + fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { normalize_optional_string(asset_folder_id) .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) @@ -6592,6 +6616,7 @@ pub async fn snap_editor_image_to_pixel_art( })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; + let existing_result_object_key = source.existing_result_object_key; let authoritative_generation_inputs = rebuild_editor_generation_inputs_with_authoritative_references( payload.generation_inputs.take(), @@ -6690,6 +6715,16 @@ pub async fn snap_editor_image_to_pixel_art( "genarrative", )?; let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); + // 中文注释:算法版本变化会改变 fingerprint 与 object key,但 operation/dialog 和稳定记录 + // ID 保持不变。若旧版本结果已经落库,必须在任何 preflight/OSS PUT 之前交给客户端读取 + // 权威项目对账;否则新对象先写入、procedure 再因稳定记录被旧结果占用而拒绝,会留下孤儿。 + // 相同 object key 仍沿用既有 PUT + procedure exact compare-and-return 重放语义。 + ensure_editor_pixel_art_existing_result_matches_candidate_object_key( + existing_result_object_key + .as_ref() + .map(|object_key| object_key.as_deref()), + prepared_object_key.as_str(), + )?; let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); let mut project_resource = EditorProjectResourceCreateRecordInput { resource_id: persistence_identity.resource_id.clone(), @@ -14918,7 +14953,7 @@ mod tests { assert!(legacy_error.is_none()); let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice()) .expect("legacy output should remain a valid image"); - assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128)); + assert_eq!((legacy_output.width(), legacy_output.height()), (64, 64)); } #[test] @@ -15245,6 +15280,7 @@ mod tests { #[test] fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() { + assert_eq!(EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, "perfect-pixel-v2"); for (record_kind, expected) in [ ("asset-object", "8a264e1086ee3d6878d753aec254e0a5"), ("project-resource", "9eee84b77e8828ca8f5042919198ac1c"), @@ -15325,6 +15361,42 @@ mod tests { assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint); } + #[test] + fn explicit_pixel_art_snap_reconciles_a_different_existing_result_before_put() { + let candidate_object_key = "pixel-art-snaps/v2.png"; + for existing_object_key in [None, Some(Some(candidate_object_key))] { + assert!( + ensure_editor_pixel_art_existing_result_matches_candidate_object_key( + existing_object_key, + candidate_object_key, + ) + .is_ok() + ); + } + + for existing_object_key in [Some(Some("pixel-art-snaps/v1.png")), Some(None)] { + let error = ensure_editor_pixel_art_existing_result_matches_candidate_object_key( + existing_object_key, + candidate_object_key, + ) + .expect_err("a different or damaged stable result must fail before upload"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert_eq!( + error.details().and_then(|details| details + [EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL] + .as_bool()), + Some(true) + ); + assert_eq!( + error + .details() + .and_then(|details| details[EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL].as_bool()), + None, + "the compatibility guard runs before this request starts persistence" + ); + } + } + #[test] fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() { let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( @@ -15566,6 +15638,9 @@ mod tests { // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 "prepare_editor_generated_image_object_data(", + // 中文注释:跨算法版本的稳定 operation 可能已有不同 object key;比较必须 + // 位于任何 preflight/PUT 之前,不得等 procedure exact compare 才发现冲突。 + "ensure_editor_pixel_art_existing_result_matches_candidate_object_key(", "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION", "tokio::time::timeout_at(", ".preflight_editor_pixel_art_result(", diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index 8e46a169d..e22f63c22 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -8,15 +8,12 @@ //! This production variant separates the flat image used for grid detection //! from the straight-RGBA image used for sampling. Soft alpha contributes to //! per-cell coverage and alpha-weighted RGB, while the delivered PNG uses only -//! binary alpha and is resized back to the RGBA source dimensions with nearest -//! neighbour sampling. +//! binary alpha and is emitted at the detected logical-grid dimensions, with +//! one output pixel per sampled grid cell. use std::{error::Error, fmt, io::Cursor, time::Instant}; -use image::{ - DynamicImage, ImageFormat, Rgba, RgbaImage, - imageops::{self, FilterType}, -}; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use crate::DownloadedImage; @@ -153,14 +150,14 @@ impl SnapConfig { /// `grid_source` supplies the RGB structure used to detect the grid. /// `rgba_source` supplies straight RGBA used for coverage and color sampling. /// Both decoded images must have exactly the same dimensions. The returned -/// image is always a PNG at the original `rgba_source` dimensions. Its alpha -/// channel contains only `0` or `255`, and fully transparent pixels are -/// canonical `[0, 0, 0, 0]`. +/// PNG uses the detected logical-grid dimensions, with one output pixel per +/// sampled grid cell. Its alpha channel contains only `0` or `255`, and fully +/// transparent pixels are canonical `[0, 0, 0, 0]`. /// -/// The deadline is checked before and after non-cooperative codec/resize -/// operations, and periodically inside the K-means, profile, and cell-sampling -/// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without -/// producing a partial image. Pass `None` to opt out of deadline checks. +/// The deadline is checked before and after non-cooperative codec operations, +/// and periodically inside the K-means, profile, and cell-sampling loops. +/// Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without producing +/// a partial image. Pass `None` to opt out of deadline checks. pub fn snap_pixel_art_with_deadline( grid_source: &DownloadedImage, rgba_source: &DownloadedImage, @@ -242,10 +239,8 @@ fn snap_pixel_art_with_grid_policy( config.alpha_threshold, deadline, )?; - deadline.check("最近邻尺寸恢复")?; - let delivered = resize_nearest(&logical, rgba_image.width(), rgba_image.height()); deadline.check("PNG 编码")?; - let encoded = encode_png(delivered)?; + let encoded = encode_png(logical)?; deadline.check("PNG 编码")?; Ok(encoded) } @@ -1000,10 +995,6 @@ fn rounded_weighted_channel(weighted_sum: u64, alpha_sum: u64) -> u8 { ((weighted_sum + alpha_sum / 2) / alpha_sum).min(255) as u8 } -fn resize_nearest(source: &RgbaImage, width: u32, height: u32) -> RgbaImage { - imageops::resize(source, width, height, FilterType::Nearest) -} - #[cfg(test)] mod tests { use super::*; @@ -1132,7 +1123,7 @@ mod tests { let strict = snap_pixel_art_strict_with_deadline(&source, &source, None) .expect_err("explicit strict action should reject an undetected grid"); - assert_eq!(decode_output(&legacy).dimensions(), (128, 128)); + assert_eq!(decode_output(&legacy).dimensions(), (64, 64)); assert!(matches!(strict, PixelArtSnapError::GridNotDetected)); } @@ -1176,7 +1167,7 @@ mod tests { } #[test] - fn output_keeps_physical_size_and_uses_nearest_blocks() { + fn output_encodes_the_logical_grid_without_nearest_resize() { let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255])); let mut rgba_image = RgbaImage::new(128, 128); for y in 0..128 { @@ -1201,13 +1192,37 @@ mod tests { ) .expect("pixel snapping should succeed"); let decoded = decode_output(&output); + let mut expected = RgbaImage::new(64, 64); + for y in 0..64 { + for x in 0..64 { + expected.put_pixel(x, y, Rgba([x as u8, y as u8, ((x + y) % 256) as u8, 255])); + } + } - assert_eq!(decoded.dimensions(), rgba_image.dimensions()); - assert_eq!(decoded, rgba_image); + assert_eq!(decoded.dimensions(), (64, 64)); + assert_eq!(decoded, expected); assert_eq!(output.mime_type, "image/png"); assert_eq!(output.extension, "png"); } + #[test] + fn non_divisible_source_dimensions_still_emit_one_pixel_per_logical_cell() { + let source_dimensions = (127, 127); + let source = downloaded_png(RgbaImage::from_pixel( + source_dimensions.0, + source_dimensions.1, + Rgba([30, 80, 140, 255]), + )); + + let output = snap_pixel_art_with_deadline(&source, &source, None) + .expect("legacy uniform fallback should emit its logical grid"); + let decoded = decode_output(&output); + + assert_eq!(decoded.dimensions(), (64, 64)); + assert_ne!(decoded.dimensions(), source_dimensions); + assert!(decoded.pixels().all(|pixel| pixel.0 == [30, 80, 140, 255])); + } + #[test] fn output_is_deterministic_and_alpha_is_binary() { let grid = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([30, 40, 50, 255]))); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 339c46c7d..9be10dd56 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -2995,20 +2995,31 @@ describe('useImageCanvasGenerationWorkflow', () => { it('retries the exact persisted request without creating a second dialog or operation', async () => { const flushProjectPersistence = vi.fn().mockResolvedValue(undefined); + const applyProjectSnapshot = vi.fn(); snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) - .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => - createMismatchedPerfectPixelResult(request), + .mockRejectedValueOnce( + new ApiClientError({ + message: '同一完美像素操作已有其它权威结果,请先读取项目状态对账。', + status: 409, + code: 'HTTP_409', + details: { operationResultAlreadyExists: true }, + }), ); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as | EditorPixelArtSnapInput | undefined; - return createConflictingPerfectPixelProject( - request!.canvasCompletion.dialogId, - ); + return snapImageToPerfectPixelsMock.mock.calls.length === 1 + ? createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ) + : createPerfectPixelProject( + request!.canvasCompletion.dialogId, + 'applied', + ); }); render( { src: '/generated-images/editor/source.png', }), ]} - applyProjectSnapshot={vi.fn()} + applyProjectSnapshot={applyProjectSnapshot} flushProjectPersistence={flushProjectPersistence} />, ); @@ -3076,6 +3087,12 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, { preferLatestGenerationDialogs: true, }); + await waitFor(() => { + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-1' }), + { type: 'perfect-pixel', count: 1 }, + ); + }); }); it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => { @@ -3559,7 +3576,10 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('reconciles a responded failure that the server marked as post-persistence', async () => { + it.each([ + ['post-persistence', { resultPersistenceStarted: true }], + ['an existing operation result', { operationResultAlreadyExists: true }], + ])('reconciles a responded failure marked as %s', async (_label, details) => { const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); let operationId = ''; @@ -3571,7 +3591,7 @@ describe('useImageCanvasGenerationWorkflow', () => { message: '画布版本冲突。', status: 409, code: 'HTTP_409', - details: { resultPersistenceStarted: true }, + details, }); }, ); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 98c6cbfaf..5164c8161 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -2799,6 +2799,10 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { resultPersistenceStarted?: unknown } | null) ?.resultPersistenceStarted === true; + const operationResultAlreadyExists = + error instanceof ApiClientError && + (error.details as { operationResultAlreadyExists?: unknown } | null) + ?.operationResultAlreadyExists === true; const outcomeMayBePersisted = perfectPixelPostAttempted && Boolean(perfectPixelDialogId) && @@ -2808,6 +2812,9 @@ export function useImageCanvasGenerationWorkflow({ // 而此时 api-server 可能已经走完 OSS PUT 与落库。必须算作未知结果去对账。 (!(error instanceof ApiClientError) || persistenceMayHaveStarted || + // 中文注释:跨算法版本护栏在本请求 PUT 前发现同 operation 已有权威结果;它不应 + // 冒充 resultPersistenceStarted,但同样必须读取项目事实完成收口。 + operationResultAlreadyExists || isGatewayUnknownOutcomeError(error)); let reconciledMessage: string | undefined; let keepPendingConfirmation = false; @@ -3041,10 +3048,15 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { resultPersistenceStarted?: unknown } | null) ?.resultPersistenceStarted === true; + const operationResultAlreadyExists = + error instanceof ApiClientError && + (error.details as { operationResultAlreadyExists?: unknown } | null) + ?.operationResultAlreadyExists === true; const outcomeMayBePersisted = postAttempted && (!(error instanceof ApiClientError) || persistenceMayHaveStarted || + operationResultAlreadyExists || isGatewayUnknownOutcomeError(error)); if (outcomeMayBePersisted) { const verdict = await reconcilePerfectPixelProject(