统一图标图集全连通域拆分

移除前端从提示词解析素材数量的逻辑,完整提示词作为单个请求元素提交。

删除后端按描述数量截断切片的路径,自动拆分与手动拆分复用全连通域算法。

统一切片命名和尺寸、像素、数量限制,保留手动拆分按钮与接口。

更新既有测试、OpenAPI、编辑器文档和共享决策记录。
This commit is contained in:
2026-07-29 04:52:29 +00:00
parent 732fcf0b45
commit 1ed8064d2f
15 changed files with 194 additions and 310 deletions
@@ -2965,6 +2965,7 @@
},
"iconDescriptions": {
"type": "array",
"description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。",
"minItems": 1,
"maxItems": 100,
"items": {
@@ -3247,7 +3248,7 @@
},
"iconImageSrcs": {
"type": "array",
"description": "按图集 alpha 连通域拆分并持久化的独立素材列表。",
"description": "识别图集中全部有效 alpha 连通域并持久化的独立素材列表,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。自动生成与手动拆分图集使用相同识别规则。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
@@ -3261,7 +3262,7 @@
"type": "null"
}
],
"description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。"
"description": "图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。自动拆分不按 iconDescriptions 数量校验切片数。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。"
},
"prompt": {
"type": "string"
@@ -257,7 +257,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`
@@ -604,7 +604,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`
@@ -4520,3 +4520,11 @@
- 去背边界:不修改 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-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`
@@ -75,7 +75,7 @@
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色``抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId``publicationGameInfo``publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 IDData URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character``icon-spritesheet``character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel``canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀拆分素材继续按素材描述命名
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel``canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
@@ -106,7 +106,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`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=<screenColor>``seg_model=<segModel>`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `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`:按图标规范图和素材描述数组生成 spritesheetapi-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `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` 个切片。请求支持 `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` 和自动决策纯色背景素材提取提示词生成素材 spritesheetapi-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,前端只消费响应快照。
@@ -2,11 +2,11 @@
日期:`2026-06-15`
更新时间:`2026-07-28`
更新时间:`2026-07-29`
## 背景
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于通过一段完整需求生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。
## 入口与画布表现
@@ -28,7 +28,7 @@
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
- 生成时按换行、逗号、顿号、分号、斜杠竖线切分,过滤空文本后最多保留 `100` 个素材描述,并按文本顺序作为 prompt 的素材清单
- 生成时只去除整段文本首尾空白,不按换行、逗号、顿号、分号、斜杠竖线或语义枚举解析素材数量;文本框内容作为一段完整用户需求进入 prompt。
## 面板外观
@@ -41,7 +41,7 @@
- 前端提交到 `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` 展示,选项跟随模型。
@@ -55,9 +55,9 @@
- Prompt 固定为:
```text
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字。根据以下用户需求生成图标素材并整理成一张 spritesheet;不同图标素材之间必须彼此分离并保留清晰间距,避免描边、底板、投影或装饰元素连接相邻图标
<素材描述按中文顿号拼接>
<完整用户需求>
```
## 像素风格后处理
@@ -74,10 +74,10 @@
## 去背与保存
- 父流程收到 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 / 资源 / 画布持久化仍全部由父流程负责。
- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 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` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制单边最多 `4096` 像素、总像素最多 `2048×2048`单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。
- 响应通过 `iconImageSrcs` 返回成功切片素材。图标素材生成的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和自动命名规则:识别多少个有效素材就返回多少个,依次命名为 `素材 N`;用户提示词及 `iconDescriptions` 数组长度都不控制切片数量
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。自动拆分和手动拆分共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。自动拆分失败以 `sliceWarning` 非阻断降级,手动拆分失败则返回接口错误。
## 前端铺放规则
@@ -89,11 +89,11 @@
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
- `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。
- 默认 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 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints``nanobanana2 1K` 应为 `12``gpt-image-2 1K` 应为 `3``gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
+119 -113
View File
@@ -20,7 +20,6 @@ use platform_image::{
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
},
};
use platform_oss::{
@@ -106,9 +105,9 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64;
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_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5;
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
@@ -4733,79 +4732,75 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let (icon_image_srcs, slice_warning) =
match slice_generated_icon_spritesheet_by_connected_components(
&slice_source,
&icon_descriptions,
) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "icon-spritesheet-assets",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "persist_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all(&slice_source) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "icon-spritesheet-assets",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "persist_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
}
Err(error) => {
let reason = error.to_string();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "detect_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分未识别到全部素材"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
}
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "detect_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分未完成"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
@@ -4911,15 +4906,7 @@ pub async fn split_editor_icon_spritesheet(
mime_type: reference.mime_type,
extension: "png".to_string(),
};
validate_editor_icon_spritesheet_manual_source(&source)?;
let slices =
slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": error.to_string(),
}))
})?;
validate_editor_icon_spritesheet_manual_slice_count(slices.len())?;
let slices = slice_editor_icon_spritesheet_all(&source)?;
let prompt = source_resource
.prompt
.clone()
@@ -4989,9 +4976,30 @@ pub async fn split_editor_icon_spritesheet(
))
}
fn validate_editor_icon_spritesheet_manual_source(
fn slice_editor_icon_spritesheet_all(
source: &DownloadedImage,
) -> Result<(), AppError> {
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, AppError> {
validate_editor_icon_spritesheet_source(source)?;
let slices =
slice_generated_icon_spritesheet_all_by_connected_components(source).map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": error.to_string(),
}))
})?;
if slices.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
validate_editor_icon_spritesheet_slice_count(slices.len())?;
Ok(slices)
}
fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> {
let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice()))
.with_guessed_format()
.map_err(|error| {
@@ -5006,7 +5014,7 @@ fn validate_editor_icon_spritesheet_manual_source(
"message": format!("无法读取图集图片尺寸:{error}"),
}))
})?;
validate_editor_icon_spritesheet_manual_dimensions(width, height)
validate_editor_icon_spritesheet_dimensions(width, height)
}
async fn resolve_editor_manual_atlas_split_group_task_id(
@@ -5050,16 +5058,13 @@ fn editor_manual_atlas_split_group_source_lookup_input(
}
}
fn validate_editor_icon_spritesheet_manual_dimensions(
width: u32,
height: u32,
) -> Result<(), AppError> {
fn validate_editor_icon_spritesheet_dimensions(width: u32, height: u32) -> Result<(), AppError> {
let pixel_count = u64::from(width).saturating_mul(u64::from(height));
if width == 0
|| height == 0
|| width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS
|| width > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION
|| height > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION
|| pixel_count > EDITOR_ICON_SPRITESHEET_MAX_PIXELS
{
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
@@ -5067,22 +5072,22 @@ fn validate_editor_icon_spritesheet_manual_dimensions(
"message": "图集尺寸超过手动拆分限制。",
"width": width,
"height": height,
"maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION,
"maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS,
"maxDimension": EDITOR_ICON_SPRITESHEET_MAX_DIMENSION,
"maxPixels": EDITOR_ICON_SPRITESHEET_MAX_PIXELS,
})),
);
}
Ok(())
}
fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> {
if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES {
fn validate_editor_icon_spritesheet_slice_count(slice_count: usize) -> Result<(), AppError> {
if slice_count > EDITOR_ICON_SPRITESHEET_MAX_SLICES {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集识别出的素材数量超过手动拆分限制。",
"sliceCount": slice_count,
"maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES,
"maxSliceCount": EDITOR_ICON_SPRITESHEET_MAX_SLICES,
})),
);
}
@@ -7384,9 +7389,9 @@ fn build_editor_icon_spritesheet_prompt(
screen_color: EditorScreenBackgroundColor,
) -> String {
format!(
"参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet\n\n{}",
"参考图1的图标素材规范,{};禁止出现文字。根据以下用户需求生成图标素材并整理成一张 spritesheet;不同图标素材之间必须彼此分离并保留清晰间距,避免描边、底板、投影或装饰元素连接相邻图标\n\n{}",
editor_green_screen_asset_prompt_clause(screen_color),
icon_descriptions.join("")
icon_descriptions.join("\n")
)
}
@@ -10281,13 +10286,13 @@ mod tests {
}
#[test]
fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() {
assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok());
assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok());
assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err());
assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err());
assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok());
assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err());
fn icon_spritesheet_limits_reject_large_images_and_excessive_slices() {
assert!(validate_editor_icon_spritesheet_dimensions(2048, 2048).is_ok());
assert!(validate_editor_icon_spritesheet_dimensions(4096, 1024).is_ok());
assert!(validate_editor_icon_spritesheet_dimensions(2049, 2048).is_err());
assert!(validate_editor_icon_spritesheet_dimensions(4097, 1).is_err());
assert!(validate_editor_icon_spritesheet_slice_count(64).is_ok());
assert!(validate_editor_icon_spritesheet_slice_count(65).is_err());
}
#[test]
@@ -10362,7 +10367,7 @@ mod tests {
}
#[test]
fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() {
fn editor_icon_spritesheet_prompt_preserves_user_descriptions() {
let descriptions = vec![
"返回按钮".to_string(),
"设置按钮".to_string(),
@@ -10380,7 +10385,8 @@ mod tests {
assert!(
prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光")
);
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
assert!(prompt.contains("返回按钮\n设置按钮\n下一关按钮"));
assert!(prompt.contains("不同图标素材之间必须彼此分离并保留清晰间距"));
}
#[test]
@@ -10732,7 +10738,7 @@ mod tests {
icon_image_srcs: Vec::new(),
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason: "标 spritesheet 连通域数量不足:需要 2 个,实际 1 个".to_string(),
reason: "集中未识别到可拆分的独立素材".to_string(),
}),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
@@ -10759,7 +10765,7 @@ mod tests {
);
assert_eq!(
payload["sliceWarning"]["reason"],
json!("标 spritesheet 连通域数量不足:需要 2 个,实际 1 个")
json!("集中未识别到可拆分的独立素材")
);
}
@@ -11515,7 +11521,7 @@ mod tests {
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"persist_editor_provider_source_resource",
"slice_generated_icon_spritesheet_by_connected_components",
"slice_editor_icon_spritesheet_all",
"persist_editor_spritesheet_slices",
],
);
@@ -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);
}
@@ -2652,7 +2652,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',
@@ -30,9 +30,7 @@ import {
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
formatIconDescriptionsText,
hideGeneratedLayerComposerAfterBlur,
parseIconDescriptionsText,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
@@ -1179,17 +1177,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
errorMessage: undefined,
prompt: '剑\n盾、药水',
iconDescriptions: ['剑', '盾', '药水'],
iconDescriptions: [],
}),
);
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
'剑',
'盾',
'药水',
'宝箱',
'钥匙',
]);
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
});
it('updates character animation duration and composer visibility', () => {
@@ -30,7 +30,6 @@ import {
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
@@ -87,18 +86,6 @@ function resetFailedGenerationPanel(panel: QuickEditPanelState) {
};
}
export function parseIconDescriptionsText(text: string): string[] {
return text
.split(/[\r\n,;/|]+/u)
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
}
export function formatIconDescriptionsText(descriptions: string[]): string {
return descriptions.join('\n');
}
function isSeedanceVideoModel(model: string | undefined) {
const resolvedModel = model ?? DEFAULT_VIDEO_MODEL;
return (
@@ -920,16 +907,17 @@ function restoreIconDescriptionsFromLayer(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const descriptions = parseIconDescriptionsText(
const prompt =
findGenerationInputFieldValue(sourceLayer, ['素材描述']) ??
sourceDialog?.prompt ??
'',
);
sourceDialog?.prompt ??
sourceDialog?.iconDescriptions?.join('\n') ??
'';
const normalizedPrompt = prompt.trim();
return {
...draft,
prompt: descriptions.join('\n'),
iconDescriptions: descriptions.length
? descriptions
prompt,
iconDescriptions: normalizedPrompt
? [normalizedPrompt]
: (sourceDialog?.iconDescriptions ?? draft.iconDescriptions),
iconSpecReference:
sourceDialog?.iconSpecReference ?? draft.iconSpecReference,
@@ -1582,7 +1570,7 @@ export function updateIconDescriptionsTextInDialog(
? {
...resetFailedGenerationDialog(dialog),
prompt: value,
iconDescriptions: parseIconDescriptionsText(value),
iconDescriptions: [],
}
: dialog;
}
@@ -83,7 +83,6 @@ export const SPEC_GENERATION_COST =
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][
SPEC_GENERATION_IMAGE_SIZE
];
export const ICON_DESCRIPTION_LIMIT = 100;
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
gameName: '',
gameCategories: '',
@@ -681,10 +681,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds icon spritesheet plans with trimmed descriptions and references', () => {
it('builds icon spritesheet plans with the complete prompt and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: ' 返回按钮 \n\n设置按钮',
prompt: ' 返回按钮\n\n设置按钮 ',
status: 'idle',
imageModel: 'gpt-image-2',
style: 'pixelArt',
@@ -710,11 +710,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
expect(plan).toEqual({
ok: true,
iconDescriptions: ['返回按钮', '设置按钮'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
input: {
referenceImageSrc: 'data:image/png;base64,spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
@@ -723,7 +723,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
imageSize: '2K',
},
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
fields: [{ title: '素材描述', value: '返回按钮\n\n设置按钮' }],
references: [
{
title: '图标规范',
@@ -39,7 +39,6 @@ import {
DEFAULT_VIDEO_MODEL,
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
@@ -596,17 +595,13 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
nextGeneratedIndex = 1,
): IconSpritesheetGenerationSubmissionPlan {
const iconDescriptionSource = dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,;/|]+/u)
: (dialog.iconDescriptions ?? []).some((description) =>
description.trim(),
)
? (dialog.iconDescriptions ?? [])
: [];
const iconDescriptions = iconDescriptionSource
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.iconDescriptions ?? [])
.map((description) => description.trim())
.filter(Boolean)
.join('\n');
const iconDescriptions = normalizedPrompt ? [normalizedPrompt] : [];
if (!dialog.iconSpecReference) {
return {
@@ -2329,7 +2329,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits icon spritesheets, trims descriptions, and remembers the model', async () => {
it('submits icon spritesheets, trims the complete prompt, and remembers the model', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
@@ -2351,7 +2351,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮 \n\n 设置按钮 ',
prompt: ' 返回按钮\n设置按钮 ',
assetLabel: ' 复古操作图标 ',
status: 'idle',
composerOpen: true,
@@ -2382,7 +2382,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
referenceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
iconDescriptions: ['返回按钮', '设置按钮'],
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
canvasCompletion: expect.objectContaining({
@@ -75,10 +75,8 @@ import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
@@ -913,17 +911,6 @@ export function useImageCanvasGenerationWorkflow({
effectiveCharacterAnimationPanel.durationSeconds,
)
: 0;
const iconDescriptionValues =
generateDialog?.mode === 'icon'
? generateDialog.prompt.trim()
? generateDialog.prompt
.split(/[\r\n,,、;/|]+/u)
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT)
: (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
: DEFAULT_ICON_DESCRIPTIONS;
const closeGenerationTransientState = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
@@ -2537,7 +2524,6 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel: setEffectiveCharacterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPrice,
iconDescriptionValues,
isSpecMenuOpen,
setIsSpecMenuOpen,
isGenerationReferenceMenuOpen,
@@ -2646,7 +2632,6 @@ export function useImageCanvasGenerationWorkflow({
submitUiAssetExtraction,
setEffectiveCharacterAnimationPanel,
hideGeneratedLayerPanelAfterBlur,
iconDescriptionValues,
isCharacterReferenceMenuOpen,
isCharacterSpecMenuOpen,
isIconSpecMenuOpen,