动作视频/生图背景色链路升级 + 阿里云抠图接入 #75

Merged
kdletters merged 48 commits from feat/video-BGfilter into master 2026-07-10 18:22:17 +08:00
41 changed files with 4313 additions and 1243 deletions
+9 -1
View File
@@ -32,9 +32,17 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
# BgFilter 失败直接本地 editor_green_screen 去背;填入后恢复 BgFilter→阿里云→本地三级兜底。
# AccessKey 也可复用标准 SDK 命名 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET。
GENARRATIVE_ALIYUN_MATTING_ENABLED=true
GENARRATIVE_ALIYUN_MATTING_ENDPOINT=imageseg.cn-shanghai.aliyuncs.com
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api
+9 -1
View File
@@ -32,9 +32,17 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
# BgFilter 失败直接本地 editor_green_screen 去背;填入后恢复 BgFilter→阿里云→本地三级兜底。
# AccessKey 也可复用标准 SDK 命名 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET。
GENARRATIVE_ALIYUN_MATTING_ENABLED=true
GENARRATIVE_ALIYUN_MATTING_ENDPOINT=imageseg.cn-shanghai.aliyuncs.com
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
@@ -2508,6 +2508,13 @@
"type": "string",
"minLength": 1
},
"screenColor": {
"type": [
"string",
"null"
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"size": {
"type": "string",
"description": "兼容旧 size 入参;未传 aspectRatio/imageSize 时生效。",
@@ -2872,6 +2879,13 @@
"type": "string"
}
},
"screenColor": {
"type": [
"string",
"null"
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"model": {
"type": "string",
"default": "gemini-3.1-flash-image-preview"
@@ -2937,6 +2951,13 @@
"type": "string",
"description": "UI 设计图 Data URL 或 objectKey。"
},
"screenColor": {
"type": [
"string",
"null"
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"model": {
"type": "string",
"default": "gemini-3.1-flash-image-preview",
@@ -3163,6 +3184,13 @@
"minLength": 1,
"maxLength": 4000
},
"screenColor": {
"type": [
"string",
"null"
],
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
},
"resolution": {
"type": "string",
"enum": [
@@ -16,6 +16,30 @@
---
## 2026-07-10 BgFilter segModel 保留内部字段,不进入外部 OpenAPI
- 背景:`api-server` 的图片生成、图标 spritesheet 与 UI 素材提取请求仍可反序列化 `segModel`,并识别 `birefnet` / `anime-seg`,以兼容内部调用和既有任务;但 BgFilter 当前受服务进程内存与并发容量约束,不同分割模型的内存占用并非可由外部调用方自由选择的稳定契约。
- 决策:`segModel` 是有效的**内部**字段,不是用户可配置字段。产品 UI 不提供抠图模型选择,应用内调用固定使用 `birefnet`;外部编辑器 OpenAPI 刻意不声明 `segModel`,并通过请求 schema 的 `additionalProperties: false` 拒绝该字段。外部调用方应省略它并使用服务端默认值;只有维护 BgFilter 容量与模型策略的后端代码可在经过内存 / 并发验证后调整内部固定值或兼容策略。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs``src/services/image-editor/editorProjectClient.ts`、图片画布提交模型、`docs/openapi/genarrative-external-v1.openapi.json`
- 验证方式:确认外部 OpenAPI 三个生成请求 schema 均未公开 `segModel` 且保持 `additionalProperties: false`;运行 `npm run check:encoding``git diff --check`
- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`(BgFilter 模型字段的对外暴露边界)。
## 2026-07-10 背景色决策统一走 gpt-5-mini 并挪到预扣泥点之后
- 背景:背景色决策此前无源图路径继承 `state.llm_client()`(Ark/豆包,选色能力弱、线上从未真正调用 VectorEngine);且四条生成链路(角色生图 / 角色动作生视频 / 图标 spritesheet / UI 设计图提取)都在 `execute_billable_asset_operation_with_cost` 预扣泥点之前发起决策,导致用户余额不足或生成注定失败时仍白发一次 gpt-5-mini 决策、平台白付 token,也与定价文档「预扣失败不得继续调用上游」的原则相悖。
- 决策:(1)无源图决策改走独立常量 `EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL = gpt-5-mini`VectorEngineResponses 协议 + `reasoning_effort=low`),与有源图视觉档 `EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL` 分离、便于各自调参;gpt5 客户端未配置时才降级回默认文本客户端。(2)**所有用到背景色决策的生成链路,决策必须在余额校验 + 预扣泥点之后发起**:预扣前只做颜色无关的算价 / 校验 / settings(动画用默认色占位算价,与队列路径一致),决策及颜色相关的 prompt / 合成 / `generationInputs` 搬进 billable 闭包,闭包把决策结果带出供后续抠图与落库使用。语义:余额不足则决策不跑(平台零成本);决策失败则闭包返 `Err` 走失败退款(用户不损失泥点)。后续新增任何用背景色的生成链路都必须遵循此顺序。
- 影响范围:`server-rs/crates/api-server/src/llm_model_routing.rs``editor_screen_background_decision.rs``editor_project.rs`(生图 / 图标 / UI 提取三条 `_for_owner`)、`character_animation_assets.rs`(动画 `_for_owner`);四条链路的 worker / inline / agent / external-API 入口同时覆盖。
- 验证方式:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``editor_screen_background_decision::tests::live_*``-- --ignored`,需真实 `VECTOR_ENGINE_*`)验证有图 / 无图两条路径真实调用 VectorEngine。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`(预扣泥点原则)、`docs/project-memory/shared-memory/pitfalls.md`gpt-5-mini 图片输入上限实测)。
## 2026-07-09 角色动作视频生成背景色统一为多色自动决策 + 阿里云抠帧
- 背景:角色动作视频抽帧过去固定 legacy `#00FF00` 绿幕 + 本地 `editor_green_screen`,与生图链路的多色自动决策不一致;实测出现背景色与前景 / 皮肤撞色(蓝撞蓝、桃 / 黄撞肤色)以及图生视频背景变白的问题。
- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、`reasoning_effort=low``max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。
- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs``editor_screen_background_decision.rs``editor_screen_background_filter.rs`(新增硬过滤模块)、`editor_green_screen.rs`(调色板)、`external_api_audit.rs``llm_model_routing.rs`、图片画布 MVP 与后端数据契约文档。
- 验证方式:`cargo test -p api-server editor_screen_background character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、真机对源角色图跑视觉决策与候选危险度表、抽帧后采样序列帧背景色确认落在冷区安全集。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-09 充值订单过期改为 SpacetimeDB scheduled 表触发
- 背景:旧充值过期处理使用 api-server 后台轮询 worker claim 普通 schedule 表,非 HTTP 的 external-generation-worker / controller 进程也可能启动同一过期任务;扩外部生成 worker 会意外放大微信查单 / 关单流量,并且本地过期后若微信仍可支付,容易出现“微信扣款但本地拒绝入账”的风险。
@@ -35,7 +59,7 @@
## 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` 绿幕
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色``抠图模型`。api-server 在 12 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容角色动作背景色和抠帧口径已由 2026-07-09 决策取代
- 影响范围:`/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`
@@ -43,7 +67,7 @@
## 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`
- 决策:前端用户路径直接固定通过 `screenColor=auto` 提交,不再展示背景色选项;api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 12 个候选色中选择具体 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`
@@ -52,6 +76,7 @@
- 背景:曾用一次性 BgFilter live probe 稳定复现 BgFilter 对 2K 输入返回 `HTTP 500 {"detail":"inference failed"}`,浏览器生成链路会因此收到“BgFilter 服务返回非成功状态”。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取仍优先调用独立 BgFilter;若 BgFilter 请求失败、返回非成功状态、返回空图片或非法图片,api-server 记录 warning 后使用本地 `editor_green_screen` 按解析后的纯色背景执行确定性去背兜底,不中断生成。手动任意图片去背景仍只走独立 BiRefNet BFF,不使用该兜底。
- 更正(截至 2026-07-10 实现):BgFilter 失败/熔断后不再直接本地兜底,而是先走阿里云通用抠图,仅阿里云也失败才本地 `editor_green_screen` 键色兜底;口径统一见 2026-07-09「角色动作视频…阿里云抠帧」决策,并已扩展到本条的角色形象/图标/UI 三条静态生图链路。
- 影响范围:`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`
@@ -59,7 +84,7 @@
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color``seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file``screen_color=<screenColor>``seg_model=<segModel>`,前端用户路径固定提交 `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`,默认请求超时 `45000ms`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` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作抽帧仍保留 legacy `#00FF00`本地 `editor_green_screen` 透明化
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file``screen_color=<screenColor>`内部固定的 `seg_model=birefnet``segModel` 虽是后端可识别的内部兼容字段(另保留 `anime-seg`),但不向用户或外部 OpenAPI 暴露:当前 BgFilter 的内存与并发容量不适合由调用方自由切换模型。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `180000ms`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` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作背景色和抠帧口径已由 2026-07-09 决策取代:角色动作同样使用多色自动决策,抽帧后优先阿里云通用抠图,失败再按选定背景色本地兜底。更正(截至 2026-07-10 实现):BgFilter 失败与熔断期本条描述的「直接本地兜底」已过时——角色形象/图标/UI 三条静态生图链路同样先走阿里云通用抠图,仅阿里云也失败才本地 `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`
@@ -2210,7 +2235,7 @@
## 2026-05-07 server-rs Cargo 依赖集中到 workspace
- 背景:`server-rs` 多 crate 已稳定成 DDD workspace,成员 `Cargo.toml` 中重复散写第三方版本和本地 path 依赖,升级 SpacetimeDB SDK、`serde``reqwest``tokio` 等依赖时容易漂移。
- 决策:`server-rs/Cargo.toml``[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;不再新增 `sha1`OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。
- 决策:`server-rs/Cargo.toml``[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。例外:`platform-matting` 向 VIAPI 官方临时 OSS 桶上传抠图输入时,因该临时桶接口要求 OSS V1 头签名,允许在该 crate 内部受限使用 `sha1` / `Hmac<sha1::Sha1>` 生成 V1 签名;该例外不得扩展到自有 OSS、通用阿里云 OpenAPI 或其它新链路。
- 影响范围:`server-rs/Cargo.toml`、所有 `server-rs/crates/*/Cargo.toml``platform-oss``platform-auth`、后续新增 Rust crate 或新增 Rust 依赖的开发流程。
- 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。
- 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`
@@ -2895,3 +2895,19 @@
- 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false``rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。
- 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`
## 背景色决策喂 gpt-5-mini 的图不必按阿里云抠图那样归一化
- 现象:担心带图背景色决策把源角色图原样 base64 塞给 gpt-5-mini`resolve_media_source_as_data_url` 不做 resize / 字节上限),会像阿里云通用抠图那样因超尺寸 / 超体积被上游拒绝,于是想给决策链路也补一套图片归一化。
- 原因:两条链路的上游限制完全不同。阿里云 SegmentCommonImage 有硬限制(≤3MB、分辨率 <2000×2000、最长边 ≤1999),必须归一化;而 gpt-5-mini(经 VectorEngine `/v1/responses`Responses 协议 + `input_image`)对图片输入宽松得多,实测远超 App 真实源图范围仍全部 HTTP 200:纯色图到 5000×5000(隔离像素维度)正常识别;噪声图到 base64 请求体 34MB(隔离字节维度,PNG 25.8MB)仍成功返回。App 真实源角色图一般 ≤2048px、几 MB,稳落在安全区。
- 处理:**不要**给 `resolve_editor_screen_background_color` 的带图路径加图片归一化——那是阿里云抠图链路(`platform-matting`)专属需求,两者别混。真要加保护也应放在字节 / 像素远高于当前实测通过档(如 base64 >40MB 或长边 >6000px)才截断,避免无谓重编码开销与画质损失。
- 验证:探针脚本 `Myscripts/probe_gpt5mini_image_limits.py`(本地不入库,逐级放大纯色 / 噪声图打 `/v1/responses`,记录 HTTP 状态与响应)。2026-07-10 实测:solid 512²~5000² 全 200noise 900²(4.1MB)~2600²(34.4MB) 全 200,无拒绝阈值出现在实用范围内。
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs``resolve_media_source_as_data_url`)、`server-rs/crates/api-server/src/editor_screen_background_decision.rs``server-rs/crates/platform-matting/src/lib.rs`(对照:阿里云输入归一化)。
## 不要把 BgFilter segModel 暴露为外部可选参数
- 现象:看到 `EditorImageGenerationRequest``EditorIconSpritesheetGenerationRequest``EditorUiDesignAssetExtractionRequest` 能反序列化 `segModel`,容易认为外部 OpenAPI 也应公开该字段,或让用户在 `birefnet``anime-seg` 间自行选择。
- 原因:`segModel` 是 BgFilter 内部调用链的有效兼容字段,不等于稳定的外部产品契约。当前 BgFilter 服务受进程内存和并发容量约束,不同分割模型的资源消耗不能交给外部调用方控制;任意开放模型切换会让容量规划、超时和故障隔离失去确定性。
- 处理:产品 UI 不提供模型选择,应用内调用固定 `birefnet`;外部编辑器 OpenAPI 不声明 `segModel`,并保持相关请求 schema 的 `additionalProperties: false`,使外部请求携带该字段时被契约拒绝。只有维护 BgFilter 模型与容量的后端代码可使用该内部字段;若未来需要开放,先完成各模型的内存、并发和超时压测,再明确版本化外部契约。
- 验证:检查 `docs/openapi/genarrative-external-v1.openapi.json` 的图片生成、图标 spritesheet 和 UI 素材提取请求 schema 均未包含 `segModel`,且均保持 `additionalProperties: false`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/services/image-editor/editorProjectClient.ts``docs/project-memory/shared-memory/decision-log.md`
File diff suppressed because one or more lines are too long
@@ -22,7 +22,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
- HTTP 服务:`api-server`
- 领域模块:`module-ai``module-assets``module-auth``module-bark-battle``module-big-fish``module-combat``module-creative-agent``module-editor-agent``module-custom-world``module-inventory``module-match3d``module-npc``module-progression``module-puzzle``module-quest``module-runtime``module-runtime-item``module-runtime-story``module-square-hole``module-story``module-visual-novel`
- 平台副作用:`platform-agent``platform-auth``platform-image``platform-llm``platform-oss``platform-wechat``platform-speech`
- 平台副作用:`platform-agent``platform-auth``platform-image``platform-llm``platform-matting``platform-oss``platform-wechat``platform-speech`
- 共享层:`shared-contracts``shared-kernel``shared-logging`
- SpacetimeDB`spacetime-client``spacetime-module`
- 测试支撑:`tests-support`
@@ -226,7 +226,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`,默认请求超时为 `45000ms`token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`后,会`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接使用本地 `editor_green_screen` 兜底,避免上游故障占住 worker。角色动作抽帧仍沿用 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`,默认请求超时为 `180000ms`BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时)token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3``GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED``GENARRATIVE_ALIYUN_MATTING_ENDPOINT``GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID``GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET``GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -234,7 +234,7 @@ npm run check:server-rs-ddd
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d``api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation``object_key` / `key_prefix``status_class``error_kind``elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;编辑器 `screenColor=auto` 的 gpt-5-mini 背景色决策同样必须审计每次已发出的 LLM 调用失败,包括传输 / 超时、上游拒绝、响应体解析、空响应和返回候选外颜色;即使随后降级默认背景色并继续主流程也不得只记 warning。`api-server` 将这些失败映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event``event_key = external_generation_run``scope_kind = module``scope_id = provider``module_key = external-generation`。metadata 固定包含 `runId``provider``operation``requestLabel``requestPayload``status``success``failureReason``providerRequestId``resultPayload``startedAtMicros``completedAtMicros``durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
## SpacetimeDB 表目录
@@ -61,7 +61,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env``/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`
图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300`,熔断期直接走本地幕布色去背景兜底。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看 `editor_bgfilter_request_start``editor_bgfilter_fallback_to_local_screen_background_removal``editor_bgfilter_circuit_open_fallback_to_local_screen_background_removal` 日志。
图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300`。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看 `editor_bgfilter_request_start``editor_bgfilter_fallback_to_aliyun_matting``editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
@@ -55,7 +55,7 @@
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 override 文件。
后端 `AppState` 启动时加载默认配置和 override。所有会调用外部生成 provider 的编辑器生成请求都必须由后端以运行时配置重新计算价格,前端请求不提交价格字段;计算完成后统一进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`
后端 `AppState` 启动时加载默认配置和 override。所有会调用外部生成 provider 的编辑器生成请求都必须由后端以运行时配置重新计算价格,前端请求不提交价格字段;计算完成后统一进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`
## 管理端
@@ -28,7 +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`,但该选项不对用户可见。
- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`。后端先在 12 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
## 提示词契约
@@ -153,7 +153,7 @@
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
```text
生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景使用后端自动决策出的单一纯色抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与抠像底色相近的描边、投影或反光;禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
动作描述:
<用户输入的动画描述>
```
@@ -162,9 +162,9 @@
- 视频生成完成后,后端按面板选择抽取对应帧数:`32``40``48`
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图`screenColor` 选择
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定`screenColor` 去背
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程。
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。
@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeBackgroundFromRgba } from './chromaKey';
function createSolidRgbaBuffer({
width,
height,
color,
}: {
width: number;
height: number;
color: [number, number, number, number];
}) {
const pixels = new Uint8ClampedArray(width * height * 4);
for (let index = 0; index < width * height; index += 1) {
pixels.set(color, index * 4);
}
return pixels;
}
function setPixel(
pixels: Uint8ClampedArray,
width: number,
x: number,
y: number,
color: [number, number, number, number],
) {
pixels.set(color, (y * width + x) * 4);
}
function alphaAt(
pixels: Uint8ClampedArray,
width: number,
x: number,
y: number,
) {
return pixels[(y * width + x) * 4 + 3] ?? 0;
}
describe('chromaKey', () => {
it('removes near-white canvas background without breaking an enclosed white character', () => {
const width = 9;
const height = 9;
const pixels = createSolidRgbaBuffer({
width,
height,
color: [250, 250, 250, 255],
});
for (let y = 2; y <= 6; y += 1) {
for (let x = 2; x <= 6; x += 1) {
setPixel(pixels, width, x, y, [92, 80, 72, 255]);
}
}
for (let y = 3; y <= 5; y += 1) {
for (let x = 3; x <= 5; x += 1) {
setPixel(pixels, width, x, y, [246, 246, 244, 255]);
}
}
expect(removeBackgroundFromRgba(pixels, width, height)).toBe(true);
expect(alphaAt(pixels, width, 0, 0)).toBe(0);
expect(alphaAt(pixels, width, 4, 4)).toBe(255);
expect(alphaAt(pixels, width, 2, 2)).toBeGreaterThan(200);
});
});
-478
View File
@@ -1,478 +0,0 @@
export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray;
const SOFT_EDGE_ALPHA_THRESHOLD = 224;
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96;
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function lerp(from: number, to: number, t: number) {
return from + (to - from) * clamp01(t);
}
function computeGreenBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const greenLead = green - Math.max(red, blue);
if (green < 52 || greenLead <= 8) {
return 0;
}
const greenRatio = green / Math.max(1, red + blue);
if (greenRatio <= 0.52) {
return 0;
}
return clamp01(
((green - 52) / 168) * 0.22 +
((greenLead - 8) / 96) * 0.53 +
((greenRatio - 0.52) / 0.82) * 0.25,
);
}
function computeWhiteBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const maxChannel = Math.max(red, green, blue);
const minChannel = Math.min(red, green, blue);
const average = (red + green + blue) / 3;
if (average < 188 || minChannel < 168) {
return 0;
}
const spread = maxChannel - minChannel;
const neutrality = 1 - clamp01((spread - 6) / 34);
const brightness = clamp01((average - 188) / 55);
const floor = clamp01((minChannel - 168) / 60);
return clamp01(neutrality * (brightness * 0.85 + floor * 0.15));
}
function collectForegroundNeighborColor(
pixels: MutableRgbaBuffer,
width: number,
height: number,
x: number,
y: number,
backgroundMask: Uint8Array,
backgroundHints: Float32Array,
) {
let totalWeight = 0;
let totalRed = 0;
let totalGreen = 0;
let totalBlue = 0;
for (let offsetY = -2; offsetY <= 2; offsetY += 1) {
for (let offsetX = -2; offsetX <= 2; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
continue;
}
const distance = Math.abs(offsetX) + Math.abs(offsetY);
const weight =
(nextAlpha / 255) *
(distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7);
totalWeight += weight;
totalRed += (pixels[nextOffset] ?? 0) * weight;
totalGreen += (pixels[nextOffset + 1] ?? 0) * weight;
totalBlue += (pixels[nextOffset + 2] ?? 0) * weight;
}
}
if (totalWeight <= 0) {
return null;
}
return {
red: Math.round(totalRed / totalWeight),
green: Math.round(totalGreen / totalWeight),
blue: Math.round(totalBlue / totalWeight),
};
}
export function removeBackgroundFromRgba(
pixels: MutableRgbaBuffer,
width: number,
height: number,
) {
const pixelCount = width * height;
if (pixelCount <= 0) {
return false;
}
const backgroundMask = new Uint8Array(pixelCount);
const greenScores = new Float32Array(pixelCount);
const whiteScores = new Float32Array(pixelCount);
const backgroundHints = new Float32Array(pixelCount);
const queue: number[] = [];
let queueIndex = 0;
let changed = false;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 4;
const red = pixels[offset] ?? 0;
const green = pixels[offset + 1] ?? 0;
const blue = pixels[offset + 2] ?? 0;
const alpha = pixels[offset + 3] ?? 0;
const greenScore = computeGreenBackgroundScore(red, green, blue, alpha);
const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha);
const transparencyHint = clamp01((56 - alpha) / 56) * 0.75;
greenScores[pixelIndex] = greenScore;
whiteScores[pixelIndex] = whiteScore;
backgroundHints[pixelIndex] = Math.max(
greenScore,
whiteScore,
transparencyHint,
);
}
const trySeedBackground = (pixelIndex: number) => {
if (backgroundMask[pixelIndex]) {
return;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
const strongCandidate =
alpha < 40 ||
(greenScores[pixelIndex] ?? 0) > 0.12 ||
(whiteScores[pixelIndex] ?? 0) > 0.32;
if (!strongCandidate) {
return;
}
backgroundMask[pixelIndex] = 1;
queue.push(pixelIndex);
};
for (let x = 0; x < width; x += 1) {
trySeedBackground(x);
trySeedBackground((height - 1) * width + x);
}
for (let y = 1; y < height - 1; y += 1) {
trySeedBackground(y * width);
trySeedBackground(y * width + width - 1);
}
while (queueIndex < queue.length) {
const pixelIndex = queue[queueIndex]!;
queueIndex += 1;
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
const neighborIndexes = [
x > 0 ? pixelIndex - 1 : -1,
x + 1 < width ? pixelIndex + 1 : -1,
y > 0 ? pixelIndex - width : -1,
y + 1 < height ? pixelIndex + width : -1,
];
for (const nextPixelIndex of neighborIndexes) {
if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
const nextGreenScore = greenScores[nextPixelIndex] ?? 0;
const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0;
const nextHint = backgroundHints[nextPixelIndex] ?? 0;
const reachableSoftEdge =
nextHint > 0.08 &&
nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD &&
(nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180);
if (
nextAlpha < 40 ||
nextGreenScore > 0.12 ||
nextWhiteScore > 0.32 ||
reachableSoftEdge
) {
backgroundMask[nextPixelIndex] = 1;
queue.push(nextPixelIndex);
}
}
}
for (let iteration = 0; iteration < 2; iteration += 1) {
const expandedMask = new Uint8Array(backgroundMask);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (expandedMask[pixelIndex]) {
continue;
}
const alpha = pixels[pixelIndex * 4 + 3] ?? 0;
const hint = backgroundHints[pixelIndex] ?? 0;
if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) {
continue;
}
let adjacentBackgroundCount = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
if (backgroundMask[nextY * width + nextX]) {
adjacentBackgroundCount += 1;
}
}
}
if (
adjacentBackgroundCount >= 2 ||
(adjacentBackgroundCount >= 1 && hint > 0.18)
) {
expandedMask[pixelIndex] = 1;
}
}
}
backgroundMask.set(expandedMask);
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (!backgroundMask[pixelIndex]) {
continue;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
const matteScore = Math.max(
backgroundHints[pixelIndex] ?? 0,
greenScores[pixelIndex] ?? 0,
whiteScores[pixelIndex] ?? 0,
);
let foregroundSupport = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0;
if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
foregroundSupport += 1;
}
}
}
let nextAlpha = alpha;
if (matteScore > 0.9 || foregroundSupport === 0) {
nextAlpha = 0;
} else if (matteScore > 0.72 && foregroundSupport <= 1) {
nextAlpha = Math.min(alpha, Math.round(alpha * 0.08));
} else {
nextAlpha = Math.min(
alpha,
Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)),
);
}
if (foregroundSupport >= 3 && matteScore < 0.55) {
nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22));
}
if (nextAlpha < 10) {
nextAlpha = 0;
}
if (nextAlpha !== alpha) {
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
let touchesTransparentEdge = false;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
touchesTransparentEdge = true;
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (
backgroundMask[nextPixelIndex] ||
(pixels[nextPixelIndex * 4 + 3] ?? 0) < 16
) {
touchesTransparentEdge = true;
}
}
}
if (!touchesTransparentEdge) {
continue;
}
const greenScore = greenScores[pixelIndex] ?? 0;
const whiteScore = whiteScores[pixelIndex] ?? 0;
const contamination = Math.max(
greenScore,
whiteScore,
backgroundMask[pixelIndex] ? 0.35 : 0,
alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0,
);
if (contamination < 0.06) {
continue;
}
let red = pixels[offset] ?? 0;
let green = pixels[offset + 1] ?? 0;
let blue = pixels[offset + 2] ?? 0;
const sample = collectForegroundNeighborColor(
pixels,
width,
height,
x,
y,
backgroundMask,
backgroundHints,
);
const blend = clamp01(
Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0),
);
if (sample) {
red = Math.round(lerp(red, sample.red, blend));
green = Math.round(lerp(green, sample.green, blend));
blue = Math.round(lerp(blue, sample.blue, blend));
if (greenScore > 0.04) {
green = Math.min(green, sample.green + 18);
}
if (whiteScore > 0.1) {
red = Math.min(red, sample.red + 26);
green = Math.min(green, sample.green + 26);
blue = Math.min(blue, sample.blue + 26);
}
} else {
if (greenScore > 0.04) {
green = Math.max(
Math.max(red, blue),
Math.round(green - (green - Math.max(red, blue)) * 0.78),
);
}
if (whiteScore > 0.12) {
const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
if (spread < 20) {
const tonedValue = Math.round(((red + green + blue) / 3) * 0.88);
red = Math.min(red, tonedValue);
green = Math.min(green, tonedValue);
blue = Math.min(blue, tonedValue);
}
}
}
let nextAlpha = alpha;
const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28);
if (edgeFade > 0.08) {
nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade)));
if (nextAlpha < 10) {
nextAlpha = 0;
}
}
if (
red !== (pixels[offset] ?? 0) ||
green !== (pixels[offset + 1] ?? 0) ||
blue !== (pixels[offset + 2] ?? 0) ||
nextAlpha !== alpha
) {
pixels[offset] = red;
pixels[offset + 1] = green;
pixels[offset + 2] = blue;
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
return changed;
}
-1
View File
@@ -1 +0,0 @@
export * from '../prompts/qwenSprite.js';
-1
View File
@@ -1,4 +1,3 @@
export * from './assets/qwenSprite';
export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
-176
View File
@@ -1,176 +0,0 @@
/**
* 共享 sprite / 角色资产正式 prompt 模板。
*
* 这份脚本属于“正式模型 prompt 模板层”,不负责从角色卡里挑默认文本。
* 它的定位是:
* - 给后端角色主图生成链路提供标准主图 prompt 骨架
* - 给后端角色动作视频生成链路提供标准动作 prompt 骨架
*
* 当前角色资产主链中的关系是:
* 1. 前端或 Rust 后端先拿到一段较短的描述文本
* 2. 当前角色资产链路调用本文件 buildMasterPrompt / buildVideoActionPrompt
* 把短描述扩成正式给模型吃的 prompt
*
* 因此本文件不要承载“角色卡字段挑选”或“UI 默认值”职责,
* 只维护共享的正式 prompt 骨架与动作模板。
*/
export type QwenSpriteActionTemplateId =
| 'idle'
| 'run'
| 'attack_slash'
| 'hurt'
| 'die';
export type QwenSpriteActionTemplate = {
id: QwenSpriteActionTemplateId;
label: string;
loop: boolean;
defaultFps: number;
bodyTravel: string;
weaponRule: string;
sequenceLines: [string, string, string, string];
ending: string;
};
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
{
id: 'idle',
label: '待机循环',
loop: true,
defaultFps: 8,
bodyTravel: '原地',
weaponRule: '武器始终在主手,位置稳定',
sequenceLines: [
'1-4 帧:稳定站姿,轻微呼吸起伏',
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
'9-12 帧:呼气回落,重心恢复',
'13-16 帧:逐渐回到与首帧接近的站姿',
],
ending: '第 16 帧自然衔接第 1 帧',
},
{
id: 'run',
label: '奔跑循环',
loop: true,
defaultFps: 12,
bodyTravel: '小幅前移但角色中心基本固定',
weaponRule: '武器始终在主手,不换手',
sequenceLines: [
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
],
ending: '第 16 帧能无缝接回第 1 帧',
},
{
id: 'attack_slash',
label: '横斩攻击',
loop: false,
defaultFps: 12,
bodyTravel: '中幅前探',
weaponRule: '右手持武器,始终右手,不换手',
sequenceLines: [
'1-4 帧:轻微收身蓄力,武器向后收',
'5-8 帧:重心前压,挥击开始',
'9-12 帧:斩击达到最大幅度,动作力量最强',
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
],
ending: '第 16 帧停在收招后稳定姿态',
},
{
id: 'hurt',
label: '受击后仰',
loop: false,
defaultFps: 10,
bodyTravel: '原地或极小后仰',
weaponRule: '武器不要脱手,不要换手',
sequenceLines: [
'1-4 帧:突然受击,头肩后仰',
'5-8 帧:身体失衡最明显',
'9-12 帧:手臂和武器随惯性摆动',
'13-16 帧:逐渐恢复到勉强站稳的姿态',
],
ending: '第 16 帧能接回 idle 或下一个动作',
},
{
id: 'die',
label: '倒地死亡',
loop: false,
defaultFps: 8,
bodyTravel: '明显倒地位移',
weaponRule: '武器不可瞬间消失',
sequenceLines: [
'1-4 帧:受创失衡,重心被打断',
'5-8 帧:身体明显下坠或后仰',
'9-12 帧:倒地过程完成,动作幅度最大',
'13-16 帧:停在清晰的终止姿态',
],
ending: '第 16 帧停在死亡结束姿态,不需要循环',
},
];
const BODY_RATIO_TEXT =
'横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。';
const PIXEL_STYLE_TEXT =
'明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。';
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
return (
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
QWEN_SPRITE_ACTION_TEMPLATES[0]
);
}
/**
* 正式角色主图 prompt 骨架。
*
* 输入应该是一段已经整理好的角色摘要或视觉描述,
* 这里会把它嵌进统一的 sprite 资产约束中,
* 输出真正发给图像模型的完整 prompt。
*/
export function buildMasterPrompt(characterBrief: string) {
return [
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`,
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
characterBrief.trim(),
]
.filter(Boolean)
.join('\n');
}
/**
* 正式动作视频 prompt 骨架。
*
* 输入应该是已经整理好的动作细节与角色摘要,
* 这里负责统一拼装成 sprite 动作生成所需的正式 prompt,
* 包括视角、像素风格、动作模板、绿幕约束等。
*/
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;
useChromaKey: boolean;
characterBrief: string;
}) {
return [
`单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}`,
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`,
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}
+25
View File
@@ -47,6 +47,31 @@ if (typoKeys.length > 0) {
);
}
// 阿里云通用抠图兜底层:默认 enabled=true,但 AccessKey 缺失时会被静默跳过,
// BgFilter 失败直接本地去背(质量下降)。属可选降级项,只告警不阻断部署。
console.log('[api-server-env] 抠图兜底(阿里云通用抠图)配置检查');
const mattingEnabled = env.GENARRATIVE_ALIYUN_MATTING_ENABLED !== 'false';
const mattingKeyPresent =
hasValue(env.GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID) ||
hasValue(env.ALIBABA_CLOUD_ACCESS_KEY_ID);
const mattingSecretPresent =
hasValue(env.GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET) ||
hasValue(env.ALIBABA_CLOUD_ACCESS_KEY_SECRET);
printStatus(
'GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_ID',
mattingKeyPresent,
);
printStatus(
'GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET / ALIBABA_CLOUD_ACCESS_KEY_SECRET',
mattingSecretPresent,
);
if (mattingEnabled && !(mattingKeyPresent && mattingSecretPresent)) {
console.warn(
'[api-server-env] 警告:GENARRATIVE_ALIYUN_MATTING_ENABLED 未关闭但缺少 AccessKey' +
'阿里云抠图兜底层将被跳过,BgFilter 失败后直接本地去背(质量下降)。',
);
}
if (missing.length > 0) {
console.error(`[api-server-env] 缺少:${missing.join(', ')}`);
process.exit(1);
+1 -1
View File
@@ -255,7 +255,7 @@ ensure_runtime_env_and_dirs() {
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000"
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
+1 -1
View File
@@ -479,7 +479,7 @@ ensure_api_runtime_env_defaults() {
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000"
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
}
+24
View File
@@ -241,6 +241,7 @@ dependencies = [
"platform-hyper3d",
"platform-image",
"platform-llm",
"platform-matting",
"platform-oss",
"platform-speech",
"platform-wechat",
@@ -4476,6 +4477,29 @@ dependencies = [
"tokio",
]
[[package]]
name = "platform-matting"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dotenvy",
"hex",
"hmac",
"httpdate",
"image",
"platform-oss",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_urlencoded",
"sha1",
"sha2",
"time",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "platform-oss"
version = "0.1.0"
+2
View File
@@ -38,6 +38,7 @@ members = [
"crates/platform-hyper3d",
"crates/platform-image",
"crates/platform-llm",
"crates/platform-matting",
"crates/platform-wechat",
"crates/platform-speech",
"crates/platform-agent",
@@ -88,6 +89,7 @@ platform-audio = { path = "crates/platform-audio", default-features = false }
platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false }
platform-image = { path = "crates/platform-image", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false }
platform-matting = { path = "crates/platform-matting", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false }
platform-speech = { path = "crates/platform-speech", default-features = false }
platform-wechat = { path = "crates/platform-wechat", default-features = false }
+1
View File
@@ -43,6 +43,7 @@ platform-auth = { workspace = true }
platform-hyper3d = { workspace = true }
platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-matting = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
platform-wechat = { workspace = true }
@@ -0,0 +1,165 @@
//! 阿里云通用抠图在 api-server 侧的适配层。
//!
//! 输入 URL 策略:抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS,
//! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。
use axum::http::StatusCode;
use platform_matting::MattingError;
use serde_json::json;
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
};
/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。
/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。
pub(crate) async fn segment_image_with_aliyun_matting(
state: &AppState,
image: &DownloadedOpenAiImage,
log_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let matting_client = state
.matting_client()
.ok_or_else(aliyun_matting_unconfigured_error)?;
let file_name = format!("{log_label}.png");
let started_at = std::time::Instant::now();
let output_bytes = matting_client
.segment_image_to_transparent_png(image.bytes.as_slice(), &file_name)
.await
.map_err(|error| {
aliyun_matting_failure_to_app_error(&error, started_at.elapsed().as_millis() as u64)
})?;
tracing::info!(
provider = "aliyun-matting",
log_label,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"阿里云通用抠图完成"
);
Ok(DownloadedOpenAiImage {
bytes: output_bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
/// 把 platform-matting 的错误映射成审计友好的 AppError。
///
/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败,
/// `external_call_attempted()` 为 false,据此跳过外部失败审计。
fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError {
let message = error.message();
if !error.external_call_attempted() {
// 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"externalCallAttempted": false,
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
let timeout = error.is_timeout();
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": timeout,
"transport": error.is_transport(),
"upstreamStatus": error.upstream_status(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
}
fn aliyun_matting_unconfigured_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-matting",
"message": "阿里云抠图客户端未配置或未启用。",
"externalCallAttempted": false,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliyun_matting_unconfigured_error_marks_no_external_call_attempt() {
let error = aliyun_matting_unconfigured_error();
assert!(!crate::external_api_audit::matting_failure_external_call_attempted(&error));
}
#[test]
fn local_preflight_failures_do_not_count_as_external_call() {
for error in [
MattingError::InvalidRequest(
"待抠图图片尺寸 16x16 过小,阿里云通用抠图要求每条边大于 32 像素".to_string(),
),
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()),
MattingError::InvalidConfig("endpoint 为空".to_string()),
MattingError::Sign("初始化 OSS V1 签名器失败".to_string()),
] {
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
assert!(
!crate::external_api_audit::matting_failure_external_call_attempted(&mapped),
"本地预检失败不应记为外部调用:{}",
error.message()
);
let details = mapped.details().expect("details present");
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(false));
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(false));
}
}
#[test]
fn upstream_transport_failure_maps_to_retryable_transport() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false);
let mapped = aliyun_matting_failure_to_app_error(&error, 12);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
// 无 HTTP 状态的传输层失败标记为可重试 transport 故障,且不带 upstreamStatus。
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true));
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(false));
assert!(details.get("upstreamStatus").is_some_and(|v| v.is_null()));
}
#[test]
fn upstream_timeout_failure_maps_to_gateway_timeout() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true);
let mapped = aliyun_matting_failure_to_app_error(&error, 7);
assert_eq!(mapped.status_code(), StatusCode::GATEWAY_TIMEOUT);
let details = mapped.details().expect("details present");
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(true));
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true));
}
#[test]
fn upstream_http_status_failure_carries_status_without_transport() {
let error = MattingError::upstream_http_error(
"通用抠图接口返回失败(HTTP 429Code=Throttled):QPS exceeded".to_string(),
429,
);
let mapped = aliyun_matting_failure_to_app_error(&error, 5);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(false));
assert_eq!(details.get("upstreamStatus").and_then(|v| v.as_u64()), Some(429));
}
}
File diff suppressed because it is too large Load Diff
+91 -3
View File
@@ -20,9 +20,11 @@ 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 = 45_000;
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 180_000;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 300;
const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com";
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
@@ -68,6 +70,11 @@ pub struct AppConfig {
pub editor_bgfilter_base_url: String,
pub editor_bgfilter_token: Option<String>,
pub editor_bgfilter_request_timeout_ms: u64,
pub aliyun_matting_enabled: bool,
pub aliyun_matting_endpoint: String,
pub aliyun_matting_access_key_id: Option<String>,
pub aliyun_matting_access_key_secret: Option<String>,
pub aliyun_matting_request_timeout_ms: u64,
pub editor_bgfilter_circuit_failure_threshold: u32,
pub editor_bgfilter_circuit_cooldown: Duration,
pub image_editor_agent_sidebar_enabled: bool,
@@ -309,6 +316,11 @@ impl Default for AppConfig {
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,
aliyun_matting_enabled: true,
aliyun_matting_endpoint: DEFAULT_ALIYUN_MATTING_ENDPOINT.to_string(),
aliyun_matting_access_key_id: None,
aliyun_matting_access_key_secret: None,
aliyun_matting_request_timeout_ms: DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS,
editor_bgfilter_circuit_failure_threshold:
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD,
editor_bgfilter_circuit_cooldown: Duration::from_secs(
@@ -518,6 +530,25 @@ impl AppConfig {
{
config.editor_bgfilter_request_timeout_ms = timeout_ms;
}
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ALIYUN_MATTING_ENABLED"]) {
config.aliyun_matting_enabled = enabled;
}
if let Some(endpoint) = read_first_non_empty_env(&["GENARRATIVE_ALIYUN_MATTING_ENDPOINT"]) {
config.aliyun_matting_endpoint = endpoint;
}
config.aliyun_matting_access_key_id = read_first_non_empty_env(&[
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID",
"ALIBABA_CLOUD_ACCESS_KEY_ID",
]);
config.aliyun_matting_access_key_secret = read_first_non_empty_env(&[
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET",
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
]);
if let Some(timeout_ms) =
read_first_positive_u64_env(&["GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"])
{
config.aliyun_matting_request_timeout_ms = timeout_ms;
}
if let Some(threshold) =
read_first_u32_env(&["GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"])
{
@@ -2303,7 +2334,7 @@ mod tests {
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN",
"shared-token",
);
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS", "180000");
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS", "240000");
}
let config = AppConfig::from_env();
@@ -2312,7 +2343,7 @@ mod tests {
config.editor_bgfilter_token.as_deref(),
Some("shared-token")
);
assert_eq!(config.editor_bgfilter_request_timeout_ms, 180_000);
assert_eq!(config.editor_bgfilter_request_timeout_ms, 240_000);
unsafe {
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN", "bgfilter-token");
@@ -2332,6 +2363,63 @@ mod tests {
}
}
#[test]
fn from_env_reads_aliyun_matting_settings_and_reuses_alibaba_cloud_keys() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET");
std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_ID", "shared-ak-id");
std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "shared-ak-secret");
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS", "45000");
}
let config = AppConfig::from_env();
assert!(config.aliyun_matting_enabled);
assert_eq!(
config.aliyun_matting_endpoint,
"imageseg.cn-shanghai.aliyuncs.com"
);
assert_eq!(
config.aliyun_matting_access_key_id.as_deref(),
Some("shared-ak-id")
);
assert_eq!(
config.aliyun_matting_access_key_secret.as_deref(),
Some("shared-ak-secret")
);
assert_eq!(config.aliyun_matting_request_timeout_ms, 45_000);
unsafe {
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", "matting-ak-id");
std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ENABLED", "false");
}
let config = AppConfig::from_env();
assert!(!config.aliyun_matting_enabled);
assert_eq!(
config.aliyun_matting_access_key_id.as_deref(),
Some("matting-ak-id")
);
unsafe {
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID");
std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID");
std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET");
}
}
#[test]
fn from_env_reads_editor_bgfilter_circuit_settings() {
let _guard = ENV_LOCK

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