diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index efdec49c5..ff13717ab 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3190,7 +3190,7 @@ "properties": { "code": { "type": "string", - "description": "自动拆分未完成的稳定原因码。" + "description": "自动拆分未完成的稳定原因码,包括原始连通域超限、局部候选拥挤、输出切片超限、处理超时、未识别到素材或切片持久化失败。" }, "reason": { "type": "string", @@ -3263,7 +3263,7 @@ "type": "null" } ], - "description": "图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。自动拆分不按 iconDescriptions 数量校验切片数。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。" + "description": "可信透明图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。" }, "prompt": { "type": "string" @@ -3327,7 +3327,7 @@ "type": "null" } ], - "description": "生成成功但风格归一化、尺寸恢复、透明背景处理或像素规整发生非阻断降级时返回。透明背景最终失败时不会进入拆分;其它通用告警可以与 sliceWarning 并存。" + "description": "生成成功但风格归一化、尺寸恢复、透明背景处理、Alpha 回贴、provider 原图修复性回读、透明图完整解码或像素规整发生非阻断降级时返回。source-only 降级只返回 provider 原图且不会进入拆分;其它通用告警可以与 sliceWarning 并存。" } } }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8d6fa58f..11bc64ec6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,11 +16,22 @@ --- +## 2026-07-29 图集切片必须受前置容量和有界 CPU 保护 + +- 背景:图标与 UI 图集的 alpha 连通域识别会在 async handler 上同步执行;原始连通域合并采用全量两两比较,`64` 个输出限制又晚于排序、裁剪和 PNG 编码。碎块或噪点图会放大 CPU 与内存成本,手动拆分、图标自动拆分和 UI 提取都受影响。另一方面,图标与 UI 的 Alpha 尺寸恢复、provider 原图回读或透明图解码失败此前只记日志,仍会把不可信透明图持久化并拆分。 +- 决策:`platform-image` 在每次 flood-fill 后累计所有原始连通域(包括随后过滤的噪点)并以 `4096` 为硬上限;合并只通过 `64px` 空间网格查询 `48px` 最大邻域内且满足辅助部件尺寸条件的候选,单网格最多登记 `256` 个组件、单 source 最多保留 `512` 个候选,拥挤时明确返回资源限制错误;调用方把固定 `maxOutputSlices=64` 传入 platform slicer,并在排序、裁剪和 PNG 编码前拒绝超限。三条入口统一走 2 路 semaphore、30 秒本地上界与请求绝对 deadline 共同保护的 `spawn_blocking`,permit 必须由 blocking 闭包持有。自动图标 / UI 超限以空切片和稳定 `sliceWarning` 完成,手动拆分返回 `422`,两者都不得产生任何切片 PUT、资源或画布切片;自动路径已成功的整张图集仍按既有契约保留。 +- source-only 收口:角色、图标和 UI 共用同一个 provider 原图收口 helper。BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只用已保存 provider 原图完成占位,返回 `completed + warning`;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分。provider 原图本身无法解码时在首次持久化前失败,不再伪造 `512×512` 元数据。 +- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/editor_project.rs`、图片画布图标与 UI 素材生成 / 手动拆分链路;不修改请求 DTO、扣费退款、SpacetimeDB schema 或成功路径多产物布局。 +- 验证方式:platform-image 覆盖大量独立 `4×4` 块、单像素噪点和 65 个有效输出;api-server 覆盖比例漂移、原图回读失败、截断透明 PNG、共享 source-only helper 无持久化副作用,以及三入口统一 bounded slicer。运行 `cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_project::tests --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +--- + ## 2026-07-29 像素规整降级必须复用交付尺寸守卫 - 背景:像素模式接入「角色带背景原图与透明图统一交付尺寸」后,删除了原先像素路径末尾的后置尺寸恢复。但像素规整的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)都直接返回 BgFilter 原始输出并把尺寸错误置为 `None`,跳过了非像素路径已有的尺寸比对与 alpha 回贴。BgFilter 回图尺寸漂移是已知现象,叠加并发上限 2 导致的 permit 超时后,角色会绕过「改用已保存的同尺寸原图完成画布」的安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。 - 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败如实返回尺寸错误,由调用方按各自既有语义处理。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。 -- 生效范围(不承诺超出这一范围):本决策只保证像素路径不再谎报「尺寸无误」,即不再把 BgFilter 原始输出连同 `None` 尺寸错误交回调用方。拿到尺寸错误之后怎么处理仍由各调用方既有语义决定,本次不改变任何调用方语义:角色生成会据此走「改用已保存的同尺寸原图完成画布」的安全降级,因此角色链路闭环;图标图集沿用 master 既有的非致命语义,只记录告警后继续持久化并拆分,因此在「BgFilter 尺寸漂移且回贴修复失败」时,图标仍可能持久化尺寸异常的透明图集——该残留属于 master 既有行为,未在本次范围内解决。 +- 生效范围(由同日后续决策补齐):像素路径继续保证不把 BgFilter 原始输出连同 `None` 尺寸错误交回调用方;角色、图标和 UI 拿到尺寸 / Alpha 错误后现已统一走 provider 原图 source-only 收口,不再持久化或拆分尺寸异常、比例异常或不可解码的透明图。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 - 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8bafd41c6..54f75f579 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,22 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图集切片上限必须早于合并、裁剪和编码 + +- 现象:透明图集含大量独立碎块或噪点时,接口长时间占用 async worker;最终即使报“超过 64 个切片”,此前仍已完成全量两两合并、裁剪和 PNG 编码。 +- 原因:原始连通域无上限,辅助部件合并全量扫描所有 pair,输出限制只在 platform slicer 返回后由 api-server 检查;UI 提取还绕过了该 wrapper。 +- 处理:platform slicer 对全部 flood-fill 连通域设置 `4096` 硬上限,用空间网格只查 `48px` 邻域候选;单网格最多 `256` 个组件、单 source 最多 `512` 个候选,避免拥挤网格重新退化为全量 pair。`maxOutputSlices` 在排序、裁剪和编码前拒绝。图标自动、手动和 UI 三入口统一在 2 路 semaphore 与 30 秒 / 请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 留在 blocking 闭包直到 CPU 工作退出。 +- 验证:覆盖大量独立 `4×4` 块、超过上限的单像素噪点、65 个有效输出和既有高光 / 阴影合并样本;手动超限必须发生在首次持久化前,自动超限不得产生切片 PUT、资源或画布切片。 +- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/editor_project.rs`。 + +## Alpha 恢复失败后不能继续持久化原始后处理图 + +- 现象:BgFilter 返回比例漂移、损坏或低分辨率图片,provider 原图修复性回读又失败时,图标 / UI 仍可能落库透明图与切片,尺寸元数据甚至回退为 `512×512`。 +- 原因:Alpha helper 会同时返回原后处理字节和错误;角色调用方会 source-only 早退,图标 / UI 却只写日志后继续。相同尺寸快路径还只读图片 header,没有完整解码。 +- 处理:角色、图标、UI 共用 provider 原图 source-only helper;比例漂移超过 `5%`、原图回读、Alpha 回贴或透明图完整解码任一失败都立即返回原图、通用 warning、空切片和空 `sliceWarning`,禁止透明图 PUT、派生资源、拆分和透明 / 切片画布层。provider 原图尺寸必须完整解码取得,不得伪造兜底值。 +- 验证:覆盖错比例 Alpha、缺失 provider 原图、合法 PNG header 但截断正文;结构断言 source-only helper 不含任何透明持久化、切片或多图层完成调用。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 工具 JSON Schema 的条件约束必须覆盖运行时默认值 - 现象:LLM 按工具 schema 生成的参数可以通过结构约束,但参数补默认值后被运行时校验拒绝,白白消耗一次工具修复轮次。例如固定 `gpt-image-2` 的 UI 工具仍暴露 `0.5K`,或视频调用省略 `model` 时 schema 允许 `1080p`,运行时却默认成 `seedance2.0-fast` 后拒绝。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d906ed40a..835477ab7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -85,6 +85,8 @@ ## 后端接口 +- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界 CPU helper:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=64` 在排序、裁剪和 PNG 编码前检查;整段 CPU 工作由 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 执行。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`。 + - `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。 - `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。 - `POST /api/editor/projects`:创建图片画布工程。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index e520ad764..8ed03321b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -298,6 +298,8 @@ npm run check:server-rs-ddd - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 - 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 +- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取共用受 2 路 semaphore、30 秒 / 请求 deadline 保护的 blocking helper;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在裁剪 / PNG 编码前执行 `maxOutputSlices=64`。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 + ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index fcb3a4387..93ef6b36a 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -70,15 +70,15 @@ - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 - 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,即前述平底 provider 原图的实际尺寸。图标链路不执行角色链路的前置 Lanczos 交付尺寸归一,nearest 也不是规整后的独立交付尺寸恢复。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 - 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。 -- 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`,fallback、Alpha 回贴和默认关闭 despill 的行为保持不变。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。 +- 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`。BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,都统一只保留 provider 原图且不拆分,像素规整不运行;像素规整自身失败但透明图仍通过完整解码和尺寸守卫时,才保留该透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达可信透明图成功后的自动拆分失败。 ## 去背与保存 - 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 -- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别透明图集中全部有效 alpha 连通域并执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 +- 透明背景处理正常成功时,父流程把带背景原图和经完整解码 / 尺寸守卫验证的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别 alpha 连通域并执行附加拆分。BgFilter 最终失败或后续 Alpha / 尺寸恢复、原图回读、透明图完整解码失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`、`sliceWarning=null`。该收口不捕获 phase 上报、provider 原图持久化或 `canvasCompletion` 写回错误;provider 原图本身解码失败时在首次持久化前失败,不允许用 `512×512` 伪造元数据。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 -- 响应通过 `iconImageSrcs` 返回成功切片素材。图标素材生成的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和自动命名规则:识别多少个有效素材就返回多少个,依次命名为 `素材 N`;用户提示词及 `iconDescriptions` 数组长度都不控制切片数量。 -- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。自动拆分和手动拆分共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。自动拆分失败以 `sliceWarning` 非阻断降级,手动拆分失败则返回接口错误。 +- 响应通过 `iconImageSrcs` 返回成功切片素材。图标自动拆分、手动 `拆分图集` 和 UI 提取复用同一个 bounded CPU helper 和 platform 实现:全部原始连通域(包括随后过滤的噪点)最多 `4096` 个,辅助部件通过 `64px` 空间网格只检查最大 `48px` 邻域候选;有效输出按视觉阅读顺序命名为 `素材 N`。 +- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `64` 个输出;输出限制在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。 ## 前端铺放规则 diff --git a/server-rs/crates/api-server/src/editor_green_screen.rs b/server-rs/crates/api-server/src/editor_green_screen.rs index c13d3abc1..ccd8874a3 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -211,7 +211,10 @@ pub(crate) fn remove_editor_generated_green_screen_background( fn map_editor_green_screen_error(error: GeneratedAssetSheetError) -> AppError { let status = match error { GeneratedAssetSheetError::DecodeImage { .. } => StatusCode::BAD_GATEWAY, - GeneratedAssetSheetError::InvalidRequest { .. } => StatusCode::BAD_REQUEST, + GeneratedAssetSheetError::InvalidRequest { .. } + | GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } + | GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } + | GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => StatusCode::BAD_REQUEST, GeneratedAssetSheetError::EncodeImage { .. } | GeneratedAssetSheetError::BuildHttpClient { .. } | GeneratedAssetSheetError::Oss(_) => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 400aa5ebf..2111c8e19 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18,7 +18,7 @@ use module_assets::{ use platform_image::{ DownloadedImage, generated_asset_sheets::{ - GeneratedAssetSheetConnectedIcon, + GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetError, slice_generated_icon_spritesheet_all_by_connected_components, }, }; @@ -108,6 +108,8 @@ const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; +const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; +const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; @@ -121,6 +123,10 @@ const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str = "raw-component-limit-exceeded"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str = "output-slice-limit-exceeded"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str = "merge-candidate-limit-exceeded"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; @@ -133,6 +139,12 @@ static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = Laz EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, )) }); +static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> = + LazyLock::new(|| { + Arc::new(tokio::sync::Semaphore::new( + EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, + )) + }); const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -1985,12 +1997,14 @@ pub(crate) async fn generate_editor_image_for_owner( error = %failure_message, "角色原图已保存,但透明背景处理失败,使用原图完成画布" ); - let completed_project = complete_editor_canvas_generation( + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - source_record.resource.as_ref(), + &source_record, + generation_warning, + dimension_warning.as_ref(), ) .await?; return Ok(json_success_body( @@ -2010,13 +2024,7 @@ pub(crate) async fn generate_editor_image_for_owner( resource: source_record.resource, asset: source_record.asset, project: completed_project, - warning: merge_editor_generation_warnings( - generation_warning, - Some(editor_postprocess_fallback_warning_with_dimension( - "生成任务成功,后处理失败。", - dimension_warning.as_ref(), - )), - ), + warning: fallback_warning, }, )); } @@ -2058,12 +2066,14 @@ pub(crate) async fn generate_editor_image_for_owner( error = ?error, "角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布" ); - let completed_project = complete_editor_canvas_generation( + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - source_record.resource.as_ref(), + &source_record, + generation_warning, + dimension_warning.as_ref(), ) .await?; return Ok(json_success_body( @@ -2085,13 +2095,7 @@ pub(crate) async fn generate_editor_image_for_owner( project: completed_project, // 中文注释:原图安全降级同样要保留此前累积的告警(例如未知 style 的 // unsupported-image-style),不能被后处理降级告警整条覆盖。 - warning: merge_editor_generation_warnings( - generation_warning, - Some(editor_postprocess_fallback_warning_with_dimension( - "生成任务成功,后处理失败。", - dimension_warning.as_ref(), - )), - ), + warning: fallback_warning, }, )); } @@ -2984,6 +2988,21 @@ fn apply_editor_postprocessed_alpha_to_provider_source( ) } +fn decode_editor_provider_source_dimensions( + image: &DownloadedOpenAiImage, + provider: &'static str, + message: &'static str, +) -> Result<(u32, u32), AppError> { + image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": provider, + "message": format!("{message}:{error}"), + })) + }) +} + fn apply_editor_postprocessed_alpha_using_provider_rgb( provider_source: &DownloadedOpenAiImage, postprocessed: &DownloadedOpenAiImage, @@ -3097,23 +3116,15 @@ fn editor_postprocessed_alpha_matches_delivery_dimensions( provider_width: u32, provider_height: u32, ) -> Result { - let (postprocessed_width, postprocessed_height) = - image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice())) - .with_guessed_format() - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-image-postprocess", - "message": format!("无法识别透明后处理图格式:{error}"), - })) - }) - .and_then(|reader| { - reader.into_dimensions().map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-image-postprocess", - "message": format!("无法读取透明后处理图尺寸:{error}"), - })) - }) - })?; + // 中文注释:不能只读取图片头中的尺寸。合法 header + 截断像素数据若穿过同尺寸 + // 快路径,后续会把不可解码的透明图持久化;这里必须完成整图解码。 + let decoded = image::load_from_memory(postprocessed.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "message": format!("透明后处理图不是有效图片:{error}"), + })) + })?; + let (postprocessed_width, postprocessed_height) = (decoded.width(), decoded.height()); Ok(postprocessed_width == provider_width && postprocessed_height == provider_height) } @@ -4657,9 +4668,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "message": "VectorEngine 未返回图标 spritesheet", })) })?; - let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) - .map(|image| (image.width(), image.height())) - .unwrap_or((512, 512)); + let (source_width, source_height) = decode_editor_provider_source_dimensions( + &image, + "editor-icon-spritesheet", + "图标 spritesheet 原图不是有效图片", + )?; let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), @@ -4735,12 +4748,14 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error = %failure_message, "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" ); - let completed_project = complete_editor_canvas_generation( + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - source_record.resource.as_ref(), + &source_record, + generation_warning, + None, ) .await?; return Ok(json_success_body( @@ -4760,12 +4775,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, - warning: merge_editor_generation_warnings( - generation_warning, - Some(editor_postprocess_fallback_warning( - "生成任务成功,后处理失败。", - )), - ), + warning: fallback_warning, }, )); } @@ -4803,8 +4813,38 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( source_width, source_height, error = ?error, - "图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出" + "图标透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + &source_record, + generation_warning, + None, + ) + .await?; + return Ok(json_success_body( + Some(request_context), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src: source_image_src, + spritesheet_width: source_width, + spritesheet_height: source_height, + icon_image_srcs: Vec::new(), + slice_warning: None, + prompt, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + price_mud_points: expected_price_mud_points, + spritesheet_resource: source_record.resource, + spritesheet_asset: source_record.asset, + project: completed_project, + warning: fallback_warning, + }, + )); } if let Some(error) = pixel_art_error { tracing::warn!( @@ -4820,9 +4860,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( } let matting_generation_inputs = build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record); - let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) - .map(|image| (image.width(), image.height())) - .unwrap_or((512, 512)); + let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions( + &image, + "editor-icon-spritesheet", + "图标 spritesheet 透明后处理图不是有效图片", + )?; let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -4875,7 +4917,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( mime_type: image.mime_type.clone(), extension: image.extension.clone(), }; - let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all(&slice_source) { + let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( + slice_source, + request_context.external_call_deadline(), + ) + .await + { Ok(icon_slices) => { match persist_editor_spritesheet_slices( state, @@ -4934,13 +4981,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( reason = %reason, "图标图集已持久化,但自动拆分未完成" ); - ( - Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, - reason, - }), - ) + let warning = editor_icon_spritesheet_slice_warning_from_error(&error); + (Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = @@ -5048,7 +5090,8 @@ pub async fn split_editor_icon_spritesheet( mime_type: reference.mime_type, extension: "png".to_string(), }; - let slices = slice_editor_icon_spritesheet_all(&source)?; + let slices = + slice_editor_icon_spritesheet_all(source, request_context.external_call_deadline()).await?; let prompt = source_resource .prompt .clone() @@ -5118,29 +5161,145 @@ pub async fn split_editor_icon_spritesheet( )) } -fn slice_editor_icon_spritesheet_all( - source: &DownloadedImage, +async fn slice_editor_icon_spritesheet_all( + source: DownloadedImage, + request_deadline: Option, ) -> Result, AppError> { - validate_editor_icon_spritesheet_source(source)?; + let processing_deadline = + resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); + if Instant::now() >= processing_deadline { + return Err(editor_icon_spritesheet_processing_timeout_error()); + } + let permit = match tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(error)) => { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, + "message": format!("图集切片 CPU 并发门限不可用:{error}"), + })), + ); + } + Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), + }; + let worker = tokio::task::spawn_blocking(move || { + // 中文注释:permit 必须由 blocking 闭包持有;即使 async 等待超时,尚未退出的 + // 解码、连通域、裁剪或 PNG 编码仍占用并发名额,不能退化成无界 blocking pool。 + let _permit = permit; + if Instant::now() >= processing_deadline { + return Err(editor_icon_spritesheet_processing_timeout_error()); + } + validate_editor_icon_spritesheet_source(&source)?; + slice_generated_icon_spritesheet_all_by_connected_components( + &source, + EDITOR_ICON_SPRITESHEET_MAX_SLICES, + ) + .map_err(map_editor_icon_spritesheet_platform_error) + }); let slices = - slice_generated_icon_spritesheet_all_by_connected_components(source).map_err(|error| { - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ - "provider": "editor-icon-spritesheet-slicing", - "message": error.to_string(), - })) - })?; + match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker) + .await + { + Ok(Ok(result)) => result?, + Ok(Err(error)) => { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("图集切片工作线程异常:{error}"), + })), + ); + } + Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), + }; if slices.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", + "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, "message": "图集中未识别到可拆分的独立素材。", })), ); } - validate_editor_icon_spritesheet_slice_count(slices.len())?; Ok(slices) } +fn resolve_editor_icon_spritesheet_processing_deadline( + started_at: Instant, + request_deadline: Option, +) -> Instant { + let local_deadline = started_at + .checked_add(EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION) + .unwrap_or(started_at); + request_deadline + .map(|request_deadline| request_deadline.min(local_deadline)) + .unwrap_or(local_deadline) +} + +fn editor_icon_spritesheet_processing_timeout_error() -> AppError { + AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, + "message": "图集切片处理超时。", + })) +} + +fn map_editor_icon_spritesheet_platform_error(error: GeneratedAssetSheetError) -> AppError { + let code = match &error { + GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } => { + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) + } + GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } => { + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) + } + GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => { + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) + } + _ => None, + }; + let mut details = json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": error.to_string(), + }); + if let Some(code) = code { + details["code"] = json!(code); + } + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(details) +} + +fn editor_icon_spritesheet_slice_warning_from_error( + error: &AppError, +) -> EditorIconSpritesheetSliceWarningResponse { + let code = match error + .details() + .and_then(|details| details.get("code")) + .and_then(Value::as_str) + { + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) => { + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT + } + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) => { + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT + } + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) => { + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT + } + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT) => { + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT + } + _ => EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + }; + EditorIconSpritesheetSliceWarningResponse { + code, + reason: error.body_text(), + } +} + fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> { let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) .with_guessed_format() @@ -5222,20 +5381,6 @@ fn validate_editor_icon_spritesheet_dimensions(width: u32, height: u32) -> Resul Ok(()) } -fn validate_editor_icon_spritesheet_slice_count(slice_count: usize) -> Result<(), AppError> { - if slice_count > EDITOR_ICON_SPRITESHEET_MAX_SLICES { - return Err( - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ - "provider": "editor-icon-spritesheet-slicing", - "message": "图集识别出的素材数量超过手动拆分限制。", - "sliceCount": slice_count, - "maxSliceCount": EDITOR_ICON_SPRITESHEET_MAX_SLICES, - })), - ); - } - Ok(()) -} - pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, @@ -5445,9 +5590,11 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": "VectorEngine 未返回 UI 设计图素材 spritesheet", })) })?; - let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) - .map(|image| (image.width(), image.height())) - .unwrap_or((512, 512)); + let (source_width, source_height) = decode_editor_provider_source_dimensions( + &image, + "editor-ui-design-asset-extraction", + "UI 素材 spritesheet 原图不是有效图片", + )?; let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), @@ -5523,12 +5670,14 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error = %failure_message, "UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布" ); - let completed_project = complete_editor_canvas_generation( + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - source_record.resource.as_ref(), + &source_record, + None, + None, ) .await?; return Ok(json_success_body( @@ -5548,9 +5697,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, - warning: Some(editor_postprocess_fallback_warning( - "生成任务成功,后处理失败。", - )), + warning: fallback_warning, }, )); } @@ -5573,14 +5720,46 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( source_width, source_height, error = ?error, - "UI 素材透明图蒙版尺寸恢复失败,保留去背景服务原始输出" + "UI 素材透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); + let (completed_project, fallback_warning) = complete_editor_source_only_fallback( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + &source_record, + None, + None, + ) + .await?; + return Ok(json_success_body( + Some(request_context), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src: source_image_src, + spritesheet_width: source_width, + spritesheet_height: source_height, + icon_image_srcs: Vec::new(), + slice_warning: None, + prompt, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + price_mud_points: expected_price_mud_points, + spritesheet_resource: source_record.resource, + spritesheet_asset: source_record.asset, + project: completed_project, + warning: fallback_warning, + }, + )); } let matting_generation_inputs = build_editor_derived_asset_generation_inputs("UI图集抠图", &source_record); - let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) - .map(|image| (image.width(), image.height())) - .unwrap_or((512, 512)); + let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions( + &image, + "editor-ui-design-asset-extraction", + "UI 素材 spritesheet 透明后处理图不是有效图片", + )?; let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -5633,75 +5812,74 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( mime_type: image.mime_type.clone(), extension: image.extension.clone(), }; - let (icon_image_srcs, slice_warning) = - match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) { - Ok(icon_slices) => { - match persist_editor_spritesheet_slices( - state, - icon_slices, - PersistEditorSpritesheetSlicesInput { - owner_user_id: owner_user_id.clone(), - project_id: payload.project_id.clone(), - asset_folder_id: asset_folder_id.clone(), - source_resource_id: spritesheet_record - .resource - .as_ref() - .map(|resource| resource.resource_id.clone()), - task_id: generated.task_id.clone(), - group_task_id: None, - prompt: "自动拆分图集".to_string(), - actual_prompt: None, - model: generation_options.model.to_string(), - provider: "Genarrative".to_string(), - generation_inputs: build_editor_derived_asset_generation_inputs( - "图集拆分", - &spritesheet_record, - ), - path_kind: "ui-design-assets", - asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, - persistence_provider: "vector-engine", - }, - ) - .await - { - Ok(icon_image_srcs) => (icon_image_srcs, None), - Err(error) => { - let reason = error.body_text(); - tracing::warn!( - provider = "editor-ui-design-asset-extraction", - operation = "persist_slices", - task_id = %generated.task_id, - reason = %reason, - "UI 设计图透明图集已持久化,但独立素材持久化失败" - ); - ( - Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, - reason, - }), - ) - } + let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( + slice_source, + request_context.external_call_deadline(), + ) + .await + { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + icon_slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: payload.project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + source_resource_id: spritesheet_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + task_id: generated.task_id.clone(), + group_task_id: None, + prompt: "自动拆分图集".to_string(), + actual_prompt: None, + model: generation_options.model.to_string(), + provider: "Genarrative".to_string(), + generation_inputs: build_editor_derived_asset_generation_inputs( + "图集拆分", + &spritesheet_record, + ), + path_kind: "ui-design-assets", + asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, None), + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-ui-design-asset-extraction", + operation = "persist_slices", + task_id = %generated.task_id, + reason = %reason, + "UI 设计图透明图集已持久化,但独立素材持久化失败" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + reason, + }), + ) } } - Err(error) => { - let reason = error.to_string(); - tracing::warn!( - provider = "editor-ui-design-asset-extraction", - operation = "detect_slices", - task_id = %generated.task_id, - reason = %reason, - "UI 设计图透明图集已持久化,但未识别到可拆分素材" - ); - ( - Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, - reason, - }), - ) - } - }; + } + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-ui-design-asset-extraction", + operation = "detect_slices", + task_id = %generated.task_id, + reason = %reason, + "UI 设计图透明图集已持久化,但未识别到可拆分素材" + ); + let warning = editor_icon_spritesheet_slice_warning_from_error(&error); + (Vec::new(), Some(warning)) + } + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -6515,6 +6693,39 @@ pub(crate) async fn complete_editor_canvas_generation( Ok(Some(editor_project_payload_from_record(saved))) } +async fn complete_editor_source_only_fallback( + state: &AppState, + owner_user_id: &str, + project_id: Option<&str>, + completion: Option<&EditorCanvasGenerationCompletionRequest>, + source_record: &EditorGeneratedAssetRecord, + current_warning: Option, + dimension_warning: Option<&EditorGenerationWarningResponse>, +) -> Result< + ( + Option, + Option, + ), + AppError, +> { + let project = complete_editor_canvas_generation( + state, + owner_user_id, + project_id, + completion, + source_record.resource.as_ref(), + ) + .await?; + let warning = merge_editor_generation_warnings( + current_warning, + Some(editor_postprocess_fallback_warning_with_dimension( + "生成任务成功,后处理失败。", + dimension_warning, + )), + ); + Ok((project, warning)) +} + pub(crate) async fn complete_editor_canvas_generation_with_items( state: &AppState, owner_user_id: &str, @@ -9260,6 +9471,69 @@ mod tests { ); } + #[test] + fn static_image_postprocess_rejects_truncated_png_even_when_header_has_dimensions() { + let mut bytes = encode_test_png(4, 4); + bytes.truncate(33); + let postprocessed = DownloadedOpenAiImage { + bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let error = editor_postprocessed_alpha_matches_delivery_dimensions(&postprocessed, 4, 4) + .expect_err("a dimension-only PNG header must not pass the full decode guard"); + + assert!(error.body_text().contains("透明后处理图不是有效图片")); + } + + #[test] + fn provider_source_dimensions_require_a_fully_decodable_image() { + let invalid = DownloadedOpenAiImage { + bytes: b"not-an-image".to_vec(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let error = decode_editor_provider_source_dimensions( + &invalid, + "editor-icon-spritesheet", + "图标 spritesheet 原图不是有效图片", + ) + .expect_err("invalid provider bytes must fail before source persistence"); + + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); + assert!(error.body_text().contains("原图不是有效图片")); + } + + #[tokio::test] + async fn static_image_postprocess_reports_provider_source_read_failure() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let postprocessed = DownloadedOpenAiImage { + bytes: encode_test_png(2, 2), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let original_bytes = postprocessed.bytes.clone(); + + let (untrusted_postprocessed, error) = + apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + &state, + "generated-images/editor/missing-provider-source.png", + 4, + 4, + postprocessed, + Some(Instant::now() + Duration::from_millis(50)), + ) + .await; + + assert_eq!(untrusted_postprocessed.bytes, original_bytes); + assert!( + error.is_some(), + "provider source read failure must be surfaced" + ); + } + #[test] fn pixel_art_sampling_uses_provider_rgb_even_when_bgfilter_dimensions_match() { let provider_source = image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 10, 20, 255])); @@ -9374,8 +9648,8 @@ mod tests { // permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter // 原始输出连同 None 尺寸错误直接交回调用方——那等于谎报「尺寸无误」。 // 本断言只覆盖「如实上报尺寸错误」这一层:拿到错误后如何处置仍由各调用方 - // 既有语义决定,角色据此走原图安全降级,图标沿用 master 的非致命语义(只告警、 - // 继续持久化与拆分),后者不在本断言的保证范围内。 + // 既有语义决定,角色、图标与 UI 据此统一走 provider 原图安全降级,禁止继续 + // 持久化或拆分未经验证的透明后处理图。 assert_function_not_contains( source, "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", @@ -10660,13 +10934,138 @@ mod tests { } #[test] - fn icon_spritesheet_limits_reject_large_images_and_excessive_slices() { + fn icon_spritesheet_limits_reject_large_images() { assert!(validate_editor_icon_spritesheet_dimensions(2048, 2048).is_ok()); assert!(validate_editor_icon_spritesheet_dimensions(4096, 1024).is_ok()); assert!(validate_editor_icon_spritesheet_dimensions(2049, 2048).is_err()); assert!(validate_editor_icon_spritesheet_dimensions(4097, 1).is_err()); - assert!(validate_editor_icon_spritesheet_slice_count(64).is_ok()); - assert!(validate_editor_icon_spritesheet_slice_count(65).is_err()); + } + + #[test] + fn icon_spritesheet_platform_limits_map_to_stable_warning_codes() { + for (error, expected_code) in [ + ( + GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { + component_count: 4097, + max_component_count: 4096, + }, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT, + ), + ( + GeneratedAssetSheetError::OutputSliceLimitExceeded { + slice_count: 65, + max_slice_count: 64, + }, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT, + ), + ( + GeneratedAssetSheetError::MergeCandidateLimitExceeded { + candidate_count: 513, + max_candidate_count: 512, + }, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT, + ), + ] { + let error = map_editor_icon_spritesheet_platform_error(error); + assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!(expected_code)) + ); + assert_eq!( + editor_icon_spritesheet_slice_warning_from_error(&error).code, + expected_code + ); + } + } + + #[test] + fn icon_spritesheet_processing_deadline_uses_earlier_budget() { + assert_eq!(EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, 2); + assert_eq!( + EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION, + Duration::from_secs(30) + ); + let started_at = Instant::now(); + assert_eq!( + resolve_editor_icon_spritesheet_processing_deadline(started_at, None) + .duration_since(started_at), + EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION + ); + let request_deadline = started_at + Duration::from_secs(5); + assert_eq!( + resolve_editor_icon_spritesheet_processing_deadline(started_at, Some(request_deadline)), + request_deadline + ); + } + + #[tokio::test] + async fn icon_spritesheet_expired_deadline_skips_blocking_image_work() { + let source = DownloadedImage { + bytes: b"not-an-image".to_vec(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let expired = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("expired deadline should be representable"); + + let error = slice_editor_icon_spritesheet_all(source, Some(expired)) + .await + .expect_err("expired CPU budget must fail before decoding"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT)) + ); + } + + #[test] + fn all_spritesheet_paths_share_bounded_blocking_slicer() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "async fn slice_editor_icon_spritesheet_all", + "fn resolve_editor_icon_spritesheet_processing_deadline", + &[ + "EDITOR_ICON_SPRITESHEET_CPU_LIMITER", + "acquire_owned", + "tokio::task::spawn_blocking", + "let _permit = permit", + "validate_editor_icon_spritesheet_source", + "slice_generated_icon_spritesheet_all_by_connected_components", + "EDITOR_ICON_SPRITESHEET_MAX_SLICES", + "tokio::time::timeout_at", + ], + ); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn split_editor_icon_spritesheet", + ), + ( + "pub async fn split_editor_icon_spritesheet", + "async fn slice_editor_icon_spritesheet_all", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ] { + assert_function_contains(source, start, end, &["slice_editor_icon_spritesheet_all"]); + } + assert_function_contains_in_order( + source, + "pub async fn split_editor_icon_spritesheet", + "async fn slice_editor_icon_spritesheet_all", + &[ + "slice_editor_icon_spritesheet_all", + ".await?", + "persist_editor_spritesheet_slices", + "complete_editor_canvas_generation_with_items", + ], + ); } #[test] @@ -11940,7 +12339,7 @@ mod tests { "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "persist_editor_provider_source_resource", - "slice_generated_icon_spritesheet_all_by_connected_components", + "slice_editor_icon_spritesheet_all", "model: generation_options.model.to_string(),", ], ); @@ -12067,7 +12466,7 @@ mod tests { assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub async fn split_editor_icon_spritesheet", &[ concat!("prepare_editor_", "icon_spritesheet_image_for_persist"), "Err(_)", @@ -12191,6 +12590,29 @@ mod tests { #[test] fn editor_static_postprocess_failure_completes_canvas_with_source_only() { let source = include_str!("editor_project.rs"); + assert_function_contains( + source, + "async fn complete_editor_source_only_fallback", + "pub(crate) async fn complete_editor_canvas_generation_with_items", + &[ + "complete_editor_canvas_generation", + "source_record.resource.as_ref()", + "merge_editor_generation_warnings", + "editor_postprocess_fallback_warning_with_dimension", + ], + ); + assert_function_not_contains( + source, + "async fn complete_editor_source_only_fallback", + "pub(crate) async fn complete_editor_canvas_generation_with_items", + &[ + "persist_editor_generated_image", + "persist_editor_generated_asset", + "persist_editor_spritesheet_slices", + "slice_editor_icon_spritesheet_all", + "complete_editor_canvas_generation_with_items", + ], + ); for (start, end) in [ ( "pub(crate) async fn generate_editor_image_for_owner", @@ -12205,12 +12627,13 @@ mod tests { "pub(crate) fn editor_project_payload_from_record", ), ] { - let warning_assignment = if start.contains("extract_editor_ui_design_assets_for_owner") - { - "warning: Some(" - } else { - "warning: merge_editor_generation_warnings(" - }; + assert_function_occurrence_count( + source, + start, + end, + "complete_editor_source_only_fallback(", + 2, + ); assert_function_contains_in_order( source, start, @@ -12220,13 +12643,8 @@ mod tests { "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "Err(error)", - "complete_editor_canvas_generation", - "source_record.resource.as_ref()", - warning_assignment, - // 中文注释:普通图片与角色分支的后处理降级分别使用 - // editor_postprocess_fallback_warning 和 _with_dimension, - // 断言只固定共同前缀,不锁定具体重载。 - "editor_postprocess_fallback_warning", + "complete_editor_source_only_fallback", + "return Ok(json_success_body", ], ); @@ -12254,6 +12672,7 @@ mod tests { }); let fallback = &body[fallback_start..fallback_end]; assert!(fallback.contains("return Ok(json_success_body")); + assert!(fallback.contains("complete_editor_source_only_fallback")); for forbidden in [ "persist_editor_generated_image(", "persist_editor_generated_asset(", @@ -12266,16 +12685,65 @@ mod tests { ); } if start.contains("spritesheet") || start.contains("ui_design") { + assert!(fallback.contains("icon_image_srcs: Vec::new()")); + assert!(fallback.contains("slice_warning: None")); let success_tail = &body[fallback_end..]; assert!(success_tail.contains("persist_editor_generated_image(")); assert!(success_tail.contains("persist_editor_generated_asset(")); assert!(success_tail.contains("persist_editor_spritesheet_slices")); } } + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn split_editor_icon_spritesheet", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ] { + let start_index = source.find(start).expect("handler should exist"); + let tail = &source[start_index..]; + let end_index = tail.find(end).expect("handler end should exist"); + let body = &tail[..end_index]; + let fallback_start = body + .find("if let Some(error) = postprocess_dimension_error") + .expect("dimension fallback should exist"); + let fallback_end = body[fallback_start..] + .find("let matting_generation_inputs") + .map(|offset| fallback_start + offset) + .expect("normal transparent persistence path should follow fallback"); + let fallback = &body[fallback_start..fallback_end]; + for snippet in [ + "complete_editor_source_only_fallback", + "return Ok(json_success_body", + "icon_image_srcs: Vec::new()", + "slice_warning: None", + ] { + assert!( + fallback.contains(snippet), + "{start} fallback should contain {snippet}" + ); + } + for forbidden in [ + "persist_editor_generated_image(", + "persist_editor_generated_asset(", + "persist_editor_spritesheet_slices(", + "slice_editor_icon_spritesheet_all(", + "complete_editor_canvas_generation_with_items(", + ] { + assert!( + !fallback.contains(forbidden), + "{start} fallback must not perform {forbidden}" + ); + } + } } #[test] - fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() { + fn editor_character_postprocess_fallback_uses_shared_helper_without_swallowing_other_failures() + { let source = include_str!("editor_project.rs"); let start = source .find("pub(crate) async fn generate_editor_image_for_owner") @@ -12317,12 +12785,9 @@ mod tests { ); for snippet in [ "Err(error)", - "complete_editor_canvas_generation", - "source_record.resource.as_ref()", + "complete_editor_source_only_fallback", "return Ok(json_success_body", - "warning: merge_editor_generation_warnings(", - // 中文注释:角色分支现在走带尺寸告警的重载,断言只固定共同前缀。 - "editor_postprocess_fallback_warning", + "warning: fallback_warning", ] { assert!( fallback.contains(snippet), @@ -12359,14 +12824,10 @@ mod tests { .expect("processed image should only continue after dimension validation"); let dimension_fallback_body = &body[dimension_fallback..processed_image]; for snippet in [ - "complete_editor_canvas_generation", - "source_record.resource.as_ref()", + "complete_editor_source_only_fallback", "return Ok(json_success_body", - // 中文注释:原图安全降级必须合并此前累积的告警,而不是整条覆盖, - // 同时仍要带上后处理降级告警本身。 - "warning: merge_editor_generation_warnings(", "generation_warning", - "editor_postprocess_fallback_warning", + "warning: fallback_warning", ] { assert!( dimension_fallback_body.contains(snippet), @@ -12450,7 +12911,7 @@ mod tests { source, "fn editor_postprocessed_alpha_matches_delivery_dimensions", "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", - &["into_dimensions"], + &["image::load_from_memory"], ); for (start, end) in [ ( @@ -12609,7 +13070,7 @@ mod tests { "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", "persist_editor_generated_asset", - "slice_generated_icon_spritesheet_all_by_connected_components", + "slice_editor_icon_spritesheet_all", "persist_editor_spritesheet_slices", ], ); @@ -12619,8 +13080,8 @@ mod tests { "pub(crate) fn editor_project_payload_from_record", &[ "EDITOR_ASSET_DEFAULT_FOLDER_ID", - "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS", "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE", + "slice_editor_icon_spritesheet_all", ], ); } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs index f01909044..fb66c5df3 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs @@ -6,10 +6,30 @@ pub const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets"; #[derive(Debug)] pub enum GeneratedAssetSheetError { - InvalidRequest { message: String }, - DecodeImage { message: String }, - EncodeImage { message: String }, - BuildHttpClient { message: String }, + InvalidRequest { + message: String, + }, + RawConnectedComponentLimitExceeded { + component_count: usize, + max_component_count: usize, + }, + OutputSliceLimitExceeded { + slice_count: usize, + max_slice_count: usize, + }, + MergeCandidateLimitExceeded { + candidate_count: usize, + max_candidate_count: usize, + }, + DecodeImage { + message: String, + }, + EncodeImage { + message: String, + }, + BuildHttpClient { + message: String, + }, Oss(OssError), } @@ -24,6 +44,24 @@ impl GeneratedAssetSheetError { | Self::DecodeImage { message } | Self::EncodeImage { message } | Self::BuildHttpClient { message } => message.clone(), + Self::RawConnectedComponentLimitExceeded { + component_count, + max_component_count, + } => format!( + "图标 spritesheet 原始连通域数量超过处理上限:{component_count},最多允许 {max_component_count} 个。" + ), + Self::OutputSliceLimitExceeded { + slice_count, + max_slice_count, + } => format!( + "图标 spritesheet 识别出的素材数量超过输出上限:{slice_count},最多允许 {max_slice_count} 个。" + ), + Self::MergeCandidateLimitExceeded { + candidate_count, + max_candidate_count, + } => format!( + "图标 spritesheet 局部连通域过于拥挤:{candidate_count},单个邻域最多允许 {max_candidate_count} 个合并候选。" + ), Self::Oss(error) => error.to_string(), } } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index bdd386e69..457cb6aaf 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -143,15 +143,26 @@ pub struct GeneratedAssetSheetConnectedIcon { const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16; const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16; +const GENERATED_ICON_MAX_RAW_COMPONENTS: usize = 4096; +const GENERATED_ICON_SPATIAL_GRID_SIZE: u32 = 64; +const GENERATED_ICON_MAX_MERGE_GAP: u32 = 48; +const GENERATED_ICON_MAX_COMPONENTS_PER_GRID_CELL: usize = 256; +const GENERATED_ICON_MAX_MERGE_CANDIDATES_PER_SOURCE: usize = 512; pub fn slice_generated_icon_spritesheet_all_by_connected_components( image: &crate::DownloadedImage, + max_output_slices: usize, ) -> Result, GeneratedAssetSheetError> { + if max_output_slices == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "图标 spritesheet 输出切片上限必须大于 0。", + )); + } let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source); - slice_generated_icon_spritesheet_rgba_by_connected_components(source) + slice_generated_icon_spritesheet_rgba_by_connected_components(source, max_output_slices) } pub fn crop_generated_asset_sheet_view_edge_matte( @@ -165,6 +176,7 @@ pub fn crop_generated_asset_sheet_view_edge_matte( fn slice_generated_icon_spritesheet_rgba_by_connected_components( source: image::DynamicImage, + max_output_slices: usize, ) -> Result, GeneratedAssetSheetError> { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -175,19 +187,25 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( )); } - let mut components = detect_generated_icon_components_by_alpha(&image, width, height); + let mut components = detect_generated_icon_components_by_alpha(&image, width, height)?; if generated_icon_alpha_fill_ratio(&image) > 0.92 { let foreground_image = build_generated_icon_spritesheet_foreground_image(&image, width, height); let foreground_components = - detect_generated_icon_components_by_alpha(&foreground_image, width, height); + detect_generated_icon_components_by_alpha(&foreground_image, width, height)?; if !foreground_components.is_empty() && foreground_components.len() >= components.len() { image = foreground_image; components = foreground_components; } } - let mut components = normalize_generated_icon_components(components, width, height); + let mut components = normalize_generated_icon_components(components, width, height)?; + if components.len() > max_output_slices { + return Err(GeneratedAssetSheetError::OutputSliceLimitExceeded { + slice_count: components.len(), + max_slice_count: max_output_slices, + }); + } sort_generated_icon_components_in_visual_rows(&mut components); let icon_names = (1..=components.len()) .map(|index| format!("素材 {index}")) @@ -236,10 +254,11 @@ fn detect_generated_icon_components_by_alpha( image: &image::RgbaImage, width: u32, height: u32, -) -> Vec { +) -> Result, GeneratedAssetSheetError> { let pixel_count = (width as usize).saturating_mul(height as usize); let mut visited = vec![false; pixel_count]; let mut components = Vec::::new(); + let mut raw_component_count = 0usize; for y in 0..height { for x in 0..width { let pixel_index = (y as usize) @@ -250,26 +269,35 @@ fn detect_generated_icon_components_by_alpha( } let bounds = flood_fill_generated_icon_component(image, &mut visited, width, height, x, y); + raw_component_count = raw_component_count.saturating_add(1); + if raw_component_count > GENERATED_ICON_MAX_RAW_COMPONENTS { + return Err( + GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { + component_count: raw_component_count, + max_component_count: GENERATED_ICON_MAX_RAW_COMPONENTS, + }, + ); + } if bounds.visible_pixels >= GENERATED_ICON_MIN_VISIBLE_PIXELS { components.push(bounds); } } } - components + Ok(components) } fn normalize_generated_icon_components( components: Vec, width: u32, height: u32, -) -> Vec { - let merged_components = merge_generated_icon_related_components(components, width, height); +) -> Result, GeneratedAssetSheetError> { + let merged_components = merge_generated_icon_related_components(components, width, height)?; let filtered_components = filter_generated_icon_scrap_components(merged_components, width, height); - filtered_components + Ok(filtered_components .into_iter() .map(|component| component.bounds) - .collect() + .collect()) } fn sort_generated_icon_components_in_visual_rows(components: &mut [GeneratedAssetSheetCellBounds]) { @@ -346,10 +374,10 @@ fn merge_generated_icon_related_components( mut components: Vec, width: u32, height: u32, -) -> Vec { +) -> Result, GeneratedAssetSheetError> { for _ in 0..GENERATED_ICON_MAX_MERGE_ITERATIONS { let Some((source_index, target_index)) = - resolve_generated_icon_component_merge_pair(&components, width, height) + resolve_generated_icon_component_merge_pair(&components, width, height)? else { break; }; @@ -358,22 +386,99 @@ fn merge_generated_icon_related_components( components[target_index] = target.merge(source); components.remove(source_index); } - components + Ok(components) } fn resolve_generated_icon_component_merge_pair( components: &[GeneratedAssetSheetIconComponent], width: u32, height: u32, -) -> Option<(usize, usize)> { +) -> Result, GeneratedAssetSheetError> { + let spatial_grid = build_generated_icon_component_spatial_grid(components)?; + let max_area = components + .iter() + .map(|component| component.bounds.area()) + .max() + .unwrap_or(0); + let max_visible_pixels = components + .iter() + .map(|component| component.visible_pixels) + .max() + .unwrap_or(0); + let max_width = components + .iter() + .map(|component| component.bounds.width()) + .max() + .unwrap_or(0); + let max_height = components + .iter() + .map(|component| component.bounds.height()) + .max() + .unwrap_or(0); + let mut seen_candidate_epoch = vec![0usize; components.len()]; let mut best_pair: Option<(usize, usize, u64, u64)> = None; for source_index in 0..components.len() { - for target_index in 0..components.len() { - if source_index == target_index - || components[source_index].bounds.area() > components[target_index].bounds.area() - { - continue; + let source = components[source_index]; + if source.bounds.area().saturating_mul(4) > max_area + && source.visible_pixels.saturating_mul(6) > max_visible_pixels + && source.bounds.width().saturating_mul(3) > max_width + && source.bounds.height().saturating_mul(3) > max_height + { + continue; + } + let mut candidate_indices = Vec::::new(); + let candidate_epoch = source_index.saturating_add(1); + let source_bounds = components[source_index].bounds; + let search_x0 = source_bounds + .x0 + .saturating_sub(GENERATED_ICON_MAX_MERGE_GAP.saturating_add(1)) + / GENERATED_ICON_SPATIAL_GRID_SIZE; + let search_y0 = source_bounds + .y0 + .saturating_sub(GENERATED_ICON_MAX_MERGE_GAP.saturating_add(1)) + / GENERATED_ICON_SPATIAL_GRID_SIZE; + let search_x1 = source_bounds + .x1 + .saturating_add(GENERATED_ICON_MAX_MERGE_GAP) + .min(width.saturating_sub(1)) + / GENERATED_ICON_SPATIAL_GRID_SIZE; + let search_y1 = source_bounds + .y1 + .saturating_add(GENERATED_ICON_MAX_MERGE_GAP) + .min(height.saturating_sub(1)) + / GENERATED_ICON_SPATIAL_GRID_SIZE; + for grid_y in search_y0..=search_y1 { + for grid_x in search_x0..=search_x1 { + let Some(target_indices) = spatial_grid.get(&(grid_x, grid_y)) else { + continue; + }; + for &target_index in target_indices { + if seen_candidate_epoch[target_index] == candidate_epoch { + continue; + } + seen_candidate_epoch[target_index] = candidate_epoch; + if source_index == target_index + || components[source_index].bounds.area() + > components[target_index].bounds.area() + || !is_generated_icon_auxiliary_component( + components[source_index], + components[target_index], + ) + { + continue; + } + candidate_indices.push(target_index); + if candidate_indices.len() > GENERATED_ICON_MAX_MERGE_CANDIDATES_PER_SOURCE { + return Err(GeneratedAssetSheetError::MergeCandidateLimitExceeded { + candidate_count: candidate_indices.len(), + max_candidate_count: GENERATED_ICON_MAX_MERGE_CANDIDATES_PER_SOURCE, + }); + } + } } + } + candidate_indices.sort_unstable(); + for target_index in candidate_indices { if !should_merge_generated_icon_components( components[source_index], components[target_index], @@ -397,7 +502,33 @@ fn resolve_generated_icon_component_merge_pair( } } } - best_pair.map(|(source_index, target_index, _, _)| (source_index, target_index)) + Ok(best_pair.map(|(source_index, target_index, _, _)| (source_index, target_index))) +} + +fn build_generated_icon_component_spatial_grid( + components: &[GeneratedAssetSheetIconComponent], +) -> Result>, GeneratedAssetSheetError> { + let mut grid = BTreeMap::<(u32, u32), Vec>::new(); + for (index, component) in components.iter().enumerate() { + let bounds = component.bounds; + let grid_x0 = bounds.x0 / GENERATED_ICON_SPATIAL_GRID_SIZE; + let grid_y0 = bounds.y0 / GENERATED_ICON_SPATIAL_GRID_SIZE; + let grid_x1 = bounds.x1.saturating_sub(1) / GENERATED_ICON_SPATIAL_GRID_SIZE; + let grid_y1 = bounds.y1.saturating_sub(1) / GENERATED_ICON_SPATIAL_GRID_SIZE; + for grid_y in grid_y0..=grid_y1 { + for grid_x in grid_x0..=grid_x1 { + let entries = grid.entry((grid_x, grid_y)).or_default(); + if entries.len() >= GENERATED_ICON_MAX_COMPONENTS_PER_GRID_CELL { + return Err(GeneratedAssetSheetError::MergeCandidateLimitExceeded { + candidate_count: entries.len().saturating_add(1), + max_candidate_count: GENERATED_ICON_MAX_COMPONENTS_PER_GRID_CELL, + }); + } + entries.push(index); + } + } + } + Ok(grid) } fn should_merge_generated_icon_components( @@ -417,7 +548,9 @@ fn should_merge_generated_icon_components( .max(source.bounds.width()) .max(source.bounds.height()); let sheet_short_side = width.min(height).max(1); - let merge_gap = (max_dimension / 4).max(sheet_short_side / 64).clamp(6, 48) as u64; + let merge_gap = (max_dimension / 4) + .max(sheet_short_side / 64) + .clamp(6, GENERATED_ICON_MAX_MERGE_GAP) as u64; gap <= merge_gap } @@ -674,7 +807,7 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("icons should slice"); assert_eq!(icons.len(), 2); @@ -706,7 +839,7 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("same-row icons should slice from left to right"); assert_icon_contains_color(&icons[0], left_color); @@ -748,7 +881,7 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("visual rows should slice in reading order"); for (icon, color) in icons.iter().zip(colors) { @@ -776,7 +909,7 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("all icons should slice"); assert_eq!(icons.len(), 2); @@ -817,7 +950,7 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("detached accents should merge into their nearby icon"); assert_eq!(icons.len(), 2); @@ -851,13 +984,205 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) .expect("tiny scraps should not become standalone icons"); assert_eq!(icons.len(), 2); assert_eq!(icons[0].name, "素材 1"); assert_eq!(icons[1].name, "素材 2"); } + + #[test] + fn rejects_excessive_raw_components_before_normalization() { + let columns = 65u32; + let rows = 64u32; + let stride = 6u32; + let mut sheet: image::RgbaImage = + ImageBuffer::from_pixel(columns * stride, rows * stride, Rgba([0, 0, 0, 0])); + for row in 0..rows { + for column in 0..columns { + let origin_x = column * stride; + let origin_y = row * stride; + for y in origin_y..origin_y + 4 { + for x in origin_x..origin_x + 4 { + sheet.put_pixel(x, y, Rgba([240, 80, 80, 255])); + } + } + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let error = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) + .expect_err("too many raw connected components must fail early"); + + assert!(error.to_string().contains("原始连通域数量超过处理上限")); + assert!(error.to_string().contains("4096")); + } + + #[test] + fn rejects_output_limit_before_cropping_and_png_encoding() { + let columns = 13u32; + let rows = 5u32; + let stride = 10u32; + let mut sheet: image::RgbaImage = + ImageBuffer::from_pixel(columns * stride, rows * stride, Rgba([0, 0, 0, 0])); + for row in 0..rows { + for column in 0..columns { + let origin_x = column * stride; + let origin_y = row * stride; + for y in origin_y..origin_y + 4 { + for x in origin_x..origin_x + 4 { + sheet.put_pixel(x, y, Rgba([80, 120, 240, 255])); + } + } + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let error = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) + .expect_err("65 output components must fail before slice encoding"); + + assert!(error.to_string().contains("素材数量超过输出上限")); + assert!(error.to_string().contains("65")); + assert!(error.to_string().contains("64")); + } + + #[test] + fn rejects_dense_single_pixel_noise_at_the_raw_component_limit() { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(512, 512, Rgba([0, 0, 0, 0])); + for y in (0..512).step_by(3) { + for x in (0..512).step_by(3) { + sheet.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + + let error = detect_generated_icon_components_by_alpha(&sheet, 512, 512) + .expect_err("single-pixel noise must still count toward the raw component budget"); + + assert!(matches!( + error, + GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { + component_count: 4097, + max_component_count: 4096, + } + )); + } + + #[test] + fn spatial_grid_includes_candidates_at_the_exact_maximum_merge_gap() { + let cases = [ + ( + GeneratedAssetSheetCellBounds { + x0: 0, + y0: 80, + x1: 16, + y1: 96, + }, + GeneratedAssetSheetCellBounds { + x0: 64, + y0: 0, + x1: 256, + y1: 128, + }, + ), + ( + GeneratedAssetSheetCellBounds { + x0: 112, + y0: 80, + x1: 128, + y1: 96, + }, + GeneratedAssetSheetCellBounds { + x0: 0, + y0: 0, + x1: 64, + y1: 256, + }, + ), + ( + GeneratedAssetSheetCellBounds { + x0: 80, + y0: 0, + x1: 96, + y1: 16, + }, + GeneratedAssetSheetCellBounds { + x0: 0, + y0: 64, + x1: 128, + y1: 256, + }, + ), + ( + GeneratedAssetSheetCellBounds { + x0: 80, + y0: 112, + x1: 96, + y1: 128, + }, + GeneratedAssetSheetCellBounds { + x0: 0, + y0: 0, + x1: 256, + y1: 64, + }, + ), + ]; + + for (source_bounds, target_bounds) in cases { + let components = vec![ + GeneratedAssetSheetIconComponent { + bounds: source_bounds, + visible_pixels: 256, + }, + GeneratedAssetSheetIconComponent { + bounds: target_bounds, + visible_pixels: 8192, + }, + ]; + let pair = resolve_generated_icon_component_merge_pair(&components, 320, 320) + .expect("spatial lookup should stay within resource limits"); + + assert_eq!(pair, Some((0, 1))); + assert_eq!( + generated_icon_bounds_gap(source_bounds, target_bounds), + u64::from(GENERATED_ICON_MAX_MERGE_GAP) + ); + } + } + + #[test] + fn spatial_grid_rejects_a_congested_cell_before_pair_scanning() { + let component = GeneratedAssetSheetIconComponent { + bounds: GeneratedAssetSheetCellBounds { + x0: 8, + y0: 8, + x1: 16, + y1: 16, + }, + visible_pixels: 64, + }; + let components = vec![component; GENERATED_ICON_MAX_COMPONENTS_PER_GRID_CELL + 1]; + + let error = resolve_generated_icon_component_merge_pair(&components, 128, 128) + .expect_err("a congested spatial cell must fail before quadratic pair scanning"); + + assert!(matches!( + error, + GeneratedAssetSheetError::MergeCandidateLimitExceeded { + candidate_count: 257, + max_candidate_count: 256, + } + )); + } } pub fn crop_generated_asset_sheet_view_edge_matte_with_options(