From cf1a0231250ccf9f8c5bee4fe4ce60522b4c3b9d Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 31 Jul 2026 13:40:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=9B=BE=E9=9B=86=E5=88=87?= =?UTF-8?q?=E7=89=87=E5=86=85=E5=AD=98=E5=B9=B6=E6=89=B9=E9=87=8F=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按需编码并限制图集上传并发、超时与累计裁剪像素 批量确认切片对象、项目资源、账号素材和完成批次 稳定派生切片记录标识并收紧重放与来源资源校验 复用已鉴权项目资源避免手动拆分全账号扫描 补充图集资源边界文档与回归测试 --- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../api-server/src/editor_green_screen.rs | 1 + .../crates/api-server/src/editor_project.rs | 836 +++++++++++++++--- .../src/generated_asset_sheets/alpha.rs | 2 +- .../src/generated_asset_sheets/error.rs | 10 + .../src/generated_asset_sheets/mod.rs | 7 +- .../src/generated_asset_sheets/sheet.rs | 220 ++++- .../spacetime-client/src/editor_project.rs | 214 +++++ .../spacetime-client/src/module_bindings.rs | 10 + ...itesheet_slice_batch_persist_input_type.rs | 22 + ...tesheet_slice_batch_persist_result_type.rs | 19 + ...ritesheet_slice_persist_item_input_type.rs | 21 + ...r_spritesheet_slice_persisted_item_type.rs | 21 + ...esheet_slice_batch_and_return_procedure.rs | 62 ++ .../src/asset_metadata/objects.rs | 2 +- .../src/editor_project_storage.rs | 573 ++++++++++++ 18 files changed, 1868 insertions(+), 160 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persist_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persisted_item_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 67b32bedb..b577cc17b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -26,7 +26,7 @@ - 现象:透明图集含大量独立碎块或噪点时,接口长时间占用 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 工作退出。 +- 处理:platform slicer 对全部 flood-fill 连通域设置 `4096` 硬上限,用空间网格只查 `48px` 邻域候选;单网格最多 `256` 个组件、单 source 最多 `512` 个候选,避免拥挤网格重新退化为全量 pair。`maxOutputSlices` 与 padding crop 总像素预算在首片 PNG 编码前拒绝。图标自动、手动和 UI 三入口统一在 2 路 CPU semaphore 与 30 秒 / 请求 deadline 保护下 prepare 出共享 RGBA + bounds 计划,不再一次返回最多 64 份 PNG。api-server 只按需编码并用容量 2 的有界管线上传,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 32 MiB 来源对象前取得独立内存 admission,同一 admission 覆盖下载、计划与上传生命周期,并在最后一次 HEAD 完成后、数据库调用前释放,排队请求、慢 OSS 或慢数据库都不能绕过内存边界。全部 `PUT + HEAD` 成功后,单个 SpacetimeDB procedure 在一个事务中批量确认对象、创建项目资源 / 账号素材并完成 cohort;resource / asset ID 按 owner + task + 序号稳定派生,重放只复用内容一致的素材,来源资源必须存在且同 owner / project;不在上传失败后留下部分数据库批次,也不在不确定结果重放后复制整批素材。 - 验证:覆盖大量独立 `4×4` 块、超过上限的单像素噪点、65 个有效输出和既有高光 / 阴影合并样本;手动超限必须发生在首次持久化前,自动超限不得产生切片 PUT、资源或画布切片。 - 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/editor_project.rs`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9a0d05dd5..606389082 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -85,7 +85,7 @@ ## 后端接口 -- 角色、图标 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`。 +- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界处理链:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=64` 和所有 padding 后 crop 的总像素预算都在首片 PNG 编码前检查。prepare 只保留一张 RGBA 与排好序的 bounds,不再一次返回最多 `64` 份 PNG;api-server 按需编码并以容量 `2` 的有界管线上传,使单个图集同时只保留整图和最多两份切片 PNG。CPU 工作继续受 2 路 semaphore、30 秒本地上限与请求 deadline 保护;独立内存 admission 从 prepare 持有到最后一片上传结束,慢 OSS 不得占用 CPU permit,也不得绕过内存限制堆积新批次。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`。 - `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。 - `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。 @@ -109,7 +109,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 +- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 9d7c4e403..d58e56983 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -340,7 +340,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 载荷约束:本次先对 `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`。 +- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=64` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 ### `external_generation_job_summary` 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 ccd8874a3..6bd011bdb 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -214,6 +214,7 @@ fn map_editor_green_screen_error(error: GeneratedAssetSheetError) -> AppError { GeneratedAssetSheetError::InvalidRequest { .. } | GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } | GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } + | GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. } | GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => StatusCode::BAD_REQUEST, GeneratedAssetSheetError::EncodeImage { .. } | GeneratedAssetSheetError::BuildHttpClient { .. } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index eb93008d9..7b0b54eb9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18,8 +18,8 @@ use module_assets::{ use platform_image::{ DownloadedImage, generated_asset_sheets::{ - GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetError, - slice_generated_icon_spritesheet_all_by_connected_components, + GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, + GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components, }, }; use platform_oss::{ @@ -27,20 +27,23 @@ use platform_oss::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; use shared_contracts::assets::{ EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::editor_project::{ + EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, +}; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, - EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, - EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, - EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, - EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, - EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetGroupSourceLookupRecordInput, + EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, + EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, @@ -109,6 +112,11 @@ 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_MEMORY_MAX_CONCURRENCY: usize = 2; +const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; +const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; +const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); 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"; @@ -126,6 +134,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persisten 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_CROP_PIXEL_LIMIT: &str = "crop-pixel-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"; @@ -145,6 +154,12 @@ static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, )) }); +static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: LazyLock> = + LazyLock::new(|| { + Arc::new(tokio::sync::Semaphore::new( + EDITOR_ICON_SPRITESHEET_MEMORY_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"; @@ -4361,12 +4376,37 @@ struct PersistEditorSpritesheetSlicesInput { persistence_provider: &'static str, } +struct EditorSpritesheetSliceUpload { + index: usize, + name: String, + width: u32, + height: u32, + image_src: String, + object_key: String, + asset_object: module_assets::AssetObjectUpsertInput, +} + +struct EditorIconSpritesheetMemoryAdmission { + _permit: tokio::sync::OwnedSemaphorePermit, +} + +#[derive(Clone)] +struct PreparedEditorIconSpritesheetPlan { + plan: GeneratedAssetSheetConnectedIconPlan, + processing_deadline: Instant, + // 中文注释:每个仍可能运行的 blocking 编码任务都持有同一 admission,超时取消 + // async 等待时也不会提前释放整张 RGBA 的内存名额。 + _memory_admission: Arc, +} + async fn persist_editor_spritesheet_slices( state: &AppState, - slices: Vec, + prepared: PreparedEditorIconSpritesheetPlan, input: PersistEditorSpritesheetSlicesInput, ) -> Result, AppError> { - if slices.is_empty() { + use futures_util::{StreamExt as _, TryStreamExt as _}; + + if prepared.plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", @@ -4375,93 +4415,332 @@ async fn persist_editor_spritesheet_slices( ); } - let expected_asset_count = u32::try_from(slices.len()).map_err(|_| { + let expected_asset_count = u32::try_from(prepared.plan.len()).map_err(|_| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分产物数量超过支持范围。", })) })?; - let mut results = Vec::with_capacity(slices.len()); - for (index, slice) in slices.into_iter().enumerate() { - let image = DownloadedOpenAiImage { - bytes: slice.bytes, - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - let file_stem = format!("asset-{:03}", index + 1); - let persisted = persist_editor_generated_image( - state, - input.owner_user_id.as_str(), - input.task_id.as_str(), - &image, - input.prompt.as_str(), - input.actual_prompt.as_deref(), - input.asset_kind, - input.path_kind, - file_stem.as_str(), - "asset", - input.persistence_provider, - ) + let input = Arc::new(input); + let http_client = build_editor_spritesheet_http_client( + EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, + EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("创建图集切片上传客户端失败:{error}"), + })) + })?; + let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) + .map(|index| { + let prepared = prepared.clone(); + let http_client = http_client.clone(); + let input = Arc::clone(&input); + async move { + let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?; + upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await + } + }) + .buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY) + .try_collect::>() .await?; - let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); - let record = persist_editor_generated_asset( - state, - PersistEditorGeneratedAssetInput { - project_id: input.project_id.clone(), - owner_user_id: input.owner_user_id.clone(), - folder_id: input.asset_folder_id.clone(), - label: slice.name.clone(), - image_src: image_src.clone(), - object_key: Some(persisted.object_key), - asset_object_id: Some(persisted.asset_object_id), - width: slice.width, - height: slice.height, - prompt: input.prompt.clone(), - actual_prompt: input.actual_prompt.clone(), - model: input.model.clone(), - provider: input.provider.clone(), - task_id: input.task_id.clone(), - group_task_id: input.group_task_id.clone(), - group_task_expected_asset_count: input - .group_task_id - .as_ref() - .map(|_| expected_asset_count), - source_resource_id: input.source_resource_id.clone(), - asset_kind: Some("icon".to_string()), - generation_inputs: input.generation_inputs.clone(), - thumbnail_src: None, - generation_cost_mud_points: 0, + uploads.sort_by_key(|upload| upload.index); + // 中文注释:上传收齐后只保留轻量 HEAD 元数据;数据库慢调用不应继续占用 + // 整张 RGBA 与图集专用内存 admission。 + drop(prepared); + + let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; + let folder_id = normalize_generated_asset_folder_id( + input.asset_folder_id.clone(), + input.owner_user_id.as_str(), + ); + let now_micros = current_utc_micros(); + let items = uploads + .iter() + .map(|upload| { + let resource_id = input.project_id.as_ref().map(|_| { + stable_editor_spritesheet_slice_record_id( + EDITOR_RESOURCE_ID_PREFIX, + input.owner_user_id.as_str(), + input.task_id.as_str(), + upload.index, + ) + }); + let project_resource = input.project_id.as_ref().map(|project_id| { + EditorProjectResourceCreateRecordInput { + resource_id: resource_id.clone().expect("resource id must exist"), + project_id: project_id.clone(), + owner_user_id: input.owner_user_id.clone(), + asset_object_id: Some(upload.asset_object.asset_object_id.clone()), + image_src: upload.image_src.clone(), + object_key: Some(upload.object_key.clone()), + width: upload.width, + height: upload.height, + source_type: "generated".to_string(), + prompt: Some(input.prompt.clone()), + actual_prompt: input.actual_prompt.clone(), + model: Some(input.model.clone()), + provider: Some(input.provider.clone()), + task_id: Some(input.task_id.clone()), + source_resource_id: normalize_optional_string(input.source_resource_id.clone()), + asset_kind: Some("icon".to_string()), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: now_micros, + } + }); + let asset = folder_id + .as_ref() + .map(|folder_id| EditorAssetCreateRecordInput { + asset_id: stable_editor_spritesheet_slice_record_id( + EDITOR_ASSET_ID_PREFIX, + input.owner_user_id.as_str(), + input.task_id.as_str(), + upload.index, + ), + owner_user_id: input.owner_user_id.clone(), + folder_id: folder_id.clone(), + label: upload.name.clone(), + asset_object_id: Some(upload.asset_object.asset_object_id.clone()), + image_src: upload.image_src.clone(), + object_key: Some(upload.object_key.clone()), + width: upload.width, + height: upload.height, + source_type: "generated".to_string(), + prompt: Some(input.prompt.clone()), + actual_prompt: input.actual_prompt.clone(), + model: Some(input.model.clone()), + provider: Some(input.provider.clone()), + task_id: Some(input.task_id.clone()), + asset_kind: Some("icon".to_string()), + generation_inputs_json: generation_inputs_json.clone(), + source_resource_id: resource_id.clone(), + generation_cost_mud_points: 0, + now_micros, + thumbnail_src: None, + group_task_id: normalize_optional_string(input.group_task_id.clone()), + group_task_expected_asset_count: input + .group_task_id + .as_ref() + .map(|_| expected_asset_count), + }); + EditorSpritesheetSlicePersistItemRecordInput { + asset_object: upload.asset_object.clone(), + project_resource, + asset, + } + }) + .collect(); + let persisted = state + .spacetime_client() + .persist_editor_spritesheet_slice_batch(EditorSpritesheetSliceBatchPersistRecordInput { + owner_user_id: input.owner_user_id.clone(), + task_id: input.task_id.clone(), + group_task_id: input.group_task_id.clone(), + expected_asset_count, + items, + completed_at_micros: now_micros, + }) + .await + .map_err(map_editor_project_error)?; + if persisted.items.len() != uploads.len() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "图集切片批量持久化返回数量不一致。", + })), + ); + } + Ok(uploads + .into_iter() + .zip(persisted.items) + .map(|(upload, item)| EditorIconSpritesheetIconResponse { + name: upload.name, + image_src: upload.image_src, + width: upload.width, + height: upload.height, + resource: item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: item.asset.map(editor_asset_payload_from_record), + }) + .collect()) +} + +fn stable_editor_spritesheet_slice_record_id( + prefix: &str, + owner_user_id: &str, + task_id: &str, + index: usize, +) -> String { + let digest = Sha256::digest( + format!( + "editor-spritesheet-slice\n{}\n{}\n{index}", + owner_user_id.trim(), + task_id.trim() + ) + .as_bytes(), + ); + format!("{prefix}{}", hex::encode(&digest[..16])) +} + +fn build_editor_spritesheet_http_client( + connect_timeout: Duration, + request_timeout: Duration, +) -> Result { + reqwest::Client::builder() + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .build() +} + +async fn encode_editor_icon_spritesheet_slice( + prepared: PreparedEditorIconSpritesheetPlan, + index: usize, +) -> Result { + if Instant::now() >= prepared.processing_deadline { + return Err(editor_icon_spritesheet_processing_timeout_error()); + } + let permit = match tokio::time::timeout_at( + tokio::time::Instant::from_std(prepared.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 processing_deadline = prepared.processing_deadline; + let worker = tokio::task::spawn_blocking(move || { + // 中文注释:blocking 任务同时持有 CPU permit 和内存 admission;async 超时 + // 只放弃等待,不能让尚未退出的编码绕过任一门限。 + let _permit = permit; + let _memory_admission = &prepared._memory_admission; + if Instant::now() >= processing_deadline { + return Err(editor_icon_spritesheet_processing_timeout_error()); + } + prepared + .plan + .encode(index) + .map_err(map_editor_icon_spritesheet_platform_error) + }); + match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await + { + Ok(Ok(result)) => result, + Ok(Err(error)) => Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("图集切片编码工作线程异常:{error}"), + }))), + Err(_) => Err(editor_icon_spritesheet_processing_timeout_error()), + } +} + +async fn upload_editor_spritesheet_slice( + state: &AppState, + http_client: &reqwest::Client, + index: usize, + slice: GeneratedAssetSheetConnectedIcon, + input: &PersistEditorSpritesheetSlicesInput, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + sanitize_editor_storage_segment(input.path_kind, "generated-images"), + sanitize_editor_storage_segment(input.task_id.as_str(), "task"), + ], + file_stem: sanitize_editor_storage_segment( + format!("asset-{:03}", index + 1).as_str(), + "image", + ), + image: GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime("image/png"), + bytes: slice.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(input.asset_kind.to_string()), + owner_user_id: Some(input.owner_user_id.clone()), + entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), + entity_id: Some(input.task_id.clone()), + slot: Some("asset".to_string()), + provider: Some(input.persistence_provider.to_string()), + task_id: Some(input.task_id.clone()), + }, + extra_metadata: BTreeMap::from([( + "source".to_string(), + "image-canvas-editor".to_string(), + )]), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备图集切片 OSS 上传请求失败:{error:?}"), + })) + })?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let put_result = oss_client + .put_object(http_client, prepared.request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: put_result.object_key, }, ) - .await?; - results.push(EditorIconSpritesheetIconResponse { - name: slice.name, - image_src, - width: slice.width, - height: slice.height, - resource: record.resource, - asset: record.asset, - }); - } - if let Some(group_task_id) = input.group_task_id.as_deref() { - let asset_ids = results - .iter() - .filter_map(|result| result.asset.as_ref().map(|asset| asset.asset_id.clone())) - .collect::>(); - state - .spacetime_client() - .complete_editor_asset_group_cohort(EditorAssetGroupCohortCompleteRecordInput { - owner_user_id: input.owner_user_id, - task_id: input.task_id, - group_task_id: group_task_id.to_string(), - expected_asset_count, - asset_ids, - completed_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; - } - Ok(results) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + let asset_object = build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + Some( + input + .actual_prompt + .as_deref() + .unwrap_or(input.prompt.as_str()) + .to_string(), + ), + input.asset_kind.to_string(), + Some(input.task_id.clone()), + Some(input.owner_user_id.clone()), + None, + Some(input.task_id.clone()), + now_micros, + ) + .map_err(map_editor_asset_field_error)?; + Ok(EditorSpritesheetSliceUpload { + index, + name: slice.name, + width: slice.width, + height: slice.height, + image_src: editor_media_src_from_object_key(head.object_key.as_str()), + object_key: head.object_key, + asset_object, + }) } pub async fn generate_editor_icon_spritesheet( @@ -4925,9 +5204,9 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let slice_source = DownloadedImage { - bytes: image.bytes.clone(), - mime_type: image.mime_type.clone(), - extension: image.extension.clone(), + bytes: image.bytes, + mime_type: image.mime_type, + extension: image.extension, }; let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, @@ -4977,10 +5256,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ); ( Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, - reason, - }), + Some(editor_icon_spritesheet_warning_after_persist_error(&error)), ) } } @@ -5078,6 +5354,17 @@ pub async fn split_editor_icon_spritesheet( "message": "未找到要拆分的图集资源。", })) })?; + if project.owner_user_id != owner_user_id + || source_resource.owner_user_id != owner_user_id + || source_resource.project_id != project_id + { + return Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集资源不属于当前账号或当前项目。", + })), + ); + } if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -5086,6 +5373,16 @@ pub async fn split_editor_icon_spritesheet( })), ); } + let source_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集资源缺少可读取的 objectKey。", + })) + })?; let task_id = build_prefixed_uuid_id("editor-atlas-split-"); let group_task_id = resolve_editor_manual_atlas_split_group_task_id( &state, @@ -5095,16 +5392,49 @@ pub async fn split_editor_icon_spritesheet( .await? .unwrap_or_else(|| task_id.clone()); - let reference = - parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str()) - .await?; + let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline( + Instant::now(), + request_context.external_call_deadline(), + ); + let memory_admission = + acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; + let download_client = build_editor_spritesheet_http_client( + EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, + EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("创建图集来源下载客户端失败:{error}"), + })) + })?; + // 中文注释:source_resource 已由 owner-scoped 项目读取完成鉴权,直接使用其稳定 + // objectKey,避免再按注册 ID 做两轮全账号项目与素材库扫描;memory admission + // 在下载前取得,使排队请求不能各自先堆积一份最大 32 MiB 的压缩源图。 + let reference = match tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + read_editor_reference_image_object_with_client( + &state, + source_object_key.as_str(), + &download_client, + ), + ) + .await + { + Ok(result) => result?, + Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), + }; let source = DownloadedImage { bytes: reference.bytes, mime_type: reference.mime_type, extension: "png".to_string(), }; - let slices = - slice_editor_icon_spritesheet_all(source, request_context.external_call_deadline()).await?; + let slices = slice_editor_icon_spritesheet_all_with_memory_admission( + source, + processing_deadline, + memory_admission, + ) + .await?; let prompt = source_resource .prompt .clone() @@ -5177,9 +5507,53 @@ pub async fn split_editor_icon_spritesheet( async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, -) -> Result, AppError> { +) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); + let memory_admission = + acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; + slice_editor_icon_spritesheet_all_with_memory_admission( + source, + processing_deadline, + memory_admission, + ) + .await +} + +async fn acquire_editor_icon_spritesheet_memory_admission( + processing_deadline: Instant, +) -> Result, AppError> { + if Instant::now() >= processing_deadline { + return Err(editor_icon_spritesheet_processing_timeout_error()); + } + let memory_permit = match tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + Arc::clone(&*EDITOR_ICON_SPRITESHEET_MEMORY_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!("图集切片内存门限不可用:{error}"), + })), + ); + } + Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), + }; + Ok(Arc::new(EditorIconSpritesheetMemoryAdmission { + _permit: memory_permit, + })) +} + +async fn slice_editor_icon_spritesheet_all_with_memory_admission( + source: DownloadedImage, + processing_deadline: Instant, + memory_admission: Arc, +) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } @@ -5201,21 +5575,24 @@ async fn slice_editor_icon_spritesheet_all( } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; + let worker_memory_admission = Arc::clone(&memory_admission); let worker = tokio::task::spawn_blocking(move || { - // 中文注释:permit 必须由 blocking 闭包持有;即使 async 等待超时,尚未退出的 - // 解码、连通域、裁剪或 PNG 编码仍占用并发名额,不能退化成无界 blocking pool。 + // 中文注释:CPU permit 和内存 admission 必须由 blocking 闭包持有;即使 + // async 等待超时,尚未退出的解码或连通域处理仍不能绕过并发门限。 let _permit = permit; + let _memory_admission = worker_memory_admission; 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( + prepare_generated_icon_spritesheet_all_by_connected_components( &source, EDITOR_ICON_SPRITESHEET_MAX_SLICES, + EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ) .map_err(map_editor_icon_spritesheet_platform_error) }); - let slices = + let plan = match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker) .await { @@ -5230,7 +5607,7 @@ async fn slice_editor_icon_spritesheet_all( } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; - if slices.is_empty() { + if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", @@ -5239,7 +5616,11 @@ async fn slice_editor_icon_spritesheet_all( })), ); } - Ok(slices) + Ok(PreparedEditorIconSpritesheetPlan { + plan, + processing_deadline, + _memory_admission: memory_admission, + }) } fn resolve_editor_icon_spritesheet_processing_deadline( @@ -5273,6 +5654,9 @@ fn map_editor_icon_spritesheet_platform_error(error: GeneratedAssetSheetError) - GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) } + GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. } => { + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) + } _ => None, }; let mut details = json!({ @@ -5302,6 +5686,9 @@ fn editor_icon_spritesheet_slice_warning_from_error( Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT } + Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) => { + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT + } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT } @@ -5313,6 +5700,33 @@ fn editor_icon_spritesheet_slice_warning_from_error( } } +fn editor_icon_spritesheet_warning_after_persist_error( + error: &AppError, +) -> EditorIconSpritesheetSliceWarningResponse { + let is_processing_failure = error + .details() + .and_then(|details| details.get("code")) + .and_then(Value::as_str) + .is_some_and(|code| { + matches!( + code, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT + | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT + | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT + | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT + | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT + ) + }); + if is_processing_failure { + editor_icon_spritesheet_slice_warning_from_error(error) + } else { + EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + 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() @@ -5821,9 +6235,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ) .await?; let slice_source = DownloadedImage { - bytes: image.bytes.clone(), - mime_type: image.mime_type.clone(), - extension: image.extension.clone(), + bytes: image.bytes, + mime_type: image.mime_type, + extension: image.extension, }; let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, @@ -5873,10 +6287,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ); ( Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, - reason, - }), + Some(editor_icon_spritesheet_warning_after_persist_error(&error)), ) } } @@ -8327,6 +8738,15 @@ fn editor_reference_image_forbidden(object_key: &str) -> AppError { async fn read_editor_reference_image_object( state: &AppState, source: &str, +) -> Result { + let http_client = reqwest::Client::new(); + read_editor_reference_image_object_with_client(state, source, &http_client).await +} + +async fn read_editor_reference_image_object_with_client( + state: &AppState, + source: &str, + http_client: &reqwest::Client, ) -> Result { if source.is_empty() { return Err( @@ -8349,7 +8769,7 @@ async fn read_editor_reference_image_object( expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let mut response = reqwest::Client::new() + let mut response = http_client .get(signed.signed_url.as_str()) .send() .await @@ -11004,6 +11424,13 @@ mod tests { }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT, ), + ( + GeneratedAssetSheetError::TotalCropPixelLimitExceeded { + total_crop_pixels: EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS + 1, + max_total_crop_pixels: EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, + }, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT, + ), ] { let error = map_editor_icon_spritesheet_platform_error(error); assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); @@ -11021,6 +11448,20 @@ mod tests { #[test] fn icon_spritesheet_processing_deadline_uses_earlier_budget() { assert_eq!(EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, 2); + assert_eq!(EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, 2); + assert_eq!(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY, 2); + assert_eq!( + EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, + Duration::from_secs(10) + ); + assert_eq!( + EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, + Duration::from_secs(60) + ); + assert_eq!( + EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, + EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4 + ); assert_eq!( EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION, Duration::from_secs(30) @@ -11051,7 +11492,8 @@ mod tests { let error = slice_editor_icon_spritesheet_all(source, Some(expired)) .await - .expect_err("expired CPU budget must fail before decoding"); + .err() + .expect("expired CPU budget must fail before decoding"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( @@ -11068,13 +11510,15 @@ mod tests { "async fn slice_editor_icon_spritesheet_all", "fn resolve_editor_icon_spritesheet_processing_deadline", &[ - "EDITOR_ICON_SPRITESHEET_CPU_LIMITER", + "EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER", "acquire_owned", + "EDITOR_ICON_SPRITESHEET_CPU_LIMITER", "tokio::task::spawn_blocking", "let _permit = permit", "validate_editor_icon_spritesheet_source", - "slice_generated_icon_spritesheet_all_by_connected_components", + "prepare_generated_icon_spritesheet_all_by_connected_components", "EDITOR_ICON_SPRITESHEET_MAX_SLICES", + "EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS", "tokio::time::timeout_at", ], ); @@ -11105,6 +11549,154 @@ mod tests { "complete_editor_canvas_generation_with_items", ], ); + assert_function_contains_in_order( + source, + "async fn persist_editor_spritesheet_slices", + "async fn encode_editor_icon_spritesheet_slice", + &[ + "build_editor_spritesheet_http_client", + "EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT", + "EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT", + "encode_editor_icon_spritesheet_slice", + "upload_editor_spritesheet_slice", + ".buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)", + ".try_collect::>()", + "uploads.sort_by_key", + "drop(prepared)", + "persist_editor_spritesheet_slice_batch", + ], + ); + assert_function_not_contains( + source, + "async fn persist_editor_spritesheet_slices", + "async fn encode_editor_icon_spritesheet_slice", + &[ + "persist_editor_generated_image(", + "persist_editor_generated_asset(", + ], + ); + assert_function_contains_in_order( + source, + "fn build_editor_spritesheet_http_client", + "pub async fn generate_editor_icon_spritesheet", + &["reqwest::Client::builder()", ".connect_timeout", ".timeout"], + ); + assert_function_contains_in_order( + source, + "pub async fn split_editor_icon_spritesheet", + "async fn slice_editor_icon_spritesheet_all", + &[ + "get_editor_project", + "normalize_editor_record_object_key", + "read_editor_reference_image_object", + "slice_editor_icon_spritesheet_all", + ], + ); + assert_function_not_contains( + source, + "pub async fn split_editor_icon_spritesheet", + "async fn slice_editor_icon_spritesheet_all", + &["parse_editor_reference_image", "list_editor_projects"], + ); + assert_function_contains_in_order( + source, + "pub async fn split_editor_icon_spritesheet", + "async fn slice_editor_icon_spritesheet_all", + &[ + "acquire_editor_icon_spritesheet_memory_admission", + "read_editor_reference_image_object_with_client", + "slice_editor_icon_spritesheet_all_with_memory_admission", + "persist_editor_spritesheet_slices", + ], + ); + } + + #[test] + fn spritesheet_slice_record_ids_are_stable_and_partitioned() { + let first = stable_editor_spritesheet_slice_record_id( + EDITOR_ASSET_ID_PREFIX, + "user-1", + "task-1", + 0, + ); + assert_eq!( + first, + stable_editor_spritesheet_slice_record_id( + EDITOR_ASSET_ID_PREFIX, + "user-1", + "task-1", + 0, + ) + ); + assert_ne!( + first, + stable_editor_spritesheet_slice_record_id( + EDITOR_ASSET_ID_PREFIX, + "user-1", + "task-1", + 1, + ) + ); + assert_ne!( + first, + stable_editor_spritesheet_slice_record_id( + EDITOR_ASSET_ID_PREFIX, + "user-2", + "task-1", + 0, + ) + ); + assert!(first.starts_with(EDITOR_ASSET_ID_PREFIX)); + } + + #[test] + fn automatic_spritesheet_persist_keeps_processing_timeout_warning_code() { + let timeout = editor_icon_spritesheet_processing_timeout_error(); + assert_eq!( + editor_icon_spritesheet_warning_after_persist_error(&timeout).code, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT + ); + let persistence = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": "upload failed", + })); + assert_eq!( + editor_icon_spritesheet_warning_after_persist_error(&persistence).code, + EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE + ); + } + + #[tokio::test] + async fn spritesheet_upload_timeout_releases_memory_admission() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("stall server should bind"); + let address = listener.local_addr().expect("stall address"); + let server = tokio::spawn(async move { + let (_socket, _) = listener.accept().await.expect("client should connect"); + std::future::pending::<()>().await; + }); + let limiter = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = Arc::clone(&limiter) + .acquire_owned() + .await + .expect("memory admission"); + let client = build_editor_spritesheet_http_client( + Duration::from_millis(50), + Duration::from_millis(50), + ) + .expect("bounded client"); + + let error = async move { + let _permit = permit; + client.get(format!("http://{address}/stall")).send().await + } + .await + .expect_err("stalled upload must time out"); + + assert!(error.is_timeout()); + assert!(limiter.try_acquire_owned().is_ok()); + server.abort(); } #[test] @@ -12959,17 +13551,13 @@ mod tests { "pub(crate) fn editor_project_payload_from_record", ), ] { - assert_function_contains( - source, - start, - end, - &["bytes: image.bytes.clone()", "slice_source"], - ); + assert_function_contains(source, start, end, &["bytes: image.bytes", "slice_source"]); assert_function_not_contains( source, start, end, &[ + "bytes: image.bytes.clone()", "download_editor_persisted_image_object", "drop(slice_source)", ], diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index cc6329d66..eab4513ae 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -93,7 +93,7 @@ pub fn apply_generated_asset_sheet_alpha_with_options( source: image::DynamicImage, options: GeneratedAssetSheetAlphaOptions, ) -> image::DynamicImage { - let mut image = source.to_rgba8(); + let mut image = source.into_rgba8(); let (width, height) = image.dimensions(); remove_generated_asset_sheet_green_screen_background( image.as_mut(), 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 fb66c5df3..ed58a2379 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 @@ -17,6 +17,10 @@ pub enum GeneratedAssetSheetError { slice_count: usize, max_slice_count: usize, }, + TotalCropPixelLimitExceeded { + total_crop_pixels: u64, + max_total_crop_pixels: u64, + }, MergeCandidateLimitExceeded { candidate_count: usize, max_candidate_count: usize, @@ -56,6 +60,12 @@ impl GeneratedAssetSheetError { } => format!( "图标 spritesheet 识别出的素材数量超过输出上限:{slice_count},最多允许 {max_slice_count} 个。" ), + Self::TotalCropPixelLimitExceeded { + total_crop_pixels, + max_total_crop_pixels, + } => format!( + "图标 spritesheet 带留白裁剪的累计像素超过处理上限:{total_crop_pixels},最多允许 {max_total_crop_pixels} 像素。" + ), Self::MergeCandidateLimitExceeded { candidate_count, max_candidate_count, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index c0f9cc8a1..172ac6d48 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -17,9 +17,10 @@ pub use persist::{ }; pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; pub use sheet::{ - GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetSliceImage, - crop_generated_asset_sheet_view_edge_matte, - crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet, + GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, + GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, + crop_generated_asset_sheet_view_edge_matte_with_options, + prepare_generated_icon_spritesheet_all_by_connected_components, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, slice_generated_icon_spritesheet_all_by_connected_components, }; 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 457cb6aaf..79d5810df 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 @@ -13,6 +13,7 @@ use super::color::{ use super::error::GeneratedAssetSheetError; use image::{GenericImageView, ImageFormat}; use std::collections::BTreeMap; +use std::sync::Arc; #[derive(Clone, Debug, PartialEq, Eq)] pub struct GeneratedAssetSheetSliceImage { @@ -141,6 +142,72 @@ pub struct GeneratedAssetSheetConnectedIcon { pub height: u32, } +#[derive(Clone)] +pub struct GeneratedAssetSheetConnectedIconPlan { + source: Arc, + icons: Arc<[GeneratedAssetSheetConnectedIconPlanItem]>, + #[cfg(test)] + encode_count: Arc, +} + +#[derive(Clone)] +struct GeneratedAssetSheetConnectedIconPlanItem { + name: String, + crop: GeneratedAssetSheetCellBounds, +} + +impl GeneratedAssetSheetConnectedIconPlan { + pub fn len(&self) -> usize { + self.icons.len() + } + + pub fn is_empty(&self) -> bool { + self.icons.is_empty() + } + + pub fn encode( + &self, + index: usize, + ) -> Result { + let item = self.icons.get(index).ok_or_else(|| { + GeneratedAssetSheetError::invalid_request(format!( + "图标 spritesheet 切片索引超出范围:index={index}, sliceCount={}。", + self.icons.len() + )) + })?; + #[cfg(test)] + self.encode_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let cropped = image::imageops::crop_imm( + self.source.as_ref(), + item.crop.x0, + item.crop.y0, + item.crop.width(), + item.crop.height(), + ) + .to_image(); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(cropped) + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + GeneratedAssetSheetError::encode_image(format!( + "图标 spritesheet 切割失败:{error}" + )) + })?; + Ok(GeneratedAssetSheetConnectedIcon { + name: item.name.clone(), + bytes: cursor.into_inner(), + width: item.crop.width(), + height: item.crop.height(), + }) + } + + #[cfg(test)] + fn test_encode_count(&self) -> usize { + self.encode_count.load(std::sync::atomic::Ordering::Relaxed) + } +} + const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16; const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16; const GENERATED_ICON_MAX_RAW_COMPONENTS: usize = 4096; @@ -153,16 +220,38 @@ pub fn slice_generated_icon_spritesheet_all_by_connected_components( image: &crate::DownloadedImage, max_output_slices: usize, ) -> Result, GeneratedAssetSheetError> { + let plan = prepare_generated_icon_spritesheet_all_by_connected_components( + image, + max_output_slices, + u64::MAX, + )?; + (0..plan.len()).map(|index| plan.encode(index)).collect() +} + +pub fn prepare_generated_icon_spritesheet_all_by_connected_components( + image: &crate::DownloadedImage, + max_output_slices: usize, + max_total_crop_pixels: u64, +) -> Result { if max_output_slices == 0 { return Err(GeneratedAssetSheetError::invalid_request( "图标 spritesheet 输出切片上限必须大于 0。", )); } + if max_total_crop_pixels == 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, max_output_slices) + prepare_generated_icon_spritesheet_rgba_by_connected_components( + source, + max_output_slices, + max_total_crop_pixels, + ) } pub fn crop_generated_asset_sheet_view_edge_matte( @@ -174,11 +263,12 @@ pub fn crop_generated_asset_sheet_view_edge_matte( ) } -fn slice_generated_icon_spritesheet_rgba_by_connected_components( +fn prepare_generated_icon_spritesheet_rgba_by_connected_components( source: image::DynamicImage, max_output_slices: usize, -) -> Result, GeneratedAssetSheetError> { - let mut image = source.to_rgba8(); + max_total_crop_pixels: u64, +) -> Result { + let mut image = source.into_rgba8(); let (width, height) = image.dimensions(); let pixel_count = (width as usize).saturating_mul(height as usize); if pixel_count == 0 { @@ -207,12 +297,9 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( }); } sort_generated_icon_components_in_visual_rows(&mut components); - let icon_names = (1..=components.len()) - .map(|index| format!("素材 {index}")) - .collect::>(); - - let mut icons = Vec::with_capacity(icon_names.len()); - for (name, bounds) in icon_names.iter().zip(components.into_iter()) { + let mut total_crop_pixels = 0u64; + let mut icons = Vec::with_capacity(components.len()); + for (index, bounds) in components.into_iter().enumerate() { let pad_x = resolve_generated_icon_crop_padding(bounds.width(), width); let pad_y = resolve_generated_icon_crop_padding(bounds.height(), height); let crop = GeneratedAssetSheetCellBounds { @@ -221,26 +308,26 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( x1: bounds.x1.saturating_add(pad_x).min(width), y1: bounds.y1.saturating_add(pad_y).min(height), }; - let cropped = - image::imageops::crop_imm(&image, crop.x0, crop.y0, crop.width(), crop.height()) - .to_image(); - let mut cursor = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(cropped) - .write_to(&mut cursor, ImageFormat::Png) - .map_err(|error| { - GeneratedAssetSheetError::encode_image(format!( - "图标 spritesheet 切割失败:{error}" - )) - })?; - icons.push(GeneratedAssetSheetConnectedIcon { - name: name.clone(), - bytes: cursor.into_inner(), - width: crop.width(), - height: crop.height(), + total_crop_pixels = total_crop_pixels + .saturating_add(u64::from(crop.width()).saturating_mul(u64::from(crop.height()))); + if total_crop_pixels > max_total_crop_pixels { + return Err(GeneratedAssetSheetError::TotalCropPixelLimitExceeded { + total_crop_pixels, + max_total_crop_pixels, + }); + } + icons.push(GeneratedAssetSheetConnectedIconPlanItem { + name: format!("素材 {}", index + 1), + crop, }); } - Ok(icons) + Ok(GeneratedAssetSheetConnectedIconPlan { + source: Arc::new(image), + icons: icons.into(), + #[cfg(test)] + encode_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }) } fn generated_icon_alpha_fill_ratio(image: &image::RgbaImage) -> f32 { @@ -788,6 +875,85 @@ mod tests { ); } + fn build_connected_icon_test_source() -> crate::DownloadedImage { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255])); + for y in 10..24 { + for x in 12..28 { + sheet.put_pixel(x, y, Rgba([240, 80, 80, 255])); + } + } + for y in 32..46 { + for x in 52..70 { + sheet.put_pixel(x, y, Rgba([80, 120, 240, 255])); + } + } + crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + } + } + + #[test] + fn connected_icon_plan_prepares_without_encoding_and_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + + let source = build_connected_icon_test_source(); + let plan = + prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, u64::MAX) + .expect("connected icon plan should prepare"); + + assert_eq!(plan.len(), 2); + assert!(!plan.is_empty()); + assert_eq!(plan.test_encode_count(), 0); + let icon = plan.encode(0).expect("first icon should encode"); + assert_eq!(icon.name, "素材 1"); + assert_eq!(plan.test_encode_count(), 1); + } + + #[test] + fn connected_icon_plan_encodes_multiple_indices_concurrently_without_changing_results() { + let source = build_connected_icon_test_source(); + let plan = + prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, u64::MAX) + .expect("connected icon plan should prepare"); + let concurrent_icons = std::thread::scope(|scope| { + let plan = &plan; + let handles = (0..plan.len()) + .map(|index| { + scope.spawn(move || plan.encode(index).expect("icon should encode in worker")) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().expect("worker should not panic")) + .collect::>() + }); + let legacy_icons = + slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) + .expect("legacy slicing API should succeed"); + + assert_eq!(concurrent_icons, legacy_icons); + assert_eq!(plan.test_encode_count(), plan.len()); + } + + #[test] + fn connected_icon_plan_rejects_total_padded_crop_pixels_before_encoding() { + let source = build_connected_icon_test_source(); + let error = prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, 1) + .err() + .expect("total padded crop pixel budget must fail during prepare"); + + assert!(matches!( + error, + GeneratedAssetSheetError::TotalCropPixelLimitExceeded { + total_crop_pixels, + max_total_crop_pixels: 1, + } if total_crop_pixels > 1 + )); + } + #[test] fn slices_icon_spritesheet_by_connected_components_in_reading_order() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255])); diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index c02657e0b..2e991dc7c 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -1,6 +1,87 @@ use super::*; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorSpritesheetSlicePersistItemRecordInput { + pub asset_object: module_assets::AssetObjectUpsertInput, + pub project_resource: Option, + pub asset: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorSpritesheetSliceBatchPersistRecordInput { + pub owner_user_id: String, + pub task_id: String, + pub group_task_id: Option, + pub expected_asset_count: u32, + pub items: Vec, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorSpritesheetSlicePersistedItemRecord { + pub asset_object: module_assets::AssetObjectUpsertSnapshot, + pub project_resource: Option, + pub asset: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorSpritesheetSliceBatchPersistRecord { + pub items: Vec, +} + +impl From + for crate::module_bindings::EditorSpritesheetSlicePersistItemInput +{ + fn from(input: EditorSpritesheetSlicePersistItemRecordInput) -> Self { + Self { + asset_object: input.asset_object.into(), + project_resource: input.project_resource.map(Into::into), + asset: input.asset.map(Into::into), + } + } +} + +impl From + for crate::module_bindings::EditorSpritesheetSliceBatchPersistInput +{ + fn from(input: EditorSpritesheetSliceBatchPersistRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + task_id: input.task_id, + group_task_id: input.group_task_id, + expected_asset_count: input.expected_asset_count, + items: input.items.into_iter().map(Into::into).collect(), + completed_at_micros: input.completed_at_micros, + } + } +} + impl SpacetimeClient { + pub async fn persist_editor_spritesheet_slice_batch( + &self, + input: EditorSpritesheetSliceBatchPersistRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "persist_editor_spritesheet_slice_batch_and_return", + move |connection, sender| { + connection + .procedures() + .persist_editor_spritesheet_slice_batch_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_spritesheet_slice_batch_persist_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_editor_project( &self, input: EditorProjectCreateRecordInput, @@ -899,3 +980,136 @@ impl SpacetimeClient { .await } } + +fn map_editor_spritesheet_slice_batch_persist_result( + result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let items = result + .items + .into_iter() + .map(|item| { + Ok(EditorSpritesheetSlicePersistedItemRecord { + asset_object: map_editor_spritesheet_asset_object_snapshot(item.asset_object), + project_resource: item + .project_resource + .map(map_editor_spritesheet_project_resource_snapshot) + .transpose()?, + asset: item + .asset + .map(map_editor_spritesheet_asset_snapshot) + .transpose()?, + }) + }) + .collect::, SpacetimeClientError>>()?; + Ok(EditorSpritesheetSliceBatchPersistRecord { items }) +} + +fn map_editor_spritesheet_asset_object_snapshot( + snapshot: crate::module_bindings::AssetObjectUpsertSnapshot, +) -> module_assets::AssetObjectUpsertSnapshot { + module_assets::AssetObjectUpsertSnapshot { + asset_object_id: snapshot.asset_object_id, + bucket: snapshot.bucket, + object_key: snapshot.object_key, + access_policy: match snapshot.access_policy { + crate::module_bindings::AssetObjectAccessPolicy::Private => { + module_assets::AssetObjectAccessPolicy::Private + } + crate::module_bindings::AssetObjectAccessPolicy::PublicRead => { + module_assets::AssetObjectAccessPolicy::PublicRead + } + }, + content_type: snapshot.content_type, + content_length: snapshot.content_length, + content_hash: snapshot.content_hash, + version: snapshot.version, + source_job_id: snapshot.source_job_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + asset_kind: snapshot.asset_kind, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_editor_spritesheet_project_resource_snapshot( + snapshot: crate::module_bindings::EditorProjectResourceSnapshot, +) -> Result { + Ok(EditorProjectResourceRecord { + resource_id: snapshot.resource_id, + project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, + asset_object_id: snapshot.asset_object_id, + image_src: snapshot.image_src, + object_key: snapshot.object_key, + width: snapshot.width, + height: snapshot.height, + source_type: snapshot.source_type, + prompt: snapshot.prompt, + actual_prompt: snapshot.actual_prompt, + model: snapshot.model, + provider: snapshot.provider, + task_id: snapshot.task_id, + source_resource_id: snapshot.source_resource_id, + asset_kind: snapshot.asset_kind, + generation_inputs: parse_editor_spritesheet_generation_inputs( + snapshot.generation_inputs_json, + "图片画布图集切片资源生成输入 JSON", + )?, + public_showcase_enabled: snapshot.public_showcase_enabled, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +fn map_editor_spritesheet_asset_snapshot( + snapshot: crate::module_bindings::EditorAssetSnapshot, +) -> Result { + Ok(EditorAssetRecord { + asset_id: snapshot.asset_id, + folder_id: snapshot.folder_id, + label: snapshot.label, + asset_object_id: snapshot.asset_object_id, + image_src: snapshot.image_src, + object_key: snapshot.object_key, + width: snapshot.width, + height: snapshot.height, + source_type: snapshot.source_type, + prompt: snapshot.prompt, + actual_prompt: snapshot.actual_prompt, + model: snapshot.model, + provider: snapshot.provider, + task_id: snapshot.task_id, + asset_kind: snapshot.asset_kind, + generation_inputs: parse_editor_spritesheet_generation_inputs( + snapshot.generation_inputs_json, + "图片画布图集切片素材生成输入 JSON", + )?, + source_resource_id: snapshot.source_resource_id, + public_showcase_enabled: snapshot.public_showcase_enabled, + thumbnail_src: snapshot.thumbnail_src, + generation_cost_mud_points: snapshot.generation_cost_mud_points, + showcase_id: snapshot.showcase_id, + showcase_review_status: snapshot.showcase_review_status, + showcase_display_enabled: snapshot.showcase_display_enabled, + showcase_like_count: snapshot.showcase_like_count, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +fn parse_editor_spritesheet_generation_inputs( + raw: Option, + label: &str, +) -> Result, SpacetimeClientError> { + raw.map(|value| { + serde_json::from_str(&value).map_err(|error| { + SpacetimeClientError::validation_failed(format!("{label} 无法解析:{error}")) + }) + }) + .transpose() +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b7d5cd579..e0100906a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -345,6 +345,10 @@ pub mod editor_showcase_campaign_config_snapshot_type; pub mod editor_showcase_campaign_config_table; pub mod editor_showcase_campaign_config_type; pub mod editor_showcase_campaign_config_upsert_input_type; +pub mod editor_spritesheet_slice_batch_persist_input_type; +pub mod editor_spritesheet_slice_batch_persist_result_type; +pub mod editor_spritesheet_slice_persist_item_input_type; +pub mod editor_spritesheet_slice_persisted_item_type; pub mod enqueue_external_generation_job_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod expire_profile_recharge_order_timer_reducer; @@ -471,6 +475,7 @@ pub mod npc_relation_state_type; pub mod npc_stance_profile_type; pub mod npc_state_table; pub mod npc_state_type; +pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; pub mod player_progression_grant_source_type; pub mod player_progression_table; pub mod player_progression_type; @@ -1163,6 +1168,10 @@ pub use editor_showcase_campaign_config_snapshot_type::EditorShowcaseCampaignCon pub use editor_showcase_campaign_config_table::*; pub use editor_showcase_campaign_config_type::EditorShowcaseCampaignConfig; pub use editor_showcase_campaign_config_upsert_input_type::EditorShowcaseCampaignConfigUpsertInput; +pub use editor_spritesheet_slice_batch_persist_input_type::EditorSpritesheetSliceBatchPersistInput; +pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSliceBatchPersistResult; +pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; +pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use expire_profile_recharge_order_timer_reducer::expire_profile_recharge_order_timer; @@ -1289,6 +1298,7 @@ pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; pub use npc_state_table::*; pub use npc_state_type::NpcState; +pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; pub use player_progression_grant_source_type::PlayerProgressionGrantSource; pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_input_type.rs new file mode 100644 index 000000000..cf843dd85 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorSpritesheetSliceBatchPersistInput { + pub owner_user_id: String, + pub task_id: String, + pub group_task_id: Option, + pub expected_asset_count: u32, + pub items: Vec, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for EditorSpritesheetSliceBatchPersistInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_result_type.rs new file mode 100644 index 000000000..09b141018 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_batch_persist_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorSpritesheetSliceBatchPersistResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for EditorSpritesheetSliceBatchPersistResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persist_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persist_item_input_type.rs new file mode 100644 index 000000000..394201bc3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persist_item_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; +use super::editor_asset_create_input_type::EditorAssetCreateInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorSpritesheetSlicePersistItemInput { + pub asset_object: AssetObjectUpsertInput, + pub project_resource: Option, + pub asset: Option, +} + +impl __sdk::InModule for EditorSpritesheetSlicePersistItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persisted_item_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persisted_item_type.rs new file mode 100644 index 000000000..882fab177 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_spritesheet_slice_persisted_item_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +use super::editor_asset_snapshot_type::EditorAssetSnapshot; +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorSpritesheetSlicePersistedItem { + pub asset_object: AssetObjectUpsertSnapshot, + pub project_resource: Option, + pub asset: Option, +} + +impl __sdk::InModule for EditorSpritesheetSlicePersistedItem { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs new file mode 100644 index 000000000..5789c1e76 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_spritesheet_slice_batch_persist_input_type::EditorSpritesheetSliceBatchPersistInput; +use super::editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSliceBatchPersistResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PersistEditorSpritesheetSliceBatchAndReturnArgs { + pub input: EditorSpritesheetSliceBatchPersistInput, +} + +impl __sdk::InModule for PersistEditorSpritesheetSliceBatchAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `persist_editor_spritesheet_slice_batch_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait persist_editor_spritesheet_slice_batch_and_return { + fn persist_editor_spritesheet_slice_batch_and_return( + &self, + input: EditorSpritesheetSliceBatchPersistInput, + ) { + self.persist_editor_spritesheet_slice_batch_and_return_then(input, |_, _| {}); + } + + fn persist_editor_spritesheet_slice_batch_and_return_then( + &self, + input: EditorSpritesheetSliceBatchPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl persist_editor_spritesheet_slice_batch_and_return for super::RemoteProcedures { + fn persist_editor_spritesheet_slice_batch_and_return_then( + &self, + input: EditorSpritesheetSliceBatchPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorSpritesheetSliceBatchPersistResult>( + "persist_editor_spritesheet_slice_batch_and_return", + PersistEditorSpritesheetSliceBatchAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index c0ba703af..cfb4487ae 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -292,7 +292,7 @@ pub(crate) fn upsert_asset_object( Ok(snapshot) } -fn find_asset_object_by_location( +pub(crate) fn find_asset_object_by_location( ctx: &ReducerContext, input: &AssetObjectLocationInput, ) -> Result, String> { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 5bebf74c8..5df4afb18 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -15,6 +15,7 @@ const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED: &str = "backfilled"; const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE: &str = "active"; const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back"; const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16; +const EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS: usize = 64; const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ @@ -850,6 +851,37 @@ pub struct EditorAssetGroupCohortCompleteInput { pub completed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorSpritesheetSlicePersistItemInput { + pub asset_object: AssetObjectUpsertInput, + pub project_resource: Option, + pub asset: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorSpritesheetSliceBatchPersistInput { + pub owner_user_id: String, + pub task_id: String, + pub group_task_id: Option, + pub expected_asset_count: u32, + pub items: Vec, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorSpritesheetSlicePersistedItem { + pub asset_object: AssetObjectUpsertSnapshot, + pub project_resource: Option, + pub asset: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorSpritesheetSliceBatchPersistResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetUpdateInput { pub asset_id: String, @@ -1476,6 +1508,26 @@ pub fn complete_editor_asset_group_cohort_and_return( } } +#[spacetimedb::procedure] +pub fn persist_editor_spritesheet_slice_batch_and_return( + ctx: &mut ProcedureContext, + input: EditorSpritesheetSliceBatchPersistInput, +) -> EditorSpritesheetSliceBatchPersistResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| persist_editor_spritesheet_slice_batch(tx, caller, input.clone())) { + Ok(items) => EditorSpritesheetSliceBatchPersistResult { + ok: true, + items, + error_message: None, + }, + Err(message) => EditorSpritesheetSliceBatchPersistResult { + ok: false, + items: Vec::new(), + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn update_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -2395,6 +2447,284 @@ fn find_editor_asset_group_source( Ok(None) } +fn persist_editor_spritesheet_slice_batch( + ctx: &ReducerContext, + caller: Identity, + input: EditorSpritesheetSliceBatchPersistInput, +) -> Result, String> { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let task_id = normalize_required(&input.task_id, "task_id")?; + let group_task_id = normalize_optional(input.group_task_id.clone()); + validate_editor_spritesheet_slice_batch( + &input, + owner_user_id.as_str(), + task_id.as_str(), + group_task_id.as_deref(), + )?; + validate_editor_spritesheet_slice_batch_asset_object_conflicts(ctx, &input)?; + + let mut persisted_items = Vec::with_capacity(input.items.len()); + let mut asset_ids = Vec::with_capacity(input.items.len()); + for item in input.items { + let asset_object = crate::asset_metadata::upsert_asset_object(ctx, item.asset_object)?; + let project_resource = item + .project_resource + .map(|mut resource| { + resource.asset_object_id = Some(asset_object.asset_object_id.clone()); + create_editor_project_resource(ctx, resource) + }) + .transpose()?; + let asset = item + .asset + .map(|mut asset| { + asset.asset_object_id = Some(asset_object.asset_object_id.clone()); + asset.source_resource_id = project_resource + .as_ref() + .map(|resource| resource.resource_id.clone()); + create_or_reuse_editor_spritesheet_asset(ctx, caller, asset) + }) + .transpose()?; + if let Some(asset) = &asset { + asset_ids.push(asset.asset_id.clone()); + } + persisted_items.push(EditorSpritesheetSlicePersistedItem { + asset_object, + project_resource, + asset, + }); + } + + if let Some(group_task_id) = group_task_id { + complete_editor_asset_group_cohort( + ctx, + caller, + EditorAssetGroupCohortCompleteInput { + owner_user_id, + task_id, + group_task_id, + expected_asset_count: input.expected_asset_count, + asset_ids, + completed_at_micros: input.completed_at_micros, + }, + )?; + } + + Ok(persisted_items) +} + +fn create_or_reuse_editor_spritesheet_asset( + ctx: &ReducerContext, + caller: Identity, + input: EditorAssetCreateInput, +) -> Result { + let asset_id = normalize_required(&input.asset_id, "editor_asset.asset_id")?; + if let Some(existing) = ctx.db.editor_asset().asset_id().find(&asset_id) { + if editor_spritesheet_asset_matches_create_input(&existing, &input) { + return Ok(asset_snapshot_from_row(ctx, existing)); + } + return Err("图集切片素材幂等键已被其他内容占用".to_string()); + } + create_editor_asset(ctx, caller, input) +} + +fn editor_spritesheet_asset_matches_create_input( + existing: &EditorAsset, + input: &EditorAssetCreateInput, +) -> bool { + existing.asset_id.trim() == input.asset_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.folder_id.trim() == input.folder_id.trim() + && existing.label == normalize_asset_label(&input.label) + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.thumbnail_src == normalize_optional(input.thumbnail_src.clone()) + && existing.generation_cost_mud_points == input.generation_cost_mud_points + && existing.group_task_id == normalize_optional(input.group_task_id.clone()) + && existing.group_task_expected_asset_count == input.group_task_expected_asset_count +} + +fn validate_editor_spritesheet_slice_batch_asset_object_conflicts( + ctx: &ReducerContext, + input: &EditorSpritesheetSliceBatchPersistInput, +) -> Result<(), String> { + for item in &input.items { + let candidate = &item.asset_object; + crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: candidate.bucket.clone(), + object_key: candidate.object_key.clone(), + }, + )?; + if let Some(existing) = + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, &candidate.asset_object_id) + && (existing.bucket != candidate.bucket || existing.object_key != candidate.object_key) + { + return Err("图集切片 asset_object_id 已指向其他对象位置".to_string()); + } + if let Some(resource) = &item.project_resource + && let Some(source_resource_id) = + normalize_optional(resource.source_resource_id.clone()) + { + let source_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id); + validate_editor_spritesheet_source_resource_relationship( + resource, + source_resource.as_ref(), + )?; + } + } + Ok(()) +} + +fn validate_editor_spritesheet_source_resource_relationship( + resource: &EditorProjectResourceCreateInput, + source_resource: Option<&EditorProjectResource>, +) -> Result<(), String> { + let source_resource = source_resource.ok_or_else(|| "图集切片来源资源不存在".to_string())?; + if source_resource.resource_id == resource.resource_id.trim() + || source_resource.owner_user_id != resource.owner_user_id.trim() + || source_resource.project_id != resource.project_id.trim() + { + return Err("图集切片来源资源不属于同一 owner 与项目".to_string()); + } + Ok(()) +} + +fn validate_editor_spritesheet_slice_batch( + input: &EditorSpritesheetSliceBatchPersistInput, + owner_user_id: &str, + task_id: &str, + group_task_id: Option<&str>, +) -> Result<(), String> { + let item_count = input.items.len(); + if !(1..=EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS).contains(&item_count) { + return Err("图集切片批次产物数量必须在 1 到 64 之间".to_string()); + } + if usize::try_from(input.expected_asset_count).ok() != Some(item_count) { + return Err("图集切片批次产物数量与预期不一致".to_string()); + } + + let mut asset_object_ids = BTreeSet::new(); + let mut object_keys = BTreeSet::new(); + let mut resource_ids = BTreeSet::new(); + let mut asset_ids = BTreeSet::new(); + let mut asset_count = 0usize; + for item in &input.items { + let asset_object_id = normalize_required( + &item.asset_object.asset_object_id, + "asset_object.asset_object_id", + )?; + let object_key = + normalize_required(&item.asset_object.object_key, "asset_object.object_key")?; + normalize_required(&item.asset_object.bucket, "asset_object.bucket")?; + normalize_required(&item.asset_object.asset_kind, "asset_object.asset_kind")?; + if !asset_object_ids.insert(asset_object_id.clone()) { + return Err("图集切片批次包含重复 asset_object_id".to_string()); + } + if !object_keys.insert(object_key.clone()) { + return Err("图集切片批次包含重复 object_key".to_string()); + } + if normalize_optional_ref(item.asset_object.owner_user_id.as_ref()) != Some(owner_user_id) + || normalize_optional_ref(item.asset_object.source_job_id.as_ref()) != Some(task_id) + { + return Err("图集切片对象的 owner 或 task 归属不一致".to_string()); + } + + let resource_identity = item + .project_resource + .as_ref() + .map(|resource| { + let resource_id = normalize_required( + &resource.resource_id, + "editor_project_resource.resource_id", + )?; + if !resource_ids.insert(resource_id.clone()) { + return Err("图集切片批次包含重复 resource_id".to_string()); + } + normalize_required(&resource.project_id, "editor_project_resource.project_id")?; + if resource.owner_user_id.trim() != owner_user_id + || normalize_optional_ref(resource.asset_object_id.as_ref()) + != Some(asset_object_id.as_str()) + || normalize_optional_ref(resource.object_key.as_ref()) + != Some(object_key.as_str()) + || normalize_optional_ref(resource.task_id.as_ref()) != Some(task_id) + || normalize_media_ref(resource.image_src.as_str()) != object_key + { + return Err("图集切片项目资源与对象的 owner、媒体或任务归属不一致".to_string()); + } + let asset_kind = normalize_optional_ref(resource.asset_kind.as_ref()) + .ok_or_else(|| "图集切片项目资源缺少素材类型".to_string())?; + Ok((resource_id, asset_kind.to_string())) + }) + .transpose()?; + + if let Some(asset) = &item.asset { + asset_count += 1; + let asset_id = normalize_required(&asset.asset_id, "editor_asset.asset_id")?; + if !asset_ids.insert(asset_id) { + return Err("图集切片批次包含重复 asset_id".to_string()); + } + normalize_required(&asset.folder_id, "editor_asset.folder_id")?; + let asset_kind = normalize_optional_ref(asset.asset_kind.as_ref()) + .ok_or_else(|| "图集切片素材缺少素材类型".to_string())?; + let source_resource_matches = match resource_identity.as_ref() { + Some((resource_id, _)) => { + normalize_optional_ref(asset.source_resource_id.as_ref()) + == Some(resource_id.as_str()) + } + None => normalize_optional_ref(asset.source_resource_id.as_ref()).is_none(), + }; + if asset.owner_user_id.trim() != owner_user_id + || normalize_optional_ref(asset.asset_object_id.as_ref()) + != Some(asset_object_id.as_str()) + || normalize_optional_ref(asset.object_key.as_ref()) != Some(object_key.as_str()) + || normalize_optional_ref(asset.task_id.as_ref()) != Some(task_id) + || !source_resource_matches + || normalize_media_ref(asset.image_src.as_str()) != object_key + { + return Err( + "图集切片素材与对象、项目资源的 owner、媒体或任务归属不一致".to_string() + ); + } + if resource_identity + .as_ref() + .is_some_and(|(_, resource_asset_kind)| resource_asset_kind != asset_kind) + { + return Err("图集切片项目资源与素材的业务类型不一致".to_string()); + } + if normalize_optional_ref(asset.group_task_id.as_ref()) != group_task_id + || asset.group_task_expected_asset_count + != group_task_id.map(|_| input.expected_asset_count) + { + return Err("图集切片素材的归组信息与批次不一致".to_string()); + } + } + } + + if group_task_id.is_some() && asset_count != item_count { + return Err("需要完成 cohort 的图集切片批次必须为每项创建素材".to_string()); + } + Ok(()) +} + fn complete_editor_asset_group_cohort( ctx: &ReducerContext, caller: Identity, @@ -8017,6 +8347,249 @@ mod tests { use super::*; use serde_json::json; + fn spritesheet_slice_item(index: usize) -> EditorSpritesheetSlicePersistItemInput { + let object_key = format!("generated/editor/slices/slice-{index}.png"); + let asset_object_id = format!("asset-object-{index}"); + let resource_id = format!("resource-{index}"); + EditorSpritesheetSlicePersistItemInput { + asset_object: AssetObjectUpsertInput { + asset_object_id: asset_object_id.clone(), + bucket: "editor-private".to_string(), + object_key: object_key.clone(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 128, + content_hash: None, + version: 1, + source_job_id: Some("task-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: None, + asset_kind: "editor_icon_spritesheet_slice".to_string(), + updated_at_micros: 1_000_000, + }, + project_resource: Some(EditorProjectResourceCreateInput { + resource_id: resource_id.clone(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some(asset_object_id.clone()), + image_src: format!("/{object_key}"), + object_key: Some(object_key.clone()), + width: 32, + height: 32, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("task-1".to_string()), + source_resource_id: Some("source-resource".to_string()), + asset_kind: Some("icon".to_string()), + generation_inputs_json: None, + updated_at_micros: 1_000_000, + }), + asset: Some(EditorAssetCreateInput { + asset_id: format!("asset-{index}"), + owner_user_id: "user-1".to_string(), + folder_id: "project".to_string(), + label: format!("slice-{index}"), + asset_object_id: Some(asset_object_id), + image_src: format!("/{object_key}"), + object_key: Some(object_key), + width: 32, + height: 32, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("task-1".to_string()), + asset_kind: Some("icon".to_string()), + generation_inputs_json: None, + source_resource_id: Some(resource_id), + now_micros: 1_000_000, + thumbnail_src: None, + generation_cost_mud_points: 0, + group_task_id: Some("group-task-1".to_string()), + group_task_expected_asset_count: Some(2), + }), + } + } + + fn spritesheet_slice_batch() -> EditorSpritesheetSliceBatchPersistInput { + EditorSpritesheetSliceBatchPersistInput { + owner_user_id: "user-1".to_string(), + task_id: "task-1".to_string(), + group_task_id: Some("group-task-1".to_string()), + expected_asset_count: 2, + items: vec![spritesheet_slice_item(0), spritesheet_slice_item(1)], + completed_at_micros: 1_000_000, + } + } + + fn spritesheet_source_resource(owner_user_id: &str, project_id: &str) -> EditorProjectResource { + let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorProjectResource { + resource_id: "source-resource".to_string(), + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: Some("source-object".to_string()), + image_src: "/generated/editor/source.png".to_string(), + object_key: Some("generated/editor/source.png".to_string()), + width: 256, + height: 256, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("source-task".to_string()), + source_resource_id: None, + created_at: timestamp, + updated_at: timestamp, + asset_kind: Some("icon-spritesheet".to_string()), + generation_inputs_json: None, + public_showcase_enabled: false, + } + } + + #[test] + fn spritesheet_slice_batch_validation_accepts_complete_owned_cohort() { + let batch = spritesheet_slice_batch(); + + validate_editor_spritesheet_slice_batch(&batch, "user-1", "task-1", Some("group-task-1")) + .expect("complete batch should pass before transaction writes"); + } + + #[test] + fn spritesheet_slice_batch_validation_rejects_duplicate_object_key() { + let mut batch = spritesheet_slice_batch(); + batch.items[1].asset_object.object_key = batch.items[0].asset_object.object_key.clone(); + + let error = validate_editor_spritesheet_slice_batch( + &batch, + "user-1", + "task-1", + Some("group-task-1"), + ) + .expect_err("duplicate object key must fail before any write"); + + assert_eq!(error, "图集切片批次包含重复 object_key"); + } + + #[test] + fn spritesheet_slice_batch_validation_requires_asset_source_from_same_item() { + let mut batch = spritesheet_slice_batch(); + batch.items[1] + .asset + .as_mut() + .expect("asset") + .source_resource_id = Some("resource-0".to_string()); + + let error = validate_editor_spritesheet_slice_batch( + &batch, + "user-1", + "task-1", + Some("group-task-1"), + ) + .expect_err("cross-item source relationship must fail closed"); + + assert!(error.contains("项目资源")); + } + + #[test] + fn spritesheet_slice_batch_validation_accepts_account_asset_without_project_resource() { + let mut batch = spritesheet_slice_batch(); + for item in &mut batch.items { + item.project_resource = None; + item.asset.as_mut().expect("asset").source_resource_id = None; + } + + validate_editor_spritesheet_slice_batch(&batch, "user-1", "task-1", Some("group-task-1")) + .expect("account-only slices should remain a valid persistence path"); + } + + #[test] + fn spritesheet_slice_batch_validation_rejects_partial_cohort() { + let mut batch = spritesheet_slice_batch(); + batch.items[1].asset = None; + + let error = validate_editor_spritesheet_slice_batch( + &batch, + "user-1", + "task-1", + Some("group-task-1"), + ) + .expect_err("cohort must include one asset per slice"); + + assert_eq!(error, "需要完成 cohort 的图集切片批次必须为每项创建素材"); + } + + #[test] + fn spritesheet_slice_source_resource_requires_same_owner_and_project() { + let resource = spritesheet_slice_item(0) + .project_resource + .expect("project resource"); + let source = spritesheet_source_resource("user-1", "project-1"); + validate_editor_spritesheet_source_resource_relationship(&resource, Some(&source)) + .expect("same owner and project source should pass"); + + let wrong_owner = spritesheet_source_resource("user-2", "project-1"); + assert!( + validate_editor_spritesheet_source_resource_relationship(&resource, Some(&wrong_owner)) + .is_err() + ); + let wrong_project = spritesheet_source_resource("user-1", "project-2"); + assert!( + validate_editor_spritesheet_source_resource_relationship( + &resource, + Some(&wrong_project) + ) + .is_err() + ); + assert!(validate_editor_spritesheet_source_resource_relationship(&resource, None).is_err()); + } + + #[test] + fn spritesheet_slice_asset_replay_only_reuses_identical_content() { + let mut input = spritesheet_slice_item(0).asset.expect("asset input"); + let timestamp = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let existing = EditorAsset { + asset_id: input.asset_id.clone(), + owner_user_id: input.owner_user_id.clone(), + folder_id: input.folder_id.clone(), + label: normalize_asset_label(&input.label), + asset_object_id: input.asset_object_id.clone(), + image_src: input.image_src.clone(), + object_key: input.object_key.clone(), + width: input.width, + height: input.height, + source_type: input.source_type.clone(), + prompt: input.prompt.clone(), + actual_prompt: input.actual_prompt.clone(), + model: input.model.clone(), + provider: input.provider.clone(), + task_id: input.task_id.clone(), + created_at: timestamp, + updated_at: timestamp, + asset_kind: input.asset_kind.clone(), + generation_inputs_json: input.generation_inputs_json.clone(), + source_resource_id: input.source_resource_id.clone(), + thumbnail_src: input.thumbnail_src.clone(), + generation_cost_mud_points: input.generation_cost_mud_points, + group_task_id: input.group_task_id.clone(), + group_task_expected_asset_count: input.group_task_expected_asset_count, + }; + + assert!(editor_spritesheet_asset_matches_create_input( + &existing, &input + )); + input.label = "other slice".to_string(); + assert!(!editor_spritesheet_asset_matches_create_input( + &existing, &input + )); + } + #[test] fn editor_layout_accepts_json_up_to_two_mib() { let payload = format!(