调整画板生图抠图背景配置
新增画板生图背景色自动决策模块,并接入角色、图标和 UI 素材提取三条路径。 扩展用户可选背景色到 11 个,并把 auto 作为默认背景色选项。 保留 anime-seg 内部解析能力,但用户界面只展示并提交默认 birefnet。 BgFilter 失败时增加本地纯色背景去背兜底,降低外部服务失败影响。 新增 BGFilter 探活与 gpt-image-2 尺寸生成调试脚本。 同步更新编辑器、生图链路和后端架构相关文档。
This commit is contained in:
@@ -19,15 +19,31 @@
|
||||
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
|
||||
|
||||
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
|
||||
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取新增用户可选纯色抠图背景。前端下拉展示颜色名、方块色样和 hex,默认 `浅雾蓝 #CFEFFF`,可选 `浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`。请求字段统一为前端 / JSON `screenColor`、Rust `screen_color`;生成输入快照写入 `抠图背景色`,同源重绘可从快照恢复。api-server 只接受上述六个 hex,缺省或空值使用 `#CFEFFF`,未知值返回 `400`;生成后 BgFilter 去背景用该值作为 `screen_color`。角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
|
||||
- 决策:角色形象、图标 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` 绿幕。
|
||||
- 影响范围:`/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`。
|
||||
|
||||
## 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`。
|
||||
- 影响范围:`/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`。
|
||||
|
||||
## 2026-07-05 BgFilter 失败时本地纯色去背兜底
|
||||
|
||||
- 背景:`scripts/check-bgfilter-live.mjs --width=2048 --height=2048` 可稳定复现 BgFilter 对 2K 输入返回 `HTTP 500 {"detail":"inference failed"}`,浏览器生成链路会因此收到“BgFilter 服务返回非成功状态”。
|
||||
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取仍优先调用独立 BgFilter;若 BgFilter 请求失败、返回非成功状态、返回空图片或非法图片,api-server 记录 warning 后使用本地 `editor_green_screen` 按解析后的纯色背景执行确定性去背兜底,不中断生成。手动任意图片去背景仍只走独立 BiRefNet BFF,不使用该兜底。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、BgFilter 运维排障、图片画布生成后处理。
|
||||
- 验证方式:运行 `node scripts/check-bgfilter-live.mjs --width=2048 --height=2048 --timeout-ms=300000` 复现远端 500;运行 `cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess 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`。
|
||||
|
||||
## 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>`,`segModel` 用户可选 `birefnet` 或 `anime-seg`,默认 `birefnet`。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 服务。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。
|
||||
- 决策:角色形象生成、图标 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` 透明化。
|
||||
- 影响范围:`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
@@ -208,7 +208,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 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>`;`segModel` 只允许 `birefnet` 或 `anime-seg`,默认 `birefnet`,其中 `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 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格、抠图背景色、抠图模型和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用所选纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按所选抠图模型透明化,并复用图标素材拆分流程,把透明 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`,后端调用 BgFilter 时作为 `screen_color` 传入;抠图模型请求字段为 `segModel`,可选 `birefnet` 或 `anime-seg`,默认 `birefnet`,后端调用 BgFilter 时作为 `seg_model` 传入。
|
||||
- UI 素材提取面板提供抠图背景色下拉和抠图模型下拉;背景色默认 `自动`,手动项展示颜色名称、方块色样和 hex 值,请求字段为 `screenColor`。`screenColor=auto` 时后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。抠图模型请求字段为 `segModel`,用户界面只展示并提交默认 `birefnet`;后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
|
||||
## 提示词契约
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后先把带所选纯色背景的源图写入 OSS,再调用 BgFilter 按所选 `segModel` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
|
||||
|
||||
## 验收点
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
- `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`。
|
||||
- Prompt 固定为:
|
||||
|
||||
```text
|
||||
@@ -57,7 +58,7 @@
|
||||
|
||||
## 去背与保存
|
||||
|
||||
- 后端收到 spritesheet 后先把带所选纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,`segModel` 可选 `birefnet` 或 `anime-seg`,默认 `birefnet`。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,用户界面只展示并提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
|
||||
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
3. 唯一文本框为 `角色设定`。
|
||||
4. 左下角展示画面比例、大小和抠图背景色选择按钮。
|
||||
- 抠图背景色下拉必须展示颜色名称、方块色样和 hex 值。
|
||||
- 默认 `浅雾蓝 #CFEFFF`;可选 `浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`。
|
||||
- 默认 `自动`;手动可选 `浅雾蓝 #CFEFFF`、`浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`、`浅柠黄 #FFF2A8`、`淡薄荷绿 #CFFFE1`、`浅中性灰 #D8DEE8`、`淡灰紫 #D8D2E8`、`高对比浅青 #A8F7F0`。
|
||||
5. 右下角展示模型选择和生成按钮。
|
||||
|
||||
## 普通生成面板视觉口径
|
||||
@@ -54,7 +54,7 @@
|
||||
- 请求同时提交 `model`、`screenColor`、`segModel`、`aspectRatio` 和 `imageSize`:
|
||||
- `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- `screenColor` 只允许上述六个 hex;前端默认提交 `#CFEFFF`,同源重绘从生成器快照或输入快照 `抠图背景色` 恢复,后端调用 BgFilter 时把该字段作为 `screen_color` 传入。`segModel` 只允许 `birefnet` 或 `anime-seg`,默认 `birefnet`,同源重绘从生成器快照或输入快照 `抠图模型` 恢复,后端调用 BgFilter 时把该字段作为 `seg_model` 传入。
|
||||
- `screenColor` 允许 `auto` 或上述 11 个 hex;前端默认提交 `auto`,同源重绘从生成器快照或输入快照 `抠图背景色` 恢复。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。`segModel` 当前用户界面只展示并提交默认 `birefnet`;后端仍识别内部保留的 `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 +67,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 +116,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` 执行透明化、写入 `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` 只退出角色规范画布点选状态,不关闭角色生成面板。
|
||||
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
|
||||
- 本次验证命令:
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const DEFAULT_BASE_URL = 'http://58.87.105.82/bgfilter';
|
||||
const DEFAULT_SCREEN_COLOR = '#CFEFFF';
|
||||
const DEFAULT_SEG_MODEL = 'birefnet';
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
const env = {};
|
||||
const content = readFileSync(path, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const fileEnv = {
|
||||
...loadEnvFile(resolve(root, '.env')),
|
||||
...loadEnvFile(resolve(root, '.env.local')),
|
||||
...loadEnvFile(resolve(root, '.env.secrets.local')),
|
||||
};
|
||||
|
||||
function readConfig(name) {
|
||||
return process.env[name] || fileEnv[name] || '';
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const prefix = `--${name}=`;
|
||||
const value = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
return value ? value.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function readPositiveIntArg(name, fallback) {
|
||||
const rawValue = readArg(name);
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`--${name} 必须是正整数,当前为 ${rawValue}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHexColor(value) {
|
||||
const color = value.trim();
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||
throw new Error(`screen_color 必须是 #RRGGBB,当前为 ${value}`);
|
||||
}
|
||||
return color.toUpperCase();
|
||||
}
|
||||
|
||||
function parseHexColor(value) {
|
||||
const color = normalizeHexColor(value);
|
||||
return [
|
||||
Number.parseInt(color.slice(1, 3), 16),
|
||||
Number.parseInt(color.slice(3, 5), 16),
|
||||
Number.parseInt(color.slice(5, 7), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
||||
return Buffer.concat([length, typeBuffer, data, checksum]);
|
||||
}
|
||||
|
||||
function makeProbePng(screenColor, width, height) {
|
||||
const [red, green, blue] = parseHexColor(screenColor);
|
||||
const rowSize = 1 + width * 4;
|
||||
const raw = Buffer.alloc(rowSize * height);
|
||||
const subjectLeft = Math.floor(width * 0.31);
|
||||
const subjectRight = Math.ceil(width * 0.69);
|
||||
const subjectTop = Math.floor(height * 0.25);
|
||||
const subjectBottom = Math.ceil(height * 0.75);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = y * rowSize;
|
||||
raw[row] = 0;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = row + 1 + x * 4;
|
||||
const inSubject =
|
||||
x >= subjectLeft && x < subjectRight && y >= subjectTop && y < subjectBottom;
|
||||
raw[offset] = inSubject ? 35 : red;
|
||||
raw[offset + 1] = inSubject ? 35 : green;
|
||||
raw[offset + 2] = inSubject ? 35 : blue;
|
||||
raw[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', deflateSync(raw)),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildEndpoint(baseUrl) {
|
||||
const base = baseUrl.trim().replace(/\/+$/, '');
|
||||
if (!base) {
|
||||
throw new Error('BgFilter base url 为空');
|
||||
}
|
||||
return `${base}/remove-background`;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
readArg('base-url') ||
|
||||
readConfig('GENARRATIVE_EDITOR_BGFILTER_BASE_URL') ||
|
||||
DEFAULT_BASE_URL;
|
||||
const screenColor = normalizeHexColor(
|
||||
readArg('screen-color') ||
|
||||
readConfig('GENARRATIVE_EDITOR_BGFILTER_SCREEN_COLOR') ||
|
||||
DEFAULT_SCREEN_COLOR,
|
||||
);
|
||||
const segModel =
|
||||
readArg('seg-model') ||
|
||||
readConfig('GENARRATIVE_EDITOR_BGFILTER_SEG_MODEL') ||
|
||||
DEFAULT_SEG_MODEL;
|
||||
const token =
|
||||
readConfig('GENARRATIVE_EDITOR_BGFILTER_TOKEN') ||
|
||||
readConfig('GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN');
|
||||
const timeoutMs = Number.parseInt(
|
||||
readArg('timeout-ms') ||
|
||||
readConfig('GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS') ||
|
||||
`${DEFAULT_TIMEOUT_MS}`,
|
||||
10,
|
||||
);
|
||||
const width = readPositiveIntArg('width', 96);
|
||||
const height = readPositiveIntArg('height', width);
|
||||
|
||||
const endpoint = buildEndpoint(baseUrl);
|
||||
const inputImage = makeProbePng(screenColor, width, height);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([inputImage], { type: 'image/png' }),
|
||||
'bgfilter-live-probe.png',
|
||||
);
|
||||
form.append('screen_color', screenColor);
|
||||
form.append('seg_model', segModel);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
|
||||
console.log('BgFilter 探活');
|
||||
console.log(`目标: ${endpoint}`);
|
||||
console.log(`screen_color: ${screenColor}`);
|
||||
console.log(`seg_model: ${segModel}`);
|
||||
console.log(`image: ${width}x${height}, ${inputImage.length} bytes`);
|
||||
console.log(`timeout_ms: ${timeoutMs}`);
|
||||
console.log(`token: ${token ? '已配置' : '未配置'}\n`);
|
||||
|
||||
try {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['X-Genarrative-Image-Token'] = token;
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const upstreamElapsedMs = response.headers.get('x-bgfilter-elapsed-ms') || '';
|
||||
const upstreamSegModel = response.headers.get('x-bgfilter-seg-model') || '';
|
||||
const upstreamScreenColor = response.headers.get('x-bgfilter-screen-color') || '';
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`失败: HTTP ${response.status} ${response.statusText}`);
|
||||
console.error(`耗时: ${elapsedMs}ms`);
|
||||
console.error(body.toString('utf8').slice(0, 500));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!contentType.toLowerCase().startsWith('image/')) {
|
||||
console.error(`失败: 返回 content-type 不是图片: ${contentType || '(empty)'}`);
|
||||
console.error(body.toString('utf8').slice(0, 500));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (body.length === 0) {
|
||||
console.error('失败: 返回图片为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`成功: HTTP ${response.status} ${response.statusText}`);
|
||||
console.log(`耗时: ${elapsedMs}ms`);
|
||||
console.log(`content-type: ${contentType}`);
|
||||
console.log(`bytes: ${body.length}`);
|
||||
if (upstreamElapsedMs) {
|
||||
console.log(`x-bgfilter-elapsed-ms: ${upstreamElapsedMs}`);
|
||||
}
|
||||
if (upstreamSegModel) {
|
||||
console.log(`x-bgfilter-seg-model: ${upstreamSegModel}`);
|
||||
}
|
||||
if (upstreamScreenColor) {
|
||||
console.log(`x-bgfilter-screen-color: ${upstreamScreenColor}`);
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const message =
|
||||
error?.name === 'AbortError'
|
||||
? `请求超时 (${timeoutMs}ms)`
|
||||
: error?.message || String(error);
|
||||
console.error(`失败: ${message}`);
|
||||
console.error(`耗时: ${elapsedMs}ms`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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}`);
|
||||
@@ -16,7 +16,7 @@ pub(crate) struct EditorScreenBackgroundColor {
|
||||
pub(crate) blue: u8,
|
||||
}
|
||||
|
||||
const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 6] = [
|
||||
pub(crate) const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 11] = [
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅雾蓝",
|
||||
hex: "#CFEFFF",
|
||||
@@ -59,6 +59,41 @@ const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 6] = [
|
||||
green: 179,
|
||||
blue: 255,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅柠黄",
|
||||
hex: "#FFF2A8",
|
||||
red: 255,
|
||||
green: 242,
|
||||
blue: 168,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "淡薄荷绿",
|
||||
hex: "#CFFFE1",
|
||||
red: 207,
|
||||
green: 255,
|
||||
blue: 225,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅中性灰",
|
||||
hex: "#D8DEE8",
|
||||
red: 216,
|
||||
green: 222,
|
||||
blue: 232,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "淡灰紫",
|
||||
hex: "#D8D2E8",
|
||||
red: 216,
|
||||
green: 210,
|
||||
blue: 232,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "高对比浅青",
|
||||
hex: "#A8F7F0",
|
||||
red: 168,
|
||||
green: 247,
|
||||
blue: 240,
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn default_editor_screen_background_color() -> EditorScreenBackgroundColor {
|
||||
@@ -104,8 +139,7 @@ pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
|
||||
"素材自身不要出现与背景色相同或相近的描边、底板、投影或反光";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
|
||||
"角色主体不得带与背景色相同或相近的描边、投影或反光";
|
||||
pub(crate) const LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE: &str =
|
||||
"背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
pub(crate) const LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE: &str = "背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
|
||||
fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> String {
|
||||
if color.hex == "#00FF00" {
|
||||
@@ -238,6 +272,12 @@ mod tests {
|
||||
.label,
|
||||
"暖浅桃色"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_editor_screen_background_color(Some("#a8f7f0"))
|
||||
.expect("new high-contrast cyan should parse")
|
||||
.label,
|
||||
"高对比浅青"
|
||||
);
|
||||
assert!(parse_editor_screen_background_color(Some("#00FF00")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,12 @@ use crate::{
|
||||
editor_green_screen::{
|
||||
EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
|
||||
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
|
||||
parse_editor_screen_background_color,
|
||||
remove_editor_generated_green_screen_background,
|
||||
},
|
||||
editor_screen_background_decision::{
|
||||
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
|
||||
EditorScreenBackgroundDecisionKind, format_editor_screen_background_decision_input,
|
||||
resolve_editor_screen_background_color,
|
||||
},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
@@ -1219,13 +1224,26 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let screen_color = if is_character_generation {
|
||||
Some(parse_editor_screen_background_color(
|
||||
payload.screen_color.as_deref(),
|
||||
)?)
|
||||
let screen_background_decision = if is_character_generation {
|
||||
Some(
|
||||
resolve_editor_screen_background_color(
|
||||
state.llm_client(),
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::Character,
|
||||
screen_color: payload.screen_color.clone(),
|
||||
prompt: role_setting.clone(),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: payload.reference_image_srcs.as_ref().map_or(0, Vec::len),
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let screen_color = screen_background_decision
|
||||
.as_ref()
|
||||
.map(|decision| decision.color);
|
||||
let seg_model = if is_character_generation {
|
||||
Some(parse_editor_bgfilter_seg_model(
|
||||
payload.seg_model.as_deref(),
|
||||
@@ -1448,7 +1466,10 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: payload.source_resource_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
generation_inputs: apply_editor_screen_background_decision_to_generation_inputs(
|
||||
payload.generation_inputs,
|
||||
screen_background_decision.as_ref(),
|
||||
),
|
||||
thumbnail_src: None,
|
||||
},
|
||||
)
|
||||
@@ -1494,6 +1515,28 @@ fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_editor_screen_background_decision_to_generation_inputs(
|
||||
generation_inputs: Option<Value>,
|
||||
decision: Option<&EditorScreenBackgroundDecision>,
|
||||
) -> Option<Value> {
|
||||
let Some(decision) = decision else {
|
||||
return generation_inputs;
|
||||
};
|
||||
let mut value = generation_inputs?;
|
||||
let Some(fields) = value.get_mut("fields").and_then(Value::as_array_mut) else {
|
||||
return Some(value);
|
||||
};
|
||||
let resolved_value = format_editor_screen_background_decision_input(decision);
|
||||
for field in fields {
|
||||
let title = field.get("title").and_then(Value::as_str);
|
||||
if title == Some("抠图背景色") {
|
||||
field["value"] = Value::String(resolved_value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn editor_generated_image_storage_profile(
|
||||
normalized_kind: Option<&str>,
|
||||
) -> EditorGeneratedImageStorageProfile {
|
||||
@@ -2038,6 +2081,35 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
|
||||
image: &DownloadedOpenAiImage,
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
seg_model: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
match request_editor_generated_screen_background_with_bgfilter(
|
||||
state,
|
||||
image,
|
||||
screen_color,
|
||||
seg_model,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(image) => Ok(image),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = "bgfilter",
|
||||
screen_color = screen_color.hex,
|
||||
seg_model,
|
||||
error = %error,
|
||||
error_details = ?error.details(),
|
||||
"editor_bgfilter_fallback_to_local_screen_background_removal"
|
||||
);
|
||||
remove_editor_generated_green_screen_background(image, screen_color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_editor_generated_screen_background_with_bgfilter(
|
||||
state: &AppState,
|
||||
image: &DownloadedOpenAiImage,
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
seg_model: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let url = editor_bgfilter_endpoint(state)?;
|
||||
let call_id = format!("bgfilter-call-{}", current_utc_micros());
|
||||
@@ -2512,7 +2584,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
payload: EditorIconSpritesheetGenerationRequest,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
|
||||
let screen_color = parse_editor_screen_background_color(payload.screen_color.as_deref())?;
|
||||
let screen_background_decision = resolve_editor_screen_background_color(
|
||||
state.llm_client(),
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
|
||||
screen_color: payload.screen_color.clone(),
|
||||
prompt: icon_descriptions.join("\n"),
|
||||
icon_descriptions: icon_descriptions.clone(),
|
||||
reference_count: 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let screen_color = screen_background_decision.color;
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let mut reference_images =
|
||||
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
|
||||
@@ -2632,7 +2715,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((512, 512));
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
|
||||
payload.generation_inputs.clone(),
|
||||
Some(&screen_background_decision),
|
||||
);
|
||||
let owner_user_id = caller.owner_user_id;
|
||||
let spritesheet_persisted = persist_editor_generated_image(
|
||||
state,
|
||||
@@ -2764,7 +2850,21 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: EditorUiDesignAssetExtractionRequest,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let screen_color = parse_editor_screen_background_color(payload.screen_color.as_deref())?;
|
||||
let screen_background_decision = resolve_editor_screen_background_color(
|
||||
state.llm_client(),
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction,
|
||||
screen_color: payload.screen_color.clone(),
|
||||
prompt: format!(
|
||||
"从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。",
|
||||
payload.aspect_ratio, payload.image_size
|
||||
),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let screen_color = screen_background_decision.color;
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let reference_image = parse_editor_reference_image(
|
||||
state,
|
||||
@@ -2892,7 +2992,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
|
||||
payload.generation_inputs.clone(),
|
||||
Some(&screen_background_decision),
|
||||
);
|
||||
let owner_user_id = caller.owner_user_id;
|
||||
let spritesheet_persisted = persist_editor_generated_image(
|
||||
state,
|
||||
@@ -5183,7 +5286,7 @@ pub(crate) fn current_utc_micros() -> i64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::AppConfig;
|
||||
use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color};
|
||||
|
||||
fn apply_editor_canvas_generation_completion(
|
||||
layers: Value,
|
||||
@@ -6342,6 +6445,17 @@ mod tests {
|
||||
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_icon_spritesheet_prompt_accepts_new_background_colors() {
|
||||
let prompt = build_editor_icon_spritesheet_prompt(
|
||||
&["发光能量按钮".to_string()],
|
||||
parse_editor_screen_background_color(Some("#A8F7F0"))
|
||||
.expect("new screen color should parse"),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("背景必须是单一纯色背景 高对比浅青 #A8F7F0 / RGB(168,247,240)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
|
||||
assert_eq!(
|
||||
@@ -6847,6 +6961,15 @@ mod tests {
|
||||
assert_function_contains(
|
||||
source,
|
||||
"async fn remove_editor_generated_screen_background_with_bgfilter",
|
||||
"async fn request_editor_generated_screen_background_with_bgfilter",
|
||||
&[
|
||||
"request_editor_generated_screen_background_with_bgfilter",
|
||||
"remove_editor_generated_green_screen_background",
|
||||
],
|
||||
);
|
||||
assert_function_contains(
|
||||
source,
|
||||
"async fn request_editor_generated_screen_background_with_bgfilter",
|
||||
"async fn request_editor_background_removal_image",
|
||||
&[
|
||||
"editor_bgfilter_endpoint",
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
use platform_llm::{LlmClient, LlmTextRequest};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
editor_green_screen::{
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS, EditorScreenBackgroundColor,
|
||||
default_editor_screen_background_color, parse_editor_screen_background_color,
|
||||
},
|
||||
http_error::AppError,
|
||||
};
|
||||
|
||||
const EDITOR_SCREEN_BACKGROUND_AUTO_VALUE: &str = "auto";
|
||||
const EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS: u8 = 3;
|
||||
const EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS: u64 = 20_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum EditorScreenBackgroundDecisionMode {
|
||||
Manual,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct EditorScreenBackgroundDecision {
|
||||
pub(crate) color: EditorScreenBackgroundColor,
|
||||
pub(crate) mode: EditorScreenBackgroundDecisionMode,
|
||||
pub(crate) attempts: u8,
|
||||
pub(crate) fallback: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum EditorScreenBackgroundDecisionKind {
|
||||
Character,
|
||||
IconSpritesheet,
|
||||
UiDesignAssetExtraction,
|
||||
}
|
||||
|
||||
impl EditorScreenBackgroundDecisionKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Character => "角色形象生成",
|
||||
Self::IconSpritesheet => "图标素材 spritesheet 生成",
|
||||
Self::UiDesignAssetExtraction => "UI 设计图素材提取",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EditorScreenBackgroundDecisionInput {
|
||||
pub(crate) kind: EditorScreenBackgroundDecisionKind,
|
||||
pub(crate) screen_color: Option<String>,
|
||||
pub(crate) prompt: String,
|
||||
pub(crate) icon_descriptions: Vec<String>,
|
||||
pub(crate) reference_count: usize,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_editor_screen_background_color(
|
||||
llm_client: Option<&LlmClient>,
|
||||
input: EditorScreenBackgroundDecisionInput,
|
||||
) -> Result<EditorScreenBackgroundDecision, AppError> {
|
||||
if !is_auto_screen_background_color(input.screen_color.as_deref()) {
|
||||
return Ok(EditorScreenBackgroundDecision {
|
||||
color: parse_editor_screen_background_color(input.screen_color.as_deref())?,
|
||||
mode: EditorScreenBackgroundDecisionMode::Manual,
|
||||
attempts: 0,
|
||||
fallback: false,
|
||||
});
|
||||
}
|
||||
|
||||
let fallback_color = default_editor_screen_background_color();
|
||||
let Some(llm_client) = llm_client else {
|
||||
warn!(
|
||||
kind = input.kind.label(),
|
||||
fallback_hex = fallback_color.hex,
|
||||
"editor_screen_background_auto_decision_fallback_no_llm"
|
||||
);
|
||||
return Ok(EditorScreenBackgroundDecision {
|
||||
color: fallback_color,
|
||||
mode: EditorScreenBackgroundDecisionMode::Auto,
|
||||
attempts: 0,
|
||||
fallback: true,
|
||||
});
|
||||
};
|
||||
|
||||
let system_prompt = editor_screen_background_decision_system_prompt();
|
||||
let user_prompt = editor_screen_background_decision_user_prompt(&input);
|
||||
let mut last_error: Option<String> = None;
|
||||
for attempt in 1..=EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS {
|
||||
let request = LlmTextRequest::single_turn(system_prompt, user_prompt.as_str())
|
||||
.with_max_tokens(96)
|
||||
.with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS);
|
||||
match llm_client.request_text(request).await {
|
||||
Ok(response) => {
|
||||
match parse_editor_screen_background_decision_response(response.content.as_str()) {
|
||||
Some(color) => {
|
||||
info!(
|
||||
kind = input.kind.label(),
|
||||
screen_color = color.hex,
|
||||
attempts = attempt,
|
||||
"editor_screen_background_auto_decision_done"
|
||||
);
|
||||
return Ok(EditorScreenBackgroundDecision {
|
||||
color,
|
||||
mode: EditorScreenBackgroundDecisionMode::Auto,
|
||||
attempts: attempt,
|
||||
fallback: false,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let message = format!("LLM 返回了无法识别的背景色:{}", response.content);
|
||||
warn!(
|
||||
kind = input.kind.label(),
|
||||
attempts = attempt,
|
||||
message = message.as_str(),
|
||||
"editor_screen_background_auto_decision_invalid_response"
|
||||
);
|
||||
last_error = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
warn!(
|
||||
kind = input.kind.label(),
|
||||
attempts = attempt,
|
||||
message = message.as_str(),
|
||||
"editor_screen_background_auto_decision_request_failed"
|
||||
);
|
||||
last_error = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
kind = input.kind.label(),
|
||||
fallback_hex = fallback_color.hex,
|
||||
attempts = EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS,
|
||||
last_error = last_error.as_deref().unwrap_or(""),
|
||||
"editor_screen_background_auto_decision_fallback_after_retries"
|
||||
);
|
||||
Ok(EditorScreenBackgroundDecision {
|
||||
color: fallback_color,
|
||||
mode: EditorScreenBackgroundDecisionMode::Auto,
|
||||
attempts: EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS,
|
||||
fallback: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_auto_screen_background_color(value: Option<&str>) -> bool {
|
||||
value
|
||||
.map(str::trim)
|
||||
.map(|value| {
|
||||
value.is_empty() || value.eq_ignore_ascii_case(EDITOR_SCREEN_BACKGROUND_AUTO_VALUE)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(crate) fn format_editor_screen_background_decision_input(
|
||||
decision: &EditorScreenBackgroundDecision,
|
||||
) -> String {
|
||||
let resolved = format!("{} {}", decision.color.label, decision.color.hex);
|
||||
match decision.mode {
|
||||
EditorScreenBackgroundDecisionMode::Manual => resolved,
|
||||
EditorScreenBackgroundDecisionMode::Auto => {
|
||||
if decision.fallback {
|
||||
format!("自动 -> {resolved}(兜底)")
|
||||
} else {
|
||||
format!("自动 -> {resolved}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_screen_background_decision_system_prompt() -> &'static str {
|
||||
"你是游戏素材生图的抠图背景色决策器。根据任务和用户输入,从候选列表中选择一个最不容易与主体混淆、最适合后续扣除的纯色背景。只能返回 JSON:{\"hex\":\"#RRGGBB\"}。不要解释,不要返回候选列表外的颜色。"
|
||||
}
|
||||
|
||||
fn editor_screen_background_decision_user_prompt(
|
||||
input: &EditorScreenBackgroundDecisionInput,
|
||||
) -> String {
|
||||
let colors = EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.map(|color| {
|
||||
format!(
|
||||
"- {} {} / RGB({},{},{})",
|
||||
color.label, color.hex, color.red, color.green, color.blue
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let context = json!({
|
||||
"task": input.kind.label(),
|
||||
"userPrompt": input.prompt,
|
||||
"iconDescriptions": input.icon_descriptions,
|
||||
"referenceCount": input.reference_count,
|
||||
"selectionCriteria": [
|
||||
"优先选择与主体、主题色、UI 面板底色、发光效果和描边明显区分的背景色",
|
||||
"避免选择可能出现在主体、服饰、道具、按钮、图标或 UI 面板里的颜色",
|
||||
"角色类优先低干扰浅色,图标和 UI 提取类优先更高对比但仍保持纯净",
|
||||
"信息不足时选择浅雾蓝 #CFEFFF"
|
||||
],
|
||||
});
|
||||
format!("候选背景色:\n{colors}\n\n任务上下文 JSON:\n{context}")
|
||||
}
|
||||
|
||||
fn parse_editor_screen_background_decision_response(
|
||||
content: &str,
|
||||
) -> Option<EditorScreenBackgroundColor> {
|
||||
let trimmed = content.trim();
|
||||
let json_source = extract_json_object(trimmed).unwrap_or(trimmed);
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json_source) {
|
||||
if let Some(hex) = value.get("hex").and_then(serde_json::Value::as_str) {
|
||||
return find_editor_screen_background_color(hex);
|
||||
}
|
||||
if let Some(hex) = value
|
||||
.get("screenColor")
|
||||
.or_else(|| value.get("screen_color"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
return find_editor_screen_background_color(hex);
|
||||
}
|
||||
}
|
||||
find_hex_in_text(trimmed).and_then(find_editor_screen_background_color)
|
||||
}
|
||||
|
||||
fn find_editor_screen_background_color(value: &str) -> Option<EditorScreenBackgroundColor> {
|
||||
let normalized = value.trim().to_ascii_uppercase();
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|color| color.hex == normalized)
|
||||
}
|
||||
|
||||
fn extract_json_object(value: &str) -> Option<&str> {
|
||||
let start = value.find('{')?;
|
||||
let end = value.rfind('}')?;
|
||||
(start <= end).then_some(&value[start..=end])
|
||||
}
|
||||
|
||||
fn find_hex_in_text(value: &str) -> Option<&str> {
|
||||
value
|
||||
.split(|character: char| character.is_whitespace() || character == '"' || character == '\'')
|
||||
.find(|part| {
|
||||
let part = part.trim_matches(|character: char| {
|
||||
character == ',' || character == '.' || character == ',' || character == '。'
|
||||
});
|
||||
part.len() == 7
|
||||
&& part.starts_with('#')
|
||||
&& part[1..]
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit())
|
||||
})
|
||||
.map(|part| {
|
||||
part.trim_matches(|character: char| {
|
||||
character == ',' || character == '.' || character == ',' || character == '。'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_screen_background_decision_keeps_selected_color() {
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::Character,
|
||||
screen_color: Some("#CFFFE1".to_string()),
|
||||
prompt: "绿色史莱姆".to_string(),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("manual color should parse");
|
||||
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Manual);
|
||||
assert_eq!(decision.color.hex, "#CFFFE1");
|
||||
assert!(!decision.fallback);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_screen_background_decision_falls_back_without_llm() {
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
|
||||
screen_color: Some("auto".to_string()),
|
||||
prompt: String::new(),
|
||||
icon_descriptions: vec!["金币按钮".to_string()],
|
||||
reference_count: 2,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("auto fallback should succeed");
|
||||
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert_eq!(decision.color, default_editor_screen_background_color());
|
||||
assert!(decision.fallback);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_screen_background_decision_uses_auto_default() {
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction,
|
||||
screen_color: None,
|
||||
prompt: "提取 UI 按钮".to_string(),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("missing color should use auto fallback");
|
||||
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert_eq!(decision.color, default_editor_screen_background_color());
|
||||
assert!(decision.fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_llm_json_background_decision() {
|
||||
let color =
|
||||
parse_editor_screen_background_decision_response("```json\n{\"hex\":\"#a8f7f0\"}\n```")
|
||||
.expect("known color should parse");
|
||||
|
||||
assert_eq!(color.label, "高对比浅青");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_llm_background_decision() {
|
||||
assert!(
|
||||
parse_editor_screen_background_decision_response("{\"hex\":\"#123456\"}").is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ mod editor_generation_config;
|
||||
mod editor_generation_queue;
|
||||
mod editor_green_screen;
|
||||
mod editor_project;
|
||||
mod editor_screen_background_decision;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
|
||||
@@ -3201,7 +3201,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
@@ -3217,7 +3217,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图背景色',
|
||||
value: '浅雾蓝 #CFEFFF',
|
||||
value: '自动',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图模型',
|
||||
|
||||
@@ -192,38 +192,38 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '生成图片背景色 浅雾蓝 #CFEFFF',
|
||||
name: '生成图片背景色 自动',
|
||||
}),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片背景色选项' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '淡薰衣草紫 #E6D8FF' }),
|
||||
within(panel).getByRole('button', { name: '高对比浅青 #A8F7F0' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片背景色选项' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '淡薰衣草紫 #E6D8FF' })
|
||||
.getByRole('button', { name: '高对比浅青 #A8F7F0' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '生成图片背景色 淡薰衣草紫 #E6D8FF',
|
||||
name: '生成图片背景色 高对比浅青 #A8F7F0',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前背景色').textContent).toBe('#E6D8FF');
|
||||
expect(screen.getByLabelText('当前背景色').textContent).toBe('#A8F7F0');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('updates the BgFilter segmentation model from the model menu', () => {
|
||||
it('only exposes the default BgFilter segmentation model', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
includeSegModel
|
||||
@@ -247,25 +247,18 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
}),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片抠图模型选项' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动漫风格',
|
||||
}),
|
||||
);
|
||||
const options = within(panel).getAllByRole('button');
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片抠图模型选项' }),
|
||||
).toBeTruthy();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0].getAttribute('aria-label')).toBe('默认');
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '动漫风格' })
|
||||
.getByRole('button', { name: '默认' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.getByLabelText('当前抠图模型').textContent).toBe(
|
||||
'anime-seg',
|
||||
'birefnet',
|
||||
);
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('locks the model trigger when a fixed model is provided', () => {
|
||||
|
||||
@@ -15,14 +15,14 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_BGFILTER_SEG_MODEL_OPTIONS,
|
||||
EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
|
||||
getEditorBgFilterSegModelOption,
|
||||
getVisibleEditorBgFilterSegModelOption,
|
||||
getEditorGenerationBackgroundColorOption,
|
||||
getEditorImageModelDisplayName,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
normalizeEditorBgFilterSegModel,
|
||||
normalizeVisibleEditorBgFilterSegModel,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
normalizeEditorImageModel,
|
||||
QUICK_EDIT_MODEL_OPTIONS,
|
||||
@@ -171,7 +171,9 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
const selectedBackgroundColor = getEditorGenerationBackgroundColorOption(
|
||||
dialog.screenColor,
|
||||
);
|
||||
const selectedSegModel = getEditorBgFilterSegModelOption(dialog.segModel);
|
||||
const selectedSegModel = getVisibleEditorBgFilterSegModelOption(
|
||||
dialog.segModel,
|
||||
);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
@@ -254,12 +256,33 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
);
|
||||
};
|
||||
|
||||
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: normalizeEditorBgFilterSegModel(segModel),
|
||||
segModel: normalizeVisibleEditorBgFilterSegModel(segModel),
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
@@ -354,20 +377,18 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
<PlatformInlineOptionButton
|
||||
ref={backgroundColorButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
|
||||
aria-label={`${labelPrefix}背景色 ${selectedBackgroundColor.label} ${selectedBackgroundColor.value}`}
|
||||
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">
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: selectedBackgroundColor.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderBackgroundColorSwatch(selectedBackgroundColor.value)}
|
||||
<span>{selectedBackgroundColor.label}</span>
|
||||
<span>{selectedBackgroundColor.value}</span>
|
||||
{selectedBackgroundColor.value === 'auto' ? null : (
|
||||
<span>{selectedBackgroundColor.value}</span>
|
||||
)}
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'backgroundColor'
|
||||
@@ -392,17 +413,17 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--background-color"
|
||||
ariaLabel={`${option.label} ${option.value}`}
|
||||
ariaLabel={formatBackgroundColorOptionAriaLabel(
|
||||
option,
|
||||
)}
|
||||
onClick={() => updateBackgroundColor(option.value)}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: option.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderBackgroundColorSwatch(option.value)}
|
||||
<span className="image-canvas-editor__background-color-choice-text">
|
||||
<span>{option.label}</span>
|
||||
<span>{option.value}</span>
|
||||
{option.value === 'auto' ? null : (
|
||||
<span>{option.value}</span>
|
||||
)}
|
||||
</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
@@ -450,7 +471,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--seg-model">
|
||||
{EDITOR_BGFILTER_SEG_MODEL_OPTIONS.map((option) => {
|
||||
{EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selectedSegModel.value === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,
|
||||
@@ -39,9 +40,11 @@ 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,
|
||||
@@ -157,6 +160,34 @@ 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: {
|
||||
@@ -252,7 +283,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
).toEqual({
|
||||
fields: [
|
||||
{ title: '角色设定', value: '主角骑士' },
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
{ title: '抠图背景色', value: '自动' },
|
||||
{ title: '抠图模型', value: '默认 birefnet' },
|
||||
],
|
||||
references: [
|
||||
@@ -281,7 +312,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
).toEqual({
|
||||
fields: [
|
||||
{ title: '素材描述', value: '返回按钮\n设置按钮' },
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
{ title: '抠图背景色', value: '自动' },
|
||||
{ title: '抠图模型', value: '默认 birefnet' },
|
||||
],
|
||||
references: [
|
||||
@@ -323,7 +354,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
},
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
{ title: '抠图背景色', value: '自动' },
|
||||
{ title: '抠图模型', value: '默认 birefnet' },
|
||||
],
|
||||
references: [
|
||||
|
||||
@@ -97,13 +97,20 @@ 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'];
|
||||
@@ -113,6 +120,9 @@ 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 =
|
||||
@@ -573,6 +583,11 @@ 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) =>
|
||||
@@ -596,6 +611,9 @@ 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}`;
|
||||
}
|
||||
|
||||
@@ -611,6 +629,17 @@ export function normalizeEditorBgFilterSegModel(
|
||||
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,
|
||||
) {
|
||||
@@ -622,6 +651,17 @@ export function getEditorBgFilterSegModelOption(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
screenColor: '#FFD6C2',
|
||||
segModel: 'anime-seg',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
referenceImageSrcs: [
|
||||
@@ -323,7 +323,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
fields: [
|
||||
{ title: '角色设定', value: '白发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
{ title: '抠图模型', value: '动漫风格 anime-seg' },
|
||||
{ title: '抠图模型', value: '默认 birefnet' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
@@ -681,7 +681,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
screenColor: '#7FB3FF',
|
||||
segModel: 'anime-seg',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
},
|
||||
@@ -689,7 +689,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
fields: [
|
||||
{ title: '素材描述', value: '返回按钮\n设置按钮' },
|
||||
{ title: '抠图背景色', value: '中度天蓝 #7FB3FF' },
|
||||
{ title: '抠图模型', value: '动漫风格 anime-seg' },
|
||||
{ title: '抠图模型', value: '默认 birefnet' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorBgFilterSegModel,
|
||||
normalizeVisibleEditorBgFilterSegModel,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
@@ -352,7 +352,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
const screenColor = normalizeEditorGenerationBackgroundColor(
|
||||
dialog.screenColor,
|
||||
);
|
||||
const segModel = normalizeEditorBgFilterSegModel(dialog.segModel);
|
||||
const segModel = normalizeVisibleEditorBgFilterSegModel(dialog.segModel);
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
@@ -609,7 +609,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
const screenColor = normalizeEditorGenerationBackgroundColor(
|
||||
dialog.screenColor,
|
||||
);
|
||||
const segModel = normalizeEditorBgFilterSegModel(dialog.segModel);
|
||||
const segModel = normalizeVisibleEditorBgFilterSegModel(dialog.segModel);
|
||||
return {
|
||||
ok: true,
|
||||
iconDescriptions,
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '提取素材背景色 浅雾蓝 #CFEFFF',
|
||||
name: '提取素材背景色 自动',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByLabelText('参考UI.png')).toBeTruthy();
|
||||
@@ -269,18 +269,18 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '提取素材背景色 浅雾蓝 #CFEFFF',
|
||||
name: '提取素材背景色 自动',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('menu', { name: '提取素材背景色选项' }),
|
||||
).getByRole('button', {
|
||||
name: '暖浅桃色 #FFD6C2',
|
||||
name: '淡薄荷绿 #CFFFE1',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onScreenColorChange).toHaveBeenCalledWith('#FFD6C2');
|
||||
expect(onScreenColorChange).toHaveBeenCalledWith('#CFFFE1');
|
||||
});
|
||||
|
||||
it('changes the UI extraction BgFilter model from the segmentation menu', () => {
|
||||
@@ -316,13 +316,14 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
name: '提取素材抠图模型 默认',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('menu', { name: '提取素材抠图模型选项' }),
|
||||
).getByRole('button', { name: '动漫风格' }),
|
||||
);
|
||||
const menu = screen.getByRole('menu', { name: '提取素材抠图模型选项' });
|
||||
const options = within(menu).getAllByRole('button');
|
||||
|
||||
expect(onSegModelChange).toHaveBeenCalledWith('anime-seg');
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0].getAttribute('aria-label')).toBe('默认');
|
||||
fireEvent.click(options[0]);
|
||||
|
||||
expect(onSegModelChange).toHaveBeenCalledWith('birefnet');
|
||||
});
|
||||
|
||||
it('crops mark previews from the same source coordinates as the red selection', () => {
|
||||
|
||||
@@ -21,12 +21,12 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
EDITOR_BGFILTER_SEG_MODEL_OPTIONS,
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
|
||||
EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorBgFilterSegModelOption,
|
||||
getEditorGenerationBackgroundColorOption,
|
||||
getEditorImageModelDisplayName,
|
||||
getVisibleEditorBgFilterSegModelOption,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import type {
|
||||
@@ -305,7 +305,29 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
const selectedScreenColor = getEditorGenerationBackgroundColorOption(
|
||||
state.screenColor,
|
||||
);
|
||||
const selectedSegModel = getEditorBgFilterSegModelOption(state.segModel);
|
||||
const selectedSegModel = getVisibleEditorBgFilterSegModelOption(
|
||||
state.segModel,
|
||||
);
|
||||
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 screenFrame = {
|
||||
left: viewport.x + sourceLayer.x * viewport.scale,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
@@ -469,20 +491,18 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color image-canvas-editor__ui-extraction-screen-color">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
|
||||
aria-label={`提取素材背景色 ${selectedScreenColor.label} ${selectedScreenColor.value}`}
|
||||
aria-label={`提取素材背景色 ${formatBackgroundColorOptionAriaLabel(selectedScreenColor)}`}
|
||||
aria-expanded={isScreenColorMenuOpen}
|
||||
disabled={state.status === 'extracting'}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsScreenColorMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__background-color-trigger-label">
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: selectedScreenColor.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderBackgroundColorSwatch(selectedScreenColor.value)}
|
||||
<span>{selectedScreenColor.label}</span>
|
||||
<span>{selectedScreenColor.value}</span>
|
||||
{selectedScreenColor.value === 'auto' ? null : (
|
||||
<span>{selectedScreenColor.value}</span>
|
||||
)}
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{isScreenColorMenuOpen ? (
|
||||
@@ -502,21 +522,21 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--background-color"
|
||||
aria-label={`${option.label} ${option.value}`}
|
||||
aria-label={formatBackgroundColorOptionAriaLabel(
|
||||
option,
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onScreenColorChange(option.value);
|
||||
setIsScreenColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: option.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderBackgroundColorSwatch(option.value)}
|
||||
<span className="image-canvas-editor__background-color-choice-text">
|
||||
<span>{option.label}</span>
|
||||
<span>{option.value}</span>
|
||||
{option.value === 'auto' ? null : (
|
||||
<span>{option.value}</span>
|
||||
)}
|
||||
</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
@@ -617,7 +637,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--seg-model">
|
||||
{EDITOR_BGFILTER_SEG_MODEL_OPTIONS.map((option) => {
|
||||
{EDITOR_VISIBLE_BGFILTER_SEG_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selectedSegModel.value === option.value;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -707,7 +707,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
sourceLayerId: 'layer-source',
|
||||
tool: 'rect',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
marks: [
|
||||
{
|
||||
@@ -794,7 +794,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
sourceLayerId: 'layer-source',
|
||||
tool: 'rect',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
marks: [
|
||||
{
|
||||
@@ -1977,7 +1977,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'generated/ui-design.png',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
}),
|
||||
);
|
||||
@@ -2093,7 +2093,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
@@ -2106,7 +2106,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图背景色',
|
||||
value: '浅雾蓝 #CFEFFF',
|
||||
value: '自动',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图模型',
|
||||
@@ -2151,7 +2151,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
screenColor: '#CFEFFF',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
referenceImageSrcs: ['generated/ref-style.png'],
|
||||
generationInputs: expect.objectContaining({
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
isCanvasGenerationDialog,
|
||||
normalizeEditorBgFilterSegModel,
|
||||
normalizeVisibleEditorBgFilterSegModel,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
@@ -756,7 +756,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const screenColor = normalizeEditorGenerationBackgroundColor(
|
||||
options.screenColor,
|
||||
);
|
||||
const segModel = normalizeEditorBgFilterSegModel(options.segModel);
|
||||
const segModel = normalizeVisibleEditorBgFilterSegModel(
|
||||
options.segModel,
|
||||
);
|
||||
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
|
||||
sourceLayer,
|
||||
options.references,
|
||||
|
||||
@@ -1757,7 +1757,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
|
||||
|
||||
expect(screen.getByTestId('ui-extraction').textContent).toBe(
|
||||
'layer-source:rect:0:idle:gemini-3.1-flash-image-preview:#CFEFFF:birefnet',
|
||||
'layer-source:rect:0:idle:gemini-3.1-flash-image-preview:auto:birefnet',
|
||||
);
|
||||
expect(screen.getByTestId('ui-extraction-source').textContent).toBe(
|
||||
'layer-source',
|
||||
@@ -1770,7 +1770,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '框选UI素材' }));
|
||||
|
||||
expect(screen.getByTestId('ui-extraction').textContent).toBe(
|
||||
'layer-source:ellipse:1:idle:gpt-image-2:#CFEFFF:birefnet',
|
||||
'layer-source:ellipse:1:idle:gpt-image-2:auto:birefnet',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user