feat(ai_engine): AI 生成引擎——视频路线动作生成管线 - #152
Conversation
- 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 测试
…e optional on macOS x86_64
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
| # ── 端点 ───────────────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| @router.post("", response_model=Response[CharacterOut]) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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." |
There was a problem hiding this comment.
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.
|
两处地基文件不在本 PR 里,合入后 缺失
引用它们的位置(本 PR 内) 两者定义在 upstream 开发分支上,拆分时未随 我这边有这两块的现成实现,可以单独提 PR 让本 PR 直接用,需要的话我今天就发:
另外三处本 PR 内的问题,与上面无关,一并列出供参考: 1. 未实现的路线返回空帧,调用方拿到的 2. 三条缩放分支都只看高度,是"主体是纵向长条"的人形先验。横向长条主体(四足兽 / 坐骑)按同一系数缩放后宽度超出画布,会被 3.
四段真 i2v 视频实测,归一化接缝 3.07/1.29/9.31/1.77 → 1.96/0.87/1.39/0.81;其中待机那段旧算法选出的 16 帧里有 10 帧逐像素重复(写出的 GIF 只有 6 帧)。 这三处我也都有修好并带回归测试的版本,可以按你方便的方式合过来 —— 独立小 PR、或直接推到本 PR 的分支都行。 |
本 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
按 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
按 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
|
按最新 main( 合并顺序(自下而上,每层依赖上一层)
为什么必须这个顺序: 每个 PR 自身 CI 可绿;前一个合入后我 rebase 下一个,届时各自 diff 只剩本层。 迁移时按主线现状做的三处调整接口签名统一到 session-per-call。 主线 保留主线后加的 SSE 文档与终态关流。
顺带修掉的几处后台线程竞态。 四处会返回「看起来成功的错结果」。 未实现路线返回 画布只按高度定标。 横向长条主体(四足兽 / 坐骑)缩放后宽度超出 cell,被 周期检测三处假周期。 详见 #180,四段真视频实测归一化接缝 3.07/1.29/9.31/1.77 → 1.96/0.87/1.39/0.81;其中待机那段旧算法选出的 16 帧里 10 帧逐像素重复(写出的 GIF 只有 6 帧)。 需要说明的两点FAL 队列 provider 从未真实调用过。 平台的图生视频接口现在是 端到端真跑未做。 「提交任务 → i2v → 上传七牛 → 落库」这条链路没有完整跑过一次。四个前置合入后可以在部署环境上跑一次冒烟。 有任何一处你觉得该换个做法,直接说,我改。 |
按 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
本 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
按 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
按 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
* 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>
按 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
) * 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>
概述
AI 生成引擎模块:视频路线角色动作生成管线,包含帧提取、策略分发、后处理、提示词构建等完整链路。
包含内容
slicing(帧提取)
extract:视频帧提取(imageio/pyav)loop:循环检测(步态周期)oneshot:一次性动作裁剪strategy(策略分发)
base:DerivationStrategy 抽象 + ROUTE_MATRIXconcrete:VIDEO_I2V(walk/run/jump/attack)、PER_FRAME、PROC_IDLEpostprocess(后处理)
pixelate:像素化rootmotion:root motion 对齐pack:sprite sheet 打包prompt(提示词)
walk/jump/actions:侧视/正视提示词构建impl(实现)
CharacterGenerator:生成器实现ports:GeneratedAction 输出接口deps
测试
test_ai_engine_skeleton.py:串联 smoke 测试作者
关联