Skip to content

feat(generation): 任务编排 - #182

Merged
huyanxius merged 11 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/generation-orchestrator
Aug 12, 2026
Merged

feat(generation): 任务编排#182
huyanxius merged 11 commits into
1024XEngineer:mainfrom
johnnyzhang-eng:feat/generation-orchestrator

Conversation

@johnnyzhang-eng

@johnnyzhang-eng johnnyzhang-eng commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

变更内容

这个 PR 直接对应 #151 / #152 里的 orchestrator 部分,按当前 main(efd230e)重新迁移。

主线上 server/orchestrator/ 只有 interface + model,缺 service / executor / task_repoweb/api/generation.py 三个端点是「接口待实现」。本 PR 补齐:

  • service.py — 建 PENDING 任务记录
  • task_repo.py — 任务持久化 + 状态变更时推 SSE
  • executor.py — 后台执行:取母版 → 读项目约束 → 经 ai_engine.ports 生成 → 逐帧上传对象存储 → 更新任务
  • web/api/generation.pyPOST /image / POST /action / GET /tasks/{id} 三个端点真实现

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

1. 接口签名统一到 session-per-call。

主线 orchestrator/interface.py 的三个方法没有 session 参数,而 #176 刚合入的 workflow_run/service.py 用的是「session 首参 + 关键字入参 + 只 flush 不 commit」。两处不一致会让同一个仓里出现两种事务写法,故 orchestrator 跟上已确立的那套;事务边界仍归 windup_framework.db.get_session(成功 commit、异常 rollback)。

2. 保留主线的 SSE 文档与终态关流。

interface.py 的 SSE 契约说明、generation.py_TERMINAL_EVENTS 与终态后 break,都是主线后来加的,比源分支写的「前端轮询 get_task」更准确,原样保留。

终态关流治的是:服务端发完 task_update 就关流但带 retry: 3000,浏览器原生 EventSource 每 3 秒重连、45 秒内 15 次,每次重收同一条 completed

3. 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(唯一装配点)注入,契约与实现两边都成立。

依赖链(为什么不能单独提)

executor.py import 了:

windup_ai_engine.impl / ports / strategy.concrete
windup_common.models
windup_framework.providers          ← 主线唯一已有的
windup_app.server.{media,project,orchestrator}

四层里主线只有 windup_framework.providers。这就是 #152 合入即 ImportError 的同一条根因——ai_engine 那三层还没进主线。

stack 声明:本分支 stack 在 #172 / #179 / #180 / #181 之上,四个合并后 rebase,届时 diff 只剩 orchestrator + generation API 这一层。

关联

Refs #171 · Refs #78(SSE 推送替代前端轮询) · 迁移自 #151 / #152 的 orchestrator 部分

本地验证

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

test_generation_orchestration.py 3 个用例(任务创建 → 后台执行 → 状态更新的串联)。

待对齐

端到端真跑未做。 本 PR 的所有验证都是单测与 mock;一次真实的「提交任务 → i2v 生成 → 上传七牛 → 落库」没有跑过(要花钱,且依赖上游四个 PR 先合)。四个前置合入后可以在部署环境上跑一次冒烟,那时才算这条链路真的通。

@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 12, 2026 8:21am

@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch 2 times, most recently from 5040d57 to 125ab67 Compare August 10, 2026 07:39

@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 Not Completed

The PR head changed while this review was running: the workspace was locked to 7a4abc07c5a4c2e660bcb7506a69f9f98ed34cbd, but GitHub now reports 125ab672d2fe641e09b8de9864817328b07f28d8. I did not submit findings from the stale diff. Please rerun /review against the current head.

View job run

@huyanxius

Copy link
Copy Markdown
Collaborator

Review Not Completed

The PR head changed while this review was running: the workspace was locked to 7a4abc07c5a4c2e660bcb7506a69f9f98ed34cbd, but GitHub now reports 125ab672d2fe641e09b8de9864817328b07f28d8. I did not submit findings from the stale diff. Please rerun /review against the current head.

View job run

@fennoai /review

@fennoai

fennoai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review completed

Submitted a non-blocking review against efd230e4bed1f4890f467aa7d875ebfeb2e5cce8...125ab672d2fe641e09b8de9864817328b07f28d8 with 7 findings.

Key blockers include stale executor assembly symbols, the unimplemented real image provider, unauthorized SSE subscriptions, mismatched terminal events, unsafe cross-thread queue publication, SSRF-capable reference downloads, and unbounded paid generation counts.

Verification: git diff --check and Python syntax compilation passed. The full test suite was not rerun because uv is unavailable in this runner.

View job run

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

Reviewed the fixed range efd230e4bed1f4890f467aa7d875ebfeb2e5cce8...125ab672d2fe641e09b8de9864817328b07f28d8. I found blockers in both real executor paths plus authorization/SSE issues. Static syntax validation and git diff --check passed; the full suite could not be rerun because uv is unavailable in this runner.

Findings without inline locations

  • backend/packages/app/src/windup_app/web/api/generation.py:274: [P1] Authorize the SSE subscription before registering it. Unlike GET /tasks/{id}, this path never checks request.state.current_user against the task; the required project_id is unused. Any authenticated user who guesses a task id can receive that task's input, result URLs, and errors when the next update is published. Fetch with get_task_by_user (and return the same 404) before subscribing.

Comment thread backend/packages/app/src/windup_app/server/orchestrator/executor.py Outdated
Comment thread backend/packages/framework/src/windup_framework/providers/sufy.py Outdated
Comment thread backend/packages/app/src/windup_app/server/orchestrator/task_repo.py Outdated
Comment thread backend/packages/app/src/windup_app/web/api/generation.py Outdated
Comment thread backend/packages/app/src/windup_app/server/orchestrator/executor.py Outdated
Comment thread backend/packages/app/src/windup_app/server/orchestrator/executor.py
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from 6b87865 to 7bd662c Compare August 10, 2026 10:24
@nighca

nighca commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

stack 声明:本分支 stack 在 #172 / #179 / #180 / #181 之上,四个合并后 rebase,届时 diff 只剩 orchestrator + generation API 这一层。

是预期先 review 那 4 个,然后再 review 这个对吧?

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deployment failed for project windup with the following error:

The provided GitHub repository does not contain the requested branch or commit reference. Please ensure the repository is not empty.

@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from a7f2212 to ab69e06 Compare August 10, 2026 15:50
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

自查追加两处(对抗复查自己这批改动时发现的,不是机器审报的):

一、请求里的 width / height 此前被丢掉。 它们从请求进到 CharacterImageInput、被 _validate_project_size 校验过,然后没有任何人用 —— ImageProvider.gen_image(prompt, refs) 没有尺寸参数,模型出多大就返多大。调用方要 512×512、拿到 1024×1024,而请求被接受了。

性质与本轮删掉的 ActionSpec.fps / loop 完全相同,只是字段在入口侧。更难看的是我今天刚给这两个字段加了 le=2048 上界,等于替一个不履约的字段背书。

模型不吃宽高,所以在编排层落实:复用已有的 _fit_to,加 smooth 参数区分重采样 —— 序列帧是像素画必须 NEAREST(插值会把硬边糊成灰边、引入调色板外的颜色),全彩角色母版用 NEAREST 缩图明显锯齿,走 LANCZOS。

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

这条修的是"错误信息不可操作",不是配置本身 —— 部署侧需要确认所配网关的目录里确实有所用模型,代码管不了这件事。这也让我上一条里"请求形状与已跑通实现逐字段一致"这句话需要收窄:path / body / 鉴权头 / 重试轮数一致是真的,host 不同,而 host 正是会出问题的地方。

顺带:文生图路径改读配置里的 chat_completions_path(它本来就在 AIProviderSettings 里、零消费方,正是本轮在删的那类字段)。

测试 +7,变异测试逐条验过。其中重采样那条第一版拿纯色图作源是无效仪器(纯色下两种重采样产出完全相同),已换成棋盘格。

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

交付尺寸这条链的实际机制比我上一条说的更糟,更正如下。

我之前说"项目要 512 时是从 256 上采样,白白糊一次"。复核后不是上采样——_fit_to 用的是 Image.thumbnail,它只缩不放。所以 256 的帧根本没被放大,而是原尺寸居中贴进 512 画布,于是引擎刚对齐好的脚线 0.92 被挪到 0.709(实测):角色不站在地上,跨动作对齐一并失效。这比"糊一次"严重。

现在把画布尺寸交给引擎,它一次出到项目 sprite 尺寸,这一步整个不存在了。_fit_to 换成 _require_size——尺寸对不上直接报错,不做静默补救。这是 app 层的行为变更:引擎若不按 canvas 出帧,任务会 FAILED 而不是被悄悄缩放救回来。按本仓"不出静默错产物"的口径,这是有意的。

端到端实测(归档真实抠图帧,主体高中位 619px):

  • 不传尺寸 → 交付主体高 159px(与改动前逐字节相同
  • (512,512)318px,倍数 2.000
  • (384,512) → 主体高与 (512,512) 相同(非方画布按高定标,正确)

预检几何一致性REJECT_ASPECT 原本不含 cell,方形画布下 128/256/512/1024 的交付占高恒为 0.617~0.621,阈值处恰好 FILL_H/2=0.31——所以方形下一直是对的。但新增的非方画布能力把这条架空了(384×512 上只有 0.2324),因此阈值改为按 REJECT_ASPECT*(cw/ch) 收紧;修复后 8 种画布形状在阈值处的交付占高与 FILL_H/2 最大偏差 0.0025。

顺带修掉一个假绿用例_SpyGenerator 一直在构造 GeneratedAction(fps=...),而该字段早已删除——构造直接 TypeError、任务其实被判 FAILED;旧用例只断言构造之前就赋值的 seen_facing,所以一直绿着。

变异测试:pack 8/8、generator 5/5、executor 4/4、master_check 5/5 全部被杀。其中"预检不吃 canvas"这条最初杀不掉,补端到端用例后才杀掉。

未验:没看最终 512 档的成品 GIF(只量了主体高与画布尺寸);512 档仍是下采样(318 < 619),1024 档已越过源分辨率、收益递减。

@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 的可行方向尚未实现也未验证,如实说明。

Comment thread backend/packages/app/src/windup_app/web/api/generation.py
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch 2 times, most recently from d007985 to a66a41f Compare August 11, 2026 09:18
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.37624% with 51 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...app/src/windup_app/server/orchestrator/executor.py 81.42% 39 Missing ⚠️
...pp/src/windup_app/server/orchestrator/task_repo.py 84.72% 11 Missing ⚠️
.../packages/app/src/windup_app/web/api/generation.py 98.07% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch 2 times, most recently from e2ced9d to a1d96d2 Compare August 11, 2026 10:08
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from a735979 to 792190e Compare August 12, 2026 02:42
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

#180 合入后跟着重排,冲突已解,CI 全绿。端点实现不受影响(已核对 generation_service 调用与 _task_to_out 都在)。

johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
评审实跑逮到的:generator 按 i/4 报,中间夹着的 strategy.derive 按 i/3 报到**同一个**
ProgressPort 上。消费方按 i/total 画条会看到倒退两次 —— route 25.0% → derive 0.0%、
derive 66.7% → lastmile 50.0%,totals 同时出现 3 和 4。一个量两个真相源,取哪个看
消费方心情,与本分片删掉 fps / loop / palette 是同一条理由。

改法取评审给的第一条(generator 传偏移):generator 独占全局刻度 total=10,strategy 的
子进度由 _BandProgress 线性映射进 derive 区间 [2,7]。

- strategy 侧零改动。适配器只读它每次调用时自报的 total,不要求它声明自己有几步——
  声明值与实际值又是一对可以对不上的真相源。也不让它知道外层有几步:它是可插拔件,
  各路线步数本就不同。
- 刻度取 10 不取 5,是为了给 derive 段留出中间刻度;否则子进度全落同一格,虽不倒退
  但最慢的那段整段不动。
- 修后序列:0 → 10 → 20 → 30 → 50 → 80 → 90%,单一 total,零倒退。

测试 +3:同一次生成只允许一个 total、进度非递减、子进度必须落在 derive 区间内且
区间内确实动过(只断言"不倒退"的话,把适配器换成"永远报区间起点"也能过)。这三条
打在修复前的代码上全部 FAIL,报的正是评审给的那两处倒退。

一处如实说明:ProgressPort 的 docstring 写的是"server 转 SSE / 轮询状态",但 1024XEngineer#182 目前
唯一的实现是 executor.py:137 的 logger.info,SSE payload 里没有进度字段。所以今天这个
倒退只落在日志里,还没被用户看到 —— 也正因为没有活消费方依赖 total==4,才能直接改刻度
而不必兼容旧值。

Refs 1024XEngineer#171 1024XEngineer#53

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from 792190e to a2f50ca Compare August 12, 2026 03:26
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

跟着 #181 的进度修复重排了一次(a2f50ca),三个端点和 uv.lock 那几处标记都逐条核对过、没动。等你重看。

@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

已经是这个写法了:订阅前先读一次终态快照,是终态就直接 yield 那个事件然后 return(generation.py:365-368),不挂心跳。那 30 秒是这次改动之前的行为,已经去掉了,test_generation_stream_auth.py:143 钉住了这条。

fail 那半我做成推 failed 事件而不是抛异常:SSE 的响应头在生成器开跑时已经发出去了,此时抛异常客户端拿到的是一截断掉的流、而不是一个干净的错误码;而订阅方要的恰好是 error_message。

@huyanxius huyanxius left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM👍

johnnyzhang-eng added a commit that referenced this pull request Aug 12, 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。

* 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): 视频下载不再把 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(ai_engine): 出帧工具箱——抽帧 / 选帧 / 后处理 / 提示词

视频路线的纯计算层,零 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 只留作诊断。

* chore(tests): 删掉 rebase 带回来的 FAL 测试文件

FAL 队列面已随 #179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来,
而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。

* 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。

* refactor(ai_engine): 收拢 PNG bytes ↔ PIL 的转换,并重新 stack 到三个前置分支

管线内部按 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 的当前终态,同名文件与三者逐字节一致。

* feat(ai_engine): 母版入口预检 + 出参成色信号 + 抠图跳过编码器边缘伪影

两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入;
出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。

此前 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% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。

* fix(ai_engine): 出参只留一个播放时序真相源,并同步上游两处修复

机器审在本 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。

* fix(ai_engine): 母版预检的比例上限跟着交付画布走,非方画布不再判宽了

上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西: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>

* fix: rebase 到新 main 后取回被基座版覆盖的 ai_engine 实现

#179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了:
character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了
装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。

同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、
且 pyproject 删掉了 #179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。

现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。

* fix(ai_engine): 进度上报统一到一个刻度,不再倒退

评审实跑逮到的:generator 按 i/4 报,中间夹着的 strategy.derive 按 i/3 报到**同一个**
ProgressPort 上。消费方按 i/total 画条会看到倒退两次 —— route 25.0% → derive 0.0%、
derive 66.7% → lastmile 50.0%,totals 同时出现 3 和 4。一个量两个真相源,取哪个看
消费方心情,与本分片删掉 fps / loop / palette 是同一条理由。

改法取评审给的第一条(generator 传偏移):generator 独占全局刻度 total=10,strategy 的
子进度由 _BandProgress 线性映射进 derive 区间 [2,7]。

- strategy 侧零改动。适配器只读它每次调用时自报的 total,不要求它声明自己有几步——
  声明值与实际值又是一对可以对不上的真相源。也不让它知道外层有几步:它是可插拔件,
  各路线步数本就不同。
- 刻度取 10 不取 5,是为了给 derive 段留出中间刻度;否则子进度全落同一格,虽不倒退
  但最慢的那段整段不动。
- 修后序列:0 → 10 → 20 → 30 → 50 → 80 → 90%,单一 total,零倒退。

测试 +3:同一次生成只允许一个 total、进度非递减、子进度必须落在 derive 区间内且
区间内确实动过(只断言"不倒退"的话,把适配器换成"永远报区间起点"也能过)。这三条
打在修复前的代码上全部 FAIL,报的正是评审给的那两处倒退。

一处如实说明:ProgressPort 的 docstring 写的是"server 转 SSE / 轮询状态",但 #182 目前
唯一的实现是 executor.py:137 的 logger.info,SSE payload 里没有进度字段。所以今天这个
倒退只落在日志里,还没被用户看到 —— 也正因为没有活消费方依赖 total==4,才能直接改刻度
而不必兼容旧值。

Refs #171 #53

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from a2f50ca to e187d1f Compare August 12, 2026 07:04
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。1024XEngineer#181 合入 main 后
1024XEngineer#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs 1024XEngineer#171 · Refs 1024XEngineer#78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor Author

#181 合入后已重排(--onto 只重放本分支自己的 10 个提交,避开 squash 造成的 add/add 冲突),四条端点标记逐条核过没动,CI 六项全绿。

顺带修了 codecov:重排后 patch 分母变了,掉到 83.91%(目标 84.10%)。去看未覆盖行,最大缺口是 _fetch.py 的 46% —— 而那 14 行正是白名单放行之后的三条防线(不跟重定向 / 声明超限 / Content-Length 撒谎时边读边截),一条都没测过。补了 4 条(MockTransport 不联网),46% → 100%,patch 87.37%。变异测试验过这几条真能咬:分别关掉三条防护,三次都被逮到。

@huyanxius 你那条 CHANGES_REQUESTED 还挂着(端点已接回服务层那次修的),麻烦再看一眼。

johnnyzhang-eng and others added 11 commits August 12, 2026 16:17
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。
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。
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。
`_get_generator()` 里还留着 `GenRoute.PROC_IDLE: ProcIdleStrategy(...)`,而这两个都已
随「程序化待机放弃」删除。注入 generator 的测试走不到这条装配路径,所以测试全绿而真实
调用全崩。改为只装当前 GenRoute 真有的路线,并加一条漏装断言——将来新增枚举成员时会
在装配处立刻暴露,而不是等某个动作第一次被请求。

`_download_master` / `_download` 直接 `httpx.get(input.reference_image_urls[0])`,
而那个 URL 来自已认证请求的请求体。等于把服务器当跳板:打 loopback 绕过鉴权中间件、
读云实例元数据服务的临时凭证、探测私网拓扑;重定向还能把合法域名换成上述任意一种。

新增 `_fetch.fetch_own_media`:白名单(必须是 `storage_settings.download_base` 前缀)
+ 禁跟随重定向 + 响应体上限 16 MiB(边读边计数,不信 Content-Length)。

取白名单而非黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、169.254/16、
::1、fc00::/7 以及各种十进制/八进制/IPv6-mapped 写法,漏一条等于没做。而本业务只需拉
自家 bucket 的图(母版与参考图都先经 /media/upload 传上去)。代价是不能再传外部图床
链接——真要支持该走一个显式的「导入外部素材」入口,在那里做完整校验与配额。

`task_repo` 一律发 `task_update`,而 stream 的 `_TERMINAL_EVENTS = {"completed","failed"}`
永不匹配,于是终态 break 走不到。端点带 `retry: 3000`,浏览器原生 EventSource 每 3 秒
重连、每次重收同一条 completed。按状态映射事件名。

`publish` 被后台 daemon thread 调用(executor → task_repo),而队列属于处理 SSE 请求
的那个 loop。`asyncio.Queue` 不是线程安全的。订阅时记下所属 loop,发布时经
`call_soon_threadsafe` 回到那个 loop 再入队;loop 已关闭时静默丢弃(任务状态已落库,
重连后靠 GET /tasks/{id} 取,抛异常会把后台任务整个带崩)。

`num_images` 直通 provider 调用循环,请求模型不设上限——一个已认证请求填个大数就能绕过
按请求计的限流、把成本拉到无上限。加 `ge=1, le=4`;`num_frames` 加 `le=64`;宽高加
`64..2048`。

顺带删掉请求体里的 `user_id: int = Field(gt=0)`:端点已改从 `request.state.current_user`
取归属,这个字段既不被读、又让调用方以为能指定归属者——填别人的 id 不报错也不生效。

20 条回归用例。变异验证中**逮到自己两条摆设测试**并已重写:
- 终态事件名那条原先直接读 `_STATUS_EVENT` 字典,而变异改的是 `_publish_task_update`
  里的用法 → 改为注入假 bus、走真实调用路径断言发出的事件名;
- 跨线程那条证不出 `call_soon_threadsafe` 的必要性(实测裸 `put_nowait` 在单队列场景
  也能被 `get()` 取到,CPython 有元素时走快路径)→ 如实在 docstring 写明本用例强度,
  另补一条「订阅必须记下所属 loop」的结构断言,那条能杀死变异。

漏装断言那条变异存活是**预期**:当前两个路线都装满,`missing` 恒为空集,它是防未来
回归的守卫而非当前行为,测试锁的是「装满」这个事实。
对抗复查发现:width / height 从请求进到 CharacterImageInput、被 _validate_project_size
校验过,然后被丢掉 —— ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大。调用方
要 512×512、拿到 1024×1024,而请求被接受了。性质与本轮删掉的 ActionSpec.fps / loop 完全
相同,只是这次字段在入口侧。今天还给这两个字段加了 le=2048 上界,等于替它们背书。

模型本身不吃宽高,所以在编排层落实:复用已有的 _fit_to。给它加 smooth 参数——序列帧是
像素画必须 NEAREST(插值会把硬边糊成灰边并引入调色板外的颜色),全彩角色母版反过来,
NEAREST 缩图明显锯齿,用 LANCZOS。

同步上游(改动本体在 feat/provider-interfaces-and-matte):文生图路径改读配置里的
chat_completions_path;400/404 翻译成指向 GET {base}/models 的可操作错误。

测试 +4。变异测试:再把尺寸丢掉 2 条红,smooth 参数不接线 1 条红。其中重采样那条第一版
用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。
编排层此前对引擎交付的每一帧再做一次 _fit_to(png, sprite_w, sprite_h)。引擎恒出
256,项目要 512 时这就是二次重采样 —— 而实际后果比"糊一次"严重得多:_fit_to 用
Image.thumbnail,**thumbnail 只缩不放**。2026-08-11 复刻这段逻辑实测(喂主体高
157px、脚线 0.92 的 256 交付帧):

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

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

改法:把项目 sprite 尺寸作为 canvas 传给 generate,引擎一次出到位,那一步不存在了。

用归档角色「林间斥候」的真实抠图帧(1280×720,主体高中位 619px)端到端实测:

    canvas 不传        交付 256×256   主体高中位 159px
    canvas (256,256)   交付 256×256   主体高中位 159px(与不传**逐字节相同**)
    canvas (512,512)   交付 512×512   主体高中位 318px —— 倍数 2.000
    canvas (384,512)   交付 384×512   主体高与 (512,512) 完全相同(高度几何只看高)
    canvas (1024,1024) 交付 1024×1024 主体高中位 635px

顺带一条选型信息:源帧主体 619px,512 档交付 318px 仍是**下采样**(不引入插值糊),
1024 档 635px 已经越过源分辨率、是上采样,收益递减。

_fit_to 换成 _require_size:只核对、不补救。尺寸对不上说明生成侧没按 canvas 出帧,
该报错让人看见,而不是缩放补边把问题抹平、交付一批脚线错位的帧。_fit_to 本身保留,
角色母版那条路径(smooth=True)仍在用。

**接口影响**:CharacterGeneratorPort.generate 多了 canvas 入参,实现该 Protocol 的
测试替身必须跟着接。修 _SpyGenerator 时顺带发现一个假绿用例:该 spy 一直在传
GeneratedAction(fps=...),而该字段早已删除,构造直接 TypeError、任务其实被判 FAILED;
当时的用例只断言 seen_facing(在构造之前就赋了值)所以一直绿着。已一并修好,
test_project_perspective_constrains_facing 现在跑的是一次真正成功的任务。

变异测试(4 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  E1 不把项目尺寸传给引擎     → project_sprite_size_is_passed 等 2 条红
  E2 canvas 宽高接反          → non_square_sprite_size_passed 红
  E3 只传宽当成方形           → non_square_sprite_size_passed 红
  E4 尺寸不符时静默放行       → wrong_size_fails_instead_of_rescaled 红

**依赖上游分支**:canvas 入参由 feat/ai-engine-ports-and-strategy 的 013520f 提供,
后者又依赖 feat/ai-engine-frame-toolkit 的 5da358e。本分支尚未同步这两个提交,故
单独跑 pytest 会有 1 条 test_action_task_runs_end_to_end 失败(真实
CharacterGenerator 还不认 canvas)。在工作区先打上那两个提交再跑,CI 全绿:
ruff / lint-imports(2 contracts kept) / pytest 309 passed。同步后即恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
人工评审报的三条,逐条修:

一、越权订阅。stream 端点只按 task_id 订阅、不看是谁在订阅,任意已认证用户猜到 id
   就能拿到别人任务的实时进度与最终产物 URL(事件体带 result,即帧的对象存储地址)。
   同文件的 GET /tasks/{task_id} 有校验,stream 漏了,而两者从没被放在一起测过。
   校验放在 subscribe **之前**:放之后的话越权请求仍会在 EventBus 上挂一个订阅者,
   照样收事件、只是响应体被丢弃,订阅表还会因为没人 unsubscribe 而增长。

二、终态预检。原先是一行 TODO,而上一行的 docstring 已经承诺了该行为 —— 读文档的人
   不会发现,实际表现是客户端要先挂满 30 秒心跳超时才拿到终态。

三、project_id 声明为必填但从未被使用,归属判定的依据是任务自己的 user_id,不是调用方
   声称的项目。删掉后 fastapi.Query 也变成未使用 import,反过来印证它确实没有消费方。

顺带:事件 payload 从 task_repo 抽成公开的 task_event_payload()。终态预检是第二个发送
点,在 API 层再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的
同名事件。

测试 +9,5 条变异全部杀掉(去掉归属校验 / 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当成 completed)。

其中"payload 同形状"那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源,
删字段一起变、断言永远成立。改成把 SSE 事件体的键集写死成契约清单,并另加一条直接比
两条真实发送路径产出的用例。

过程中三处是测试自身写错、不是代码错,记下避免再犯:按 HTTP 状态码断言越权(本仓
BizException 统一以 200 + 业务码返回,把"校验生效"误判成"越权",且成功码是 200 不是 0);
create_task 签名靠猜;conftest 的建表清单里没有本 PR 新引入的 generation_task 表。
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 1024XEngineer#179 移除)。
rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

1024XEngineer#181 昨天修过同一处,当时没顺手检查 1024XEngineer#182
`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。1024XEngineer#181 合入 main 后
1024XEngineer#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs 1024XEngineer#171 · Refs 1024XEngineer#78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnnyzhang-eng
johnnyzhang-eng force-pushed the feat/generation-orchestrator branch from 22dc372 to 635897d Compare August 12, 2026 08:21
@huyanxius
huyanxius dismissed their stale review August 12, 2026 08:36

good job

@huyanxius
huyanxius merged commit b8e747e into 1024XEngineer:main Aug 12, 2026
7 checks passed
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.

5 participants