From 41f578f0acb8f57eb53dde3711d02de89b6e3dac Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 20 Jul 2026 13:02:59 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=8A=A0=E5=9B=BE=E5=89=8D=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E8=A1=A5=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动作视频帧在抠图前改为 RGB8 等比缩放且不补边 抠图完成后保留 RGBA8 最终尺寸透明补边 补充像素回归测试并同步权威文档与长期决策 --- .../shared-memory/decision-log.md | 11 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 4 + .../src/character_animation_assets.rs | 176 +++++++++++++++++- 5 files changed, 190 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 57a8898bf..7cc7d5ab0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,17 @@ --- +## 2026-07-20 角色动作抠图前禁止透明 padding + +- 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 +- 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 +- 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 +- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 +- 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --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/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试 - 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 478583ba6..4cfbf2236 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -31,6 +31,10 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 +### 角色动作帧抠图像素边界 + +- 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 + ## 交互规则 - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d9d38b5e9..3935de1c8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-18` +更新时间:`2026-07-20` ## 后端主线 @@ -241,6 +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` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 +- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - 编辑器抠图服务:手动 `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 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `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 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - 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 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射;不限制 BgFilter、阿里云或本地处理,但角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 057f22910..f9a920fe9 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,6 +2,8 @@ 日期:`2026-06-15` +更新时间:`2026-07-20` + ## 背景 图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色规范与常规参考图。 @@ -162,7 +164,9 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 +- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 +- BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 - 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 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 83b3e074c..0400a0ef8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -2320,6 +2320,7 @@ async fn extract_and_persist_editor_character_animation_frames( let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, apply_chroma_key: false, + prepare_for_bgfilter_input: true, sample_start_ratio: 0.0, sample_end_ratio: 1.0, }; @@ -4077,6 +4078,7 @@ fn normalize_animation_frame_extraction_plan( AnimationFrameExtractionPlan { frame_count, apply_chroma_key, + prepare_for_bgfilter_input: false, sample_start_ratio, sample_end_ratio, } @@ -4149,13 +4151,23 @@ async fn extract_animation_frames_from_preview_video( "message": format!("读取动作抽帧结果失败:{error}"), })) })?; - finalized_frames.push(finalize_animation_frame_payload( - frame_bytes.as_slice(), - "image/png", - frame_width, - frame_height, - plan.apply_chroma_key, - )?); + let finalized_frame = if plan.prepare_for_bgfilter_input { + prepare_editor_character_animation_bgfilter_input( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + )? + } else { + finalize_animation_frame_payload( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + plan.apply_chroma_key, + )? + }; + finalized_frames.push(finalized_frame); } Ok::<_, AppError>(finalized_frames) @@ -4368,6 +4380,67 @@ fn run_process_with_timeout( } } +fn prepare_editor_character_animation_bgfilter_input( + source: &[u8], + mime_type: &str, + target_width: u32, + target_height: u32, +) -> Result { + let image_format = match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + }; + let image = match image_format { + Some(format) => image::load_from_memory_with_format(source, format), + None => image::load_from_memory(source), + } + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("解析 BgFilter 输入动作帧图片失败:{error}"), + })) + })? + .to_rgb8(); + + let target_width = target_width.max(1); + let target_height = target_height.max(1); + let source_width = image.width().max(1); + let source_height = image.height().max(1); + let scale = (target_width as f32 / source_width as f32) + .min(target_height as f32 / source_height as f32); + let draw_width = ((source_width as f32 * scale).round() as u32) + .max(1) + .min(target_width); + let draw_height = ((source_height as f32 * scale).round() as u32) + .max(1) + .min(target_height); + let resized = image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle); + + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image( + resized.as_raw(), + resized.width(), + resized.height(), + ColorType::Rgb8.into(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("编码 BgFilter 输入动作帧 PNG 失败:{error}"), + })) + })?; + + Ok(FinalizedAnimationFrame { + bytes: encoded, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + fn finalize_animation_frame_payload( source: &[u8], mime_type: &str, @@ -5759,6 +5832,7 @@ struct BackendFrameExtractionSettings { struct AnimationFrameExtractionPlan { frame_count: u32, apply_chroma_key: bool, + prepare_for_bgfilter_input: bool, sample_start_ratio: f32, sample_end_ratio: f32, } @@ -6267,6 +6341,77 @@ mod tests { ); } + #[test] + fn editor_character_animation_bgfilter_input_is_rgb_without_padding() { + let source = image::RgbImage::from_pixel(560, 752, image::Rgb([17, 99, 201])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgb8.into(), + ) + .expect("source RGB frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 323, + 480, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode"); + + assert_eq!(prepared.mime_type, "image/png"); + assert_eq!(prepared.extension, "png"); + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 434); + assert_eq!(decoded.color(), ColorType::Rgb8); + assert!( + decoded + .to_rgb8() + .pixels() + .all(|pixel| pixel.0 == [17, 99, 201]) + ); + } + + #[test] + fn editor_character_animation_final_frame_adds_transparent_vertical_padding() { + let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let finalized = + finalize_animation_frame_payload(source_png.as_slice(), "image/png", 323, 480, false) + .expect("final transparent frame should be finalized"); + let decoded = + image::load_from_memory_with_format(finalized.bytes.as_slice(), ImageFormat::Png) + .expect("final transparent frame should decode"); + + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 480); + assert_eq!(decoded.color(), ColorType::Rgba8); + let output = decoded.to_rgba8(); + for (x, y, pixel) in output.enumerate_pixels() { + let expected = if (23..457).contains(&y) { + [31, 127, 223, 191] + } else { + [0, 0, 0, 0] + }; + assert_eq!(pixel.0, expected, "unexpected pixel at ({x}, {y})"); + } + } + #[test] fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); @@ -6276,6 +6421,7 @@ mod tests { "async fn publish_animation_set", &[ "apply_chroma_key: false", + "prepare_for_bgfilter_input: true", "editor_character_animation_bgfilter_request_timeout_ms", "state.config.editor_bgfilter_request_timeout_ms", "process_and_persist_editor_character_animation_frame", @@ -6286,6 +6432,22 @@ mod tests { "frame_errors.sort_by_key", ], ); + assert_function_contains( + source, + "fn normalize_animation_frame_extraction_plan", + "fn normalize_sample_ratio", + &["apply_chroma_key", "prepare_for_bgfilter_input: false"], + ); + assert_function_contains_in_order( + source, + "async fn extract_animation_frames_from_preview_video", + "fn create_animation_temp_dir", + &[ + "plan.prepare_for_bgfilter_input", + "prepare_editor_character_animation_bgfilter_input", + "finalize_animation_frame_payload", + ], + ); assert_function_contains_in_order( source, "async fn process_and_persist_editor_character_animation_frame", -- 2.52.0 From 75184d07714be40a470eb5257b84c64ce6247977 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 20 Jul 2026 13:37:55 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=A0=BC=E5=BC=8F=E5=B9=B6=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=B8=A7=E5=9B=BE=E5=83=8F=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg 抽帧显式指定 -pix_fmt rgb24,消除 to_rgb8 截断 alpha 的隐患 抽出 compute_contain_dimensions 与 image_format_from_mime 供两条帧路径共用 尺寸已匹配时跳过无效的 Triangle 重采样 Co-Authored-By: Claude Fable 5 --- .../src/character_animation_assets.rs | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) 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 0400a0ef8..370d82ca8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4287,6 +4287,8 @@ fn extract_video_frame_to_png( input_path.to_string_lossy().as_ref(), "-frames:v", "1", + "-pix_fmt", + "rgb24", "-f", "image2", output_path.to_string_lossy().as_ref(), @@ -4386,13 +4388,7 @@ fn prepare_editor_character_animation_bgfilter_input( target_width: u32, target_height: u32, ) -> Result { - let image_format = match mime_type { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/webp" => Some(ImageFormat::WebP), - _ => None, - }; - let image = match image_format { + let image = match image_format_from_mime(mime_type) { Some(format) => image::load_from_memory_with_format(source, format), None => image::load_from_memory(source), } @@ -4404,19 +4400,13 @@ fn prepare_editor_character_animation_bgfilter_input( })? .to_rgb8(); - let target_width = target_width.max(1); - let target_height = target_height.max(1); - let source_width = image.width().max(1); - let source_height = image.height().max(1); - let scale = (target_width as f32 / source_width as f32) - .min(target_height as f32 / source_height as f32); - let draw_width = ((source_width as f32 * scale).round() as u32) - .max(1) - .min(target_width); - let draw_height = ((source_height as f32 * scale).round() as u32) - .max(1) - .min(target_height); - let resized = image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle); + let (draw_width, draw_height) = + compute_contain_dimensions(image.width(), image.height(), target_width, target_height); + let resized = if (draw_width, draw_height) == (image.width(), image.height()) { + image + } else { + image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle) + }; let mut encoded = Vec::new(); let encoder = PngEncoder::new(&mut encoded); @@ -4448,13 +4438,7 @@ fn finalize_animation_frame_payload( frame_height: u32, apply_chroma_key: bool, ) -> Result { - let image_format = match mime_type { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/webp" => Some(ImageFormat::WebP), - _ => None, - }; - let mut image = match image_format { + let mut image = match image_format_from_mime(mime_type) { Some(format) => image::load_from_memory_with_format(source, format), None => image::load_from_memory(source), } @@ -4501,8 +4485,39 @@ fn finalize_animation_frame_payload( fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0])); - let source_width = source.width().max(1); - let source_height = source.height().max(1); + let (draw_width, draw_height) = + compute_contain_dimensions(source.width(), source.height(), target_width, target_height); + let offset_x = ((target_width - draw_width) / 2) as i64; + let offset_y = ((target_height - draw_height) / 2) as i64; + if (draw_width, draw_height) == (source.width(), source.height()) { + image::imageops::overlay(&mut canvas, source, offset_x, offset_y); + } else { + let resized = + image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); + image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); + } + canvas +} + +fn image_format_from_mime(mime_type: &str) -> Option { + match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + } +} + +fn compute_contain_dimensions( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, +) -> (u32, u32) { + let target_width = target_width.max(1); + let target_height = target_height.max(1); + let source_width = source_width.max(1); + let source_height = source_height.max(1); let scale = (target_width as f32 / source_width as f32) .min(target_height as f32 / source_height as f32); let draw_width = ((source_width as f32 * scale).round() as u32) @@ -4511,11 +4526,7 @@ fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) let draw_height = ((source_height as f32 * scale).round() as u32) .max(1) .min(target_height); - let resized = image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); - let offset_x = ((target_width - draw_width) / 2) as i64; - let offset_y = ((target_height - draw_height) / 2) as i64; - image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); - canvas + (draw_width, draw_height) } async fn load_media_source_payload( -- 2.52.0 From 0f0a8d52df19037ea23428d759d5f19bd6479278 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 03:12:06 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=E8=BF=98=E5=8E=9F=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=8A=BD=E5=B8=A7=E5=83=8F=E7=B4=A0=E6=A0=BC=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=20BgFilter=20=E6=B6=88=E8=B4=B9=E7=82=B9=E5=90=88?= =?UTF-8?q?=E6=88=90=20alpha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤掉 extract_video_frames_to_png 里无条件的 -pix_fmt rgb24,共享抽帧不再携带 BgFilter 专属策略 prepare_editor_character_animation_bgfilter_input 解码后先按白底合成 alpha 再转 RGB,补上对应回归测试 Co-Authored-By: Claude Fable 5 --- .../src/character_animation_assets.rs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) 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 62d308a7b..43aa0111b 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4334,8 +4334,6 @@ fn extract_video_frames_to_png( format!("[f{frame_index}]"), "-frames:v".to_string(), "1".to_string(), - "-pix_fmt".to_string(), - "rgb24".to_string(), "-f".to_string(), "image2".to_string(), output_path.to_string_lossy().into_owned(), @@ -4471,6 +4469,25 @@ fn run_process_with_timeout( } } +/// BgFilter 只接受不透明 RGB 输入;直接丢弃 alpha 会让全透明像素下未定义的 +/// RGB 值以杂色进入抠图,这里先按白底合成再转 RGB。 +fn flatten_alpha_onto_white_rgb(image: image::DynamicImage) -> image::RgbImage { + if !image.color().has_alpha() { + return image.to_rgb8(); + } + let rgba = image.to_rgba8(); + let mut flattened = image::RgbImage::new(rgba.width(), rgba.height()); + for (source, target) in rgba.pixels().zip(flattened.pixels_mut()) { + let [red, green, blue, alpha] = source.0; + let opacity = f32::from(alpha) / 255.0; + let blend = |channel: u8| -> u8 { + (f32::from(channel) * opacity + 255.0 * (1.0 - opacity)).round() as u8 + }; + target.0 = [blend(red), blend(green), blend(blue)]; + } + flattened +} + fn prepare_editor_character_animation_bgfilter_input( source: &[u8], mime_type: &str, @@ -4486,8 +4503,8 @@ fn prepare_editor_character_animation_bgfilter_input( "provider": "character-animation", "message": format!("解析 BgFilter 输入动作帧图片失败:{error}"), })) - })? - .to_rgb8(); + })?; + let image = flatten_alpha_onto_white_rgb(image); let (draw_width, draw_height) = compute_contain_dimensions(image.width(), image.height(), target_width, target_height); @@ -6478,6 +6495,38 @@ mod tests { ); } + #[test] + fn editor_character_animation_bgfilter_input_flattens_alpha_onto_white() { + let mut source = RgbaImage::from_pixel(8, 8, Rgba([17, 99, 201, 255])); + source.put_pixel(0, 0, Rgba([255, 0, 0, 0])); + source.put_pixel(1, 0, Rgba([0, 0, 0, 127])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 8, + 8, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode") + .to_rgb8(); + + assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255]); + assert_eq!(decoded.get_pixel(1, 0).0, [128, 128, 128]); + assert_eq!(decoded.get_pixel(2, 0).0, [17, 99, 201]); + } + #[test] fn editor_character_animation_final_frame_adds_transparent_vertical_padding() { let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191])); -- 2.52.0 From 7831a23956dc93769f633a493edb9f816d524263 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 05:55:27 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=8A=BD=E5=B8=A7=E5=90=AB=20alpha=20?= =?UTF-8?q?=E5=85=88=E7=99=BD=E5=BA=95=E5=90=88=E6=88=90=E7=9A=84=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E8=BE=93=E5=85=A5=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决策日志、数据契约、画布接入方案和角色形象入口设计四份文档同步最终实现: 解码帧携带 Alpha 时必须先按白底合成再转 RGB8,禁止直接丢弃 Alpha, 共享 FFmpeg 抽帧命令不固定 -pix_fmt,合成职责只在 BgFilter 输入准备阶段 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 1 + .../【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 ++-- docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 23d78e71f..a1b883946 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 - 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 8024fc550..59b7c2141 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,6 +34,7 @@ ### 角色动作帧抠图像素边界 - 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 +- 转 RGB8 时若解码帧携带 Alpha 通道,必须先按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha:全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘。共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只发生在 BgFilter 输入准备阶段。 ## 交互规则 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 62caff5a8..d45b627c0 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-20` +更新时间:`2026-07-21` ## 后端主线 @@ -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` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 -- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。 +- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - 编辑器抠图服务:手动 `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 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`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 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `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 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - 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 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射;不限制 BgFilter、阿里云或本地处理,但角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 5282f2ddc..edd32af5b 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-07-20` +更新时间:`2026-07-21` ## 背景 @@ -164,7 +164,7 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 -- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。 +- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 - BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 -- 2.52.0 From e312f1632bce65d28b6e82645c1987d388b09383 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 05:59:09 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=E6=9B=B4=E6=AD=A3=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=AD=20contain=5Frgba=5Fimage=20?= =?UTF-8?q?=E7=9A=84=E8=BE=B9=E7=95=8C=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 2026-07-21 补充行注明该函数后续重构(共享 contain 计算、同尺寸跳过重采样)但输出语义不变, 「不修改 contain_rgba_image」应理解为「不改变最终帧的 RGBA/padding 语义」 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a1b883946..2071bf68a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 -- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。另:`contain_rgba_image` 后续已被重构(contain 缩放计算抽出为 `compute_contain_dimensions` 共享,同尺寸时跳过重采样直接 overlay),输出语义不变;边界行中的「不修改 `contain_rgba_image`」应理解为「不改变最终帧的 RGBA/padding 语义」,不承诺函数实现不动。 - 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 -- 2.52.0 From 81437260954c5750f58bc639af3ff42aaf6176fc Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 06:33:14 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=E5=86=B3=E7=AD=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E8=A1=8C=E7=9B=B4=E6=8E=A5=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E7=BA=A7=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本条决策与其补充均为本分支新增,无需保留旧文再追加更正: 边界行「不修改 contain_rgba_image」改写为「不改变最终帧的 RGBA/padding 语义与透明帧格式」, 并删除补充行中为此加的更正说明 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2071bf68a..bf492c40e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,8 +20,8 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 -- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。另:`contain_rgba_image` 后续已被重构(contain 缩放计算抽出为 `compute_contain_dimensions` 共享,同尺寸时跳过重采样直接 overlay),输出语义不变;边界行中的「不修改 `contain_rgba_image`」应理解为「不改变最终帧的 RGBA/padding 语义」,不承诺函数实现不动。 -- 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 +- 边界:不改变最终帧的 RGBA/padding 语义与透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --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/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -- 2.52.0