收口画布生图抠图选项

移除前端用户侧背景色和抠图模型选择入口,提交路径固定使用 auto 与 birefnet。

清理生成输入快照和恢复逻辑中的抠图背景色与抠图模型残留。

保留后端 BgFilter 内部兼容能力,并修正空图标描述不再兜底默认描述提交。

同步更新编辑器和项目记忆文档。
This commit is contained in:
2026-07-07 04:04:37 +00:00
parent c323b60ef8
commit 2095d8686a
34 changed files with 74 additions and 1335 deletions
@@ -19,7 +19,7 @@
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取新增用户可选纯色抠图背景。前端下拉展示颜色名、方块色样和 hex,默认 `自动`;手动可选 `浅雾蓝 #CFEFFF``浅钢蓝 #B0C2E0``暖浅桃色 #FFD6C2``淡薰衣草紫 #E6D8FF``浅粉灰 #F4D8E8``中度天蓝 #7FB3FF``浅柠黄 #FFF2A8``淡薄荷绿 #CFFFE1``浅中性灰 #D8DEE8``淡灰紫 #D8D2E8``高对比浅青 #A8F7F0`。请求字段统一为前端 / JSON `screenColor`、Rust `screen_color`生成输入快照写入 `抠图背景色`,同源重绘可从快照恢复。api-server 接受 `auto` 或上述 11 个 hex,缺省或空值按 `auto` 处理;手动未知值返回 `400`;生成后 BgFilter 去背景解析后的具体 hex 作为 `screen_color`。角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色``抠图模型`。api-server 在 11 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`后端仍保留手动 hex 解析能力供内部兼容,角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、BgFilter 服务入参、图片画布 MVP 和角色形象生成设计文档。
- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml``cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
@@ -27,7 +27,7 @@
## 2026-07-05 图片画布抠图背景色自动决策
- 背景:手动背景色选择对用户负担较高,且不同角色、图标和 UI 素材主题需要避开不同主体色;但 BgFilter 和生成 prompt 仍必须拿到明确的纯色 hex。
- 决策:前端`自动` 作为默认背景色选项并继续通过 `screenColor` 提交api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 11 个候选色中选择具体 hex,最多重试 3 次,LLM 未配置、请求失败或返回非法颜色时 fallback 到 `浅雾蓝 #CFEFFF`。自动解析结果写入生成输入快照 `抠图背景色`,格式为 `自动 -> <颜色名> <hex>`,兜底时追加 `(兜底)`;最终生图 prompt 和 BgFilter `screen_color` 永远只接收具体 hex,不透传 `auto`
- 决策:前端用户路径直接固定通过 `screenColor=auto` 提交,不再展示背景色选项api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 11 个候选色中选择具体 hex,最多重试 3 次,LLM 未配置、请求失败或返回非法颜色时 fallback 到 `浅雾蓝 #CFEFFF`。自动解析结果写入用户可见生成输入快照;最终生图 prompt 和 BgFilter `screen_color` 永远只接收具体 hex,不透传 `auto`
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、api-server LLM 调用、BgFilter 参数、图片画布文档。
- 验证方式:运行背景决策模块单测、画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_screen_background_decision editor_green_screen --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
@@ -43,7 +43,7 @@
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color``seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file``screen_color=<screenColor>``seg_model=<segModel>`当前用户界面只展示并提交默认 `birefnet``anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file``screen_color=<screenColor>``seg_model=<segModel>`前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。
- 影响范围:`server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。
- 验证方式:运行 `cargo test -p api-server config::tests::from_env_reads_editor_bgfilter_settings_and_reuses_background_token editor_project::tests::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:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
File diff suppressed because one or more lines are too long
@@ -218,7 +218,7 @@ npm run check:server-rs-ddd
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`;用户路径展示提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`前端用户路径展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -185,7 +185,7 @@
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格、抠图背景色、抠图模型和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用所选纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
@@ -28,7 +28,7 @@
- 支持自定义画面比例和大小尺寸。
- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`
- 默认画面比例为 `16:9`,默认大小为 `1K`
- UI 素材提取面板提供抠图背景色下拉和抠图模型下拉;背景色默认 `自动`,手动项展示颜色名称、方块色样和 hex 值,请求字段为 `screenColor``screenColor=auto`后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。抠图模型请求字段为 `segModel`,用户界面只展示并提交默认 `birefnet`后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- UI 素材提取面板不展示抠图背景色抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
## 提示词契约
@@ -58,7 +58,7 @@
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
```text
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
```
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
@@ -47,18 +47,18 @@
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024``1K 2:3/9:16 -> 1024x1536``1K 3:2/16:9 -> 1536x1024``2K 1:1 -> 2048x2048``2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
- 抠图背景色默认 `自动`,前端提交 `screenColor=auto`;手动选择时提交 11 个候选色之一。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`
- 不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`
- Prompt 固定为:
```text
参考图1的图标规范,背景必须是所选单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
<素材描述按中文顿号拼接>
```
## 去背与保存
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor``segModel`用户界面只展示并提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor``segModel`前端用户路径固定提交 `screenColor=auto`默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
@@ -29,9 +29,7 @@
- 上传后的每张常规参考图以缩略图展示。
- 每张常规参考图右下角显示大号序号,从 `1` 开始递增。
3. 唯一文本框为 `角色设定`
4. 左下角展示画面比例大小抠图背景色选择按钮
- 抠图背景色下拉必须展示颜色名称、方块色样和 hex 值。
- 默认 `自动`;手动可选 `浅雾蓝 #CFEFFF``浅钢蓝 #B0C2E0``暖浅桃色 #FFD6C2``淡薰衣草紫 #E6D8FF``浅粉灰 #F4D8E8``中度天蓝 #7FB3FF``浅柠黄 #FFF2A8``淡薄荷绿 #CFFFE1``浅中性灰 #D8DEE8``淡灰紫 #D8D2E8``高对比浅青 #A8F7F0`
4. 左下角展示画面比例大小,不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`
5. 右下角展示模型选择和生成按钮。
## 普通生成面板视觉口径
@@ -54,7 +52,7 @@
- 请求同时提交 `model``screenColor``segModel``aspectRatio``imageSize`
- `model` 支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
- `screenColor` 允许 `auto` 或上述 11 个 hex;前端默认提交 `auto`,同源重绘从生成器快照或输入快照 `抠图背景色` 恢复。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。`segModel` 当前用户界面只展示并提交默认 `birefnet`后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 前端用户路径固定提交 `screenColor=auto``segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。
- 尺寸选项来源以 VectorEngine 接入文档为准:
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`
@@ -67,7 +65,7 @@
角色设定:<用户输入的角色设定>
```
- 角色图生成完成后,编辑器后端必须先把带所选纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file``screen_color=<screenColor>``seg_model=<segModel>`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file``screen_color=<screenColor>``seg_model=<segModel>`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。
-`assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`
## 生成规范参考图
@@ -116,7 +114,7 @@
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`
- 角色图层重绘同样走 `/api/editor/images/generations``kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent``imageConfig.aspectRatio/imageSize``gpt-image-2` 使用文档列出的 `size` 字符串。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`所选纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
+15 -6
View File
@@ -60,9 +60,16 @@ function readPositiveIntArg(name, fallback) {
if (!rawValue) {
return fallback;
}
return parsePositiveInt(`--${name}`, rawValue);
}
function parsePositiveInt(label, rawValue) {
if (!/^\d+$/.test(rawValue)) {
throw new Error(`${label} 必须是正整数,当前为 ${rawValue}`);
}
const value = Number.parseInt(rawValue, 10);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`--${name} 必须是正整数,当前为 ${rawValue}`);
if (value <= 0) {
throw new Error(`${label} 必须是正整数,当前为 ${rawValue}`);
}
return value;
}
@@ -168,11 +175,13 @@ const segModel =
const token =
readConfig('GENARRATIVE_EDITOR_BGFILTER_TOKEN') ||
readConfig('GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN');
const timeoutMs = Number.parseInt(
readArg('timeout-ms') ||
const timeoutMs = readPositiveIntArg(
'timeout-ms',
parsePositiveInt(
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS',
readConfig('GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS') ||
`${DEFAULT_TIMEOUT_MS}`,
10,
`${DEFAULT_TIMEOUT_MS}`,
),
);
const width = readPositiveIntArg('width', 96);
const height = readPositiveIntArg('height', width);
-266
View File
@@ -1,266 +0,0 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const defaultTimeoutMs = 1_000_000;
function readArg(name) {
const prefix = `--${name}=`;
const value = process.argv.find((arg) => arg.startsWith(prefix));
return value ? value.slice(prefix.length) : '';
}
function resolveOutputSize() {
const value = readArg('size') || '2048x1152';
if (!/^\d{2,5}x\d{2,5}$/u.test(value)) {
throw new Error(`--size 必须形如 2048x2048,当前为 ${value}`);
}
return value;
}
const outputSize = resolveOutputSize();
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) {
return {};
}
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) {
output.push(nested.trim());
}
if (Array.isArray(nested)) {
nested.forEach((entry) => {
if (typeof entry === 'string' && entry.trim()) {
output.push(entry.trim());
}
});
}
}
collectStringsByKey(nested, targetKey, output);
}
}
function extractImageUrls(payload) {
const urls = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
}
function extractBase64Images(payload) {
const values = [];
collectStringsByKey(payload, 'b64_json', values);
return values;
}
function inferExtensionFromContentType(contentType) {
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
if (normalized === 'image/png') {
return 'png';
}
if (normalized === 'image/webp') {
return 'webp';
}
if (normalized === 'image/gif') {
return 'gif';
}
return 'jpg';
}
function inferExtensionFromBytes(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
return 'png';
}
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'jpg';
}
if (
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
return 'png';
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
return {
bytes: Buffer.from(await response.arrayBuffer()),
extension: inferExtensionFromContentType(
response.headers.get('content-type') || 'image/jpeg',
),
};
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
function buildPrompt() {
return [
'按照角色描述生成游戏角色立绘。',
'角色设定:女精灵弓箭手,修长敏捷,尖耳,银金色长发,翠绿色眼睛,穿轻便森林皮甲与深绿色披风,手持精致长弓,背负箭袋,姿态自信警觉。',
'严格要求:画面中心构图,角色主体完整置于画面中央,完整展示头部、身体、双腿、武器和披风,禁止镜头透视,禁止特写。',
'美术风格:高品质幻想游戏概念设定图,干净轮廓,精致但不复杂,适合作为可抠图角色素材。',
'背景固定为单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255),只作为抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与背景色相同或相近的描边、投影或反光。',
'禁止生成建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字、水印、边框、UI 元素或其他角色。',
].join('');
}
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
console.error(
`缺少配置:VECTOR_ENGINE_BASE_URL=${env.baseUrl ? '已配置' : '未配置'}VECTOR_ENGINE_API_KEY=${
env.apiKey ? '已配置' : '未配置'
}`,
);
process.exit(1);
}
const requestBody = {
model: 'gpt-image-2',
prompt: buildPrompt(),
n: 1,
size: outputSize,
};
console.log('gpt-image-2 生成女精灵弓箭手');
console.log(`目标: ${buildImagesGenerationUrl(env.baseUrl)}`);
console.log(`size: ${outputSize}`);
console.log(`timeout_ms: ${env.timeoutMs}`);
console.log('api_key: 已配置\n');
const payload = await fetchJson(
buildImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
let image;
const base64Images = extractBase64Images(payload);
if (base64Images.length > 0) {
const bytes = Buffer.from(base64Images[0], 'base64');
image = { bytes, extension: inferExtensionFromBytes(bytes) };
} else {
const urls = extractImageUrls(payload);
if (urls.length === 0) {
throw new Error(`响应中未找到图片 URL 或 b64_json${JSON.stringify(payload).slice(0, 600)}`);
}
image = await downloadUrl(urls[0], env.timeoutMs);
}
const downloadsDir = path.join(os.homedir(), 'Downloads');
mkdirSync(downloadsDir, { recursive: true });
const outputPath = path.join(
downloadsDir,
`gpt-image-2-elf-archer-${outputSize}.${image.extension}`,
);
writeFileSync(outputPath, image.bytes);
console.log(`已保存: ${outputPath}`);
console.log(`bytes: ${image.bytes.length}`);
@@ -2235,7 +2235,7 @@ async fn request_editor_generated_screen_background_with_bgfilter(
let url = editor_bgfilter_endpoint(state)?;
let call_id = format!("bgfilter-call-{}", current_utc_micros());
let request_started_at = Instant::now();
let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(120_000);
let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1);
let input_bytes = image.bytes.len();
let source_mime_type = image.mime_type.clone();
let source_file_name = format!(
@@ -7234,6 +7234,7 @@ mod tests {
"async fn request_editor_background_removal_image",
&[
"editor_bgfilter_endpoint",
"editor_bgfilter_request_timeout_ms.max(1)",
"\"screen_color\"",
"\"seg_model\"",
"seg_model.to_string()",
@@ -291,8 +291,6 @@ export function ImageCanvasCharacterGenerationComposerView({
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
includeBackgroundColor
includeSegModel
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
@@ -3215,14 +3215,6 @@ describe('ImageCanvasEditorView generation integration', () => {
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
}),
expect.objectContaining({
title: '抠图背景色',
value: '自动',
}),
expect.objectContaining({
title: '抠图模型',
value: '默认 birefnet',
}),
]),
references: expect.arrayContaining([
expect.objectContaining({
@@ -552,7 +552,6 @@ export function hydrateCanvasGenerationDialog(
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
screenColor: stringOrUndefined(snapshot.screenColor),
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
@@ -194,8 +194,6 @@ function createStageProps(): ImageCanvasStageViewProps {
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionModelChange: vi.fn(),
onUiAssetExtractionScreenColorChange: vi.fn(),
onUiAssetExtractionSegModelChange: vi.fn(),
onUiAssetExtractionPointerStart: vi.fn(),
onUiAssetExtractionPointerMove: vi.fn(),
onUiAssetExtractionPointerEnd: vi.fn(),
@@ -344,8 +342,6 @@ describe('ImageCanvasEditorShellView', () => {
sourceLayerId: layer.id,
tool: 'rect' as const,
model: 'gemini-3.1-flash-image-preview',
screenColor: '#CFEFFF',
segModel: 'birefnet',
marks: [],
references: [],
draftMark: null,
@@ -213,8 +213,6 @@ export type GenerateDialogState = {
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
screenColor?: string;
segModel?: string;
videoModel?: EditorVideoModel;
videoAspectRatio?: EditorVideoAspectRatio;
videoResolution?: EditorVideoResolution;
@@ -1261,8 +1261,6 @@ export function ImageCanvasEditorView({
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeUiAssetExtractionScreenColor,
changeUiAssetExtractionSegModel,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
@@ -2003,8 +2001,6 @@ export function ImageCanvasEditorView({
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
onUiAssetExtractionScreenColorChange: changeUiAssetExtractionScreenColor,
onUiAssetExtractionSegModelChange: changeUiAssetExtractionSegModel,
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
@@ -516,8 +516,6 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'character',
prompt: '红发骑士',
sourceLayerId: 'layer-source',
screenColor: '#FFD6C2',
segModel: 'anime-seg',
placeholder: {
x: 472,
y: 140,
@@ -585,8 +583,6 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
generatedLayerId: 'layer-source',
imageModel: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
aspectRatio: '2:3',
imageSize: '2K',
},
@@ -597,8 +593,6 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '普通图',
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
aspectRatio: '2:3',
imageSize: '2K',
});
@@ -18,9 +18,7 @@ import {
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
@@ -37,9 +35,7 @@ import {
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorBgFilterSegModel,
normalizeEditorImageModel,
normalizeEditorGenerationBackgroundColor,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
@@ -276,8 +272,6 @@ export function createCharacterGenerationDialogDraft({
characterSpecReference: null,
characterReferences: [],
imageModel: normalizedImageModel,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
segModel: DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -312,8 +306,6 @@ export function createIconGenerationDialogDraft({
generationReferences: [],
iconDescriptions: [],
imageModel: normalizedImageModel,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
segModel: DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -747,18 +739,9 @@ function restoreSharedImageOptions(
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const fields = getGenerationInputFieldValues(sourceLayer);
const sourceScreenColor =
sourceDialog?.screenColor ?? fields.get('抠图背景色');
const sourceSegModel = sourceDialog?.segModel ?? fields.get('抠图模型');
return {
...draft,
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
screenColor: normalizeEditorGenerationBackgroundColor(
sourceScreenColor ?? draft.screenColor,
),
segModel: normalizeEditorBgFilterSegModel(
sourceSegModel ?? draft.segModel,
),
aspectRatio:
sourceDialog?.aspectRatio ??
draft.aspectRatio ??
@@ -15,15 +15,13 @@ import {
function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
includeBackgroundColor = false,
includeSegModel = false,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
includeBackgroundColor?: boolean;
includeSegModel?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
@@ -37,8 +35,6 @@ function ImageOptionsHarness({
dialog={dialog}
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
includeBackgroundColor={includeBackgroundColor}
includeSegModel={includeSegModel}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
@@ -51,8 +47,6 @@ function ImageOptionsHarness({
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前背景色">{dialog.screenColor}</output>
<output aria-label="当前抠图模型">{dialog.segModel}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
@@ -180,87 +174,6 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
).toBe('生成3泥点');
});
it('updates the keyed background color from the color menu', () => {
render(
<ImageOptionsHarness
includeBackgroundColor
initialDialog={{
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
screenColor: 'auto',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', {
name: '生成图片背景色 自动',
}),
);
const panel = screen.getByRole('menu', { name: '生成图片背景色选项' });
fireEvent.click(
within(panel).getByRole('button', { name: '高对比浅青 #A8F7F0' }),
);
expect(screen.getByRole('menu', { name: '生成图片背景色选项' })).toBeTruthy();
expect(
within(panel)
.getByRole('button', { name: '高对比浅青 #A8F7F0' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', {
name: '生成图片背景色 高对比浅青 #A8F7F0',
}),
).toBeTruthy();
expect(screen.getByLabelText('当前背景色').textContent).toBe('#A8F7F0');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('only exposes the default BgFilter segmentation model', () => {
render(
<ImageOptionsHarness
includeSegModel
initialDialog={{
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
screenColor: '#CFEFFF',
segModel: 'birefnet',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', {
name: '生成图片抠图模型 默认',
}),
);
const panel = screen.getByRole('menu', { name: '生成图片抠图模型选项' });
const options = within(panel).getAllByRole('button');
expect(options).toHaveLength(1);
expect(options[0].getAttribute('aria-label')).toBe('默认');
expect(
within(panel)
.getByRole('button', { name: '默认' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByLabelText('当前抠图模型').textContent).toBe(
'birefnet',
);
});
it('locks the model trigger when a fixed model is provided', () => {
const rememberImageModel = vi.fn();
render(
@@ -1,4 +1,4 @@
import { Check, ChevronDown, Cpu, Scissors } from 'lucide-react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -15,15 +15,9 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
getVisibleEditorBgFilterSegModelOption,
getEditorGenerationBackgroundColorOption,
getEditorImageModelDisplayName,
IMAGE_MODEL_NANOBANANA2,
normalizeVisibleEditorBgFilterSegModel,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
@@ -36,8 +30,6 @@ type ImageCanvasGenerationImageOptionsViewProps = {
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
includeModel?: boolean;
includeBackgroundColor?: boolean;
includeSegModel?: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
@@ -54,7 +46,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
) => CSSProperties;
};
type OpenPanel = 'dimensions' | 'model' | 'backgroundColor' | 'segModel' | null;
type OpenPanel = 'dimensions' | 'model' | null;
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
@@ -129,8 +121,6 @@ export function ImageCanvasGenerationImageOptionsView({
setGenerateDialog,
includeDimensions,
includeModel = true,
includeBackgroundColor = false,
includeSegModel = false,
onRememberImageModel,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
@@ -145,8 +135,6 @@ export function ImageCanvasGenerationImageOptionsView({
}: ImageCanvasGenerationImageOptionsViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const backgroundColorButtonRef = useRef<HTMLButtonElement | null>(null);
const segModelButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isQuickEdit = dialog.mode === 'quick-edit';
const normalizedLockedModel = lockedModel
@@ -168,12 +156,6 @@ export function ImageCanvasGenerationImageOptionsView({
const selectedModelLabel =
modelOptions.find((option) => option.value === selectedModel)?.label ??
getEditorImageModelDisplayName(selectedModel);
const selectedBackgroundColor = getEditorGenerationBackgroundColorOption(
dialog.screenColor,
);
const selectedSegModel = getVisibleEditorBgFilterSegModelOption(
dialog.segModel,
);
const selectedSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: selection.aspectRatio,
imageSize: selection.imageSize,
@@ -190,12 +172,7 @@ export function ImageCanvasGenerationImageOptionsView({
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [
dimensionsButtonRef,
backgroundColorButtonRef,
segModelButtonRef,
modelButtonRef,
],
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
onDismiss: dismissOpenPanel,
});
@@ -245,49 +222,6 @@ export function ImageCanvasGenerationImageOptionsView({
});
};
const updateBackgroundColor = (screenColor: string) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? {
...resetFailedDialogStatus(currentDialog),
screenColor: normalizeEditorGenerationBackgroundColor(screenColor),
}
: currentDialog,
);
};
const renderBackgroundColorSwatch = (value: string) =>
value === 'auto' ? (
<span
className="image-canvas-editor__background-color-swatch"
aria-hidden="true"
/>
) : (
<span
className="image-canvas-editor__background-color-swatch"
style={{ backgroundColor: value }}
aria-hidden="true"
/>
);
const formatBackgroundColorOptionAriaLabel = (option: {
label: string;
value: string;
}) =>
option.value === 'auto'
? option.label
: `${option.label} ${option.value}`;
const updateSegModel = (segModel: string) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? {
...resetFailedDialogStatus(currentDialog),
segModel: normalizeVisibleEditorBgFilterSegModel(segModel),
}
: currentDialog,
);
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
};
@@ -372,136 +306,6 @@ export function ImageCanvasGenerationImageOptionsView({
: null}
</div>
) : null}
{includeBackgroundColor ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color">
<PlatformInlineOptionButton
ref={backgroundColorButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
aria-label={`${labelPrefix}背景色 ${formatBackgroundColorOptionAriaLabel(selectedBackgroundColor)}`}
aria-expanded={openPanel === 'backgroundColor'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('backgroundColor')}
>
<span className="image-canvas-editor__background-color-trigger-label">
{renderBackgroundColorSwatch(selectedBackgroundColor.value)}
<span>{selectedBackgroundColor.label}</span>
{selectedBackgroundColor.value === 'auto' ? null : (
<span>{selectedBackgroundColor.value}</span>
)}
</span>
</PlatformInlineOptionButton>
{openPanel === 'backgroundColor'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--background-color image-canvas-editor__portal-menu"
label={`${labelPrefix}背景色选项`}
placement="top-start"
style={buildPortalMenuStyle(
backgroundColorButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--background-color">
{EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map(
(option) => {
const selected =
selectedBackgroundColor.value === option.value;
return (
<OptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--background-color"
ariaLabel={formatBackgroundColorOptionAriaLabel(
option,
)}
onClick={() => updateBackgroundColor(option.value)}
>
{renderBackgroundColorSwatch(option.value)}
<span className="image-canvas-editor__background-color-choice-text">
<span>{option.label}</span>
{option.value === 'auto' ? null : (
<span>{option.value}</span>
)}
</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</OptionChoice>
);
},
)}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
{includeSegModel ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--seg-model">
<PlatformInlineOptionButton
ref={segModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--seg-model"
aria-label={`${labelPrefix}抠图模型 ${selectedSegModel.label}`}
aria-expanded={openPanel === 'segModel'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('segModel')}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Scissors />
</span>
<span>{selectedSegModel.label}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'segModel'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--seg-model image-canvas-editor__portal-menu"
label={`${labelPrefix}抠图模型选项`}
placement="top-start"
style={buildPortalMenuStyle(segModelButtonRef.current, 'above')}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--seg-model">
{EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS.map((option) => {
const selected = selectedSegModel.value === option.value;
return (
<OptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--seg-model"
ariaLabel={option.label}
onClick={() => updateSegModel(option.value)}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Scissors />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</OptionChoice>
);
})}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
{includeModel ? (
<div
className={[
@@ -32,7 +32,6 @@ import {
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
EDITOR_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_OPTIONS,
@@ -40,11 +39,9 @@ import {
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_OPTIONS,
formatEditorGenerationBackgroundColor,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
normalizeEditorGenerationBackgroundColor,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -160,34 +157,6 @@ describe('ImageCanvasGenerationModel', () => {
);
});
it('provides auto plus eleven keyed background colors and normalizes choices', () => {
expect(EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS).toHaveLength(12);
expect(
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map((option) => option.value),
).toEqual([
'auto',
'#CFEFFF',
'#B0C2E0',
'#FFD6C2',
'#E6D8FF',
'#F4D8E8',
'#7FB3FF',
'#FFF2A8',
'#CFFFE1',
'#D8DEE8',
'#D8D2E8',
'#A8F7F0',
]);
expect(normalizeEditorGenerationBackgroundColor(' #a8f7f0 ')).toBe(
'#A8F7F0',
);
expect(formatEditorGenerationBackgroundColor('#A8F7F0')).toBe(
'高对比浅青 #A8F7F0',
);
expect(normalizeEditorGenerationBackgroundColor(undefined)).toBe('auto');
expect(formatEditorGenerationBackgroundColor('auto')).toBe('自动');
});
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
applyEditorGenerationPricingConfig({
models: {
@@ -283,8 +252,6 @@ describe('ImageCanvasGenerationModel', () => {
).toEqual({
fields: [
{ title: '角色设定', value: '主角骑士' },
{ title: '抠图背景色', value: '自动' },
{ title: '抠图模型', value: '默认 birefnet' },
],
references: [
{
@@ -310,11 +277,7 @@ describe('ImageCanvasGenerationModel', () => {
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
{ title: '抠图背景色', value: '自动' },
{ title: '抠图模型', value: '默认 birefnet' },
],
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标规范',
@@ -354,8 +317,6 @@ describe('ImageCanvasGenerationModel', () => {
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
{ title: '抠图背景色', value: '自动' },
{ title: '抠图模型', value: '默认 birefnet' },
],
references: [
{
@@ -97,36 +97,8 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
'原图按钮',
'冻结按钮',
];
export const EDITOR_GENERATION_BACKGROUND_COLOR_AUTO_VALUE = 'auto';
export const EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS = [
{ label: '自动', value: EDITOR_GENERATION_BACKGROUND_COLOR_AUTO_VALUE },
{ label: '浅雾蓝', value: '#CFEFFF' },
{ label: '浅钢蓝', value: '#B0C2E0' },
{ label: '暖浅桃色', value: '#FFD6C2' },
{ label: '淡薰衣草紫', value: '#E6D8FF' },
{ label: '浅粉灰', value: '#F4D8E8' },
{ label: '中度天蓝', value: '#7FB3FF' },
{ label: '浅柠黄', value: '#FFF2A8' },
{ label: '淡薄荷绿', value: '#CFFFE1' },
{ label: '浅中性灰', value: '#D8DEE8' },
{ label: '淡灰紫', value: '#D8D2E8' },
{ label: '高对比浅青', value: '#A8F7F0' },
] as const;
export type EditorGenerationBackgroundColorValue =
(typeof EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS)[number]['value'];
export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR =
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0].value;
export const EDITOR_BGFILTER_SEG_MODEL_OPTIONS = [
{ label: '默认', value: 'birefnet' },
{ label: '动漫风格', value: 'anime-seg' },
] as const;
export const EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS = [
EDITOR_BGFILTER_SEG_MODEL_OPTIONS[0],
] as const;
export type EditorBgFilterSegModelValue =
(typeof EDITOR_BGFILTER_SEG_MODEL_OPTIONS)[number]['value'];
export const DEFAULT_EDITOR_BGFILTER_SEG_MODEL =
EDITOR_BGFILTER_SEG_MODEL_OPTIONS[0].value;
export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR = 'auto';
export const DEFAULT_EDITOR_BGFILTER_SEG_MODEL = 'birefnet';
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。';
export const QUICK_EDIT_SIZE_PRESETS = [
@@ -579,96 +551,6 @@ export function getEditorImageModelDisplayName(
);
}
export function normalizeEditorGenerationBackgroundColor(
value: string | null | undefined,
): EditorGenerationBackgroundColorValue {
const normalizedValue = value?.trim().toUpperCase();
if (
normalizedValue === EDITOR_GENERATION_BACKGROUND_COLOR_AUTO_VALUE.toUpperCase()
) {
return EDITOR_GENERATION_BACKGROUND_COLOR_AUTO_VALUE;
}
const normalizedHexValue = normalizedValue?.match(/#[0-9A-F]{6}/)?.[0];
const matched = EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
(option) =>
option.value === normalizedValue || option.value === normalizedHexValue,
);
return matched?.value ?? DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
}
export function getEditorGenerationBackgroundColorOption(
value: string | null | undefined,
) {
const normalizedValue = normalizeEditorGenerationBackgroundColor(value);
return (
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
(option) => option.value === normalizedValue,
) ?? EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0]
);
}
export function formatEditorGenerationBackgroundColor(
value: string | null | undefined,
) {
const option = getEditorGenerationBackgroundColorOption(value);
if (option.value === EDITOR_GENERATION_BACKGROUND_COLOR_AUTO_VALUE) {
return option.label;
}
return `${option.label} ${option.value}`;
}
export function normalizeEditorBgFilterSegModel(
value: string | null | undefined,
): EditorBgFilterSegModelValue {
const normalizedValue = value?.trim().toLowerCase();
const normalizedToken = normalizedValue?.match(/(?:anime-seg|birefnet)/u)?.[0];
const matched = EDITOR_BGFILTER_SEG_MODEL_OPTIONS.find(
(option) =>
option.value === normalizedValue || option.value === normalizedToken,
);
return matched?.value ?? DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
}
export function normalizeVisibleEditorBgFilterSegModel(
value: string | null | undefined,
): EditorBgFilterSegModelValue {
const normalizedValue = normalizeEditorBgFilterSegModel(value);
return (
EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS.find(
(option) => option.value === normalizedValue,
)?.value ?? DEFAULT_EDITOR_BGFILTER_SEG_MODEL
);
}
export function getEditorBgFilterSegModelOption(
value: string | null | undefined,
) {
const normalizedValue = normalizeEditorBgFilterSegModel(value);
return (
EDITOR_BGFILTER_SEG_MODEL_OPTIONS.find(
(option) => option.value === normalizedValue,
) ?? EDITOR_BGFILTER_SEG_MODEL_OPTIONS[0]
);
}
export function getVisibleEditorBgFilterSegModelOption(
value: string | null | undefined,
) {
const normalizedValue = normalizeVisibleEditorBgFilterSegModel(value);
return (
EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS.find(
(option) => option.value === normalizedValue,
) ?? EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS[0]
);
}
export function formatEditorBgFilterSegModel(
value: string | null | undefined,
) {
const option = getEditorBgFilterSegModelOption(value);
return `${option.label} ${option.value}`;
}
export function buildQuickEditModelOptions(currentModel: string) {
void currentModel;
const options = [...QUICK_EDIT_MODEL_OPTIONS];
@@ -1194,21 +1076,9 @@ export function buildCharacterGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references: CharacterReferenceImage[] | undefined,
screenColor?: string,
segModel?: string,
): CanvasGenerationInputs {
return {
fields: [
...createGenerationInputField('角色设定', prompt),
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
...createGenerationInputField(
'抠图模型',
formatEditorBgFilterSegModel(segModel),
),
],
fields: createGenerationInputField('角色设定', prompt),
references: [
...(specReference
? createGenerationInputReference('角色规范', specReference)
@@ -1313,8 +1183,6 @@ export function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
references?: CharacterReferenceImage[],
screenColor?: string,
segModel?: string,
): CanvasGenerationInputs {
return {
fields: [
@@ -1322,14 +1190,6 @@ export function buildIconGenerationInputs(
title: '素材描述',
value: iconDescriptions.join('\n'),
},
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
...createGenerationInputField(
'抠图模型',
formatEditorBgFilterSegModel(segModel),
),
],
references: [
...createGenerationInputReference('图标规范', specReference),
@@ -1343,8 +1203,6 @@ export function buildIconGenerationInputs(
export function buildUiDesignAssetExtractionGenerationInputs(
sourceLayer: CanvasLayer,
references?: CharacterReferenceImage[],
screenColor?: string,
segModel?: string,
): CanvasGenerationInputs {
return {
fields: [
@@ -1352,14 +1210,6 @@ export function buildUiDesignAssetExtractionGenerationInputs(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
...createGenerationInputField(
'抠图模型',
formatEditorBgFilterSegModel(segModel),
),
],
references: [
...createLayerGenerationInputReference('UI设计图', sourceLayer),
@@ -273,8 +273,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 白发骑士 ',
status: 'idle',
imageModel: 'gpt-image-2',
screenColor: '#FFD6C2',
segModel: 'anime-seg',
aspectRatio: '2:3',
imageSize: '2K',
characterSpecReference: {
@@ -303,7 +301,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: '白发骑士',
kind: 'character',
model: 'gpt-image-2',
screenColor: '#FFD6C2',
screenColor: 'auto',
segModel: 'birefnet',
aspectRatio: '2:3',
imageSize: '2K',
@@ -322,8 +320,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
fields: [
{ title: '角色设定', value: '白发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
{ title: '抠图模型', value: '默认 birefnet' },
],
references: [
{
@@ -620,7 +616,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('falls back to default icon descriptions when the draft descriptions are empty', () => {
it('returns an icon spritesheet error when descriptions are empty', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '',
@@ -634,15 +630,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
iconDescriptions: [' ', '\n'],
});
expect(plan.ok).toBe(true);
expect(plan.ok === true ? plan.iconDescriptions : []).toEqual([
'返回按钮',
'设置按钮',
'下一关按钮',
'提示按钮',
'原图按钮',
'冻结按钮',
]);
expect(plan).toEqual({
ok: false,
errorMessage: '请填写素材描述',
});
});
it('builds icon spritesheet plans with trimmed descriptions and references', () => {
@@ -651,8 +642,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 返回按钮 \n\n设置按钮',
status: 'idle',
imageModel: 'gpt-image-2',
screenColor: '#7FB3FF',
segModel: 'anime-seg',
aspectRatio: '3:2',
imageSize: '2K',
iconSpecReference: {
@@ -680,17 +669,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
screenColor: '#7FB3FF',
screenColor: 'auto',
segModel: 'birefnet',
aspectRatio: '3:2',
imageSize: '2K',
},
generationInputs: {
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
{ title: '抠图背景色', value: '中度天蓝 #7FB3FF' },
{ title: '抠图模型', value: '默认 birefnet' },
],
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标规范',
@@ -29,7 +29,8 @@ import {
buildUiDesignGenerationInputs,
buildVideoGenerationInputs,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
@@ -42,8 +43,6 @@ import {
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeVisibleEditorBgFilterSegModel,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
@@ -349,10 +348,8 @@ export function buildImageGenerationSubmissionPlan({
),
].filter((src): src is string => Boolean(src));
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = normalizeEditorGenerationBackgroundColor(
dialog.screenColor,
);
const segModel = normalizeVisibleEditorBgFilterSegModel(dialog.segModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
return {
kind: 'image',
normalizedPrompt,
@@ -373,8 +370,6 @@ export function buildImageGenerationSubmissionPlan({
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
screenColor,
segModel,
),
},
rememberImageModel: imageModel,
@@ -585,7 +580,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
description.trim(),
)
? (dialog.iconDescriptions ?? [])
: DEFAULT_ICON_DESCRIPTIONS;
: [];
const iconDescriptions = iconDescriptionSource
.map((description) => description.trim())
.filter(Boolean)
@@ -606,10 +601,8 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = normalizeEditorGenerationBackgroundColor(
dialog.screenColor,
);
const segModel = normalizeVisibleEditorBgFilterSegModel(dialog.segModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
return {
ok: true,
iconDescriptions,
@@ -635,8 +628,6 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
iconDescriptions,
dialog.iconSpecReference,
dialog.generationReferences,
screenColor,
segModel,
),
rememberImageModel,
};
@@ -239,8 +239,6 @@ export function ImageCanvasIconSpritesheetComposerView({
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
includeBackgroundColor
includeSegModel
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={calculateEditorIconSpritesheetPrice(

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