背景
ai_engine 在 #53 落了空骨架(ports 契约 + DerivationStrategy 分流 + 串联),该 Issue 验收标准第 4 条写明:
内部真实实现不在本 Issue —— 另开开发 Issue,迁 windup-pipeline,带测试。
本 Issue 即该开发 Issue。实现来自个人管线仓的实测通路(定妆母版 → 图生视频 → 抽帧 → 抠图 → 像素化 → 对齐 → 序列帧),本次迁入主仓并补齐测试。
main 上现状:ai_engine 除 .gitkeep 外无任何实现;windup_common/models 为空;windup_framework/providers 只有三个 create_*_client 工厂,无可供上层依赖的抽象类型。
实现范围
按 backend/pyproject.toml 的 import-linter 分层契约自下而上切 4 个 PR,每个 PR 自身 CI 可绿、不依赖未合分支。
| PR |
层 |
内容 |
依赖 |
规模 |
用例 |
| 1 |
windup_common |
角色卡 / 动作规格 / 生成路线枚举 |
无 |
2 文件 +78 |
— |
| 2 |
windup_framework |
三个 Provider Protocol + 抠图 + 视频/图像实现 |
无 |
8 文件 +792 |
+7 |
| 3 |
windup_ai_engine 叶子 |
抽帧 / 选帧 / 后处理 / 提示词 |
无 |
19 文件 +1550 |
+17 |
| 4 |
windup_ai_engine 契约层 |
ports / strategy / impl |
PR 1·2·3 |
35 文件 +2994 |
+9 |
PR 1·2·3 互不依赖,可并行评审。PR 4 stack 在三者之上,合并后 rebase。
实现内容
1. 共享 DTO(windup_common/models)
CharacterCard — 角色身份(name / desc / palette / view / master_ref / version)
ActionSpec — 动作规格(action / fps / loop / poses / stylize / pixel_h / palette_size / facing)
ActionType — idle / walk / run / jump / attack / hit
GenRoute — video_i2v / per_frame(只列有实现的路线,理由见 Q4)
2. Provider 抽象与实现(windup_framework/providers)
interfaces.py — ImageProvider / VideoProvider / MatteProvider 三个 Protocol,零依赖,供上层按能力而非按厂商声明依赖
matte.py — OnnxU2NetMatteProvider:onnxruntime 直跑 u2netp。不用 rembg:其依赖链 pymatting → numba 0.53 / llvmlite 0.36 在 Python 3.12 无轮子;rembg 内核本身就是 u2netp 过 onnxruntime,alpha_matting=False 时不碰 pymatting
matte.py — GreenScreenMatteProvider:绿幕抠图,背景色由调用方显式给出。附带溢色抑制(绿幕反光在主体边缘留一圈绿,实测占主体像素 7.1%,送进图生 3D 会被烤进贴图)
sufy.py — 图生视频 / 文生图 provider
3. 出帧工具箱(windup_ai_engine slicing / postprocess / prompt / master_prep)
零 windup 依赖,只用 PIL + numpy,可独立测试。
slicing/extract 解码;slicing/loop 循环类动作抽单步态周期;slicing/oneshot 一次性动作裁区间;slicing/quality 帧质量诊断
postprocess/pixelate 母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化;postprocess/pack 脚线对齐 / 图集 / GIF;postprocess/rootmotion 逐帧时长
master_prep 按动作预处理母版
4. 对外契约与分流(windup_ai_engine ports / strategy / impl)
CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction,server 只 import ai_engine.ports,由 import-linter 强制
ROUTE_MATRIX 动作类型 → 生成路线
CharacterGenerator 选路线 → strategy.derive 出帧 → 脚线对齐 → 出参
边界(与作者对齐)
ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由 server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传对象存储、写 character_data。故本层无 ArtifactStorePort。
生成路线矩阵(架构契约)
| 动作 |
路线 |
状态 |
依据 |
| walk / run |
VIDEO_I2V |
实测通 |
逐帧独立生成锁不住"哪条腿在前"→ 踢踏舞;视频天生连贯、腿自然交替 |
| jump / attack |
VIDEO_I2V |
实测通 |
同上;但属一次性动作,抽帧不闭环,走 pick_oneshot |
| idle |
VIDEO_I2V |
实测通 |
见下方与 #53 的差异 ① |
| hit |
PER_FRAME |
未实现,调用即抛错 |
离散姿势,单帧可编辑价值高、无连续步态 |
改此矩阵 = 改产线,需实测支撑。依据见 #35。
与 #53 原设计的三处差异
| # |
#53 原设计 |
现状 |
依据 |
| ① |
idle → PROC_IDLE(¥0 程序化局部呼吸 Idle-B) |
idle → VIDEO_I2V,PROC_IDLE 枚举与 ProcIdleStrategy 一并移除 |
程序化呼吸做不出可用效果,放弃,认这份 i2v 的钱 |
| ② |
抠图 = rembg |
onnxruntime 直跑 u2netp |
rembg 依赖链在 3.12 无轮子;同模型同质量 |
| ③ |
视频 = kling-video-o1,v2-5-turbo / v2-1 已下架 |
v2-5-turbo 未下架 |
2026-07-27 复测收回该结论 |
关键缺陷修复
| 问题 |
影响 |
修复 |
未实现的路线返回 [b""] * n_frames |
调用方拿到帧数对、时长对、无异常的 GeneratedAction——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储并写入 character_data,用户看到 N 张裂图,排查时不会想到是路线没实现 |
边界上抛错:strategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出已装配了哪些;strategy 吐出空帧时抛 ValueError |
| 抠图 provider 在 onnxruntime 缺失时静默降级为"取四角主色 chroma-key" |
白底母版四角即白色,浅色角色(骨白 / 银甲)与背景撞色被抠穿;且静默——开发机上看着能跑、输出实际是坏的 |
改为抛 RuntimeError;绿幕抠图另立 provider,背景色显式给出,不猜 |
align_bottom_center 三条缩放分支只按高度定标 |
横向长条主体缩放后宽度超出画布,被 alpha_composite 以负 dest 静默丢像素,PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测某四足角色母版 w/h=1.78 丢 27px(鼻尖 + 尾尖),w/h=2.0 只剩 79.9% 内容 |
加宽度兜底 fill_w=0.96。人形 w/h 0.3–1.1 时该约束恒不生效,产物逐像素不变 |
| 步态周期误检(三个坑,见下) |
半周期闭环 = 末帧接回首帧时左右腿瞬间互换 |
见下 |
| 视频成品下载单次读取、无重试、不校验长度 |
该步发生在提交任务、轮询、等待全部成功之后,费用已产生、视频已生成,连接断一次整单作废。实测同一角色连续两单死在此处各烧一次费用 |
三次退避重试 + 长度校验(#129,已单独修复并合入 upstream 开发分支,本次随 PR 2 迁入) |
周期检测:三个坑与实测
坑 1|平移偏置。 角色在画面里横向位移,d(p) 被"挪了多远"主导、随 p 单调上升,真周期的凹陷被抬平,argmin 滑到搜索窗边界交出假周期。
实测某走路视频(源 121 帧,搜索窗 p ∈ [16,72]):未消平移时曲线 40/56 段在上升,只剩 22 / 42 / 52 三个浅坑,argmin = 22;加 _deskew 消整体平移后,整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。
坑 2|搜索窗过窄。 上界 n//2 把真周期挡在窗外(某待机视频真周期 62 > pmax 60)。改为 total*0.6。
坑 3|谐波。 平移偏置让 d(p) 偏爱短 lag,常选中真周期的约 1/2。改为在基周期整数倍里按归一化接缝(末→首帧差 ÷ 组内相邻帧差均值)复选,并优先取最小倍数——倍数越大 = 一个 loop 里塞进越多周期 = 每周期帧数越少 = 动作变糙。
无周期时不硬闭环。 凹陷深度 < 0.25 判"测不到可信周期",退化成全片均匀取。
实测某待机视频(源 31 帧,搜索窗 p ∈ [6,18]):未消平移时曲线单调,argmin 落在搜索窗下界 6,交出纯粹的边界假值;消平移后 p=11 虽是局部极小,但其值 14.010 夹在 14.013 与 14.023 之间,凹陷深度 0.006,被门槛挡掉。
四段真视频实测(n=16;接缝越接近 1 越闭合)
| 视频 |
旧 · 接缝 |
旧 · 逐像素重复帧 |
新 · 接缝 |
新 · 重复帧 |
| 走路 A |
3.07 |
0 |
1.96 |
0 |
| 走路 B |
1.29 |
0 |
0.87 |
0 |
| 待机 |
9.31 |
16 |
1.39 |
0 |
| 奔跑 |
1.77 |
0 |
0.81 |
0 |
待机一行:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。
消融。 改善全部来自 _deskew + 谐波复选。另行追加的「死帧避让 + 冻结裁剪」两条,两个样本无变化、两个变差(奔跑接缝 0.81 → 2.00),已回退。quality.py 保留作诊断,不进选帧。
测试覆盖
| 文件 |
用例 |
覆盖 |
test_ai_engine_skeleton.py |
9 |
路由矩阵契约、端到端串联、未实现路线抛错、空帧拒绝、已移除枚举 |
test_loop.py |
4 |
周期检测与循环选帧 |
test_oneshot.py |
5 |
一次性动作裁区间 |
test_pixelate.py |
8 |
像素化、母版规格探测、色板锁定 |
test_matte_provider.py |
2 |
抠图 provider |
test_sufy_video_download.py |
5 |
视频下载重试与长度校验 |
四条抛错相关的用例已拿修复前的旧实现对照,确认在修复前全部失败。
对抗式自检
以下四条是评审历史上会被追问的点。Q1–Q3 给出答案与其失效边界;Q4 是自检抓出的自相矛盾,已在提交前改掉。
Q1|ROUTE_MATRIX 固定在这一层对吗?为什么不让调用方传路线?
固定在 ai_engine.strategy。理由:路线选择依据是动作的物理性质(有无连续步态、是否一次性、是否需要单帧可编辑),不是调用方的业务决定。让 server 传等于把实测挣得的结论推给不掌握依据的一方。
但这个理由在渲染出帧路线进来后不成立——同一个 walk 既可走 i2v 也可走渲染,选择依据变成"该角色有没有 3D 模型",那是 server 才知道的事。
该边界已写进 strategy/base.py 的注释,以免后来者按错误前提扩展:接入第三条路线前须先定「路线选择由谁决定」,并可能要把矩阵改成「动作类型 → 可选路线集合」+ 一个选择器。
Q2|GeneratedAction{frames, durations, fps} 三个字段够吗?
对视频路线够。对渲染路线不够:出帧台在产出帧的同一时刻还能给出 root_motion / sample_times / fps_equiv / 源片段时长,这些只有渲染当时拿得到——帧出完后在图像空间只能反推位移,采样时刻与源片段时长反推不回来。
已在 #122 提出扩展,本 Issue 不改,避免同一契约两处并行修改。
Q3|为什么 ai_engine 不碰存储?边界依据是什么?
依据是"谁掌握租户与配额上下文"。上传对象存储要知道 bucket、路径规则、归属项目、配额;这些全在 server。ai_engine 若自持存储,等于把租户概念下沉到一个只应该做图像计算的层。
代价是帧 bytes 要在内存里过一次。当前 16 帧 512×512 RGBA ≈ 16MB,可接受;帧数或分辨率显著上升时需重新评估。
Q4|自相矛盾:删 PROC_IDLE 的理由是"不留没有实现的枚举值",却加了同样没有实现的 RENDER_3D
自检发现的矛盾,已在提交前改掉:RENDER_3D 已移除。
原本保留它的理由是「契约先留位,将来接入时 GenRoute 不必二次改形,避免一次跨模块的
破坏性变更」。该理由不成立:全仓 grep 确认 GenRoute 只在内存里当分流键,无持久化、
无序列化,枚举加成员是纯加法,不构成破坏性变更。理由塌掉后,留位就只剩「在代码里
表达意图」,而按团队规范意图应写进 Issue 不写进代码。
三渲二的契约需求由 #81 / #122 承载,随实现一起加枚举成员。
GenRoute 现在只有 VIDEO_I2V / PER_FRAME,并由 test_genroute_only_lists_implemented_routes
锁死——该用例同时管住两个方向:已证否的路线要删,未来路线不提前留位。
需要评审拍板(本 Issue 不改,列出待议)
以下四条是自检时发现的既有契约问题,修改会动 windup_common 的公共契约,故不在本批 PR 内处理。
它们有共同根因:契约按逐帧路线(route A)设计,而落地的是视频路线。n_frames 由 poses 推导、
CharacterCard 零读取、loop / view / palette 零消费方,都是同一副旧设计骨架的残留。
D0|CharacterCard 六个字段在 ai_engine 里零读取
generate(card, action, master, progress) 与 strategy.derive(card, ...) 都接收 card,
但全仓 grep card\. 在 ai_engine 下零命中——name / desc / palette / view /
master_ref / version 一个都没被读过。
原因是视频路线的角色身份由母版图像承载,不由提示词承载:_build_prompt 只取
action.facing,服装 / 鞋子走默认值,所以不同角色跑出来的提示词是同一份。
这不是缺陷,是路线特性。但它意味着:CharacterCard 目前是为逐帧路线设计、却只在视频
路线上被传递的占位参数。逐帧路线要用 desc 组提示词,渲染路线要用 model_3d_ref(#122),
两条都还没实现。
建议:明确它是「为未实现路线预留的入参」并在 ports docstring 里写清,或暂时从签名移除、
随逐帧路线一起加回。此处不单方面决定。
D1|ActionSpec.n_frames 由 len(poses) 推导,但视频路线不用 poses
@property
def n_frames(self) -> int:
return len(self.poses)
视频路线取 n = action.n_frames or 8。调用方要出 16 帧,必须传一个 16 元素的 poses 列表,而列表内容在视频路线上完全不被使用。
建议:n_frames 改为显式字段,poses 仅逐帧路线使用;n_frames 缺省时再回退到 len(poses)。
D2|枚举风格前后不一致
ActionType / GenRoute 是 Enum;而 ActionSpec.loop / stylize / facing 与 CharacterCard.view 是裸 str,合法值只写在注释里(none/linear/pingpong、pixel/none、side/front、side/topdown/isometric)。
facing 尤其要紧:master_prep 里写死的契约是"提示词朝向必须与母版一致,给正面母版喂侧走词会让模型靠转身调和图文矛盾"。这条约束靠字符串传递,拼错不报错。
建议:四者都改枚举。
D3|CharacterCard.palette 是 str 但无格式约定
字段存在但没有消费方,也没写清是色板名、十六进制串还是存储引用。
建议:定格式或删除。
第二轮对抗自检:可生成性与质量信号的缺口
第一轮自检查的是契约形状,这一轮查的是输入不合作时会怎样。结论:本批实现对任意输入
都会走完全程并报成功,失败全部是静默的。
以「一张人物在画板前作画的图,请求 walk」为例,逐段追踪:
| 段 |
行为 |
报错 |
generate() 入口 |
不校验母版能否解码、有无主体、有无腿、朝向是否与 facing 一致 |
否 |
prepare_master |
walk 不做处理,原图直传 |
否 |
_build_prompt |
只传 action.facing,提示词是固定的侧走模板,与母版内容无关 |
否 |
| i2v |
图文矛盾(图在作画、词在走路)→ 模型靠转身 / 变形调和(master_prep 已记录该机制) |
否 |
matte.cutout |
显著性抠图,全仓无连通域筛选 → 画板被当主体一并保留 |
否 |
pick_cycle |
测不到步态周期 → 凹陷深度 < 0.25 → 静默降级为全片均匀取 |
否 |
align_bottom_center |
按整幅 alpha bbox 定标,画板每帧都在,中位数同样被撑大 → 角色系统性缩小 |
否 |
| 出参 |
16 帧、逐帧时长齐、无异常 |
— |
调用方得到一个"看起来成功"的结果。这与本 Issue 已修的「未实现路线返回空帧」属同一类问题,
只是发生在产品层而非代码层。
E1|输入侧零校验
ports 与 impl 内所有 raise 都在输出侧(空帧、未实现路线)。master: bytes 不做
任何前置判定。
建议:加可生成性预检并允许拒绝——母版可解码 / 有主体 / 主体连通域数 / 走路类动作是否可见腿 /
朝向与 facing 是否一致。
E2|pick_cycle 四条返回路径,调用方无法区分
帧数不足 → 原样返回
测不到可信周期 → 均匀取,不闭环
候选为空 → 均匀取,不闭环
正常 → 单周期闭环
四条返回类型相同、无任何标记。上层无法判断这 16 帧是干净的步态循环还是降级产物。
E3|抠图无连通域筛选
OnnxU2NetMatteProvider.cutout 直接用显著性 mask 合成,抠出几个连通域就保留几个。
画板、道具、第二个角色都会被保留。
E4|对齐实现与产品规则不一致
产品规则要求「脚底线 + 躯干中心锚点对齐,忽略剑 / 围巾等延展物」。实现为:
canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift))
水平方向取的是 bbox 中心而非躯干中心;延展物仅靠"各帧高度取中位数"抵抗单帧异常
(如某帧武器高举),对每帧都存在的延展物(画板 / 披风 / 坐骑)无效。
E5|出参无质量信号
GeneratedAction{frames, durations, fps} 无法表达"这次生成成色如何"。而所需指标本仓已具备
——归一化接缝、死帧数、周期可信度均可由 slicing/quality 与 slicing/loop 给出,只是不进出参。
建议:出参增加质量字段,让上层据此决定交付 / 重试 / 提示用户换母版。
E6|提示词硬编码,不构成一个可运营的能力
prompt/*.py 为写死模板;build_walk_prompt(garment, feet, facing) 三个参数中实际只传
facing,服装 / 鞋子取默认值,CharacterCard.desc 未进入提示词(即 D0)。因此不同角色
使用同一份提示词。当前无版本、无 A/B、无回滚,调整一次需发一次代码。
提示词是"如何让模型听话"的知识,会随模型版本漂移,且用户有自定义诉求(前端曾出现
ActionType.CUSTOM + action_desc 的形状)。建议单列为独立能力,本期先把注入口留出:
_build_prompt 目前写死 builder 表,应改为可注入。
测试覆盖的已知空白
本批 34 个用例每个都有断言,无空跑用例。但以下模块的公开函数零直接覆盖:
| 模块 |
未覆盖函数 |
slicing/quality |
frame_deltas dead_frame_mask active_span blur_ratio |
postprocess/rootmotion |
extract_root_motion frame_durations |
prompt/actions |
build_idle_prompt build_attack_prompt |
master_prep |
add_headroom prepare_master |
其中 frame_durations 参与每次出参构造、prepare_master 参与每次 jump 生成,属实际执行
路径上的未覆盖点。
以上 E1–E6 与测试空白均不在本批 PR 修改范围,建议单开 Issue「可生成性判定与质量门禁」
承载。当前契约是「给我母版,我给你帧」,缺的是「判断这个母版能否做这个动作」与「告知这次
成色如何」两层。
补充:视频接口面已变更(2026-08-07 实测)
本节推翻上文对 PR 2 的部分描述,与之冲突时以本节为准。
现状
SufyVideoProvider 建在过时的接口形状上:打 /v1/videos,首帧走 input_reference 的
base64 dataURI。而平台当前有 69 个 POST 视频端点,其中 22 个是图生视频,全部是
FAL 队列格式 /queue/...,全部要 image_url 公网 URL。
发现经过:用一张全新角色母版跑 kling-v3-omni 端到端,费用已产生。任务 completed、
16 帧齐、逐帧时长齐,选帧指标反而是历史最好的一组(接缝 0.66、零重复帧、零死帧),
但画面里是一个与母版毫无关系的写实路人,且只有下半身——提示词里 "the legs clearly
visible" 被当成了取景指令。参考图从未被采纳。
kling-v3-omni 的图生视频真实地址是 /queue/fal-ai/kling-video/o3/{mode}/image-to-video。
在 /v1/videos 上给它塞 input_reference 或 image_list,四种字段形状全部试过,都不
可能成功——地址就是错的。
已按现行接口重写
新增 FalQueueVideoProvider 与显式的模型→端点映射表(10 个模型),保留
SufyVideoProvider 不动(/v1/videos 对 sora 系可能仍有效,无证据说它坏了)。
规范里挖出四处不能靠直觉推断的地方:
| 项 |
事实 |
| 轮询地址 |
不是提交地址加 /requests。六个 kling 模型共用 /queue/fal-ai/kling-video/requests/{id},模型段与 {mode} 全部消失;vidu 也丢掉 q3/pro |
| 认证 |
FAL 端点是 Authorization: Key {key},不是 Bearer |
| base 路径 |
AI_BASE_URL 结尾是 /v1,而 /queue 是它的兄弟。不剥掉 /v1,每个调用都是 404 |
| mode 枚举 |
各模型不同:v2.6 只有 pro,v3-turbo 是 standard/pro(没有 std)。现默认 mode="std" 在两个模型上会 400,故构造时即拒绝 |
另:请求体没有 model 字段(模型即路径),duration 有三种写法("5" / "8s" / 5)。
契约冲突与解法
VideoProvider.i2v(first_frame: bytes, ...) 收 bytes,FAL 要公网 URL。
未改 Protocol。 新增 FirstFrameUploader port,作为 FalQueueVideoProvider 的必填
无默认构造参数——构造不出一个没有首帧发布途径的 FAL provider。
不选另外三条的理由:
- 改 Protocol 收 URL → 把"取公网 URL"推给所有调用方,包括需要 bytes 的
SufyVideoProvider,
且让对象存储成为整条管线的关切,与 ai_engine ports 的"不碰存储"边界矛盾
- provider 自持上传 → 要在
framework/providers 里塞一个对象存储客户端,无凭证无法测试;
windup_framework/storage/ 目前是空的,那条线该由存储的负责人来落
i2v 加可选 URL 参数 → 一个实现必需、另一个忽略的参数,污染共享契约
影响面:当前零调用点。 git grep .i2v( / VideoProvider 只命中 provider 自身与文档字符串。
新增义务只落在组装层:构造 FAL provider 时必须提供 uploader。
测试 37 个用例全 mock、零网络;变异测试 11/11 全部被捕获。
未验证
- 从未真实调用过 FAL 端点。 认证方式、
/v1 剥离、请求体形状、轮询、结果回退、下载,
全部只对着 OpenAPI 规范与 mock 验证过。首次真跑会花钱,且可能暴露不一致
- 三份规范(seedance-2.0 / vidu-q3-pro / kling-v3-turbo)称图字段接受 "URL 或 base64",
另 19 份只写 URL。而实测四种 base64 形状均未被采纳。实现统一要求 http(s),对 22 个都安全
- 哪个模型对我们的角色效果最好——未测。映射表是能力清单,不是推荐
补充:抠图(2026-08-07 实测)
两个问题
一、u2netp 对闭合区域天然失灵。 四足角色腿间的背景是一块被主体围住的空隙,显著性
模型把它当成主体内部,整块底色留在产物里;轮廓上还带一圈底色描边。
二、底色不可指定。 母版生成提示词写 SOLID MAGENTA #FF00FF,模型实际给 #DE297C
(偏离 140);改要求纯绿 #00FF00,给 #9ACC5D(偏离 190)。两次都不听。 因此
"规定一个底色然后按死值抠"这个思路不成立,只能采样实际底色。
修法必须窄
实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道完全相同、
欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去"反解",越解越坏
(先成橄榄绿、再成亮绿)。
最终解法:u2netp 定主体,只杀 d < 38 的近似精确等于底色的像素。橙毛 d≈117
完全不受影响,闭合空隙 d≈0 干净移除。三个角色底色残留
2.54% / 0.44% / 1.25% → 0.17% / 0.21% / 0.26%,主体内部逐像素未动。
与"按颜色抠是死路"那条规则的边界:那条说的是拿颜色当主体判据(白底浅色角色会被
抠穿)。这里主体判据仍是 u2netp,颜色只用来做减法,绝不新增主体像素;底色不够均匀
时(四角标准差 > 8)直接跳过。
顺带去掉 onnxruntime 缺失时"猜四角主色"的静默兜底,改为抛错。
方法论教训
修这个问题的过程中造了三个残留指标,全部漏判了橙毛被改坏——因为橙毛的 alpha 是
0.62,不在"内部像素 α>250"的统计范围里。每次都是先看画面才发现指标在骗人。涉及画面
的改动,指标只能用来复核,不能用来验收。
补充:新增验证(2026-08-07)
Holdout(8 段从未参与调参的真视频):pick_oneshot 首次在真视频上验证——它在
jump/attack 主路径上,此前只有合成数据的单元测试。4 段攻击视频无重复帧,选中段的运动
强度是全片平均的 1.7–2.3 倍。pick_cycle 4 段中 3 段干净,唯一告警是一段近乎静止的早期
废片,算法正确地判"无周期"并退化成不硬闭环。
主路径补测:frame_durations(每次出参构造都走)与 prepare_master(每次 jump/attack
生成都走)此前零直接覆盖,补 21 个用例,变异测试 5/5 全部被捕获。
代码去重:_gray() + _SMALL=48 在 slicing/loop.py 与 slicing/quality.py 各有一份
完整拷贝;_png()/_img() 在 strategy/concrete.py 与 impl/character_generator.py 各一份。
两组都收成唯一定义。这类分叉不会报错,只在数据或画面上体现。
画布裁切修复的真实样本:新生成的四足角色主体 w/h = 1.92,超过 1.61 裁切悬崖。修复前
鼻尖与尾尖被切平、主体贴死画布左右边缘;修复后完整落在画布内。两个人形角色(w/h 0.48 /
0.70)修复前后逐像素相同,确认该约束不误伤既有资产。
关联
背景
ai_engine在 #53 落了空骨架(ports 契约 + DerivationStrategy 分流 + 串联),该 Issue 验收标准第 4 条写明:本 Issue 即该开发 Issue。实现来自个人管线仓的实测通路(定妆母版 → 图生视频 → 抽帧 → 抠图 → 像素化 → 对齐 → 序列帧),本次迁入主仓并补齐测试。
main上现状:ai_engine除.gitkeep外无任何实现;windup_common/models为空;windup_framework/providers只有三个create_*_client工厂,无可供上层依赖的抽象类型。实现范围
按
backend/pyproject.toml的 import-linter 分层契约自下而上切 4 个 PR,每个 PR 自身 CI 可绿、不依赖未合分支。windup_commonwindup_frameworkwindup_ai_engine叶子windup_ai_engine契约层PR 1·2·3 互不依赖,可并行评审。PR 4 stack 在三者之上,合并后 rebase。
实现内容
1. 共享 DTO(
windup_common/models)CharacterCard— 角色身份(name / desc / palette / view / master_ref / version)ActionSpec— 动作规格(action / fps / loop / poses / stylize / pixel_h / palette_size / facing)ActionType— idle / walk / run / jump / attack / hitGenRoute— video_i2v / per_frame(只列有实现的路线,理由见 Q4)2. Provider 抽象与实现(
windup_framework/providers)interfaces.py—ImageProvider/VideoProvider/MatteProvider三个 Protocol,零依赖,供上层按能力而非按厂商声明依赖matte.py—OnnxU2NetMatteProvider:onnxruntime 直跑 u2netp。不用 rembg:其依赖链 pymatting → numba 0.53 / llvmlite 0.36 在 Python 3.12 无轮子;rembg 内核本身就是 u2netp 过 onnxruntime,alpha_matting=False时不碰 pymattingmatte.py—GreenScreenMatteProvider:绿幕抠图,背景色由调用方显式给出。附带溢色抑制(绿幕反光在主体边缘留一圈绿,实测占主体像素 7.1%,送进图生 3D 会被烤进贴图)sufy.py— 图生视频 / 文生图 provider3. 出帧工具箱(
windup_ai_engineslicing / postprocess / prompt / master_prep)零 windup 依赖,只用 PIL + numpy,可独立测试。
slicing/extract解码;slicing/loop循环类动作抽单步态周期;slicing/oneshot一次性动作裁区间;slicing/quality帧质量诊断postprocess/pixelate母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化;postprocess/pack脚线对齐 / 图集 / GIF;postprocess/rootmotion逐帧时长master_prep按动作预处理母版4. 对外契约与分流(
windup_ai_engineports / strategy / impl)CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction,server 只 importai_engine.ports,由 import-linter 强制ROUTE_MATRIX动作类型 → 生成路线CharacterGenerator选路线 →strategy.derive出帧 → 脚线对齐 → 出参边界(与作者对齐)
ai_engine只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由 server 从Character.reference_image_url取好以 bytes 传入;产出的帧由 server 上传对象存储、写character_data。故本层无ArtifactStorePort。生成路线矩阵(架构契约)
pick_oneshot改此矩阵 = 改产线,需实测支撑。依据见 #35。
与 #53 原设计的三处差异
PROC_IDLE(¥0 程序化局部呼吸 Idle-B)VIDEO_I2V,PROC_IDLE枚举与ProcIdleStrategy一并移除关键缺陷修复
[b""] * n_framesGeneratedAction——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储并写入character_data,用户看到 N 张裂图,排查时不会想到是路线没实现NotImplementedError;装配表缺该路线时抛错并报出已装配了哪些;strategy 吐出空帧时抛ValueErrorRuntimeError;绿幕抠图另立 provider,背景色显式给出,不猜align_bottom_center三条缩放分支只按高度定标alpha_composite以负 dest 静默丢像素,PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测某四足角色母版 w/h=1.78 丢 27px(鼻尖 + 尾尖),w/h=2.0 只剩 79.9% 内容fill_w=0.96。人形 w/h 0.3–1.1 时该约束恒不生效,产物逐像素不变周期检测:三个坑与实测
坑 1|平移偏置。 角色在画面里横向位移,
d(p)被"挪了多远"主导、随 p 单调上升,真周期的凹陷被抬平,argmin滑到搜索窗边界交出假周期。实测某走路视频(源 121 帧,搜索窗 p ∈ [16,72]):未消平移时曲线 40/56 段在上升,只剩 22 / 42 / 52 三个浅坑,
argmin = 22;加_deskew消整体平移后,整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。坑 2|搜索窗过窄。 上界
n//2把真周期挡在窗外(某待机视频真周期 62 > pmax 60)。改为total*0.6。坑 3|谐波。 平移偏置让
d(p)偏爱短 lag,常选中真周期的约 1/2。改为在基周期整数倍里按归一化接缝(末→首帧差 ÷ 组内相邻帧差均值)复选,并优先取最小倍数——倍数越大 = 一个 loop 里塞进越多周期 = 每周期帧数越少 = 动作变糙。无周期时不硬闭环。 凹陷深度 < 0.25 判"测不到可信周期",退化成全片均匀取。
实测某待机视频(源 31 帧,搜索窗 p ∈ [6,18]):未消平移时曲线单调,
argmin落在搜索窗下界 6,交出纯粹的边界假值;消平移后 p=11 虽是局部极小,但其值 14.010 夹在 14.013 与 14.023 之间,凹陷深度 0.006,被门槛挡掉。四段真视频实测(n=16;接缝越接近 1 越闭合)
待机一行:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。
消融。 改善全部来自
_deskew+ 谐波复选。另行追加的「死帧避让 + 冻结裁剪」两条,两个样本无变化、两个变差(奔跑接缝 0.81 → 2.00),已回退。quality.py保留作诊断,不进选帧。测试覆盖
test_ai_engine_skeleton.pytest_loop.pytest_oneshot.pytest_pixelate.pytest_matte_provider.pytest_sufy_video_download.py四条抛错相关的用例已拿修复前的旧实现对照,确认在修复前全部失败。
对抗式自检
以下四条是评审历史上会被追问的点。Q1–Q3 给出答案与其失效边界;Q4 是自检抓出的自相矛盾,已在提交前改掉。
Q1|
ROUTE_MATRIX固定在这一层对吗?为什么不让调用方传路线?固定在
ai_engine.strategy。理由:路线选择依据是动作的物理性质(有无连续步态、是否一次性、是否需要单帧可编辑),不是调用方的业务决定。让 server 传等于把实测挣得的结论推给不掌握依据的一方。但这个理由在渲染出帧路线进来后不成立——同一个 walk 既可走 i2v 也可走渲染,选择依据变成"该角色有没有 3D 模型",那是 server 才知道的事。
该边界已写进
strategy/base.py的注释,以免后来者按错误前提扩展:接入第三条路线前须先定「路线选择由谁决定」,并可能要把矩阵改成「动作类型 → 可选路线集合」+ 一个选择器。Q2|
GeneratedAction{frames, durations, fps}三个字段够吗?对视频路线够。对渲染路线不够:出帧台在产出帧的同一时刻还能给出
root_motion/sample_times/fps_equiv/ 源片段时长,这些只有渲染当时拿得到——帧出完后在图像空间只能反推位移,采样时刻与源片段时长反推不回来。已在 #122 提出扩展,本 Issue 不改,避免同一契约两处并行修改。
Q3|为什么
ai_engine不碰存储?边界依据是什么?依据是"谁掌握租户与配额上下文"。上传对象存储要知道 bucket、路径规则、归属项目、配额;这些全在 server。
ai_engine若自持存储,等于把租户概念下沉到一个只应该做图像计算的层。代价是帧 bytes 要在内存里过一次。当前 16 帧 512×512 RGBA ≈ 16MB,可接受;帧数或分辨率显著上升时需重新评估。
Q4|自相矛盾:删
PROC_IDLE的理由是"不留没有实现的枚举值",却加了同样没有实现的RENDER_3D自检发现的矛盾,已在提交前改掉:
RENDER_3D已移除。原本保留它的理由是「契约先留位,将来接入时
GenRoute不必二次改形,避免一次跨模块的破坏性变更」。该理由不成立:全仓 grep 确认
GenRoute只在内存里当分流键,无持久化、无序列化,枚举加成员是纯加法,不构成破坏性变更。理由塌掉后,留位就只剩「在代码里
表达意图」,而按团队规范意图应写进 Issue 不写进代码。
三渲二的契约需求由 #81 / #122 承载,随实现一起加枚举成员。
GenRoute现在只有VIDEO_I2V/PER_FRAME,并由test_genroute_only_lists_implemented_routes锁死——该用例同时管住两个方向:已证否的路线要删,未来路线不提前留位。
需要评审拍板(本 Issue 不改,列出待议)
以下四条是自检时发现的既有契约问题,修改会动
windup_common的公共契约,故不在本批 PR 内处理。它们有共同根因:契约按逐帧路线(route A)设计,而落地的是视频路线。
n_frames由poses推导、CharacterCard零读取、loop/view/palette零消费方,都是同一副旧设计骨架的残留。D0|
CharacterCard六个字段在ai_engine里零读取generate(card, action, master, progress)与strategy.derive(card, ...)都接收card,但全仓 grep
card\.在ai_engine下零命中——name/desc/palette/view/master_ref/version一个都没被读过。原因是视频路线的角色身份由母版图像承载,不由提示词承载:
_build_prompt只取action.facing,服装 / 鞋子走默认值,所以不同角色跑出来的提示词是同一份。这不是缺陷,是路线特性。但它意味着:
CharacterCard目前是为逐帧路线设计、却只在视频路线上被传递的占位参数。逐帧路线要用
desc组提示词,渲染路线要用model_3d_ref(#122),两条都还没实现。
建议:明确它是「为未实现路线预留的入参」并在 ports docstring 里写清,或暂时从签名移除、
随逐帧路线一起加回。此处不单方面决定。
D1|
ActionSpec.n_frames由len(poses)推导,但视频路线不用poses视频路线取
n = action.n_frames or 8。调用方要出 16 帧,必须传一个 16 元素的poses列表,而列表内容在视频路线上完全不被使用。建议:
n_frames改为显式字段,poses仅逐帧路线使用;n_frames缺省时再回退到len(poses)。D2|枚举风格前后不一致
ActionType/GenRoute是Enum;而ActionSpec.loop/stylize/facing与CharacterCard.view是裸str,合法值只写在注释里(none/linear/pingpong、pixel/none、side/front、side/topdown/isometric)。facing尤其要紧:master_prep里写死的契约是"提示词朝向必须与母版一致,给正面母版喂侧走词会让模型靠转身调和图文矛盾"。这条约束靠字符串传递,拼错不报错。建议:四者都改枚举。
D3|
CharacterCard.palette是str但无格式约定字段存在但没有消费方,也没写清是色板名、十六进制串还是存储引用。
建议:定格式或删除。
第二轮对抗自检:可生成性与质量信号的缺口
第一轮自检查的是契约形状,这一轮查的是输入不合作时会怎样。结论:本批实现对任意输入
都会走完全程并报成功,失败全部是静默的。
以「一张人物在画板前作画的图,请求 walk」为例,逐段追踪:
generate()入口facing一致prepare_master_build_promptaction.facing,提示词是固定的侧走模板,与母版内容无关master_prep已记录该机制)matte.cutoutpick_cyclealign_bottom_center调用方得到一个"看起来成功"的结果。这与本 Issue 已修的「未实现路线返回空帧」属同一类问题,
只是发生在产品层而非代码层。
E1|输入侧零校验
ports与impl内所有raise都在输出侧(空帧、未实现路线)。master: bytes不做任何前置判定。
建议:加可生成性预检并允许拒绝——母版可解码 / 有主体 / 主体连通域数 / 走路类动作是否可见腿 /
朝向与
facing是否一致。E2|
pick_cycle四条返回路径,调用方无法区分四条返回类型相同、无任何标记。上层无法判断这 16 帧是干净的步态循环还是降级产物。
E3|抠图无连通域筛选
OnnxU2NetMatteProvider.cutout直接用显著性 mask 合成,抠出几个连通域就保留几个。画板、道具、第二个角色都会被保留。
E4|对齐实现与产品规则不一致
产品规则要求「脚底线 + 躯干中心锚点对齐,忽略剑 / 围巾等延展物」。实现为:
水平方向取的是 bbox 中心而非躯干中心;延展物仅靠"各帧高度取中位数"抵抗单帧异常
(如某帧武器高举),对每帧都存在的延展物(画板 / 披风 / 坐骑)无效。
E5|出参无质量信号
GeneratedAction{frames, durations, fps}无法表达"这次生成成色如何"。而所需指标本仓已具备——归一化接缝、死帧数、周期可信度均可由
slicing/quality与slicing/loop给出,只是不进出参。建议:出参增加质量字段,让上层据此决定交付 / 重试 / 提示用户换母版。
E6|提示词硬编码,不构成一个可运营的能力
prompt/*.py为写死模板;build_walk_prompt(garment, feet, facing)三个参数中实际只传facing,服装 / 鞋子取默认值,CharacterCard.desc未进入提示词(即 D0)。因此不同角色使用同一份提示词。当前无版本、无 A/B、无回滚,调整一次需发一次代码。
提示词是"如何让模型听话"的知识,会随模型版本漂移,且用户有自定义诉求(前端曾出现
ActionType.CUSTOM+action_desc的形状)。建议单列为独立能力,本期先把注入口留出:_build_prompt目前写死 builder 表,应改为可注入。测试覆盖的已知空白
本批 34 个用例每个都有断言,无空跑用例。但以下模块的公开函数零直接覆盖:
slicing/qualityframe_deltasdead_frame_maskactive_spanblur_ratiopostprocess/rootmotionextract_root_motionframe_durationsprompt/actionsbuild_idle_promptbuild_attack_promptmaster_prepadd_headroomprepare_master其中
frame_durations参与每次出参构造、prepare_master参与每次 jump 生成,属实际执行路径上的未覆盖点。
以上 E1–E6 与测试空白均不在本批 PR 修改范围,建议单开 Issue「可生成性判定与质量门禁」
承载。当前契约是「给我母版,我给你帧」,缺的是「判断这个母版能否做这个动作」与「告知这次
成色如何」两层。
补充:视频接口面已变更(2026-08-07 实测)
本节推翻上文对 PR 2 的部分描述,与之冲突时以本节为准。
现状
SufyVideoProvider建在过时的接口形状上:打/v1/videos,首帧走input_reference的base64 dataURI。而平台当前有 69 个 POST 视频端点,其中 22 个是图生视频,全部是
FAL 队列格式
/queue/...,全部要image_url公网 URL。发现经过:用一张全新角色母版跑
kling-v3-omni端到端,费用已产生。任务 completed、16 帧齐、逐帧时长齐,选帧指标反而是历史最好的一组(接缝 0.66、零重复帧、零死帧),
但画面里是一个与母版毫无关系的写实路人,且只有下半身——提示词里 "the legs clearly
visible" 被当成了取景指令。参考图从未被采纳。
kling-v3-omni的图生视频真实地址是/queue/fal-ai/kling-video/o3/{mode}/image-to-video。在
/v1/videos上给它塞input_reference或image_list,四种字段形状全部试过,都不可能成功——地址就是错的。
已按现行接口重写
新增
FalQueueVideoProvider与显式的模型→端点映射表(10 个模型),保留SufyVideoProvider不动(/v1/videos对 sora 系可能仍有效,无证据说它坏了)。规范里挖出四处不能靠直觉推断的地方:
/requests。六个 kling 模型共用/queue/fal-ai/kling-video/requests/{id},模型段与{mode}全部消失;vidu 也丢掉q3/proAuthorization: Key {key},不是BearerAI_BASE_URL结尾是/v1,而/queue是它的兄弟。不剥掉/v1,每个调用都是 404pro,v3-turbo 是standard/pro(没有std)。现默认mode="std"在两个模型上会 400,故构造时即拒绝另:请求体没有
model字段(模型即路径),duration有三种写法("5"/"8s"/5)。契约冲突与解法
VideoProvider.i2v(first_frame: bytes, ...)收 bytes,FAL 要公网 URL。未改 Protocol。 新增
FirstFrameUploaderport,作为FalQueueVideoProvider的必填无默认构造参数——构造不出一个没有首帧发布途径的 FAL provider。
不选另外三条的理由:
SufyVideoProvider,且让对象存储成为整条管线的关切,与 ai_engine ports 的"不碰存储"边界矛盾
framework/providers里塞一个对象存储客户端,无凭证无法测试;windup_framework/storage/目前是空的,那条线该由存储的负责人来落i2v加可选 URL 参数 → 一个实现必需、另一个忽略的参数,污染共享契约影响面:当前零调用点。
git grep.i2v(/VideoProvider只命中 provider 自身与文档字符串。新增义务只落在组装层:构造 FAL provider 时必须提供 uploader。
测试 37 个用例全 mock、零网络;变异测试 11/11 全部被捕获。
未验证
/v1剥离、请求体形状、轮询、结果回退、下载,全部只对着 OpenAPI 规范与 mock 验证过。首次真跑会花钱,且可能暴露不一致
另 19 份只写 URL。而实测四种 base64 形状均未被采纳。实现统一要求
http(s),对 22 个都安全补充:抠图(2026-08-07 实测)
两个问题
一、u2netp 对闭合区域天然失灵。 四足角色腿间的背景是一块被主体围住的空隙,显著性
模型把它当成主体内部,整块底色留在产物里;轮廓上还带一圈底色描边。
二、底色不可指定。 母版生成提示词写
SOLID MAGENTA #FF00FF,模型实际给#DE297C(偏离 140);改要求纯绿
#00FF00,给#9ACC5D(偏离 190)。两次都不听。 因此"规定一个底色然后按死值抠"这个思路不成立,只能采样实际底色。
修法必须窄
实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道完全相同、
欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去"反解",越解越坏
(先成橄榄绿、再成亮绿)。
最终解法:u2netp 定主体,只杀
d < 38的近似精确等于底色的像素。橙毛d≈117完全不受影响,闭合空隙
d≈0干净移除。三个角色底色残留2.54% / 0.44% / 1.25% → 0.17% / 0.21% / 0.26%,主体内部逐像素未动。
与"按颜色抠是死路"那条规则的边界:那条说的是拿颜色当主体判据(白底浅色角色会被
抠穿)。这里主体判据仍是 u2netp,颜色只用来做减法,绝不新增主体像素;底色不够均匀
时(四角标准差 > 8)直接跳过。
顺带去掉 onnxruntime 缺失时"猜四角主色"的静默兜底,改为抛错。
方法论教训
修这个问题的过程中造了三个残留指标,全部漏判了橙毛被改坏——因为橙毛的 alpha 是
0.62,不在"内部像素 α>250"的统计范围里。每次都是先看画面才发现指标在骗人。涉及画面
的改动,指标只能用来复核,不能用来验收。
补充:新增验证(2026-08-07)
Holdout(8 段从未参与调参的真视频):
pick_oneshot首次在真视频上验证——它在jump/attack 主路径上,此前只有合成数据的单元测试。4 段攻击视频无重复帧,选中段的运动
强度是全片平均的 1.7–2.3 倍。
pick_cycle4 段中 3 段干净,唯一告警是一段近乎静止的早期废片,算法正确地判"无周期"并退化成不硬闭环。
主路径补测:
frame_durations(每次出参构造都走)与prepare_master(每次 jump/attack生成都走)此前零直接覆盖,补 21 个用例,变异测试 5/5 全部被捕获。
代码去重:
_gray()+_SMALL=48在slicing/loop.py与slicing/quality.py各有一份完整拷贝;
_png()/_img()在strategy/concrete.py与impl/character_generator.py各一份。两组都收成唯一定义。这类分叉不会报错,只在数据或画面上体现。
画布裁切修复的真实样本:新生成的四足角色主体 w/h = 1.92,超过 1.61 裁切悬崖。修复前
鼻尖与尾尖被切平、主体贴死画布左右边缘;修复后完整落在画布内。两个人形角色(w/h 0.48 /
0.70)修复前后逐像素相同,确认该约束不误伤既有资产。
关联
ROUTE_MATRIX的实测依据)