新抠图算法(轻量版)上线 #73
@@ -9,7 +9,9 @@
|
||||
"Bash(findstr LISTENING)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(lsof -ti:8081)",
|
||||
"Bash(curl -s http://localhost:8081/health)"
|
||||
"Bash(curl -s http://localhost:8081/health)",
|
||||
"Skill(code-review)",
|
||||
"Skill(code-review:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,38 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
|
||||
|
||||
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
|
||||
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色` 或 `抠图模型`。api-server 在 11 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容,角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
|
||||
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、BgFilter 服务入参、图片画布 MVP 和角色形象生成设计文档。
|
||||
- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-07-05 图片画布抠图背景色自动决策
|
||||
|
||||
- 背景:手动背景色选择对用户负担较高,且不同角色、图标和 UI 素材主题需要避开不同主体色;但 BgFilter 和生成 prompt 仍必须拿到明确的纯色 hex。
|
||||
- 决策:前端用户路径直接固定通过 `screenColor=auto` 提交,不再展示背景色选项;api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 11 个候选色中选择具体 hex,最多重试 3 次,LLM 未配置、请求失败或返回非法颜色时 fallback 到 `浅雾蓝 #CFEFFF`。自动解析结果不写入用户可见生成输入快照;最终生图 prompt 和 BgFilter `screen_color` 永远只接收具体 hex,不透传 `auto`。
|
||||
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、api-server LLM 调用、BgFilter 参数、图片画布文档。
|
||||
- 验证方式:运行背景决策模块单测、画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_screen_background_decision editor_green_screen --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-07-05 BgFilter 失败时本地纯色去背兜底
|
||||
|
||||
- 背景:曾用一次性 BgFilter live probe 稳定复现 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 运维排障、图片画布生成后处理。
|
||||
- 验证方式:运行 `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` 路径保留为后端可识别的内部能力但不对用户可见。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`。
|
||||
|
||||
## 2026-07-03 作品公开默认关闭
|
||||
|
||||
- 背景:作品发布完成不应默认进入公开广场 / 公开详情 / 公开互动消费路径,需要先由后台可见性开关明确开启。
|
||||
@@ -196,7 +228,7 @@
|
||||
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
|
||||
|
||||
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留扣绿后的 spritesheet 图集,不再额外铺独立图标。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留透明 spritesheet 图集,不再额外铺独立图标。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
|
||||
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -218,6 +218,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>` 和 `seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。
|
||||
- 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/` 时生成失败。
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 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,6 +28,7 @@
|
||||
- 支持自定义画面比例和大小尺寸。
|
||||
- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。
|
||||
- 默认画面比例为 `16:9`,默认大小为 `1K`。
|
||||
- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
|
||||
## 提示词契约
|
||||
|
||||
@@ -57,10 +58,10 @@
|
||||
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
|
||||
|
||||
```text
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
|
||||
|
||||
## 验收点
|
||||
@@ -70,5 +71,5 @@
|
||||
- 从画布选择时只能绑定图标规范图片。
|
||||
- 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。
|
||||
- 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入`、`图标规范` 与普通参考图。
|
||||
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。
|
||||
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`,画布同时出现透明 spritesheet 图集和拆分后的独立素材。
|
||||
- UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。
|
||||
|
||||
@@ -47,17 +47,18 @@
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。
|
||||
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- 不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`。
|
||||
- Prompt 固定为:
|
||||
|
||||
```text
|
||||
参考图1的图标规范,背景必须是单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:
|
||||
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:
|
||||
|
||||
<素材描述按中文顿号拼接>
|
||||
```
|
||||
|
||||
## 去背与保存
|
||||
|
||||
- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,底层复用 `platform-image::generated_asset_sheets`。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
|
||||
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
- 上传后的每张常规参考图以缩略图展示。
|
||||
- 每张常规参考图右下角显示大号序号,从 `1` 开始递增。
|
||||
3. 唯一文本框为 `角色设定`。
|
||||
4. 左下角展示画面比例和大小选择按钮。
|
||||
4. 左下角只展示画面比例和大小,不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。
|
||||
5. 右下角展示模型选择和生成按钮。
|
||||
|
||||
## 普通生成面板视觉口径
|
||||
@@ -49,9 +49,10 @@
|
||||
- 角色规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
|
||||
1. 角色规范图。
|
||||
2. 常规参考图列表。
|
||||
- 请求同时提交 `model`、`aspectRatio` 和 `imageSize`:
|
||||
- 请求同时提交 `model`、`screenColor`、`segModel`、`aspectRatio` 和 `imageSize`:
|
||||
- `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- 前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
- 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。
|
||||
- 尺寸选项来源以 VectorEngine 接入文档为准:
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。
|
||||
@@ -60,11 +61,11 @@
|
||||
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
|
||||
|
||||
```text
|
||||
基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯色背景 <颜色名> <hex> / RGB(<r>,<g>,<b>),只作为抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与背景色相同或相近的描边、投影或反光;禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
角色设定:<用户输入的角色设定>
|
||||
```
|
||||
|
||||
- 角色图生成完成后,编辑器后端必须先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。角色图 prompt 固定要求标准 `#00FF00 / RGB(0,255,0)` 绿幕;后端路径仍兼容生成模型把标准绿幕压成暗绿 / 灰绿背景的情况,但这类宽松识别只用于从画布边缘连通扩散出的背景,不作为全图断开绿色区域删除依据。该流程不再调用 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"`。
|
||||
|
||||
## 生成规范参考图
|
||||
@@ -102,7 +103,7 @@
|
||||
- `角色规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
|
||||
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
|
||||
- 上传常规参考图后缩略图右下角显示序号。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`aspectRatio` 和 `imageSize`。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。
|
||||
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
|
||||
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
|
||||
|
||||
@@ -113,7 +114,7 @@
|
||||
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。
|
||||
- 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。
|
||||
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `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` 只退出角色规范画布点选状态,不关闭角色生成面板。
|
||||
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
|
||||
- 本次验证命令:
|
||||
@@ -161,7 +162,7 @@
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须先把带绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。
|
||||
- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图的 `screenColor` 选择。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
|
||||
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
|
||||
|
||||
@@ -61,7 +61,8 @@ use crate::{
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
},
|
||||
editor_green_screen::{
|
||||
editor_green_screen_character_prompt_clause,
|
||||
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
|
||||
legacy_editor_green_screen_background_color,
|
||||
remove_editor_generated_green_screen_background,
|
||||
},
|
||||
editor_project::{
|
||||
@@ -2153,11 +2154,14 @@ fn remove_editor_character_animation_frame_backgrounds(
|
||||
) -> Result<Vec<FinalizedAnimationFrame>, AppError> {
|
||||
let mut removed_frames = Vec::with_capacity(frames.len());
|
||||
for frame in frames {
|
||||
let removed = remove_editor_generated_green_screen_background(&DownloadedOpenAiImage {
|
||||
bytes: frame.bytes,
|
||||
mime_type: frame.mime_type,
|
||||
extension: frame.extension,
|
||||
})?;
|
||||
let removed = remove_editor_generated_green_screen_background(
|
||||
&DownloadedOpenAiImage {
|
||||
bytes: frame.bytes,
|
||||
mime_type: frame.mime_type,
|
||||
extension: frame.extension,
|
||||
},
|
||||
legacy_editor_green_screen_background_color(),
|
||||
)?;
|
||||
removed_frames.push(finalize_animation_frame_payload(
|
||||
removed.bytes.as_slice(),
|
||||
removed.mime_type.as_str(),
|
||||
@@ -3384,7 +3388,7 @@ fn editor_video_bad_request(message: impl Into<String>) -> AppError {
|
||||
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
|
||||
format!(
|
||||
"生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:\n{}",
|
||||
editor_green_screen_character_prompt_clause(),
|
||||
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
|
||||
prompt_text.trim()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS: u64 = 2;
|
||||
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
|
||||
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82";
|
||||
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000;
|
||||
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
|
||||
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -56,6 +58,9 @@ pub struct AppConfig {
|
||||
pub editor_background_removal_base_url: String,
|
||||
pub editor_background_removal_token: Option<String>,
|
||||
pub editor_background_removal_request_timeout_ms: u64,
|
||||
pub editor_bgfilter_base_url: String,
|
||||
pub editor_bgfilter_token: Option<String>,
|
||||
pub editor_bgfilter_request_timeout_ms: u64,
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub log_filter: String,
|
||||
pub otel_enabled: bool,
|
||||
@@ -284,6 +289,9 @@ impl Default for AppConfig {
|
||||
editor_background_removal_token: None,
|
||||
editor_background_removal_request_timeout_ms:
|
||||
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS,
|
||||
editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(),
|
||||
editor_bgfilter_token: None,
|
||||
editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
|
||||
image_editor_agent_sidebar_enabled: false,
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
otel_enabled: false,
|
||||
@@ -475,6 +483,19 @@ impl AppConfig {
|
||||
]) {
|
||||
config.editor_background_removal_request_timeout_ms = timeout_ms;
|
||||
}
|
||||
if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_EDITOR_BGFILTER_BASE_URL"])
|
||||
{
|
||||
config.editor_bgfilter_base_url = base_url;
|
||||
}
|
||||
config.editor_bgfilter_token = read_first_non_empty_env(&[
|
||||
"GENARRATIVE_EDITOR_BGFILTER_TOKEN",
|
||||
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN",
|
||||
]);
|
||||
if let Some(timeout_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
config.editor_bgfilter_request_timeout_ms = timeout_ms;
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
|
||||
{
|
||||
@@ -1481,9 +1502,10 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
mod tests {
|
||||
use super::{
|
||||
AppConfig, DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL,
|
||||
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS,
|
||||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode, LlmProvider,
|
||||
ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
||||
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS, DEFAULT_EDITOR_BGFILTER_BASE_URL,
|
||||
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool,
|
||||
parse_external_generation_mode, parse_process_role,
|
||||
};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -1506,6 +1528,15 @@ mod tests {
|
||||
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
assert!(config.editor_background_removal_token.is_none());
|
||||
assert_eq!(
|
||||
config.editor_bgfilter_base_url,
|
||||
DEFAULT_EDITOR_BGFILTER_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
config.editor_bgfilter_request_timeout_ms,
|
||||
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
assert!(config.editor_bgfilter_token.is_none());
|
||||
assert!(config.ark_character_video_base_url.is_empty());
|
||||
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
|
||||
assert!(config.ark_character_video_model.is_empty());
|
||||
@@ -2183,6 +2214,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_editor_bgfilter_settings_and_reuses_background_token() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_EDITOR_BGFILTER_BASE_URL",
|
||||
"http://10.0.0.12/bgfilter",
|
||||
);
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN",
|
||||
"shared-token",
|
||||
);
|
||||
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS", "180000");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert_eq!(config.editor_bgfilter_base_url, "http://10.0.0.12/bgfilter");
|
||||
assert_eq!(
|
||||
config.editor_bgfilter_token.as_deref(),
|
||||
Some("shared-token")
|
||||
);
|
||||
assert_eq!(config.editor_bgfilter_request_timeout_ms, 180_000);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN", "bgfilter-token");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert_eq!(
|
||||
config.editor_bgfilter_token.as_deref(),
|
||||
Some("bgfilter-token")
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_image_editor_agent_sidebar_runtime_flag() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -1709,6 +1709,8 @@ async fn execute_editor_agent_tool_call(
|
||||
size: None,
|
||||
kind,
|
||||
model: tool_call.model.clone(),
|
||||
screen_color: Some("auto".to_string()),
|
||||
seg_model: Some("birefnet".to_string()),
|
||||
aspect_ratio: tool_call.aspect_ratio.clone(),
|
||||
image_size: tool_call.image_size.clone(),
|
||||
reference_image_srcs: Some(tool_reference_sources.clone()),
|
||||
@@ -1785,6 +1787,8 @@ async fn execute_editor_agent_tool_call(
|
||||
reference_image_srcs: Some(extra_references),
|
||||
icon_descriptions,
|
||||
model: tool_call.model.clone(),
|
||||
screen_color: Some("auto".to_string()),
|
||||
seg_model: Some("birefnet".to_string()),
|
||||
aspect_ratio: tool_call.aspect_ratio.clone(),
|
||||
image_size: tool_call.image_size.clone(),
|
||||
project_id: Some(conversation.project_id.clone()),
|
||||
|
||||
@@ -1,42 +1,184 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::generated_asset_sheets::{
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError,
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
remove_generated_asset_sheet_green_screen_background_bytes,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, openai_image_generation::DownloadedOpenAiImage};
|
||||
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_COLOR_PROMPT: &str = "单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS: &str =
|
||||
"绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
|
||||
"素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
|
||||
"角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct EditorScreenBackgroundColor {
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) hex: &'static str,
|
||||
pub(crate) red: u8,
|
||||
pub(crate) green: u8,
|
||||
pub(crate) blue: u8,
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause() -> String {
|
||||
pub(crate) const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 11] = [
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅雾蓝",
|
||||
hex: "#CFEFFF",
|
||||
red: 207,
|
||||
green: 239,
|
||||
blue: 255,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅钢蓝",
|
||||
hex: "#B0C2E0",
|
||||
red: 176,
|
||||
green: 194,
|
||||
blue: 224,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "暖浅桃色",
|
||||
hex: "#FFD6C2",
|
||||
red: 255,
|
||||
green: 214,
|
||||
blue: 194,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "淡薰衣草紫",
|
||||
hex: "#E6D8FF",
|
||||
red: 230,
|
||||
green: 216,
|
||||
blue: 255,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅粉灰",
|
||||
hex: "#F4D8E8",
|
||||
red: 244,
|
||||
green: 216,
|
||||
blue: 232,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "中度天蓝",
|
||||
hex: "#7FB3FF",
|
||||
red: 127,
|
||||
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 {
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS[0]
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_editor_green_screen_background_color() -> EditorScreenBackgroundColor {
|
||||
EditorScreenBackgroundColor {
|
||||
label: "纯绿色",
|
||||
hex: "#00FF00",
|
||||
red: 0,
|
||||
green: 255,
|
||||
blue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_editor_screen_background_color(
|
||||
value: Option<&str>,
|
||||
) -> Result<EditorScreenBackgroundColor, AppError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(default_editor_screen_background_color());
|
||||
};
|
||||
let normalized_value = value.to_ascii_uppercase();
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|option| option.hex == normalized_value)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-screen-background",
|
||||
"message": "screenColor 必须是编辑器支持的纯色背景色。",
|
||||
"allowed": EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.map(|option| option.hex)
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS: &str =
|
||||
"纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具";
|
||||
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) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
|
||||
fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> String {
|
||||
if color.hex == "#00FF00" {
|
||||
return "单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕".to_string();
|
||||
}
|
||||
format!(
|
||||
"单一纯色背景 {} {} / RGB({},{},{})",
|
||||
color.label, color.hex, color.red, color.green, color.blue
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景必须是{},且{},方便扣除背景;{}",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause() -> String {
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景固定为{},只作为抠像底色;{};{}",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_ui_design_asset_extraction_prompt() -> String {
|
||||
pub(crate) fn editor_ui_design_asset_extraction_prompt(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用{}。{},方便后续扣除背景;{}。",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
@@ -44,14 +186,22 @@ pub(crate) fn editor_ui_design_asset_extraction_prompt() -> String {
|
||||
|
||||
pub(crate) fn remove_editor_generated_green_screen_background(
|
||||
image: &DownloadedOpenAiImage,
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let key_color = GeneratedAssetSheetKeyColor {
|
||||
red: color.red,
|
||||
green: color.green,
|
||||
blue: color.blue,
|
||||
};
|
||||
let bytes = remove_generated_asset_sheet_green_screen_background_bytes(
|
||||
image.bytes.as_slice(),
|
||||
GeneratedAssetSheetAlphaOptions {
|
||||
remove_muted_green_screen_background: true,
|
||||
key_color,
|
||||
remove_near_white_background: key_color.is_green_screen(),
|
||||
remove_disconnected_hard_key_background: true,
|
||||
remove_muted_green_screen_background: key_color.is_green_screen(),
|
||||
detect_internal_holes: true,
|
||||
internal_hole_min_pixels: 16,
|
||||
..GeneratedAssetSheetAlphaOptions::green_screen()
|
||||
},
|
||||
)
|
||||
.map_err(map_editor_green_screen_error)?;
|
||||
@@ -85,20 +235,23 @@ mod tests {
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
|
||||
#[test]
|
||||
fn editor_green_screen_postprocess_turns_background_transparent() {
|
||||
let mut source = RgbaImage::from_pixel(3, 3, Rgba([0, 255, 0, 255]));
|
||||
fn editor_green_screen_postprocess_turns_selected_background_transparent() {
|
||||
let mut source = RgbaImage::from_pixel(3, 3, Rgba([207, 239, 255, 255]));
|
||||
source.put_pixel(1, 1, Rgba([240, 40, 40, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
DynamicImage::ImageRgba8(source)
|
||||
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
|
||||
.expect("test PNG should encode");
|
||||
|
||||
let output = remove_editor_generated_green_screen_background(&DownloadedOpenAiImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("green screen should be removed");
|
||||
let output = remove_editor_generated_green_screen_background(
|
||||
&DownloadedOpenAiImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
},
|
||||
default_editor_screen_background_color(),
|
||||
)
|
||||
.expect("screen background should be removed");
|
||||
let image = image::load_from_memory(output.bytes.as_slice())
|
||||
.expect("output PNG should decode")
|
||||
.to_rgba8();
|
||||
@@ -106,4 +259,25 @@ mod tests {
|
||||
assert_eq!(image.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(image.get_pixel(1, 1).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_screen_background_color_parser_uses_default_and_rejects_unknown() {
|
||||
assert_eq!(
|
||||
parse_editor_screen_background_color(None).expect("default should parse"),
|
||||
default_editor_screen_background_color()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_editor_screen_background_color(Some("#ffd6c2"))
|
||||
.expect("known lowercase hex should parse")
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -481,7 +481,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
{
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
},
|
||||
{ title: 'model', value: 'gpt-image-2' },
|
||||
],
|
||||
@@ -496,13 +496,17 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
const characterDraft = createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '红发骑士' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
sourceLayer: createLayer({
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '角色设定', value: '红发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
{ title: '抠图模型', value: '动漫风格 anime-seg' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
canvasSize,
|
||||
viewport,
|
||||
mode: 'redraw',
|
||||
|
||||
@@ -738,6 +738,7 @@ function restoreSharedImageOptions(
|
||||
sourceLayer: CanvasLayer,
|
||||
sourceDialog?: CanvasGenerationDialogState | null,
|
||||
): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const fields = getGenerationInputFieldValues(sourceLayer);
|
||||
return {
|
||||
...draft,
|
||||
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
|
||||
onRememberImageModel = vi.fn(),
|
||||
lockedModel,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
lockedModel?: string;
|
||||
}) {
|
||||
|
||||
@@ -250,7 +250,9 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
fields: [{ title: '角色设定', value: '主角骑士' }],
|
||||
fields: [
|
||||
{ title: '角色设定', value: '主角骑士' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
@@ -313,7 +315,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
{
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
|
||||
@@ -97,8 +97,10 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
|
||||
'原图按钮',
|
||||
'冻结按钮',
|
||||
];
|
||||
export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR = 'auto';
|
||||
export const DEFAULT_EDITOR_BGFILTER_SEG_MODEL = 'birefnet';
|
||||
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。';
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。';
|
||||
export const QUICK_EDIT_SIZE_PRESETS = [
|
||||
'1024x1024',
|
||||
'1536x1024',
|
||||
@@ -1203,10 +1205,12 @@ export function buildUiDesignAssetExtractionGenerationInputs(
|
||||
references?: CharacterReferenceImage[],
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField(
|
||||
'提取提示词',
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
),
|
||||
fields: [
|
||||
...createGenerationInputField(
|
||||
'提取提示词',
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
),
|
||||
],
|
||||
references: [
|
||||
...createLayerGenerationInputReference('UI设计图', sourceLayer),
|
||||
...(references ?? []).flatMap((reference, index) =>
|
||||
|
||||
@@ -301,6 +301,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: '白发骑士',
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
referenceImageSrcs: [
|
||||
@@ -316,7 +318,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||||
{
|
||||
fields: [{ title: '角色设定', value: '白发骑士' }],
|
||||
fields: [
|
||||
{ title: '角色设定', value: '白发骑士' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
@@ -665,6 +669,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
},
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
buildUiDesignGenerationInputs,
|
||||
buildVideoGenerationInputs,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
@@ -347,6 +348,8 @@ export function buildImageGenerationSubmissionPlan({
|
||||
),
|
||||
].filter((src): src is string => Boolean(src));
|
||||
const imageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
|
||||
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
@@ -354,6 +357,8 @@ export function buildImageGenerationSubmissionPlan({
|
||||
prompt: normalizedPrompt,
|
||||
kind: 'character',
|
||||
model: imageModel,
|
||||
screenColor,
|
||||
segModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
@@ -569,11 +574,14 @@ export function buildImageGenerationSubmissionPlan({
|
||||
export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
dialog: GenerateDialogState,
|
||||
): IconSpritesheetGenerationSubmissionPlan {
|
||||
const iconDescriptions = (
|
||||
dialog.prompt.trim()
|
||||
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
|
||||
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
|
||||
)
|
||||
const iconDescriptionSource = dialog.prompt.trim()
|
||||
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
|
||||
: (dialog.iconDescriptions ?? []).some((description) =>
|
||||
description.trim(),
|
||||
)
|
||||
? (dialog.iconDescriptions ?? [])
|
||||
: [];
|
||||
const iconDescriptions = iconDescriptionSource
|
||||
.map((description) => description.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, ICON_DESCRIPTION_LIMIT);
|
||||
@@ -593,6 +601,8 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
}
|
||||
|
||||
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
|
||||
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
|
||||
return {
|
||||
ok: true,
|
||||
iconDescriptions,
|
||||
@@ -609,6 +619,8 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
: {}),
|
||||
iconDescriptions,
|
||||
model: rememberImageModel,
|
||||
screenColor,
|
||||
segModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
|
||||
@@ -48,7 +48,10 @@ export type UiAssetExtractionImageSize =
|
||||
|
||||
export function createUiAssetExtractionState(
|
||||
sourceLayerId: string,
|
||||
options: { initialTool?: UiAssetExtractionTool | null; model: string },
|
||||
options: {
|
||||
initialTool?: UiAssetExtractionTool | null;
|
||||
model: string;
|
||||
},
|
||||
): UiAssetExtractionState {
|
||||
return {
|
||||
sourceLayerId,
|
||||
|
||||
@@ -172,6 +172,12 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '提取素材模型 nanobanana2' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: /提取素材背景色/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: /提取素材抠图模型/u }),
|
||||
).toBeNull();
|
||||
expect(within(panel).getByLabelText('参考UI.png')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '上传UI素材参考图' }),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user