Skip to content

feat(ai_engine): 对外契约 + 动作分流 + 入口预检与出参成色(Refs #171 #53) - #181

Open
johnnyzhang-eng wants to merge 28 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-ports-and-strategy
Open

feat(ai_engine): 对外契约 + 动作分流 + 入口预检与出参成色(Refs #171 #53)#181
johnnyzhang-eng wants to merge 28 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/ai-engine-ports-and-strategy

Conversation

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

变更内容

windup_ai_engine 的对外契约与分流决策:

  • ports/CharacterGeneratorPort 是 server 唯一入口,由 import-linter 的分层契约强制(app.web / app.worker 不得直连 ai_engine
  • strategy/ROUTE_MATRIX 动作类型 → 生成路线;VideoFrameStrategy 实测通路
  • impl/CharacterGenerator — 母版预检 → 选路线 → 出帧 → 脚线对齐 → 量成色 → 出参
  • master_check + _subject — 入口可生成性预检

生成路线矩阵(架构契约,改它 = 改产线)

动作 路线 依据
walk / run VIDEO_I2V 逐帧独立生成锁不住「哪条腿在前」→ 踢踏舞;视频天生连贯、腿自然交替
jump / attack VIDEO_I2V 同上;但属一次性动作,抽帧不闭环,走 pick_oneshot
idle VIDEO_I2V 见下方与 #53 的差异
hit PER_FRAME 离散姿势,单帧可编辑价值高、无连续步态。未实现,调用即抛错

依据见 #35。另记录该矩阵形状的已知边界(写进 strategy/base.py 注释,免得后来者按错误前提扩展):它是「动作类型 → 路线」一对一映射,隐含前提是「路线由动作的物理性质唯一决定」。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。

四类静默失败修复

本仓吃过四次「看起来成功的错结果」,这批全部改成在边界上抛错。

1. 未实现的路线返回 [b""] * n_frames 调用方拿到的 GeneratedAction 帧数对、时长对、无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进 character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出已装配了哪些;strategy 吐出空帧时抛 ValueError

2. 抽帧会静默少给帧。 pick_cycle / pick_oneshot 在源帧不足时长度不足且不报错,而 frame_durations(..., len(frames))实际长度现算——时长表跟帧数自洽,server 看不出异常,用户拿到一段步子没走完的循环。加帧数对账,放在 generator 而不是某个 strategy 里,将来任何新路线都受同一约束。

3. 入口零校验。 实测(2026-08-07):喂一张「人物在画板前作画」的图请求 walk,全程无一处报错,产出 16 帧构图完整的错角色、钱花完。加 check_master 预检,判四类本地零成本可判的形态问题——不可解码 / 无主体(全透明或全同色)/ 主体过小 / 比例过扁。

MasterRejected 带机器可读的 code,与其他异常分工明确:它 = 调用方输入不行,同一张母版重试多少次都一样,server 该映射成 4xx、翻成「请换一张母版」、不要重试NotImplementedError / 其他 ValueError = 引擎侧装配或产出出了问题,属 5xx、要人介入,让用户换母版是把锅甩错地方。

阈值 REJECT_ASPECT 由交付画布几何推出2*FILL_W/FILL_H)而非拍定,并有测试锁住这个推导关系——改了 pack.py 的填充比而预检不动,就会放行一批下游装不下的母版。

判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清「本层不判什么、为什么」。

4. 出参无法表达成色。 一段每帧都一样的 walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。加 ActionQuality,三个字段各自不可由其他两个推导:

  • motion_scale 相邻帧差的绝对尺度。必须单独给:dead_frame_mask 两条判据都是相对的,整段冻结时 d 全为 0、两条不等式变成 0<0一帧死帧都报不出(实测 12 帧全同报 0 死帧)——相对判据天生看不见「整体没动」
  • dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要的是「哪几帧」)
  • loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在对齐之后量,量的是用户真正看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成「完美闭环」。一次性动作不给:首尾姿态本就不同,发一个必然难看的数会诱导错误决定

刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数,上层拿它做不了任何决定。

引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。

#53 原设计的两处差异

# #53 原设计 现状 依据
idle → PROC_IDLE(¥0 程序化局部呼吸 Idle-B) idle → VIDEO_I2VPROC_IDLE 枚举与 ProcIdleStrategy 一并移除 程序化呼吸做不出可用效果,放弃、认这份 i2v 的钱。不留没有实现的枚举值
抠图 = rembg onnxruntime 直跑 u2netp rembg 依赖链在 3.12 无轮子;同模型同质量

一处顺手修掉的用户可见缺陷

Python 3.11+ 改了 str-mixin 枚举的 __format__f"{action.action}" 现在给出 ActionType.WALK 而不是 walk(3.12.13 实测),而这串字经 server 变成用户看到的 SSE 进度文案。所有进度文案改取 .value

边界(与作者对齐)

ai_engine 只产出帧 bytes + 逐帧时长 + 成色,不碰存储 / 数据库 / 任务状态。依据是「谁掌握租户与配额上下文」——上传对象存储要知道 bucket、路径规则、归属项目、配额,这些全在 server;ai_engine 若自持存储,等于把租户概念下沉到一个只做图像计算的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受;帧数或分辨率显著上升时需重估)。

关联与依赖

Refs #171 · Refs #53 · Refs #35 · 是 #152 的前置

stack 声明:本分支 stack 在 #172 / #179 / #180 之上,那三个合并后 rebase,届时 diff 只剩 ports / strategy / impl / master_check 这一层。

本地验证

uv run ruff check .   All checks passed!
uv run lint-imports   Contracts: 2 kept, 0 broken.
uv run pytest -q      全绿

变异测试 6/6 被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、抠图贴边采样。

待对齐

CharacterCard 的字段在 ai_engine零读取(视频路线的角色身份由母版图像承载,提示词只取 action.facing,所以不同角色跑出来的提示词是同一份)。它是为逐帧路线(用 desc 组提示词)与渲染路线(model_3d_ref,见 #122)预留的入参,已在 ports docstring 写明。是否现在就精简,听评审意见。

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
windup Ignored Ignored Preview Aug 11, 2026 10:08am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Found four high-confidence regressions in the new public contract and runtime path. Static validation passed with python3 -m compileall and git diff --check; the test suite could not run because this runner has neither uv nor pytest installed.

View job run

Findings without inline locations

  • backend/uv.lock:1892: [P1] Keep the lockfile synchronized with the unchanged manifests. This regenerated lock drops pydantic[email], passlib[bcrypt], redis, resend, and pytest-cov, even though the workspace pyproject.toml files still require them. A frozen sync from this lock therefore produces an incomplete environment: EmailStr validation and the framework auth/email/Redis imports can fail, and pytest is configured with --cov but no coverage plugin. Please regenerate uv.lock from the current manifests without removing the existing dependencies.

Comment thread backend/packages/common/src/windup_common/models/character.py Outdated
Comment thread backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py Outdated
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已 force-push:把这条依赖链重排成真正的线性 stack。评审锚点会移位,说明原因。

问题:GitHub 的 MERGEABLE 只计算"对当前 main",不计算"前面几个先合进去之后"。本地实测按依赖顺序合并:main → #172#179#180 都干净,#181 时 3~4 个文件冲突matte.py / test_matte_provider.py / test_character_contract.py / quality.py)。

机制#181 / #182 此前是各自独立基于 main、靠"同步提交"携带前置分片内容的副本。合并 #181 时的共同祖先里没有 matte.py(它属于 #179),于是两边各自"新增"同一个文件 = add/add 冲突 —— 即使一边是严格超集,git 也无法自动合并。所以逐文件对齐内容没用,必须让祖先里真有那些文件。

处理:改成 #172#179#180#181#182 的线性 stack,每个分支真正包含前置分支的提交。副作用是那些"同步上游/下游"的提交全部变成冗余,已在重排中丢弃。

内容变化(重排本身不改逻辑,两处例外,均已核对):

验收(重排后逐项跑过):

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

这五个 PR 已达 ready:无待追加改动、CI 通过、AI review 意见全部 resolved。可以开始 review。

依赖顺序(已重排为线性 stack,逐级包含前一片的提交):

#172 共享契约  →  #179 Provider 与抠图  →  #180 出帧工具箱  →  #181 引擎契约与串联  →  #182 任务编排

#180 零依赖于前两片的业务逻辑(纯 PIL / numpy + 真实视频实测),想先看小的可以从它入手。

本地已验的三项(每次推送后重跑):

  • 按依赖顺序合并 main → #172 → #179 → #180 → #181 → #182 五步全干净
  • 逐分支 CI 原样命令全过,测试数 111 → 185 → 266 → 307 → 337 单调递增
  • 全部合入后应用可启动,app.openapi() 口径 29 条路由,与 main 一字不差(零新增零删除)

端到端实证:2026-08-11 用这条链路(不是旁路脚本)从零跑通两个全新角色的走路序列帧——文生图出母版 → i2v → 抽帧 → 选帧 → 抠图 → 像素化 → 对齐 → 打包。


三条已知缺陷,代码在本批 PR 内,已独立立项跟踪,不在本批修复:

三条都不影响流程成功与 CI,属品相问题。选择独立跟踪而不是塞进本批,是为了不让改动范围与 Issue 脱节;其中 #197 的可行方向尚未实现也未验证,如实说明。

@johnnyzhang-eng
johnnyzhang-eng requested a review from nighca August 11, 2026 05:48
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-ports-and-strategy branch from 5e07641 to 5026970 Compare August 11, 2026 07:59
johnnyzhang-eng and others added 10 commits August 11, 2026 16:58
providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。
2026-08-07 用一张全新角色母版跑 kling-v3-omni 端到端时实测发现,费用已产生。

现象:提交成功、status=completed、16 帧齐、逐帧时长齐、下游抽帧/选帧/抠图/脚线对齐
全部正常工作,最终产出一组构图完整的序列帧。但画面里是一个**与母版毫无关系的写实路人**
——母版是插画风、赭黄长外套、背铜管乐器的乐手,产出是深绿外套的写实人物,且只有下半身
(提示词里 "the legs clearly visible" 被当成了取景指令)。

根因:首帧字段按**模型**选,不是按"本地图/公网 URL"选。厂商文档写明 Kling 用
image_list、Sora 用 input_reference,而本仓只把 kling-video-o1 列进了 image_list 名单。
kling-v3-omni 收到 input_reference 后既不报错也不采纳,退化成纯文生视频。

危险在于失败形态:老模型(v2 系列)塞错字段会 failed,还能发现;kling-v3-omni 是
**成功返回一个错误结果**,整条管线无一处能察觉。这与本批 PR 已修的"未实现路线返回空帧"
属同一类问题,只是发生在更外层——空帧至少还能靠"帧是空的"判出来,这个连帧都是好的。

两处修复:

1) _needs_image_list 显式归类 + kling-v3 前缀兜底。仅对已确认的型号切换字段:
   v2-5-turbo / v2-1 已实测可吃 input_reference(2026-07-27 端到端到 completed),
   不动既有通路,避免为修一个模型而破坏三个。

2) _assert_reference_registered 在**下载视频之前**拦截。网关在
   billing_type_description 里明写计费口径,送了首帧却拿到"无参考视频"即为铁证。
   提交后与轮询到 completed 时各查一次。字段缺失时不拦——不同网关字段不一定存在,
   宁可漏判也不误伤。

四条回归测试,变异测试确认有效:把 v3-omni 退回 input_reference(复现原 bug)、
去掉计费口径检查,各有 1 条用例失败;还原后 8 passed。
2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个
图生视频**全部**在 FAL 队列面 /queue/... 下,首帧一律是 URL 形态字段(image_url /
start_image_url),同日实测送 base64 dataURI 无一能用。原 SufyVideoProvider 建在
OpenAI 风格 /v1/videos + input_reference dataURI 上,是过时的接口形状——在它上面打的
两处补丁方向错了,一并回退:

- _needs_image_list / _IMAGE_LIST_MODELS 里新增的 kling-v3-omni / kling-v3
- _assert_reference_registered / ReferenceIgnoredError 及其 3 条测试

新增 FalQueueVideoProvider 与旧实现并存(没有实测证据说 /v1/videos 已坏,sora 系可能
仍只在那一面)。要点:

1) 模型 → 端点的显式硬表 FAL_I2V_ENDPOINTS,不拼路径。每家有三样东西不同且都猜不出
   来:提交路径的型号段;首帧字段名(同是 kling,o3 / v2.5-turbo 叫 image_url,
   v3 / v2.6 / o1 叫 start_image_url);轮询前缀(**不是**提交路径 + /requests,
   kling 六个型号共用 /queue/fal-ai/kling-video/requests/{id})。未登记的模型抛
   UnknownVideoModelError,不做前缀匹配、不做兜底——猜出一条"存在但语义不同"的路径
   (如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费。

2) i2v 契约冲突:Protocol 收 bytes,FAL 面只吃公网 URL。选择"provider 自己适配",
   Protocol 签名不动——新增 FirstFrameUploader port,provider 构造时必传,内部把补边
   后的首帧换成 URL。调用方零改动;母版已在公网时用 PreUploadedFirstFrame 复用该
   URL、不重传。

3) 失败一律显式抛错,不静默降级:spec 明写「任务失败时后端也返回 COMPLETED,通过
   detail 区分」,故 COMPLETED 还要查 detail;认不出的 status 当失败(继续轮询会把
   "协议变了"伪装成"生成太慢");超时抛 VideoJobTimeoutError;参数校验在上传首帧之前
   完成;下载复用既有 _download(重试 + 长度校验,治"视频已生成、费用已产生,下载断
   一次整单作废")。

FAL 面鉴权是 Authorization: Key(不是 Bearer),base_url 需从 /v1 退回网关根
(/queue 与 /v1 平级)。两处都有 spec 依据,已写进注释与测试。

37 条新测试全程 mock 不联网;11 个变异(错端点 / 错字段名 / 错轮询前缀 / 去掉各处抛错
/ 去掉下载重试 / 参数校验挪到上传后)逐个确认能被测到,全部 KILLED。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
两处,都是 2026-08-07 用三个全新角色母版实测出来的。

1) u2netp 对闭合区域天然失灵
   四足角色腿间的背景是一块被主体围住的空隙,显著性模型把它当成主体内部,整块底色
   留在产物里;轮廓上还带一圈底色描边。母版底色是刻意生成的纯色、均匀度极高(实测
   四角标准差 1.0–1.2),拿它做一次窄阈值清理正好补上这个洞。

   阈值必须窄。实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道
   完全相同、欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去"反解",
   越解越坏(先成橄榄绿、再成亮绿)。取 38 时橙毛 d≈117 完全不受影响,而闭合空隙里的
   背景 d≈0 干净移除。三个角色残留 2.54%/0.44%/1.25% → 0.17%/0.21%/0.26%。

   与"按颜色抠是死路"那条规则的边界:那条说的是拿颜色当**主体判据**(白底浅色角色会
   被抠穿)。这里主体判据仍是 u2netp,颜色只用来**做减法**,绝不新增主体像素;底色不够
   均匀时(四角 std > 8)直接跳过,等于不清理。

2) 去掉 onnxruntime 缺失时的静默兜底
   旧行为是回落到"取四角主色做 chroma-key"。两个问题:猜背景色——白底母版四角就是白色,
   浅色角色与背景撞色会被抠穿;静默——开发机上看着能跑、输出其实是坏的,要到产物验收
   才发现。改为抛 RuntimeError。

五条回归测试,变异测试确认有效:阈值放宽到 120(误伤橙毛)、去掉均匀性守卫、清理系数
允许 >1(凭空造主体)、恢复静默兜底,各有用例失败;还原后 7 passed。
rebase 到 main 时解冲突取了主线的 uv.lock,但 framework/pyproject.toml 取了本分支的,
后者少了主线用户模块加的 passlib[bcrypt] / redis / resend —— CI 装依赖时按 pyproject
解析,于是 conftest.py 导入 bcrypt 失败(ModuleNotFoundError,本地因 venv 里已装而没暴露)。

主线的 pyproject 已含本分支需要的全部依赖(onnxruntime<1.24 / qiniu / pillow / numpy,
连注释都是从这条线过去的),故直接取主线版本,两边并集自然成立。uv lock --check 通过。
机器审 PR 1024XEngineer#179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是
网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx
只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上
client 级 headers —— API key 因此发给了那个域名。

改法:
- 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接,
  那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。
- Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。
- 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口),
  未 import 其私有函数,免得被上游改名。
- 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。
- 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、
  费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取"
  的注释同步更正 —— 它正是这个泄漏的出处。

变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把
:443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /generation/image 是可达端点,ImageTaskExecutor 默认实例化 SufyImageProvider,
而该类的 gen_image 直接抛 NotImplementedError —— 每个图像任务都稳定走到 FAILED。
端点看着可用、实际必失败,是本仓最忌讳的形态(机器审逮到)。

实现要点:
- 走 OpenAI 兼容的 /chat/completions 面,参考图以 data URI 塞进 content 数组。
  与 i2v 的提交-轮询-下载三段式是完全不同的调用形状,不复用 VideoProvider 通路。
- 对整个响应 JSON 正则取 data URI,不猜 message.content 的层级:不同网关包裹层级
  不一致,猜错的代价是"调用成功、费用已产生、但我们报没图"。
- 空图重试 3 次。模型偶发返回一条不含图的正常响应;这与 _download 的网络重试是两
  码事,后者治连接断。
- 校验 base64 解出的字节数下限 5000。响应里可能带几十字节的占位串,当图存下去就是
  一个打不开的文件。
- 取不到有效图抛 RuntimeError,不返回空 bytes:上游会把返回值直接上传对象存储并写
  进任务结果,0 字节的"成功"就是用户看到的裂图。

通路取自已跑通的实现(同日用它出过三张角色母版),非新写。

测试 5 条,全 mock 无付费调用,逐条做过变异测试:
把重试改成 1 次 / 去掉字节下限校验 / 丢掉参考图 / 拿不到图返回空 bytes / 成功后不
早退,五个变异各让 1~3 条用例变红。
对抗复查自己今天这笔实现时发现的两处:

一、路径此前硬编码 "/chat/completions",而 AIProviderSettings.chat_completions_path
   本来就在配置里、零消费方 —— 正是本轮在删的那类字段。改成读配置。

二、更要紧:同一把 key 下不同网关的模型目录**不一样**。实测 GET /v1/models:
   一个网关 73 个模型、一个图像模型都没有;另一个 134 个、含本模块的默认模型
   (2026-08-10 实测)。配错 AI_BASE_URL 时原始报错只是一条裸 404,读的人无从判断
   该改配置还是改模型名。现在 400/404 一律翻译成指向 GET {base}/models 的错误。

这条修的是"错误信息不可操作",不是"配置错误本身"——后者要在部署侧确认网关目录里
确实有所用模型,代码管不了。

测试 +3(路径来自配置、400/404 给出目录提示)。变异测试:路径写死 1 条红、去掉错误
翻译 2 条红。
放大看交付帧,主体内部有透明洞,背景直接透出来。2026-08-11 在归档角色
「林间斥候」走路的 121 帧真实视频帧(1280×720)上把成因拆开量了一遍:

- u2netp 自己在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因;
- 真正的成因是键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为
  主体的像素。角色浅肤色 (243,221,200) 到母版灰底 (219,219,220) 的欧氏距离
  只有 31.3,窄于 _KEY_KILL=38,于是大腿、小臂这些浅色皮肤被当底色抠掉。
  这些被误杀的像素被主体围住,就是「封闭空洞」,填回去即修复。

**只按「不与画面边界连通」判定会把两腿之间填实。** 直觉上腿间空隙从下方通到
画幅底边所以天然安全,实测不成立:迈步相里两只靴子在下方交叠,把空隙彻底封死。
121 帧里 80 帧存在这种封闭的底色空隙,共 25173 像素;只判连通性的朴素版把这
25173 像素**全部**填成主体(最惨单帧 src_024 填掉 3172 像素,两条腿焊在一起,
截图见验证记录)。归档的 04_走路_原画帧/frame_03 同样有 129 像素的封闭腿间空隙。

所以判据是连通性 + 颜色两条一起:一个透明连通域只要「碰到画幅边界」或者
「里面存在任何一个确实是底色的像素」,就不是洞。两条否决合成一次扩散,种子 =
边界上的透明像素 ∪ 底色像素。实测结果:

- 25173 个真空隙像素,守卫版填掉 0 个,朴素版填掉 25173 个;
- 121 帧合计填回 51273 个被误杀的主体像素(朴素版 82118,多出来的就是空隙);
- alpha 只增不减,改动值只能是 1.0,RGB 通道不碰 —— 没有洞的帧逐像素不变。

_HOLE_BG_TOL=14 的取值有实测依据:视频帧里纯背景区域的色距 p99.9≈6.5、
最大 11.1(压缩噪点),而被误杀的浅肤色连通域中位色距 ≥17.1,14 落在这条间隙里。

扩散不用逐像素 BFS:1280×720 约 92 万像素,纯 Python BFS 要几十秒,抠图是逐帧
调用的扛不住。改成按行/列游程传播,一个 pass 推过整条游程。实测填洞单独耗时
34ms,cutout 端到端 0.44s/帧。scipy 不在依赖里,没有为此新增依赖。

顺手把四角估底色抽成 _bg_key(),让「底色是什么」只有一个真相源 —— 键控清理和
填洞必须按同一个 key 判,否则一个把某块当背景清掉、另一个又把它当主体填回来。

变异测试(9 个变异逐个改坏实现 → 确认对应用例变红 → 还原,全部被杀):
  M1 去掉颜色守卫(种子只剩边界,即朴素设计)→ closed_leg_gap 红
  M2 去掉边界种子                              → border_touching 红
  M3/M4 _spread 只做行传播 / 只做列传播        → spread_is_four_connected 红
  M5 去掉「底不是纯色就停手」的早退            → non_flat_background 红
  M6 填成 0.5 而不是 1.0                       → enclosed_hole_is_filled 红
  M7 _HOLE_BG_TOL 放大到 200                   → enclosed_hole_is_filled 红
  M8 _HOLE_BG_TOL 归零                         → closed_leg_gap 红
  M9 丢掉「封闭」条件                          → closed_leg_gap 等 4 条红
另外 _spread 与逐像素 BFS 在 300 组随机掩码 + 螺旋形上逐点等价(用例里留了 25 组)。

CI: ruff / lint-imports(2 contracts kept) / pytest 185 passed 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
人工评审指出 providers 层硬编码过多。拆开看是三类,处理方式不同:

**改进配置**(本次做的):三条能力各自的模型型号。
`AIProviderSettings` 加 `video_model` / `image_model` / `fal_video_model`,默认值即当前
实测在用的型号,部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖。
分成三个字段而不是共用已有的 `model`:三条能力同时在用不同模型,共用一个意味着换其中
一条把另外两条也换了。显式传参仍优先于配置,方便 A/B 对比时不必改环境变量。

**留在代码里**(本次不做,理由写进配置类的注释):哪个模型吃 image_list、哪个吃
input_reference、FAL 队列路径长什么样。这些不是运行参数,是该模型的 API 形状事实,改变
的是请求怎么构造。放进配置会把"填错了会怎样"从部署期推到运行期 —— 字段塞错不会立刻
报错,任务照常 queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。

**暂不处理**:重试次数与字节下限。可配置化,但现在提出去只增加配置面,等真要调再说。

顺带修一个这批测试逮到的真 bug:`FalQueueVideoProvider` 的构造期校验发生在型号解析
**之前**,于是 `model=None`(表示"用配置里的")会被直接拿去查端点表,报"模型 None 不在
表里"—— 走默认路径就构造失败。改成先解析型号再校验。

测试 +5,5 条变异全部杀掉(共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 /
配置里补上请求形状字段 / 校验挪回解析之前)。
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-ports-and-strategy branch from 5026970 to 00e0ae7 Compare August 11, 2026 09:18
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

冲突已解,重排到最新 main。 #172 已合入,依赖链缩短为 #179#180#181#182

冲突根因:#172 合入后本分支还带着它 squash 前的三个提交,与 main 上的 squash 版 add/add 冲突;同时 main 合了 #110 / #194 等 10 个提交,已全部重排吸收。

两种口径都验过:逐个直接对 main 合干净(GitHub 的判据)、按依赖顺序连合也干净。逐分支 CI 全过(212 → 293 → 334 → 374,单调递增)。

评审质疑「为什么还需要一层不该理解业务的东西」(指 FirstFrameUploader)。查证后我认同,
但比他说的更彻底:**整个 FAL 队列面从未被真实调用过** —— app / ai_engine 里零引用,
产品链路走不到它,唯一的"引用"是 interfaces.py 里一句 docstring 指路。

删掉的理由与 GenRoute 只列有实现的路线是同一条,也是我在这批 PR 里反复引用的判据:
没有消费方的代码等于死代码,它让调用方以为该能力已具备。我一边用这条原则删掉
ActionSpec.fps / loop、一边留着 412 行未验证的 provider,是自相矛盾的。

删除:FalQueueVideoProvider / FirstFrameUploader / PreUploadedFirstFrame /
FAL_I2V_ENDPOINTS 与端点映射 / 三个 FAL 专用异常 / config.fal_video_model /
28 条 FAL 测试。sufy.py 从 740 行降到 343 行。

保留一段注释记下两个实测挣来的事实,避免将来重新摸索:FAL 面只吃公网 URL 不吃 base64
(塞 base64 会 queued 之后在生成阶段才 failed,费用可能已产生);鉴权头是
`Authorization: Key`,路径与 /v1 平级。

顺带把 VideoProvider 的 docstring 改成正面依据:**入参恒为 bytes**,因为 ai_engine 必须
持有 bytes —— master_check 预检、master_prep 预处理、像素化锁色板全都读母版像素;改传
URL 的话 ai_engine 还得自己下载回来。某厂商只吃 URL 属该 provider 自己的适配问题,
在 provider 内部转换,不把差异漏给上层。

代价如实说明:veo / seedance 只在 FAL 面,而实测 veo 的走路步态比 kling 更自然。真要接
时连同一次真实调用一起加回,归档里有完整的接入记录,重写成本不高。
johnnyzhang-eng and others added 17 commits August 11, 2026 17:59
CI 的 codecov/patch 报红,查证后是真缺口:`SufyVideoProvider.i2v` —— **产品唯一的付费
路径** —— 一条测试都没有。sufy.py 覆盖率 72%,未覆盖的正是提交/轮询/下载三段式与首帧
处理。matte.py 的 `cutout` 装配顺序同样零覆盖。

补 sufy 7 条(sufy.py 72% → 99%):
- 完整付费路径:提交拿 job id → 轮询到 completed → 下载 mp4
- 首帧必须是 JPEG data URI。PNG base64 会让任务 status=failed(VENDOR_FAILED,
  2026-07-22 实测,33s fail-fast)—— 这条错在提交之后才报,本地看不出来
- 首帧按目标画布**补边不拉伸**:拉伸会改角色比例,而母版比例是角色一致性的一部分
- failed / cancelled 立刻抛,不把剩余轮询预算耗完(钱已经花了,尽快暴露原因更有用)
- 轮询预算用尽抛错而不返回空 bytes(空 bytes 会被当视频送进抽帧,报"无可解码帧",
  真正的原因被埋掉)
- 首帧字段按模型选(塞错字段任务照常 queued,直到生成阶段才 failed,费用可能已产生)

补 matte 3 条(matte.py 71% → 93%):cutout 输出 RGBA、**RGB 通道不被改动**(改了会让
后续像素化锁色板取到被改过的颜色)、清理与填洞的**调用顺序**(反过来会把刚填上的像素
又清掉,且不报错)。真实推理需要 4.7MB onnx 权重,CI 里下不到也不该下,故用假 session
只覆盖装配逻辑。

顺带修一个测试逮到的真 bug:`poll_interval=0` 会在 `max_min * 60 // poll` 处除零,报
ZeroDivisionError,读的人完全看不出是配错了参数。改为构造期拒绝非正数。

9 条变异全部杀掉。其中"补边不拉伸"第一版是摆设 —— 纯色图拉伸后对称两点颜色照样相同,
M3 存活;改成在源图里放一个偏心方块、量它在成品里的宽高比(补边≈1.0,拉伸≈2.67)
才真能杀掉。

另记一个操作教训:变异测试期间用 `git checkout -- <file>` 还原,会把同文件里**尚未提交**
的改动一起丢掉(守卫被静默还原,表现为"还原后测试仍红")。变异 harness 一律用脚本内的
文本备份还原,并在结束时校验 sha256。
视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。

slicing/  视频 → 帧序列
  extract   解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间;
  quality   帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring)

postprocess/  帧 → 交付级序列帧
  pixelate  母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化
  pack      脚线对齐 / sprite sheet / GIF
  rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘)

prompt/ + master_prep.py  按动作类型选提示词、按动作预处理母版

两处实测挣得的修复一并带上:

1) 画布横向裁切(postprocess/pack.py)
   align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。
   横向长条主体按同一系数缩放后宽度超出 cell,被 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 时该约束恒不生效,
   产物逐像素不变。

2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测
   a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。
      实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22;
      加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。
   b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。
   c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。
      改为在基周期整数倍里按归一化接缝复选,优先最小倍数。
   测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环——
   实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。

实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合)
  骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81
  待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。

消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、
两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。
`frame_durations` 参与每一次出参构造,`prepare_master` 参与每一次 jump / attack 生成,
此前两者均无直接覆盖。21 个用例,锁行为不锁具体数值。

frame_durations
- 动作间必须有区分度(idle > walk > run)——等时长会让动作发飘、没有重量感
- 关键帧定格必须真的比邻帧长,且只定格一帧
- 未知动作要有可用兜底,不能返回 0 或抛错(上游动作类型可能先于本模块扩展)
- 越界 key_frame 不炸(帧数由选帧决定,调用方未必对齐)
- hold_ms 小于基准时长时取基准,定格不能反而变快

prepare_master
- jump / attack 必须补顶部空间,否则腾空 / 过顶挥砍会顶出视频画面上沿被裁
  (实测 attack 15/72 帧触顶)
- 其余动作必须**原样**返回同一对象,无谓重编码会引入压缩损失
- 补的边在顶部、原图贴底(贴反了动作会往下出画)
- ratio 越小顶部留白越多;jump 需要的空间多于 attack
- 非法 ratio 抛 ValueError

已做变异测试,五处故意引入的错误全部被捕获:
  抹掉动作间时长区分度        → 2 failed
  关键帧不定格                → 1 failed
  jump/attack 不补顶部空间     → 3 failed
  补边加在底部而非顶部         → 1 failed
  jump 与 attack 用同一 ratio  → 1 failed
还原后 21 passed。不是写完就绿。
_gray() 与 _SMALL=48 此前在 slicing/loop.py 与 slicing/quality.py 各有一份完整拷贝。
两处必须在同一尺度上看帧,否则算出的差异量不可比;而分叉不会报错、只在数据上体现
——调一边的降采样尺寸,另一边悄悄保持 48,两个模块的指标从此不再可比。

收成 slicing/_frames.py 唯一定义(SMALL / gray)。行为不变。
ai_engine 的 pyproject 声明了 imageio / av(抽帧必需),而 rebase 解冲突时 uv.lock
取的是主线版本,两者不一致:uv lock --check 报 lockfile needs to be updated。
本地 venv 里恰好装过这两个包,故本地测试没暴露;CI 用 --frozen 装依赖时会缺。

重锁时带 UV_DEFAULT_INDEX=阿里云镜像 —— 直接 uv lock 会把全仓 90 处包源改写成
pypi.org,产出两千多行与本次改动无关的 diff(主线 Dockerfile 定的就是这个镜像源)。
重锁后:阿里源 90 处、pypi 0 处,只新增 627 行(两个新包及其依赖树)。
机器审在 PR 1024XEngineer#180 报的两条 P1,加相邻边界扫描的发现。共同判据:返回长度恒等于 n,
凡是给不出 n 帧的入参一律报错,绝不静默交出一个长度自洽的短序列。

pick_oneshot:
- n=1 撞 /(n-1) 除零(P1)。改为取"关键姿势"单帧:airborne 取脚线最高(顶点),
  swing 取能量峰后一帧(命中瞬间);不取区间首帧(蓄力,和待机一个样)也不取中点
  (动作区间前后不对称,中点落在蓄力段)。
- n<=0 原本静默返回 [](range(n) 为空,连除零都不报)→ 显式拒绝。
- 源帧不足原本原样返回一个短序列 → 报错并报出两个数字。
- 动作区间被裁到不足 n 帧时原本直接返回该区间(14 帧输入请求 12 帧只回 9 帧),
  改为把窗口放宽回来;动作贴视频尾部时缺口退回左边补,保证 n 帧互不重复。
- kind 拼错不再静默按 swing 处理(判据用错会裁出"看起来对"的错区间)。

pick_cycle:
- n<=0 拒绝(P1)。实际机制与机器审所述不同:_offsets(P, 0) 并不除零(range(n) 为空,
  k*P/n 没被求值);真实路径是检出周期时走到 M[idx[-1], idx[0]] 抛 IndexError,而
  测不到周期时**静默返回 []** —— 后者更危险。
- 源帧不足改为报错(原本原样返回)。
- n=1 显式取 medoid:单帧"循环"没有接缝也没有相位,原实现靠 nan 比较的意外结果返回首帧。

测试:两个文件各补边界用例,含 n=1..len(frames) 全量扫长度与不重复性。
变异验证 22 个错法,21 个被杀;唯一存活(区间放宽只往右补)经 1,565,565 组
(total, n, start, end) 穷举确认为等价变异 —— 长度契约完全一致,只有窗口位置不同。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
机器审 P2:iio.imread 会把 (T, H, W, C) 整个 materialize 出来,而我们只要其中 8~16 帧。

实测(14 段真实 i2v 视频,进程 RSS 峰值,非 tracemalloc):
- 121 帧 720p,抽 16 帧:488 MiB → 126 MiB,降 74%
- 同一段抽 8 帧:460 MiB → 98 MiB,降 79%
- 抽 150 帧(周期检测用的 extract_all_frames_bytes):858 MiB → 498 MiB,降 42%

最后一条如实说明降幅边界:它保留全部 121 帧,省掉的只是那个完整 ndarray,保留帧本身
该占的内存还在。并发 worker 叠加时这仍是主要占用项。

正确性:改造前后在 14 段真实视频 × n=8/16/150 共 42 组上抽出的帧**逐像素完全相同**。
n=1 取首帧的既有约定保持不变(关键姿势的选择归 pick_oneshot,不由抽帧层猜)。

帧数来源:先读容器元数据(在 14 段真实视频上与实际帧数全部一致),拿不到正整数就退回
逐帧计数——计数不保留帧、内存不涨。按错的帧数算下标会抽出错位的帧,那是"帧数对、内容
错"的静默失败,多解一遍换一个确定的数划算。

顺带:imageio 分支的 except Exception 加了 warning 日志。它此前把"我们自己算错下标"和
"环境里没装 imageio"混为一谈,两者都表现为悄悄换用 ffmpeg 分支、产出看着正常的帧。

测试 11 条(tests/test_extract_streaming.py),含下标边界、抽到的是首尾与均匀分布那几帧、
要的比有的多时不补帧、元数据报 0 或抛错时退回计数。做过变异测试:改回 imread 让两条变红。
其中一条最初是摆设——炸弹抛 AssertionError 被 except Exception 吞掉、静默走 ffmpeg 后照
样绿,改成 BaseException 子类才真能杀掉变异;docstring 里写明了这个坑。
契约断言(DTO 自身)留在 feat/character-domain-models,不 import 上层包;本分片引入
prompt 模块,配套的实现侧断言就该落在这里:类型注解不是运行期约束,把校验写成
`SIDE if facing == Facing.SIDE else FRONT` 的二分时,"sidee" 会静默落到 FRONT 模板——
正面走的提示词配侧面母版,模型靠转身调和矛盾,调用方什么错都收不到。

4 条:四个 build_* 拒绝非法 facing、枚举与合法字符串等价、walk 按 facing 选对模板体、
其余 build_* 同样按 facing 切换。
交付帧一直是 256×256 方形,而项目的 sprite 尺寸是 sprite_width×sprite_height
(API 允许 32~2048,且宽高各自独立、可非方)。上层拿到 256 的帧再缩到项目尺寸,
问题不是"糊一点":那一步用 Image.thumbnail,而 **thumbnail 只缩不放**。
2026-08-11 实测复刻上层这段逻辑,喂一张主体高 157px、脚线 0.92 的 256 交付帧:

    目标 512×512 → 画布 512,主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 画布 384,主体仍 157px,                脚线 0.92 → 0.779
    目标 128×128 → 画布 128,主体 78px,                  脚线 0.914(缩小这侧正常)

也就是说放大方向上,align_bottom_center 刚对齐好的脚线被整体挪高,角色不站在地上,
跨动作对齐(ref_height 那套)也一起失效。根治办法是引擎一次就出到目标尺寸。

align_bottom_center 本来就接受 cell,按 cell 出 512 时主体高度实测 154 → 308,
确实翻倍;缺的只是**非方形**能力:cell 只能出方形,非方 sprite 仍得回到上层补边。
本次加 cell_h(None = 方形 cell×cell,默认行为不变),并把体内的几何拆成
cw(宽:水平居中、宽度兜底)与 ch(高:脚线、占高定标),不许串轴。

实测(同一组帧,ref_height=300):
    默认           canvas 256×256  主体高 159  脚线 0.918  水平中心 0.498
    cell=512       canvas 512×512  主体高 317  脚线 0.920  水平中心 0.500
    cell=384,h=512 canvas 384×512  主体高 317  脚线 0.920  水平中心 0.500
    cell=128,h=192 canvas 128×192  主体高 119  脚线 0.917  水平中心 0.496

**默认行为逐像素不变**:default / ref_height / preserve_lift / 宽主体兜底 /
cell=128 / cell=512 六个用例改动前后 sha256 完全一致;另有用例钉死
"不传 cell_h" 与 "cell_h=cell" 两种写法逐像素相同。

几何用比例表达(foot_line / fill_h / fill_w),换画布尺寸不改变构图,所以母版入口
预检与出帧仍共用同一套几何 —— master_check.REJECT_ASPECT = 2*FILL_W/FILL_H 里
本来就没有 cell,与画布像素尺寸无关。用例
test_subject_fill_ratio_is_scale_invariant 在 128/256/512/1024 四档上钉死这条。

顺带:cell/cell_h 非正数改为报错。PIL 允许建 0×0 的图、alpha_composite 也不报错,
静默出一张空图要到落库或前端才暴露。

变异测试(8 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  P1 cell_h 默认写死 256          → doubling_cell_doubles_subject_height 红
  P2 主体占高改按画布宽算          → non_square_applies_each_axis 红
  P3 脚线改按画布宽算              → non_square_applies_each_axis 红
  P4 水平居中改按画布高算          → non_square_applies_each_axis 红
  P5 宽度兜底改按画布高算          → width_fallback_uses_canvas_width 红
  P6 去掉画布尺寸校验              → non_positive_canvas_raises 红
  P7 全透明兜底退回 256 方形       → all_transparent_honour_canvas 红
  P8 出帧画布忽略请求值            → 另外 3 条红(默认档下该变异是恒等,测不到属正常)

本提交只动 ai_engine;把尺寸从 app 传进引擎的接线在上层分支。
CI: ruff / lint-imports(2 contracts kept) / pytest 255 passed 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
server 与生成引擎之间的唯一边界,以及"哪个动作走哪条生成路线"这个架构决策。

ports/  server 只 import 这里,由 CI 的 import-linter 分层门禁强制。
  CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction
  边界:ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由
  server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传
  对象存储、写 character_data。依据是"谁掌握租户与配额上下文"——bucket、路径规则、
  归属项目、配额全在 server;ai_engine 自持存储等于把租户概念下沉到一个只做图像计算
  的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受)。

strategy/  ROUTE_MATRIX 是实测挣得的架构契约,改它 = 改产线。
  walk / run / jump / attack / idle -> VIDEO_I2V;hit -> PER_FRAME
  依据:逐帧独立生成锁不住"哪条腿在前"(踢踏舞),视频天生连贯、腿自然交替;
  hit 这类离散姿势单帧可编辑价值高、无连续步态。Refs 1024XEngineer#35 1024XEngineer#53。

impl/CharacterGenerator  选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。

与 1024XEngineer#53 原设计的两处差异:

1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。
   1024XEngineer#53 原设计 idle 走 ¥0 的程序化局部呼吸(Idle-B),实测做不出可用效果,放弃,认这份
   i2v 的钱。不留没有实现的枚举值。

2) 未实现的路线抛错,不返回空帧。
   旧桩实现 return [b""] * n_frames,调用方拿到的 GeneratedAction 帧数对、时长对、
   无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进
   character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。
   现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出
   已装配了哪些;strategy 吐出空帧时抛 ValueError。四条回归测试拿旧实现对照过,
   确认在修复前全部失败。

另记录 ROUTE_MATRIX 形状的已知边界:它是「动作类型 → 路线」一对一映射,隐含前提是
"路线由动作的物理性质唯一决定"。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——
同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。
接入第三条路线前须先定「路线选择由谁决定」。

本分支 stack 在 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 之上,那三个合并后 rebase。
管线内部按 PIL.Image 处理,跨模块边界(strategy → generator → ports 出参)按 PNG bytes
传递。这对转换此前在 strategy/concrete.py 与 impl/character_generator.py 各写了一份完整
拷贝,收成 _imgio.py 唯一定义(to_png / from_png)。编码参数一旦分叉,会在"某些帧丢了
alpha"这类只在画面上体现、不报错的地方出问题。

同步 stack:本分支重新对齐到 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 的当前终态,同名文件与三者逐字节一致。
facing / loop / stylize / view 四个受限取值原先是裸 str,合法值只写在行尾注释里:
写成 "Side" / "sidee" 不报错、不告警,一路放行到 i2v 出片后才在画面上看出角色转了身,
一次误判的成本是一次付费生成。改成 Enum 后这类错误在构造 ActionSpec 时就是
ValidationError。prompt.build_* 是普通函数,注解不构成运行期约束,故各自显式过一遍
Facing() 构造(否则非法值会静默落到 FRONT 模板)。

n_frames 改成显式字段,不再由 len(poses) 推导 —— 视频路线根本不读 poses,推导等于
"想要 16 帧先编 16 条用不上的姿势描述",而读代码的人会以为那 16 条真的进了提示词。
只传 poses 的旧调用方行为不变;两者打架时抛错而不是猜哪个说了算。

CharacterCard.palette 删除:零消费方、无格式约定,而真正锁色的色板由
postprocess.master_pixel_spec 从母版像素量出来。留着它 = 调用方填了色板、管线照旧
用母版色板、不报错也不生效。CharacterCard 六个字段在 ai_engine 全零读取,不删,
但在 ports docstring 写清它是给未实现路线预留的入参。

顺带修掉三处同源问题:
- 帧数对账。pick_cycle / pick_oneshot 在源帧不足时静默少给,时长表按 len(frames)
  现算所以产物自洽,server 看不出异常,用户拿到一段步子没走完的循环。
- 进度文案里的枚举取 .value。Python 3.11+ 改了 str-mixin 枚举 __format__,
  f"{action.action}" 给的是 "ActionType.WALK"(3.12.13 实测),而这串字会变成
  用户看到的 SSE 进度。
- fps/pixel_h/palette_size 的下界抄实现里已有的真实取值域,把"实现悄悄纠正入参"
  (palette_size=1 被 max(2,…) 抬成 2)提前成入参报错。

测试全部做过变异验证:24 个变异逐个确认打红对应用例(其中一个变异抓出本次新写的
no-mutation 用例只覆盖了校验器三条出口中的一条,已补全参数化)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入;
出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。

此前 ports 与 impl 里所有 raise 都在输出侧,对 master 不做任何前置判定。
2026-08-07 实测:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错,
16 帧构图完整的错角色出完、钱花完。

check_master 判三类**本地零成本可判**的形态问题,不通过抛 MasterRejected:
- UNDECODABLE 不是图 / 截断
- NO_SUBJECT 全透明或全同色,没有可动的东西
- SUBJECT_TOO_SMALL 包围盒最短边 < 8px(放大 20 倍是色块不是角色),
  或主体占比 < 0.1%(对角散落两粒噪点会把包围盒撑到整幅,边长检查全过)
- ASPECT_TOO_WIDE 主体 w/h 超阈值,方形画布只能把角色硬缩成一条

REJECT_ASPECT 由交付画布几何推出(2*FILL_W/FILL_H)而非拍脑袋,并有测试锁住
这个推导关系——改了 pack.py 的填充比而这里不动,预检会放行一批下游装不下的母版。

MasterRejected 带机器可读的 code:server 据此选文案、判 4xx-不重试,与
NotImplementedError / 其他 ValueError(引擎侧问题,5xx,要人介入)分工明确。
判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清"本层不判什么"。

GeneratedAction 此前只能表达"生成完了",不能表达"生成得怎么样":一段每帧都一样的
walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。

三个字段各自不可由其他两个推导:
- motion_scale 相邻帧差的**绝对**尺度。必须单独给:dead_frame_mask 两条判据都是
  相对的,整段冻结时 d 全为 0、两条不等式变成 0<0,一帧死帧都报不出(实测 12 帧
  全同报 0 死帧)——相对判据天生看不见"整体没动"。
- dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要"哪几帧")
- loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在**对齐之后**量,量的是用户真正
  看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成"完美闭环"。
  一次性动作(jump/attack)不给:首尾姿态本就不同,给个必然难看的数会诱导错误决定。

刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数。

引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按
场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。

底色采样此前贴边取。视频帧最外一两行/列常是**编码器边缘伪影**而非底色:实测 9 段真
i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)被判"底不均匀"而跳过清理——底色清理在
真实路径上等于从不生效。逐一查证全部由最外圈造成(某视频最右一列整列纯黑 std 50.4,
待机视频最顶一行 std 8.4 恰好压线越过 8)。往里让 2px 后 144 帧零误跳,三张静态母版的
取样中位色一个字节未变。

17 条新用例。变异测试 6/6 全部被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、
motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、贴边采样。

其中"去掉最短边检查"最初**没被杀**——样本用的小方块占比也不达标,占比那条接住了它。
换成细长条(占比 1.3% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。
ai_engine 的 pyproject 声明了 imageio / av(抽帧必需),而 rebase 解冲突时 uv.lock
取的是主线版本,两者不一致:uv lock --check 报 lockfile needs to be updated。
本地 venv 里恰好装过这两个包,故本地测试没暴露;CI 用 --frozen 装依赖时会缺。

重锁时带 UV_DEFAULT_INDEX=阿里云镜像 —— 直接 uv lock 会把全仓 90 处包源改写成
pypi.org,产出两千多行与本次改动无关的 diff(主线 Dockerfile 定的就是这个镜像源)。
机器审在本 PR 报的三条 P2,两条同源:契约里存在"能填/能读、但与另一处矛盾或不生效"的
字段。

一、GeneratedAction.fps 删除。它抄自入参,而 durations 按动作查表得来,两者描述同一段
   素材的不同播放速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧,取哪个看消费方心情。
   逐帧 ms 严格更能表达(关键帧定格),所以保 durations、删 fps;真要单一帧率由消费方算。
   连带删除 ActionSpec.fps(在 feat/character-domain-models 里,本分支同步)。

二、删掉一条为缺陷背书的测试。此处曾有 test_loop_mode_currently_changes_nothing,把
   "传 pingpong / none 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒
   删注释"。那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个
   行为背书。现改为断言字段确实不存在——ActionSpec.loop 与 LoopMode 都已移除。
   同理,test_generate_walk_is_wired_end_to_end 里的 `assert out.fps == action.fps`
   换成断言时长确实来自动作查表(walk = 125ms/帧)。

三、抽帧改流式(改动本体在 feat/ai-engine-frame-toolkit,本分支同步)。121 帧 720p 真实
   视频抽 16 帧,进程 RSS 峰值 488 → 126 MiB。

变异测试:把 GeneratedAction.fps 加回去 1 条红;把 durations 改成固定 50ms 不查表 1 条红。
CI:ruff / import-linter 2 contracts / pytest 276 passed。
引擎恒出 256 方形,项目的 sprite 尺寸由 app 层拿到帧之后再缩一次。那一步的代价
不是"糊一点":它用 Image.thumbnail 补边,而 **thumbnail 只缩不放**。2026-08-11
复刻上层这段逻辑实测(喂主体高 157px、脚线 0.92 的 256 交付帧):

    目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 主体仍 157px,              脚线 0.92 → 0.779

即放大方向上主体尺寸一点没涨("成品放大看很糊"的直接来源),而且 _lastmile 刚
对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作一致性也跟着失效。

故 CharacterGeneratorPort.generate 增加可选入参 canvas=(宽, 高),一路传到
_lastmile → align_bottom_center(cell=宽, cell_h=高),让引擎一次出到目标尺寸,
上层那次二次缩放整个消掉。

为什么放在 generate 的入参而不是 ActionSpec 的字段:画布尺寸是**项目级输出约束**,
不是动作规格。挂到 ActionSpec 上,每个动作各带一份尺寸,一旦不同动作填得不一致就
会静默破坏跨动作本体尺寸一致(ref_height 专门守的就是这条),而入参形式下它由
编排层一处给出。同时 windup_common 一个字段都不用动。

canvas=None 时行为与本提交之前**逐字节相同**(用例钉死:不传 canvas 与传
(256,256) 出参逐字节一致)。

实测(mock strategy + 真实对齐链,主体 60px):
    不传        画布 256×256
    (512,512)   画布 512×512,主体高 / 256 档主体高 = 2.00×(翻倍)
    (384,512)   画布 384×512,主体高与 (512,512) 完全相同 —— 高度几何只看画布高
    (320,320)   整段 4 帧全部 320×320(不是只有第一帧生效)

母版入口预检与出帧仍共用同一套几何:master_check.REJECT_ASPECT = 2*FILL_W/FILL_H
里没有 cell,本就与画布像素尺寸无关;画布几何全部按比例表达,换尺寸不改变构图。

变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  G1 canvas 不往下传 _lastmile     → canvas_512_doubles 等 3 条红
  G2 只用宽、忽略高(退化成方形)  → canvas_non_square 红
  G3 宽高接反                      → canvas_non_square 红
  G4 canvas 给了也当没给           → canvas_512_doubles 等 3 条红
  G5 丢掉 ref_height 定标          → canvas_omitted_keeps_default 红

**依赖上游分支**:本提交调用的 align_bottom_center(cell_h=...) 由
feat/ai-engine-frame-toolkit 的 5da358e 提供。本分支尚未同步该提交,故单独跑
pytest 会有 4 条 TypeError: unexpected keyword argument 'cell_h'。在工作区先打上
5da358e 的 pack.py 再跑,CI 全绿:ruff / lint-imports(2 contracts kept) /
pytest 283 passed。同步后即恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西:master_check 的
REJECT_ASPECT 推导默认画布是方形 —— FILL_W 与 FILL_H 是**同一条边长**的两个比例。
画布能非方之后前提不成立了,同一条推导做下来是

    R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch)

不跟着收的后果正是这条阈值最怕的那件事:**预检按方形判、出帧按非方出**。
2026-08-11 实测,一个刚好过检(w/h=3.0968)的主体在各档画布上的交付占高:

    256×256   0.3086      512×512  0.3105      1024×1024  0.3105
    384×512   0.2324  ← 阈值本意保证的下限是 FILL_H/2 = 0.31,被架空

新增 reject_aspect_for(canvas) 算实际上限,check_master 收可选 canvas,
CharacterGenerator 把**出帧用的同一个 canvas** 传给预检。
canvas=None 或方形画布时与本提交之前完全一致(用例钉死 128/256/512/1024 四档
以及 None 都等于原 REJECT_ASPECT)。

修好之后的不变式实测(源画幅放大到 3000×600 杜绝主体被源边界裁掉;处在各自比例
上限的主体,交付占高应恒等于 FILL_H/2 = 0.31):

    256×256 上限 3.0968 → 0.3086      512×512 上限 3.0968 → 0.3105
    1024×1024 上限 3.0968 → 0.3105    384×512 上限 2.3226 → 0.3105
    512×384 上限 4.1290 → 0.3099      128×192 上限 2.0645 → 0.3125
    640×480 上限 4.1290 → 0.3104      2048×2048 上限 3.0968 → 0.3101
    与 FILL_H/2 的最大偏差 0.0025(取整噪声量级)

即窄高画布收紧、宽扁画布放宽,两侧都回到同一条几何。

变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  M1 非方画布不收紧阈值            → narrow_canvas_tightens 等 3 条红
  M2 宽高比取倒数(方向反了)      → narrow_canvas_tightens 等 3 条红
  M3 方形画布也被改动              → square_canvas_is_unchanged 红
  M4 判定仍用写死的 REJECT_ASPECT  → check_master_uses_the_canvas 红
  M5 预检不吃 canvas               → precheck_and_output_share_geometry 红

M5 一开始杀不掉(没有任何用例覆盖"预检与出帧用了不同 canvas"),补
test_precheck_and_output_share_the_same_canvas_geometry 之后才杀掉 —— 取一个夹在
方形阈值与 384×512 阈值之间的母版,方形放行、窄高必拒。

**依赖上游分支**:同 013520f,需要 feat/ai-engine-frame-toolkit 的 5da358e。
在工作区打上该提交的 pack.py 后跑,CI 全绿:ruff / lint-imports(2 contracts kept)
/ pytest 288 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/ai-engine-ports-and-strategy branch from 35b41d7 to 5e63597 Compare August 11, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant