Skip to content

feat(ai_engine): AI 生成引擎——视频路线动作生成管线 - #152

Closed
xiaocheny214 wants to merge 13 commits into
1024XEngineer:mainfrom
xiaocheny214:feat/ai-engine
Closed

feat(ai_engine): AI 生成引擎——视频路线动作生成管线#152
xiaocheny214 wants to merge 13 commits into
1024XEngineer:mainfrom
xiaocheny214:feat/ai-engine

Conversation

@xiaocheny214

Copy link
Copy Markdown
Contributor

概述

AI 生成引擎模块:视频路线角色动作生成管线,包含帧提取、策略分发、后处理、提示词构建等完整链路。

包含内容

slicing(帧提取)

  • extract:视频帧提取(imageio/pyav)
  • loop:循环检测(步态周期)
  • oneshot:一次性动作裁剪

strategy(策略分发)

  • base:DerivationStrategy 抽象 + ROUTE_MATRIX
  • concrete:VIDEO_I2V(walk/run/jump/attack)、PER_FRAME、PROC_IDLE

postprocess(后处理)

  • pixelate:像素化
  • rootmotion:root motion 对齐
  • pack:sprite sheet 打包

prompt(提示词)

  • walk / jump / actions:侧视/正视提示词构建

impl(实现)

  • CharacterGenerator:生成器实现
  • ports:GeneratedAction 输出接口

deps

  • imageio/av 视频帧提取
  • onnxruntime macOS x86_64 兼容

测试

  • test_ai_engine_skeleton.py:串联 smoke 测试

作者

  • johnnyzhang-eng:核心管线实现(4 commits)
  • xiaocheny214:依赖配置(1 commit)

关联

xiaocheny214 and others added 13 commits August 6, 2026 18:22
- Add backend/Dockerfile with multi-stage build (uv + Python 3.12)
- Add docker-compose.yml with backend and PostgreSQL services
- Add db/init.sql for automatic database table initialization
- Add .env.example with configuration template
- PostgreSQL configured with port 7856 and secure password
…browser

三处让部署跑不起来的问题,都在这台服务器上实测定位:

1. 构建阶段 uv sync 超时。宿主机访问 pypi.org 需 8s,构建容器内默认超时会在
   下载大包(uvloop)时 "operation timed out" 直接失败。改走国内镜像源并把
   UV_HTTP_TIMEOUT 拉到 180s。

2. 容器起来即反复重启,报 "exec /app/.venv/bin/uvicorn: no such file or directory"。
   文件其实存在,报的是它 shebang 指向的解释器——uv 装出来的 venv 里 shebang 与
   .pth 都是绝对路径,builder 在 /build、runtime 在 /app,跨路径拷贝后解释器与
   workspace 包全部失效。把 builder 的 WORKDIR 也改成 /app 即可。

3. 七牛上传 TLS 握手超时、媒体上传请求挂死。宿主机网卡 MTU 1480,而 compose
   自建网络不继承 daemon 的 mtu 设置、默认仍是 1500,大包被丢。显式给网络设
   1450 后,up-z0.qiniup.com 从握手超时 14s 变为 1.0s,上传恢复正常。

4. 浏览器跨域被全部拦下:OPTIONS 预检返回 405、响应无 access-control-* 头,
   后端日志里连请求都看不到。挂上 CORSMiddleware,允许来源用
   WINDUP_CORS_ORIGINS 覆盖,并放行 Vercel 预览域名。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
后端验证码/refresh_token 依赖 Redis,原 compose 只有 Postgres。
新增 redis:7-alpine 服务(含健康检查),backend depends_on 等待就绪,
环境变量 REDIS_URL=redis://redis:6379/0。
ORM 声明了 UniqueConstraint(user_id, project_name),但 init.sql 建表时遗漏。
生产 Postgres 并发创建同名项目不会触发 IntegrityError,API 兜底失效。
补上 CONSTRAINT uq_windup_project_user_name UNIQUE (user_id, project_name)。
…imiting

- 注册/登录(邮箱+验证码+密码)、免密登录、刷新 token、登出、改密
- JWT 鉴权中间件(白名单放行 + request.state.current_user 注入)
- 邮箱验证码(Redis 存储 + 冷却计时)
- 接口限流中间件(Redis 滑动窗口 + 降级策略)
- Redis 连接配置与客户端单例
- 22 个集成测试覆盖完整认证链路
- Add auth_client fixture with valid JWT token
- Update test_project_api.py to use auth_client
- Fix CI failures caused by auth middleware blocking unauthenticated requests
- workflow_run 模块:接口、ORM 模型、JSONB 节点树 schema
- agent 模块:SSE 会话管理骨架
- project/character 模块:接口、ORM 模型、service 实现
- 统一异常处理器(BizException 继承体系)
- media 上传 API
- server/generation → server/orchestrator(生成任务编排/调度)
- orchestrator 模块:interface、model、service、task_repo、executor
- generation API:图片生成 + 动作生成 + 任务轮询
- after_commit 回调修复任务行未提交竞态
- generation 端点从 JWT 取 user_id,加项目归属校验
- slicing: 帧提取(extract)、循环检测(loop)、一次性动作(oneshot)
- strategy: 路线分发(VIDEO_I2V / PER_FRAME / PROC_IDLE)+ DerivationStrategy
- postprocess: 像素化(pixelate)、root motion、sprite sheet 打包(pack)
- prompt: 提示词构建(walk / jump / actions)
- impl: CharacterGenerator 实现
- ports: GeneratedAction 输出接口
- test: 串联 smoke 测试
@xiaocheny214 xiaocheny214 added the enhancement New feature or request label Aug 6, 2026
@vercel

vercel Bot commented Aug 6, 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 6, 2026 1:31pm

@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.

Three concrete issues stand out: character CRUD is missing ownership checks, code-login creates passwordless accounts that later break password login, and image generation drops the supplied negative prompt.

# ── 端点 ─────────────────────────────────────────────────────────────────────


@router.post("", response_model=Response[CharacterOut])

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.

High: this router never checks request.state.current_user against the owning project/character. As written, any authenticated user can create, list, read, update, or delete another user's characters by guessing ids.

# 查找或创建用户
user = session.scalar(select(User).where(User.email == input.email))
if user is None:
user = User(email=input.email, email_verified_at=datetime.now(timezone.utc))

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.

High: this auto-register path leaves password_hash at the empty-string default. The password-login path later feeds that value into _verify_password() / bcrypt, so code-created accounts can hit a 500 instead of a normal bad-credentials error.

pass # 风格参考图下载失败不阻断

# 3. 构建提示词
base = input.prompt or "Clean full-body character reference of the figure in the image."

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.

Medium: negative_prompt from the API is never threaded into the prompt or provider call. Users can send it, but it has no effect on the generated image.

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

两处地基文件不在本 PR 里,合入后 ai_engine 会在 import 期直接失败。核实于 2026-08-10 14:49(对 PR head 逐文件查证):

缺失

  • backend/packages/common/src/windup_common/models/character.py — 该目录下只有 .gitkeep
  • backend/packages/framework/src/windup_framework/providers/matte.py

引用它们的位置(本 PR 内)

ai_engine/ports/__init__.py:15        from windup_common.models import ActionSpec, CharacterCard
ai_engine/strategy/concrete.py:16     from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute
ai_engine/strategy/concrete.py:17     from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider
ai_engine/strategy/concrete.py:57     def __init__(self, video: VideoProvider, matte: MatteProvider)

两者定义在 upstream 开发分支上,拆分时未随 ai_engine 一起带过来。


我这边有这两块的现成实现,可以单独提 PR 让本 PR 直接用,需要的话我今天就发:

  • windup_common/modelsCharacterCard / ActionSpec / ActionType / GenRoute,受限取值用枚举(facing 承载"提示词朝向必须与母版一致"这条硬约束,此前是裸 str,写错不报错、要等一次付费生成后在画面上才看得出)
  • windup_framework/providers/matte.pyOnnxU2NetMatteProvider(onnxruntime 直跑 u2netp;不用 rembg 是因为其依赖链 pymatting→numba 在 3.12 无轮子)

另外三处本 PR 内的问题,与上面无关,一并列出供参考:

1. strategy/concrete.py 两处 return [b"" for _ in range(action.n_frames)](144、165 行)

未实现的路线返回空帧,调用方拿到的 GeneratedAction 帧数对、时长对、无异常,看起来是一次成功的生成。server 会把 N 个 0 字节文件传上对象存储并写进 character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。建议改为抛 NotImplementedError

2. postprocess/pack.pyalign_bottom_center 只按高度定标

三条缩放分支都只看高度,是"主体是纵向长条"的人形先验。横向长条主体(四足兽 / 坐骑)按同一系数缩放后宽度超出画布,会被 alpha_composite 以负 dest 静默丢像素,PIL 不报错。裁切悬崖在主体 w/h ≈ 1.61;实测一个 w/h=1.92 的四足角色,鼻尖与尾尖被切平、主体贴死画布左右边缘。加一条宽度兜底即可,人形(w/h 0.3–1.1)产物逐像素不变。

3. slicing/loop.py 的周期检测有三处会交出假周期

  • 角色整体平移让 d(p) 随 p 单调上升,argmin 滑到搜索窗边界(实测某走路视频 p 恒等于 pmin=16,真周期 56)
  • 搜索窗上界 n//2 会把真周期挡在窗外(某待机视频真周期 62 > pmax 60)
  • 谐波:常选中真周期的约 1/2,半周期闭环 = 末帧接回首帧时左右腿瞬间互换

四段真 i2v 视频实测,归一化接缝 3.07/1.29/9.31/1.77 → 1.96/0.87/1.39/0.81;其中待机那段旧算法选出的 16 帧里有 10 帧逐像素重复(写出的 GIF 只有 6 帧)。

这三处我也都有修好并带回归测试的版本,可以按你方便的方式合过来 —— 独立小 PR、或直接推到本 PR 的分支都行。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
本 PR 是 1024XEngineer#152 的前置:它的 `ai_engine/ports` 与 `strategy/concrete` 都 import
`windup_common.models` 的 `CharacterCard` / `ActionSpec`,而那两个符号在主线上不存在
(`models/` 下只有 `.gitkeep`),合入即 import 失败。

## 为什么受限取值一律用枚举

`facing` 承载一条实测挣得的硬约束——提示词朝向必须与母版朝向一致(给正面母版喂侧走词,
模型会靠转身调和图文矛盾)。它此前是裸 str、合法值只写在行尾注释里:写成 "Side" /
"sidee" 不报错、不告警,调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上
看出角色转了身。枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时
ValidationError",成本从一次付费生成降到零。`loop` / `stylize` / `view` 同理。

`view` 的取值与前端契约(frontend/API_CONTRACT.md 的映射表)逐字一致,免得将来做
int ↔ str 映射时再造一套别名(topdown / top_down / top-down 三写)。

## extra="forbid"

理由与用枚举同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`
(少个 s)在 pydantic 默认的 extra="ignore" 下不报错、不生效,调用方以为要了 16 帧、
实际拿到默认 8 帧。已删字段(如 palette)同理会被静默吞掉。

## n_frames 独立成字段

原先由 `len(poses)` 推导,但视频路线根本不读 poses——推导意味着"想要 16 帧就得先编
16 条用不上的姿势描述",而那 16 条描述读者会以为真的进了提示词。
只传 poses 的旧调用方零改动(回退到 len(poses));两个都给且不等时抛错而不是猜一个——
common 层看不到 ROUTE_MATRIX,判不出走哪条路线,猜的代价是静默出错帧数。

## 删掉 palette

它会变成"看起来生效、实则被忽略"的第二真相源:真正锁色的色板由
postprocess.master_pixel_spec 从母版像素里量出来,而这个字段零消费方、无格式约定。
调用方填了 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效。
将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化类型而非自由 str。

## 数值下界抄的是实现里已有的真实取值域

把"实现悄悄纠正入参"提前成入参报错:`pixel_h` → to_pixel_art 对 <1 直接 raise;
`palette_size` → `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2,于是
"我要 1 色"拿到 2 色且无任何提示;`fps` → 0 对播放侧是除零/静止,没有合法语义。

Refs 1024XEngineer#171
Refs 1024XEngineer#53
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

按最新 main(efd230e)重新迁移完成,拆成五个 PR,全部 CI 绿。#151 / #152 的内容都有去处,可以关了。

合并顺序(自下而上,每层依赖上一层)

PR 对应 #151/#152 的哪部分
#172 windup_common/models 角色卡 / 动作规格 / 生成路线枚举
#179 windup_framework/providers Provider Protocol + 抠图 + 视频下载重试
#180 windup_ai_engine 叶子 抽帧 / 选帧 / 后处理 / 提示词
#181 windup_ai_engine 契约层 ports / strategy / impl
#182 server/orchestrator + generation API #151 的 orchestrator 部分

为什么必须这个顺序:orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete}windup_common.models,四层里主线只有 windup_framework.providers。这也是 #152 合入即 ImportError 的同一条根因。

每个 PR 自身 CI 可绿;前一个合入后我 rebase 下一个,届时各自 diff 只剩本层。

迁移时按主线现状做的三处调整

接口签名统一到 session-per-call。 主线 orchestrator/interface.py 三个方法没有 session 参数,而 #176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字入参 + 只 flush 不 commit」。两处不一致会让同一个仓出现两种事务写法,故 orchestrator 跟上已确立的那套。

保留主线后加的 SSE 文档与终态关流。 interface.py 的 SSE 契约说明、generation.py_TERMINAL_EVENTS,都比源分支写的「前端轮询」更准确,原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生 EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed

user_id 从 JWT 取。 源分支请求体里的 user_id: int = Field(gt=0) 客户端可以填别人的 id。改为 request.state.current_user.id 并加项目归属校验(不存在或不属当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

顺带修掉的几处

后台线程竞态。 create_task 只 flush,session 要等 handler 返回后才 commit。直接起线程的话后台 session 读不到未提交的任务行,update 静默跳过——任务永远停在 PENDING 且无报错。改为注册 after_commit 回调再派发。

四处会返回「看起来成功的错结果」。 未实现路线返回 [b""] * n(帧数对、时长对、无异常,server 照常落库、用户看到裂图);抽帧源帧不足时静默少给而时长表按实际长度现算、自洽到看不出异常;抠图在 onnxruntime 缺失时静默猜背景色;入口零校验(实测喂一张「人物在画板前作画」的图请求 walk,全程无报错、16 帧错角色出完、钱花完)。全部改成在边界上抛错,MasterRejected 带机器可读 code 供 server 判 4xx-不重试。

画布只按高度定标。 横向长条主体(四足兽 / 坐骑)缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素。裁切悬崖在主体 w/h ≈ 1.61;实测 w/h=1.92 的角色鼻尖尾尖被切平。加宽度兜底后人形角色(w/h 0.48/0.70)修复前后逐像素相同,不误伤既有资产。

周期检测三处假周期。 详见 #180,四段真视频实测归一化接缝 3.07/1.29/9.31/1.77 → 1.96/0.87/1.39/0.81;其中待机那段旧算法选出的 16 帧里 10 帧逐像素重复(写出的 GIF 只有 6 帧)。

需要说明的两点

FAL 队列 provider 从未真实调用过。 平台的图生视频接口现在是 /queue/... 格式、要 image_url 公网 URL,与 /v1/videos + base64 那套不同(kling-v3-omni 的图生视频真实地址是 /queue/fal-ai/kling-video/o3/{mode}/image-to-video)。已按 OpenAPI 规范重写并有 37 个 mock 用例、变异测试 11/11 被捕获,但认证方式、/v1 前缀剥离、轮询地址这些只对着规范验证过,没跑过真调用。

端到端真跑未做。 「提交任务 → i2v → 上传七牛 → 落库」这条链路没有完整跑过一次。四个前置合入后可以在部署环境上跑一次冒烟。

有任何一处你觉得该换个做法,直接说,我改。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 10, 2026
本 PR 是 1024XEngineer#152 的前置:它的 `ai_engine/ports` 与 `strategy/concrete` 都 import
`windup_common.models` 的 `CharacterCard` / `ActionSpec`,而那两个符号在主线上不存在
(`models/` 下只有 `.gitkeep`),合入即 import 失败。

## 为什么受限取值一律用枚举

`facing` 承载一条实测挣得的硬约束——提示词朝向必须与母版朝向一致(给正面母版喂侧走词,
模型会靠转身调和图文矛盾)。它此前是裸 str、合法值只写在行尾注释里:写成 "Side" /
"sidee" 不报错、不告警,调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上
看出角色转了身。枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时
ValidationError",成本从一次付费生成降到零。`loop` / `stylize` / `view` 同理。

`view` 的取值与前端契约(frontend/API_CONTRACT.md 的映射表)逐字一致,免得将来做
int ↔ str 映射时再造一套别名(topdown / top_down / top-down 三写)。

## extra="forbid"

理由与用枚举同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`
(少个 s)在 pydantic 默认的 extra="ignore" 下不报错、不生效,调用方以为要了 16 帧、
实际拿到默认 8 帧。已删字段(如 palette)同理会被静默吞掉。

## n_frames 独立成字段

原先由 `len(poses)` 推导,但视频路线根本不读 poses——推导意味着"想要 16 帧就得先编
16 条用不上的姿势描述",而那 16 条描述读者会以为真的进了提示词。
只传 poses 的旧调用方零改动(回退到 len(poses));两个都给且不等时抛错而不是猜一个——
common 层看不到 ROUTE_MATRIX,判不出走哪条路线,猜的代价是静默出错帧数。

## 删掉 palette

它会变成"看起来生效、实则被忽略"的第二真相源:真正锁色的色板由
postprocess.master_pixel_spec 从母版像素里量出来,而这个字段零消费方、无格式约定。
调用方填了 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效。
将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化类型而非自由 str。

## 数值下界抄的是实现里已有的真实取值域

把"实现悄悄纠正入参"提前成入参报错:`pixel_h` → to_pixel_art 对 <1 直接 raise;
`palette_size` → `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2,于是
"我要 1 色"拿到 2 色且无任何提示;`fps` → 0 对播放侧是除零/静止,没有合法语义。

Refs 1024XEngineer#171
Refs 1024XEngineer#53
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
xiaocheny214 pushed a commit that referenced this pull request Aug 11, 2026
* feat(common): 角色卡与动作规格共享 DTO

跨层契约,无内部依赖。ai_engine 与 app 均依赖此,避免两层各自定义同名结构。

- CharacterCard  角色身份(一致性主键,资产库基础)
- ActionSpec     动作规格(帧数 / 帧率 / 循环模式 / 风格化 / 朝向)
- ActionType     idle / walk / run / jump / attack / hit
- GenRoute       video_i2v / per_frame

GenRoute 只列有实现的路线。没有实现的枚举值等于死代码:它让调用方以为该能力存在,
而分流到它只能得到运行时错误。未来路线(三渲二渲染出帧)的契约需求记在 #81 / #122,
随实现一起加成员——枚举加成员是纯加法,不构成破坏性变更。

Refs #53

* refactor(common): 受限取值改枚举,n_frames 独立,删掉零消费方的 palette

本 PR 是 #152 的前置:它的 `ai_engine/ports` 与 `strategy/concrete` 都 import
`windup_common.models` 的 `CharacterCard` / `ActionSpec`,而那两个符号在主线上不存在
(`models/` 下只有 `.gitkeep`),合入即 import 失败。

## 为什么受限取值一律用枚举

`facing` 承载一条实测挣得的硬约束——提示词朝向必须与母版朝向一致(给正面母版喂侧走词,
模型会靠转身调和图文矛盾)。它此前是裸 str、合法值只写在行尾注释里:写成 "Side" /
"sidee" 不报错、不告警,调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上
看出角色转了身。枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时
ValidationError",成本从一次付费生成降到零。`loop` / `stylize` / `view` 同理。

`view` 的取值与前端契约(frontend/API_CONTRACT.md 的映射表)逐字一致,免得将来做
int ↔ str 映射时再造一套别名(topdown / top_down / top-down 三写)。

## extra="forbid"

理由与用枚举同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`
(少个 s)在 pydantic 默认的 extra="ignore" 下不报错、不生效,调用方以为要了 16 帧、
实际拿到默认 8 帧。已删字段(如 palette)同理会被静默吞掉。

## n_frames 独立成字段

原先由 `len(poses)` 推导,但视频路线根本不读 poses——推导意味着"想要 16 帧就得先编
16 条用不上的姿势描述",而那 16 条描述读者会以为真的进了提示词。
只传 poses 的旧调用方零改动(回退到 len(poses));两个都给且不等时抛错而不是猜一个——
common 层看不到 ROUTE_MATRIX,判不出走哪条路线,猜的代价是静默出错帧数。

## 删掉 palette

它会变成"看起来生效、实则被忽略"的第二真相源:真正锁色的色板由
postprocess.master_pixel_spec 从母版像素里量出来,而这个字段零消费方、无格式约定。
调用方填了 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效。
将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化类型而非自由 str。

## 数值下界抄的是实现里已有的真实取值域

把"实现悄悄纠正入参"提前成入参报错:`pixel_h` → to_pixel_art 对 <1 直接 raise;
`palette_size` → `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2,于是
"我要 1 色"拿到 2 色且无任何提示;`fps` → 0 对播放侧是除零/静止,没有合法语义。

Refs #171
Refs #53

* refactor(common): 删掉两个接了不履约的入参,并给契约补自带测试

机器审在本 PR 报的问题指向同一件事:契约里存在"能填、但填了不生效"的字段。本 PR 自己的
GenRoute docstring 已经写了这条原则("只列有实现的路线;没有实现的枚举值等于死代码"),
只是没对 ActionSpec 执行。

删除:
- ActionSpec.fps —— 零写入方(编排层构造 ActionSpec 时从不传),而 postprocess.
  frame_durations 按动作查表、根本不看它。留着的后果是同一段素材有两个互相矛盾的播放
  速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧。播放时序的唯一真相源定为出参的
  durations(逐帧 ms),比单一帧率严格更能表达(关键帧定格)。
- ActionSpec.loop 与 LoopMode 枚举 —— 零消费方。闭环行为写死在 slicing.pick_cycle:
  循环类动作一律抽单周期闭环,传 pingpong / none 不改变任何产出。调用方能为一段往返
  动画付费、拿到一段线性循环,正是本项目最忌讳的静默成功。此前的处理是写注释说明"别
  指望它生效",并写了一条把"三种 loop 产出相同"钉成事实的测试 —— 那是把缺陷固化,不是
  修。真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。

补文档:
- ActionType docstring 显式声明本枚举是"引擎能生成的动作",与入口侧
  orchestrator.model.ActionType(另有 custom,少 run/jump/hit)刻意分离,跨越靠编排层
  的显式适配函数 _to_engine_action。不把 custom 加进来的理由与上面同一条:ROUTE_MATRIX
  没有它的分流,加成员等于接收一个无法履约的请求。API 入口枚举不变,既有调用方兼容性
  不受影响。

补测试(本 PR 此前零测试,而它是其余分片的硬前置):
- 新增 tests/test_character_contract.py,35 条,只测 DTO 自身、不 import 上层包 ——
  契约包要能独立验证。提示词构造器那几条依赖 ai_engine,随实现分片走。
- 覆盖:受限取值拒绝拼写错误、字段名打错不被静默丢弃(extra="forbid")、n_frames 与
  poses 自相矛盾时抛错而非二选一、取值域下界、以及 palette / fps / loop 三个已删字段
  传入时必须听得见响。
- 做过变异测试:把 fps 加回去 2 条红,把 loop + LoopMode 加回去 3 条红。

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
1024XEngineer#151 / 1024XEngineer#152 的内容迁到当前 main(efd230e)之上重提。主线上 orchestrator 只有
interface + model,缺 service / executor / task_repo;generation API 三个端点是
「接口待实现」。

## 迁移时按主线现状做的调整

**接口签名统一到 session-per-call。** 主线 orchestrator/interface.py 的三个方法没有
session 参数,而 1024XEngineer#176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字
入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator
跟上已确立的那套;事务边界仍归 windup_framework.db.get_session。

**保留主线的 SSE 文档与终态关流。** interface.py 的 SSE 契约说明、generation.py 的
_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支的「前端轮询」更准确,
原样保留。终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生
EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed。

**user_id 从 JWT 取,不信客户端。** 源分支的请求体里有 user_id: int = Field(gt=0),
客户端可以填别人的 id。改为 request.state.current_user.id,并加项目归属校验
(项目不存在或不属于当前用户一律 404,不区分两者以免泄露他人项目是否存在)。

## 后台执行的两处竞态与分层

**after_commit 再起线程。** create_task 只 flush,session 要等 handler 返回后才 commit。
直接起线程的话,后台 session 可能读不到未提交的任务行,update 静默跳过——任务永远停在
PENDING 且无任何报错。改为注册 after_commit 回调,提交成功后才派发。

**executor 挂 app.state,不 import 进 web 层。** import-linter 的分层契约禁止
app.web 直连 ai_engine,而 executor 要调 ai_engine.impl。放进 state 由 bootstrap
(唯一装配点)注入,契约与实现两边都成立。

## 依赖链

orchestrator/executor.py import 了 windup_ai_engine.{impl,ports,strategy.concrete} 与
windup_common.models,四层里主线只有 windup_framework.providers。故本分支 stack 在
ai_engine 那条线之上,那条合入后 rebase。

Refs 1024XEngineer#171
xiaocheny214 pushed a commit that referenced this pull request Aug 11, 2026
)

* feat(framework): 补 provider 抽象接口与抠图/视频实现

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。

* fix(providers): 首帧字段按模型选,并拦截被静默忽略的参考图

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。

* feat(providers): 按现行 FAL 队列接口重写 i2v,并回退按旧接口形状打的两处补丁

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>

* fix(providers): 抠图补底色清理,去掉猜背景色的静默兜底

两处,都是 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。

* fix(framework): 依赖声明取主线版本,修 rebase 时 lock 与 pyproject 不一致

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 通过。

* fix(providers): 视频下载不再把 API key 带给成品域名

机器审 PR #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>

* feat(providers): 实现文生图 provider,端点不再必然失败

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 条用例变红。

* fix(providers): 文生图走配置里的路径,并把"网关没这个模型"翻译成能照着修的错误

对抗复查自己今天这笔实现时发现的两处:

一、路径此前硬编码 "/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 条红。

* fix(providers): 抠图补封闭空洞,但腿间空隙必须按颜色豁免

放大看交付帧,主体内部有透明洞,背景直接透出来。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>

* refactor(providers): 模型型号进配置,请求形状留在代码里

人工评审指出 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 条变异全部杀掉(共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 /
配置里补上请求形状字段 / 校验挪回解析之前)。

* refactor(providers): 移除从未真实调用过的 FAL 队列面

评审质疑「为什么还需要一层不该理解业务的东西」(指 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 更自然。真要接
时连同一次真实调用一起加回,归档里有完整的接入记录,重写成本不高。

* test(providers): 补 i2v 主流程与 cutout 装配的覆盖,并修一个除零

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。

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants