feat(generation): align validated SSE adapter with backend tasks - #110
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Review summary: two issues stand out.
Findings without inline locations
frontend/src/entities/generation/index.ts:102: The complete-animation result type only preserves URLs, but the backend action frame DTO already carriesduration_msand the frontendFramemodel can storedurationMs. Once this adapter maps the task intoGenerationResult, that timing data is gone and downstream character writes will have to invent defaults.
实测反馈:契约对得上,但终态不关流会无限重连;另外目前零 UI 消费者在集成分支( 1. 契约是对的,两处可以放心
2. 终态之后 EventSource 会每 3 秒重连一次,需要显式 close服务端发完终态事件就关流,但响应里带了 也就是说:任务早就结束了,前端还在每 3 秒打一次请求,且业务回调会被重复触发。 修法二选一(前者更稳):
3. 目前全仓没有调用方搜 |
b0bc0ce to
33b4863
Compare
33b4863 to
d8d171b
Compare
f1042ab to
3fcdceb
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
@fennoai 审核评价 |
审核完成已针对固定提交范围 发现 4 个问题:
验证:尝试运行 Generation/SSE 测试与类型检查,但工作区未安装可执行的 |
|
LGTM👍 |
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
**先更正我上一版的方向。** 上一版删掉了 `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 事件体键集写死为契约清单。
* 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(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(ai_engine): 对外契约 ports + 动作分流 strategy + 串联 CharacterGenerator 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 #35 #53。 impl/CharacterGenerator 选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。 与 #53 原设计的两处差异: 1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。 #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。 * fix(generation): 修机器审报的五处 P1 `_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` 恒为空集,它是防未来 回归的守卫而非当前行为,测试锁的是「装满」这个事实。 * fix(generation): 请求里的图片尺寸真的生效,并同步上游 provider 两处修复 对抗复查发现: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 条红。其中重采样那条第一版 用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。 * fix(app): 交付尺寸传给引擎出帧,不再拿到帧之后再缩一次 编排层此前对引擎交付的每一帧再做一次 _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> * fix(generation): SSE 订阅补归属校验与终态预检,去掉无消费方的 project_id 人工评审报的三条,逐条修: 一、越权订阅。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 表。 * fix(generation): SSE 归属补齐「任务属于项目」这一道,并落终态预检 **先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属 校验,并把 EventBus 改成单键。那是错的:主线 #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」会拿到空队列,主线 #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 事件体键集写死为契约清单。 * fix(generation): 三个端点接回服务层,并补上能发现"没接上"的测试 评审报的是实情:这三个端点在分支上还是 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 面已随 #179 移除)。 * fix(deps): framework 依赖声明与 lock 取回 main 版 rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了 bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。 #181 昨天修过同一处,当时没顺手检查 #182。 * test(orchestrator): 补齐白名单取图的下载段 —— 三条防线此前一行没测 `codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。#181 合入 main 后 #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 #171 · Refs #78 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>
本次更新
character_image与character_action;首帧和完整动画通过num_frames(1 / 32)区分,不再另造任务类型。user_id,并校验返回任务的用户、项目、任务 ID、类型、状态和终态结果。task_id与id,统一转换成前端taskId。依赖说明
当前
main中已合并的旧 WorkflowController 仍使用旧 Generation 名称。本 PR 保持后端命名,不在适配器内重新引入旧概念;Controller 调用方需按新的character_image/character_action契约同步更新。验证