Merge pull request 'Feat/pixel art' (#119) from feat/pixel_art into master
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Frontend tests (push) Successful in 2m2s
Project CI / Native shell tests (push) Successful in 2m24s
Project CI / Backend tests (push) Successful in 3m31s

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/119
Reviewed-by: 段舒康 <kdletters@qq.com>
This commit was merged in pull request #119.
This commit is contained in:
2026-07-29 21:38:11 +08:00
39 changed files with 3017 additions and 427 deletions
@@ -115,6 +115,8 @@ python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_extern
## Request Patterns
For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`.
For Python callers, prefer:
```python
@@ -324,7 +326,7 @@ Character image generation (including character redraw through `kind: "character
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
## Guardrails
@@ -67,15 +67,44 @@ Ask a follow-up only when two routes could both be correct and produce different
| User intent | Endpoint | Required fields | Common optional fields |
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
## Image Post-processing Style
The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting.
- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning.
- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation.
- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`.
- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum.
Image or character generation with pixel-art snapping:
```json
{
"prompt": "生成一个正面站立的像素风冒险者角色",
"kind": "character",
"style": "pixelArt"
}
```
Icon spritesheet generation with pixel-art snapping:
```json
{
"referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png",
"iconDescriptions": ["木剑", "圆盾", "红色药水"],
"style": "pixelArt"
}
```
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
## HTTP 2xx Warning Handling
@@ -84,7 +113,7 @@ Character image generation (including character redraw through `kind: "character
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
## Reference Image Upload
@@ -2562,6 +2562,21 @@
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"style": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"examples": [
"none",
"pixelArt"
],
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
},
"size": {
"type": "string",
"description": "兼容旧 size 入参;未传 aspectRatio/imageSize 时生效。",
@@ -2586,7 +2601,7 @@
"ui-design",
"publication-material"
],
"default": "spec"
"description": "省略时生成普通图片;其它值选择对应的专用生成流程。"
},
"model": {
"type": "string",
@@ -2951,6 +2966,7 @@
},
"iconDescriptions": {
"type": "array",
"description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。",
"minItems": 1,
"maxItems": 100,
"items": {
@@ -2964,6 +2980,21 @@
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"style": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"examples": [
"none",
"pixelArt"
],
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
},
"model": {
"type": "string",
"default": "gemini-3.1-flash-image-preview"
@@ -3177,8 +3208,13 @@
"properties": {
"code": {
"type": "string",
"const": "postprocess-failed-source-preserved",
"description": "透明背景处理最终失败并保留 provider 原图时的稳定原因码。"
"enum": [
"postprocess-failed-source-preserved",
"dimension-restore-fallback",
"unsupported-image-style",
"multiple-generation-warnings"
],
"description": "生成成功但后处理发生非阻断降级时的稳定原因码。"
},
"reason": {
"type": "string",
@@ -3213,7 +3249,7 @@
},
"iconImageSrcs": {
"type": "array",
"description": "按图集 alpha 连通域拆分并持久化的独立素材列表。",
"description": "识别图集中全部有效 alpha 连通域并持久化的独立素材列表,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。自动生成与手动拆分图集使用相同识别规则。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
@@ -3227,7 +3263,7 @@
"type": "null"
}
],
"description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。通用 warning 互斥。"
"description": "图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。自动拆分不按 iconDescriptions 数量校验切片数。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。"
},
"prompt": {
"type": "string"
@@ -3291,14 +3327,8 @@
"type": "null"
}
],
"description": "透明背景处理最终失败、provider 原图作为主结果时返回的非阻断告警。与 sliceWarning 互斥。"
"description": "生成成功但风格归一化、尺寸恢复、透明背景处理或像素规整发生非阻断降级时返回。透明背景最终失败时不会进入拆分;其它通用告警可以与 sliceWarning 并存。"
}
},
"not": {
"required": [
"warning",
"sliceWarning"
]
}
},
"EditorCharacterAnimationGenerationRequest": {
@@ -16,6 +16,20 @@
---
## 2026-07-29 像素规整降级必须复用交付尺寸守卫
- 背景:像素模式接入「角色带背景原图与透明图统一交付尺寸」后,删除了原先像素路径末尾的后置尺寸恢复。但像素规整的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)都直接返回 BgFilter 原始输出并把尺寸错误置为 `None`,跳过了非像素路径已有的尺寸比对与 alpha 回贴。BgFilter 回图尺寸漂移是已知现象,叠加并发上限 2 导致的 permit 超时后,角色会绕过「改用已保存的同尺寸原图完成画布」的安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。
- 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败如实返回尺寸错误,由调用方按各自既有语义处理。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。
- 生效范围(不承诺超出这一范围):本决策只保证像素路径不再谎报「尺寸无误」,即不再把 BgFilter 原始输出连同 `None` 尺寸错误交回调用方。拿到尺寸错误之后怎么处理仍由各调用方既有语义决定,本次不改变任何调用方语义:角色生成会据此走「改用已保存的同尺寸原图完成画布」的安全降级,因此角色链路闭环;图标图集沿用 master 既有的非致命语义,只记录告警后继续持久化并拆分,因此在「BgFilter 尺寸漂移且回贴修复失败」时,图标仍可能持久化尺寸异常的透明图集——该残留属于 master 既有行为,未在本次范围内解决。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。
- 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
- 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。
- 补充(同日):守卫的回读必须分两类处理。已取得 provider 原图的四条降级分支(permit 获取失败、worker 内 deadline、join 异常、worker 超时)改走纯内存守卫 `degrade_editor_pixel_art_with_provider_source`,零额外 GET;尚未取得原图的三条分支(进函数即预算耗尽、第一次回读失败、第一次回读超时)才走会回读的守卫。计数断言固定「回读守卫 3 处、内存守卫 4 处」,防止后续新增分支时误用回读版本。
- OSS 回读口径(修正此前「最多增加一次 OSS GET」的措辞):约束是**不重复读取已经成功取得的对象**,而不是"整个请求至多一次 GET"。仅在尺寸漂移且尚未持有原图时才发起最多一次修复性回读,失败后不再重试;因此第一次回读失败或被像素预算掐断时,允许存在第二次、也是最后一次尝试——第一次超时往往并非 OSS 异常,而是被 30 秒像素预算切断,此时对象通常可正常读取,放弃修复反而会让角色更频繁地退化为原图单产物。
- 回读上界:修复性回读必须始终有绝对 deadline。优先取外层 `request_deadline`,但它只在队列 worker 路径上有值——inline HTTP 请求的 `RequestContext` 默认 `external_call_deadline = None`,此时守卫自行以 `Instant::now() + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION` 重新计时派生上界,不得退化为无界 `download.await``apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original` 的可选 `download_deadline` 只对像素守卫传值,非像素路径继续传 `None` 保持既有语义不变。结构断言固定守卫内必须同时出现 `request_deadline.unwrap_or_else(``EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION`,防止兜底上界被移除后静默退回无界。
---
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
@@ -26,6 +40,16 @@
---
## 2026-07-29 图标图集 BgFilter 开启 cross-check
- 背景:图标 spritesheet 的透明化需要提高主体内部孔洞、轮廓和相邻小图标边缘的交叉校验质量。
- 决策:生成图标素材的 BgFilter `background_mode=flat` 请求固定显式传 `cross_check=on`,与角色形象和角色动作逐帧去背一致;UI 设计图素材提取及手动 complex 去背景继续传 `off`。该参数仍属于后端内部供应商策略,不进入前端 DTO 或外部 OpenAPI。
- 边界:不修改 BgFilter fallback、Alpha 回贴、默认关闭 despill、图标切片、OSS / 资源 / 画布持久化和任务告警语义。
- 验证方式:运行 `cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
---
## 2026-07-23 画布 Agent 工具生命周期统一经 object-safe trait 分派
- 背景:画布 Agent 八类工具的参数规范化、确认展示、计价与 worker payload、完成结果格式化和媒体投影分别在 `tool_args.rs``display_args.rs``api.rs``reconcile.rs` 重复按工具名分派;新增或调整工具时容易漏改其中一处。
@@ -267,7 +291,7 @@
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
- 决策:主生成状态继续使用可选 `assetLabel`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel``canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。2026-07-29 起,图标切片不再按用户提示词命名,统一按全连通域视觉顺序命名为 `素材 N`
- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。
- 验证方式:覆盖生成面板不渲染资源名称输入、提示词边框、空白回退、内部自定义名与长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck``npm run check:encoding``git diff --check`
@@ -616,7 +640,7 @@
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。2026-07-29 起,图标生成的自动拆分与手动拆分共同识别全图集有效连通域,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,不再以提示词条目数决定切片数量;手动拆分仍保留且不计费。所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
@@ -4523,6 +4547,16 @@
- 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。
- 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run admin-web:typecheck``npm run check:encoding``git diff --check`
## 2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发
- 契约:普通图片 / 角色共用的图片生成请求和图标图集生成请求增加可选字符串 `style`,当前公开合法值为 `none / pixelArt`。省略、`null`、空字符串和 `none` 归一为内部 `None` 且不告警;未知字符串、或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `pixelArt` 时,按 `None` 继续原管线并返回 `unsupported-image-style` 通用告警;非字符串 JSON 返回 `400`。旧队列 payload 缺少字段时兼容为 `None`
- UI 边界:只有普通 `生成图片``生成角色形象``生成图标素材` 显示 `像素艺术` 勾选项;当前选择可进入已有生成器快照和请求 / 队列 payload,但不写入 `generationInputs`、素材元数据或新表。画布 Agent 和其它生成 / 编辑入口不开放该选项。
- 处理边界:`PixelArt``platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。2026-07-29 合并「角色带背景原图与透明图统一交付尺寸」后本条修订:像素模式不再豁免提前归一,网格分析源是已按业务像素矩阵 `resize_to_fill`(Lanczos 重采样 + 居中裁切)后的交付尺寸平底图,不再是 provider 原生分辨率图;像素规整在交付尺寸上完成、由 snapper 自行还原回输入尺寸,因此不再执行后置的 nearest 二次恢复。
- 执行边界:像素规整 CPU 工作使用进程级最大并发 2;取得并发许可的排队时间与实际处理时间共享最多 30 秒预算,同时不得晚于当前请求 deadline,最终取更早者。输入图片任一边上限为 10000 像素、总像素上限为 8294400;超限、排队超时或处理超时均按 best-effort 非致命降级,不持久化部分结果。
- 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。
- 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/openapi/genarrative-external-v1.openapi.json`
## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层
- 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。
@@ -4530,3 +4564,11 @@
- 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runnercompletion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。
- 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。
- 验证方式:`cargo test -p platform-agent-harness``cargo test -p platform-editor-agent``cargo test -p api-server editor_agent``cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`
## 2026-07-29 图标图集拆分数量只由有效连通域决定
- 背景:图标素材生成前端曾把单个提示词按换行、逗号、顿号等分隔符解析成描述数组,后端再用数组长度作为期望切片数。这会把“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”一类自然语言错误地解释为固定数量,并在图集中存在更多有效素材时截断结果。
- 决策:画布前端不再从提示词解析素材数量,完整提示词作为 `iconDescriptions` 的唯一数组元素提交以兼容现有请求契约;后端仍允许其它调用方提交多条文本,但数组长度只参与 prompt 组装,绝不作为切片数量或切片命名依据。生成后的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和 `素材 N` 命名,识别多少个有效素材就拆多少个;手动按钮与 `/api/editor/icon-spritesheets/slices` 路由继续保留。
- 失败与限制:两条图标拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,并在持久化前完成校验。自动拆分仍是 best-effort,失败后保留整张透明图集并返回 `sliceWarning`;手动拆分失败返回接口错误。UI 设计图素材提取继续使用全连通域识别,不受提示词数量影响。
- 验证方式:调整既有前端提交、Prompt、连通域切片、上限和响应契约测试,不新增仅用于证明旧解析函数已删除的测试;运行前后端定向测试、类型与 Rust 检查、编码检查和 `git diff --check`
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/openapi/genarrative-external-v1.openapi.json`
File diff suppressed because one or more lines are too long
@@ -207,7 +207,7 @@ controller 配置:
透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。
inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。
inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。
## 验收
File diff suppressed because one or more lines are too long
@@ -42,7 +42,14 @@ v1 只开放以下能力:
- `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code``postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`;服务端保证通用 `warning``sliceWarning` 互斥,防御性客户端若收到异常双字段响应仍以通用 `warning` 为准。
生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`。外部 OpenAPI 当前公开四个稳定 `code`
- `postprocess-failed-source-preserved`:生成成功,但透明处理、像素规整等后处理未完成,接口保留仍可使用的原图或进入该步骤前的结果。
- `dimension-restore-fallback`:provider 回图无法安全收口到目标交付尺寸,接口保留实际回图尺寸。
- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。
- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`
provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 `warning.reason`,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning``sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。
管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON
@@ -123,7 +123,7 @@
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
- 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。
- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。
- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`该情况不会进入拆分,因此`sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。
- 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。
- 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource``editor_asset``canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。
- 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。
@@ -2,9 +2,11 @@
日期:`2026-06-15`
更新时间:`2026-07-29`
## 背景
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于通过一段完整需求生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。
## 入口与画布表现
@@ -27,7 +29,7 @@
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
- 生成时按换行、逗号、顿号、分号、斜杠竖线切分,过滤空文本后最多保留 `100` 个素材描述,并按文本顺序作为 prompt 的素材清单
- 生成时只去除整段文本首尾空白,不按换行、逗号、顿号、分号、斜杠竖线或语义枚举解析素材数量;文本框内容作为一段完整用户需求进入 prompt。
## 面板外观
@@ -40,10 +42,11 @@
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- 请求字段:
- `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。
- `iconDescriptions`过滤空文本后的图标描述数组,`1..100`
- `iconDescriptions`兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量
- `model`:支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
- `style`:可选生成后处理风格;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`
- `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K``12``gpt-image-2 1K``3``gpt-image-2 2K``5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
- 模型与尺寸选项:
- `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
@@ -53,18 +56,29 @@
- Prompt 固定为:
```text
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字。根据以下用户需求生成图标素材并整理成一张 spritesheet;不同图标素材之间必须彼此分离并保留清晰间距,避免描边、底板、投影或装饰元素连接相邻图标
<素材描述按中文顿号拼接>
<完整用户需求>
```
## 像素风格后处理
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`
- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。
- 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375``ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。
- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,即前述平底 provider 原图的实际尺寸。图标链路不执行角色链路的前置 Lanczos 交付尺寸归一,nearest 也不是规整后的独立交付尺寸恢复。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。
- 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`fallback、Alpha 回贴和默认关闭 despill 的行为保持不变。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。
## 去背与保存
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URLmultipart 固定传 `image_url``screen_color=<screenColor>``seg_model=<segModel>``background_mode=flat``cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。
- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制单边最多 `4096` 像素、总像素最多 `2048×2048`单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URLmultipart 固定传 `image_url``screen_color=<screenColor>``seg_model=<segModel>``background_mode=flat``cross_check=on`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。
- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别透明图集中全部有效 alpha 连通域执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
- 响应通过 `iconImageSrcs` 返回成功切片素材。图标素材生成的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和自动命名规则:识别多少个有效素材就返回多少个,依次命名为 `素材 N`;用户提示词及 `iconDescriptions` 数组长度都不控制切片数量
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。自动拆分和手动拆分共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。自动拆分失败以 `sliceWarning` 非阻断降级,手动拆分失败则返回接口错误。
## 前端铺放规则
@@ -76,11 +90,12 @@
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
- `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述
- 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
- 图标素材面板可选择 `style: "none" | "pixelArt"``none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints``nanobanana2 1K` 应为 `12``gpt-image-2 1K` 应为 `3``gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
@@ -2,7 +2,7 @@
日期:`2026-06-15`
更新时间:`2026-07-21`
更新时间:`2026-07-28`
## 背景
@@ -60,6 +60,18 @@
- `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`
- `gpt-image-2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations``/v1/images/edits`。K 档按最长边计算,并转换为 provider 可直接生成的合法像素:`1K``1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9` 分别为 `1024x1024 / 1024x768 / 1024x688 / 688x1024 / 608x1088 / 1088x608``2K` 分别为 `2048x2048 / 2048x1536 / 2048x1376 / 1376x2048 / 1152x2048 / 2048x1152`。其中 9:16 的 1K 尺寸按 provider 最小总像素和 16 对齐约束修正。禁止把 2K 竖图回落为 1K 请求,也禁止在回图后放大伪造所选 K 档。
- 后端如果收到参考图,`nanobanana2` 把参考图作为 `inline_data` 传入原生 `generateContent``gpt-image-2` 走带多参考图的图片编辑链路。没有参考图时按所选模型走纯文本生成链路。
## 风格与像素规整
- 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"``kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。
- 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`
- 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375``ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。
- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,该输入已经是前述 Lanczos 归一后的交付尺寸,或尺寸归一无法安全执行时保留的 provider 实际尺寸。nearest 不是新的交付尺寸归一,规整完成后也不再执行第二次 Lanczos 或其它尺寸恢复。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。禁止上传逻辑低分辨率图、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。
- 本功能不修改 BgFilter `flat` 调用、`cross_check=on`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图的既有收口且不运行像素规整;像素规整自身失败时保留已成功的透明图并继续原有持久化,通过通用 `warning` 非致命提示,不退款。
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
```text
@@ -106,6 +118,7 @@
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``screenColor``aspectRatio``imageSize`
- 角色面板可选择 `style: "none" | "pixelArt"``none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
+77
View File
@@ -1912,6 +1912,83 @@ mod tests {
);
}
#[tokio::test]
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138228", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_body");
let app = build_router(state);
let requests = [
(
"/api/editor/images/generations",
serde_json::json!({
"prompt": "生成像素图片",
"style": {"unexpected": true},
}),
),
(
"/api/editor/icon-spritesheets/generations",
serde_json::json!({
"referenceImageSrc": "/generated-images/editor/icon-spec.png",
"iconDescriptions": ["宝箱"],
"style": ["pixelArt"],
}),
),
];
for (path, request_body) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{path} should normalize JSON data errors to 400"
);
}
}
#[tokio::test]
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138229", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_media_type");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/generations")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(r#"{"prompt":"生成图片","style":"pixelArt"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn editor_image_edit_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
@@ -330,6 +330,7 @@ impl EditorAgentTool for GenerateImageTool {
prompt: args.prompt,
size: None,
kind: None,
style: None,
model: Some(args.model),
screen_color: None,
seg_model: None,
@@ -437,6 +438,7 @@ impl EditorAgentTool for GenerateCharacterTool {
prompt: args.prompt,
size: None,
kind: Some("character".to_string()),
style: None,
model: Some(args.model),
screen_color: Some("auto".to_string()),
seg_model: Some("birefnet".to_string()),
@@ -546,6 +548,7 @@ impl EditorAgentTool for GenerateUiDesignTool {
prompt: args.prompt,
size: None,
kind: Some("ui-design".to_string()),
style: None,
model: Some(args.model),
screen_color: None,
seg_model: None,
@@ -799,6 +802,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
reference_image_src,
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
style: None,
model: Some(args.model),
screen_color: Some("auto".to_string()),
seg_model: Some("birefnet".to_string()),
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,8 @@ use crate::{
editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
parse_editor_generation_json_payload, save_editor_project_layout_with_revision_and_get,
serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
@@ -608,8 +609,9 @@ pub async fn generate_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorImageGenerationRequest>,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_image_for_owner(
&state,
@@ -640,8 +642,9 @@ pub async fn generate_external_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_icon_spritesheet_for_owner(
&state,
@@ -871,6 +874,16 @@ mod tests {
.get("priceMudPoints")
.is_none()
);
let image_style_schema =
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"];
assert_eq!(image_style_schema["anyOf"][0]["type"], "string");
assert!(image_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"]));
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]
.get("default")
.is_none()
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
@@ -909,6 +922,11 @@ mod tests {
.get("/api/external/v1/editor/icon-spritesheets/generations")
.is_some()
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
assert!(icon_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"]));
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["sliceWarning"]["anyOf"][0]["$ref"],
@@ -928,6 +946,15 @@ mod tests {
parsed["components"]["schemas"]["EditorGenerationWarning"]["required"],
json!(["code", "reason"])
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"],
json!([
"postprocess-failed-source-preserved",
"dimension-restore-fallback",
"unsupported-image-style",
"multiple-generation-warnings"
])
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/ui-designs/assets/extractions")
@@ -952,6 +979,7 @@ mod tests {
.get("priceMudPoints")
.is_none()
);
assert!(ui_extraction_schema["properties"].get("style").is_none());
assert!(
parsed["paths"]
.get("/api/external/v1/editor/videos/generations")
@@ -40,7 +40,8 @@ use crate::{
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
},
editor_project::{
EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter,
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest,
EditorGenerationCaller, EditorGenerationPhaseReporter,
EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
@@ -1270,12 +1271,11 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
result
}
fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
let data = response.get("data").unwrap_or(response);
let (warning, is_slice_warning) = match data.get("warning") {
Some(warning) => (warning, false),
None => (data.get("sliceWarning")?, true),
};
fn extract_editor_generation_warning_fields(
warning: Option<&Value>,
is_slice_warning: bool,
) -> Option<(String, String)> {
let warning = warning?;
let code = warning.get("code")?.as_str()?.trim();
let reason = warning.get("reason")?.as_str()?.trim();
if code.is_empty() || reason.is_empty() {
@@ -1286,6 +1286,29 @@ fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
} else {
reason.to_string()
};
Some((code.to_string(), reason))
}
fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
let data = response.get("data").unwrap_or(response);
// 中文注释:风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存。
// 队列结果只有一个有界 warning 字段,因此按与 inline 响应相同的策略归一:
// code 不同时收敛为 multiple-generation-warningsreason 按“通用在前、拆分在后”
// 顺序拼接,再交给既有上界收敛,不允许其中任何一条被静默丢弃。
let common = extract_editor_generation_warning_fields(data.get("warning"), false);
let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true);
let (code, reason) = match (common, slice) {
(None, None) => return None,
(Some(warning), None) | (None, Some(warning)) => warning,
(Some((common_code, common_reason)), Some((slice_code, slice_reason))) => {
let code = if common_code == slice_code {
common_code
} else {
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string()
};
(code, format!("{common_reason} {slice_reason}"))
}
};
let reason = normalize_editor_generation_warning_reason(reason.as_str());
Some(json!({
"code": code,
@@ -1846,18 +1869,18 @@ mod tests {
}
#[test]
fn editor_generation_result_payload_prefers_common_postprocess_warning() {
fn editor_generation_result_payload_merges_common_and_slice_warnings() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"warning": {
"code": "postprocess-failed-source-preserved",
"reason": "生成任务成功,后处理失败"
"code": "unsupported-image-style",
"reason": "不支持的图片风格,已按无风格继续生成。"
},
"sliceWarning": {
"code": "insufficient-connected-components",
"reason": "不应覆盖通用后处理告警"
"reason": "有效连通域不足"
}
}
});
@@ -1866,6 +1889,35 @@ mod tests {
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
// 中文注释:风格归一化告警与拆分告警可以并存,队列只有一个 warning 字段,
// 必须拼接后收敛 code,不能让其中任何一条消失。
assert_eq!(
payload["warning"],
json!({
"code": "multiple-generation-warnings",
"reason": "不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:有效连通域不足"
})
);
assert!(payload.get("imageSrc").is_none());
}
#[test]
fn editor_generation_result_payload_keeps_single_warning_untouched() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"warning": {
"code": "postprocess-failed-source-preserved",
"reason": "生成任务成功,后处理失败。"
}
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
// 中文注释:透明背景最终失败不会进入拆分,此时仍是单条告警,原样保留。
assert_eq!(
payload["warning"],
json!({
@@ -1873,7 +1925,6 @@ mod tests {
"reason": "生成任务成功,后处理失败。"
})
);
assert!(payload.get("imageSrc").is_none());
}
#[test]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hugo Duprez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -22,5 +22,4 @@ pub use sheet::{
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
};
@@ -144,17 +144,6 @@ pub struct GeneratedAssetSheetConnectedIcon {
const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16;
const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16;
pub fn slice_generated_icon_spritesheet_by_connected_components(
image: &crate::DownloadedImage,
icon_names: &[String],
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
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, icon_names, false)
}
pub fn slice_generated_icon_spritesheet_all_by_connected_components(
image: &crate::DownloadedImage,
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
@@ -162,7 +151,7 @@ pub fn slice_generated_icon_spritesheet_all_by_connected_components(
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, &[], true)
slice_generated_icon_spritesheet_rgba_by_connected_components(source)
}
pub fn crop_generated_asset_sheet_view_edge_matte(
@@ -176,8 +165,6 @@ pub fn crop_generated_asset_sheet_view_edge_matte(
fn slice_generated_icon_spritesheet_rgba_by_connected_components(
source: image::DynamicImage,
icon_names: &[String],
auto_name_all_components: bool,
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -189,44 +176,22 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
}
let mut components = detect_generated_icon_components_by_alpha(&image, width, height);
if components.len() < icon_names.len()
|| (auto_name_all_components && generated_icon_alpha_fill_ratio(&image) > 0.92)
{
if generated_icon_alpha_fill_ratio(&image) > 0.92 {
let foreground_image =
build_generated_icon_spritesheet_foreground_image(&image, width, height);
let foreground_components =
detect_generated_icon_components_by_alpha(&foreground_image, width, height);
if !foreground_components.is_empty()
&& (foreground_components.len() >= icon_names.len()
|| auto_name_all_components && foreground_components.len() >= components.len())
{
if !foreground_components.is_empty() && foreground_components.len() >= components.len() {
image = foreground_image;
components = foreground_components;
}
}
let mut components = normalize_generated_icon_components(
components,
width,
height,
icon_names.len(),
auto_name_all_components,
);
let mut components = normalize_generated_icon_components(components, width, height);
sort_generated_icon_components_in_visual_rows(&mut components);
let icon_names = if auto_name_all_components {
(1..=components.len())
.map(|index| format!("素材 {index}"))
.collect::<Vec<_>>()
} else {
icon_names.to_vec()
};
if components.len() < icon_names.len() {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"图标 spritesheet 连通域数量不足:需要 {} 个,实际 {} 个。",
icon_names.len(),
components.len()
)));
}
let icon_names = (1..=components.len())
.map(|index| format!("素材 {index}"))
.collect::<Vec<_>>();
let mut icons = Vec::with_capacity(icon_names.len());
for (name, bounds) in icon_names.iter().zip(components.into_iter()) {
@@ -297,17 +262,10 @@ fn normalize_generated_icon_components(
components: Vec<GeneratedAssetSheetIconComponent>,
width: u32,
height: u32,
required_count: usize,
auto_name_all_components: bool,
) -> Vec<GeneratedAssetSheetCellBounds> {
let merged_components = merge_generated_icon_related_components(components, width, height);
let filtered_components = filter_generated_icon_scrap_components(
merged_components,
width,
height,
required_count,
auto_name_all_components,
);
let filtered_components =
filter_generated_icon_scrap_components(merged_components, width, height);
filtered_components
.into_iter()
.map(|component| component.bounds)
@@ -511,10 +469,8 @@ fn filter_generated_icon_scrap_components(
components: Vec<GeneratedAssetSheetIconComponent>,
width: u32,
height: u32,
required_count: usize,
auto_name_all_components: bool,
) -> Vec<GeneratedAssetSheetIconComponent> {
if components.len() <= required_count.max(1) {
if components.len() <= 1 {
return components;
}
let max_visible_pixels = components
@@ -546,10 +502,7 @@ fn filter_generated_icon_scrap_components(
})
.collect::<Vec<_>>();
if filtered.is_empty()
|| required_count > 0 && filtered.len() < required_count
|| !auto_name_all_components && filtered.len() < components.len().min(required_count)
{
if filtered.is_empty() {
return components;
}
filtered
@@ -721,15 +674,12 @@ mod tests {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["返回按钮".to_string(), "设置按钮".to_string()],
)
.expect("icons should slice");
let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.expect("icons should slice");
assert_eq!(icons.len(), 2);
assert_eq!(icons[0].name, "返回按钮");
assert_eq!(icons[1].name, "设置按钮");
assert_eq!(icons[0].name, "素材 1");
assert_eq!(icons[1].name, "素材 2");
assert!(icons[0].width >= 16);
assert!(icons[0].height >= 14);
assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok());
@@ -756,11 +706,8 @@ mod tests {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["左侧素材".to_string(), "右侧素材".to_string()],
)
.expect("same-row icons should slice from left to right");
let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.expect("same-row icons should slice from left to right");
assert_icon_contains_color(&icons[0], left_color);
assert_icon_contains_color(&icons[1], right_color);
@@ -801,45 +748,14 @@ mod tests {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&[
"第一行左侧".to_string(),
"第一行右侧".to_string(),
"第二行左侧".to_string(),
"第二行右侧".to_string(),
],
)
.expect("visual rows should slice in reading order");
let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.expect("visual rows should slice in reading order");
for (icon, color) in icons.iter().zip(colors) {
assert_icon_contains_color(icon, color);
}
}
#[test]
fn rejects_when_connected_components_are_fewer_than_icon_names() {
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255]));
for y in 12..24 {
for x in 12..24 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
}
}
let source = crate::DownloadedImage {
bytes: encode_png(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let error = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["返回按钮".to_string(), "设置按钮".to_string()],
)
.expect_err("missing component should fail");
assert!(error.to_string().contains("连通域数量不足"));
}
#[test]
fn slices_all_icon_spritesheet_components_with_auto_names() {
let mut sheet: image::RgbaImage =
@@ -901,15 +817,12 @@ mod tests {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["爱心".to_string(), "星星".to_string()],
)
.expect("detached accents should merge into their nearby icon");
let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.expect("detached accents should merge into their nearby icon");
assert_eq!(icons.len(), 2);
assert_eq!(icons[0].name, "爱心");
assert_eq!(icons[1].name, "星星");
assert_eq!(icons[0].name, "素材 1");
assert_eq!(icons[1].name, "素材 2");
assert!(icons[0].width >= 48);
assert!(icons[0].height >= 56);
}
@@ -1,7 +1,12 @@
pub mod generated_asset_sheets;
pub mod generated_assets;
pub mod pixel_art_snapper;
pub mod vector_engine;
pub use pixel_art_snapper::{
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline,
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage,
File diff suppressed because it is too large Load Diff
@@ -837,6 +837,10 @@ describe('ImageCanvasEditorView generation integration', () => {
name: '生成图片模型 nanobanana2',
}).className,
).toContain('platform-inline-option-button');
const pixelArtToggle = within(generateDialog).getByRole('checkbox', {
name: '像素艺术',
}) as HTMLInputElement;
expect(pixelArtToggle.checked).toBe(false);
expect(
within(generateDialog).getByRole('button', { name: '生成' }).className,
).toContain('platform-button');
@@ -848,6 +852,7 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
fireEvent.click(pixelArtToggle);
fireEvent.click(
within(generateDialog).getByRole('button', { name: '生成' }),
);
@@ -858,6 +863,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
prompt: '一张明亮的拼图主视觉',
model: 'gemini-3.1-flash-image-preview',
style: 'pixelArt',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
@@ -866,6 +872,11 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
);
});
const submittedGenerationInputs =
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
'pixelArt',
);
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
@@ -2646,7 +2657,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'resource-icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
@@ -3237,7 +3248,9 @@ describe('ImageCanvasEditorView generation integration', () => {
});
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
const characterLayer = screen.getByAltText('画布图片:市场老妇人');
// 项目封面快照会在图层落位后触发一次重渲染,同步查询可能正好落进该窗口;
// 这里用异步查询容忍这次重渲染,不改变断言语义。
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
fireEvent.click(characterLayer.closest('button')!);
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
@@ -7,6 +7,7 @@ import {
createLayerFromAsset,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatCanvasDisplayScalePercent,
hydrateCanvasGenerationDialog,
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
@@ -631,6 +632,7 @@ describe('ImageCanvasEditorModel', () => {
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
style: 'pixelArt',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
@@ -688,6 +690,7 @@ describe('ImageCanvasEditorModel', () => {
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
style: 'pixelArt',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
@@ -704,6 +707,35 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('defaults restored supported image styles to none and drops them from other modes', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-legacy',
mode: 'generate',
prompt: '旧任务',
status: 'idle',
})?.style,
).toBe('none');
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-unknown-style',
mode: 'character',
prompt: '未知风格',
status: 'idle',
style: 'futureStyle',
})?.style,
).toBe('none');
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-spec',
mode: 'spec',
prompt: '规范任务',
status: 'idle',
style: 'pixelArt',
})?.style,
).toBeUndefined();
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
@@ -537,6 +537,14 @@ export function hydrateCanvasGenerationDialog(
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
return null;
}
const style =
snapshot.mode === 'generate' ||
snapshot.mode === 'character' ||
snapshot.mode === 'icon'
? snapshot.style === 'pixelArt'
? 'pixelArt'
: 'none'
: undefined;
return {
id,
@@ -601,6 +609,7 @@ export function hydrateCanvasGenerationDialog(
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
@@ -5,6 +5,7 @@ import type {
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorVideoAspectRatio,
EditorVideoModel,
EditorVideoResolution,
@@ -215,6 +216,7 @@ export type GenerateDialogState = {
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
style?: EditorImageGenerationStyle;
videoModel?: EditorVideoModel;
videoAspectRatio?: EditorVideoAspectRatio;
videoResolution?: EditorVideoResolution;

Some files were not shown because too many files have changed in this diff Show More