From 76d95ff561ede32d07828a2ffc1cf7cb14b8c1a8 Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 11 Jul 2026 10:24:30 +0000 Subject: [PATCH 01/21] =?UTF-8?q?=E7=BB=9F=E4=B8=80BGFilter=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E9=99=8D=E7=BA=A7=E8=B7=AF=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为角色形象、图标、UI素材和动作帧显式设置cross_check策略 角色动作逐帧复用BGFilter到阿里云再到本地键色的三段式降级 更新BGFilter服务地址、架构文档和项目决策记录 --- .env.local | 2 + .../shared-memory/decision-log.md | 10 ++- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../src/character_animation_assets.rs | 64 +++++++------------ .../crates/api-server/src/editor_project.rs | 56 ++++++++++++++-- 6 files changed, 85 insertions(+), 51 deletions(-) diff --git a/.env.local b/.env.local index 66c7cb8e0..4eaa9fafe 100644 --- a/.env.local +++ b/.env.local @@ -29,6 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark" GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" +GENARRATIVE_EDITOR_BGFILTER_BASE_URL=https://u39211-9b1c-e4a7a054.westb.seetacloud.com:8443 APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="" APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 @@ -36,6 +37,7 @@ DASHSCOPE_SCENE_IMAGE_MODEL="wan2.2-t2i-flash" DASHSCOPE_REFERENCE_IMAGE_MODEL="qwen-image-2.0" DASHSCOPE_COVER_IMAGE_MODEL="wan2.2-t2i-flash" ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000 + # 启用服务端大模型调试日志(记录所有输入输出) LLM_DEBUG_LOG="true" diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5a01edd7b..8c56aeeb8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-11 BgFilter 交叉模型否决只用于角色形象 + +- 背景:新版 BgFilter 的 `cross_check` 默认开启,会额外运行 HR-matting 第二意见模型;角色形象需要保留发丝、镂空等复杂边缘质量,但角色动作序列帧、图标 spritesheet 和 UI 素材提取不需要承担这部分额外推理开销。 +- 决策:api-server 调用 BgFilter 时必须显式发送 multipart 字段 `cross_check`,不依赖服务端默认值。角色形象生成固定传 `on`;角色动作逐帧去背、图标 spritesheet 生成和 UI 设计图素材提取固定传 `off`。角色动作逐帧去背与三条静态生图路线复用同一条 `BgFilter → 阿里云通用抠图 → 本地键色` 降级链和同一 BgFilter 熔断器。该字段是后端内部供应商策略,不进入前端请求或外部 OpenAPI。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、图片画布 BgFilter 调用文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_cross_check --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-10 BgFilter segModel 保留内部字段,不进入外部 OpenAPI - 背景:`api-server` 的图片生成、图标 spritesheet 与 UI 素材提取请求仍可反序列化 `segModel`,并识别 `birefnet` / `anime-seg`,以兼容内部调用和既有任务;但 BgFilter 当前受服务进程内存与并发容量约束,不同分割模型的内存占用并非可由外部调用方自由选择的稳定契约。 @@ -35,7 +43,7 @@ ## 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`」补齐冷区绿色段。 +- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、`reasoning_effort=low`,`max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet`、`cross_check=off`),失败依次降级阿里云通用抠图和本地 `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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 767b59d62..b9e760512 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:前端固定提交 `screenColor=auto`,后端视觉决策出具体 hex 并把源角色图合成到该背景色后再图生视频;抽帧后逐帧优先阿里云通用抠图,失败降级本地 `editor_green_screen`(按选定背景色,而非固定 `#00FF00`)。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成传 `on`,角色动作逐帧去背、图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(cross_check=off)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 36a5d4e0c..85e2b93fe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -227,7 +227,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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `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` 审计。 +- 编辑器抠图服务:手动 `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=`、`seg_model=` 和 `cross_check=`;角色形象生成固定传 `cross_check=on`,角色动作逐帧去背、图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=off` 进入上述三段式链路。阿里云通用抠图配置为 `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 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index e99ab0e46..4d3ae53a6 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -62,17 +62,18 @@ use crate::{ }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, - remove_editor_generated_green_screen_background, - }, - editor_screen_background_decision::{ - EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, - resolve_editor_screen_background_color, }, editor_project::{ + EDITOR_BGFILTER_CROSS_CHECK_DISABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, + remove_editor_generated_screen_background_with_bgfilter, + }, + editor_screen_background_decision::{ + EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, + resolve_editor_screen_background_color, }, http_error::AppError, openai_image_generation::DownloadedOpenAiImage, @@ -2216,7 +2217,7 @@ async fn persist_editor_character_animation_green_screen_source_frames( Ok(()) } -/// 逐帧抠图:优先阿里云通用抠图,失败降级本地键色(使用生成时的纯色背景色)。 +/// 逐帧抠图:优先 BgFilter(关闭 cross-check),失败依次降级阿里云通用抠图和本地键色。 /// 小并发保序处理,单帧降级不影响其他帧。 async fn remove_editor_character_animation_frame_backgrounds( state: &AppState, @@ -2237,43 +2238,20 @@ async fn remove_editor_character_animation_frame_backgrounds( mime_type: frame.mime_type, extension: frame.extension, }; - let removed = match crate::aliyun_matting::segment_image_with_aliyun_matting( + let removed = remove_editor_generated_screen_background_with_bgfilter( state, &image, - "editor-animation-frame", + screen_color, + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_BGFILTER_CROSS_CHECK_DISABLED, + audit, ) - .await - { - Ok(removed) => removed, - Err(error) => { - tracing::warn!( - provider = "aliyun-matting", - frame_index, - screen_color = screen_color.hex, - error = %error, - error_details = ?error.details(), - "editor_animation_frame_aliyun_matting_fallback_to_local" - ); - if crate::external_api_audit::matting_failure_external_call_attempted(&error) { - crate::external_api_audit::record_matting_external_api_failure( - state, - audit, - "aliyun-matting", - state.config.aliyun_matting_endpoint.clone(), - "editor-character-animation-frame-matting", - "aliyun_segment", - crate::external_api_audit::matting_failure_audit_status_code(&error), - crate::external_api_audit::matting_failure_audit_timeout(&error), - crate::external_api_audit::matting_failure_audit_latency_ms(&error), - error.message().to_string(), - crate::external_api_audit::matting_failure_audit_raw_excerpt(&error) - .or_else(|| Some(format!("frame_index={frame_index}"))), - ) - .await; - } - remove_editor_generated_green_screen_background(&image, screen_color)? - } - }; + .await?; + tracing::debug!( + frame_index, + screen_color = screen_color.hex, + "editor_animation_frame_background_removed" + ); finalize_animation_frame_payload( removed.bytes.as_slice(), removed.mime_type.as_str(), @@ -5699,7 +5677,7 @@ mod tests { } #[test] - fn editor_character_animation_frames_use_local_green_screen_postprocess() { + fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); assert_function_contains( source, @@ -5736,7 +5714,9 @@ mod tests { "fn remove_editor_character_animation_frame_backgrounds", "async fn publish_animation_set", &[ - "remove_editor_generated_green_screen_background", + "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "finalize_animation_frame_payload", ], ); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7c81b2ba9..233aab6c9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -113,8 +113,10 @@ const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesh const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; -const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; +pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; +const EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK: bool = true; +pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; @@ -1585,6 +1587,7 @@ pub(crate) async fn generate_editor_image_for_owner( &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), + EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK, &matting_audit, ) .await?; @@ -2239,11 +2242,12 @@ struct EditorBackgroundRemovalImage { height: u32, } -async fn remove_editor_generated_screen_background_with_bgfilter( +pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, + cross_check: bool, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { @@ -2253,6 +2257,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( provider = "bgfilter", screen_color = screen_color.hex, seg_model, + cross_check, cooldown_ms = remaining.as_millis() as u64, "editor_bgfilter_circuit_open_fallback_to_aliyun_matting" ); @@ -2264,6 +2269,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( image, screen_color, seg_model, + cross_check, ) .await { @@ -2277,6 +2283,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( provider = "bgfilter", screen_color = screen_color.hex, seg_model, + cross_check, error = %error, error_details = ?error.details(), "editor_bgfilter_fallback_to_aliyun_matting" @@ -2401,6 +2408,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, + cross_check: bool, ) -> Result { let url = editor_bgfilter_endpoint(state)?; let call_id = format!("bgfilter-call-{}", current_utc_micros()); @@ -2419,6 +2427,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( mime_type = %source_mime_type, screen_color = screen_color.hex, seg_model, + cross_check, input_bytes, timeout_ms, "editor_bgfilter_request_start" @@ -2444,7 +2453,11 @@ async fn request_editor_generated_screen_background_with_bgfilter( let form = reqwest::multipart::Form::new() .part("file", file_part) .text("screen_color", screen_color.hex.to_string()) - .text("seg_model", seg_model.to_string()); + .text("seg_model", seg_model.to_string()) + .text( + "cross_check", + editor_bgfilter_cross_check_form_value(cross_check), + ); let mut request = http_client.post(url.as_str()).multipart(form); if let Some(token) = state .config @@ -2511,9 +2524,18 @@ async fn request_editor_generated_screen_background_with_bgfilter( .get("x-bgfilter-screen-color") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); - let bytes = - read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) - .await?; + let upstream_cross_check = response + .headers() + .get("x-bgfilter-cross-check") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let bytes = read_editor_image_removal_response_bytes( + response, + "bgfilter", + "BgFilter", + request_started_at, + ) + .await?; if bytes.is_empty() { // HTTP 已成功但 body 为空属于上游内容缺陷(5xx 归类正确),仍补 latencyMs 与 Aliyun 兜底口径对齐。 return Err( @@ -2537,8 +2559,10 @@ async fn request_editor_generated_screen_background_with_bgfilter( height = decoded.height(), screen_color = screen_color.hex, seg_model, + cross_check, upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""), upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""), + upstream_cross_check = upstream_cross_check.as_deref().unwrap_or(""), input_bytes, output_bytes = image.bytes.len(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, @@ -2732,6 +2756,10 @@ fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, } } +fn editor_bgfilter_cross_check_form_value(enabled: bool) -> &'static str { + if enabled { "on" } else { "off" } +} + fn map_editor_background_removal_error(error: reqwest::Error) -> AppError { let status = if error.is_timeout() { StatusCode::GATEWAY_TIMEOUT @@ -3075,6 +3103,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &image, screen_color, seg_model, + EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) .await?; @@ -3376,6 +3405,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( &image, screen_color, seg_model, + EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) .await?; @@ -7544,6 +7574,12 @@ mod tests { assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err()); } + #[test] + fn editor_bgfilter_cross_check_form_value_matches_service_contract() { + assert_eq!(editor_bgfilter_cross_check_form_value(true), "on"); + assert_eq!(editor_bgfilter_cross_check_form_value(false), "off"); + } + #[test] fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() { reset_editor_bgfilter_circuit_for_tests(); @@ -7618,6 +7654,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK", "if is_character_generation", ], ); @@ -7628,6 +7665,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK", ], ); assert_function_contains( @@ -7637,6 +7675,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); assert_function_contains_in_order( @@ -7646,6 +7685,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); assert_function_contains( @@ -7655,6 +7695,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "slice_generated_icon_spritesheet_all_by_connected_components", ], ); @@ -7665,6 +7706,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); assert_function_contains_in_order( @@ -7699,6 +7741,8 @@ mod tests { "\"screen_color\"", "\"seg_model\"", "seg_model.to_string()", + "\"cross_check\"", + "editor_bgfilter_cross_check_form_value(cross_check)", ], ); assert_function_contains( -- 2.52.0 From 20600c75c788e80f59cf3ffcb41cea387cd313a0 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 13 Jul 2026 09:04:20 +0000 Subject: [PATCH 02/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9CBgFilter=E5=B9=B6=E5=8F=91=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用BgFilter HTTP连接池并为单帧失败增加一次重试 将全部动作帧改为无序连续在途并在完成后恢复帧序 按单帧源图上传、抠图和透明帧上传组成流水线并排空失败批次 为角色动作开启cross-check并同步后端文档与决策记录 --- .../shared-memory/decision-log.md | 24 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- .../src/character_animation_assets.rs | 274 +++++++++--------- .../crates/api-server/src/editor_project.rs | 154 ++++++---- server-rs/crates/api-server/src/state.rs | 26 ++ 6 files changed, 295 insertions(+), 189 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6aef0e895..ca371cf90 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-07-13 角色动作逐帧开启 BgFilter cross-check + +- 背景:角色动作逐帧抠图此前为减少额外推理开销固定传 `cross_check=off`,但动作帧同样需要保留发丝、镂空和运动边缘质量。 +- 决策:角色动作逐帧 BgFilter 请求固定显式传 `cross_check=on`,与角色形象保持一致;图标 spritesheet 和 UI 设计图素材提取继续固定传 `off`。该策略仍属于后端内部供应商参数,不进入前端或外部 OpenAPI。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_cross_check --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-07-13 角色动作 BgFilter 全帧流水线与单次重试 + +- 背景:角色动作抽帧后原先固定 `buffered(3)`,并在整批绿幕源帧串行落 OSS 后才开始抠图;每帧还单独创建 HTTP Client。公网 BgFilter 的网络等待会让服务端推理队列出现空档,且首个最终错误会通过 `try_collect` 提前取消 api-server 中其余已发 Future。 +- 决策:BgFilter HTTP Client 在 `AppState` 中统一创建并复用 keep-alive 连接池;每次 BgFilter 调用失败后立即重试 `1` 次,两次都失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发失败调用都保留审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的 `buffer_unordered` 连续发射并携带原始帧序,完成后排序;不在 api-server 新增供应商进程锁或全局 Semaphore。任一帧最终失败时先排空全部已启动 Future,再让整个动作任务失败退款,不发布缺帧动画。 +- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_retries_once_before_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server 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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-12 泥点充值收敛为四档并统一资产入口 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 @@ -32,10 +48,10 @@ - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、钱包定向 Rust 测试、个人中心定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 2026-07-11 BgFilter 交叉模型否决只用于角色形象 +## 2026-07-11 BgFilter 交叉模型否决用于角色形象与角色动作 -- 背景:新版 BgFilter 的 `cross_check` 默认开启,会额外运行 HR-matting 第二意见模型;角色形象需要保留发丝、镂空等复杂边缘质量,但角色动作序列帧、图标 spritesheet 和 UI 素材提取不需要承担这部分额外推理开销。 -- 决策:api-server 调用 BgFilter 时必须显式发送 multipart 字段 `cross_check`,不依赖服务端默认值。角色形象生成固定传 `on`;角色动作逐帧去背、图标 spritesheet 生成和 UI 设计图素材提取固定传 `off`。角色动作逐帧去背与三条静态生图路线复用同一条 `BgFilter → 阿里云通用抠图 → 本地键色` 降级链和同一 BgFilter 熔断器。该字段是后端内部供应商策略,不进入前端请求或外部 OpenAPI。 +- 背景:新版 BgFilter 的 `cross_check` 默认开启,会额外运行 HR-matting 第二意见模型;角色形象与角色动作序列帧需要保留发丝、镂空和运动边缘质量;图标 spritesheet 和 UI 素材提取不需要承担这部分额外推理开销。 +- 决策:api-server 调用 BgFilter 时必须显式发送 multipart 字段 `cross_check`,不依赖服务端默认值。角色形象生成和角色动作逐帧去背固定传 `on`;图标 spritesheet 生成和 UI 设计图素材提取固定传 `off`。角色动作逐帧去背与三条静态生图路线复用同一条 `BgFilter → 阿里云通用抠图 → 本地键色` 降级链和同一 BgFilter 熔断器。该字段是后端内部供应商策略,不进入前端请求或外部 OpenAPI。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、图片画布 BgFilter 调用文档。 - 验证方式:运行 `cargo test -p api-server editor_bgfilter_cross_check --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -75,7 +91,7 @@ ## 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 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet`、`cross_check=off`),失败依次降级阿里云通用抠图和本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。 +- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、`reasoning_effort=low`,`max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet`、`cross_check=on`),失败依次降级阿里云通用抠图和本地 `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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d180077eb..fdea14f6f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,8 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成传 `on`,角色动作逐帧去背、图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(cross_check=off)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(cross_check=on)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b833ab561..be867894c 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -230,7 +230,8 @@ 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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=`、`seg_model=` 和 `cross_check=`;角色形象生成固定传 `cross_check=on`,角色动作逐帧去背、图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=off` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 +- 编辑器抠图服务:手动 `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=`、`seg_model=` 和 `cross_check=`;角色形象生成和角色动作逐帧去背固定传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 +- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。单次 BgFilter 调用失败后立即重试 `1` 次,第二次仍失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发出的失败调用都单独写审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 4d3ae53a6..7fcbe6949 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -64,7 +64,7 @@ use crate::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, }, editor_project::{ - EDITOR_BGFILTER_CROSS_CHECK_DISABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, @@ -2132,138 +2132,151 @@ async fn extract_and_persist_editor_character_animation_frames( Some(request.duration_seconds as f64), ) .await?; - persist_editor_character_animation_green_screen_source_frames( - state, - owner_user_id, - source_layer_id, - task_id, - &finalized_frames, - ) - .await?; - let finalized_frames = remove_editor_character_animation_frame_backgrounds( - state, - finalized_frames, - request.frame_width, - request.frame_height, - request.screen_color, - audit, - ) - .await?; + use futures_util::StreamExt as _; - let mut frame_payloads = Vec::with_capacity(finalized_frames.len()); - for (index, frame) in finalized_frames.into_iter().enumerate() { - let put_result = put_character_animation_object( - state, - LegacyAssetPrefix::Animations, - vec![ - "editor".to_string(), - sanitize_storage_segment(source_layer_id, "layer"), - task_id.to_string(), - ], - format!("frame{:02}.{}", index + 1, frame.extension), - frame.mime_type, - frame.bytes, - build_asset_metadata( - EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + let frame_count = finalized_frames.len(); + let frame_results = futures_util::stream::iter(finalized_frames.into_iter().enumerate().map( + |(frame_index, frame)| async move { + process_and_persist_editor_character_animation_frame( + state, owner_user_id, - "editor_layer", source_layer_id, - "animation_frame", - "editor-character-animation", - ), - ) - .await?; - frame_payloads.push(EditorCharacterAnimationFramePayload { - frame_index: index as u32 + 1, - image_src: put_result.legacy_public_path, - width: request.frame_width, - height: request.frame_height, - }); + task_id, + frame_index, + frame, + request.frame_width, + request.frame_height, + request.screen_color, + audit, + ) + .await + .map_err(|error| (frame_index, error)) + }, + )) + // 中文注释:动作帧上限固定为 48。全部帧连续进入在途集合,由 BgFilter 服务端既有进程锁自行排队; + // api-server 不再等待前一帧返回,也不因返回乱序产生队头阻塞。 + .buffer_unordered(frame_count.max(1)) + // 中文注释:不能 try_collect 提前取消。已经发出的请求必须全部排空,避免服务端完成计算后无人接收。 + .collect::>() + .await; + + let mut frame_payloads = Vec::with_capacity(frame_count); + let mut frame_errors = Vec::new(); + for frame_result in frame_results { + match frame_result { + Ok(frame_payload) => frame_payloads.push(frame_payload), + Err(frame_error) => frame_errors.push(frame_error), + } + } + frame_payloads.sort_by_key(|frame| frame.frame_index); + frame_errors.sort_by_key(|(frame_index, _)| *frame_index); + if let Some((frame_index, error)) = frame_errors.into_iter().next() { + tracing::warn!( + frame_index = frame_index + 1, + completed_frames = frame_payloads.len(), + expected_frames = frame_count, + error = %error, + "editor_animation_frame_pipeline_failed_after_drain" + ); + return Err(error); } Ok(frame_payloads) } -async fn persist_editor_character_animation_green_screen_source_frames( +async fn process_and_persist_editor_character_animation_frame( state: &AppState, owner_user_id: &str, source_layer_id: &str, task_id: &str, - frames: &[FinalizedAnimationFrame], -) -> Result<(), AppError> { - for (index, frame) in frames.iter().enumerate() { - put_character_animation_object( - state, - LegacyAssetPrefix::Animations, - vec![ - "editor".to_string(), - sanitize_storage_segment(source_layer_id, "layer"), - task_id.to_string(), - ], - format!("green-screen-frame{:02}.{}", index + 1, frame.extension), - frame.mime_type.clone(), - frame.bytes.clone(), - build_asset_metadata( - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, - owner_user_id, - "editor_layer", - source_layer_id, - EDITOR_GREEN_SCREEN_SOURCE_SLOT, - "editor-character-animation", - ), - ) - .await?; - } - Ok(()) -} - -/// 逐帧抠图:优先 BgFilter(关闭 cross-check),失败依次降级阿里云通用抠图和本地键色。 -/// 小并发保序处理,单帧降级不影响其他帧。 -async fn remove_editor_character_animation_frame_backgrounds( - state: &AppState, - frames: Vec, + frame_index: usize, + frame: FinalizedAnimationFrame, frame_width: u32, frame_height: u32, screen_color: EditorScreenBackgroundColor, audit: &crate::external_api_audit::ExternalApiAuditContext, -) -> Result, AppError> { - use futures_util::{StreamExt as _, TryStreamExt as _}; +) -> Result { + // 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。 + put_character_animation_object( + state, + LegacyAssetPrefix::Animations, + vec![ + "editor".to_string(), + sanitize_storage_segment(source_layer_id, "layer"), + task_id.to_string(), + ], + format!( + "green-screen-frame{:02}.{}", + frame_index + 1, + frame.extension + ), + frame.mime_type.clone(), + frame.bytes.clone(), + build_asset_metadata( + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + owner_user_id, + "editor_layer", + source_layer_id, + EDITOR_GREEN_SCREEN_SOURCE_SLOT, + "editor-character-animation", + ), + ) + .await?; - const FRAME_MATTING_CONCURRENCY: usize = 3; + let image = DownloadedOpenAiImage { + bytes: frame.bytes, + mime_type: frame.mime_type, + extension: frame.extension, + }; + let removed = remove_editor_generated_screen_background_with_bgfilter( + state, + &image, + screen_color, + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_BGFILTER_CROSS_CHECK_ENABLED, + audit, + ) + .await?; + tracing::debug!( + frame_index, + screen_color = screen_color.hex, + "editor_animation_frame_background_removed" + ); + let finalized = finalize_animation_frame_payload( + removed.bytes.as_slice(), + removed.mime_type.as_str(), + frame_width, + frame_height, + false, + )?; + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::Animations, + vec![ + "editor".to_string(), + sanitize_storage_segment(source_layer_id, "layer"), + task_id.to_string(), + ], + format!("frame{:02}.{}", frame_index + 1, finalized.extension), + finalized.mime_type, + finalized.bytes, + build_asset_metadata( + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + owner_user_id, + "editor_layer", + source_layer_id, + "animation_frame", + "editor-character-animation", + ), + ) + .await?; - futures_util::stream::iter(frames.into_iter().enumerate().map( - |(frame_index, frame)| async move { - let image = DownloadedOpenAiImage { - bytes: frame.bytes, - mime_type: frame.mime_type, - extension: frame.extension, - }; - let removed = remove_editor_generated_screen_background_with_bgfilter( - state, - &image, - screen_color, - EDITOR_BGFILTER_DEFAULT_SEG_MODEL, - EDITOR_BGFILTER_CROSS_CHECK_DISABLED, - audit, - ) - .await?; - tracing::debug!( - frame_index, - screen_color = screen_color.hex, - "editor_animation_frame_background_removed" - ); - finalize_animation_frame_payload( - removed.bytes.as_slice(), - removed.mime_type.as_str(), - frame_width, - frame_height, - false, - ) - }, - )) - .buffered(FRAME_MATTING_CONCURRENCY) - .try_collect::>() - .await + Ok(EditorCharacterAnimationFramePayload { + frame_index: frame_index as u32 + 1, + image_src: put_result.legacy_public_path, + width: frame_width, + height: frame_height, + }) } async fn publish_animation_set( @@ -5685,38 +5698,37 @@ mod tests { "async fn publish_animation_set", &[ "apply_chroma_key: false", - "persist_editor_character_animation_green_screen_source_frames", - "remove_editor_character_animation_frame_backgrounds", + "process_and_persist_editor_character_animation_frame", + ".buffer_unordered(frame_count.max(1))", + ".collect::>()", + "frame_payloads.sort_by_key", + "frame_errors.sort_by_key", ], ); assert_function_contains_in_order( source, - "async fn extract_and_persist_editor_character_animation_frames", + "async fn process_and_persist_editor_character_animation_frame", "async fn publish_animation_set", &[ - "persist_editor_character_animation_green_screen_source_frames", - "remove_editor_character_animation_frame_backgrounds", + "put_character_animation_object", + "green-screen-frame", + "remove_editor_generated_screen_background_with_bgfilter", + "finalize_animation_frame_payload", + "put_character_animation_object", + "animation_frame", ], ); assert_function_contains( source, - "async fn persist_editor_character_animation_green_screen_source_frames", - "fn remove_editor_character_animation_frame_backgrounds", + "async fn process_and_persist_editor_character_animation_frame", + "async fn publish_animation_set", &[ "EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND", "EDITOR_GREEN_SCREEN_SOURCE_SLOT", "green-screen-frame", - "put_character_animation_object", - ], - ); - assert_function_contains( - source, - "fn remove_editor_character_animation_frame_backgrounds", - "async fn publish_animation_set", - &[ "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", - "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", + "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "finalize_animation_frame_payload", ], ); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e13c8f1da..73042bbd1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -115,8 +115,9 @@ const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source" const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; -const EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK: bool = true; +pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false; +const EDITOR_BGFILTER_RETRY_COUNT: usize = 1; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; @@ -1587,7 +1588,7 @@ pub(crate) async fn generate_editor_image_for_owner( &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), - EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK, + EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &matting_audit, ) .await?; @@ -2412,47 +2413,73 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( return fallback_editor_screen_background_removal(state, image, screen_color, audit).await; } - match request_editor_generated_screen_background_with_bgfilter( - state, - image, - screen_color, - seg_model, - cross_check, - ) - .await - { - Ok(image) => { - record_editor_bgfilter_success(); - Ok(image) - } - Err(error) => { - record_editor_bgfilter_failure(state); - tracing::warn!( - provider = "bgfilter", - screen_color = screen_color.hex, - seg_model, - cross_check, - error = %error, - error_details = ?error.details(), - "editor_bgfilter_fallback_to_aliyun_matting" - ); - crate::external_api_audit::record_matting_external_api_failure( - state, - audit, - "bgfilter", - state.config.editor_bgfilter_base_url.clone(), - "editor-screen-background-removal", - "bgfilter_segment", - crate::external_api_audit::matting_failure_audit_status_code(&error), - crate::external_api_audit::matting_failure_audit_timeout(&error), - crate::external_api_audit::matting_failure_audit_latency_ms(&error), - error.message().to_string(), - crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), - ) - .await; - fallback_editor_screen_background_removal(state, image, screen_color, audit).await + let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1; + let mut final_error = None; + for attempt in 1..=max_attempts { + match request_editor_generated_screen_background_with_bgfilter( + state, + image, + screen_color, + seg_model, + cross_check, + ) + .await + { + Ok(image) => { + record_editor_bgfilter_success(); + return Ok(image); + } + Err(error) => { + record_editor_bgfilter_failure(state); + let will_retry = attempt < max_attempts; + tracing::warn!( + provider = "bgfilter", + screen_color = screen_color.hex, + seg_model, + cross_check, + attempt, + max_attempts, + will_retry, + error = %error, + error_details = ?error.details(), + "editor_bgfilter_request_attempt_failed" + ); + crate::external_api_audit::record_matting_external_api_failure( + state, + audit, + "bgfilter", + state.config.editor_bgfilter_base_url.clone(), + "editor-screen-background-removal", + "bgfilter_segment", + crate::external_api_audit::matting_failure_audit_status_code(&error), + crate::external_api_audit::matting_failure_audit_timeout(&error), + crate::external_api_audit::matting_failure_audit_latency_ms(&error), + error.message().to_string(), + crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), + ) + .await; + if will_retry { + continue; + } + final_error = Some(error); + } } } + + if let Some(error) = final_error { + tracing::warn!( + provider = "bgfilter", + screen_color = screen_color.hex, + seg_model, + cross_check, + error = %error, + error_details = ?error.details(), + "editor_bgfilter_fallback_to_aliyun_matting" + ); + return fallback_editor_screen_background_removal(state, image, screen_color, audit).await; + } + + fallback_editor_screen_background_removal(state, image, screen_color, audit).await } /// BgFilter 不可用(熔断打开或调用失败)时的统一兜底链:先阿里云通用抠图,失败再退本地键色扣除。 @@ -2580,15 +2607,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( timeout_ms, "editor_bgfilter_request_start" ); - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_millis(timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "bgfilter", - "message": format!("创建 BgFilter HTTP 客户端失败:{error}"), - })) - })?; + let http_client = state.editor_bgfilter_http_client(); let file_part = reqwest::multipart::Part::bytes(image.bytes.clone()) .file_name(source_file_name) .mime_str(source_mime_type.as_str()) @@ -7855,7 +7874,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", - "EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK", + "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "if is_character_generation", ], ); @@ -7866,7 +7885,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", - "EDITOR_BGFILTER_CHARACTER_IMAGE_CROSS_CHECK", + "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", ], ); assert_function_contains( @@ -7919,7 +7938,10 @@ mod tests { // BgFilter 调用失败分支同样走该兜底链。 "editor_bgfilter_circuit_open_remaining", "fallback_editor_screen_background_removal", + "EDITOR_BGFILTER_RETRY_COUNT + 1", "request_editor_generated_screen_background_with_bgfilter", + "will_retry", + "record_matting_external_api_failure", "fallback_editor_screen_background_removal", ], ); @@ -7939,6 +7961,7 @@ mod tests { &[ "editor_bgfilter_endpoint", "editor_bgfilter_request_timeout_ms.max(1)", + "state.editor_bgfilter_http_client()", "\"screen_color\"", "\"seg_model\"", "seg_model.to_string()", @@ -7946,6 +7969,12 @@ mod tests { "editor_bgfilter_cross_check_form_value(cross_check)", ], ); + assert_function_not_contains( + source, + "async fn request_editor_generated_screen_background_with_bgfilter", + "async fn request_editor_background_removal_image", + &["reqwest::Client::builder"], + ); assert_function_contains( source, "async fn request_editor_background_removal_image", @@ -8017,6 +8046,27 @@ mod tests { ); } + #[test] + fn editor_bgfilter_retries_once_before_fallback() { + let source = include_str!("editor_project.rs"); + assert!(source.contains("const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;")); + assert_function_contains_in_order( + source, + "async fn remove_editor_generated_screen_background_with_bgfilter", + "async fn fallback_editor_screen_background_removal", + &[ + "let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1", + "for attempt in 1..=max_attempts", + "request_editor_generated_screen_background_with_bgfilter", + "let will_retry = attempt < max_attempts", + "if will_retry", + "continue", + "final_error = Some(error)", + "fallback_editor_screen_background_removal", + ], + ); + } + #[test] fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() { assert_eq!( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index dc0da4fc0..c089c4492 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -276,6 +276,7 @@ pub struct AppStateInner { llm_client: Option, creative_agent_gpt5_client: Option, matting_client: Option, + editor_bgfilter_http_client: reqwest::Client, creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 @@ -510,6 +511,7 @@ impl AppState { let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let matting_client = build_matting_client(&config)?; + let editor_bgfilter_http_client = build_editor_bgfilter_http_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); @@ -550,6 +552,7 @@ impl AppState { llm_client, creative_agent_gpt5_client, matting_client, + editor_bgfilter_http_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), profile_recharge_order_updates, @@ -1248,6 +1251,10 @@ impl AppState { self.matting_client.as_ref() } + pub fn editor_bgfilter_http_client(&self) -> &reqwest::Client { + &self.editor_bgfilter_http_client + } + pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() } @@ -1875,6 +1882,25 @@ fn build_matting_client(config: &AppConfig) -> Result, App .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string())) } +fn build_editor_bgfilter_http_client( + config: &AppConfig, +) -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_millis( + config.editor_bgfilter_request_timeout_ms.max(1), + )) + .connect_timeout(std::time::Duration::from_secs(30)) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(64) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!( + "构建共享 BgFilter HTTP 客户端失败:{error}" + )) + }) +} + fn build_wechat_client(config: &AppConfig) -> WechatClient { WechatClient::new(WechatConfig { app_id: config.wechat_mini_program_app_id.clone(), -- 2.52.0 From 3101c515dd5e21b1a431e19e41299ee6fe92f823 Mon Sep 17 00:00:00 2001 From: lhk229 Date: Mon, 13 Jul 2026 20:36:01 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=97=A5=E5=BF=97=E5=88=B7=E6=96=B0=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 忽略根目录 nohup 日志,避免 Vite 监听后反复刷新页面。 更新本地 BgFilter 服务地址。 补充 nohup 启动开发栈的日志忽略说明。 --- .env.local | 2 +- .gitignore | 1 + docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ vite.config.ts | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.local b/.env.local index 4eaa9fafe..8e970016c 100644 --- a/.env.local +++ b/.env.local @@ -29,7 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark" GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" -GENARRATIVE_EDITOR_BGFILTER_BASE_URL=https://u39211-9b1c-e4a7a054.westb.seetacloud.com:8443 +GENARRATIVE_EDITOR_BGFILTER_BASE_URL=https://u1082648-97d5-01f90c83.westx.seetacloud.com:8443 APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="" APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 diff --git a/.gitignore b/.gitignore index ee516d36a..b9ff3d97f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ temp*build*/ .worktrees/ .rag/ .env.secrets.local +nohup.out spacetime.local.json deploy/container/api-server.env deploy/container/worker-smoke/ diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 38c55fe08..adaf6cb95 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -33,6 +33,8 @@ npm run dev `npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 +通过 `nohup` 在仓库根目录启动 dev 栈时,`nohup.out` 已被 Vite 和 Git 忽略,避免 API 日志持续追加后触发页面刷新循环;重启 Vite 后生效。 + 单独启动主站前端: ```bash diff --git a/vite.config.ts b/vite.config.ts index afe9b24a4..2d76d36e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,8 @@ import {defineConfig, loadEnv} from 'vite'; export default defineConfig(({mode}) => { const env = loadEnv(mode, __dirname, ''); const ignoredWatchGlobs = [ + // Prevent a root-level nohup log from triggering a Vite reload loop. + '**/nohup.out', '**/.git/**', '**/.worktrees/**', '**/dist/**', -- 2.52.0 From 5299f4bc67ade3b6efc935a3a63ba3b3e681021c Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 13 Jul 2026 14:05:01 +0000 Subject: [PATCH 04/21] =?UTF-8?q?=E5=8C=BA=E5=88=86=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8E=E6=8A=A0=E5=9B=BE=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在现有外部生成任务及摘要投影中持久化 generating/processing 阶段 通过 worker 租约保护 procedure 在真实抠图边界切换为正在处理 更新 BFF 状态映射、任务侧栏测试、迁移兼容、bindings 与架构文档 --- .../shared-memory/decision-log.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../src/character_animation_assets.rs | 23 +++++- .../crates/api-server/src/editor_agent.rs | 1 + .../crates/api-server/src/editor_project.rs | 63 ++++++++++++++- .../api-server/src/external_editor_api.rs | 2 + .../api-server/src/external_generation.rs | 64 +++++++++++++++ .../src/external_generation_worker.rs | 45 ++++++++--- .../src/external_generation.rs | 25 ++++++ server-rs/crates/spacetime-client/src/lib.rs | 7 +- .../crates/spacetime-client/src/mapper.rs | 6 +- .../src/mapper/external_generation.rs | 23 ++++++ .../spacetime-client/src/module_bindings.rs | 4 + ..._generation_job_phase_update_input_type.rs | 18 +++++ .../external_generation_job_snapshot_type.rs | 1 + ...al_generation_job_summary_snapshot_type.rs | 1 + .../external_generation_job_summary_type.rs | 3 + .../external_generation_job_type.rs | 3 + ...neration_job_phase_and_return_procedure.rs | 62 +++++++++++++++ .../src/external_generation.rs | 78 +++++++++++++++++++ .../crates/spacetime-module/src/migration.rs | 9 +++ .../ImageCanvasTaskSidebarView.test.tsx | 4 +- 24 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ca371cf90..2496537ca 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-13 外部生成任务持久化真实执行阶段 + +- 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。 +- 决策:不新增 DB 表,在既有 `external_generation_job` 与 `external_generation_job_summary` 末尾追加带默认值的可选 `phase`。worker claim 时写 `generating`;角色生图、图标 spritesheet、UI 素材提取在调用 BgFilter 前,角色动作在视频生成返回并开始抽帧/逐帧抠图前,手动去背景在执行开始时,通过 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。BFF 将 `running + processing` 映射为“正在处理”,其它 `running`(含旧数据 `phase=None`)映射为“正在生成”;前端只展示后端投影。 +- 影响范围:`external_generation_job`、`external_generation_job_summary`、SpacetimeDB procedure / typed client / bindings、图片画布生成 worker、任务列表 BFF 与相关文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、外部生成 module/client/api-server 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-13 角色动作逐帧开启 BgFilter cross-check - 背景:角色动作逐帧抠图此前为减少额外推理开销固定传 `cross_check=off`,但动作帧同样需要保留发丝、镂空和运动边缘质量。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fdea14f6f..94a0872e7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -25,7 +25,7 @@ - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 +- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或独立去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6908adc2c..cefdcf63c 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -14,7 +14,7 @@ - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 - 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 -- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。 +- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 - 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 ## Module 与 Interface @@ -24,6 +24,7 @@ - `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。 - `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。 - `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。 +- `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。 - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 - `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。 @@ -39,6 +40,7 @@ - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 +- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或独立去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index be867894c..7333741bf 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -270,14 +270,14 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 +- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 - 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。 ### `external_generation_job_event` diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 7fcbe6949..fbd654242 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -65,7 +65,8 @@ use crate::{ }, editor_project::{ EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, - EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, + EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, + PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, @@ -605,6 +606,7 @@ pub async fn generate_editor_character_animation( request_context, owner_user_id, Ok(Json(payload)), + None, ) .await } @@ -614,6 +616,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( request_context: RequestContext, owner_user_id: String, payload: Result, JsonRejection>, + phase_reporter: Option, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_animation_error_response( @@ -728,6 +731,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner( source_data_url.as_str(), ) .await?; + if let Some(reporter) = phase_reporter.as_ref() { + reporter.report_processing(&state).await?; + } let frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), @@ -5689,6 +5695,21 @@ mod tests { ); } + #[test] + fn editor_character_animation_reports_processing_after_video_generation() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "request_editor_character_animation_preview", + "reporter.report_processing(&state).await?", + "extract_and_persist_editor_character_animation_frames", + ], + ); + } + #[test] fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs index 6ac84f022..3b3b052ac 100644 --- a/server-rs/crates/api-server/src/editor_agent.rs +++ b/server-rs/crates/api-server/src/editor_agent.rs @@ -1669,6 +1669,7 @@ async fn execute_editor_agent_tool_call( owner_user_id: conversation.owner_user_id.clone(), audit_subject_user_id: Some(conversation.owner_user_id.clone()), audit_project_id: Some(conversation.project_id.clone()), + phase_reporter: None, }; let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 73042bbd1..6e4789e89 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -43,7 +43,7 @@ use spacetime_client::{ EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - SpacetimeClientError, + ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError, }; use crate::{ @@ -314,6 +314,7 @@ pub(crate) struct EditorGenerationCaller { pub(crate) owner_user_id: String, pub(crate) audit_subject_user_id: Option, pub(crate) audit_project_id: Option, + pub(crate) phase_reporter: Option, } impl EditorGenerationCaller { @@ -323,8 +324,52 @@ impl EditorGenerationCaller { audit_subject_user_id: Some(owner_user_id.clone()), owner_user_id, audit_project_id: None, + phase_reporter: None, } } + + async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { + if let Some(reporter) = self.phase_reporter.as_ref() { + reporter.report_processing(state).await?; + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct EditorGenerationPhaseReporter { + job_id: String, + worker_id: String, + lease_token: String, +} + +impl EditorGenerationPhaseReporter { + pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self { + Self { + job_id, + worker_id, + lease_token, + } + } + + pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> { + state + .spacetime_client() + .update_external_generation_job_phase(ExternalGenerationJobPhaseUpdateRecordInput { + job_id: self.job_id.clone(), + worker_id: self.worker_id.clone(), + lease_token: self.lease_token.clone(), + phase: "processing".to_string(), + }) + .await + .map(|_| ()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "external-generation-phase", + "message": format!("更新外部生成任务处理阶段失败:{error}"), + })) + }) + } } #[derive(Debug, Serialize)] @@ -1575,6 +1620,7 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -2296,6 +2342,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { let started_at = Instant::now(); + caller.report_processing_phase(state).await?; let source_image = parse_editor_reference_image( state, caller.owner_user_id.as_str(), @@ -3257,6 +3304,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -3559,6 +3607,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -7873,6 +7922,7 @@ mod tests { "fn normalize_editor_image_generation_size", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "if is_character_generation", @@ -7894,6 +7944,7 @@ mod tests { "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], @@ -7904,10 +7955,20 @@ mod tests { "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); + assert_function_contains_in_order( + source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "struct EditorBackgroundRemovalImage", + &[ + "caller.report_processing_phase(state).await?", + "request_editor_background_removal_image", + ], + ); assert_function_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index eb5ac1a69..196e39041 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -682,6 +682,7 @@ pub async fn generate_external_editor_character_animation( request_context, principal.owner_user_id().to_string(), payload, + None, ) .await } @@ -748,6 +749,7 @@ fn editor_generation_caller( owner_user_id: principal.owner_user_id().to_string(), audit_subject_user_id: Some(principal.owner_user_id().to_string()), audit_project_id: normalize_optional_string(project_id), + phase_reporter: None, } } diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 5d3bacc4b..55e43521c 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -167,6 +167,9 @@ fn map_external_generation_job_status( ) -> ExternalGenerationJobStatusRecord { let (status, phase_detail, progress) = match job.status.as_str() { "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" if job.phase.as_deref() == Some("processing") => { + (ExternalGenerationJobStatus::Running, "正在处理。", 70) + } "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), "failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), @@ -293,9 +296,70 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, + phase: None, }); assert_eq!(task.request_prompt.as_deref(), Some("发光主视觉")); assert_eq!(task.status, ExternalGenerationJobStatus::Completed); } + + #[test] + fn maps_running_processing_phase_from_backend_projection() { + let status = map_external_generation_job_status(ExternalGenerationJobSummaryRecord { + job_id: "task-processing".to_string(), + job_kind: "editor_character_animation_generation".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "角色动作生成".to_string(), + request_prompt: None, + status: "running".to_string(), + last_error_message: None, + created_at: "2026-07-13T08:00:00Z".to_string(), + started_at: Some("2026-07-13T08:00:01Z".to_string()), + completed_at: None, + updated_at: "2026-07-13T08:00:10Z".to_string(), + updated_at_micros: 1_000, + price_mud_points: 4, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: Some("processing".to_string()), + }); + + assert_eq!(status.status, ExternalGenerationJobStatus::Running); + assert_eq!(status.phase_detail, "正在处理。"); + assert_eq!(status.progress, 70); + } + + #[test] + fn maps_legacy_running_job_without_phase_as_generating() { + let mut job = ExternalGenerationJobSummaryRecord { + job_id: "task-legacy".to_string(), + job_kind: "editor_image_generation".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片生成".to_string(), + request_prompt: None, + status: "running".to_string(), + last_error_message: None, + created_at: "2026-07-13T08:00:00Z".to_string(), + started_at: Some("2026-07-13T08:00:01Z".to_string()), + completed_at: None, + updated_at: "2026-07-13T08:00:10Z".to_string(), + updated_at_micros: 1_000, + price_mud_points: 4, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: None, + }; + + let legacy = map_external_generation_job_status(job.clone()); + assert_eq!(legacy.phase_detail, "正在生成。"); + job.phase = Some("generating".to_string()); + let generating = map_external_generation_job_status(job); + assert_eq!(generating.phase_detail, "正在生成。"); + } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 993a75ad9..2427661b6 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -28,7 +28,7 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EditorBackgroundRemovalRequest, EditorGenerationCaller, + EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, @@ -666,7 +666,7 @@ async fn process_external_generation_job_once( match generate_editor_image_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -694,7 +694,7 @@ async fn process_external_generation_job_once( match edit_editor_image_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -723,7 +723,7 @@ async fn process_external_generation_job_once( match remove_editor_image_background_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -751,7 +751,7 @@ async fn process_external_generation_job_once( match generate_editor_icon_spritesheet_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -779,7 +779,7 @@ async fn process_external_generation_job_once( match extract_editor_ui_design_assets_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -810,6 +810,7 @@ async fn process_external_generation_job_once( request_context, job.owner_user_id.clone(), Ok(Json(payload)), + Some(editor_generation_phase_reporter(&worker_id, &job)?), ) .await { @@ -993,12 +994,27 @@ fn worker_request_context(job: &ExternalGenerationJobRecord) -> RequestContext { ) } -fn editor_generation_worker_caller(job: &ExternalGenerationJobRecord) -> EditorGenerationCaller { - EditorGenerationCaller { +fn editor_generation_worker_caller( + worker_id: &str, + job: &ExternalGenerationJobRecord, +) -> Result { + Ok(EditorGenerationCaller { owner_user_id: job.owner_user_id.clone(), audit_subject_user_id: Some(job.owner_user_id.clone()), audit_project_id: Some(job.source_entity_id.clone()), - } + phase_reporter: Some(editor_generation_phase_reporter(worker_id, job)?), + }) +} + +fn editor_generation_phase_reporter( + worker_id: &str, + job: &ExternalGenerationJobRecord, +) -> Result { + Ok(EditorGenerationPhaseReporter::new( + job.job_id.clone(), + worker_id.to_string(), + require_job_lease_token(job)?, + )) } async fn complete_editor_generation_job( @@ -1180,6 +1196,16 @@ mod tests { assert_eq!(guard.lease_token.as_deref(), Some("lease-1")); } + #[test] + fn editor_worker_caller_carries_phase_reporter() { + let job = external_generation_job_record_fixture(Some("lease-1")); + + let caller = editor_generation_worker_caller("worker-a", &job) + .expect("worker caller should include claimed job phase reporter"); + + assert!(caller.phase_reporter.is_some()); + } + #[test] fn worker_write_guard_requires_claimed_job_lease_token() { let job = external_generation_job_record_fixture(None); @@ -1314,6 +1340,7 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, + phase: Some("generating".to_string()), } } } diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index 6d70087ea..5ee02ade6 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -257,6 +257,31 @@ impl SpacetimeClient { .await } + pub async fn update_external_generation_job_phase( + &self, + input: ExternalGenerationJobPhaseUpdateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "update_external_generation_job_phase_and_return", + move |connection, sender| { + connection + .procedures() + .update_external_generation_job_phase_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn fail_external_generation_job( &self, input: ExternalGenerationJobFailRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 2ae6f57ca..172ab2e6a 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -56,9 +56,10 @@ pub use mapper::{ ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, - ExternalGenerationJobListRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, - ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, + ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, + ExternalGenerationQueueStatsRecord, FeatureGateConfigRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c29d71cde..d0fe61822 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -106,9 +106,9 @@ pub use self::external_generation::{ ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, - ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, - ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, - ExternalGenerationQueueStatsRecord, + ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, + ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, }; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index fa1901aac..77535e7b6 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -54,6 +54,17 @@ impl From for ExternalGenerationJobR } } +impl From for ExternalGenerationJobPhaseUpdateInput { + fn from(input: ExternalGenerationJobPhaseUpdateRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + phase: input.phase, + } + } +} + impl From for ExternalGenerationJobFailInput { fn from(input: ExternalGenerationJobFailRecordInput) -> Self { Self { @@ -237,6 +248,7 @@ fn map_external_generation_job_snapshot( .notification_acknowledged_at_micros .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, + phase: snapshot.phase, } } @@ -264,6 +276,7 @@ fn map_external_generation_job_summary_snapshot( .notification_acknowledged_at_micros .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, + phase: snapshot.phase, } } @@ -309,6 +322,14 @@ pub struct ExternalGenerationJobRenewLeaseRecordInput { pub renewed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobPhaseUpdateRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobFailRecordInput { pub job_id: String, @@ -369,6 +390,7 @@ pub struct ExternalGenerationJobRecord { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -400,6 +422,7 @@ pub struct ExternalGenerationJobSummaryRecord { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index c0db8c832..974675948 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -485,6 +485,7 @@ pub mod external_generation_job_get_input_type; pub mod external_generation_job_list_input_type; pub mod external_generation_job_payload_compaction_input_type; pub mod external_generation_job_payload_compaction_procedure_result_type; +pub mod external_generation_job_phase_update_input_type; pub mod external_generation_job_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_snapshot_type; @@ -1232,6 +1233,7 @@ pub mod update_editor_asset_and_return_procedure; pub mod update_editor_asset_folder_and_return_procedure; pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_editor_showcase_asset_display_and_return_procedure; +pub mod update_external_generation_job_phase_and_return_procedure; pub mod update_jump_hop_work_procedure; pub mod update_match_3_d_work_procedure; pub mod update_puzzle_clear_work_procedure; @@ -1823,6 +1825,7 @@ pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; pub use external_generation_job_list_input_type::ExternalGenerationJobListInput; pub use external_generation_job_payload_compaction_input_type::ExternalGenerationJobPayloadCompactionInput; pub use external_generation_job_payload_compaction_procedure_result_type::ExternalGenerationJobPayloadCompactionProcedureResult; +pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; @@ -2570,6 +2573,7 @@ pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return pub use update_editor_asset_folder_and_return_procedure::update_editor_asset_folder_and_return; pub use update_editor_project_resource_showcase_and_return_procedure::update_editor_project_resource_showcase_and_return; pub use update_editor_showcase_asset_display_and_return_procedure::update_editor_showcase_asset_display_and_return; +pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; pub use update_jump_hop_work_procedure::update_jump_hop_work; pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_clear_work_procedure::update_puzzle_clear_work; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs new file mode 100644 index 000000000..4d91f5eea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobPhaseUpdateInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + +impl __sdk::InModule for ExternalGenerationJobPhaseUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs index a5a208caf..8778d141c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct ExternalGenerationJobSnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs index 492102908..653af336c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSummarySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs index 685be2b15..56fa51c24 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummary { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSummary { @@ -53,6 +54,7 @@ pub struct ExternalGenerationJobSummaryCols { pub refund_ledger_id: __sdk::__query_builder::Col>, pub notification_acknowledged_at: __sdk::__query_builder::Col>, + pub phase: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { @@ -78,6 +80,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { table_name, "notification_acknowledged_at", ), + phase: __sdk::__query_builder::Col::new(table_name, "phase"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs index ec62daa60..f63d494a4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs @@ -31,6 +31,7 @@ pub struct ExternalGenerationJob { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJob { @@ -67,6 +68,7 @@ pub struct ExternalGenerationJobCols { pub refund_ledger_id: __sdk::__query_builder::Col>, pub notification_acknowledged_at: __sdk::__query_builder::Col>, + pub phase: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJob { @@ -106,6 +108,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJob { table_name, "notification_acknowledged_at", ), + phase: __sdk::__query_builder::Col::new(table_name, "phase"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs new file mode 100644 index 000000000..76a8bce83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateExternalGenerationJobPhaseAndReturnArgs { + pub input: ExternalGenerationJobPhaseUpdateInput, +} + +impl __sdk::InModule for UpdateExternalGenerationJobPhaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_external_generation_job_phase_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_external_generation_job_phase_and_return { + fn update_external_generation_job_phase_and_return( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + ) { + self.update_external_generation_job_phase_and_return_then(input, |_, _| {}); + } + + fn update_external_generation_job_phase_and_return_then( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_external_generation_job_phase_and_return for super::RemoteProcedures { + fn update_external_generation_job_phase_and_return_then( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "update_external_generation_job_phase_and_return", + UpdateExternalGenerationJobPhaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index c4bf6c665..6c62cf888 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -7,6 +7,8 @@ const EXTERNAL_GENERATION_STATUS_RUNNING: &str = "running"; const EXTERNAL_GENERATION_STATUS_COMPLETED: &str = "completed"; const EXTERNAL_GENERATION_STATUS_FAILED: &str = "failed"; const EXTERNAL_GENERATION_STATUS_CANCELLED: &str = "cancelled"; +const EXTERNAL_GENERATION_PHASE_GENERATING: &str = "generating"; +const EXTERNAL_GENERATION_PHASE_PROCESSING: &str = "processing"; const EXTERNAL_GENERATION_EVENT_ENQUEUED: &str = "enqueued"; const EXTERNAL_GENERATION_EVENT_CLAIMED: &str = "claimed"; const EXTERNAL_GENERATION_EVENT_LEASE_RENEWED: &str = "lease_renewed"; @@ -82,6 +84,8 @@ pub struct ExternalGenerationJob { pub(crate) refund_ledger_id: Option, #[default(None::)] pub(crate) notification_acknowledged_at: Option, + #[default(None::)] + pub(crate) phase: Option, } #[spacetimedb::table( @@ -134,6 +138,8 @@ pub struct ExternalGenerationJobSummary { pub(crate) price_mud_points: u64, pub(crate) refund_ledger_id: Option, pub(crate) notification_acknowledged_at: Option, + #[default(None::)] + pub(crate) phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -169,6 +175,14 @@ pub struct ExternalGenerationJobRenewLeaseInput { pub renewed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobPhaseUpdateInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobCompleteInput { pub job_id: String, @@ -252,6 +266,7 @@ pub struct ExternalGenerationJobSnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -284,6 +299,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -424,6 +440,23 @@ pub fn renew_external_generation_job_lease_and_return( } } +#[spacetimedb::procedure] +pub fn update_external_generation_job_phase_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobPhaseUpdateInput, +) -> ExternalGenerationJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + update_external_generation_job_phase_tx(tx, input.clone()) + }) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + #[spacetimedb::procedure] pub fn fail_external_generation_job_and_return( ctx: &mut ProcedureContext, @@ -678,6 +711,7 @@ fn enqueue_external_generation_job_tx( price_mud_points: input.price_mud_points, refund_ledger_id: None, notification_acknowledged_at: None, + phase: None, }; persist_external_generation_job_row(ctx, row.clone()); insert_external_generation_job_event( @@ -752,6 +786,7 @@ fn claim_external_generation_jobs_tx( claim_time, ); row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string(); + row.phase = Some(EXTERNAL_GENERATION_PHASE_GENERATING.to_string()); row.worker_id = Some(worker_id.clone()); row.lease_expires_at = Some(lease_expires_at); row.lease_token = Some(lease_token); @@ -1268,6 +1303,23 @@ fn renew_external_generation_job_lease_tx( Ok(map_external_generation_job_row(row)) } +fn update_external_generation_job_phase_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobPhaseUpdateInput, +) -> Result { + let phase = normalize_external_generation_job_phase(&input.phase)?; + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + row.phase = Some(phase); + row.updated_at = ctx.timestamp; + persist_external_generation_job_row(ctx, row.clone()); + Ok(map_external_generation_job_row(row)) +} + fn fail_external_generation_job_tx( ctx: &ReducerContext, input: ExternalGenerationJobFailInput, @@ -1672,6 +1724,18 @@ fn normalize_external_generation_job_status_filter(statuses: &[String]) -> Vec<& .collect() } +fn normalize_external_generation_job_phase(phase: &str) -> Result { + match phase.trim() { + EXTERNAL_GENERATION_PHASE_GENERATING => { + Ok(EXTERNAL_GENERATION_PHASE_GENERATING.to_string()) + } + EXTERNAL_GENERATION_PHASE_PROCESSING => { + Ok(EXTERNAL_GENERATION_PHASE_PROCESSING.to_string()) + } + _ => Err("external_generation_job.phase 只支持 generating 或 processing".to_string()), + } +} + fn record_external_generation_claimable_age( stats: &mut ExternalGenerationQueueStatsSnapshot, row: &ExternalGenerationJob, @@ -1760,6 +1824,7 @@ fn build_external_generation_job_summary_row( price_mud_points: row.price_mud_points, refund_ledger_id: row.refund_ledger_id.clone(), notification_acknowledged_at: row.notification_acknowledged_at, + phase: row.phase.clone(), } } @@ -1976,6 +2041,7 @@ fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenera price_mud_points: row.price_mud_points, refund_ledger_id: row.refund_ledger_id, notification_acknowledged_at_micros, + phase: row.phase, } } @@ -2005,6 +2071,7 @@ fn map_external_generation_job_summary_row( notification_acknowledged_at_micros: row .notification_acknowledged_at .map(|value| value.to_micros_since_unix_epoch()), + phase: row.phase, } } @@ -2041,6 +2108,7 @@ fn map_external_generation_job_summary_to_compat_snapshot( price_mud_points: summary.price_mud_points, refund_ledger_id: summary.refund_ledger_id, notification_acknowledged_at_micros: summary.notification_acknowledged_at_micros, + phase: summary.phase, } } @@ -2368,6 +2436,15 @@ fn normalize_optional_text(value: &str) -> Option { mod tests { use super::*; + #[test] + fn external_generation_phase_only_accepts_known_execution_phases() { + assert_eq!( + normalize_external_generation_job_phase(" processing ").as_deref(), + Ok(EXTERNAL_GENERATION_PHASE_PROCESSING) + ); + assert!(normalize_external_generation_job_phase("uploading").is_err()); + } + #[test] fn external_generation_job_result_failure_is_structured() { let result = failed_external_generation_job_result("失败".to_string()); @@ -2849,6 +2926,7 @@ mod tests { price_mud_points: 10, refund_ledger_id: None, notification_acknowledged_at: None, + phase: None, } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0dc48da85..e760788f7 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1350,6 +1350,15 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "external_generation_job" || table_name == "external_generation_job_summary" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:执行阶段晚于外部生成主表和摘要投影加入,旧迁移包按未知阶段兼容; + // BFF 会把 running + phase=null 视为 generating。 + object + .entry("phase".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index ce29b9bda..3a0874626 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -219,6 +219,8 @@ describe('ImageCanvasTaskSidebarView', () => { createExternalTask({ jobId: 'active-current', startedAt, + phaseDetail: '正在处理。', + progress: 70, }), createExternalTask({ jobId: 'active-other-project', @@ -258,7 +260,7 @@ describe('ImageCanvasTaskSidebarView', () => { expect(await screen.findByText('图片画布生成图片')).toBeTruthy(); expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy(); - expect(screen.getByText('正在生成第 2/4 段。')).toBeTruthy(); + expect(screen.getByText('正在处理。')).toBeTruthy(); expect(screen.queryByText(/35%/u)).toBeNull(); expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/总进度/u)).toBeNull(); -- 2.52.0 From 43471ed9b42db1187d32c90cc4f65bcafffd0a6f Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 14 Jul 2026 07:22:43 +0000 Subject: [PATCH 05/21] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=8A=A0=E5=9B=BE=E5=88=B0BGFilter=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为标准纯色背景请求显式传递background_mode=flat。 将手动去背景迁移到background_mode=complex、seg_model=birefnet、cross_check=off。 删除独立BiRefNet服务地址与超时配置并保留token兼容别名。 同步更新前端provider、定向测试、架构文档与项目决策记录。 --- .../shared-memory/decision-log.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 3 +- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 2 +- server-rs/crates/api-server/src/config.rs | 74 +--------- .../crates/api-server/src/editor_project.rs | 128 +++++++++--------- .../useImageCanvasGenerationWorkflow.test.tsx | 6 +- .../useImageCanvasGenerationWorkflow.ts | 2 +- .../image-editor/editorProjectClient.test.ts | 2 +- 12 files changed, 86 insertions(+), 148 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2496537ca..8eaa60f79 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-14 手动去背景迁移到 BgFilter complex 模式 + +- 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。 +- 决策:`POST /api/editor/images/background-removals` 保持前端与 BFF 契约不变,worker 改用现有 BgFilter 地址、token、超时和共享 HTTP client。multipart 提交图片文件、`background_mode=complex`、`seg_model=birefnet` 与 `cross_check=off`,不提交 `screen_color`。标准纯色背景的角色形象、图标 spritesheet、UI 素材提取和角色动作逐帧抠图继续使用 `background_mode=flat`。删除独立 BiRefNet base URL / timeout 配置;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 仅作为 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 的兼容回退别名。 +- 影响范围:图片画布手动去背景 worker、BgFilter HTTP 协议、api-server 配置、资源元数据、前端 provider 展示和相关文档。 +- 验证方式:运行 api-server BGFilter / 手动去背景定向测试、前端 editorProjectClient / 画布 workflow 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-07-13 外部生成任务持久化真实执行阶段 - 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 94a0872e7..41571cbf8 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,8 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(cross_check=on)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定传 `background_mode=flat`,明确使用现有单一纯色背景抠图模式;同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 2026-07-14 更新:上句关于手动去背景代理独立 BiRefNet 和使用独立 base URL 的口径已废止。手动去背景仍走同一 BFF/队列,但 worker 改用 BgFilter `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不发送 `screen_color`;标准纯色背景四条链路继续使用 `background_mode=flat`。两类模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 @@ -86,6 +87,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 +- 2026-07-14 更新:该接口的上游已替换为 BgFilter complex;请求字段和回包结构保持不变,服务端 multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,provider 返回 `BgFilter`。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后恢复到业务目标精确尺寸,再落 OSS、project resource、账号素材和画布快照。16 对齐尺寸不得泄漏到响应、持久化资源或图层 Resolution。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7333741bf..70b5c1024 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -231,6 +231,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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=`、`seg_model=` 和 `cross_check=`;角色形象生成和角色动作逐帧去背固定传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 +- 2026-07-14 更新:上句关于“手动去背景继续代理独立 BiRefNet”的口径已废止。手动 `POST /api/editor/images/background-removals` 现与标准纯色背景链路共用 BgFilter 配置和 HTTP client,固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat` 并继续沿用各自的 `screen_color`、`seg_model`、`cross_check` 策略。独立 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 与 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS` 已删除,旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。单次 BgFilter 调用失败后立即重试 `1` 次,第二次仍失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发出的失败调用都单独写审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - 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。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index adaf6cb95..afeccb17d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -65,7 +65,8 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 -图片画布角色图、图标素材和 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` 日志。 +图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `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,再看带 `background_mode=flat` 的 `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` 日志。 +手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 `我的` 页签或排障面板展示队列等待时,只读取 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 暴露到前端。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index 1829eb851..80547dccf 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -61,7 +61,7 @@ 仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。 ``` -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter,固定传 `background_mode=flat` 并按默认 `segModel=birefnet` 透明化,然后复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。 - 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。 ## 验收点 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 84a19ac5e..3fc095ea9 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -58,7 +58,7 @@ ## 去背与保存 -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`,请求字段同时包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 - 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。 - 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index fe9c1bec7..e3be44cc5 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,7 +65,7 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=` 和 `seg_model=`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=` 和 `background_mode=flat`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示沿用单一纯色背景抠图模式。这里的 `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"`。 ## 生成规范参考图 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index c30af9396..12bcd4523 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -17,8 +17,6 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600; const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900; const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800; 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 = 180_000; const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3; @@ -64,9 +62,6 @@ pub struct AppConfig { pub wallet_refund_outbox_flush_interval: Duration, pub wallet_refund_outbox_max_bytes: u64, pub editor_generation_pricing_override_path: PathBuf, - pub editor_background_removal_base_url: String, - pub editor_background_removal_token: Option, - pub editor_background_removal_request_timeout_ms: u64, pub editor_bgfilter_base_url: String, pub editor_bgfilter_token: Option, pub editor_bgfilter_request_timeout_ms: u64, @@ -309,11 +304,6 @@ impl Default for AppConfig { wallet_refund_outbox_max_bytes: 64 * 1024 * 1024, editor_generation_pricing_override_path: crate::editor_generation_config::default_editor_generation_pricing_override_path(), - editor_background_removal_base_url: DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL - .to_string(), - 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, @@ -507,18 +497,6 @@ impl AppConfig { { config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path); } - if let Some(base_url) = - read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL"]) - { - config.editor_background_removal_base_url = base_url; - } - config.editor_background_removal_token = - read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"]); - if let Some(timeout_ms) = read_first_positive_u64_env(&[ - "GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS", - ]) { - 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; @@ -1591,8 +1569,7 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - AppConfig, DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL, - DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS, DEFAULT_EDITOR_BGFILTER_BASE_URL, + AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL, DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD, DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS, @@ -1617,15 +1594,6 @@ mod tests { assert!(config.llm_base_url.is_empty()); // assert!(config.apimart_base_url.is_empty()); assert!(config.vector_engine_base_url.is_empty()); - assert_eq!( - config.editor_background_removal_base_url, - DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL - ); - assert_eq!( - config.editor_background_removal_request_timeout_ms, - 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 @@ -2338,46 +2306,6 @@ mod tests { } } - #[test] - fn from_env_reads_editor_background_removal_settings() { - let _guard = ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .expect("env lock should not poison"); - - unsafe { - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL"); - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"); - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS"); - std::env::set_var( - "GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL", - "http://10.0.0.12:8090", - ); - std::env::set_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN", "token-1"); - std::env::set_var( - "GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS", - "90000", - ); - } - - let config = AppConfig::from_env(); - assert_eq!( - config.editor_background_removal_base_url, - "http://10.0.0.12:8090" - ); - assert_eq!( - config.editor_background_removal_token.as_deref(), - Some("token-1") - ); - assert_eq!(config.editor_background_removal_request_timeout_ms, 90_000); - - unsafe { - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL"); - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"); - std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS"); - } - } - #[test] fn from_env_reads_editor_bgfilter_settings_and_reuses_background_token() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6e4789e89..10381bb3a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -115,6 +115,8 @@ const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source" const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; +const EDITOR_BGFILTER_BACKGROUND_MODE_FLAT: &str = "flat"; +const EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX: &str = "complex"; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false; const EDITOR_BGFILTER_RETRY_COUNT: usize = 1; @@ -2363,7 +2365,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( "background-removal", "removed-background", "result", - "birefnet", + "bgfilter-complex", ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); @@ -2382,8 +2384,8 @@ pub(crate) async fn remove_editor_image_background_for_owner( height: removed.height, prompt: "remove background".to_string(), actual_prompt: None, - model: "BiRefNet".to_string(), - provider: "BiRefNet".to_string(), + model: "BgFilter complex".to_string(), + provider: "BgFilter".to_string(), task_id: task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, @@ -2424,7 +2426,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( source_type: "generated", task_id, elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), - provider: "BiRefNet", + provider: "BgFilter", resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, @@ -2649,6 +2651,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( mime_type = %source_mime_type, screen_color = screen_color.hex, seg_model, + background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, cross_check, input_bytes, timeout_ms, @@ -2668,6 +2671,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( .part("file", file_part) .text("screen_color", screen_color.hex.to_string()) .text("seg_model", seg_model.to_string()) + .text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_FLAT) .text( "cross_check", editor_bgfilter_cross_check_form_value(cross_check), @@ -2791,13 +2795,10 @@ async fn request_editor_background_removal_image( state: &AppState, source_image: OpenAiReferenceImage, ) -> Result { - let url = editor_background_removal_endpoint(state)?; + let url = editor_bgfilter_endpoint(state)?; let call_id = format!("background-removal-call-{}", current_utc_micros()); let request_started_at = Instant::now(); - let timeout_ms = state - .config - .editor_background_removal_request_timeout_ms - .max(1); + let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1); let input_bytes = source_image.bytes.len(); let source_file_name = source_image.file_name.clone(); let source_mime_type = source_image.mime_type.clone(); @@ -2806,34 +2807,34 @@ async fn request_editor_background_removal_image( upstream_url = %url, file_name = %source_file_name, mime_type = %source_mime_type, + background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, + cross_check = EDITOR_BGFILTER_CROSS_CHECK_DISABLED, input_bytes, timeout_ms, "editor_background_removal_request_start" ); - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_millis(timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "birefnet", - "message": format!("创建抠图 HTTP 客户端失败:{error}"), - })) - })?; + let http_client = state.editor_bgfilter_http_client(); let file_part = reqwest::multipart::Part::bytes(source_image.bytes) .file_name(source_image.file_name) .mime_str(source_image.mime_type.as_str()) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "birefnet", + "provider": "bgfilter", "message": format!("图片 MIME 类型无效:{error}"), })) })?; - let mut request = http_client - .post(url.as_str()) - .multipart(reqwest::multipart::Form::new().part("file", file_part)); + let form = reqwest::multipart::Form::new() + .part("file", file_part) + .text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX) + .text("seg_model", EDITOR_BGFILTER_DEFAULT_SEG_MODEL) + .text( + "cross_check", + editor_bgfilter_cross_check_form_value(EDITOR_BGFILTER_CROSS_CHECK_DISABLED), + ); + let mut request = http_client.post(url.as_str()).multipart(form); if let Some(token) = state .config - .editor_background_removal_token + .editor_bgfilter_token .as_deref() .map(str::trim) .filter(|token| !token.is_empty()) @@ -2848,7 +2849,10 @@ async fn request_editor_background_removal_image( error = %error, "editor_background_removal_request_failed" ); - map_editor_background_removal_error(error) + map_editor_bgfilter_error( + error, + request_started_at.elapsed().as_millis() as u64, + ) })?; let status = response.status(); if !status.is_success() { @@ -2865,8 +2869,8 @@ async fn request_editor_background_removal_image( ); return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "birefnet", - "message": "抠图服务返回非成功状态", + "provider": "bgfilter", + "message": "BgFilter 服务返回非成功状态", "upstreamStatus": status.as_u16(), "upstreamMessage": message.chars().take(500).collect::(), })), @@ -2881,15 +2885,15 @@ async fn request_editor_background_removal_image( .to_string(); let upstream_elapsed_ms = response .headers() - .get("x-birefnet-elapsed-ms") + .get("x-bgfilter-elapsed-ms") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); let bytes = read_editor_background_removal_bytes(response, request_started_at).await?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "birefnet", - "message": "抠图服务未返回图片", + "provider": "bgfilter", + "message": "BgFilter 服务未返回图片", })), ); } @@ -2918,22 +2922,6 @@ async fn request_editor_background_removal_image( }) } -fn editor_background_removal_endpoint(state: &AppState) -> Result { - let base_url = state.config.editor_background_removal_base_url.trim(); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "birefnet", - "message": "抠图服务地址未配置", - })), - ); - } - Ok(format!( - "{}/remove-background", - base_url.trim_end_matches('/') - )) -} - fn editor_bgfilter_endpoint(state: &AppState) -> Result { let base_url = state.config.editor_bgfilter_base_url.trim(); if base_url.is_empty() { @@ -2974,19 +2962,6 @@ fn editor_bgfilter_cross_check_form_value(enabled: bool) -> &'static str { if enabled { "on" } else { "off" } } -fn map_editor_background_removal_error(error: reqwest::Error) -> AppError { - let status = if error.is_timeout() { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - AppError::from_status(status).with_details(json!({ - "provider": "birefnet", - "message": format!("请求抠图服务失败:{error}"), - "timeout": error.is_timeout(), - })) -} - fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError { let status = if error.is_timeout() { StatusCode::GATEWAY_TIMEOUT @@ -3006,7 +2981,7 @@ async fn read_editor_background_removal_bytes( response: reqwest::Response, request_started_at: Instant, ) -> Result, AppError> { - read_editor_image_removal_response_bytes(response, "birefnet", "抠图服务", request_started_at) + read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) .await } @@ -3079,7 +3054,7 @@ fn editor_image_removal_body_read_error( } fn decode_editor_background_removal_image(bytes: &[u8]) -> Result { - decode_editor_removed_background_image(bytes, "birefnet") + decode_editor_removed_background_image(bytes, "bgfilter") } fn decode_editor_removed_background_image( @@ -7041,8 +7016,8 @@ mod tests { source_type: "generated".to_string(), prompt: Some("remove background".to_string()), actual_prompt: None, - model: Some("BiRefNet".to_string()), - provider: Some("BiRefNet".to_string()), + model: Some("BgFilter complex".to_string()), + provider: Some("BgFilter".to_string()), task_id: Some("extgen-cutout".to_string()), source_resource_id: Some("resource-source".to_string()), asset_kind: Some("character".to_string()), @@ -7849,6 +7824,12 @@ mod tests { assert_eq!(editor_bgfilter_cross_check_form_value(false), "off"); } + #[test] + fn editor_bgfilter_background_mode_matches_service_contract() { + assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, "flat"); + assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex"); + } + #[test] fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() { reset_editor_bgfilter_circuit_for_tests(); @@ -8026,6 +8007,8 @@ mod tests { "\"screen_color\"", "\"seg_model\"", "seg_model.to_string()", + "\"background_mode\"", + "EDITOR_BGFILTER_BACKGROUND_MODE_FLAT", "\"cross_check\"", "editor_bgfilter_cross_check_form_value(cross_check)", ], @@ -8039,12 +8022,27 @@ mod tests { assert_function_contains( source, "async fn request_editor_background_removal_image", - "fn editor_background_removal_endpoint", + "fn editor_bgfilter_endpoint", &[ - "editor_background_removal_endpoint", - "\"provider\": \"birefnet\"", + "editor_bgfilter_endpoint", + "editor_bgfilter_request_timeout_ms.max(1)", + "state.editor_bgfilter_http_client()", + "editor_bgfilter_token", + "\"background_mode\"", + "EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX", + "\"seg_model\"", + "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", + "\"cross_check\"", + "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", + "\"provider\": \"bgfilter\"", ], ); + assert_function_not_contains( + source, + "async fn request_editor_background_removal_image", + "fn editor_bgfilter_endpoint", + &["\"screen_color\"", "reqwest::Client::builder"], + ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index d4a5eacfa..f031acc89 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1785,7 +1785,7 @@ describe('useImageCanvasGenerationWorkflow', () => { height: 768, taskId: 'background-removal-task', elapsedMs: 1234, - provider: 'BiRefNet', + provider: 'BgFilter', }); render( { height: 768, taskId: 'background-removal-project-task', elapsedMs: 1234, - provider: 'BiRefNet', + provider: 'BgFilter', project: null, }); }); @@ -1929,7 +1929,7 @@ describe('useImageCanvasGenerationWorkflow', () => { height: 768, taskId: 'background-removal-project-task', elapsedMs: 1234, - provider: 'BiRefNet', + provider: 'BgFilter', project: { projectId: 'project-1', title: '队列项目', diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 75b120429..023df3046 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1635,7 +1635,7 @@ export function useImageCanvasGenerationWorkflow({ originalWidth: result.width, originalHeight: result.height, sourceType: result.sourceType ?? 'generated', - provider: result.provider ?? 'BiRefNet', + provider: result.provider ?? 'BgFilter', taskId: result.taskId ?? null, objectKey: result.resource?.objectKey ?? result.objectKey ?? null, assetObjectId: diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 1b8c34627..2acc1ad06 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1539,7 +1539,7 @@ describe('editorProjectClient', () => { assetObjectId: 'asset-object-cutout', taskId: 'background-removal-1', elapsedMs: 1200, - provider: 'BiRefNet', + provider: 'BgFilter', project: null, }); -- 2.52.0 From dd55834884cd90441ac64dbdc9542b64fc57e838 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 07:38:23 +0000 Subject: [PATCH 06/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=A4=8D=E6=9D=82=E5=8E=BB=E8=83=8C=E6=99=AF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动 BGfilter complex 请求失败后自动重试一次。 补充 api-server 回归测试并记录重试决策。 --- .../shared-memory/decision-log.md | 8 + .../crates/api-server/src/editor_project.rs | 200 +++++++++++++++++- 2 files changed, 206 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8c5a31e72..2bb7bef68 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试 + +- 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。 +- 决策:手动去背景的 complex 请求复用现有 `EDITOR_BGFILTER_RETRY_COUNT=1`,首次请求失败后立即重试一次,两次都失败仍返回最终错误;本次不把手动 complex 接入标准纯色背景链路的阿里云 / 本地兜底,也不改变 flat 路径的熔断状态。 +- 影响范围:图片画布手动去背景 worker、BgFilter complex 请求日志和 api-server 定向测试。 +- 验证方式:运行 `cargo test -p api-server editor_manual_background_removal_retries_once --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/project-memory/shared-memory/decision-log.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-07-14 手动去背景迁移到 BgFilter complex 模式 - 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index beed46c31..411784d88 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2708,7 +2708,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload.source_image_src.as_str(), ) .await?; - let removed = request_editor_background_removal_image(state, source_image).await?; + let removed = request_editor_background_removal_image_with_retry(state, source_image).await?; let task_id = normalize_optional_string(payload.task_id.clone()) .unwrap_or_else(|| format!("background-removal-{}", current_utc_micros())); let persisted = persist_editor_generated_image( @@ -2800,6 +2800,38 @@ struct EditorBackgroundRemovalImage { height: u32, } +async fn request_editor_background_removal_image_with_retry( + state: &AppState, + source_image: OpenAiReferenceImage, +) -> Result { + let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1; + let mut final_error = None; + for attempt in 1..=max_attempts { + match request_editor_background_removal_image(state, source_image.clone()).await { + Ok(removed) => return Ok(removed), + Err(error) => { + let will_retry = attempt < max_attempts; + tracing::warn!( + provider = "bgfilter", + background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, + attempt, + max_attempts, + will_retry, + error = %error, + error_details = ?error.details(), + "editor_background_removal_request_attempt_failed" + ); + if will_retry { + continue; + } + final_error = Some(error); + } + } + } + + Err(final_error.expect("BgFilter complex retry loop should retain its final error")) +} + pub(crate) struct EditorScreenBackgroundRemovalOutput { pub(crate) image: DownloadedOpenAiImage, pub(crate) provider: &'static str, @@ -7000,6 +7032,99 @@ pub(crate) fn current_utc_micros() -> i64 { mod tests { use super::*; use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; + use std::{ + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + }; + + fn encode_test_png(width: u32, height: u32) -> Vec { + let image = image::DynamicImage::new_rgba8(width, height); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test PNG should encode"); + bytes.into_inner() + } + + fn read_mock_http_request(stream: &mut TcpStream) -> Vec { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("mock request read timeout should be set"); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + request.extend_from_slice(&chunk[..bytes_read]); + if expected_total.is_none() + && let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + expected_total = Some(header_end + content_length); + } + if expected_total.is_some_and(|total| request.len() >= total) { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + + request + } + + fn spawn_bgfilter_png_mock( + response_png: Vec, + ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind"); + let address = listener + .local_addr() + .expect("mock listener should expose address"); + let (request_sender, request_receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("BgFilter request should connect"); + let request = read_mock_http_request(&mut stream); + request_sender + .send(request) + .expect("captured request should be delivered"); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-BgFilter-Elapsed-Ms: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response_png.len() + ); + stream + .write_all(headers.as_bytes()) + .expect("mock response headers should be written"); + stream + .write_all(response_png.as_slice()) + .expect("mock response PNG should be written"); + stream.flush().expect("mock response should flush"); + }); + + (format!("http://{address}"), request_receiver, server) + } fn editor_project_resource_for_canvas_test( resource_id: &str, @@ -9324,6 +9449,58 @@ mod tests { assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex"); } + #[tokio::test] + async fn editor_manual_background_removal_sends_complex_bgfilter_http_contract() { + let response_png = encode_test_png(3, 2); + let (base_url, request_receiver, server) = spawn_bgfilter_png_mock(response_png.clone()); + let state = AppState::new(AppConfig { + editor_bgfilter_base_url: base_url, + editor_bgfilter_token: Some("manual-complex-token".to_string()), + editor_bgfilter_request_timeout_ms: 5_000, + ..AppConfig::default() + }) + .expect("state should build"); + let source_image = OpenAiReferenceImage { + bytes: encode_test_png(2, 2), + mime_type: "image/png".to_string(), + file_name: "manual-source.png".to_string(), + }; + + let removed = request_editor_background_removal_image_with_retry(&state, source_image) + .await + .expect("BgFilter PNG response should decode"); + let request = request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("mock server should capture request"); + server.join().expect("mock server should stop cleanly"); + let request_text = String::from_utf8_lossy(request.as_slice()); + let lowercase_request = request_text.to_ascii_lowercase(); + + assert!(request_text.starts_with("POST /remove-background HTTP/1.1\r\n")); + assert!( + lowercase_request.contains("\r\nx-genarrative-image-token: manual-complex-token\r\n") + ); + assert!(request_text.contains("name=\"file\"; filename=\"manual-source.png\"")); + assert!(request_text.contains("name=\"background_mode\"\r\n\r\ncomplex\r\n")); + assert!(request_text.contains("name=\"seg_model\"\r\n\r\nbirefnet\r\n")); + assert!(request_text.contains("name=\"cross_check\"\r\n\r\noff\r\n")); + assert!(!request_text.contains("name=\"screen_color\"")); + + assert_eq!((removed.width, removed.height), (3, 2)); + assert_eq!(removed.image.mime_type, "image/png"); + assert_eq!(removed.image.extension, "png"); + assert_eq!(removed.image.bytes, response_png); + + let provider_error = decode_editor_background_removal_image(b"not an image") + .expect_err("invalid BgFilter response should fail decoding"); + assert_eq!( + provider_error + .details() + .and_then(|details| details.get("provider")), + Some(&json!("bgfilter")) + ); + } + #[test] fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() { reset_editor_bgfilter_circuit_for_tests(); @@ -9447,7 +9624,7 @@ mod tests { "struct EditorBackgroundRemovalImage", &[ "caller.report_processing_phase(state).await?", - "request_editor_background_removal_image", + "request_editor_background_removal_image_with_retry", ], ); assert_function_contains( @@ -9628,6 +9805,25 @@ mod tests { ); } + #[test] + fn editor_manual_background_removal_retries_once() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "async fn request_editor_background_removal_image_with_retry", + "async fn request_editor_background_removal_image(", + &[ + "let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1", + "for attempt in 1..=max_attempts", + "request_editor_background_removal_image(state, source_image.clone())", + "let will_retry = attempt < max_attempts", + "if will_retry", + "continue", + "final_error = Some(error)", + ], + ); + } + #[test] fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { let source = include_str!("editor_project.rs"); -- 2.52.0 From 49879f39a71a4d134cf4a1be38a17a7433693c04 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 08:10:03 +0000 Subject: [PATCH 07/21] =?UTF-8?q?=E7=BB=9F=E4=B8=80BgFilter=E4=B8=8E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E7=94=9F=E6=88=90=E6=96=87=E6=A1=A3=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收敛手动去背景到BgFilter complex并补齐单次重试边界 明确外部生成summary读取事实源、phase字段与正式procedure 补充nohup.out刷新循环的运维说明和共享踩坑记录 整理SpacetimeDB mapper导出列表格式 --- docs/project-memory/shared-memory/pitfalls.md | 8 +++++++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 11 +++++----- ...端架构】外部生成Worker化方案-2026-06-03.md | 22 ++++++++++++------- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 5 ++--- ...发运维】本地开发验证与生产运维-2026-05-15.md | 6 ++--- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 2 +- server-rs/crates/spacetime-client/src/lib.rs | 21 +++++++++--------- 7 files changed, 43 insertions(+), 32 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3f2050978..6fdd9ab55 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1602,6 +1602,14 @@ - 验证:`npm run dev -- --watch` 下修改 `apps/admin-web/src/**` 应由 Vite HMR 处理,不应出现连续 `[dev] 重启 admin-web`;`scripts/dev.test.ts` 覆盖 web/admin-web 不注册外层 watch。 - 关联:`scripts/dev.mjs`、`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 +## 根目录 `nohup.out` 持续写入会触发主站 Vite 刷新循环 + +- 现象:在仓库根目录用 `nohup npm run dev ... &` 启动完整 dev 栈后,即使没有修改前端源码,主站页面也会反复整页刷新;`nohup.out` 同时持续增长。 +- 原因:未显式重定向 stdout / stderr 时,`nohup.out` 会收集 SpacetimeDB、api-server 和两套 Vite 的整套 dev 栈输出。主站 Vite 的 root 是仓库根目录,若 watcher 未忽略这个持续写入的文件,每次追加日志都会被当成文件变化;后台 Vite root 是 `apps/admin-web`,仓库根日志不在其监听根内。 +- 处理:主站 `vite.config.ts` 的 `server.watch.ignored` 保持忽略 `**/nohup.out`,Git 同时忽略 `nohup.out`。修改配置后重启主站 Vite。若显式重定向到其它仓库内日志文件,该文件不会自动受保护,应写到 Vite root 之外或补充精确忽略规则。 +- 验证:在仓库根目录追加 `nohup.out` 时主站不再刷新,真实源码修改仍正常触发 HMR;`git check-ignore nohup.out` 能命中忽略规则,`git status` 不出现该日志。 +- 关联:`vite.config.ts`、`.gitignore`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 本地 SpacetimeDB publish 401 可清本地库重发 - 现象:本地 `spacetime publish` 显示 `401` 无权限,或重新发布仍像是在更新旧库。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f9ff1ca55..af3cec512 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,13 +21,13 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 BgFilter 路径都固定传 `background_mode=flat`,明确使用现有单一纯色背景抠图模式;同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 -- 2026-07-14 更新:上句关于手动去背景代理独立 BiRefNet 和使用独立 base URL 的口径已废止。手动去背景仍走同一 BFF/队列,但 worker 改用 BgFilter `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不发送 `screen_color`;标准纯色背景四条链路继续使用 `background_mode=flat`。两类模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client。 +- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或独立去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 +- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 @@ -88,8 +88,7 @@ - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 -- 2026-07-14 更新:该接口的上游已替换为 BgFilter complex;请求字段和回包结构保持不变,服务端 multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,provider 返回 `BgFilter`。 +- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider: "BgFilter"` 和可选 `project` 快照。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -131,7 +130,7 @@ - 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 - Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index cefdcf63c..62a24f544 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,6 +1,6 @@ # 外部生成 Worker 化方案 -更新时间:`2026-06-24` +更新时间:`2026-07-15` ## 背景 @@ -13,7 +13,7 @@ - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 -- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 +- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 - 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 - 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 @@ -27,10 +27,12 @@ - `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。 - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 -- `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。 -- `acknowledge_external_generation_jobs_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入 `notification_acknowledged_at` 并追加审计事件。 +- `list_external_generation_job_summaries_and_return`:按当前账号从轻量摘要投影读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格、执行阶段和完成提示确认状态。 +- `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 -- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 +- `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 + +不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 @@ -40,11 +42,11 @@ - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 -- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或独立去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 +- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 -BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 ## 任务表 @@ -54,7 +56,7 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍 | --- | --- | | `job_id` | 主键,`extgen-` 前缀 UUID | | `dedupe_key` | 唯一键,建议为 `play/action/session/scope` | -| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | +| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | | `owner_user_id` | 触发用户 | | `source_module` | 玩法或能力名,例如 `puzzle` | | `source_entity_id` | session/profile/work 等作用域 | @@ -72,6 +74,9 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍 | `price_mud_points` | 后端计算的本任务价格,用于任务列表展示和排障 | | `refund_ledger_id` | 失败退款产生的钱包退款流水 ID,便于从任务追到退款记录 | | `notification_acknowledged_at` | 用户已确认完成 / 失败提示的时间,未确认终态任务下次登录继续集中弹出 | +| `phase` | 尾部可选字段;`null / generating / processing`,claim 时写 `generating`,进入正式后处理时写 `processing` | + +用户正式读取使用私有轻量投影 `external_generation_job_summary`。该表同步保存 owner、来源、状态、`phase`、价格、有限错误/告警摘要、通知确认和时间字段,不复制 request/result payload、worker lease 或 dedupe 内部字段;enqueue、claim、renew、phase update、complete、fail 与 acknowledge 都必须维护对应投影语义。 新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。 @@ -180,6 +185,7 @@ controller 配置: - `editor_image_generation`:普通图片、生成规范、角色形象、UI 设计图、宣发素材和图片快速编辑。 - `editor_image_edit`:图片编辑 / 修改结果。 +- `editor_background_removal`:手动去除任意图片背景,worker 使用 BgFilter complex 模式、首次失败后重试 `1` 次,并把执行阶段标记为 `processing`。 - `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。 - `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。 - `editor_character_animation_generation`:角色动作视频和帧素材生成。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 92ea3298d..fa43f12ae 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -240,9 +240,8 @@ 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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=`、`seg_model=` 和 `cross_check=`;角色形象生成和角色动作逐帧去背固定传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取固定传 `cross_check=off`,不依赖服务端默认值。前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务;`cross_check` 同样只属于后端内部供应商策略,不进入前端或外部 OpenAPI。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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 -- 2026-07-14 更新:上句关于“手动去背景继续代理独立 BiRefNet”的口径已废止。手动 `POST /api/editor/images/background-removals` 现与标准纯色背景链路共用 BgFilter 配置和 HTTP client,固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat` 并继续沿用各自的 `screen_color`、`seg_model`、`cross_check` 策略。独立 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 与 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS` 已删除,旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名。 -- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。单次 BgFilter 调用失败后立即重试 `1` 次,第二次仍失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发出的失败调用都单独写审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 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 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 +- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 4846203ff..6cd9886b5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-06-12` +更新时间:`2026-07-15` ## 标准开发流程 @@ -33,7 +33,7 @@ npm run dev `npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 -通过 `nohup` 在仓库根目录启动 dev 栈时,`nohup.out` 已被 Vite 和 Git 忽略,避免 API 日志持续追加后触发页面刷新循环;重启 Vite 后生效。 +通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 单独启动主站前端: @@ -66,7 +66,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `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,再看带 `background_mode=flat` 的 `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` 日志。 -手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 +手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 `我的` 页签或排障面板展示队列等待时,只读取 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 暴露到前端。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 24bced80c..134c71d39 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,7 +65,7 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=` 和 `background_mode=flat`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示沿用单一纯色背景抠图模式。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 - 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index ddb11ef96..1c661eae2 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -62,17 +62,16 @@ pub use mapper::{ ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, - ExternalGenerationQueueStatsRecord, - FeatureGateConfigRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, - JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, - JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, - JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, - JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, Match3DAgentMessageFinalizeRecordInput, + ExternalGenerationQueueStatsRecord, FeatureGateConfigRecord, JumpHopActionRequest, + JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, + JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, + JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, + JumpHopJumpResult, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, + JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, + JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, + JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, + JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, -- 2.52.0 From 3aa61d61c8c22ad76c077029da3959b4b07b8f5a Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 08:26:22 +0000 Subject: [PATCH 08/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Rust=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 格式化 api-server 抠图错误映射代码 格式化编辑器绿幕与背景决策代码 格式化编辑器背景筛选代码 格式化抠图冒烟示例 格式化抠图平台库代码 格式化充值过期订阅客户端代码 --- .../crates/api-server/src/aliyun_matting.rs | 41 ++++-- .../api-server/src/editor_green_screen.rs | 1 - .../src/editor_screen_background_decision.rs | 51 ++++---- .../src/editor_screen_background_filter.rs | 27 +++- .../examples/segment_smoke.rs | 21 +-- server-rs/crates/platform-matting/src/lib.rs | 120 ++++++++++-------- .../src/profile_recharge_expiration.rs | 10 +- 7 files changed, 165 insertions(+), 106 deletions(-) diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index d3c684d57..60f5a9921 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -118,22 +118,36 @@ mod tests { 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)); + 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 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_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())); } @@ -146,7 +160,10 @@ mod tests { 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)); + assert_eq!( + details.get("transport").and_then(|v| v.as_bool()), + Some(true) + ); } #[test] @@ -159,7 +176,13 @@ mod tests { 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)); + 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) + ); } } diff --git a/server-rs/crates/api-server/src/editor_green_screen.rs b/server-rs/crates/api-server/src/editor_green_screen.rs index 5840533de..c13d3abc1 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -109,7 +109,6 @@ pub(crate) fn default_editor_screen_background_color() -> EditorScreenBackground EDITOR_SCREEN_BACKGROUND_COLORS[0] } - pub(crate) fn parse_editor_screen_background_color( value: Option<&str>, ) -> Result { diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs index 77e3dcc2c..ac38816c9 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -177,16 +177,15 @@ pub(crate) async fn resolve_editor_screen_background_color( let mut last_error: Option = None; for attempt in 1..=EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS { let user_message = match source_image_data_url { - Some(image_url) => { - LlmMessage::user(user_prompt.as_str()).with_image_url(image_url) - } + Some(image_url) => LlmMessage::user(user_prompt.as_str()).with_image_url(image_url), None => LlmMessage::user(user_prompt.as_str()), }; // 预算要够推理模型(如 gpt-5-mini)先花几百 token 推理、再吐 JSON 答案; // 实测 low 档推理约 320~384 token,取 1024 留足余量。降级客户端遇 stop 提前结束,不会多花。 - let mut request = LlmTextRequest::new(vec![LlmMessage::system(system_prompt), user_message]) - .with_max_tokens(1024) - .with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS); + let mut request = + LlmTextRequest::new(vec![LlmMessage::system(system_prompt), user_message]) + .with_max_tokens(1024) + .with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS); if let Some(decision_model) = decision_model { // gpt-5-mini 是推理模型(有图视觉档 / 无图文本档均适用):走 Responses 协议并压到 low // 推理档,否则默认档会把预算全烧在推理上、返回空答案。 @@ -296,8 +295,7 @@ async fn record_editor_screen_background_decision_llm_error( prompt_chars: usize, reference_image_count: usize, ) { - let (failure_stage, status_code, timeout, retryable, error_source, raw_excerpt) = match error - { + let (failure_stage, status_code, timeout, retryable, error_source, raw_excerpt) = match error { LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => return, LlmError::Timeout { .. } => ("request_timeout", None, true, true, None, None), LlmError::Connectivity { message, .. } => ( @@ -329,14 +327,9 @@ async fn record_editor_screen_background_decision_llm_error( ), LlmError::EmptyResponse => ("missing_response", Some(200), false, false, None, None), LlmError::StreamUnavailable => ("response_body", Some(200), false, true, None, None), - LlmError::Transport(message) => ( - "transport", - None, - false, - true, - Some(message.as_str()), - None, - ), + LlmError::Transport(message) => { + ("transport", None, false, true, Some(message.as_str()), None) + } }; record_editor_screen_background_decision_failure( audit, @@ -754,8 +747,7 @@ mod tests { request_id: Some("request-1".to_string()), }, ); - let tracking = - crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); + let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); assert_eq!(audit.provider, "vector-engine"); assert_eq!(audit.endpoint, "https://vector.example/v1/responses"); @@ -809,8 +801,7 @@ mod tests { 1, &ExternalApiAuditContext::default(), ); - let tracking = - crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); + let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); assert_eq!(audit.failure_stage, "request_timeout"); assert_eq!(audit.status_code, None); @@ -897,7 +888,8 @@ mod tests { let (k, v) = trimmed.split_once('=').unwrap(); let v = v.trim().trim_matches('"').trim_matches('\''); // 先出现的文件优先(.env.local > .env.secrets.local > .env),与服务端 dotenv 顺序一致。 - map.entry(k.trim().to_string()).or_insert_with(|| v.to_string()); + map.entry(k.trim().to_string()) + .or_insert_with(|| v.to_string()); } } std::env::var(key).ok().or_else(|| map.get(key).cloned()) @@ -934,7 +926,10 @@ mod tests { let image = RgbaImage::from_pixel(64, 64, Rgba([120, 180, 120, 255])); let mut bytes = Vec::new(); image::DynamicImage::ImageRgba8(image) - .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) .expect("test image should encode"); format!( "data:image/png;base64,{}", @@ -966,7 +961,11 @@ mod tests { eprintln!( "[live 无图] mode={:?} hex={} label={} attempts={} fallback={}", - decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback + decision.mode, + decision.color.hex, + decision.color.label, + decision.attempts, + decision.fallback ); assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto); assert!( @@ -1000,7 +999,11 @@ mod tests { eprintln!( "[live 有图] mode={:?} hex={} label={} attempts={} fallback={}", - decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback + decision.mode, + decision.color.hex, + decision.color.label, + decision.attempts, + decision.fallback ); assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto); assert!( diff --git a/server-rs/crates/api-server/src/editor_screen_background_filter.rs b/server-rs/crates/api-server/src/editor_screen_background_filter.rs index 59c70a521..d67d7bba0 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_filter.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_filter.rs @@ -203,11 +203,12 @@ impl ForegroundHistogram { .filter(|bin| bin.mass >= mass_floor) .filter_map(|bin| { let lab = bin.mean(); - (lab[0] > SKIN_MIN_LIGHTNESS && lab[1] > SKIN_MIN_A && lab[2] > SKIN_MIN_B) - .then(|| SkinReference { + (lab[0] > SKIN_MIN_LIGHTNESS && lab[1] > SKIN_MIN_A && lab[2] > SKIN_MIN_B).then( + || SkinReference { lab, rgb: bin.mean_rgb(), - }) + }, + ) }) .max_by(|left, right| { left.lab[0] @@ -481,12 +482,18 @@ mod tests { let report = report_for(&transparent_image_with_center_block([250, 224, 200]), true); assert!( - report.excluded.iter().any(|(color, _)| color.hex == "#FFD6C2"), + report + .excluded + .iter() + .any(|(color, _)| color.hex == "#FFD6C2"), "肤色前景应剔除暖浅桃色,excluded: {}", report.excluded_summary() ); assert!( - report.excluded.iter().any(|(color, _)| color.hex == "#FFF2A8"), + report + .excluded + .iter() + .any(|(color, _)| color.hex == "#FFF2A8"), "肤色前景应剔除淡黄(Rule 2/3 关键新覆盖),excluded: {}", report.excluded_summary() ); @@ -520,7 +527,10 @@ mod tests { let enabled = report_for(&image, true); assert!( - enabled.excluded.iter().any(|(color, _)| color.hex == "#FFF2A8"), + enabled + .excluded + .iter() + .any(|(color, _)| color.hex == "#FFF2A8"), "开启皮肤否决时淡黄应被剔除,excluded: {}", enabled.excluded_summary() ); @@ -542,7 +552,10 @@ mod tests { let report = report_for(&transparent_image_with_center_block([127, 179, 255]), false); assert!( - report.excluded.iter().any(|(color, _)| color.hex == "#7FB3FF"), + report + .excluded + .iter() + .any(|(color, _)| color.hex == "#7FB3FF"), "蓝色前景应剔除中度天蓝,excluded: {}", report.excluded_summary() ); diff --git a/server-rs/crates/platform-matting/examples/segment_smoke.rs b/server-rs/crates/platform-matting/examples/segment_smoke.rs index c778b5f02..a0c817402 100644 --- a/server-rs/crates/platform-matting/examples/segment_smoke.rs +++ b/server-rs/crates/platform-matting/examples/segment_smoke.rs @@ -38,17 +38,16 @@ async fn main() { }); let input_bytes = std::fs::read(&input_path) .unwrap_or_else(|error| panic!("读取测试图片失败({input_path}):{error}")); - println!("[1/5] 已读取测试图片:{input_path}({} 字节)", input_bytes.len()); + println!( + "[1/5] 已读取测试图片:{input_path}({} 字节)", + input_bytes.len() + ); // SegmentCommonImage 要求分辨率低于 2000x2000,超限先等比缩小。 const MAX_EDGE: u32 = 1999; let decoded = image::load_from_memory(&input_bytes).expect("测试图片应可解码"); let input_bytes = if decoded.width() > MAX_EDGE || decoded.height() > MAX_EDGE { - let resized = decoded.resize( - MAX_EDGE, - MAX_EDGE, - image::imageops::FilterType::CatmullRom, - ); + let resized = decoded.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::CatmullRom); let mut buffer = std::io::Cursor::new(Vec::new()); resized .write_to(&mut buffer, image::ImageFormat::Png) @@ -70,8 +69,14 @@ async fn main() { // --- 调用通用抠图 --- // key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。 let (matting_key_id, matting_key_secret) = [ - ("ALIYUN_IMAGESEG_ACCESS_KEY_ID", "ALIYUN_IMAGESEG_ACCESS_KEY_SECRET"), - ("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"), + ( + "ALIYUN_IMAGESEG_ACCESS_KEY_ID", + "ALIYUN_IMAGESEG_ACCESS_KEY_SECRET", + ), + ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + ), ("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_ACCESS_KEY_SECRET"), ] .iter() diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 0df252889..854fafb19 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -162,9 +162,9 @@ pub struct UpstreamFailure { impl MattingError { pub fn message(&self) -> &str { match self { - Self::InvalidConfig(message) - | Self::InvalidRequest(message) - | Self::Sign(message) => message, + Self::InvalidConfig(message) | Self::InvalidRequest(message) | Self::Sign(message) => { + message + } Self::Upstream(failure) => &failure.message, } } @@ -270,7 +270,10 @@ impl MattingClient { } let mut form = BTreeMap::new(); - form.insert("Action".to_string(), SEGMENT_COMMON_IMAGE_ACTION.to_string()); + form.insert( + "Action".to_string(), + SEGMENT_COMMON_IMAGE_ACTION.to_string(), + ); form.insert("Format".to_string(), "json".to_string()); form.insert("Version".to_string(), IMAGESEG_API_VERSION.to_string()); form.insert("ImageURL".to_string(), image_url); @@ -451,17 +454,12 @@ impl MattingClient { } async fn download_result_image(&self, url: &str) -> Result, MattingError> { - let mut response = self - .client - .get(url) - .send() - .await - .map_err(|error| { - MattingError::upstream_transport_error( - describe_result_download_transport_error(&error), - error.is_timeout(), - ) - })?; + let mut response = self.client.get(url).send().await.map_err(|error| { + MattingError::upstream_transport_error( + describe_result_download_transport_error(&error), + error.is_timeout(), + ) + })?; let status = response.status(); if !status.is_success() { return Err(MattingError::upstream_http_error( @@ -501,9 +499,7 @@ impl MattingClient { content_type: &str, ) -> Result { if bytes.is_empty() { - return Err(MattingError::InvalidRequest( - "上传内容不能为空".to_string(), - )); + return Err(MattingError::InvalidRequest("上传内容不能为空".to_string())); } let sts = self.get_oss_sts_token().await?; let file_name = file_name.trim().trim_matches('/'); @@ -536,7 +532,8 @@ impl MattingClient { "PUT\n\n{content_type}\n{date}\nx-oss-security-token:{}\n/{VIAPI_TEMP_BUCKET}/{object_key}", sts.security_token ); - let signature = hmac_sha1_base64(sts.access_key_secret.as_bytes(), string_to_sign.as_bytes())?; + let signature = + hmac_sha1_base64(sts.access_key_secret.as_bytes(), string_to_sign.as_bytes())?; let authorization = format!("OSS {}:{}", sts.access_key_id, signature); let target_url = format!("https://{VIAPI_TEMP_OSS_HOST}/{object_key}"); @@ -625,7 +622,9 @@ impl MattingClient { format!( "GetOssStsToken 返回失败(HTTP {},Code={}):{}", http_status.as_u16(), - body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"), + body.get("Code") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), body.get("Message") .and_then(|value| value.as_str()) .unwrap_or("unknown") @@ -762,10 +761,7 @@ fn encode_rgba_png(image: &image::RgbaImage) -> Result, MattingError> { image::ExtendedColorType::Rgba8, ) .map_err(|error| { - MattingError::upstream_response_error( - format!("编码抠图结果 PNG 失败:{error}"), - None, - ) + MattingError::upstream_response_error(format!("编码抠图结果 PNG 失败:{error}"), None) })?; Ok(encoded) } @@ -934,13 +930,7 @@ fn current_aliyun_timestamp() -> String { fn canonicalize_aliyun_form_params(params: &BTreeMap) -> String { params .iter() - .map(|(key, value)| { - format!( - "{}={}", - urlencoding_encode(key), - urlencoding_encode(value) - ) - }) + .map(|(key, value)| format!("{}={}", urlencoding_encode(key), urlencoding_encode(value))) .collect::>() .join("&") } @@ -1001,8 +991,10 @@ mod tests { #[test] fn upstream_transport_error_classifies_as_transport() { - let error = - MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false); + let error = MattingError::upstream_transport_error( + "通用抠图请求失败:dns error".to_string(), + false, + ); assert!(error.external_call_attempted()); assert!(error.is_transport()); assert!(!error.is_timeout()); @@ -1032,10 +1024,7 @@ mod tests { #[test] fn upstream_response_error_is_external_but_not_transport() { - let error = MattingError::upstream_response_error( - "抠图结果尺寸不一致".to_string(), - None, - ); + let error = MattingError::upstream_response_error("抠图结果尺寸不一致".to_string(), None); assert!(error.external_call_attempted()); assert!(!error.is_transport()); assert!(!error.is_timeout()); @@ -1130,13 +1119,31 @@ mod tests { let sanitized = sanitize_oss_upload_error_body(&body, token); // 敏感串全部消失:StringToSign 明文、其中的 token、十六进制、签名串、以及 Message 里的 token 明文。 - assert!(!sanitized.contains(token), "STS token 不能残留(含元素外的明文)"); - assert!(!sanitized.contains("x-oss-security-token:CAIS"), "StringToSign 明文不能残留"); - assert!(!sanitized.contains("sigSECRET"), "SignatureProvided 不能残留"); - assert!(!sanitized.contains("50 55 54 0a"), "StringToSignBytes 不能残留"); + assert!( + !sanitized.contains(token), + "STS token 不能残留(含元素外的明文)" + ); + assert!( + !sanitized.contains("x-oss-security-token:CAIS"), + "StringToSign 明文不能残留" + ); + assert!( + !sanitized.contains("sigSECRET"), + "SignatureProvided 不能残留" + ); + assert!( + !sanitized.contains("50 55 54 0a"), + "StringToSignBytes 不能残留" + ); // 可诊断信息保留。 - assert!(sanitized.contains("SignatureDoesNotMatch"), "OSS Code 应保留供诊断"); - assert!(sanitized.contains("[redacted]"), "签名材料元素应被脱敏为 [redacted]"); + assert!( + sanitized.contains("SignatureDoesNotMatch"), + "OSS Code 应保留供诊断" + ); + assert!( + sanitized.contains("[redacted]"), + "签名材料元素应被脱敏为 [redacted]" + ); } fn noise_image(width: u32, height: u32) -> image::DynamicImage { @@ -1151,10 +1158,12 @@ mod tests { #[test] fn normalize_keeps_small_image_and_outputs_png() { - let source = - image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(200, 150, image::Rgba([10, 20, 30, 255]))); - let (bytes, dims) = - normalize_matting_input_png(&source).expect("normalize should succeed"); + let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 200, + 150, + image::Rgba([10, 20, 30, 255]), + )); + let (bytes, dims) = normalize_matting_input_png(&source).expect("normalize should succeed"); assert_eq!(dims, (200, 150), "小图不缩放,尺寸原样"); assert_eq!( @@ -1168,12 +1177,18 @@ mod tests { #[test] fn normalize_caps_oversized_edge_to_1999() { - let source = - image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2400, 1200, image::Rgba([0, 0, 0, 255]))); + let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2400, + 1200, + image::Rgba([0, 0, 0, 255]), + )); let (_bytes, (width, height)) = normalize_matting_input_png(&source).expect("normalize should succeed"); - assert!(width <= MAX_INPUT_EDGE && height <= MAX_INPUT_EDGE, "两边都 ≤1999,实得 {width}x{height}"); + assert!( + width <= MAX_INPUT_EDGE && height <= MAX_INPUT_EDGE, + "两边都 ≤1999,实得 {width}x{height}" + ); assert_eq!(width.max(height), MAX_INPUT_EDGE, "最长边压到 1999"); } @@ -1189,7 +1204,10 @@ mod tests { let (bytes, (width, height)) = normalize_matting_input_png_within(&source, limit) .expect("limited normalize should succeed"); - assert!(width < 300 && height < 300, "应从 300x300 降尺寸,实得 {width}x{height}"); + assert!( + width < 300 && height < 300, + "应从 300x300 降尺寸,实得 {width}x{height}" + ); assert!( bytes.len() <= limit || width.min(height) <= MIN_INPUT_EDGE + 1, "编码 {} 字节应落在 {limit} 内(或已触最小边下限)", diff --git a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs index 7bab87ddc..593f634f2 100644 --- a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs +++ b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs @@ -67,12 +67,10 @@ impl SpacetimeClient { send_connect_once(&connect_sender, Ok(())); }) .on_disconnect(move |_, error| { - let message = error - .map(|error| error.to_string()) - .unwrap_or_else(|| { - "SpacetimeDB profile recharge expiration subscription disconnected" - .to_string() - }); + let message = error.map(|error| error.to_string()).unwrap_or_else(|| { + "SpacetimeDB profile recharge expiration subscription disconnected" + .to_string() + }); send_connect_once( &disconnect_sender, Err(SpacetimeClientError::Procedure(message)), -- 2.52.0 From 4033b23149733480469af072b44713202bddda4e Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 09:29:40 +0000 Subject: [PATCH 09/21] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=8E=BB=E8=83=8C=E6=99=AF=E5=85=A5=E9=98=9F=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一手动去背景客户端类型为 queueState 响应 移除前端同步完成分支并更新队列测试 修正文档中的接口响应说明 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- .../useImageCanvasGenerationWorkflow.test.tsx | 165 ++---------------- .../useImageCanvasGenerationWorkflow.ts | 67 ++----- .../image-editor/editorProjectClient.test.ts | 21 +-- .../image-editor/editorProjectClient.ts | 14 +- 5 files changed, 44 insertions(+), 227 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index af3cec512..275449f3b 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 @@ -88,7 +88,7 @@ - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider: "BgFilter"` 和可选 `project` 快照。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 +- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f35c01921..8ca83fe19 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1868,21 +1868,19 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); - it('resolves private character image before removing background', async () => { + it('queues background removal for private character images', async () => { resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,resolved-character', ); removeImageBackgroundMock.mockResolvedValueOnce({ - imageSrc: - '/generated-character-drafts/editor/background-removal/result.png', - objectKey: - 'generated-character-drafts/editor/background-removal/result.png', - assetObjectId: 'asset-object-background-removal', - width: 512, - height: 768, - taskId: 'background-removal-task', - elapsedMs: 1234, - provider: 'BgFilter', + queueState: { + operationId: 'background-removal-task', + status: 'completed', + phaseLabel: '已完成', + phaseDetail: '已完成', + progress: 100, + updatedAtMicros: 1, + }, }); render( { sourceResourceId: 'resource-source', }); }); + await waitFor(() => { + expect(screen.getByTestId('task-refresh-key').textContent).toBe('1'); + }); expect(screen.getByTestId('layers').textContent).toContain( - 'layer-source:源图:resource-source:character:/generated-character-drafts/editor/background-removal/result.png:background-removal-task', + '/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png', + ); + expect(screen.getByTestId('layers').textContent).not.toContain( + 'background-removal-task', ); - expect(screen.getByTestId('task-refresh-key').textContent).toBe('0'); }); it('opens a generating canvas placeholder when removing background in a project', async () => { - let resolveBackgroundRemoval: ((value: unknown) => void) | null = null; removeImageBackgroundMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveBackgroundRemoval = resolve; - }), + () => new Promise(() => {}), ); render( @@ -1960,136 +1959,6 @@ describe('useImageCanvasGenerationWorkflow', () => { }), ); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); - await act(async () => { - resolveBackgroundRemoval?.({ - imageSrc: - '/generated-character-drafts/editor/background-removal/project-result.png', - objectKey: - 'generated-character-drafts/editor/background-removal/project-result.png', - assetObjectId: 'asset-object-background-removal', - width: 512, - height: 768, - taskId: 'background-removal-project-task', - elapsedMs: 1234, - provider: 'BgFilter', - project: null, - }); - }); - expect(screen.getByTestId('layers').textContent).not.toContain( - 'project-result.png', - ); - expect(screen.getByTestId('layers').textContent).not.toContain( - 'background-removal-project-task', - ); - }); - - it('preserves a just-created crop layer when applying a background removal project snapshot', async () => { - const applyProjectSnapshot = vi.fn(); - let resolveBackgroundRemoval: ((value: unknown) => void) | null = null; - removeImageBackgroundMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveBackgroundRemoval = resolve; - }), - ); - render( - , - ); - - fireEvent.click(screen.getByRole('button', { name: '去除背景' })); - - await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled()); - await act(async () => { - resolveBackgroundRemoval?.({ - imageSrc: - '/generated-character-drafts/editor/background-removal/project-result.png', - objectKey: - 'generated-character-drafts/editor/background-removal/project-result.png', - assetObjectId: 'asset-object-background-removal', - width: 512, - height: 768, - taskId: 'background-removal-project-task', - elapsedMs: 1234, - provider: 'BgFilter', - project: { - projectId: 'project-1', - title: '队列项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-background-removal-result', - resourceId: 'resource-background-removal', - title: '源图 裁扩 去背景', - x: 480, - y: 140, - width: 512, - height: 768, - originalWidth: 512, - originalHeight: 768, - zIndex: 9, - sourceType: 'generated', - }, - ], - resources: [ - { - resourceId: 'resource-background-removal', - projectId: 'project-1', - imageSrc: - '/generated-character-drafts/editor/background-removal/project-result.png', - objectKey: - 'generated-character-drafts/editor/background-removal/project-result.png', - assetObjectId: 'asset-object-background-removal', - width: 512, - height: 768, - sourceType: 'generated', - sourceResourceId: 'resource-crop-expand', - }, - ], - updatedAt: '2026-06-23T08:00:01.000Z', - }, - }); - }); - - const appliedProject = applyProjectSnapshot.mock.calls[0]?.[0]; - expect(appliedProject?.layers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - layerId: 'layer-crop-expand-1', - resourceId: 'resource-crop-expand', - title: '源图 裁扩', - }), - expect.objectContaining({ - layerId: 'layer-background-removal-result', - resourceId: 'resource-background-removal', - }), - ]), - ); - expect(appliedProject?.resources).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - resourceId: 'resource-crop-expand', - objectKey: - 'generated-character-drafts/editor/crop-expand/project-1/result.png', - assetObjectId: 'asset-object-crop-expand', - }), - ]), - ); }); it('opens UI design extraction as a mark selection state before submitting', () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 5e9c3af9c..e0c37d02e 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1613,58 +1613,19 @@ export function useImageCanvasGenerationWorkflow({ } : {}), }); - if ( - await applyQueuedEditorGenerationProject( - result, - projectId, - applyProjectSnapshot, - refreshTaskListForQueuedGeneration, - onWalletBalanceMayHaveChanged, - setGenerationWarning, - backgroundRemovalDialogId, - applyBackgroundRemovalProjectSnapshot - ? (project) => - preserveSourceLayerInProjectSnapshot(project, sourceLayer) - : undefined, - ) - ) { - return; - } - if (result.project && applyBackgroundRemovalProjectSnapshot) { - applyBackgroundRemovalProjectSnapshot(result.project); - return; - } - if (backgroundRemovalPlacement?.placeholder && applyProjectSnapshot) { - return; - } - updateSourceLayer(sourceLayer.id, (layer) => ({ - ...layer, - resourceId: result.resource?.resourceId ?? layer.resourceId, - src: result.imageSrc, - width: result.width, - height: result.height, - originalWidth: result.width, - originalHeight: result.height, - sourceType: result.sourceType ?? 'generated', - provider: result.provider ?? 'BgFilter', - taskId: result.taskId ?? null, - objectKey: result.resource?.objectKey ?? result.objectKey ?? null, - assetObjectId: - result.resource?.assetObjectId ?? result.assetObjectId ?? null, - sourceResourceId: - result.resource?.sourceResourceId ?? sourceLayer.resourceId, - sourceAssetId: null, - prompt: result.resource?.prompt ?? layer.prompt, - actualPrompt: result.resource?.actualPrompt ?? layer.actualPrompt, - model: result.resource?.model ?? layer.model, - assetKind: - (result.resource?.assetKind as CanvasLayer['assetKind']) ?? - layer.assetKind, - generationInputs: - result.resource?.generationInputs ?? layer.generationInputs, - generatedAssetSnapshot: result.asset ?? layer.generatedAssetSnapshot, - })); - setActiveSidebarPanel('layers'); + await applyQueuedEditorGenerationProject( + result, + projectId, + applyProjectSnapshot, + refreshTaskListForQueuedGeneration, + onWalletBalanceMayHaveChanged, + setGenerationWarning, + backgroundRemovalDialogId, + applyBackgroundRemovalProjectSnapshot + ? (project) => + preserveSourceLayerInProjectSnapshot(project, sourceLayer) + : undefined, + ); } catch (error) { if (backgroundRemovalDialogId) { updateCanvasGenerationDialogById( @@ -1689,11 +1650,9 @@ export function useImageCanvasGenerationWorkflow({ openPlacedCanvasGenerationDialog, projectId, refreshTaskListForQueuedGeneration, - setActiveSidebarPanel, setImageContextMenu, setMetadataLayer, updateCanvasGenerationDialogById, - updateSourceLayer, ], ); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 0edc3658b..2e8da9ddb 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1618,18 +1618,17 @@ describe('editorProjectClient', () => { it('passes canvas completion context to background removal', async () => { requestJsonMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,cutout', - width: 512, - height: 512, - objectKey: 'generated-character-drafts/editor-cutouts/cutout.png', - assetObjectId: 'asset-object-cutout', - taskId: 'background-removal-1', - elapsedMs: 1200, - provider: 'BgFilter', - project: null, + queueState: { + operationId: 'background-removal-1', + status: 'queued', + phaseLabel: '排队中', + phaseDetail: '排队中', + progress: 0, + updatedAtMicros: 1, + }, }); - await removeEditorImageBackground({ + const response = await removeEditorImageBackground({ sourceImageSrc: 'data:image/png;base64,source', projectId: 'editor-project-1', targetLayerId: 'layer-source', @@ -1647,6 +1646,8 @@ describe('editorProjectClient', () => { }, }); + expect(response.queueState.operationId).toBe('background-removal-1'); + expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/background-removals', expect.objectContaining({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index d4d1e8360..15c72b3ad 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -277,19 +277,7 @@ export type EditorImageGenerationResult = { }; export type EditorBackgroundRemovalResult = { - imageSrc: string; - objectKey?: string | null; - assetObjectId?: string | null; - width: number; - height: number; - sourceType?: 'generated'; - taskId?: string | null; - elapsedMs?: number; - provider?: string; - resource?: EditorProjectResourceSnapshot | null; - asset?: EditorAssetSnapshot | null; - project?: EditorProjectSnapshot | null; - queueState?: ExternalGenerationJobStatusRecord | null; + queueState: ExternalGenerationJobStatusRecord; }; export type EditorIconSpritesheetIconResult = { -- 2.52.0 From e62406aae15bfc96d42b99604646b4ed33170cec Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 09:37:42 +0000 Subject: [PATCH 10/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E4=BB=BB=E5=8A=A1=E4=BE=A7=E6=A0=8F=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 processing 阶段的固定百分比断言改为任意百分比均不显示 --- src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index b38bb2135..d13243333 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -263,7 +263,7 @@ describe('ImageCanvasTaskSidebarView', () => { expect(await screen.findByText('图片画布生成图片')).toBeTruthy(); expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy(); expect(screen.getByText('正在处理。')).toBeTruthy(); - expect(screen.queryByText(/35%/u)).toBeNull(); + expect(screen.queryByText(/\d{1,3}%/u)).toBeNull(); expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/总进度/u)).toBeNull(); expect(screen.queryByText(/不该显示的外部项目任务/u)).toBeNull(); -- 2.52.0 From 70935bdc785a9a09ac4b640edd01086231ec779f Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 09:46:10 +0000 Subject: [PATCH 11/21] =?UTF-8?q?=E8=B5=84=E4=BA=A7=E5=AF=B9=E8=B1=A1/?= =?UTF-8?q?=E7=BB=91=E5=AE=9AID=E6=94=B9=E7=94=A8=E5=BE=AE=E7=A7=92?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=88=B3+UUID=E9=98=B2=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=BB=E9=94=AE=E7=A2=B0=E6=92=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯微秒种子ID在同一微秒并发确认资产时会产生相同主键(如角色 动作帧48路并发确认),导致SpacetimeDB唯一约束冲突、整项动作 退款并遗留已上传OSS对象。shared-kernel新增带熵的 build_prefixed_seed_entropy_id(微秒hex+UUID后缀,wasm32为 panic变体),generate_asset_object_id与generate_asset_binding_id 切换过去,签名不变,全部调用点一次修复。 Co-Authored-By: Claude Fable 5 --- .../module-assets/src/asset_object_core.rs | 23 ++++++++++++++--- server-rs/crates/shared-kernel/src/lib.rs | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/module-assets/src/asset_object_core.rs b/server-rs/crates/module-assets/src/asset_object_core.rs index 56f66f705..6e7d567c3 100644 --- a/server-rs/crates/module-assets/src/asset_object_core.rs +++ b/server-rs/crates/module-assets/src/asset_object_core.rs @@ -1,5 +1,5 @@ use shared_kernel::{ - build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string, + build_prefixed_seed_entropy_id, format_timestamp_micros, normalize_optional_string, normalize_required_string, }; @@ -203,12 +203,14 @@ pub fn build_asset_entity_binding_record( } } +// 资产确认可能在同一微秒内并发发生(例如角色动作帧 48 路并发确认), +// 主键必须带随机熵;时间前缀仅保留调试可读性,不参与唯一性保证。 pub fn generate_asset_object_id(seed_micros: i64) -> String { - build_prefixed_seed_id(ASSET_OBJECT_ID_PREFIX, seed_micros) + build_prefixed_seed_entropy_id(ASSET_OBJECT_ID_PREFIX, seed_micros) } pub fn generate_asset_binding_id(seed_micros: i64) -> String { - build_prefixed_seed_id(ASSET_BINDING_ID_PREFIX, seed_micros) + build_prefixed_seed_entropy_id(ASSET_BINDING_ID_PREFIX, seed_micros) } pub fn normalize_optional_value(value: Option) -> Option { @@ -219,6 +221,21 @@ pub fn normalize_optional_value(value: Option) -> Option { mod tests { use super::*; + #[test] + fn generated_ids_with_same_seed_micros_never_collide() { + let seed_micros = 1_713_686_400_000_000; + + let first_object_id = generate_asset_object_id(seed_micros); + let second_object_id = generate_asset_object_id(seed_micros); + assert!(first_object_id.starts_with(ASSET_OBJECT_ID_PREFIX)); + assert_ne!(first_object_id, second_object_id); + + let first_binding_id = generate_asset_binding_id(seed_micros); + let second_binding_id = generate_asset_binding_id(seed_micros); + assert!(first_binding_id.starts_with(ASSET_BINDING_ID_PREFIX)); + assert_ne!(first_binding_id, second_binding_id); + } + #[test] fn validate_asset_object_fields_accepts_minimal_private_object_contract() { let result = validate_asset_object_fields( diff --git a/server-rs/crates/shared-kernel/src/lib.rs b/server-rs/crates/shared-kernel/src/lib.rs index ba9d67250..613ef14c3 100644 --- a/server-rs/crates/shared-kernel/src/lib.rs +++ b/server-rs/crates/shared-kernel/src/lib.rs @@ -30,6 +30,21 @@ pub fn build_prefixed_seed_id(prefix: &str, seed_micros: i64) -> String { format!("{prefix}{seed_micros:x}") } +/// 统一生成“前缀 + 十六进制微秒种子 + UUID simple 随机后缀”的唯一 ID。 +/// 纯微秒种子在并发写入时会撞主键,带熵版本保留时间前缀的可读排序,同时保证全局唯一。 +#[cfg(not(target_arch = "wasm32"))] +pub fn build_prefixed_seed_entropy_id(prefix: &str, seed_micros: i64) -> String { + format!("{prefix}{seed_micros:x}_{}", Uuid::new_v4().simple()) +} + +/// SpacetimeDB 的 wasm32 模块不应走浏览器/本地随机 UUID 生成。 +#[cfg(target_arch = "wasm32")] +pub fn build_prefixed_seed_entropy_id(_prefix: &str, _seed_micros: i64) -> String { + panic!( + "shared-kernel::build_prefixed_seed_entropy_id 不支持 wasm32,请改用显式 ID 或 SpacetimeDB 上下文生成能力" + ) +} + /// 统一生成“前缀 + UUID simple”随机 ID,适合会话态或一次性票据主键。 #[cfg(not(target_arch = "wasm32"))] pub fn build_prefixed_uuid_id(prefix: &str) -> String { @@ -124,6 +139,16 @@ mod tests { assert_eq!(build_prefixed_seed_id("assetobj_", 255), "assetobj_ff"); } + #[test] + fn build_prefixed_seed_entropy_id_keeps_seed_prefix_and_never_collides_on_same_seed() { + let first = build_prefixed_seed_entropy_id("assetobj_", 255); + let second = build_prefixed_seed_entropy_id("assetobj_", 255); + + assert!(first.starts_with("assetobj_ff_")); + assert!(second.starts_with("assetobj_ff_")); + assert_ne!(first, second); + } + #[test] fn format_timestamp_micros_is_stable() { assert_eq!( -- 2.52.0 From 20a4b8022e334d7615ecc800672f35f238db7498 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 10:42:24 +0000 Subject: [PATCH 12/21] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8D=95=E4=BB=BB=E5=8A=A1=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐共享契约中的轮询字段 明确重试次数不向前端状态接口暴露 --- docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 62a24f544..8e54cbba9 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -44,7 +44,7 @@ - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 - 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 -- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 +- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选的 `warning`。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。 BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 -- 2.52.0 From fddf30d7c3ff744344d80518da71bda4d84879d2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 11:48:23 +0000 Subject: [PATCH 13/21] =?UTF-8?q?=E6=8C=89=E5=B8=A7=E6=95=B0=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=A7=92=E8=89=B2=E5=8A=A8=E4=BD=9C=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按基准超时加每帧两秒计算角色动作批量帧的单请求超时 在共享 BgFilter 客户端上覆盖请求级超时并保持普通路径、重试和降级行为 补充动态超时、请求覆盖和全帧流水线测试 同步后端、运维、画布专题及项目决策文档 --- .../shared-memory/decision-log.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 3 +- .../src/character_animation_assets.rs | 61 ++++++++++- .../crates/api-server/src/editor_project.rs | 100 ++++++++++++++++-- 7 files changed, 161 insertions(+), 17 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a04774c9f..f9b8370fd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展 + +- 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。 +- 决策:保留 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 作为统一基准值。只有角色动作逐帧 BgFilter 在共享 Client 的 RequestBuilder 上把每一次 HTTP attempt 覆盖为“基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧为 `244000 / 260000 / 276000ms`;角色形象单图、图标、UI 和手动去背景不增加帧预算。该 timeout 覆盖请求发起到响应体读取完成;首次失败后的重试重新获得同样的 request deadline,整批并发策略、失败排空语义和 worker long-job 总预算不变。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构、开发运维和图片画布专题文档。 +- 验证方式:运行角色动作超时公式、BgFilter request override 与逐帧流水线定向测试,执行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试 - 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 275449f3b..0bedb60d0 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -23,7 +23,7 @@ - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 -- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 +- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 86a3d2378..551c20a96 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -241,7 +241,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` 与角色形象生成、图标 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 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 -- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 +- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新获得同样的 deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 563c55b3f..c11e82c9f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -67,7 +67,7 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 -图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `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,再看带 `background_mode=flat` 的 `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` 日志。 +图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `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,再看带 `background_mode=flat` 的 `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` 日志。 手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 `我的` 页签或排障面板展示队列等待时,只读取 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 暴露到前端。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 134c71d39..60fe57826 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -162,7 +162,8 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 -- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。 +- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再走 `BgFilter(background_mode=flat、seg_model=birefnet、cross_check=on)→ 阿里云通用抠图 → 本地 editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 +- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 - 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `