From 81fff0eda5e1c0ddf1a34b89e1e6f6dc43e8c829 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 6 Jul 2026 09:25:45 +0000 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=94=BB=E6=9D=BF=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E6=8A=A0=E5=9B=BE=E8=83=8C=E6=99=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画板生图背景色自动决策模块,并接入角色、图标和 UI 素材提取三条路径。 扩展用户可选背景色到 11 个,并把 auto 作为默认背景色选项。 保留 anime-seg 内部解析能力,但用户界面只展示并提交默认 birefnet。 BgFilter 失败时增加本地纯色背景去背兜底,降低外部服务失败影响。 新增 BGFilter 探活与 gpt-image-2 尺寸生成调试脚本。 同步更新编辑器、生图链路和后端架构相关文档。 --- .../shared-memory/decision-log.md | 20 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 4 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 3 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 8 +- scripts/check-bgfilter-live.mjs | 264 ++++++++++++++ scripts/generate-gpt-image-2-elf-archer.mjs | 266 ++++++++++++++ .../api-server/src/editor_green_screen.rs | 46 ++- .../crates/api-server/src/editor_project.rs | 145 +++++++- .../src/editor_screen_background_decision.rs | 339 ++++++++++++++++++ server-rs/crates/api-server/src/main.rs | 1 + ...CanvasEditorGenerationIntegration.test.tsx | 4 +- ...eCanvasGenerationImageOptionsView.test.tsx | 31 +- .../ImageCanvasGenerationImageOptionsView.tsx | 61 ++-- .../ImageCanvasGenerationModel.test.ts | 37 +- .../ImageCanvasGenerationModel.ts | 40 +++ ...ageCanvasGenerationSubmissionModel.test.ts | 8 +- .../ImageCanvasGenerationSubmissionModel.ts | 6 +- ...anvasUiAssetExtractionOverlayView.test.tsx | 21 +- ...mageCanvasUiAssetExtractionOverlayView.tsx | 56 ++- ...anvasGenerationSubmissionWorkflow.test.tsx | 12 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 6 +- .../useImageCanvasGenerationWorkflow.test.tsx | 4 +- .../useImageCanvasGenerationWorkflow.ts | 4 +- 26 files changed, 1276 insertions(+), 118 deletions(-) create mode 100644 scripts/check-bgfilter-live.mjs create mode 100644 scripts/generate-gpt-image-2-elf-archer.mjs create mode 100644 server-rs/crates/api-server/src/editor_screen_background_decision.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 48c706491..d095a2bf9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。自动解析结果会写入生成输入快照 `抠图背景色`,格式为 `自动 -> <颜色名> `,兜底时追加 `(兜底)`;最终生图 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=` 和 `seg_model=`,`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=` 和 `seg_model=`,当前用户界面只展示并提交默认 `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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9d21575d9..a7382da2d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,8 +20,8 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板额外提供抠图背景色下拉和抠图模型下拉;背景色展示颜色名称、方块色样和 hex 值,默认 `浅雾蓝 #CFEFFF`,可选 `浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`;抠图模型可选 `birefnet` 或 `anime-seg`,默认 `birefnet`。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取都必须把所选 `screenColor` 注入 prompt、请求体、BgFilter `screen_color` multipart 字段和 `generationInputs.fields` 的 `抠图背景色`;后端只接受上述六个 hex,缺省或空值按 `#CFEFFF` 处理,未知值返回 `400`。三条 BgFilter 路径还必须把用户选择的 `segModel` 传为 `seg_model`,只允许 `birefnet` 或 `anime-seg`,默认 `birefnet`;这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS。角色动作抽帧后的序列帧暂不接用户背景色选择,仍沿用 legacy `#00FF00` 绿幕和本地 `editor_green_screen` 透明化,不依赖 BiRefNet 或 BgFilter。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板额外提供抠图背景色下拉和抠图模型下拉;背景色展示颜色名称、方块色样和 hex 值,默认 `自动`,手动可选 `浅雾蓝 #CFEFFF`、`浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`、`浅柠黄 #FFF2A8`、`淡薄荷绿 #CFFFE1`、`浅中性灰 #D8DEE8`、`淡灰紫 #D8D2E8`、`高对比浅青 #A8F7F0`;抠图模型当前仅对用户展示默认 `birefnet`,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取都必须把所选 `screenColor` 注入请求体和 `generationInputs.fields` 的 `抠图背景色`;`screenColor=auto` 时由 api-server 在组装 prompt 前调用背景决策模块,从上述 11 色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;手动未知 hex 返回 `400`。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须把用户可见的默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;若 BgFilter 请求失败、返回非成功状态、空图片或非法图片,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作抽帧后的序列帧暂不接用户背景色选择,仍沿用 legacy `#00FF00` 绿幕和本地 `editor_green_screen` 透明化,不依赖 BiRefNet 或 BgFilter。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 500ab4c7b..996cf0899 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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=` 和 `seg_model=`;`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=` 和 `seg_model=`;用户路径只展示并提交默认 `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/` 时生成失败。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 93bb7f0ac..d4a1e373b 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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`。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index e489a2961..5385bcd39 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -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"` 图标图层继续放到画布。 ## 验收点 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 50bc1adab..5ee227c62 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -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 设计图提取素材仍可复用该响应结构返回切片素材。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 801e85941..a4362e26f 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -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=` 和 `seg_model=`,默认 `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=` 和 `seg_model=`,用户路径默认并只提交 `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//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和所选纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: diff --git a/scripts/check-bgfilter-live.mjs b/scripts/check-bgfilter-live.mjs new file mode 100644 index 000000000..18348ccd1 --- /dev/null +++ b/scripts/check-bgfilter-live.mjs @@ -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); +} diff --git a/scripts/generate-gpt-image-2-elf-archer.mjs b/scripts/generate-gpt-image-2-elf-archer.mjs new file mode 100644 index 000000000..41e6ab8b7 --- /dev/null +++ b/scripts/generate-gpt-image-2-elf-archer.mjs @@ -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}`); diff --git a/server-rs/crates/api-server/src/editor_green_screen.rs b/server-rs/crates/api-server/src/editor_green_screen.rs index 8e2a983b3..586b682d1 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -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()); } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d09ee5263..3ae197edf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, + decision: Option<&EditorScreenBackgroundDecision>, +) -> Option { + 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 { + 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 { 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, 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, 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", diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs new file mode 100644 index 000000000..021988940 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -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, + pub(crate) prompt: String, + pub(crate) icon_descriptions: Vec, + pub(crate) reference_count: usize, +} + +pub(crate) async fn resolve_editor_screen_background_color( + llm_client: Option<&LlmClient>, + input: EditorScreenBackgroundDecisionInput, +) -> Result { + 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 = 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::>() + .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 { + let trimmed = content.trim(); + let json_source = extract_json_object(trimmed).unwrap_or(trimmed); + if let Ok(value) = serde_json::from_str::(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 { + 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() + ); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 93e18e9d1..e6d125d93 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 03b959b3a..f4af6ea26 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -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: '抠图模型', diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 27859db12..7770866ea 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -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( { }), ); 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', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index aa5e6a608..a8e05b0dc 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -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' ? ( +