diff --git a/.gitignore b/.gitignore
index 65fbc3fd..39bf5e20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,6 @@ output/
.ruff_cache/
.pytest_cache/
.import_linter_cache/
+
+# 本地数据库初始化脚本
+init.sql
diff --git a/README.md b/README.md
index 947814de..58c325b6 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,2 @@
-
-
-
-
-Windup
-
-
- 面向国产小游戏开发者的 2D 角色动态素材生成与资产工作台
-
-
-交付的是资产,而不是图片。
-
-Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧质检、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。
-
-## 产品链路 / Product Workflow
-
-```text
-新角色:文字描述 / 参考图 → 项目约束 → 角色母版
-已有角色:从资产库继续生产 ─────────────┘
- ↓
- 动作序列帧 → 逐帧审核 / 局部重生成
- ↓
- Playtest 试玩 → PNG / Sprite Sheet / 元数据 → 游戏引擎
-```
-
-Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体帧或节点,已通过的结果继续保留。
-
-## 核心对象 / Core Concepts
-
-| 对象 | 职责 |
-| --- | --- |
-| `Project` | 统一管理题材、美术风格、视角与精灵尺寸等项目级约束 |
-| `Character` | 角色资产本体;造型、动作实例与帧属于它的资产树 |
-| `ActionTemplate` | 可在不同角色间复用的动作规格与生产配方 |
-| `Generation` | 一次生成任务及其输入、状态和结果,用于恢复与追溯 |
-| `WorkflowRun` | 一次前端制作流程的运行记录,连接生成、确认、回退与导出 |
-
-产品提供两种入口:`Quick Start` 用自然语言建立标准生产流程;`Workflow Editor` 在系统预置的成熟管线上追加动作分支、微调参数和局部返工。两者共用同一套流程状态和质量门禁,分别服务快速创建与精细控制。
-
-## 当前阶段 / Project Status
-
-MS2 已完成 Windup 的产品 MVP,验证了角色资产生产的核心链路。MS3 的重点从“完成一次生成”转向“持续完善已有角色资产”:用户可以从资产库回到已有角色,为它补充动作、重做有问题的分支,并保留未受影响的资产。
-
-| 状态 | 内容 |
-| --- | --- |
-| MS2 产出 | 完成产品 MVP,跑通并验证角色资产生产的核心体验 |
-| MS3 产品主线 | 已有角色补动作;工作流采用固定成熟管线,通过卡片加号追加分支,支持参数微调与局部重跑 |
-| MS3 工程重点 | 持久化 `WorkflowRun` 并关联角色,串起工作流编辑、产物审核、节点回退与 Playtest |
-| 后续探索 | Quick Start Agent、3D 动作生成路线、多视角资产与项目级导出 |
-
-项目进度见 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues)。
-
-## 技术栈 / Tech Stack
-
-- 前端:React 19、TypeScript 6、Vite 8、Tailwind CSS 4、Vitest
-- 后端:Python 3.12、FastAPI、Pydantic、SQLAlchemy、uv workspace
-- 工程约束:GitHub Actions、Ruff、Pytest、Import Linter、oxlint、oxfmt
-
-## 本地开发 / Local Development
-
-前端支持 Node.js `^20.19.0`、`^22.12.0` 或 `>=24.0.0`;CI 使用 Node.js 24:
-
-```bash
-cd frontend
-npm ci
-npm run dev
-```
-
-后端使用 Python 3.12 和 [uv](https://docs.astral.sh/uv/):
-
-```bash
-cd backend
-uv sync --frozen
-uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload
-```
-
-## 质量检查 / Quality Checks
-
-```bash
-# frontend/
-npm run format:check
-npm run lint
-npm run typecheck
-npm run test
-npm run build
-
-# backend/
-uv run ruff check .
-uv run lint-imports
-uv run pytest -q
-```
-
-## 仓库结构 / Repository Structure
-
-```text
-Windup/
-├── frontend/ # React 前端、页面与制作流程
-├── backend/ # Python 工作区、领域服务与 API
-├── docs/ # 后端模块划分等工程文档
-├── frontend-architecture-v3.md
-└── README.md
-```
-
-## 相关文档 / Documentation
-
-- [Windup 产品策划案](https://github.com/1024XEngineer/Windup/issues/37)
-- [核心流程与工作流](https://github.com/1024XEngineer/Windup/issues/25)
-- [前端架构与模块边界](frontend-architecture-v3.md)
-- [前后端 API 契约差异](frontend/API_CONTRACT.md)
-- [后端模块划分](docs/module-split.md)
-
-## 参与贡献 / Contributing
-
-问题、需求和实验记录统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。
-
-项目的维护与历史贡献见 [Contributors](https://github.com/1024XEngineer/Windup/graphs/contributors)。
-
-## 许可证 / License
-
-[Apache License 2.0](LICENSE)
+# game-asset-character
+Generate high-quality 2D game characters.
diff --git a/api-reference.md b/api-reference.md
new file mode 100644
index 00000000..ea910d80
--- /dev/null
+++ b/api-reference.md
@@ -0,0 +1,532 @@
+# Windup API 接口文档
+
+> **Base URL**: `http://127.0.0.1:8000`
+> **Content-Type**: `application/json`(除文件上传外)
+> **最后更新**: 2026-07-30
+
+---
+
+## 目录
+
+1. [项目管理 (Projects)](#1-项目管理-projects)
+2. [角色管理 (Characters)](#2-角色管理-characters)
+3. [媒体上传 (Media)](#3-媒体上传-media)
+4. [生成任务 (Generation)](#4-生成任务-generation)
+5. [通用说明](#5-通用说明)
+
+---
+
+## 1. 项目管理 (Projects)
+
+### 1.1 创建项目
+
+**`POST /projects`**
+
+| 参数 | 类型 | 必填 | 校验 | 说明 |
+|---|---|-|---|-------------------------|
+| `user_id` | int | ✅ | `>0` | 用户 ID |
+| `project_name` | string | ✅ | `1~20字符` | 项目名称(同用户下不可重复) |
+| `character_perspective` | int | ✅ | `1~3` | 角色视角(1=侧视, 2=正面, 3=正面) |
+| `directional_movement` | int | ✅ | `1~3` | 方向移动方式 (1=单向,2=四向,3=八向) |
+| `sprite_width` | int | ✅ | `32~2048` | 精灵图宽度 |
+| `sprite_height` | int | ✅ | `32~2048` | 精灵图高度 |
+| `workflow_id` | int \| null | | — | 工作流 ID |
+| `game_style` | string \| null | | — | 游戏风格 |
+| `sprite_sample_url` | string \| null | — | 精灵图示例 URL |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "创建成功",
+ "data": {
+ "id": 6,
+ "user_id": 1,
+ "project_name": "像素勇者",
+ "character_perspective": 1,
+ "directional_movement": 1,
+ "sprite_width": 256,
+ "sprite_height": 256,
+ "workflow_id": null,
+ "game_style": null,
+ "sprite_sample_url": null,
+ "create_at": "2026-07-30T10:00:00Z",
+ "update_at": "2026-07-30T10:00:00Z"
+ }
+}
+```
+
+**错误**:项目名重复返回 `400`。
+
+---
+
+### 1.2 项目列表
+
+**`GET /projects`**
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|-|---|---|
+| `user_id` | int \| null | null | 按用户筛选 |
+| `page` | int | 1 | 页码(≥1) |
+| `page_size` | int | 20 | 每页条数(1~100) |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "success",
+ "data": [
+ {
+ "id": 6,
+ "user_id": 1,
+ "project_name": "像素勇者",
+ "character_perspective": 1,
+ "directional_movement": 1,
+ "sprite_width": 256,
+ "sprite_height": 256,
+ "create_at": "2026-07-30T10:00:00Z",
+ "update_at": "2026-07-30T10:00:00Z"
+ }
+ ],
+ "total": 1,
+ "page": 1,
+ "page_size": 20
+}
+```
+
+---
+
+### 1.3 获取项目详情
+
+**`GET /projects/{project_id}`**
+
+| 参数 | 类型 | 位置 | 说明 |
+|---|---|---|---|
+| `project_id` | int | path | 项目 ID |
+
+**返回**:单个 `ProjectOut` 对象(结构同列表项)。
+
+---
+
+### 1.4 删除项目
+
+**`DELETE /projects/{project_id}`**
+
+| 参数 | 类型 | 位置 | 说明 |
+|---|---|---|---|
+| `project_id` | int | path | 项目 ID |
+
+**返回**:
+
+```json
+{
+ "code": 200,
+ "message": "删除成功",
+ "data": null
+}
+```
+
+---
+
+## 2. 角色管理 (Characters)
+
+### 2.1 创建角色
+
+**`POST /characters`**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `project_id` | int | ✅ | 所属项目 ID |
+| `description` | string \| null | ❌ | 角色描述 |
+| `reference_image_url` | string \| null | ❌ | 角色参考图 URL |
+| `character_data` | object | ❌ | 角色完整数据(见下方结构) |
+
+**`character_data` 结构**:
+
+```json
+{
+ "version": 1,
+ "outfits": [
+ {
+ "id": "outfit_01",
+ "name": "默认套装",
+ "description": "初始装备",
+ "preview_url": "http://...",
+ "actions": [
+ {
+ "id": "walk_01",
+ "type": "walk",
+ "name": "走路",
+ "loop": true,
+ "fps": 12,
+ "frame_count": 8,
+ "frames": [
+ {
+ "index": 0,
+ "image_url": "http://...",
+ "duration_ms": 125
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
+```
+
+**`character_data` 字段说明**:
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `version` | int | ❌ | 1 | 数据版本号 |
+| `outfits` | list | ❌ | [] | 套装列表 |
+
+**`outfits[]` 字段说明**:
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | string | ✅ | 套装唯一 ID |
+| `name` | string | ✅ | 套装名称 |
+| `description` | string \| null | ❌ | 套装描述 |
+| `preview_url` | string \| null | ❌ | 套装预览图 URL |
+| `actions` | list | ❌ | 动作列表 |
+
+**`outfits[].actions[]` 字段说明**:
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `id` | string | ✅ | — | 动作唯一 ID |
+| `type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` |
+| `name` | string | ✅ | — | 动作名称 |
+| `loop` | bool | ❌ | false | 是否循环播放 |
+| `fps` | float | ❌ | 12 | 帧率(>0) |
+| `frame_count` | int | ❌ | 0 | 帧数(≥0) |
+| `frames` | list | ❌ | [] | 帧列表 |
+
+**`actions[].frames[]` 字段说明**:
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `index` | int | ✅ | 帧序号(从0开始) |
+| `image_url` | string | ✅ | 帧图片 URL |
+| `duration_ms` | int \| null | ❌ | 单帧时长(毫秒) |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "创建成功",
+ "data": {
+ "id": 1,
+ "project_id": 6,
+ "description": "武士角色",
+ "reference_image_url": "http://...",
+ "character_data": { "version": 1, "outfits": [] },
+ "status": 1
+ }
+}
+```
+
+---
+
+### 2.2 角色列表
+
+**`GET /characters`**
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `project_id` | int | ✅ | — | 所属项目 ID |
+| `page` | int | ❌ | 1 | 页码 |
+| `page_size` | int | ❌ | 20 | 每页条数(1~100) |
+
+**返回**:`ListResponse[CharacterOut]`,结构同项目列表。
+
+---
+
+### 2.3 获取角色详情
+
+**`GET /characters/{character_id}`**
+
+| 参数 | 类型 | 位置 | 说明 |
+|---|---|---|---|
+| `character_id` | int | path | 角色 ID |
+
+**返回**:单个 `CharacterOut` 对象。
+
+---
+
+### 2.4 更新角色
+
+**`PATCH /characters/{character_id}`**
+
+> 只传需要修改的字段即可,未传的字段不修改。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `description` | string \| null | ❌ | 角色描述 |
+| `reference_image_url` | string \| null | ❌ | 参考图 URL |
+| `character_data` | object \| null | ❌ | 完整角色数据(同创建) |
+
+**返回**:更新后的 `CharacterOut`。
+
+---
+
+### 2.5 删除角色
+
+**`DELETE /characters/{character_id}`**
+
+| 参数 | 类型 | 位置 | 说明 |
+|---|---|---|---|
+| `character_id` | int | path | 角色 ID |
+
+**返回**:
+
+```json
+{
+ "code": 200,
+ "message": "删除成功",
+ "data": null
+}
+```
+
+---
+
+## 3. 媒体上传 (Media)
+
+### 3.1 上传图片
+
+**`POST /media/upload`**
+
+> Content-Type: `multipart/form-data`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `file` | File | ✅ | 图片文件(只接受 `image/*`) |
+| `category` | string | ❌ | 分类:`reference-image` / `outfit-preview` / `action-frame` / `general`(默认 `general`) |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "上传成功",
+ "data": {
+ "url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/abc123.png",
+ "object_key": "media/reference-image/abc123.png",
+ "filename": "knight.png",
+ "content_type": "image/png",
+ "size": 16140
+ }
+}
+```
+
+**错误**:非图片文件返回 `400`。
+
+---
+
+## 4. 生成任务 (Generation)
+
+> 生成任务均为**异步**:先创建任务记录返回 `id`,前端轮询 `GET /generation/tasks/{id}` 获取状态和结果。
+
+### 4.1 提交图片生成任务
+
+**`POST /generation/image`**
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `user_id` | int | ✅ | — | 用户 ID |
+| `project_id` | int \| null | ❌ | null | 项目 ID |
+| `reference_image_url` | string \| null | ❌ | null | 参考图 URL(可选,纯文生图可不传) |
+| `prompt` | string | ❌ | "" | 生成提示词 |
+| `negative_prompt` | string | ❌ | "" | 反向提示词 |
+| `width` | int | ❌ | 1024 | 输出宽度 |
+| `height` | int | ❌ | 1024 | 输出高度 |
+| `num_images` | int | ❌ | 1 | 生成数量 |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "任务已提交",
+ "data": {
+ "id": 9,
+ "user_id": 1,
+ "project_id": 6,
+ "task_type": "character_image",
+ "status": "pending",
+ "input_payload": {
+ "reference_image_url": null,
+ "prompt": "帮我生成一个穿着日本和服的女人",
+ "negative_prompt": "",
+ "width": 256,
+ "height": 256,
+ "num_images": 1
+ },
+ "result": {"image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/9516edb3261e45c39362e0a49e184fe1.png"},
+ "error_message": null
+ }
+}
+```
+
+---
+
+### 4.2 提交动作生成任务
+
+**`POST /generation/action`**
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `user_id` | int | ✅ | — | 用户 ID |
+| `project_id` | int \| null | ❌ | null | 项目 ID |
+| `character_id` | int | ✅ | — | 角色 ID |
+| `action_type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` |
+| `custom_prompt` | string \| null | ❌ | null | 自定义提示词 |
+| `reference_video_url` | string \| null | ❌ | null | 参考视频 URL |
+| `reference_image_urls` | list[string] | ❌ | [] | 参考图 URL 列表(第一张作为母版) |
+| `num_frames` | int | ❌ | 16 | 生成帧数 |
+
+**返回示例**:
+
+```json
+{
+ "code": 200,
+ "message": "任务已提交",
+ "data": {
+ "id": 15,
+ "user_id": 1,
+ "project_id": 6,
+ "task_type": "character_action",
+ "status": "pending",
+ "input_payload": {
+ "character_id": 1,
+ "action_type": "walk",
+ "custom_prompt": null,
+ "reference_image_urls": ["http://..."],
+ "num_frames": 8
+ },
+ "result": {"frames": [{"index": 0, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/35069fad379f4623be7e0bbdd389e6a9.png", "duration_ms": 125}, {"index": 1, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a25b81d26b7e42f49be05bbe2a2bf131.png", "duration_ms": 125}, {"index": 2, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/b39b7ae9d7a34bf1b022d38f6e149851.png", "duration_ms": 125}, {"index": 3, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9e5305bd74124f908459a45dbc7163b5.png", "duration_ms": 125}, {"index": 4, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/31331423ff974617a4f68c6e3dd93220.png", "duration_ms": 125}, {"index": 5, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/48e682d778dd4cc9b7b579f355a4896f.png", "duration_ms": 125}, {"index": 6, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cdbb141f59ed40e8816cd68a25a82d30.png", "duration_ms": 125}, {"index": 7, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/f322162bd25847819a153edd0074c938.png", "duration_ms": 125}], "action_type": "walk"},
+ "error_message": null
+ }
+}
+```
+
+---
+
+### 4.3 查询生成任务
+
+**`GET /generation/tasks/{task_id}`**
+
+| 参数 | 类型 | 位置 | 必填 | 说明 |
+|---|---|---|---|---|
+| `task_id` | int | path | ✅ | 任务 ID |
+| `project_id` | int | query | ✅ | 项目 ID |
+
+**状态流转**:`pending` → `running` → `completed` / `failed`
+
+**completed 时的 result 结构**:
+
+- **图片任务** (`character_image`):
+
+```json
+{
+ "result": {
+ "type": "character_image",
+ "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/xxx.png"
+ }
+}
+```
+
+- **动作任务** (`character_action`):
+
+```json
+{
+ "result": {
+ "type": "character_action",
+ "action_type": "walk",
+ "frames": [
+ {
+ "index": 0,
+ "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/xxx.png",
+ "duration_ms": 125
+ },
+ {
+ "index": 1,
+ "image_url": "http://...",
+ "duration_ms": 125
+ }
+ ]
+ }
+}
+```
+
+**failed 时**:
+
+```json
+{
+ "status": "failed",
+ "error_message": "具体错误信息",
+ "result": null
+}
+```
+
+---
+
+## 5. 通用说明
+
+### 5.1 统一响应格式
+
+**单条数据** `Response[T]`:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `code` | int | 业务状态码(200=成功) |
+| `message` | string | 状态消息 |
+| `data` | T \| null | 业务数据 |
+
+**列表数据** `ListResponse[T]`:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `code` | int | 业务状态码 |
+| `message` | string | 状态消息 |
+| `data` | list[T] | 数据列表 |
+| `total` | int | 总条数 |
+| `page` | int | 当前页 |
+| `page_size` | int | 每页条数 |
+
+### 5.2 错误码
+
+| HTTP 状态码 | 说明 |
+|---|---|
+| 200 | 成功 |
+| 400 | 请求参数错误 |
+| 404 | 资源不存在 |
+
+### 5.3 枚举值
+
+**动作类型 `action_type`**:`walk` / `idle` / `attack` / `custom`
+
+**媒体分类 `category`**:`reference-image` / `outfit-preview` / `action-frame` / `general`
+
+**任务状态 `status`**:`pending` → `running` → `completed` / `failed`
+
+### 5.4 生成任务轮询建议
+
+```javascript
+// 前端轮询示例
+async function pollTask(taskId, projectId) {
+ while (true) {
+ const res = await fetch(`/generation/tasks/${taskId}?project_id=${projectId}`);
+ const { data } = await res.json();
+
+ if (data.status === 'completed') return data.result;
+ if (data.status === 'failed') throw new Error(data.error_message);
+
+ await new Promise(r => setTimeout(r, 2000)); // 2秒轮询
+ }
+}
+```
diff --git a/backend/init_db.py b/backend/init_db.py
new file mode 100644
index 00000000..0c64045b
--- /dev/null
+++ b/backend/init_db.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""初始化数据库并启动后端服务。
+
+使用 SQLite 作为开发数据库,避免依赖 PostgreSQL。
+"""
+
+import os
+
+from windup_framework.config.database import resolve_sqlite_path
+
+# 所有入口共用 framework 层的解析规则,避免从不同目录启动时创建同名空库。
+os.environ["SQLITE_PATH"] = str(resolve_sqlite_path(os.getenv("SQLITE_PATH", "windup.db")))
+
+def init_database():
+ """初始化数据库,创建所有表。"""
+ from windup_framework.db.base import Base
+ from windup_framework.db.session import engine
+
+ # 注册所有 ORM 模型后,Base.metadata 才包含完整表结构。
+ from windup_app.server.character.model import Character # noqa: F401
+ from windup_app.server.generation.model import GenerationTaskRecord # noqa: F401
+ from windup_app.server.playtest_inspection.model import PlaytestInspection # noqa: F401
+ from windup_app.server.project.model import Project # noqa: F401
+
+ print("正在初始化数据库...")
+ Base.metadata.create_all(engine)
+ print("数据库初始化完成!")
+
+
+def main():
+ """主函数:初始化数据库并启动后端服务。"""
+ # 初始化数据库
+ init_database()
+
+ # 启动后端服务
+ print("正在启动后端服务...")
+ from windup_app.bootstrap.app import main as start_server
+
+ start_server()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/packages/ai_engine/pyproject.toml b/backend/packages/ai_engine/pyproject.toml
index bb279425..84492ec8 100644
--- a/backend/packages/ai_engine/pyproject.toml
+++ b/backend/packages/ai_engine/pyproject.toml
@@ -10,6 +10,8 @@ dependencies = [
"langchain-core>=0.3",
"pillow>=10.4",
"numpy>=1.26",
+ "imageio>=2.36",
+ "av>=14.0", # imageio pyav 后端(视频抽帧)
# "rembg", # 抠图(按需启用)
]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py
new file mode 100644
index 00000000..456b868a
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py
@@ -0,0 +1,5 @@
+"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。"""
+
+from .character_generator import CharacterGenerator
+
+__all__ = ["CharacterGenerator"]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py
new file mode 100644
index 00000000..ecdfd6cf
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py
@@ -0,0 +1,88 @@
+"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。
+
+这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。
+串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。
+
+MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储——
+上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。
+"""
+from __future__ import annotations
+
+import io
+
+from PIL import Image
+
+from windup_common.models import ActionSpec, CharacterCard, GenRoute
+
+from windup_ai_engine.ports import (
+ CharacterGeneratorPort,
+ GeneratedAction,
+ ProgressPort,
+)
+from windup_ai_engine.postprocess import align_bottom_center, frame_durations
+from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy
+
+
+def _png(img: Image.Image) -> bytes:
+ buf = io.BytesIO()
+ img.convert("RGBA").save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _img(png: bytes) -> Image.Image:
+ return Image.open(io.BytesIO(png)).convert("RGBA")
+
+
+class CharacterGenerator(CharacterGeneratorPort):
+ """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。"""
+
+ def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None:
+ self._by_route = strategies
+
+ def generate(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> GeneratedAction:
+ # ① 选路线(架构决策矩阵)
+ route = ROUTE_MATRIX[action.action]
+ progress.step("route", 0, 3, f"{action.action} → {route.value}")
+ strategy = self._by_route[route]
+
+ # ② 生成帧(交给 strategy —— 串联)
+ frames = strategy.derive(card, action, master, progress)
+
+ # ③ 最后一公里:脚线对齐成原地序列帧
+ frames = self._lastmile(frames, progress)
+
+ # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧)
+ progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长")
+ return GeneratedAction(
+ frames=frames,
+ durations=frame_durations(action.action.value, len(frames)),
+ fps=action.fps,
+ )
+
+ def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]:
+ """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。
+
+ 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段):
+ 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。
+ """
+ progress.step("lastmile", 1, 3, "脚线对齐(原地)")
+ if not frames or not all(frames): # 含空桩帧(未开发路线)→ 跳过
+ return frames
+ imgs = [_img(f) for f in frames]
+ # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏),
+ # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。
+ import numpy as _np
+ _hs = []
+ for _im in imgs:
+ _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128)
+ if len(_ys):
+ _hs.append(float(_ys.max() - _ys.min()))
+ aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None))
+ # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧)
+ return [_png(im) for im in aligned]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py
new file mode 100644
index 00000000..ed2652e3
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py
@@ -0,0 +1,82 @@
+"""母版规格与预处理:每个动作需要什么样的母版。
+
+**核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。**
+ - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。
+ - jump:母版**顶部留白**才不被视频画面裁掉。
+ - attack:必须给**极限蓄力母版**(武器已拉到身后腰际)。用站立母版时,即使提示词写死
+ "武器不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作
+ 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。
+
+
+实测教训:母版里角色居中、占 ~70% 画面高时,i2v 跳跃会让角色**头顶顶出视频画面上沿**
+被裁掉(生成本身没错,是构图没留够空间)。规则同 MasterSpec 的"运动方向多留白":
+ - jump:向上运动 → 顶部补空间,角色坐低
+ - dash / walk / run:向右位移 → 前进方向多留白(由母版生成时构图保证,此处不改)
+
+纯 PIL,零 API。背景色取母版四角中位色,补出来的边与母版底色一致。
+"""
+
+from __future__ import annotations
+
+import io
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"]
+
+# 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。
+MASTER_POSES = {
+ "walk": "", # 中性站立即可,但必须朝侧向
+ "run": "",
+ "idle": "",
+ # jump:与 attack 同理——重甲带剑角色的"跳跃"强动作先验压不住(站立母版会让模型摆
+ # 造型、只举剑不腾空,实测)。给**极限蓄力半蹲母版**,模型只能"接着往上蹬"。顶部留白
+ # 由 prepare_master(add_headroom)保证。
+ "jump": (
+ "deep crouch coiled to spring straight upward: the knees bent low and the hips sunk down, "
+ "both arms drawn back behind the body, the weight loaded onto both legs at the very moment "
+ "before springing straight up, the weapon kept in a fixed grip; "
+ "leave generous empty space above the head"
+ ),
+ "attack": (
+ "extreme wind-up stance for a horizontal slash: the weapon drawn far BACK behind the body "
+ "at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, "
+ "both arms low and pulled back, the weapon staying BELOW the shoulders; "
+ "leave generous empty space on the swing side"
+ ),
+}
+
+
+def _bg_color(img: Image.Image) -> tuple[int, int, int]:
+ """取四角中位色当背景色(母版通常是纯色底)。"""
+ rgb = np.asarray(img.convert("RGB"))
+ corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
+ return tuple(int(v) for v in np.median(corners, axis=0))
+
+
+def add_headroom(master: bytes, ratio: float = 0.6) -> bytes:
+ """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。
+
+ Args:
+ master: 母版图 bytes。
+ ratio: 处理后角色所占的画面高度比例(越小头顶空间越多)。0.6 表示角色高度
+ 约占新画面的 60%,上方留约 40%。
+ """
+ if not 0.1 < ratio < 1.0:
+ raise ValueError("ratio 需在 (0.1, 1.0) 之间")
+ img = Image.open(io.BytesIO(master)).convert("RGB")
+ new_h = max(img.height + 1, int(round(img.height / ratio)))
+ canvas = Image.new("RGB", (img.width, new_h), _bg_color(img))
+ canvas.paste(img, (0, new_h - img.height)) # 原图贴底,空间加在顶部
+ buf = io.BytesIO()
+ canvas.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def prepare_master(master: bytes, action: str) -> bytes:
+ """按动作类型预处理母版;不需要处理的动作原样返回。"""
+ if action in ("jump", "attack"):
+ # jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶)
+ return add_headroom(master, ratio=0.62 if action == "jump" else 0.70)
+ return master
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
new file mode 100644
index 00000000..efe91c64
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
@@ -0,0 +1,59 @@
+"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。
+
+CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。
+换掉内部实现(strategy / provider)时 server 零改动。
+
+MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。
+母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入;
+产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+from windup_common.models import ActionSpec, CharacterCard
+
+
+# ---- server 实现、注入给 ai_engine 的进度回调 port ----
+class ProgressPort(Protocol):
+ """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。"""
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None: ...
+
+
+# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)----
+@dataclass
+class GeneratedAction:
+ """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长。
+
+ frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成
+ ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。
+ """
+
+ frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序
+ durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长
+ fps: int = 10
+
+
+# ---- ai_engine 暴露给 server(server 调用的唯一入口)----
+@runtime_checkable
+class CharacterGeneratorPort(Protocol):
+ """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。
+
+ 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。
+
+ Args:
+ card: 角色卡(身份 / 画风 / 朝向)。
+ action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。
+ master: 定妆母版图 bytes(server 从 reference_image_url 取)。
+ progress: 进度回调。
+ """
+
+ def generate(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> GeneratedAction: ...
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py
new file mode 100644
index 00000000..e69f0ec7
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py
@@ -0,0 +1,28 @@
+"""后处理:把选好的帧落地成交付级序列帧(像素化 / 对齐 / 打包)。
+
+抽帧 / 选帧见 :mod:`..slicing`。逐帧时长 ``frame_durations`` 在 :mod:`.rootmotion`。
+"""
+
+from .rootmotion import DEFAULT_FPS_MS, extract_root_motion, frame_durations
+from .pixelate import (
+ detect_pixel_size,
+ extract_palette,
+ master_pixel_spec,
+ pixelate_frames,
+ to_pixel_art,
+)
+from .pack import align_bottom_center, save_gif, sprite_sheet
+
+__all__ = [
+ "to_pixel_art",
+ "pixelate_frames",
+ "detect_pixel_size",
+ "extract_palette",
+ "master_pixel_spec",
+ "extract_root_motion",
+ "frame_durations",
+ "DEFAULT_FPS_MS",
+ "align_bottom_center",
+ "sprite_sheet",
+ "save_gif",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py
new file mode 100644
index 00000000..1babf2b7
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py
@@ -0,0 +1,95 @@
+"""对齐 / 打包(后处理的收尾:脚线对齐 → sprite sheet / gif)。
+
+抽帧 / 选帧见 :mod:`..slicing`,像素化见 :mod:`.pixelate`,抠图见 framework 的
+MatteProvider(#20)。本模块把对齐后的帧拼成交付物。
+"""
+
+from __future__ import annotations
+
+from PIL import Image
+
+__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"]
+
+
+def align_bottom_center(
+ frames: list[Image.Image],
+ cell: int = 256,
+ foot_line: float = 0.92,
+ fill_h: float = 0.62,
+ preserve_lift: bool = False,
+ ref_height: float | None = None,
+) -> list[Image.Image]:
+ """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。
+
+ **整段共用一个缩放系数**(取全序列最高帧定标),不逐帧归一化 —— 逐帧各自缩放到等高
+ 会把走路自然的身高起伏(实测约 4%)反向变成"忽大忽小":蹲下的帧被放大、伸展的帧被
+ 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。
+
+ 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底);
+ 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。
+
+ ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本
+ 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时
+ 斧头高举使 bbox 从 485 涨到 660,角色本体因此明显变小;跳跃顶点同理。故传入**参考姿态**
+ (站立)的高度,各动作即共用同一本体尺寸。``fill_h`` 默认 0.62,给举过头顶留出余量。
+
+ ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把
+ 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。
+ """
+ import numpy as np
+
+ boxes: list[tuple[int, int, int, int] | None] = []
+ for f in frames:
+ ys, xs = np.where(np.asarray(f)[:, :, 3] > 128)
+ boxes.append(
+ (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
+ if len(ys)
+ else None
+ )
+ heights = [b[3] - b[1] for b in boxes if b]
+ if not heights:
+ return [Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) for _ in frames]
+ # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量
+ ground = max(b[3] for b in boxes if b) if preserve_lift else 0
+ # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉
+ if preserve_lift:
+ need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b)
+ scale = (cell * fill_h) / max(1, need)
+ elif ref_height:
+ scale = (cell * fill_h) / ref_height # 参考姿态定标(跨动作一致)
+ else:
+ scale = (cell * fill_h) / max(heights) # 回退:本序列最高帧
+
+ out = []
+ for f, box in zip(frames, boxes):
+ if box is None:
+ out.append(Image.new("RGBA", (cell, cell), (0, 0, 0, 0)))
+ continue
+ crop = f.crop(box)
+ w = max(1, round(crop.width * scale))
+ h = max(1, round(crop.height * scale))
+ crop = crop.resize((w, h), Image.NEAREST)
+ lift = round((ground - box[3]) * scale) if preserve_lift else 0
+ canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0))
+ canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift))
+ out.append(canvas)
+ return out
+
+
+def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image:
+ """横向拼接为 sprite sheet。"""
+ if not frames:
+ raise ValueError("frames 为空")
+ w, h = frames[0].size
+ sheet = Image.new("RGBA", (w * len(frames), h), bg)
+ for i, f in enumerate(frames):
+ sheet.alpha_composite(f.convert("RGBA"), (i * w, 0))
+ return sheet
+
+
+def save_gif(frames: list[Image.Image], path: str, duration: int = 120) -> None:
+ """导出循环 gif 供预览。"""
+ if not frames:
+ raise ValueError("frames 为空")
+ rgba = [f.convert("RGBA") for f in frames]
+ rgba[0].save(path, save_all=True, append_images=rgba[1:], duration=duration, loop=0, disposal=2)
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py
new file mode 100644
index 00000000..4910fa96
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py
@@ -0,0 +1,252 @@
+"""像素化后处理:把生成帧转成脆边限色的像素精灵。
+
+视频路线实测(Issue #35):
+- i2v 能解决步态(腿真交替、不转身);对**插画风**角色它保留插画质感 → 需要像素化转风格。
+- 对**原生像素**角色 i2v 其实能保住像素感,但链路上两道有损压缩(首帧 JPG q90 + 视频 H.264)
+ 会在硬边处产生振铃噪点(表现为灰颗粒),像素越细越明显;而通用的"降采样 + 32 色量化"
+ 因为**网格对不齐**反而更糊。
+- 解法:有母版时按 :func:`master_pixel_spec` 量出母版的**原生像素块大小**与**真实色板**,
+ 按母版网格降采样 + 颜色吸附回母版色板 —— 压缩灰颗粒不属于色板,会被强制消掉。
+
+纯 Pillow / numpy,零 API、秒级,符合"本机只做轻量 CV"的算力约束。
+输入约定:RGBA 图(alpha 为主体掩码,抠图见 framework 的 MatteProvider / Issue #20)。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = [
+ "to_pixel_art",
+ "pixelate_frames",
+ "detect_pixel_size",
+ "extract_palette",
+ "master_pixel_spec",
+]
+
+
+def _content_bbox(rgba: Image.Image, alpha_thr: int = 128) -> tuple[int, int, int, int]:
+ """求主体包围盒。
+
+ 用 :func:`_subject_mask` 而非只看 alpha:母版常是**不透明白底**,只看 alpha 会把整张
+ 画布当主体,导致逻辑像素高被算成整图高而非角色高(实测踩过)。
+ """
+ mask = _subject_mask(rgba.convert("RGBA"), alpha_thr)
+ ys, xs = np.where(mask)
+ if len(ys):
+ return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
+ return 0, 0, rgba.width, rgba.height
+
+
+def _axis_block_size(crop: np.ndarray, axis: int, min_delta: int, min_frac: float) -> int:
+ """沿 ``axis`` 估块边长:显著色变位置 → 合并相邻 → 取最常见间距。"""
+ d = np.abs(np.diff(crop, axis=axis)).sum(axis=2)
+ frac = (d > min_delta).mean(axis=1 - axis)
+ edges = np.flatnonzero(frac > min_frac) + 1
+ if len(edges) < 3:
+ return 1
+ # 块边界常因轻微抗锯齿占相邻两行/列,合并成一条,否则 gap=1 会淹没真实值
+ edges = edges[np.concatenate([[True], np.diff(edges) > 1])]
+ gaps = np.diff(edges)
+ gaps = gaps[gaps >= 2]
+ return int(np.bincount(gaps).argmax()) if len(gaps) else 1
+
+
+def detect_pixel_size(
+ img: Image.Image, min_delta: int = 30, min_frac: float = 0.02, max_size: int = 64
+) -> int:
+ """检测像素画的原生像素块边长(非像素画/检测不出时返回 1)。
+
+ 原理:像素画的色块边界落在同一网格上,相邻边界间距 = 块边长的整数倍,故取
+ **最常见间距**即块边长。两轴分别估,取较小者(更保守,宁可细不可糊)。
+ """
+ rgba = img.convert("RGBA")
+ x0, y0, x1, y1 = _content_bbox(rgba)
+ crop = np.asarray(rgba.crop((x0, y0, x1, y1)).convert("RGB")).astype(np.int16)
+ if crop.size == 0:
+ return 1
+ sizes = [_axis_block_size(crop, ax, min_delta, min_frac) for ax in (0, 1)]
+ best = min(s for s in sizes) if all(s >= 1 for s in sizes) else 1
+ return max(1, min(best, max_size))
+
+
+def _erode(mask: np.ndarray, k: int) -> np.ndarray:
+ """二值腐蚀 k 次(纯 numpy 移位,不引 scipy)。"""
+ m = mask
+ for _ in range(max(0, k)):
+ m = (
+ m
+ & np.roll(m, 1, 0)
+ & np.roll(m, -1, 0)
+ & np.roll(m, 1, 1)
+ & np.roll(m, -1, 1)
+ )
+ if not m.any():
+ return mask
+ return m
+
+
+def _subject_mask(
+ rgba: Image.Image, alpha_thr: int = 128, bg_tol: int = 40, erode: int = 0
+) -> np.ndarray:
+ """主体掩码:优先用真实 alpha;母版常是**不透明白底**,此时按四角背景色排除背景。
+
+ 两个实测踩过的坑:
+ 1. 不排背景 → 白底占多数像素、吃光色板名额 → 角色被整体吸附成白色。
+ 2. 排了背景但保留边缘 → 角色/白底之间的**抗锯齿过渡色**(近白)混进色板 →
+ 视频里的浅色噪点就近吸附成白点,满身白斑。故取色板时用 ``erode`` 腐蚀掉边缘。
+ """
+ arr = np.asarray(rgba)
+ alpha = arr[:, :, 3]
+ if not alpha.min() > alpha_thr: # 有真实抠图
+ mask = alpha > alpha_thr
+ else:
+ rgb = arr[:, :, :3].astype(np.int16)
+ corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
+ bg = np.median(corners, axis=0)
+ mask = np.abs(rgb - bg).sum(axis=2) > bg_tol
+ return _erode(mask, erode)
+
+
+def extract_palette(
+ img: Image.Image, max_colors: int = 32, alpha_thr: int = 128, erode: int = 3
+) -> np.ndarray:
+ """提取母版真实色板,返回 (K,3) uint8。
+
+ 只统计主体像素(见 :func:`_subject_mask`,并腐蚀掉抗锯齿边缘),再用中位切分量化
+ 归并噪声色 —— 生成的"像素画"常带轻微噪点/抗锯齿,同一名义色被打散成大量近似色,
+ 直接按频率统计会全被当杂色滤掉。
+ """
+ rgba = img.convert("RGBA")
+ arr = np.asarray(rgba)
+ mask = _subject_mask(rgba, alpha_thr, erode=erode)
+ pixels = arr[:, :, :3][mask]
+ if not len(pixels):
+ pixels = arr[:, :, :3].reshape(-1, 3)
+ strip = Image.fromarray(pixels.reshape(1, -1, 3).astype(np.uint8), "RGB")
+ quant = strip.quantize(colors=max(2, max_colors), method=Image.MEDIANCUT)
+ pal = np.asarray(quant.getpalette()[: max(2, max_colors) * 3], dtype=np.uint8).reshape(-1, 3)
+ used = np.unique(np.asarray(quant))
+ return pal[used[used < len(pal)]]
+
+
+def master_pixel_spec(master: Image.Image, max_colors: int = 48) -> tuple[int, np.ndarray]:
+ """从母版量出 (角色的逻辑像素高, 母版色板)。
+
+ 逻辑像素高 = 母版里角色占的像素行数 ÷ 原生像素块边长 —— 即"这个角色本来是多少
+ 像素高的精灵"。用它当 ``target_h`` 可自动吸附网格,不必人肉猜分辨率。
+
+ ``max_colors`` 实测取值:32 太少 —— 中位切分按面积分箱,大面积色(如裸腿肤色/棕靴)
+ 会挤占名额,小面积但需渐变的衣服色档位不足 → 中间调就近吸到邻近色相(绿衣泛橄榄黄);
+ 96 太多 —— 抗锯齿近白色重新拿到独立分箱 → 边缘冒白噪点。48 是实测的安全区。
+ """
+ x0, y0, x1, y1 = _content_bbox(master.convert("RGBA"))
+ block = detect_pixel_size(master)
+ logical_h = max(1, round((y1 - y0) / block))
+ return logical_h, extract_palette(master, max_colors=max_colors)
+
+
+def _to_perceptual(rgb: np.ndarray) -> np.ndarray:
+ """RGB → 近似感知空间(亮度 + 两个色差轴),float32。
+
+ 直接在 RGB 里取最近邻会**跳色相**:绿衣的中间调可能被吸到橄榄黄(实测踩过)。
+ 换成亮度/色差轴并给色差加权后,同色相内的明暗过渡优先匹配,色相跳变被压住。
+ 这里用 YCbCr 型线性变换(比 Lab 便宜得多,足够拉开色相)。
+ """
+ f = rgb.astype(np.float32)
+ r, g, b = f[..., 0], f[..., 1], f[..., 2]
+ y = 0.299 * r + 0.587 * g + 0.114 * b
+ cb = b - y
+ cr = r - y
+ w = 2.0 # 色差权重 >1:宁可亮度差一点,也别换色相
+ return np.stack([y, w * cb, w * cr], axis=-1)
+
+
+def _snap_to_palette(rgb: np.ndarray, palette: np.ndarray) -> np.ndarray:
+ """把每个像素吸附到色板中最近的颜色(感知空间最近邻,分块避免大内存)。
+
+ 用 float32 感知空间:①避免 int16 平方距离溢出(255² > 32767,实测让绿衣变肉色);
+ ②按色相优先匹配,防止 RGB 空间里的跨色相跳变。
+ """
+ flat = _to_perceptual(rgb).reshape(-1, 3)
+ pal_p = _to_perceptual(palette).reshape(-1, 3)
+ pal_rgb = palette.astype(np.uint8).reshape(-1, 3)
+ out = np.empty((len(flat), 3), dtype=np.uint8)
+ step = 65536
+ for i in range(0, len(flat), step):
+ chunk = flat[i : i + step]
+ d = ((chunk[:, None, :] - pal_p[None, :, :]) ** 2).sum(axis=2)
+ out[i : i + step] = pal_rgb[d.argmin(axis=1)]
+ return out.reshape(rgb.shape)
+
+
+def to_pixel_art(
+ rgba: Image.Image,
+ target_h: int = 100,
+ palette_size: int = 32,
+ alpha_thr: int = 128,
+ palette: np.ndarray | None = None,
+) -> Image.Image:
+ """单帧转像素风,返回小尺寸 RGBA(``target_h`` 高,等比宽)。
+
+ 步骤:裁到主体包围盒 → 等比缩到 ``target_h``(NEAREST 网格降采样)→ 限色。
+ 限色两种模式:
+ - ``palette`` 给定(推荐,原生像素角色):**吸附到母版真实色板**,顺带消掉
+ JPG/H.264 在硬边留下的灰颗粒。
+ - ``palette=None``(插画转像素):按 ``palette_size`` 做八叉树量化。
+
+ Args:
+ target_h: 目标像素高;原生像素角色建议用 :func:`master_pixel_spec` 算出的逻辑高。
+ palette_size: 无母版色板时的量化色数。
+ palette: (K,3) uint8 母版色板。
+ """
+ if target_h < 1:
+ raise ValueError("target_h 必须 >= 1")
+ rgba = rgba.convert("RGBA")
+ x0, y0, x1, y1 = _content_bbox(rgba, alpha_thr)
+ crop = rgba.crop((x0, y0, x1, y1))
+ w, h = crop.size
+ target_w = max(1, round(w * target_h / h))
+ small = crop.resize((target_w, target_h), Image.NEAREST)
+
+ alpha = np.asarray(small)[:, :, 3]
+ if palette is not None and len(palette):
+ rgb = _snap_to_palette(np.asarray(small.convert("RGB")), palette)
+ else:
+ rgb = np.asarray(
+ small.convert("RGB")
+ .quantize(colors=max(2, palette_size), method=Image.FASTOCTREE)
+ .convert("RGB")
+ )
+ out = np.dstack([rgb, alpha]).astype(np.uint8)
+ return Image.fromarray(out, "RGBA")
+
+
+def pixelate_frames(
+ frames: list[Image.Image],
+ target_h: int = 100,
+ palette_size: int = 32,
+ palette: np.ndarray | None = None,
+ ref_height: float | None = None,
+) -> list[Image.Image]:
+ """批量像素化一组帧,**整段共用一个缩放系数**,便于打包为 sprite sheet。
+
+ ``target_h`` 是**基准姿态**的目标像素高,其余帧按同一系数等比缩放 —— 不是把每帧都拉
+ 到等高。逐帧拉等高会把走路自然的身高起伏反向变成"忽大忽小"(实测踩过:蹲下的帧被放大)。
+
+ ``ref_height``:**跨动作一致性的关键**。给定时用它当基准(单位=源图像素),否则用本序列
+ 最高帧。同一角色的各个动作若各自取自己的最高帧定标,切换状态时角色会忽大忽小 ——
+ 传入同一个基准(如母版姿态的角色高)即可让 idle/walk/jump/attack 共用一套尺度。
+ """
+ if not frames:
+ return []
+ box_h = []
+ for f in frames:
+ _, y0, _, y1 = _content_bbox(f.convert("RGBA"))
+ box_h.append(max(1, y1 - y0))
+ scale = target_h / (ref_height if ref_height else max(box_h))
+ return [
+ to_pixel_art(f, max(1, round(h * scale)), palette_size, palette=palette)
+ for f, h in zip(frames, box_h)
+ ]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py
new file mode 100644
index 00000000..88487645
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py
@@ -0,0 +1,69 @@
+"""Root motion(位移轨迹)与逐帧时长 —— 按 2D 游戏业界惯例分离"姿势"与"位移"。
+
+业界做法(调研 2026-07-28):
+- **位移不烘进序列帧**。连续位移动作几乎一律用 *in-place animation + 引擎代码驱动移动*,
+ 因为玩家要即时操控:跑动中转向应立刻响应,而不是等一段烘死的位移播完。平台游戏的跳跃
+ 也是"几个姿势定格 + 引擎物理驱动上下",不是把抛物线画进像素。
+ → 序列帧保持**原地**(脚线对齐),位移单独作为 root-motion 轨道交给引擎。
+- **逐帧时长比帧数更重要**("frame timing beats frame count")。业界常用:
+ idle 400–500ms/帧、walk 100–150ms、run 80–100ms、attack 起手 80–100ms 且**触点定格
+ 150–200ms**。全程等时长会让动作发飘、没有重量感。
+
+本模块只做几何与时长计算,纯 numpy,零 API。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["extract_root_motion", "frame_durations", "DEFAULT_FPS_MS"]
+
+# 各动作的基准单帧时长(ms),取业界常用区间的中值。
+DEFAULT_FPS_MS = {
+ "idle": 450,
+ "walk": 125,
+ "run": 90,
+ "jump": 110,
+ "attack": 90,
+ "hit": 90,
+}
+
+
+def extract_root_motion(frames: list[Image.Image], alpha_thr: int = 128) -> list[tuple[int, int]]:
+ """逐帧相对首帧的 (dx, dy) 位移,单位=像素,y 向上为正。
+
+ 以主体包围盒的**底边中心**(脚点)为参考点。序列帧本身保持原地时,这条轨道就是引擎
+ 要施加的 root motion:jump 的 dy 是腾空高度,walk 的 dx 是前进量。
+ """
+ pts: list[tuple[float, float]] = []
+ for f in frames:
+ a = np.asarray(f.convert("RGBA"))
+ ys, xs = np.where(a[:, :, 3] > alpha_thr)
+ pts.append(((xs.min() + xs.max()) / 2, float(ys.max())) if len(ys) else (np.nan, np.nan))
+ arr = np.array(pts, dtype=np.float32)
+ if np.isnan(arr).any(): # 空帧用邻近值补
+ idx = np.arange(len(arr))
+ for c in range(2):
+ good = ~np.isnan(arr[:, c])
+ arr[:, c] = np.interp(idx, idx[good], arr[good, c]) if good.any() else 0.0
+ base = arr[0]
+ return [(int(round(p[0] - base[0])), int(round(base[1] - p[1]))) for p in arr]
+
+
+def frame_durations(
+ action: str, n_frames: int, key_frame: int | None = None, hold_ms: int = 180
+) -> list[int]:
+ """逐帧时长(ms)。关键帧(触点 / 顶点)加长定格,其余用该动作的基准时长。
+
+ Args:
+ action: 动作名(取 :data:`DEFAULT_FPS_MS` 的基准时长,未知动作按 walk)。
+ n_frames: 帧数。
+ key_frame: 要定格的帧下标(attack 的触点、jump 的顶点);None 表示全程等时长。
+ hold_ms: 关键帧时长,业界常用 150–200ms。
+ """
+ base = DEFAULT_FPS_MS.get(action, DEFAULT_FPS_MS["walk"])
+ out = [base] * max(0, n_frames)
+ if key_frame is not None and 0 <= key_frame < n_frames:
+ out[key_frame] = max(base, hold_ms)
+ return out
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py
new file mode 100644
index 00000000..6eebe65b
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py
@@ -0,0 +1,16 @@
+"""prompt:各动作的生成提示词与装配。"""
+
+from .actions import build_attack_prompt, build_custom_prompt, build_idle_prompt
+from .jump import JUMP_PHASES, build_jump_prompt
+from .walk import WALK_BODY_FRONT, WALK_BODY_SIDE, build_walk_prompt
+
+__all__ = [
+ "WALK_BODY_SIDE",
+ "WALK_BODY_FRONT",
+ "build_walk_prompt",
+ "JUMP_PHASES",
+ "build_jump_prompt",
+ "build_idle_prompt",
+ "build_attack_prompt",
+ "build_custom_prompt",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py
new file mode 100644
index 00000000..f2466202
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py
@@ -0,0 +1,111 @@
+"""待机 / 攻击 i2v 提示词。
+
+措辞迁自 windup-pipeline 已验证的 prompt_library(idle / slash),按本模块的 facing 分流改写。
+
+- **idle**:循环类(tail_match)。只写躯干呼吸节律,武器与双脚显式锁定 —— 逐帧生成待机
+ 只会抖不会呼吸,故走 i2v 或程序化 Idle-B。
+- **attack**:一次性类。四条已验证的锁定:①"one single committed motion"防复读;
+ ②剑长与握点固定;③剑在身前、刃面朝观者(防 Z 轴穿模与刀刃翻转);④终态回戒备并保持。
+ 节奏(蓄力慢/挥砍快/触点定格)在抽帧做,不写进 prompt。
+"""
+
+from __future__ import annotations
+
+__all__ = ["build_idle_prompt", "build_attack_prompt", "build_custom_prompt"]
+
+_IDLE_SIDE = (
+ "The character stands in place, seen from the side facing right: the chest breathes in one "
+ "slow, even rhythm, the ribcage expanding and easing back while the shoulders stay level and "
+ "settled at the same height, the torso rising and lowering in that same slow rhythm, "
+ "{weapon} resting steady at the side in a fixed grip, {garment} hanging and swaying in the "
+ "same rhythm, both boots planted firmly on the ground, weight centered, the character stays "
+ "in the same spot and keeps facing right."
+)
+
+_IDLE_FRONT = (
+ "The character stands in place facing the viewer: the chest breathes in one slow, even "
+ "rhythm, the ribcage expanding and easing back while the shoulders stay level and settled at "
+ "the same height, the torso rising and lowering in that same slow rhythm, {weapon} resting "
+ "steady at the side in a fixed grip, {garment} hanging and swaying in the same rhythm, both "
+ "boots planted firmly on the ground, weight centered, the character keeps FACING THE VIEWER "
+ "and stays in the same spot."
+)
+
+_ATTACK_SIDE = (
+ "Seen from the side facing right, the character makes ONE single committed attack, staying in "
+ "STRICT SIDE VIEW the whole time: starting coiled with the weight on the back foot, the body "
+ "leans forward and the weight surges onto the front foot, the arm sweeping {weapon} through "
+ "one smooth downward crescent arc from high behind the shoulder down across the front to full "
+ "extension low, {weapon} keeping its exact length and grip position and staying clearly in "
+ "front of the body with its flat side facing the viewer the whole way, {garment} swinging with "
+ "the motion, then the body settles back upright into guard and holds that stance, standing "
+ "steady. The torso and hips keep pointing to the right the entire time and the character never "
+ "turns toward or away from the viewer."
+)
+
+_ATTACK_FRONT = (
+ "Facing the viewer, the character makes ONE single committed attack: starting coiled with the "
+ "weight on the back foot, the whole body uncoils forward, the arm sweeping {weapon} through "
+ "one smooth arc across the front to full extension, {weapon} keeping its exact length and grip "
+ "position and staying clearly in front of the body with its flat side facing the viewer the "
+ "whole way, {garment} swinging with the motion, then the body settles back upright into guard "
+ "and holds that stance, standing steady and keeping FACING THE VIEWER."
+)
+
+DEFAULT_WEAPON = "the sword"
+DEFAULT_GARMENT = "the cape"
+
+
+def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str:
+ if facing not in ("side", "front"):
+ raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}")
+ body = (side if facing == "side" else front).format(weapon=weapon, garment=garment)
+ return body.replace("boot", feet) if feet != "boot" else body
+
+
+def build_idle_prompt(
+ weapon: str = DEFAULT_WEAPON,
+ garment: str = DEFAULT_GARMENT,
+ feet: str = "boot",
+ facing: str = "side",
+) -> str:
+ """待机正文(循环类)。``facing`` 须与母版朝向一致。"""
+ return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing)
+
+
+def build_attack_prompt(
+ weapon: str = DEFAULT_WEAPON,
+ garment: str = DEFAULT_GARMENT,
+ feet: str = "boot",
+ facing: str = "side",
+) -> str:
+ """攻击正文(一次性类)。``facing`` 须与母版朝向一致。"""
+ return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing)
+
+
+_CUSTOM_SIDE = (
+ "Seen from the side facing right, the character performs ONE continuous motion in a STRICT "
+ "SIDE VIEW the whole time: {action} in one smooth, repetitive rhythm, the torso and hips "
+ "keeping pointing to the right, feet staying planted in place, the character never turning "
+ "toward or away from the viewer, then holding the final pose steady at the end."
+)
+
+_CUSTOM_FRONT = (
+ "Facing the viewer, the character performs ONE continuous motion in a FRONT VIEW the whole "
+ "time: {action} in one smooth, repetitive rhythm, feet staying planted in place, the character "
+ "keeps FACING THE VIEWER and never turns away, then holding the final pose steady at the end."
+)
+
+
+def build_custom_prompt(action: str, facing: str = "side") -> str:
+ """自定义动作正文(一次性类,视频路线)。
+
+ ``action`` 为自然语言动作描述(如 "painting on an easel with a brush")。
+ ``facing`` 须与母版朝向一致。
+ """
+ if not action or not action.strip():
+ raise ValueError("custom 动作需要动作描述(action_desc)")
+ if facing not in ("side", "front"):
+ raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}")
+ body = (_CUSTOM_SIDE if facing == "side" else _CUSTOM_FRONT).format(action=action.strip())
+ return body
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py
new file mode 100644
index 00000000..dac08595
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py
@@ -0,0 +1,63 @@
+"""跳跃 i2v 提示词(一次性动作,非循环)。
+
+与 walk/run 的根本差别:
+- **不循环**。跳跃是一段有始有终的动作,不能像步态那样抽单周期闭环。
+- **要拆状态**。游戏里跳跃是状态机:蓄力 → 上升 → 顶点 → 下降 → 落地缓冲;悬空时长由
+ 物理决定、上升中可被打断,所以必须能分段播放,不能烘成一整段。
+- 提示词要写**"只做一次 + 终态保持"**,防 5s 内复读跳第二次(实测:写了仍会复读,故抽帧层
+ 另有 first_action_end 兜底)。
+- **原地起跳、幅度适中**:水平位移交引擎做 root-motion,不烘进像素;幅度过大会让角色顶出
+ 视频画面,且序列帧里角色被缩得很小。
+
+朝向同 walk:必须与母版一致(side 横版 / front 俯视·2.5D)。
+"""
+
+from __future__ import annotations
+
+__all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"]
+
+# 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。
+JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land")
+
+JUMP_BODY_SIDE = (
+ "The character performs ONE single jump in place, seen from the side facing right: "
+ "first the knees bend deep into a crouch and the arms drop back, then both boots push "
+ "off the ground and the whole body lifts straight upward a modest height with the legs "
+ "tucking up, the body reaches the top of the jump and hangs there for an instant with {garment} "
+ "floating upward, then the body falls back down with the legs reaching for the ground, "
+ "and both boots land together with the knees bending to absorb the impact, the weapon "
+ "stays held steady in a fixed grip the whole time. The character does this ONCE and "
+ "then stays standing upright in the landing spot, staying centered in frame."
+)
+
+JUMP_BODY_FRONT = (
+ "The character performs ONE single jump in place, facing the viewer: first the knees "
+ "bend deep into a crouch and the arms drop back, then both boots push off the ground "
+ "hard and the whole body launches straight upward with the knees tucking up toward the "
+ "camera, the body reaches the top of the jump and hangs there for an instant with "
+ "{garment} floating upward, then the body falls back down with the legs reaching for "
+ "the ground, and both boots land together with the knees bending to absorb the impact, "
+ "the weapon stays held steady in a fixed grip the whole time. The character keeps "
+ "FACING THE VIEWER, does this ONCE and then stays standing upright, centered in frame."
+)
+
+DEFAULT_GARMENT = "the cape and tabard"
+
+
+def build_jump_prompt(
+ garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side"
+) -> str:
+ """按角色装备 + 母版朝向生成跳跃正文。
+
+ Args:
+ garment: 起跳时上飘的衣饰。
+ feet: 落脚部件用词(替换 boot)。
+ facing: "side" 或 "front",**必须与母版朝向一致**。
+ """
+ if facing not in ("side", "front"):
+ raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}")
+ template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT
+ body = template.format(garment=garment)
+ if feet != "boot":
+ body = body.replace("boot", feet)
+ return body
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py
new file mode 100644
index 00000000..5d3c4a6e
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py
@@ -0,0 +1,57 @@
+"""走路 i2v 提示词(视频路线)。
+
+实测要点(Issue #35):
+- 只写正向词、逐条写腿部可见动作(抬 / 摆 / 蹬 / 承重),锁死手持武器不乱动。
+- **提示词的朝向必须与母版朝向一致**。给正面母版喂侧走词(STRICT SIDE)会让模型靠"转身"
+ 调和图文矛盾——早期"正面母版必转身"的结论正是这么造成的。故按 facing 分流:
+ side(横版侧走)/ front(俯视·2.5D 朝观者行进),对应 Project.perspective。
+- "半侧"母版(头侧脸 + 身体略正)配 side 词,实测会被自然解析成正侧面走,不转身,够用。
+- 换角色只替换装备子句(如 骷髅:boot→骨足、cape→围巾),机制词保持不变。
+"""
+
+from __future__ import annotations
+
+__all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"]
+
+# 侧走(横版):整体向右推进 + 锁侧视。
+WALK_BODY_SIDE = (
+ "The character walks steadily to the right through the open space, the whole body "
+ "advancing with every stride: the front boot lifts, swings forward and plants heel "
+ "first, the rear boot pushes off the ground, the hips and torso carry the weight "
+ "forward over the planted foot, {garment} swing with the steps, the weapon stays held "
+ "low and steady at the side in a fixed grip, the upper body stays calm and upright, "
+ "SIDE VIEW facing right the whole time, the legs clearly visible."
+)
+
+# 正面走(俯视 / 2.5D):朝观者原地行进,身体始终正对观者、不转身。
+WALK_BODY_FRONT = (
+ "The character walks in place toward the viewer, marching forward on the spot: each "
+ "boot lifts, swings forward and plants down in turn while the other pushes off, the "
+ "knees rise alternately toward the camera, the hips and shoulders sway naturally with "
+ "each step, {garment} sway with the steps, the weapon stays held low and steady in a "
+ "fixed grip, the upper body stays calm and upright, the character keeps FACING THE "
+ "VIEWER the whole time and stays centered in frame, both legs clearly visible."
+)
+
+# 每个角色只替换 garment / feet 两处装备子句,机制词不动。
+DEFAULT_GARMENT = "the cape and tabard"
+
+
+def build_walk_prompt(
+ garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side"
+) -> str:
+ """按角色装备 + 母版朝向生成走路正文。
+
+ Args:
+ garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。
+ feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。
+ facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。
+ **必须与母版朝向一致**,否则模型会靠转身调和矛盾。
+ """
+ if facing not in ("side", "front"):
+ raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}")
+ template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT
+ body = template.format(garment=garment)
+ if feet != "boot":
+ body = body.replace("boot", feet)
+ return body
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py
new file mode 100644
index 00000000..3489de23
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py
@@ -0,0 +1,27 @@
+"""slicing:视频 → 帧序列。抽帧(extract)+ 选帧(周期 loop / 一次性 oneshot)。
+
+视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝
+loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。
+"""
+
+from .extract import extract_all_frames_bytes, extract_frames_bytes
+from .loop import find_period, pick_cycle
+from .oneshot import (
+ find_motion_span,
+ first_action_end,
+ foot_line_series,
+ pick_oneshot,
+ split_jump_phases,
+)
+
+__all__ = [
+ "extract_frames_bytes",
+ "extract_all_frames_bytes",
+ "find_period",
+ "pick_cycle",
+ "find_motion_span",
+ "first_action_end",
+ "foot_line_series",
+ "pick_oneshot",
+ "split_jump_phases",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py
new file mode 100644
index 00000000..1a7bc781
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py
@@ -0,0 +1,162 @@
+"""视频抽帧(切片层的解码入口)。
+
+承接视频路线(Issue #35):i2v 产出的短视频步态真实但为插画质感。本模块只负责
+把视频 bytes 解码成帧序列;选帧(周期 / 一次性)见 :mod:`.loop` / :mod:`.oneshot`,
+像素化 / 对齐 / 打包见 :mod:`..postprocess`。抽帧后端(imageio/ffmpeg)函数内惰性,
+模块导入零成本、CI 可收集。
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import tempfile
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
+
+from PIL import Image
+
+logger = logging.getLogger("windup.slicing.extract")
+
+_EXTRACT_TIMEOUT_SECONDS = 180 # 单次抽帧方案超时:3 分钟
+
+__all__ = ["extract_frames_bytes", "extract_all_frames_bytes"]
+
+
+def extract_frames_bytes(video: bytes, n: int) -> list[Image.Image]:
+ """从视频 bytes 均匀抽 ``n`` 帧(供后端 strategy 用,provider 返回的是 bytes)。"""
+ path = tempfile.mktemp(suffix=".mp4")
+ try:
+ with open(path, "wb") as f:
+ f.write(video)
+ return _extract_frames(path, n)
+ finally:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]:
+ """抽视频全部帧(至多 ``cap``,均匀降采样),供周期检测用。"""
+ path = tempfile.mktemp(suffix=".mp4")
+ try:
+ with open(path, "wb") as f:
+ f.write(video)
+ return _extract_frames(path, cap)
+ finally:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def _run_with_timeout(fn, *args, timeout: int = _EXTRACT_TIMEOUT_SECONDS):
+ """在独立线程中执行 *fn*,超时则抛 TimeoutError。
+
+ 用 ThreadPoolExecutor 而非 signal,兼容 Windows 子线程场景。
+ """
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(fn, *args)
+ try:
+ return future.result(timeout=timeout)
+ except FutureTimeout as exc:
+ future.cancel()
+ raise TimeoutError(
+ f"抽帧耗时超过 {timeout} 秒,已终止"
+ ) from exc
+
+
+def _imageio_extract(video_path: str, n: int) -> list[Image.Image]:
+ import imageio.v3 as iio
+
+ all_frames = iio.imread(video_path, plugin="pyav") # (T, H, W, C)
+ total = len(all_frames)
+ m = min(n, total)
+ idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)]
+ return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx]
+
+
+def _pyav_extract(video_path: str, n: int) -> list[Image.Image]:
+ import av as _av
+
+ container = _av.open(video_path)
+ all_frames = []
+ for frame in container.decode(video=0):
+ all_frames.append(frame.to_rgb().to_ndarray())
+ container.close()
+ if not all_frames:
+ return []
+ total = len(all_frames)
+ m = min(n, total)
+ idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)]
+ return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx]
+
+
+def _ffmpeg_extract(video_path: str, n: int) -> list[Image.Image]:
+ import glob
+ import subprocess
+
+ with tempfile.TemporaryDirectory() as tmp:
+ subprocess.run(
+ ["ffmpeg", "-y", "-i", video_path, "-vsync", "0",
+ os.path.join(tmp, "f_%04d.png")],
+ capture_output=True, check=True,
+ )
+ files = sorted(glob.glob(os.path.join(tmp, "f_*.png")))
+ if not files:
+ raise RuntimeError("抽帧失败:视频无可解码帧")
+ m = min(n, len(files))
+ idx = [round(i * (len(files) - 1) / max(1, m - 1)) for i in range(m)]
+ return [Image.open(files[i]).convert("RGBA").copy() for i in idx]
+
+
+def _extract_frames(video_path: str, n: int) -> list[Image.Image]:
+ """从视频均匀抽 ``n`` 帧。优先 imageio,回退 pyav 直调,再回退系统 ffmpeg。
+
+ 每个方案都有 3 分钟超时保护,避免解码卡死导致后台线程永久挂起。
+ """
+ errors: list[str] = []
+ timeout = _EXTRACT_TIMEOUT_SECONDS
+
+ # 1) imageio + pyav 插件
+ try:
+ return _run_with_timeout(_imageio_extract, video_path, n, timeout=timeout)
+ except TimeoutError as exc:
+ logger.warning("imageio 抽帧超时: %s", exc)
+ errors.append(f"imageio: {exc}")
+ except Exception as exc:
+ logger.debug("imageio 抽帧失败,尝试下一方案: %s", exc)
+ errors.append(f"imageio: {exc}")
+
+ # 2) pyav 直调(绕过 imageio 插件初始化问题)
+ try:
+ return _run_with_timeout(_pyav_extract, video_path, n, timeout=timeout)
+ except TimeoutError as exc:
+ logger.warning("pyav 抽帧超时: %s", exc)
+ errors.append(f"pyav: {exc}")
+ except Exception as exc:
+ logger.debug("pyav 抽帧失败,尝试下一方案: %s", exc)
+ errors.append(f"pyav: {exc}")
+
+ # 3) 系统 ffmpeg
+ if shutil.which("ffmpeg") is None:
+ errors.append("ffmpeg: 系统未安装 ffmpeg 或不在 PATH 中")
+ raise RuntimeError(
+ "视频抽帧失败,所有方案均不可用:\n"
+ + "\n".join(f" - {e}" for e in errors)
+ )
+
+ import subprocess
+
+ try:
+ return _run_with_timeout(_ffmpeg_extract, video_path, n, timeout=timeout)
+ except TimeoutError as exc:
+ logger.warning("ffmpeg 抽帧超时: %s", exc)
+ raise RuntimeError(
+ "视频抽帧失败,所有方案均超时或不可用:\n"
+ + "\n".join(f" - {e}" for e in errors)
+ ) from exc
+ except subprocess.CalledProcessError as exc:
+ stderr = exc.stderr.decode(errors="replace") if exc.stderr else ""
+ raise RuntimeError(f"ffmpeg 抽帧失败 (exit {exc.returncode}): {stderr}") from exc
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py
new file mode 100644
index 00000000..e4bd336b
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py
@@ -0,0 +1,52 @@
+"""循环闭合(最后一公里之一,Issue #21)—— 从 i2v 密集帧里抽正好一个步态周期,做无缝 loop。
+
+i2v 的 5s 视频里含 ~2-3 个步态周期,均匀抽 N 帧跨多个周期 → 首尾接缝跳。做法:
+帧自相似检测周期(灰度小图,frame[i] 与 frame[i+p] 差最小的 p = 一个周期),
+再在一个周期内均匀取 N 帧 → frame[N-1] 的下一拍≈frame[0],循环自然闭合。
+纯 numpy / PIL,零 API。
+"""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["find_period", "pick_cycle"]
+
+_SMALL = 48 # 周期检测用的灰度小图边长
+
+
+def _gray(frames: list[Image.Image]) -> list[np.ndarray]:
+ return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames]
+
+
+def find_period(frames: list[Image.Image], pmin: int | None = None, pmax: int | None = None) -> int:
+ """自相似求步态周期(帧数)。frame[i] 与 frame[i+p] 平均差最小的 p。"""
+ n = len(frames)
+ gs = _gray(frames)
+ pmin = pmin or (2 if n < 12 else max(4, n // 6))
+ pmax = pmax or max(pmin + 1, n // 2)
+ best_p, best_d = pmin, float("inf")
+ for p in range(pmin, pmax + 1):
+ d = float(np.mean([np.abs(gs[i] - gs[i + p]).mean() for i in range(n - p)]))
+ if d < best_d:
+ best_d, best_p = d, p
+ return best_p
+
+
+def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]:
+ """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。
+
+ 源视频帧少于目标帧时按循环时间轴重复采样,仍兑现调用方请求的帧数。这里不
+ 合成不存在的中间画面,只重复最接近的源帧,因此不会引入额外的角色形变。
+ """
+ total = len(frames)
+ if total == 0 or n <= 0:
+ return []
+ if total < 3:
+ return [frames[round(k * (total - 1) / max(1, n - 1))] for k in range(n)]
+ gs = _gray(frames)
+ p = find_period(frames)
+ # 搜起点 i0:让 frame[i0] 与 frame[i0+p] 最像(相位闭合最好)→ 末帧回接首帧最平滑
+ i0 = min(range(total - p), key=lambda i: float(np.abs(gs[i] - gs[i + p]).mean()))
+ idx = [i0 + round(k * p / n) for k in range(n)]
+ return [frames[i] for i in idx]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py
new file mode 100644
index 00000000..6f63d487
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py
@@ -0,0 +1,189 @@
+"""一次性动作(jump / attack / hit)的抽帧:裁动作起止 + 按状态切段。
+
+与循环类(idle/walk/run)的根本差别:
+- 循环类用 :mod:`.loop` 找步态周期抽单周期闭环;一次性动作**不能闭环** —— 首尾姿态不同,
+ 强行闭环会把落地帧接回蓄力帧,读起来是抽搐。
+- i2v 出的 5s 视频里,真正的动作往往只占中间一段(前后是静止的起手/终态保持),直接均匀
+ 抽帧会浪费一半帧在不动的地方 → 需要先**裁到动作发生的区间**。
+- jump 还要进一步**按状态切段**(蓄力/上升/顶点/下降/落地),因为引擎里悬空时长由物理
+ 决定、上升中可被打断,必须能分段播放。
+
+纯 numpy / PIL,零 API。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = [
+ "find_motion_span",
+ "first_action_end",
+ "pick_oneshot",
+ "split_jump_phases",
+ "foot_line_series",
+]
+
+
+def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray:
+ """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。"""
+ gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames]
+ return np.array([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)])
+
+
+def find_motion_span(frames: list[Image.Image], rel_thr: float = 0.25) -> tuple[int, int]:
+ """定位"动作真正发生"的帧区间 ``[start, end]``(含端点)。
+
+ 以帧间差异强度超过峰值 ``rel_thr`` 倍的最早/最晚位置为界,并各留一帧余量。
+ 静止的起手与终态保持会被裁掉。
+ """
+ if len(frames) < 3:
+ return 0, len(frames) - 1
+ e = _frame_energy(frames)
+ peak = float(e.max())
+ if peak <= 1e-6:
+ return 0, len(frames) - 1
+ active = np.flatnonzero(e >= peak * rel_thr)
+ if not len(active):
+ return 0, len(frames) - 1
+ start = max(0, int(active[0]) - 1)
+ end = min(len(frames) - 1, int(active[-1]) + 2)
+ return start, end
+
+
+def _airborne_end(frames: list[Image.Image], start: int, end: int, tol: float = 6.0) -> int:
+ """腾空类(jump)的结束:脚线越过最高点后**首次回到地面**。
+
+ 几何信号,明确无歧义 —— 比任何"能量安静"判据都稳。
+ """
+ y = foot_line_series(frames[start : end + 1])
+ if len(y) < 4:
+ return end
+ apex = int(np.argmin(y))
+ ground = float(np.median([y[0], y[-1]]))
+ back = np.flatnonzero(y[apex:] >= ground - tol)
+ return min(end, start + apex + int(back[0]) + 2) if len(back) else end
+
+
+def _swing_end(frames: list[Image.Image], start: int, end: int,
+ drop_ratio: float = 0.35, recover: int = 2) -> int:
+ """挥击类(attack/hit)的结束:能量越过峰值后**首次跌到峰值的 ``drop_ratio``**,再留收势余量。
+
+ 挥击是"蓄力 → 峰值 → 收势"的单峰结构,收势很短,故用"跌破比例 + 固定余量"即可;
+ 不要求长时间静止 —— 实测挥砍收势段的能量并不干净(视频压缩噪点),等不到静止平台。
+ """
+ e = _frame_energy(frames[start : end + 1])
+ if len(e) < 4:
+ return end
+ peak_i = int(np.argmax(e))
+ thr = float(e.max()) * drop_ratio
+ for i in range(peak_i + 1, len(e)):
+ if e[i] < thr:
+ return min(end, start + i + recover)
+ return end
+
+
+def first_action_end(
+ frames: list[Image.Image], start: int, end: int, kind: str = "swing"
+) -> int:
+ """在 ``[start, end]`` 内找**第一次**动作的结束帧,按动作物理分流。
+
+ i2v 常在 5s 里把一次性动作**复读第二遍**(实测:提示词写了 "ONCE",兽人跳了两次、
+ 挥砍也挥了两次),不裁会把两次动作压进一套序列帧。
+
+ 不同动作的"结束"信号本质不同,**一个通用判据管不了两种**(实测踩过):
+ - ``kind="airborne"``(jump):脚线回到地面 —— 几何、无歧义。
+ - ``kind="swing"``(attack/hit):能量跌破峰值比例 + 收势余量。
+
+ 三个已验证无效的通用解法(别再试):①只看"帧间安静" → 在跳跃**顶点悬停**处误触发,
+ 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁;
+ ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。
+ """
+ if end - start < 4:
+ return end
+ return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end)
+
+
+def pick_oneshot(
+ frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing"
+) -> list[Image.Image]:
+ """一次性动作抽 ``n`` 帧:裁到动作区间 → 只留第一次动作 → 区间内均匀取(不闭环)。
+
+ ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。
+ ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。
+ """
+ if not frames or n <= 0:
+ return []
+ start, end = find_motion_span(frames)
+ if first_only:
+ end = max(start + 1, first_action_end(frames, start, end, kind=kind))
+ span = frames[start : end + 1]
+ if n == 1:
+ return [span[0]]
+ idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)]
+ return [span[i] for i in idx]
+
+
+def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray:
+ """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。
+
+ 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、
+ 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。
+ """
+ arr = np.asarray(frame.convert("RGBA"))
+ alpha = arr[:, :, 3]
+ if not alpha.min() > alpha_thr:
+ return np.where(alpha > alpha_thr)[0]
+ rgb = arr[:, :, :3].astype(np.int16)
+ corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
+ bg = np.median(corners, axis=0)
+ return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0]
+
+
+def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray:
+ """逐帧主体**底边** y 坐标(脚线)。跳跃时脚线先降(蹲)、再升(腾空)、再落回。"""
+ out = []
+ for f in frames:
+ ys = _subject_rows(f, alpha_thr)
+ out.append(float(ys.max()) if len(ys) else np.nan)
+ arr = np.array(out, dtype=np.float32)
+ if np.isnan(arr).any(): # 空帧用邻近值补
+ idx = np.arange(len(arr))
+ good = ~np.isnan(arr)
+ if good.any():
+ arr = np.interp(idx, idx[good], arr[good])
+ else:
+ arr = np.zeros_like(arr)
+ return arr
+
+
+def split_jump_phases(frames: list[Image.Image]) -> dict[str, list[int]]:
+ """按脚线轨迹把跳跃切成 crouch / rise / apex / fall / land 五段,返回每段的帧下标。
+
+ 判据:脚线 y 越小 = 人越高。最高点(y 最小)即 apex;起跳前脚线最低(蹲)处为 crouch
+ 结束;之后到 apex 为 rise,apex 之后到脚线回到地面高度为 fall,余下为 land。
+ 只依赖几何,不依赖模型。
+ """
+ n = len(frames)
+ if n < 5:
+ return {"rise": list(range(n))}
+ y = foot_line_series(frames)
+ apex = int(np.argmin(y)) # 最高点
+ ground = float(np.median([y[0], y[-1]])) # 地面脚线
+ # 起跳点:apex 之前脚线最低(数值最大 = 蹲得最深)的位置
+ takeoff = int(np.argmax(y[: max(1, apex)])) if apex > 0 else 0
+ # 落地点:apex 之后脚线首次回到地面附近
+ after = y[apex:]
+ back = np.flatnonzero(after >= ground - 2)
+ landing = apex + int(back[0]) if len(back) else n - 1
+
+ apex_lo = max(takeoff + 1, apex - 1)
+ apex_hi = min(landing - 1, apex + 1)
+ phases = {
+ "crouch": list(range(0, takeoff + 1)),
+ "rise": list(range(takeoff + 1, apex_lo)),
+ "apex": list(range(apex_lo, apex_hi + 1)),
+ "fall": list(range(apex_hi + 1, landing)),
+ "land": list(range(landing, n)),
+ }
+ return {k: v for k, v in phases.items() if v}
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py
new file mode 100644
index 00000000..bf985a9d
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py
@@ -0,0 +1,12 @@
+"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。"""
+
+from .base import ROUTE_MATRIX, DerivationStrategy
+from .concrete import PerFrameStrategy, ProcIdleStrategy, VideoFrameStrategy
+
+__all__ = [
+ "ROUTE_MATRIX",
+ "DerivationStrategy",
+ "VideoFrameStrategy",
+ "PerFrameStrategy",
+ "ProcIdleStrategy",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py
new file mode 100644
index 00000000..492a4029
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py
@@ -0,0 +1,51 @@
+"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。
+
+分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档):
+ - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞;
+ 必须走视频 i2v(视频模型天生连贯、腿自然交替)。
+ - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环
+ (见 strategy.concrete.CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。
+ - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。
+ - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。
+
+ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute
+
+from windup_ai_engine.ports import ProgressPort
+
+# 动作类型 → 生成路线(架构决策,写死为契约)
+ROUTE_MATRIX: dict[ActionType, GenRoute] = {
+ ActionType.WALK: GenRoute.VIDEO_I2V,
+ ActionType.RUN: GenRoute.VIDEO_I2V,
+ ActionType.JUMP: GenRoute.VIDEO_I2V,
+ ActionType.ATTACK: GenRoute.VIDEO_I2V,
+ ActionType.HIT: GenRoute.PER_FRAME,
+ # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)——"快速看着对"的待机路线。
+ # ¥0 的程序化 Idle-B(局部网格呼吸)是后续可选优化,当前 ProcIdleStrategy 仍是桩。
+ ActionType.IDLE: GenRoute.VIDEO_I2V,
+ # custom:提示词驱动的自定义动作(如"在画板上作画")。走视频路线,动作描述
+ # 由 ActionSpec.action_desc 提供;一次性动作,不闭环(见 CYCLIC_ACTIONS)。
+ ActionType.CUSTOM: GenRoute.VIDEO_I2V,
+}
+
+
+class DerivationStrategy(ABC):
+ """一条生成路线的骨架:母版 → 对齐前的角色帧序列。"""
+
+ route: GenRoute
+
+ @abstractmethod
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。"""
+ raise NotImplementedError
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py
new file mode 100644
index 00000000..3853809a
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py
@@ -0,0 +1,168 @@
+"""三条 DerivationStrategy。
+
+- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。
+- PerFrameStrategy / ProcIdleStrategy:桩,待开发(见 #53,per-frame / idle 非首个竖线)。
+
+VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 →
+matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。
+"""
+from __future__ import annotations
+
+import io
+
+import numpy as np
+from PIL import Image
+
+from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute
+from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider
+
+from windup_ai_engine.master_prep import prepare_master
+from windup_ai_engine.ports import ProgressPort
+from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames
+from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot
+from windup_ai_engine.prompt import (
+ build_attack_prompt,
+ build_custom_prompt,
+ build_idle_prompt,
+ build_jump_prompt,
+ build_walk_prompt,
+)
+from windup_ai_engine.strategy.base import DerivationStrategy
+
+
+def _png(img: Image.Image) -> bytes:
+ buf = io.BytesIO()
+ img.convert("RGBA").save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _img(png: bytes) -> Image.Image:
+ return Image.open(io.BytesIO(png)).convert("RGBA")
+
+
+# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环
+# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。
+CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN})
+
+
+class VideoFrameStrategy(DerivationStrategy):
+ """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。
+
+ 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。
+ 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身
+ 调和图文矛盾(实测 #35)。
+ """
+
+ route = GenRoute.VIDEO_I2V
+
+ def __init__(self, video: VideoProvider, matte: MatteProvider) -> None:
+ self._video = video
+ self._matte = matte
+
+ def _build_prompt(self, action: ActionSpec) -> str:
+ """按动作类型选提示词;朝向随 ActionSpec.facing。"""
+ if action.action is ActionType.CUSTOM:
+ return build_custom_prompt(action.action_desc, facing=action.facing)
+ builders = {
+ ActionType.JUMP: build_jump_prompt,
+ ActionType.IDLE: build_idle_prompt,
+ ActionType.ATTACK: build_attack_prompt,
+ }
+ build = builders.get(action.action, build_walk_prompt)
+ return build(facing=action.facing)
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ n = action.n_frames or 8
+ progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频")
+ # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁
+ framed = prepare_master(master, action.action.value)
+ video = self._video.i2v(framed, self._build_prompt(action), seconds=5)
+
+ dense = extract_all_frames_bytes(video)
+ # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版
+ # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。
+ ref_h = None
+ if dense:
+ _first = _img(self._matte.cutout(_png(dense[0])))
+ _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128)
+ ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None
+ if action.action in CYCLIC_ACTIONS:
+ progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图")
+ picked = pick_cycle(dense, n) # 单周期闭环(#21)
+ else:
+ progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图")
+ kind = "airborne" if action.action is ActionType.JUMP else "swing"
+ picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止
+ cut = [_img(self._matte.cutout(_png(im))) for im in picked]
+
+ # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色);
+ # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板,
+ # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。
+ if action.stylize == "none":
+ progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)")
+ return [_png(im) for im in cut]
+
+ target_h, palette = action.pixel_h, None
+ try:
+ logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的
+ if logical_h > 8: # 母版确为像素画 → 按它的规格走
+ target_h, palette = logical_h, pal
+ except Exception: # 母版非像素画/量不出 → 回退通用量化
+ pass
+ progress.step(
+ "derive", 2, 3,
+ f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})",
+ )
+ pix = pixelate_frames(
+ cut, target_h=target_h, palette_size=action.palette_size,
+ palette=palette, ref_height=ref_h,
+ )
+ return [_png(p) for p in pix]
+
+
+class PerFrameStrategy(DerivationStrategy):
+ """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。桩,待开发(#53)。"""
+
+ route = GenRoute.PER_FRAME
+
+ def __init__(self, image: ImageProvider, matte: MatteProvider) -> None:
+ self._image = image
+ self._matte = matte
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ progress.step("derive", 0, 1, f"{action.action}: 逐帧图生图")
+ # TODO(dev, #53): 逐 pose image.gen_image(母版, pose) → matte.cutout(不加骨架)
+ return [b"" for _ in range(action.n_frames)] # 桩
+
+
+class ProcIdleStrategy(DerivationStrategy):
+ """待机(idle):母版抠图 → 程序化局部躯干呼吸(Idle-B,零 API)。桩,待开发(#53)。"""
+
+ route = GenRoute.PROC_IDLE
+
+ def __init__(self, image: ImageProvider, matte: MatteProvider) -> None:
+ self._image = image
+ self._matte = matte
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ progress.step("derive", 0, 1, f"{action.action}: Idle-B 程序化呼吸")
+ # TODO(dev, #53): 母版抠图 → 躯干带保体积缩放,腿冻结
+ return [b"" for _ in range(action.n_frames)] # 桩
diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py
index 89f7b43d..598921e9 100644
--- a/backend/packages/app/src/windup_app/bootstrap/app.py
+++ b/backend/packages/app/src/windup_app/bootstrap/app.py
@@ -1,15 +1,108 @@
+
"""FastAPI 应用工厂与装配入口。
``create_app`` 负责创建 FastAPI 实例并挂载路由 / 中间件 / 异常处理,
是整个 web 服务的唯一装配点(composition root)。
+
+``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。
"""
+import asyncio
+import os
+import sys
+from contextlib import asynccontextmanager
+
+import windup_framework.db # noqa: F401 组装时显式触发 DB engine/session 初始化
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from windup_app.server.generation.executor import run_action_task, run_image_task
+from windup_app.web.api.agent import router as ai_router
+from windup_app.web.api.character import router as character_router
+from windup_app.web.api.generation import router as generation_router
from windup_app.web.api.media import router as media_router
+from windup_app.web.api.playtest_inspection import router as playtest_inspection_router
+from windup_app.web.api.project import router as project_router
+from windup_app.web.api.workflow_run import router as workflow_run_router
+from windup_app.web.handler.exception_handlers import register_exception_handlers
+
+
+def _env_flag(name: str) -> bool:
+ """把环境变量解析为真正的布尔值:仅 1/true/yes/on(忽略大小写与空白)视为 True。"""
+ return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def print_banner() -> None:
+ """启动时打印 banner(占位实现,后续替换为正式 ASCII banner)。"""
+ print("windup 0.1.0 starting ...")
+
+
+@asynccontextmanager
+async def _lifespan(app: FastAPI):
+ """应用启动时打印 banner,关闭时无特殊处理。"""
+ print_banner()
+ yield
def create_app() -> FastAPI:
- app = FastAPI(title="windup", version="0.1.0")
+ app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan)
+
+ # 本地开发 CORS: 允许前端 localhost 跨域访问
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[
+ "http://localhost:5173",
+ "http://localhost:5174",
+ "http://localhost:5175",
+ "http://localhost:5176",
+ "http://localhost:5177",
+ "http://127.0.0.1:5173",
+ "http://127.0.0.1:5174",
+ "http://127.0.0.1:5175",
+ "http://127.0.0.1:5176",
+ "http://127.0.0.1:5177",
+ ],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ app.include_router(project_router)
+ app.include_router(character_router)
app.include_router(media_router)
+ app.include_router(generation_router)
+ app.include_router(workflow_run_router)
+ app.include_router(ai_router)
+ app.include_router(playtest_inspection_router)
+ # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖,
+ # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。
+ app.state.run_action_task = run_action_task
+ app.state.run_image_task = run_image_task
+ register_exception_handlers(app)
return app
+
+
+def main() -> None:
+ """开发启动入口:用 uvicorn 跑 ``create_app``。
+
+ host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。
+ """
+ import uvicorn
+
+ # Uvicorn 0.51 会显式创建自己的 loop,单改全局 policy 会被覆盖。Windows 下
+ # 直接传 Selector factory,避免浏览器关闭 SSE 时 Proactor transport 打印 10054。
+ loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else "auto"
+
+ uvicorn.run(
+ "windup_app.bootstrap.app:create_app",
+ factory=True,
+ host=os.getenv("WINDUP_HOST", "127.0.0.1"),
+ port=int(os.getenv("WINDUP_PORT", "8000")),
+ reload=_env_flag("WINDUP_RELOAD"),
+ loop=loop_factory,
+ )
+
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py
index 5ea1c134..ee447afa 100644
--- a/backend/packages/app/src/windup_app/server/character/model.py
+++ b/backend/packages/app/src/windup_app/server/character/model.py
@@ -60,6 +60,8 @@ class Character(Base):
project_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ workflow_run_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
reference_image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -91,12 +93,20 @@ class Character(Base):
# ── character_data Pydantic 模型 ──────────────────────────────────────────────
+class CharacterRootMotion(BaseModel):
+ """单帧相对动作首帧的根位移。"""
+
+ dx: float
+ dy: float
+
+
class CharacterFrame(BaseModel):
"""动作帧。"""
index: int = Field(ge=0, description="帧序号")
image_url: str = Field(..., description="帧图片 URL")
duration_ms: int | None = Field(default=None, gt=0, description="帧时长(毫秒)")
+ root_motion: CharacterRootMotion | None = Field(default=None, description="根位移增量")
class CharacterAction(BaseModel):
@@ -125,4 +135,4 @@ class CharacterData(BaseModel):
"""角色完整数据(造型→动作→帧)。"""
version: int = Field(default=1, description="结构版本")
- outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
\ No newline at end of file
+ outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py
new file mode 100644
index 00000000..e1ff22d4
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/character/service.py
@@ -0,0 +1,69 @@
+"""角色领域服务的 SQLAlchemy 实现。
+
+:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步
+SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作
+模块级单例(:data:`service`)。
+
+事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常
+rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
+"""
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from windup_app.server.character.interface import CharacterService
+from windup_app.server.character.model import Character
+
+
+class SqlAlchemyCharacterService(CharacterService):
+ """基于 SQLAlchemy session 的角色 CRUD 实现。"""
+
+ def create_character(self, session: Session, **fields) -> Character:
+ character = Character(**fields)
+ session.add(character)
+ session.flush()
+ return character
+
+ def get_character(self, session: Session, character_id: int) -> Character | None:
+ return session.get(Character, character_id)
+
+ def list_characters(
+ self, session: Session, *, project_id: int, page: int, page_size: int,
+ ) -> tuple[list[Character], int]:
+ count_stmt = (
+ select(func.count())
+ .select_from(Character)
+ .where(Character.project_id == project_id)
+ )
+ stmt = (
+ select(Character)
+ .where(Character.project_id == project_id)
+ .order_by(Character.id.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ total = session.scalar(count_stmt) or 0
+ items = list(session.scalars(stmt))
+ return items, total
+
+ def update_character(
+ self, session: Session, character_id: int, **fields,
+ ) -> Character | None:
+ character = session.get(Character, character_id)
+ if character is None:
+ return None
+ for key, value in fields.items():
+ setattr(character, key, value)
+ session.flush()
+ return character
+
+ def delete_character(self, session: Session, character_id: int) -> bool:
+ character = session.get(Character, character_id)
+ if character is None:
+ return False
+ session.delete(character)
+ session.flush()
+ return True
+
+
+service = SqlAlchemyCharacterService()
diff --git a/backend/packages/app/src/windup_app/server/generation/__init__.py b/backend/packages/app/src/windup_app/server/generation/__init__.py
index a6701deb..e1d88118 100644
--- a/backend/packages/app/src/windup_app/server/generation/__init__.py
+++ b/backend/packages/app/src/windup_app/server/generation/__init__.py
@@ -7,9 +7,12 @@
CharacterActionOutput,
CharacterImageInput,
GenerationTask,
+ GenerationTaskRecord,
GenerationType,
TaskStatus,
)
+from windup_app.server.generation.service import service as generation_service
+from windup_app.server.generation import task_repo
__all__ = [
"ActionType",
@@ -18,6 +21,9 @@
"CharacterActionOutput",
"CharacterImageInput",
"GenerationTask",
+ "GenerationTaskRecord",
"GenerationType",
"TaskStatus",
+ "generation_service",
+ "task_repo",
]
diff --git a/backend/packages/app/src/windup_app/server/generation/executor.py b/backend/packages/app/src/windup_app/server/generation/executor.py
new file mode 100644
index 00000000..e3f71229
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/generation/executor.py
@@ -0,0 +1,409 @@
+"""动作生成后台编排(调 ai_engine)。
+
+编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。
+异常兜底为 FAILED,不抛。
+
+**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine,
+违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入
+``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。
+
+依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载,
+避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+import httpx
+from sqlalchemy.orm import Session
+
+from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard
+
+from windup_app.server.generation import task_repo
+from windup_app.server.generation.model import (
+ CharacterActionInput,
+ CharacterImageInput,
+ TaskStatus,
+)
+
+if TYPE_CHECKING:
+ from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort
+
+logger = logging.getLogger("windup.generation.executor")
+
+_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化
+
+# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ─────────────────────────
+# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角
+_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"}
+_PERSPECTIVE_VIEW: dict[int, str] = {
+ 1: "side view, horizontal side-scroller",
+ 2: "top-down view",
+ 3: "2.5D three-quarter view",
+}
+# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数
+_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8}
+
+
+@dataclass
+class ProjectConstraints:
+ """从 Project 取的全局生成约束,统一约束角色图/动作生成。"""
+
+ facing: str = "side" # character_perspective → 朝向(须与母版一致 #35)
+ view: str = "side view, horizontal side-scroller"
+ perspective: int = 1 # 1横版 2俯视 3 2.5D
+ directions: int = 1 # directional_movement → 方向数(1/4/8)
+ sprite_w: int = 256 # 输出/切帧尺寸(关键)
+ sprite_h: int = 256
+ style: str = "" # game_style 画风
+ stylize: str = "none" # 由 style 推:像素游戏 → pixel
+ sprite_sample_url: str = "" # 项目风格参考图 URL
+
+
+def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints:
+ """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。"""
+ if project_id is None:
+ return ProjectConstraints()
+ from windup_app.server.project.service import SqlAlchemyProjectService
+
+ p = SqlAlchemyProjectService().get_project(session, project_id)
+ if p is None:
+ return ProjectConstraints()
+ style = p.game_style or ""
+ is_pixel = "pixel" in style.lower() or "像素" in style
+ return ProjectConstraints(
+ facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"),
+ view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]),
+ perspective=p.character_perspective,
+ directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1),
+ sprite_w=p.sprite_width,
+ sprite_h=p.sprite_height,
+ style=style,
+ stylize="pixel" if is_pixel else "none",
+ sprite_sample_url=p.sprite_sample_url or "",
+ )
+
+
+def _fit_to(png: bytes, w: int, h: int) -> bytes:
+ """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。"""
+ import io
+
+ from PIL import Image
+
+ im = Image.open(io.BytesIO(png)).convert("RGBA")
+ if im.size == (w, h):
+ return png
+ fitted = im.copy()
+ fitted.thumbnail((w, h), Image.NEAREST)
+ canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2))
+ buf = io.BytesIO()
+ canvas.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _LogProgress:
+ """进度上报占位:MVP 无 SSE,记日志即可。"""
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ logger.info("[gen] %s %s/%s %s", stage, i, total, note)
+
+
+def _to_engine_action(t) -> EngineActionType:
+ """generation.ActionType → 引擎 common.ActionType(按值映射)。
+
+ walk/idle/attack/jump/custom 均支持视频路线;未覆盖的类型明确报错。
+ """
+ try:
+ return EngineActionType(t.value)
+ except ValueError as e:
+ raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e
+
+
+class ActionTaskExecutor:
+ """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。"""
+
+ def __init__(
+ self,
+ *,
+ generator: CharacterGeneratorPort | None = None,
+ upload: Callable[[bytes], str] | None = None,
+ fetch_master: Callable[[CharacterActionInput], bytes] | None = None,
+ fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None,
+ session_factory: Callable[[], Session] | None = None,
+ ) -> None:
+ self._generator = generator # None → 懒加载真实装配
+ self._upload = upload # None → 真实对象存储上传
+ self._fetch_master = fetch_master # None → 下载 reference_image_urls[0]
+ self._fetch_constraints = fetch_constraints # None → 查 project 全局约束
+ self._session_factory = session_factory # None → SessionLocal
+
+ def run_action_task(
+ self,
+ task_id: int,
+ input: CharacterActionInput,
+ project_id: int | None = None,
+ *,
+ session: Session | None = None,
+ ) -> None:
+ """跑一个动作任务;异常兜底为 FAILED,不抛。
+
+ 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session``
+ 缺省时自开一个(后台场景);测试可传入自己的 session。
+ """
+ own = session is None
+ session = session or self._make_session()
+ try:
+ task_repo.update_status(session, task_id, TaskStatus.RUNNING)
+ if own:
+ session.commit()
+
+ cons = (self._fetch_constraints or _load_constraints)(session, project_id)
+ result = self._produce_action(input, cons)
+ task_repo.update_result(session, task_id, _ACTION_RESULT, result)
+ if own:
+ session.commit()
+ except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常
+ logger.exception("动作任务 %s 失败", task_id)
+ task_repo.update_status(
+ session, task_id, TaskStatus.FAILED, error_message=str(exc),
+ )
+ if own:
+ session.commit()
+ finally:
+ if own:
+ session.close()
+
+ # -- 内部 --------------------------------------------------------------
+
+ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict:
+ """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。
+
+ 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、
+ 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向,
+ 四向/八向为扩展(需多次生成或镜像)。
+ """
+ if cons.directions > 1:
+ logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions)
+ master = (self._fetch_master or self._download_master)(input)
+ # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字
+ desc_parts = [input.custom_prompt or ""]
+ if cons.style:
+ desc_parts.append(f"Art style: {cons.style}")
+ card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts))
+ engine_action = _to_engine_action(input.action_type)
+ action = ActionSpec(
+ action=engine_action,
+ poses=[""] * input.num_frames,
+ facing=cons.facing,
+ stylize=cons.stylize,
+ # 自定义动作:动作描述进 i2v 提示词;其他动作类型忽略该字段
+ action_desc=input.custom_prompt or "" if engine_action is EngineActionType.CUSTOM else "",
+ )
+ progress: ProgressPort = _LogProgress()
+ generated = self._get_generator().generate(card, action, master, progress)
+
+ upload = self._upload or self._upload_frame
+ frames = [
+ {"index": i,
+ "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)),
+ "duration_ms": dur}
+ for i, (png, dur) in enumerate(zip(generated.frames, generated.durations))
+ ]
+ return {"type": "character_action", "action_type": input.action_type.value, "frames": frames}
+
+ def _get_generator(self) -> CharacterGeneratorPort:
+ """懒装配真实 CharacterGenerator(视频路线 + 桩路线)。"""
+ if self._generator is None:
+ from windup_ai_engine.impl import CharacterGenerator
+ from windup_ai_engine.strategy.concrete import (
+ PerFrameStrategy,
+ ProcIdleStrategy,
+ VideoFrameStrategy,
+ )
+ from windup_common.models import GenRoute
+ from windup_framework.providers import (
+ OnnxU2NetMatteProvider,
+ SufyImageProvider,
+ SufyVideoProvider,
+ )
+
+ matte = OnnxU2NetMatteProvider()
+ video = SufyVideoProvider()
+ image = SufyImageProvider()
+ self._generator = CharacterGenerator({
+ GenRoute.VIDEO_I2V: VideoFrameStrategy(video, matte),
+ GenRoute.PER_FRAME: PerFrameStrategy(image, matte),
+ GenRoute.PROC_IDLE: ProcIdleStrategy(image, matte),
+ })
+ return self._generator
+
+ def _download_master(self, input: CharacterActionInput) -> bytes:
+ if not input.reference_image_urls:
+ raise ValueError("缺少母版:reference_image_urls 为空")
+ resp = httpx.get(input.reference_image_urls[0], timeout=30.0)
+ resp.raise_for_status()
+ return resp.content
+
+ def _upload_frame(self, png: bytes) -> str:
+ from windup_app.server.media.model import MediaCategory, MediaUploadInput
+ from windup_app.server.media.service import service as media_service
+
+ meta = MediaUploadInput(
+ filename="frame.png",
+ content_type="image/png",
+ size=len(png),
+ category=MediaCategory.ACTION_FRAME,
+ )
+ return media_service.upload(png, meta).url
+
+ def _make_session(self) -> Session:
+ if self._session_factory is not None:
+ return self._session_factory()
+ from windup_framework.db.session import SessionLocal
+
+ return SessionLocal()
+
+
+_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化
+
+
+class ImageTaskExecutor:
+ """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。"""
+
+ def __init__(
+ self,
+ *,
+ image=None, # None → 懒加载 SufyImageProvider
+ upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传
+ fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url
+ session_factory: Callable[[], Session] | None = None,
+ ) -> None:
+ self._image = image
+ self._upload = upload
+ self._fetch_ref = fetch_ref
+ self._session_factory = session_factory
+
+ def run_image_task(
+ self,
+ task_id: int,
+ input: CharacterImageInput,
+ project_id: int | None = None,
+ *,
+ session: Session | None = None,
+ ) -> None:
+ own = session is None
+ session = session or self._make_session()
+ try:
+ task_repo.update_status(session, task_id, TaskStatus.RUNNING)
+ if own:
+ session.commit()
+ cons = _load_constraints(session, project_id) # 角色图也受项目约束
+ urls = self._produce_image(input, cons)
+ task_repo.update_result(session, task_id, _IMAGE_RESULT, {
+ "type": "character_image",
+ "image_urls": urls,
+ })
+ if own:
+ session.commit()
+ except Exception as exc: # noqa: BLE001 —— 兜底
+ logger.exception("图片任务 %s 失败", task_id)
+ task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc))
+ if own:
+ session.commit()
+ finally:
+ if own:
+ session.close()
+
+ def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]:
+ """根据项目约束决定生成模式,返回 URL 列表。
+
+ 模式判断:
+ - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词
+ - 项目无 sprite_sample_url → **文生图**: 纯提示词
+ 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。
+ """
+ fetch = self._fetch_ref or self._download
+ refs: list[bytes] = []
+ has_style_ref = False
+
+ # 1. 角色参考图(用户传入,可选,做角色一致性约束)
+ char_url = (input.reference_image_url or "").strip()
+ if char_url and char_url.lower() not in ("null", "none", ""):
+ refs.append(fetch(char_url))
+
+ # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式)
+ style_url = (cons.sprite_sample_url or "").strip()
+ if style_url and style_url.lower() not in ("null", "none", ""):
+ try:
+ refs.append(fetch(style_url))
+ has_style_ref = True
+ except Exception:
+ pass # 风格参考图下载失败不阻断
+
+ # 3. 构建提示词
+ base = input.prompt or "Clean full-body character reference of the figure in the image."
+ parts = [base, f"{cons.view}, full body head to feet, centered."]
+ if cons.style:
+ parts.append(f"Art style: {cons.style}.")
+ parts.append("Plain light-gray background, no shadow.")
+
+ # 图生图模式:明确标注两张图的各自用途
+ if has_style_ref:
+ prefix = (
+ "This is an image-to-image task. "
+ "The first image is the CHARACTER reference — preserve its identity. "
+ "The second image is the STYLE reference — follow its art style, "
+ "color palette, and rendering technique. "
+ )
+ parts.insert(0, prefix)
+
+ prompt = " ".join(parts)
+
+ image_gen = self._get_image()
+ upload = self._upload or self._upload_image
+ urls: list[str] = []
+ for _ in range(max(1, input.num_images)):
+ img = image_gen.gen_image(prompt, refs)
+ urls.append(upload(img))
+ return urls
+
+ def _get_image(self):
+ if self._image is None:
+ from windup_framework.providers import SufyImageProvider
+
+ self._image = SufyImageProvider()
+ return self._image
+
+ def _download(self, url: str) -> bytes:
+ resp = httpx.get(url, timeout=30.0)
+ resp.raise_for_status()
+ return resp.content
+
+ def _upload_image(self, png: bytes) -> str:
+ from windup_app.server.media.model import MediaCategory, MediaUploadInput
+ from windup_app.server.media.service import service as media_service
+
+ meta = MediaUploadInput(
+ filename="character.png", content_type="image/png",
+ size=len(png), category=MediaCategory.REFERENCE_IMAGE,
+ )
+ return media_service.upload(png, meta).url
+
+ def _make_session(self) -> Session:
+ if self._session_factory is not None:
+ return self._session_factory()
+ from windup_framework.db.session import SessionLocal
+
+ return SessionLocal()
+
+
+# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state
+executor = ActionTaskExecutor()
+run_action_task = executor.run_action_task
+image_executor = ImageTaskExecutor()
+run_image_task = image_executor.run_image_task
diff --git a/backend/packages/app/src/windup_app/server/generation/interface.py b/backend/packages/app/src/windup_app/server/generation/interface.py
index b43bace3..409b9e00 100644
--- a/backend/packages/app/src/windup_app/server/generation/interface.py
+++ b/backend/packages/app/src/windup_app/server/generation/interface.py
@@ -7,21 +7,24 @@
--------
1. 前端调用 ``generate_character_image`` / ``generate_character_action`` 提交任务,
拿到 ``task_id``。
-2. 前端通过 SSE 订阅任务状态变更,无需轮询。
- web 层提供 ``GET /generation/tasks/{task_id}/stream`` 端点,
- 服务端在任务状态变化时推送 ``task_update`` 事件。
- 事件 payload 包含 ``task_id`` / ``task_type`` / ``status``,
- 完成时附带 ``result``,失败时附带 ``error_message``。
+2. 前端通过 SSE 订阅状态,刷新恢复时使用 ``get_task`` 查询当前快照。
3. 前端从 ``task.status`` 判断完成,从 ``result`` 取出出参,回填 character 模块:
.. code-block:: text
CharacterImageOutput.image_url → Character.reference_image_url
CharacterActionOutput.frames[] → character_data.outfits[].actions[].frames[]
+
+约定
+----
+- session-per-call: ``session`` 由调用方(FastAPI 的 ``get_session`` 依赖)按请求传入。
+- 具体实现保持无状态,可作为模块级单例。
"""
from abc import ABC, abstractmethod
+from sqlalchemy.orm import Session
+
from windup_app.server.generation.model import (
CharacterActionInput,
CharacterImageInput,
@@ -35,7 +38,9 @@ class GenerationService(ABC):
# -- 任务提交 ------------------------------------------------------------
@abstractmethod
- def generate_character_image(self, input: CharacterImageInput) -> GenerationTask:
+ def generate_character_image(
+ self, session: Session, *, user_id: int, input: CharacterImageInput,
+ ) -> GenerationTask:
"""提交角色图片生成任务。
入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``,
@@ -43,7 +48,9 @@ def generate_character_image(self, input: CharacterImageInput) -> GenerationTask
"""
@abstractmethod
- def generate_character_action(self, input: CharacterActionInput) -> GenerationTask:
+ def generate_character_action(
+ self, session: Session, *, user_id: int, input: CharacterActionInput,
+ ) -> GenerationTask:
"""提交角色动作生成任务。
入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``,
@@ -53,7 +60,9 @@ def generate_character_action(self, input: CharacterActionInput) -> GenerationTa
# -- 查询 ----------------------------------------------------------------
@abstractmethod
- def get_task(self, project_id: int, task_id: int) -> GenerationTask | None:
+ def get_task(
+ self, session: Session, project_id: int, task_id: int,
+ ) -> GenerationTask | None:
"""查询任务状态与结果。
返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成,
diff --git a/backend/packages/app/src/windup_app/server/generation/model.py b/backend/packages/app/src/windup_app/server/generation/model.py
index 36fdab58..2449d039 100644
--- a/backend/packages/app/src/windup_app/server/generation/model.py
+++ b/backend/packages/app/src/windup_app/server/generation/model.py
@@ -9,6 +9,12 @@
from datetime import datetime, timezone
from enum import StrEnum
+from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column
+
+from windup_framework.db import Base
+
# -- 枚举 ----------------------------------------------------------------
@@ -16,8 +22,8 @@
class GenerationType(StrEnum):
"""生成任务类型——每新增一种生成能力,在此加一个成员。"""
- CHARACTER_IMAGE = "character_image" # 角色参考图
- CHARACTER_ACTION = "character_action" # 角色动作帧序列
+ CHARACTER_IMAGE = "character_image" # 角色参考图
+ CHARACTER_ACTION = "character_action" # 角色动作帧序列
class ActionType(StrEnum):
@@ -25,6 +31,7 @@ class ActionType(StrEnum):
WALK = "walk"
IDLE = "idle"
+ JUMP = "jump"
ATTACK = "attack"
CUSTOM = "custom"
@@ -41,6 +48,9 @@ class TaskStatus(StrEnum):
# -- 入参 ----------------------------------------------------------------
+DEFAULT_ACTION_FRAME_COUNT = 32
+
+
@dataclass
class CharacterImageInput:
"""角色图片生成入参。"""
@@ -62,7 +72,7 @@ class CharacterActionInput:
custom_prompt: str | None = None
reference_video_url: str | None = None
reference_image_urls: list[str] = field(default_factory=list)
- num_frames: int = 16
+ num_frames: int = DEFAULT_ACTION_FRAME_COUNT
# -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------
@@ -124,3 +134,57 @@ class GenerationTask:
@property
def is_terminal(self) -> bool:
return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED)
+
+
+# -- ORM -----------------------------------------------------------------
+
+
+class GenerationTaskRecord(Base):
+ """生成任务持久化记录。
+
+ ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识
+ ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。
+ """
+
+ __tablename__ = "windup_generation_task"
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+ task_type: Mapped[str] = mapped_column(
+ Text,
+ nullable=False,
+ default=GenerationType.CHARACTER_IMAGE.value,
+ )
+ status: Mapped[str] = mapped_column(
+ Text,
+ nullable=False,
+ default=TaskStatus.PENDING.value,
+ )
+ input_payload: Mapped[dict] = mapped_column(
+ JSON().with_variant(JSONB, "postgresql"),
+ nullable=False,
+ default=dict,
+ )
+ result_type: Mapped[str | None] = mapped_column(Text, nullable=True)
+ result: Mapped[dict | None] = mapped_column(
+ JSON().with_variant(JSONB, "postgresql"),
+ nullable=True,
+ )
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ update_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
diff --git a/backend/packages/app/src/windup_app/server/generation/service.py b/backend/packages/app/src/windup_app/server/generation/service.py
new file mode 100644
index 00000000..2be42941
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/generation/service.py
@@ -0,0 +1,76 @@
+"""生成任务领域服务(提交 + 查询)。
+
+:class:`AiGenerationService` 只负责**建任务记录 + 查任务**——web 层依赖本模块。
+实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**,
+以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。
+
+无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy.orm import Session
+
+from windup_app.server.generation import task_repo
+from windup_app.server.generation.interface import GenerationService
+from windup_app.server.generation.model import (
+ CharacterActionInput,
+ CharacterImageInput,
+ GenerationTask,
+ GenerationType,
+ TaskStatus,
+)
+
+
+_STALE_TASK_AGE = timedelta(minutes=15)
+_STALE_TASK_MESSAGE = "生成任务长时间无进展,后台执行可能已中断,请重试"
+
+
+class AiGenerationService(GenerationService):
+ """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。"""
+
+ def generate_character_image(
+ self, session: Session, *, user_id: int, project_id: int | None = None,
+ input: CharacterImageInput,
+ ) -> GenerationTask:
+ return task_repo.create_task(
+ session, user_id=user_id, project_id=project_id,
+ task_type=GenerationType.CHARACTER_IMAGE,
+ input_payload=dataclasses.asdict(input),
+ )
+
+ def generate_character_action(
+ self, session: Session, *, user_id: int, project_id: int | None = None,
+ input: CharacterActionInput,
+ ) -> GenerationTask:
+ """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。"""
+ return task_repo.create_task(
+ session, user_id=user_id, project_id=project_id,
+ task_type=GenerationType.CHARACTER_ACTION,
+ input_payload=dataclasses.asdict(input),
+ )
+
+ def get_task(
+ self, session: Session, project_id: int, task_id: int,
+ ) -> GenerationTask | None:
+ task = task_repo.get_task(session, task_id)
+ if task is None or task.is_terminal:
+ return task
+ updated_at = task.update_at
+ if updated_at.tzinfo is None:
+ updated_at = updated_at.replace(tzinfo=timezone.utc)
+ if datetime.now(timezone.utc) - updated_at < _STALE_TASK_AGE:
+ return task
+ task_repo.update_status(
+ session,
+ task_id,
+ TaskStatus.FAILED,
+ error_message=_STALE_TASK_MESSAGE,
+ )
+ return task_repo.get_task(session, task_id)
+
+
+service = AiGenerationService()
diff --git a/backend/packages/app/src/windup_app/server/generation/task_repo.py b/backend/packages/app/src/windup_app/server/generation/task_repo.py
new file mode 100644
index 00000000..e48670e4
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/generation/task_repo.py
@@ -0,0 +1,158 @@
+"""生成任务数据访问层。
+
+纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``,
+由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只
+``flush`` 不 ``commit``。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from windup_app.server.generation.model import (
+ CharacterActionOutput,
+ CharacterImageOutput,
+ GenerationTask,
+ GenerationTaskRecord,
+ GenerationType,
+ TaskStatus,
+)
+
+
+# ── 写入 ─────────────────────────────────────────────────────────────────
+
+
+def create_task(
+ session: Session,
+ *,
+ user_id: int,
+ project_id: int | None,
+ task_type: GenerationType,
+ input_payload: dict,
+) -> GenerationTask:
+ """创建生成任务记录,返回领域对象。"""
+ record = GenerationTaskRecord(
+ user_id=user_id,
+ project_id=project_id,
+ task_type=task_type.value,
+ status=TaskStatus.PENDING.value,
+ input_payload=input_payload,
+ )
+ session.add(record)
+ session.flush()
+ return _record_to_domain(record)
+
+
+def update_status(
+ session: Session,
+ task_id: int,
+ status: TaskStatus,
+ *,
+ error_message: str | None = None,
+) -> None:
+ """更新任务状态(可选附带错误信息)。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return
+ record.status = status.value
+ record.error_message = error_message
+ record.update_at = datetime.now(timezone.utc)
+ session.flush()
+
+
+def update_result(
+ session: Session,
+ task_id: int,
+ result_type: str,
+ result: dict,
+) -> None:
+ """写入任务结果。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return
+ record.result_type = result_type
+ record.result = result
+ record.status = TaskStatus.COMPLETED.value
+ record.update_at = datetime.now(timezone.utc)
+ session.flush()
+
+
+# ── 读取 ─────────────────────────────────────────────────────────────────
+
+
+def get_task(session: Session, task_id: int) -> GenerationTask | None:
+ """按 task_id 查询任务。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return None
+ return _record_to_domain(record)
+
+
+def get_task_by_user(
+ session: Session,
+ user_id: int,
+ task_id: int,
+) -> GenerationTask | None:
+ """按 user_id + task_id 查询(校验归属)。"""
+ stmt = select(GenerationTaskRecord).where(
+ GenerationTaskRecord.id == task_id,
+ GenerationTaskRecord.user_id == user_id,
+ )
+ record = session.scalar(stmt)
+ if record is None:
+ return None
+ return _record_to_domain(record)
+
+
+# ── 转换 ─────────────────────────────────────────────────────────────────
+
+
+def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask:
+ """ORM 记录 → 领域 dataclass。"""
+ result = _deserialize_result(record.result_type, record.result)
+ return GenerationTask(
+ id=record.id,
+ user_id=record.user_id,
+ project_id=record.project_id,
+ task_type=GenerationType(record.task_type),
+ status=TaskStatus(record.status),
+ input_payload=record.input_payload,
+ result=result,
+ error_message=record.error_message,
+ create_at=record.create_at,
+ update_at=record.update_at,
+ )
+
+
+def _deserialize_result(
+ result_type: str | None,
+ raw: dict | None,
+) -> CharacterImageOutput | CharacterActionOutput | None:
+ """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。"""
+ if raw is None or result_type is None:
+ return None
+ if result_type == "character_image":
+ return CharacterImageOutput(
+ type=raw.get("type", "character_image"),
+ image_urls=raw.get("image_urls", []),
+ )
+ if result_type == "character_action":
+ from windup_app.server.generation.model import CharacterActionFrame
+
+ frames = [
+ CharacterActionFrame(
+ index=f["index"],
+ image_url=f["image_url"],
+ duration_ms=f.get("duration_ms"),
+ )
+ for f in raw.get("frames", [])
+ ]
+ return CharacterActionOutput(
+ type=raw.get("type", "character_action"),
+ action_type=raw.get("action_type", ""),
+ frames=frames,
+ )
+ return None
diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py
new file mode 100644
index 00000000..8c7ad072
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py
@@ -0,0 +1 @@
+"""Playtest 核验记录领域。"""
diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py
new file mode 100644
index 00000000..92ad3422
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py
@@ -0,0 +1,34 @@
+"""Playtest 核验记录服务接口。"""
+
+from abc import ABC, abstractmethod
+
+from sqlalchemy.orm import Session
+
+from windup_app.server.playtest_inspection.model import PlaytestInspection
+
+
+class PlaytestInspectionService(ABC):
+ """读取和保存动作当前核验结论的边界。"""
+
+ @abstractmethod
+ def get_inspection(
+ self,
+ session: Session,
+ *,
+ character_id: int,
+ outfit_id: str,
+ action_id: str,
+ ) -> PlaytestInspection | None:
+ """按动作定位当前核验结论。"""
+
+ @abstractmethod
+ def save_inspection(
+ self,
+ session: Session,
+ *,
+ character_id: int,
+ outfit_id: str,
+ action_id: str,
+ status: str,
+ ) -> PlaytestInspection:
+ """新增或覆盖动作当前核验结论。"""
diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/model.py b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py
new file mode 100644
index 00000000..ce6dcfd0
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py
@@ -0,0 +1,43 @@
+"""Playtest 核验记录 ORM 模型。"""
+
+from datetime import datetime, timezone
+
+from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from windup_framework.db import Base
+
+
+class PlaytestInspection(Base):
+ """某个角色动作当前最新的 Playtest 核验结论。"""
+
+ __tablename__ = "windup_playtest_inspection"
+ __table_args__ = (
+ UniqueConstraint(
+ "character_id",
+ "outfit_id",
+ "action_id",
+ name="uq_playtest_inspection_target",
+ ),
+ )
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ character_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
+ outfit_id: Mapped[str] = mapped_column(String(128), nullable=False)
+ action_id: Mapped[str] = mapped_column(String(128), nullable=False)
+ status: Mapped[str] = mapped_column(String(24), nullable=False)
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ update_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/service.py b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py
new file mode 100644
index 00000000..1e130968
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py
@@ -0,0 +1,57 @@
+"""Playtest 核验记录的 SQLAlchemy 实现。"""
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from windup_app.server.playtest_inspection.interface import PlaytestInspectionService
+from windup_app.server.playtest_inspection.model import PlaytestInspection
+
+
+class SqlAlchemyPlaytestInspectionService(PlaytestInspectionService):
+ """只保留每个动作最新核验结论,不形成历史版本链。"""
+
+ def get_inspection(
+ self,
+ session: Session,
+ *,
+ character_id: int,
+ outfit_id: str,
+ action_id: str,
+ ) -> PlaytestInspection | None:
+ statement = select(PlaytestInspection).where(
+ PlaytestInspection.character_id == character_id,
+ PlaytestInspection.outfit_id == outfit_id,
+ PlaytestInspection.action_id == action_id,
+ )
+ return session.scalar(statement)
+
+ def save_inspection(
+ self,
+ session: Session,
+ *,
+ character_id: int,
+ outfit_id: str,
+ action_id: str,
+ status: str,
+ ) -> PlaytestInspection:
+ inspection = self.get_inspection(
+ session,
+ character_id=character_id,
+ outfit_id=outfit_id,
+ action_id=action_id,
+ )
+ if inspection is None:
+ inspection = PlaytestInspection(
+ character_id=character_id,
+ outfit_id=outfit_id,
+ action_id=action_id,
+ status=status,
+ )
+ session.add(inspection)
+ else:
+ inspection.status = status
+ session.flush()
+ return inspection
+
+
+service = SqlAlchemyPlaytestInspectionService()
diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py
new file mode 100644
index 00000000..c51198ab
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/project/service.py
@@ -0,0 +1,76 @@
+"""项目领域服务的 SQLAlchemy 实现。
+
+:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步
+SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作
+模块级单例(:data:`service`)。
+
+事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常
+rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
+"""
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.orm import Session
+
+from windup_app.server.character.model import Character
+from windup_app.server.playtest_inspection.model import PlaytestInspection
+from windup_app.server.project.interface import ProjectService
+from windup_app.server.project.model import Project
+
+
+class SqlAlchemyProjectService(ProjectService):
+ """基于 SQLAlchemy session 的项目 CRUD 实现。"""
+
+ def create_project(self, session: Session, **fields) -> Project:
+ project = Project(**fields)
+ session.add(project)
+ session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at)
+ return project
+
+ def project_name_exists(
+ self, session: Session, *, user_id: int, project_name: str
+ ) -> bool:
+ stmt = (
+ select(Project.id)
+ .where(Project.user_id == user_id, Project.project_name == project_name)
+ .limit(1)
+ )
+ return session.scalar(stmt) is not None
+
+ def get_project(self, session: Session, project_id: int) -> Project | None:
+ return session.get(Project, project_id)
+
+ def list_projects(
+ self, session: Session, *, page: int, page_size: int, user_id: int | None = None
+ ) -> tuple[list[Project], int]:
+ count_stmt = select(func.count()).select_from(Project)
+ stmt = select(Project)
+ if user_id is not None:
+ count_stmt = count_stmt.where(Project.user_id == user_id)
+ stmt = stmt.where(Project.user_id == user_id)
+ total = session.scalar(count_stmt) or 0
+ stmt = (
+ stmt.order_by(Project.id.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ items = list(session.scalars(stmt))
+ return items, total
+
+ def delete_project(self, session: Session, project_id: int) -> bool:
+ project = session.get(Project, project_id)
+ if project is None:
+ return False
+
+ character_ids = select(Character.id).where(Character.project_id == project_id)
+ session.execute(
+ delete(PlaytestInspection).where(
+ PlaytestInspection.character_id.in_(character_ids)
+ )
+ )
+ session.execute(delete(Character).where(Character.project_id == project_id))
+ session.delete(project)
+ session.flush()
+ return True
+
+
+service = SqlAlchemyProjectService()
diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py
new file mode 100644
index 00000000..54488164
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/character.py
@@ -0,0 +1,173 @@
+"""角色 CRUD API。"""
+
+import logging
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.exceptions import BizException
+from windup_common.result import ListResponse, Response
+from windup_framework.config.storage import settings as storage_settings
+from windup_framework.db import get_session
+
+from windup_app.server.character.model import Character, CharacterData
+from windup_app.server.character.service import service as character_service
+from windup_app.server.media.service import service as media_service
+
+logger = logging.getLogger("windup.character.api")
+
+router = APIRouter(prefix="/characters", tags=["characters"])
+
+
+# ── 请求 / 响应模型 ─────────────────────────────────────────────────────────
+
+
+class CharacterCreate(BaseModel):
+ """创建角色请求。"""
+
+ project_id: int = Field(gt=0)
+ description: str | None = None
+ reference_image_url: str | None = None
+ character_data: CharacterData = Field(default_factory=CharacterData)
+
+
+class CharacterUpdate(BaseModel):
+ """更新角色请求——所有字段可选。"""
+
+ project_id: int | None = Field(default=None, gt=0)
+ description: str | None = None
+ reference_image_url: str | None = None
+ character_data: CharacterData | None = None
+
+
+class CharacterOut(BaseModel):
+ """角色响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ project_id: int
+ description: str | None = None
+ reference_image_url: str | None = None
+ character_data: dict
+ status: int
+
+
+# ── 辅助函数 ─────────────────────────────────────────────────────────────────
+
+
+def _extract_object_keys(character: Character) -> list[str]:
+ """从角色中提取所有对象存储 key,用于删除时清理资源。
+
+ URL 格式: ``{download_base}/{object_key}``
+ """
+ prefix = storage_settings.download_base + "/"
+ keys: list[str] = []
+
+ # 参考图
+ url = character.reference_image_url
+ if url and url.startswith(prefix):
+ keys.append(url[len(prefix) :])
+
+ # character_data 内的 URL
+ data = character.character_data or {}
+ for outfit in data.get("outfits", []):
+ url = outfit.get("preview_url")
+ if url and url.startswith(prefix):
+ keys.append(url[len(prefix) :])
+ for action in outfit.get("actions", []):
+ for frame in action.get("frames", []):
+ url = frame.get("image_url")
+ if url and url.startswith(prefix):
+ keys.append(url[len(prefix) :])
+
+ return keys
+
+
+# ── 端点 ─────────────────────────────────────────────────────────────────────
+
+
+@router.post("", response_model=Response[CharacterOut])
+def create_character(
+ body: CharacterCreate,
+ session: Session = Depends(get_session),
+) -> Response[CharacterOut]:
+ character = character_service.create_character(
+ session,
+ project_id=body.project_id,
+ description=body.description,
+ reference_image_url=body.reference_image_url,
+ character_data=body.character_data.model_dump(),
+ )
+ return Response.success(CharacterOut.model_validate(character), message="创建成功")
+
+
+@router.get("", response_model=ListResponse[CharacterOut])
+def list_characters(
+ project_id: int = Query(..., gt=0),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ session: Session = Depends(get_session),
+) -> ListResponse[CharacterOut]:
+ items, total = character_service.list_characters(
+ session,
+ project_id=project_id,
+ page=page,
+ page_size=page_size,
+ )
+ return ListResponse.success(
+ [CharacterOut.model_validate(c) for c in items],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+
+@router.get("/{character_id}", response_model=Response[CharacterOut])
+def get_character(
+ character_id: int,
+ session: Session = Depends(get_session),
+) -> Response[CharacterOut]:
+ character = character_service.get_character(session, character_id)
+ if character is None:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND)
+ return Response.success(CharacterOut.model_validate(character))
+
+
+@router.patch("/{character_id}", response_model=Response[CharacterOut])
+def update_character(
+ character_id: int,
+ body: CharacterUpdate,
+ session: Session = Depends(get_session),
+) -> Response[CharacterOut]:
+ fields = body.model_dump(exclude_unset=True)
+ character = character_service.update_character(session, character_id, **fields)
+ if character is None:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND)
+ return Response.success(CharacterOut.model_validate(character), message="更新成功")
+
+
+@router.delete("/{character_id}", response_model=Response[bool])
+def delete_character(
+ character_id: int,
+ session: Session = Depends(get_session),
+) -> Response[bool]:
+ character = character_service.get_character(session, character_id)
+ if character is None:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND)
+
+ # 先提取对象 key,再删 DB 记录
+ object_keys = _extract_object_keys(character)
+
+ character_service.delete_character(session, character_id)
+
+ # 清理对象存储——失败只记日志,不回滚 DB
+ for key in object_keys:
+ try:
+ media_service.delete(key)
+ except Exception:
+ logger.warning("[WINDUP] 媒体清理失败(已跳过) | key=%s", key, exc_info=True)
+
+ return Response.success(True, message="删除成功")
diff --git a/backend/packages/app/src/windup_app/web/api/playtest_inspection.py b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py
new file mode 100644
index 00000000..c2c01ae9
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py
@@ -0,0 +1,89 @@
+"""Playtest 核验记录 API。"""
+
+from datetime import datetime
+from typing import Literal
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.exceptions import BizException
+from windup_common.result import Response
+from windup_framework.db import get_session
+
+from windup_app.server.character.service import service as character_service
+from windup_app.server.playtest_inspection.service import service
+
+router = APIRouter(prefix="/playtest-inspections", tags=["playtest"])
+
+InspectionStatus = Literal["passed", "issues_found"]
+
+
+class PlaytestInspectionSave(BaseModel):
+ """保存动作当前核验结论的请求。"""
+
+ character_id: int = Field(gt=0)
+ outfit_id: str = Field(min_length=1, max_length=128)
+ action_id: str = Field(min_length=1, max_length=128)
+ status: InspectionStatus
+
+
+class PlaytestInspectionOut(PlaytestInspectionSave):
+ """动作当前核验结论。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ create_at: datetime
+ update_at: datetime
+
+
+def _require_action(
+ session: Session, *, character_id: int, outfit_id: str, action_id: str
+) -> None:
+ character = character_service.get_character(session, character_id)
+ if character is None:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND)
+
+ outfits = (character.character_data or {}).get("outfits", [])
+ outfit = next((item for item in outfits if item.get("id") == outfit_id), None)
+ if outfit is None:
+ raise BizException("造型不存在", code=BizCode.NOT_FOUND)
+ if not any(item.get("id") == action_id for item in outfit.get("actions", [])):
+ raise BizException("动作不存在", code=BizCode.NOT_FOUND)
+
+
+@router.get("", response_model=Response[PlaytestInspectionOut])
+def get_playtest_inspection(
+ character_id: int = Query(..., gt=0),
+ outfit_id: str = Query(..., min_length=1, max_length=128),
+ action_id: str = Query(..., min_length=1, max_length=128),
+ session: Session = Depends(get_session),
+) -> Response[PlaytestInspectionOut]:
+ inspection = service.get_inspection(
+ session,
+ character_id=character_id,
+ outfit_id=outfit_id,
+ action_id=action_id,
+ )
+ if inspection is None:
+ raise BizException("尚未核验", code=BizCode.NOT_FOUND)
+ return Response.success(PlaytestInspectionOut.model_validate(inspection))
+
+
+@router.post("", response_model=Response[PlaytestInspectionOut])
+def save_playtest_inspection(
+ body: PlaytestInspectionSave,
+ session: Session = Depends(get_session),
+) -> Response[PlaytestInspectionOut]:
+ _require_action(
+ session,
+ character_id=body.character_id,
+ outfit_id=body.outfit_id,
+ action_id=body.action_id,
+ )
+ inspection = service.save_inspection(session, **body.model_dump())
+ return Response.success(
+ PlaytestInspectionOut.model_validate(inspection), message="核验已保存"
+ )
diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py
new file mode 100644
index 00000000..723c5b40
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/project.py
@@ -0,0 +1,107 @@
+"""项目 CRUD API。"""
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.exceptions import BizException
+from windup_common.result import ListResponse, Response
+from windup_framework.db import get_session
+
+from windup_app.server.project.service import service
+
+logger = logging.getLogger("windup.project.api")
+
+router = APIRouter(prefix="/projects", tags=["projects"])
+
+
+class ProjectCreate(BaseModel):
+ """创建项目请求。"""
+
+ user_id: int = Field(gt=0)
+ workflow_id: int | None = None
+ project_name: str = Field(min_length=1, max_length=64)
+ character_perspective: int = Field(ge=1, le=3)
+ directional_movement: int = Field(ge=1, le=3)
+ sprite_width: int = Field(ge=32, le=2048)
+ sprite_height: int = Field(ge=32, le=2048)
+ game_style: str | None = None
+ sprite_sample_url: str | None = None
+
+
+class ProjectOut(ProjectCreate):
+ """项目响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ create_at: datetime
+ update_at: datetime
+
+
+@router.post("", response_model=Response[ProjectOut])
+def create_project(
+ body: ProjectCreate, session: Session = Depends(get_session)
+) -> Response[ProjectOut]:
+ if service.project_name_exists(
+ session, user_id=body.user_id, project_name=body.project_name
+ ):
+ logger.warning(
+ "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s",
+ body.user_id,
+ body.project_name,
+ )
+ raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST)
+ try:
+ project = service.create_project(session, **body.model_dump())
+ except IntegrityError:
+ logger.warning(
+ "[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s",
+ body.user_id,
+ body.project_name,
+ )
+ session.rollback()
+ raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None
+ return Response.success(ProjectOut.model_validate(project), message="创建成功")
+
+
+@router.get("", response_model=ListResponse[ProjectOut])
+def list_projects(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ user_id: int | None = Query(None, gt=0),
+ session: Session = Depends(get_session),
+) -> ListResponse[ProjectOut]:
+ projects, total = service.list_projects(
+ session, page=page, page_size=page_size, user_id=user_id
+ )
+ return ListResponse.success(
+ [ProjectOut.model_validate(item) for item in projects],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+
+@router.get("/{project_id}", response_model=Response[ProjectOut])
+def get_project(
+ project_id: int, session: Session = Depends(get_session)
+) -> Response[ProjectOut]:
+ project = service.get_project(session, project_id)
+ if project is None:
+ raise BizException("项目不存在", code=BizCode.NOT_FOUND)
+ return Response.success(ProjectOut.model_validate(project))
+
+
+@router.delete("/{project_id}", response_model=Response[bool])
+def delete_project(
+ project_id: int, session: Session = Depends(get_session)
+) -> Response[bool]:
+ if not service.delete_project(session, project_id):
+ raise BizException("项目不存在", code=BizCode.NOT_FOUND)
+ return Response.success(True, message="删除成功")
diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py
new file mode 100644
index 00000000..2cb791b8
--- /dev/null
+++ b/backend/packages/common/src/windup_common/models/__init__.py
@@ -0,0 +1,15 @@
+from windup_common.models.character import (
+ ActionSpec,
+ ActionType,
+ AssetPackageRef,
+ CharacterCard,
+ GenRoute,
+)
+
+__all__ = [
+ "ActionType",
+ "GenRoute",
+ "CharacterCard",
+ "ActionSpec",
+ "AssetPackageRef",
+]
diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py
new file mode 100644
index 00000000..cccda23f
--- /dev/null
+++ b/backend/packages/common/src/windup_common/models/character.py
@@ -0,0 +1,81 @@
+"""共享 DTO —— 跨层契约(common,无内部依赖)。
+
+产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举、资产包引用。
+仅定义结构,不含行为。ai_engine / app 均依赖此。
+"""
+from __future__ import annotations
+
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class ActionType(str, Enum):
+ """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。"""
+
+ IDLE = "idle"
+ WALK = "walk"
+ RUN = "run"
+ JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases)
+ ATTACK = "attack" # slash / thrust / dash 归此
+ HIT = "hit"
+ CUSTOM = "custom" # 提示词驱动的自定义动作(走视频路线,动作描述见 ActionSpec.action_desc)
+
+
+class GenRoute(str, Enum):
+ """生成路线 —— 实测挣得的分流依据(见 strategy 层 docstring)。"""
+
+ VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿)
+ PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑)
+ PROC_IDLE = "proc_idle" # 待机:程序化局部呼吸(Idle-B)
+
+
+class CharacterCard(BaseModel):
+ """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。"""
+
+ name: str
+ desc: str # 身份描述(喂模型锁一致性)
+ palette: str = ""
+ view: str = "pseudo-side" # side / topdown / isometric
+ master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径)
+ version: str = "v1"
+
+
+class ActionSpec(BaseModel):
+ """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化。"""
+
+ action: ActionType
+ fps: int = 10
+ loop: str = "linear" # none / linear / pingpong
+ poses: list[str] = Field(default_factory=list)
+ # 风格化:pixel=像素化(原生像素角色 i2v 后复原像素感);none=保留 i2v 插画质感。
+ # 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由 CharacterCard 画风决定。
+ stylize: str = "pixel" # pixel / none
+ pixel_h: int = 100 # 像素化目标高(角色像素行数)
+ palette_size: int = 32 # 色板色数
+ # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective):
+ # side=横版侧走 / front=俯视·2.5D 朝观者。不一致会让模型靠转身调和图文矛盾。
+ facing: str = "side" # side / front
+ # 自定义动作(custom)的自然语言动作描述,如 "the character is painting on an easel"。
+ action_desc: str = "" # 仅 custom 使用;其他动作类型忽略
+
+ @property
+ def n_frames(self) -> int:
+ return len(self.poses)
+
+
+class AssetPackageRef(BaseModel):
+ """生成产出 —— 引擎可用资产包的存储引用(二进制在对象存储)。"""
+
+ character: str
+ action: ActionType
+ sheet_ref: str = "" # sprite sheet 存储 ref
+ frame_refs: list[str] = Field(default_factory=list)
+ plist_ref: str = "" # Cocos SpriteFrames
+ fps: int = 10
+ # 引擎侧元数据(业界惯例:位移不烘进像素,交引擎驱动):
+ # root_motion 逐帧 (dx, dy) 像素位移,y 向上为正;durations 逐帧时长(ms),
+ # 关键帧(攻击触点 / 跳跃顶点)会加长定格 —— 等时长会让动作发飘、没重量感。
+ root_motion: list[tuple[int, int]] = Field(default_factory=list)
+ durations: list[int] = Field(default_factory=list)
+ qa: dict = Field(default_factory=dict)
diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml
index 17726b58..a4550e9c 100644
--- a/backend/packages/framework/pyproject.toml
+++ b/backend/packages/framework/pyproject.toml
@@ -9,11 +9,22 @@ dependencies = [
"pydantic-settings>=2.4",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
+ "redis>=5.0",
"httpx>=0.27",
"pyjwt>=2.9",
- # 以下两项按选型启用:
+ # AI 模型适配器(providers/):chat 走 langchain,video/image 走 httpx。
+ "langchain-core>=0.3",
+ "langchain-openai>=0.3",
+ # 抠图 MatteProvider:onnxruntime 直跑 u2netp(替代 rembg,其 numba 老链在 3.12 无轮子)。
+ # 上限 <1.24:onnxruntime 自 1.24 起砍了 macOS Intel(x86_64)轮子;1.23.x 仍覆盖
+ # Intel/arm64/Linux + py3.12,保证 Intel Mac 也能装。API 与新版一致,不改抠图代码。
+ "numpy>=1.26",
+ "onnxruntime>=1.17,<1.24",
+ "pillow>=10.4",
+ # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。
+ "qiniu>=7.14",
+ # 以下按选型启用:
# "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一)
- # "minio", # 对象存储;若用 OSS/S3 换 oss2 / boto3
]
[tool.uv.sources]
diff --git a/backend/packages/framework/src/windup_framework/config/__init__.py b/backend/packages/framework/src/windup_framework/config/__init__.py
index 2f4cd973..8118ac71 100644
--- a/backend/packages/framework/src/windup_framework/config/__init__.py
+++ b/backend/packages/framework/src/windup_framework/config/__init__.py
@@ -2,13 +2,16 @@
from windup_framework.config.database import DatabaseSettings, settings
from windup_framework.config.provider import AIProviderSettings, settings as provider_settings
+from windup_framework.config.redis import RedisSettings, settings as redis_settings
from windup_framework.config.storage import StorageSettings, settings as storage_settings
__all__ = [
"AIProviderSettings",
"DatabaseSettings",
+ "RedisSettings",
"StorageSettings",
"provider_settings",
+ "redis_settings",
"settings",
"storage_settings",
]
diff --git a/backend/packages/framework/src/windup_framework/config/database.py b/backend/packages/framework/src/windup_framework/config/database.py
index 6cb3d483..9ff015ed 100644
--- a/backend/packages/framework/src/windup_framework/config/database.py
+++ b/backend/packages/framework/src/windup_framework/config/database.py
@@ -1,20 +1,32 @@
-"""Postgres 数据库连接配置。
+"""数据库连接配置。
-从环境变量(或 ``.env``)读取,字段前缀 ``POSTGRES_``。
-本地开发默认值对应 Docker 容器 root/admin123@localhost:4000。
+优先使用 SQLite(通过 SQLITE_PATH 环境变量),否则回退到 PostgreSQL。
"""
+import os
+from pathlib import Path
+
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
+_BACKEND_ROOT = Path(__file__).resolve().parents[5]
+
+
+def resolve_sqlite_path(value: str) -> Path:
+ """把本地 SQLite 相对路径固定解释为 backend 目录下的路径。"""
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ path = _BACKEND_ROOT / path
+ return path.resolve()
+
+
class DatabaseSettings(BaseSettings):
"""数据库连接配置。"""
model_config = SettingsConfigDict(
env_prefix="POSTGRES_",
- # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录
env_file=("../.env", ".env"),
env_file_encoding="utf-8",
extra="ignore",
@@ -31,11 +43,14 @@ class DatabaseSettings(BaseSettings):
@property
def url(self) -> str:
- """SQLAlchemy 连接串(psycopg3 驱动)。
+ """SQLAlchemy 连接串。
- 用 ``URL.create`` 构造以正确转义密码中的保留字符(``@ : /`` 等),
- 再渲染为 str 以保持返回类型契约。
+ 若设置 SQLITE_PATH 环境变量则使用 SQLite,否则连接 PostgreSQL。
"""
+ sqlite_path = os.getenv("SQLITE_PATH")
+ if sqlite_path:
+ return f"sqlite:///{resolve_sqlite_path(sqlite_path)}"
+
return URL.create(
drivername="postgresql+psycopg",
username=self.user,
diff --git a/backend/packages/framework/src/windup_framework/config/redis.py b/backend/packages/framework/src/windup_framework/config/redis.py
new file mode 100644
index 00000000..61f8b082
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/config/redis.py
@@ -0,0 +1,20 @@
+"""Redis 连接配置。"""
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class RedisSettings(BaseSettings):
+ """Redis 预留配置;只有 ``REDIS_ENABLED=true`` 时才允许创建客户端。"""
+
+ model_config = SettingsConfigDict(
+ env_prefix="REDIS_",
+ env_file=("../.env", ".env"),
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ enabled: bool = False
+ url: str = "redis://127.0.0.1:6379/0"
+
+
+settings = RedisSettings()
diff --git a/backend/packages/framework/src/windup_framework/config/storage.py b/backend/packages/framework/src/windup_framework/config/storage.py
index 06eff8c0..7f12adf7 100644
--- a/backend/packages/framework/src/windup_framework/config/storage.py
+++ b/backend/packages/framework/src/windup_framework/config/storage.py
@@ -4,16 +4,22 @@
本地开发需在 ``.env`` 填入 AccessKey / SecretKey / Bucket / 绑定域名。
"""
+from pathlib import Path
+
+from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
+# 显式加载项目根目录的 .env,避免 CWD 不同时相对路径找不到文件
+_ROOT_ENV = Path(__file__).resolve().parents[6] / ".env"
+load_dotenv(_ROOT_ENV, override=False)
+
class StorageSettings(BaseSettings):
"""七牛 Kodo 对象存储配置。"""
model_config = SettingsConfigDict(
env_prefix="QINIU_",
- # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录
- env_file=("../.env", ".env"),
+ env_file=(_ROOT_ENV, ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
@@ -33,7 +39,11 @@ class StorageSettings(BaseSettings):
@property
def download_base(self) -> str:
"""下载 URL 基础域名,去掉末尾 ``/``,客户端拼接 key 即可。"""
- return self.bucket_domain.rstrip("/")
+ domain = self.bucket_domain.rstrip("/")
+ if domain and not domain.startswith(("http://", "https://")):
+ # 七牛测试域名 SSL 证书可能不匹配,默认用 http
+ domain = f"http://{domain}"
+ return domain
settings = StorageSettings()
diff --git a/backend/packages/framework/src/windup_framework/db/__init__.py b/backend/packages/framework/src/windup_framework/db/__init__.py
index 91f57b23..e745ebdb 100644
--- a/backend/packages/framework/src/windup_framework/db/__init__.py
+++ b/backend/packages/framework/src/windup_framework/db/__init__.py
@@ -1,6 +1,7 @@
-"""数据库基础设施:ORM 基类、engine、session 工厂。"""
+"""数据库基础设施:ORM 基类、engine、session 工厂、Redis 客户端。"""
from windup_framework.db.base import Base
+from windup_framework.db.redis import get_redis
from windup_framework.db.session import SessionLocal, engine, get_session
-__all__ = ["Base", "SessionLocal", "engine", "get_session"]
+__all__ = ["Base", "SessionLocal", "engine", "get_redis", "get_session"]
diff --git a/backend/packages/framework/src/windup_framework/db/redis.py b/backend/packages/framework/src/windup_framework/db/redis.py
new file mode 100644
index 00000000..693aa313
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/db/redis.py
@@ -0,0 +1,25 @@
+"""按需创建 Redis 客户端;默认配置不会打开 Redis 连接。"""
+
+import redis
+
+from windup_framework.config.redis import settings as redis_settings
+
+
+from windup_framework.config.redis import RedisSettings
+
+
+def create_redis_client(settings: RedisSettings) -> redis.Redis | None:
+ """仅在显式启用时创建客户端;redis-py 会在首次命令时建立连接。"""
+
+ if not settings.enabled:
+ return None
+ return redis.Redis.from_url(settings.url, decode_responses=True)
+
+
+_client = create_redis_client(redis_settings)
+
+
+def get_redis() -> redis.Redis | None:
+ """返回可选 Redis 客户端;默认关闭时返回 ``None``。"""
+
+ return _client
diff --git a/backend/packages/framework/src/windup_framework/db/session.py b/backend/packages/framework/src/windup_framework/db/session.py
index a63b53bd..a767fa9b 100644
--- a/backend/packages/framework/src/windup_framework/db/session.py
+++ b/backend/packages/framework/src/windup_framework/db/session.py
@@ -12,12 +12,19 @@
from windup_framework.config.database import settings as db_settings
-engine = create_engine(
- db_settings.url,
- pool_size=db_settings.pool_size,
- max_overflow=db_settings.max_overflow,
- pool_pre_ping=db_settings.pool_pre_ping,
-)
+database_url = db_settings.url
+if database_url.startswith("sqlite:"):
+ engine = create_engine(
+ database_url,
+ connect_args={"check_same_thread": False},
+ )
+else:
+ engine = create_engine(
+ database_url,
+ pool_size=db_settings.pool_size,
+ max_overflow=db_settings.max_overflow,
+ pool_pre_ping=db_settings.pool_pre_ping,
+ )
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py
index 3524bbf3..61edb2f6 100644
--- a/backend/packages/framework/src/windup_framework/providers/__init__.py
+++ b/backend/packages/framework/src/windup_framework/providers/__init__.py
@@ -1,8 +1,15 @@
-"""按模型能力划分的 AI Provider 接口。"""
+"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。"""
from windup_framework.config.provider import AIProviderSettings
from windup_framework.providers.chat import create_chat_model
from windup_framework.providers.image import create_image_client
+from windup_framework.providers.interfaces import (
+ ImageProvider,
+ MatteProvider,
+ VideoProvider,
+)
+from windup_framework.providers.matte import OnnxU2NetMatteProvider
+from windup_framework.providers.sufy import SufyImageProvider, SufyVideoProvider
from windup_framework.providers.video import create_video_client
__all__ = [
@@ -10,4 +17,12 @@
"create_chat_model",
"create_image_client",
"create_video_client",
+ # 能力接口(ai_engine 依赖这些稳定契约)
+ "ImageProvider",
+ "VideoProvider",
+ "MatteProvider",
+ # 实现
+ "SufyVideoProvider",
+ "SufyImageProvider",
+ "OnnxU2NetMatteProvider",
]
diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py
new file mode 100644
index 00000000..697962d5
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py
@@ -0,0 +1,34 @@
+"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。
+
+ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。
+实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测
+到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。
+
+本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。
+"""
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class ImageProvider(Protocol):
+ """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。"""
+
+ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ...
+
+
+@runtime_checkable
+class VideoProvider(Protocol):
+ """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。"""
+
+ def i2v(
+ self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720"
+ ) -> bytes: ...
+
+
+@runtime_checkable
+class MatteProvider(Protocol):
+ """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。"""
+
+ def cutout(self, frame: bytes) -> bytes: ...
diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py
new file mode 100644
index 00000000..050997b8
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/matte.py
@@ -0,0 +1,103 @@
+"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。
+
+为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python
+3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认
+``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖,
+3.12 干净可装、可进 lock。同模型(u2netp),同质量。
+
+模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。
+onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。
+"""
+from __future__ import annotations
+
+import io
+import urllib.request
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+from .interfaces import MatteProvider
+
+# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。
+_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx"
+_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx"
+
+# u2net 预处理常量(与 rembg 一致)。
+_MEAN = (0.485, 0.456, 0.406)
+_STD = (0.229, 0.224, 0.225)
+_SIZE = (320, 320)
+
+
+class OnnxU2NetMatteProvider(MatteProvider):
+ """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。"""
+
+ def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None:
+ self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE
+ self._model_url = model_url
+ self._session = None # 惰性
+
+ def _ensure_model(self) -> Path:
+ if not self._model_path.exists():
+ self._model_path.parent.mkdir(parents=True, exist_ok=True)
+ urllib.request.urlretrieve(self._model_url, self._model_path)
+ return self._model_path
+
+ def _get_session(self):
+ if self._session is None:
+ try:
+ import onnxruntime as ort # 惰性:导入慢
+ except ImportError:
+ return None # onnxruntime 不可用(如 macOS x86_64),走 Pillow 兜底
+ self._session = ort.InferenceSession(
+ str(self._ensure_model()), providers=["CPUExecutionProvider"]
+ )
+ return self._session
+
+ def _predict_mask(self, img: Image.Image) -> Image.Image:
+ """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。"""
+ im = img.convert("RGB").resize(_SIZE, Image.LANCZOS)
+ ary = np.array(im).astype(np.float32)
+ ary = ary / max(float(ary.max()), 1e-6)
+ tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32)
+ for c in range(3):
+ tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c]
+ tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32)
+
+ session = self._get_session()
+ pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :]
+ mi, ma = float(pred.min()), float(pred.max())
+ pred = (pred - mi) / max(ma - mi, 1e-6)
+ mask = (pred.squeeze() * 255).astype(np.uint8)
+ return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS)
+
+ def cutout(self, frame: bytes) -> bytes:
+ img = Image.open(io.BytesIO(frame)).convert("RGBA")
+ session = self._get_session()
+ if session is not None:
+ mask = self._predict_mask(img)
+ else:
+ mask = self._fallback_mask(img)
+ cut = Image.composite(img, Image.new("RGBA", img.size, (0, 0, 0, 0)), mask)
+ buf = io.BytesIO()
+ cut.save(buf, "PNG")
+ return buf.getvalue()
+
+ @staticmethod
+ def _fallback_mask(img: Image.Image) -> Image.Image:
+ """Pillow 兜底:取四角主色做 chroma-key 式去背(精度远低于 u2netp,仅开发用)。"""
+ import numpy as np
+
+ ary = np.array(img.convert("RGB"))
+ # 取四角 8×8 采样主色
+ corners = np.concatenate([
+ ary[:8, :8].reshape(-1, 3),
+ ary[:8, -8:].reshape(-1, 3),
+ ary[-8:, :8].reshape(-1, 3),
+ ary[-8:, -8:].reshape(-1, 3),
+ ])
+ bg = corners.mean(axis=0)
+ diff = np.linalg.norm(ary.astype(float) - bg, axis=2)
+ # 阈值:距离 < 60 视为背景
+ mask = (diff > 60).astype(np.uint8) * 255
+ return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS)
diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py
new file mode 100644
index 00000000..e7c52126
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/sufy.py
@@ -0,0 +1,140 @@
+"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。
+
+视频走异步任务协议(2026-07-27 端到端实测):
+ POST /videos {model, prompt, size, seconds, mode, input_reference}
+ 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4
+key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。
+重依赖(rembg)惰性导入,保证模块导入零成本。
+"""
+from __future__ import annotations
+
+import base64
+import io
+import time
+
+import httpx
+
+from windup_framework.config.provider import AIProviderSettings, settings
+
+from .interfaces import ImageProvider, VideoProvider
+
+# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。
+_IMAGE_LIST_MODELS = ("kling-video-o1",)
+DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo"
+
+
+def _first_frame_datauri(frame: bytes, size: str) -> str:
+ """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。
+
+ 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64
+ 会 VENDOR_FAILED(实测)。
+ """
+ from PIL import Image
+
+ w, h = (int(x) for x in size.split("x"))
+ im = Image.open(io.BytesIO(frame)).convert("RGB")
+ pad = im.getpixel((0, 0))
+ fitted = im.copy()
+ fitted.thumbnail((w, h), Image.LANCZOS)
+ canvas = Image.new("RGB", (w, h), pad)
+ canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2))
+ buf = io.BytesIO()
+ canvas.save(buf, "JPEG", quality=90)
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
+
+
+class SufyVideoProvider(VideoProvider):
+ """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。"""
+
+ def __init__(
+ self,
+ config: AIProviderSettings = settings,
+ model: str = DEFAULT_VIDEO_MODEL,
+ mode: str = "std",
+ poll_interval: float = 60.0,
+ max_min: int = 30,
+ ) -> None:
+ self._cfg = config
+ self._model = model
+ self._mode = mode
+ self._poll = poll_interval
+ self._max_min = max_min
+
+ def _client(self) -> httpx.Client:
+ return httpx.Client(
+ base_url=self._cfg.normalized_base_url,
+ headers={"Authorization": f"Bearer {self._cfg.api_key}"},
+ timeout=self._cfg.timeout,
+ )
+
+ def i2v(
+ self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720"
+ ) -> bytes:
+ body: dict = {
+ "model": self._model,
+ "prompt": prompt,
+ "size": size,
+ "seconds": str(seconds),
+ "mode": self._mode,
+ }
+ if self._model in _IMAGE_LIST_MODELS:
+ b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1]
+ body["image_list"] = [{"image": b64}]
+ else:
+ body["input_reference"] = _first_frame_datauri(first_frame, size)
+
+ with self._client() as client:
+ job = client.post("/videos", json=body).raise_for_status().json()
+ jid = job.get("id")
+ url = None
+ for _ in range(max(1, int(self._max_min * 60 // self._poll))):
+ time.sleep(self._poll)
+ st = client.get(f"/videos/{jid}").raise_for_status().json()
+ status = st.get("status")
+ if status == "completed":
+ vids = (st.get("task_result") or {}).get("videos") or []
+ url = vids[0].get("url") if vids else None
+ break
+ if status in ("failed", "cancelled"):
+ raise RuntimeError(f"i2v 失败: {status}")
+ if not url:
+ raise RuntimeError("i2v 未取得视频 URL(超时或失败)")
+ return client.get(url).raise_for_status().content
+
+
+class SufyImageProvider(ImageProvider):
+ """图像 provider:gemini 系图生图(OpenAI 兼容 ``/chat/completions``,返回 base64 图)。
+
+ 参考图 + 文字约束 → 生成一张图(角色基准图 CHARACTER_IMAGE / 逐帧图生图)。
+ key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。
+ """
+
+ def __init__(
+ self,
+ config: AIProviderSettings = settings,
+ model: str = "gemini-2.5-flash-image",
+ ) -> None:
+ self._cfg = config
+ self._model = model
+
+ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes:
+ import json
+ import re
+
+ content: list = [{"type": "text", "text": prompt}]
+ for r in refs:
+ b64 = base64.b64encode(r).decode()
+ content.append(
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}}
+ )
+ body = {"model": self._model, "messages": [{"role": "user", "content": content}]}
+ with httpx.Client(
+ base_url=self._cfg.normalized_base_url,
+ headers={"Authorization": f"Bearer {self._cfg.api_key}"},
+ timeout=self._cfg.timeout,
+ ) as client:
+ res = client.post(self._cfg.chat_completions_path, json=body).raise_for_status().json()
+ m = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})", json.dumps(res))
+ if not m:
+ raise RuntimeError(f"图像响应无有效图: {json.dumps(res)[:200]}")
+ return base64.b64decode(m.group(1))
diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py
new file mode 100644
index 00000000..b4448110
--- /dev/null
+++ b/backend/tests/test_generation_api.py
@@ -0,0 +1,273 @@
+"""生成任务 HTTP 边界回归测试。"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from windup_app.bootstrap.app import create_app
+from windup_app.server.generation import task_repo
+from windup_app.server.generation.model import (
+ ActionType,
+ CharacterActionInput,
+ GenerationTaskRecord,
+)
+from windup_app.server.generation.service import AiGenerationService
+from windup_framework.db import Base, get_session
+
+
+class _ImmediateThread:
+ """让测试中的后台任务立即运行,稳定复现提交与执行的先后顺序。"""
+
+ def __init__(self, *, target, args, daemon):
+ self._target = target
+ self._args = args
+
+ def start(self):
+ self._target(*self._args)
+
+
+def _action_payload():
+ return {
+ "user_id": 1,
+ "project_id": 1,
+ "character_id": 1,
+ "action_type": "walk",
+ "reference_image_urls": ["https://cdn.example.com/master.png"],
+ "num_frames": 2,
+ }
+
+
+def _image_payload():
+ return {
+ "user_id": 1,
+ "project_id": None,
+ "prompt": "像素角色",
+ "width": 64,
+ "height": 64,
+ "num_images": 1,
+ }
+
+
+def test_action_request_defaults_to_32_frames(client, monkeypatch):
+ """调用方省略帧数时,HTTP 边界必须创建一项 32 帧动作任务。"""
+ captured_inputs = []
+
+ class _CaptureOnlyThread:
+ def __init__(self, *, target, args, daemon):
+ captured_inputs.append(args[1])
+
+ def start(self):
+ return None
+
+ monkeypatch.setattr(
+ "windup_app.web.api.generation.threading.Thread",
+ _CaptureOnlyThread,
+ )
+ payload = _action_payload()
+ payload.pop("num_frames")
+
+ response = client.post("/generation/action", json=payload)
+
+ assert response.status_code == 200
+ assert response.json()["data"]["input_payload"]["num_frames"] == 32
+ assert captured_inputs[0].num_frames == 32
+
+
+def test_action_task_is_committed_before_background_execution(
+ tmp_path,
+ monkeypatch,
+):
+ """后台执行器必须能读到刚提交的任务并写入终态。"""
+ engine = create_engine(f"sqlite:///{tmp_path / 'generation.db'}")
+ Base.metadata.create_all(engine)
+ session_local = sessionmaker(bind=engine, expire_on_commit=False)
+
+ def override_get_session():
+ with session_local() as session:
+ try:
+ yield session
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+
+ app = create_app()
+ app.dependency_overrides[get_session] = override_get_session
+
+ def complete_task(task_id, _input, _project_id):
+ with session_local() as session:
+ task_repo.update_result(
+ session,
+ task_id,
+ "character_action",
+ {
+ "type": "character_action",
+ "action_type": "walk",
+ "frames": [
+ {
+ "index": 0,
+ "image_url": "https://cdn.example.com/frame.png",
+ "duration_ms": 100,
+ }
+ ],
+ },
+ )
+ session.commit()
+
+ app.state.run_action_task = complete_task
+ monkeypatch.setattr(
+ "windup_app.web.api.generation.threading.Thread",
+ _ImmediateThread,
+ )
+
+ with TestClient(app) as client:
+ submitted = client.post("/generation/action", json=_action_payload()).json()[
+ "data"
+ ]
+ task = client.get(
+ f"/generation/tasks/{submitted['id']}",
+ params={"project_id": 1},
+ ).json()["data"]
+
+ assert task["status"] == "completed"
+ assert task["result"]["frames"][0]["image_url"].endswith("frame.png")
+ engine.dispose()
+
+
+def test_image_task_is_committed_before_background_execution(tmp_path, monkeypatch):
+ """Quick Start 的首段图片任务也必须在后台执行前完成提交。"""
+ engine = create_engine(f"sqlite:///{tmp_path / 'image-generation.db'}")
+ Base.metadata.create_all(engine)
+ session_local = sessionmaker(bind=engine, expire_on_commit=False)
+
+ def override_get_session():
+ with session_local() as session:
+ try:
+ yield session
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+
+ app = create_app()
+ app.dependency_overrides[get_session] = override_get_session
+
+ def complete_task(task_id, _input, _project_id):
+ with session_local() as session:
+ task_repo.update_result(
+ session,
+ task_id,
+ "character_image",
+ {
+ "type": "character_image",
+ "image_urls": ["https://cdn.example.com/character.png"],
+ },
+ )
+ session.commit()
+
+ app.state.run_image_task = complete_task
+ monkeypatch.setattr(
+ "windup_app.web.api.generation.threading.Thread",
+ _ImmediateThread,
+ )
+
+ with TestClient(app) as client:
+ submitted = client.post("/generation/image", json=_image_payload()).json()[
+ "data"
+ ]
+ task = client.get(
+ f"/generation/tasks/{submitted['id']}",
+ params={"project_id": 1},
+ ).json()["data"]
+
+ assert task["status"] == "completed"
+ assert task["result"]["image_urls"] == ["https://cdn.example.com/character.png"]
+ engine.dispose()
+
+
+def test_completed_task_stream_emits_terminal_snapshot(client, engine):
+ """晚于任务完成建立 SSE 连接时,也必须立刻收到可恢复的终态快照。"""
+ session_local = sessionmaker(bind=engine, expire_on_commit=False)
+ action_input = CharacterActionInput(
+ character_id=1,
+ action_type=ActionType.WALK,
+ reference_image_urls=["https://cdn.example.com/master.png"],
+ num_frames=1,
+ )
+ with session_local() as session:
+ task = AiGenerationService().generate_character_action(
+ session,
+ user_id=1,
+ project_id=1,
+ input=action_input,
+ )
+ session.commit()
+ task_repo.update_result(
+ session,
+ task.id,
+ "character_action",
+ {
+ "type": "character_action",
+ "action_type": "walk",
+ "frames": [
+ {
+ "index": 0,
+ "image_url": "https://cdn.example.com/frame.png",
+ "duration_ms": 100,
+ }
+ ],
+ },
+ )
+ session.commit()
+ task_id = task.id
+
+ with client.stream(
+ "GET",
+ f"/generation/tasks/{task_id}/stream",
+ params={"project_id": 1},
+ ) as response:
+ body = "".join(response.iter_text())
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/event-stream")
+ event_lines = [line for line in body.splitlines() if line.startswith("data: ")]
+ assert len(event_lines) == 1
+ payload = json.loads(event_lines[0].removeprefix("data: "))
+ assert payload["task_id"] == task_id
+ assert payload["task_type"] == "character_action"
+ assert payload["status"] == "completed"
+ assert payload["result"]["frames"][0]["image_url"].endswith("frame.png")
+
+
+def test_stale_incomplete_task_becomes_retryable_failure(client, engine):
+ """后台进程丢失的旧任务不能让 Quick Start 永久停在生成中。"""
+ session_local = sessionmaker(bind=engine, expire_on_commit=False)
+ with session_local() as session:
+ task = AiGenerationService().generate_character_action(
+ session,
+ user_id=1,
+ project_id=1,
+ input=CharacterActionInput(
+ character_id=1,
+ action_type=ActionType.WALK,
+ reference_image_urls=["https://cdn.example.com/master.png"],
+ ),
+ )
+ session.commit()
+ record = session.get(GenerationTaskRecord, task.id)
+ record.update_at = datetime.now(timezone.utc) - timedelta(minutes=16)
+ session.commit()
+ task_id = task.id
+
+ response = client.get(
+ f"/generation/tasks/{task_id}",
+ params={"project_id": 1},
+ ).json()["data"]
+
+ assert response["status"] == "failed"
+ assert "请重试" in response["error_message"]
diff --git a/backend/tests/test_local_runtime.py b/backend/tests/test_local_runtime.py
new file mode 100644
index 00000000..b56b21ca
--- /dev/null
+++ b/backend/tests/test_local_runtime.py
@@ -0,0 +1,86 @@
+"""本地启动入口的路径与 Windows 事件循环回归测试。"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+import uvicorn
+
+from windup_app.bootstrap import app as app_module
+
+
+BACKEND_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_init_db_resolves_relative_sqlite_path_from_backend_directory(tmp_path):
+ """无论从哪个目录启动,都必须复用 backend/windup.db,而不是创建新空库。"""
+ script = """
+import importlib.util
+import os
+from pathlib import Path
+
+path = Path(os.environ["WINDUP_INIT_DB"])
+spec = importlib.util.spec_from_file_location("windup_init_db", path)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+print(os.environ["SQLITE_PATH"])
+"""
+ env = os.environ.copy()
+ env["SQLITE_PATH"] = "./windup.db"
+ env["WINDUP_INIT_DB"] = str(BACKEND_ROOT / "init_db.py")
+
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=tmp_path,
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ assert Path(completed.stdout.strip()) == (BACKEND_ROOT / "windup.db").resolve()
+
+
+def test_framework_resolves_relative_sqlite_path_from_backend_directory(tmp_path):
+ """直接运行 windup 入口时也必须使用同一份本地库。"""
+ script = """
+from windup_framework.db.session import engine
+print(engine.url.database)
+"""
+ env = os.environ.copy()
+ env["SQLITE_PATH"] = "./windup.db"
+
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=tmp_path,
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ assert Path(completed.stdout.strip()) == (BACKEND_ROOT / "windup.db").resolve()
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="仅验证 Windows asyncio 运行时")
+def test_main_passes_selector_loop_factory_to_uvicorn(monkeypatch):
+ """Uvicorn 必须实际创建 Selector,而不是覆盖一个只写在全局 policy 的配置。"""
+ captured_options = {}
+
+ def capture_run(*args, **kwargs):
+ captured_options.update(kwargs)
+
+ monkeypatch.setattr(uvicorn, "run", capture_run)
+ app_module.main()
+
+ loop_factory = captured_options["loop"]
+ loop = loop_factory()
+ try:
+ assert isinstance(loop, asyncio.SelectorEventLoop)
+ finally:
+ loop.close()
diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py
new file mode 100644
index 00000000..24ad5ff6
--- /dev/null
+++ b/backend/tests/test_loop.py
@@ -0,0 +1,53 @@
+"""循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。"""
+
+from PIL import Image
+
+from windup_ai_engine.slicing import find_period, pick_cycle
+
+
+def _periodic_frames(period: int, cycles: int) -> list[Image.Image]:
+ """构造已知周期的帧序列:亮度按周期正弦变化(每帧一张纯灰图)。"""
+ import math
+
+ frames = []
+ for i in range(period * cycles):
+ v = int(128 + 100 * math.sin(2 * math.pi * i / period))
+ frames.append(Image.new("RGB", (48, 48), (v, v, v)))
+ return frames
+
+
+def test_find_period_detects_known_period():
+ frames = _periodic_frames(period=20, cycles=5)
+ p = find_period(frames)
+ assert abs(p - 20) <= 1 # 检出周期 ≈ 真值
+
+
+def test_pick_cycle_returns_n_frames():
+ frames = _periodic_frames(period=20, cycles=5)
+ out = pick_cycle(frames, 8)
+ assert len(out) == 8
+
+
+def test_pick_cycle_resamples_when_source_has_too_few_frames():
+ frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8
+ out = pick_cycle(frames, 8)
+
+ assert len(out) == 8
+ import numpy as np
+
+ first = np.asarray(out[0].convert("L"), float)
+ last = np.asarray(out[-1].convert("L"), float)
+ assert np.abs(last - first).mean() == 0
+
+
+def test_pick_cycle_closes_the_loop():
+ # 取出的一周期,末帧的下一拍应接近首帧(亮度差小)
+ import numpy as np
+
+ frames = _periodic_frames(period=20, cycles=5)
+ out = pick_cycle(frames, 8)
+ first = np.asarray(out[0].convert("L"), float)
+ last = np.asarray(out[-1].convert("L"), float)
+ step = np.abs(np.asarray(out[1].convert("L"), float) - first).mean()
+ seam = np.abs(last - first).mean()
+ assert seam <= step * 2 + 5 # 回接缝不显著大于一个正常步幅
diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py
new file mode 100644
index 00000000..d83e291e
--- /dev/null
+++ b/backend/tests/test_oneshot.py
@@ -0,0 +1,87 @@
+"""一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。"""
+
+import numpy as np
+from PIL import Image
+
+from windup_ai_engine.slicing import (
+ find_motion_span,
+ foot_line_series,
+ pick_oneshot,
+ split_jump_phases,
+)
+
+
+def _figure_at(y_bottom: int, size: int = 64, h: int = 20) -> Image.Image:
+ """在指定底边高度画一个方块"角色"(RGBA,其余透明)。"""
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ arr = np.asarray(img).copy()
+ top = max(0, y_bottom - h)
+ arr[top:y_bottom, size // 2 - 4 : size // 2 + 4] = (200, 60, 60, 255)
+ return Image.fromarray(arr, "RGBA")
+
+
+def _jump_sequence() -> list[Image.Image]:
+ """合成跳跃:静止 → 蹲(底边下移)→ 升 → 顶点 → 落 → 静止。"""
+ ground, low, apex = 50, 52, 30
+ ys = [ground] * 3 + [low, low] + [44, 38, apex, apex, 38, 44] + [ground] * 3
+ return [_figure_at(y) for y in ys]
+
+
+def test_find_motion_span_trims_static_head_and_tail():
+ frames = _jump_sequence()
+ start, end = find_motion_span(frames)
+ assert start >= 1 # 前面的静止帧被裁掉
+ assert end <= len(frames) - 2 # 后面的静止帧被裁掉
+ assert end > start
+
+
+def test_pick_oneshot_returns_n_and_does_not_wrap():
+ frames = _jump_sequence()
+ out = pick_oneshot(frames, 6)
+ assert len(out) == 6
+ # 一次性动作不闭环:首尾姿态应不同(闭环的话会几乎一样)
+ first = np.asarray(out[0].convert("L"), float)
+ last = np.asarray(out[-1].convert("L"), float)
+ assert np.abs(first - last).mean() >= 0
+
+
+def test_pick_oneshot_resamples_short_motion_span_to_requested_frame_count():
+ """动作裁剪只剩少量源帧时,仍须兑现调用方请求的 32 帧契约。"""
+ frames = _jump_sequence()
+
+ out = pick_oneshot(frames, 32)
+
+ assert len(out) == 32
+ assert out[0] is not frames[0]
+ assert out[-1] is not frames[-1]
+
+
+def test_pick_oneshot_can_return_exactly_one_first_frame():
+ frames = _jump_sequence()
+
+ out = pick_oneshot(frames, 1)
+
+ assert out[0] is not frames[0]
+
+
+def test_foot_line_tracks_height():
+ frames = _jump_sequence()
+ y = foot_line_series(frames)
+ assert y.argmin() in range(6, 10) # 最高点(y 最小)落在顶点附近
+ assert y[0] > y.min() # 起始在地面,低于顶点
+
+
+def test_split_jump_phases_covers_all_frames_in_order():
+ frames = _jump_sequence()
+ phases = split_jump_phases(frames)
+ assert "apex" in phases
+ idx = [i for seg in phases.values() for i in seg]
+ assert sorted(idx) == list(range(len(frames))) # 不重不漏
+ # apex 段应在 rise 之后、fall 之前
+ if "rise" in phases and "fall" in phases:
+ assert max(phases["rise"]) < min(phases["apex"])
+ assert max(phases["apex"]) < min(phases["fall"])
+
+
+def test_split_jump_phases_short_input_is_safe():
+ assert split_jump_phases([_figure_at(50)] * 3)
diff --git a/docs/module-split-plan.md b/docs/module-split-plan.md
new file mode 100644
index 00000000..e0089334
--- /dev/null
+++ b/docs/module-split-plan.md
@@ -0,0 +1,118 @@
+# 前端模块拆分规划(chaifen 系列)
+
+> 决策日期:2026-08-04
+> 核心源目录:`.pr70-playtest-worktree`(分支 `feat/playtest-on-module-skeleton`)
+> 拆分目标目录:`chaifen/`
+> 范围决策:**只拆前端**;后端不拆分(见「后端(决策:不拆分)」)。
+
+## 1. 拆分模式(01-04 已验证)
+
+1. 每个模块 = `chaifen/0X-` worktree + 独立分支 + 独立 PR(推送到 `1024XEngineer/Windup`)。
+2. 每个 PR **只含模块自身文件**,通过「允许提交路径」白名单控制范围;配套源码在本地保留、不提交。
+3. 新模块基于上一个已验证提交创建,形成顺序依赖链。
+4. 每模块一份 `_PR说明.md` 管理文档(本地 exclude,不提交),记录范围、验证、改动记录。
+
+## 2. 模块总表
+
+### 已完成(01-06)
+
+| # | 模块 | 分支 | 内容 | 状态 |
+|---|---|---|---|---|
+| 01 | workflow-run-core | `feat/workflow-run-core-clean` | `entities/workflow-run`(model/store/service) | PR #86 open |
+| 02 | workflow-controller | `feat/workflow-controller-coordinator` | `features/workflow-controller`(controller.ts + tests) | 已推送 `5ec5ba3`,未开 PR |
+| 03 | playtest | `split/playtest` | 数据适配器(`shared/api`、`entities/character\|project\|playtest-inspection`)+ 预览/质量内核 + 页面与路由(`pages/playtest`,含 `app.tsx`/`layout` 集成) | 三个提交齐备(`8f9abeb`),未推送 |
+| 04 | quick-start | `split/quick-start` | `pages/quick-start`(index/service + tests) | PR #95 draft |
+| 05 | export-package | `feat/export-package` | `features/export-package` 全量 + 契约(`contract.ts`、`cocos-target.ts`、schema、gifenc.d.ts)+ package.json/lock | PR #97 draft,已推送 `3cbfbc9` |
+| 06 | history | `feat/history-page` | `pages/history`(index + tests) | PR #105 draft,已推送 |
+
+### 进行中(07-09)
+
+| # | 模块 | 分支 | 内容 | 状态 |
+|---|---|---|---|---|
+| 07 | media-upload | `feat/media-upload` | `entities/media` 适配器 + `shared/api/upload.ts` | Refs #109;文件就绪、未提交 |
+| 08 | generation-sse-adapter | `feat/generation-sse-adapter` | `entities/generation` 适配器 + `shared/api/stream.ts` | Refs #78;验证通过(lint/typecheck/test/build)、未提交 |
+| 09 | asset-library | — | `pages/asset-library/index.tsx` | 目录已建,worktree 待初始化 |
+| 12 | auth-session | `split/auth-session` | `entities/user`(index + api)+ `features/auth-session`(index + session-storage + 2 tests) | 已拆分到 chaifen/12-auth-session,未提交 |
+
+### 待拆(剩余)
+
+| # | 模块 | 包含文件(白名单) | 说明 |
+|---|---|---|---|
+| — | publish-review | `features/publish/index.ts`、`workflow-to-character.ts`、`features/review/index.ts` | 原计划 06,顺延 |
+| — | workflow-editor | `pages/workflow-editor/index.tsx`、`node-canvas.ts`、`service.ts`、`workflow-canvas.tsx`、`workflow-editor.css` | 原计划 07 |
+| — | projects | `pages/projects/index.tsx`、`create-page.tsx`、`detail-page.tsx` | 原计划 10 |
+| — | app-shell | `app/` 剩余(layout/`__fixtures__`/api-contract.test)、`pages/not-found/index.tsx`、`main.tsx`、`index.css` | 最后收口 |
+| — | 规划外残留 | `pages/home/*`、`features/character-setup/*`、`features/export/index.ts`、`features/generation/index.ts`、`entities/project/index.ts`、playtest 剩余 4 文件、`shared/pagination` | 见备注 |
+
+### 后端(决策:不拆分)
+
+2026-08-04 决策:**不拆分后端**。`docs/module-split.md` 仅作为后端模块架构说明
+(接口 / 模型 / 实现的组织方式),不作为拆分执行计划。后端改动直接在主分支常规流程推进。
+
+## 3. 执行顺序与依赖
+
+```
+01 → 02 → 03 → 04 → 05 → 06(history) → 07(media) → 08(generation) → 09(asset-library)
+→ 12(auth-session) → publish-review → workflow-editor → projects → app-shell(收口)
+```
+
+- 01-06 已完成;07/08 文件就绪、待提交;09 待初始化。
+- 07/08 基于 `main`(7ee5a98)自包含,不依赖功能分支。
+- publish-review、workflow-editor 依赖 02-controller 与 01 entities,需在依赖合入后重放。
+- app-shell 最后拆,负责把全部模块收口进 `app/` 路由。
+
+> **编号说明:** 实际序列与原规划不同——06 拆的是 history(原 08),07/08 为新增的
+> media-upload 与 generation-sse-adapter,原 06=publish-review、07=workflow-editor
+> 顺延待拆。目录编号以 chaifen/ 实际为准,不再回填。
+
+## 4. 每模块执行步骤
+
+1. 在核心工作树确认模块文件完整(从核心源 `git add` 精确收集)。
+2. `git worktree add chaifen/0X- -b split/`,基于上一个已验证提交(或 01 的 `feat/workflow-run-core-clean`)。
+3. 新 worktree 基于基础提交展开(该提交已含模块骨架版本),从核心源逐文件复制模块的真实实现覆盖到对应路径(含 index.ts 入口与测试)。
+4. 验证:格式(oxlint/prettier)、TypeScript、单元测试、生产构建通过。
+5. 提交(conventional commits),更新 `_PR说明.md`(范围、验证结果、改动记录)。
+6. 需要时推送到贡献者分支开 PR(是否推送由用户确认)。
+
+## 5. 验证标准(每模块)
+
+- [ ] 格式检查通过(允许路径文件)
+- [ ] Lint 通过
+- [ ] TypeScript 通过
+- [ ] 单元测试通过
+- [ ] 生产构建通过
+- [ ] 范围检查:`upstream/main...HEAD` 差异只含白名单文件
+
+## 6. 改动记录
+
+### 2026-08-04 规划落盘
+
+- 首次编写本规划:前端 05-11 拆分范围、后端 B 系列留待以后、执行顺序与验证标准。
+- 决策:只拆前端、逐模块推进、规划写入 docs/。
+
+### 2026-08-04 决策:后端不拆分
+
+- 明确**不拆分后端**:`docs/module-split.md` 仅作架构说明,后端改动走常规流程。
+- 更新范围决策与模块总表说明。
+
+### 2026-08-04 状态同步(第二次检索)
+
+- 05/06 已完成:05 增补 `3cbfbc9` 契约提交并推送;06 推送 `d2105bc`(PR #105)。
+- 03 补齐第三个提交 `8f9abeb`(页面与路由集成,含 `app.tsx`/`layout` 改动),未推送。
+ - ⚠️ 该提交越过了 playtest 边界进入 app-shell 文件;11 拆分时以主工作树完整版
+ `app.tsx` 为准。
+- 02 已推送 `5ec5ba3`(rebase 后哈希变化),未开 PR。
+- 新增 07 media-upload(Refs #109)、08 generation-sse-adapter(Refs #78):
+ 文件就绪、验证通过、未提交;09 asset-library 仅建目录,worktree 待初始化。
+- 原规划编号作废:06=publish-review、07=workflow-editor、08=history;publish-review
+ 与 workflow-editor 顺延待拆(见「待拆(剩余)」)。
+- 清理中间产物:主工作树根目录 5 张截图、06 本地预览文件
+ (`history-preview.html`/`preview.tsx`)、02/06 的 node_modules 与 dist。
+- 模块总表重写为实际状态;后续模块完成或推进时同步更新本表。
+
+### 2026-08-06 新增 12-auth-session
+
+- 从主工作树拆分登录与认证会话模块到 `chaifen/12-auth-session/`。
+- 包含 `entities/user`(类型定义 + 后端 API 适配器)和 `features/auth-session`(Provider/hook/ProtectedRoute/本地开发适配器/session-storage + 完整测试)。
+- 依赖 `shared/api`(chaifen/10-shared-api-client)。
+- 更新模块总表与执行顺序。
diff --git a/docs/module-split.md b/docs/module-split.md
index 3dd8d743..0b35a306 100644
--- a/docs/module-split.md
+++ b/docs/module-split.md
@@ -127,10 +127,10 @@ CharacterData
## 4. generation — AI 生成任务
-**接口:** `GenerationService` **传输:** SSE 推送任务状态
+**接口:** `GenerationService` **目标传输:** SSE 推送任务状态(当前实现仍为轮询)
-职责:管理生成任务生命周期,按任务类型区分入参和出参。前端通过 SSE 订阅任务
-状态变更,无需轮询。
+职责:管理生成任务生命周期,按任务类型区分入参和出参。当前前端每 2 秒查询任务
+快照;目标是通过 SSE 订阅任务状态变化,接口见 `sse-generation-flow.md`。
**任务类型与出参对应关系:**
@@ -158,7 +158,7 @@ CharacterData
| `generate_character_action(input)` | 提交角色动作生成任务 |
| `get_task(project_id, task_id)` | 查询任务状态与结果 |
-**SSE 调用流程:**
+**目标 SSE 调用流程:**
1. 前端 POST 提交任务,拿到 `task_id`。
2. 前端连接 `GET /generation/tasks/{task_id}/stream`,服务端在任务状态变化时
@@ -169,7 +169,8 @@ CharacterData
> **与旧设计的差异:** 不再使用策略模式(`GenerationStrategy` / `register_strategy` /
> `submit(payload)`),改为按任务类型拆分明确的接口方法。不再使用泛化出参
> `GenerationResult(urls, metadata)`,改为按任务类型细化出参
-> `CharacterImageOutput` / `CharacterActionOutput`。不再使用前端轮询,改为 SSE 推送。
+> `CharacterImageOutput` / `CharacterActionOutput`。SSE 尚未落地,当前前端轮询将在
+> stream 接口完成后替换。
---
diff --git a/docs/sse-generation-flow.md b/docs/sse-generation-flow.md
new file mode 100644
index 00000000..969e9c71
--- /dev/null
+++ b/docs/sse-generation-flow.md
@@ -0,0 +1,242 @@
+# SSE 生成全流程与接口
+
+## 1. 当前实现状态
+
+当前生成任务已经具备异步任务模型,但**尚未实现 SSE 接口**:
+
+- 后端支持提交角色图、提交动作和查询任务快照。
+- 后台线程执行 AI 生成、上传图片并更新任务状态。
+- 前端 `GenerationApis.subscribe()` 每 2 秒调用一次查询接口,属于轮询。
+- `GET /generation/tasks/{task_id}/stream` 目前不存在。
+
+因此,下文将“当前已经可用的接口”和“需要实现的 SSE 接口”分开描述。
+
+## 2. 生成制作全流程
+-+-++++++++++++++++++++++++
+```mermaid
+sequenceDiagram
+ participant UI as Quick Start / Workflow Editor
+ participant API as Generation API
+ participant DB as GenerationTask
+ participant Worker as Generation Executor
+ participant AI as AI Engine
+ participant Storage as Object Storage
+ participant SSE as SSE Stream
+ participant Character as Character API
+ participant Playtest as Playtest
+
+ UI->>API: POST /generation/image 或 /generation/action
+ API->>DB: 创建 PENDING 任务
+ API-->>UI: 返回 task_id 和任务快照
+ API->>Worker: 启动后台生成
+ UI->>SSE: 连接任务 stream
+ SSE-->>UI: 推送 PENDING 当前快照
+ Worker->>DB: 状态改为 RUNNING
+ SSE-->>UI: 推送 RUNNING
+ Worker->>AI: 按项目视角、尺寸和画风生成
+ AI-->>Worker: 返回角色图或动作帧
+ Worker->>Storage: 上传生成图片
+ Storage-->>Worker: 返回图片 URL
+ Worker->>DB: 写入结果并标记 COMPLETED
+ SSE-->>UI: 推送 COMPLETED 和 result
+ UI->>UI: 回填当前制作步骤并等待用户确认
+ UI->>Character: 用户确认后写入角色、造型和动作
+ Playtest->>Character: 读取已确认 Character
+```
+
+失败路径:Worker 捕获异常,将任务改为 `FAILED` 并写入 `error_message`;SSE 推送失败事件后关闭连接。断开 SSE 只停止接收消息,不取消后台任务。
+
+## 3. 当前已经实现的接口
+
+Base URL:`http://127.0.0.1:8000`
+
+### 3.1 提交角色图生成
+
+`POST /generation/image`
+
+```json
+{
+ "user_id": 1,
+ "project_id": 37,
+ "reference_image_url": null,
+ "prompt": "侧视像素风守夜人",
+ "negative_prompt": "",
+ "width": 256,
+ "height": 256,
+ "num_images": 4
+}
+```
+
+后端创建 `character_image` 任务。输入宽高必须与项目精灵尺寸一致。
+
+### 3.2 提交动作生成
+
+`POST /generation/action`
+
+```json
+{
+ "user_id": 1,
+ "project_id": 37,
+ "character_id": 25,
+ "action_type": "custom",
+ "custom_prompt": "举起并挥动灯笼",
+ "reference_video_url": null,
+ "reference_image_urls": ["https://example.com/master.png"],
+ "num_frames": 32
+}
+```
+
+后端创建 `character_action` 任务。生成器读取项目视角、画风和精灵尺寸,逐帧上传后返回完整帧列表。
+
+### 3.3 查询任务快照
+
+`GET /generation/tasks/{task_id}?project_id={project_id}`
+
+```json
+{
+ "code": 200,
+ "message": "success",
+ "data": {
+ "id": 71,
+ "user_id": 1,
+ "project_id": 37,
+ "task_type": "character_action",
+ "status": "completed",
+ "input_payload": {},
+ "result": {
+ "type": "character_action",
+ "action_type": "custom",
+ "frames": [
+ {
+ "index": 0,
+ "image_url": "https://example.com/frame-0.png",
+ "duration_ms": 125
+ }
+ ]
+ },
+ "error_message": null
+ }
+}
+```
+
+任务状态只有:`pending`、`running`、`completed`、`failed`。当前模型没有百分比 `progress` 字段。
+
+## 4. 需要新增的 SSE 接口
+
+### 4.1 订阅任务状态
+
+`GET /generation/tasks/{task_id}/stream?project_id={project_id}`
+
+响应头:
+
+```http
+Content-Type: text/event-stream
+Cache-Control: no-cache
+Connection: keep-alive
+X-Accel-Buffering: no
+```
+
+连接建立后,服务端必须立即推送任务当前快照,不能等待下一次状态变化。任务进入终态后推送最后一条消息并关闭连接。
+
+### 4.2 任务事件
+
+```text
+event: task_update
+id: 71:2
+retry: 2000
+data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"running","result":null,"error_message":null}
+
+```
+
+完成事件:
+
+```text
+event: task_update
+id: 71:3
+data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"completed","result":{"type":"character_action","action_type":"custom","frames":[]},"error_message":null}
+
+```
+
+失败事件:
+
+```text
+event: task_update
+id: 71:3
+data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"failed","result":null,"error_message":"母版下载失败"}
+
+```
+
+事件字段:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `task_id` | int | 生成任务 ID |
+| `project_id` | int | 所属项目 ID |
+| `task_type` | string | `character_image` 或 `character_action` |
+| `status` | string | 四种任务状态之一 |
+| `result` | object/null | 仅完成时存在 |
+| `error_message` | string/null | 仅失败时存在 |
+
+### 4.3 心跳事件
+
+服务端每 15 秒发送一次心跳,避免代理关闭空闲连接:
+
+```text
+event: ping
+data: {}
+
+```
+
+心跳不进入业务状态机,前端可以忽略。
+
+## 5. 后端处理规则
+
+1. 建立流之前校验 `project_id + task_id`,不存在返回业务码 404。
+2. 建立连接后立即查询数据库并推送当前快照。
+3. 只在状态或结果变化时推送 `task_update`,不重复发送同一快照。
+4. `completed` 或 `failed` 推送后关闭连接。
+5. 客户端断开时释放监听资源,但不停止 GenerationTask。
+6. 客户端重连时不依赖内存中的旧连接,重新读取数据库最新状态。
+7. MVP 不添加虚假的百分比进度;若以后需要逐帧进度,应先扩展任务模型和事件表。
+
+当前后台任务使用线程执行。单进程 MVP 可以使用任务通知队列唤醒 SSE;多进程部署时需要 Redis Pub/Sub、数据库通知或持久事件表,不能只依赖进程内 `asyncio.Queue`。
+
+## 6. 前端接入规则
+
+`GenerationApis` 的业务接口保持不变:
+
+```ts
+interface GenerationApis {
+ create(input: GenerationInput): Promise
+ get(projectId: string, taskId: string): Promise
+ subscribe(
+ projectId: string,
+ taskId: string,
+ onEvent: (event: GenerationEvent) => void,
+ ): () => void
+}
+```
+
+只替换 `subscribe()` 的传输实现:
+
+```ts
+const source = new EventSource(
+ `/generation/tasks/${taskId}/stream?project_id=${encodeURIComponent(projectId)}`,
+)
+
+source.addEventListener('task_update', (message) => {
+ onEvent(JSON.parse(message.data))
+})
+
+return () => source.close()
+```
+
+Workflow Controller 继续消费统一的 `GenerationEvent`,不应知道底层使用 SSE 还是轮询。页面刷新后先调用 `get()` 读取任务最新快照;若仍是 `pending/running`,再恢复 SSE 订阅。
+
+## 7. 模块责任边界
+
+- `generation`:创建任务、执行生成、保存任务结果、推送 SSE。
+- `workflow-controller`:把任务结果写入当前前端制作步骤。
+- `character`:只在用户确认后保存最终角色和动作。
+- `playtest`:只读取已确认 Character 进行预览和核验,不订阅生成任务。
+- 当前不接历史记录,也不建设资产库流程。
diff --git a/docs/superpowers/plans/2026-08-05-home-auth-account.md b/docs/superpowers/plans/2026-08-05-home-auth-account.md
new file mode 100644
index 00000000..7b7cab72
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-home-auth-account.md
@@ -0,0 +1,206 @@
+# Home Authentication and Account Settings Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add real email authentication, persistent sessions, protected product routes, and backend-supported account settings to the existing Windup homepage.
+
+**Architecture:** `entities/user` owns the exact backend contract; `features/auth-session` owns session state and token lifecycle; `pages/home` owns account UI; `app` performs composition and route protection. Refresh Token persists in localStorage while Access Token remains in memory and is exposed through the existing shared API token-provider boundary.
+
+**Tech Stack:** React 19, React Router 8, TypeScript 6, Vite 8, Tailwind CSS 4, Vitest, Testing Library.
+
+## Global Constraints
+
+- Implement only endpoints and fields present in `feat/user-module`.
+- Do not add OAuth, avatar, nickname editing, email editing, account deletion, `application`, or `capabilities`.
+- Keep the homepage public and protect all production product routes.
+- Persist only Refresh Token under `windup.auth.refresh-token`; keep Access Token in memory.
+- Preserve the existing homepage editorial grey-green visual language.
+
+---
+
+### Task 1: User entity and real authentication adapter
+
+**Files:**
+- Create: `frontend/src/entities/user/index.ts`
+- Create: `frontend/src/entities/user/api.ts`
+- Create: `frontend/src/entities/user/api.test.ts`
+- Modify: `frontend/src/entities/index.ts`
+
+**Interfaces:**
+- Consumes: `ApiClient` and `createApiClient` from `@/shared/api`.
+- Produces: `User`, `AuthTokens`, `UserApis`, and `createUserApis(options?)`.
+
+- [ ] **Step 1: Write failing adapter tests**
+
+Cover literal request paths and bodies for `sendCode`, `register`, `login`, `loginByCode`, `refresh`, `logout`, `me`, and `changePassword`. Assert snake_case responses become camelCase:
+
+```ts
+expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({
+ accessToken: 'access',
+ refreshToken: 'refresh',
+ user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' },
+})
+```
+
+- [ ] **Step 2: Run the entity test and verify RED**
+
+Run: `npm test -- --run src/entities/user/api.test.ts`
+
+Expected: FAIL because `createUserApis` does not exist.
+
+- [ ] **Step 3: Implement the exact backend contract**
+
+Use these public signatures:
+
+```ts
+interface UserApis {
+ sendCode(input: { email: string; purpose: 'login' | 'register' | 'reset_password' }): Promise
+ register(input: { email: string; password: string; code: string; nickname?: string }): Promise
+ login(input: { email: string; password: string; code: string }): Promise
+ loginByCode(input: { email: string; code: string }): Promise
+ refresh(refreshToken: string): Promise
+ logout(refreshToken: string): Promise
+ me(): Promise
+ changePassword(input: { oldPassword: string; newPassword: string }): Promise
+}
+```
+
+- [ ] **Step 4: Run the entity tests and verify GREEN**
+
+Run: `npm test -- --run src/entities/user/api.test.ts`
+
+Expected: PASS.
+
+### Task 2: Persistent authentication session
+
+**Files:**
+- Create: `frontend/src/features/auth-session/session-storage.ts`
+- Create: `frontend/src/features/auth-session/session-storage.test.ts`
+- Create: `frontend/src/features/auth-session/index.tsx`
+- Create: `frontend/src/features/auth-session/index.test.tsx`
+
+**Interfaces:**
+- Consumes: `UserApis`, `AuthTokens`, `registerApiAccessTokenProvider`.
+- Produces: `AuthSessionProvider`, `useAuthSession`, `ProtectedRoute`.
+
+- [ ] **Step 1: Write failing storage and provider tests**
+
+Test that only Refresh Token enters localStorage, bootstrap refreshes and fetches `/auth/me`, login stores the rotated Refresh Token, logout clears local state even when the backend rejects, and guests are redirected with a safe `returnTo`.
+
+- [ ] **Step 2: Run the session tests and verify RED**
+
+Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx`
+
+Expected: FAIL because the session module does not exist.
+
+- [ ] **Step 3: Implement session state and route protection**
+
+Use this state contract:
+
+```ts
+type AuthSessionState =
+ | { status: 'booting'; user: null }
+ | { status: 'guest'; user: null }
+ | { status: 'authenticated'; user: User }
+```
+
+Register a token getter during Provider lifetime. Decode only the JWT `exp` payload to schedule refresh 60 seconds early; token signature validation remains a backend responsibility. Reject `returnTo` values that do not start with `/` or start with `//`.
+
+- [ ] **Step 4: Run the session tests and verify GREEN**
+
+Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx`
+
+Expected: PASS.
+
+### Task 3: Homepage account interface
+
+**Files:**
+- Create: `frontend/src/pages/home/account-panel.tsx`
+- Create: `frontend/src/pages/home/account-panel.test.tsx`
+- Modify: `frontend/src/pages/home/index.tsx`
+- Modify: `frontend/src/pages/home/index.test.tsx`
+
+**Interfaces:**
+- Consumes: `useAuthSession` methods and URL query parameters.
+- Produces: login, register, read-only profile, password change, and logout UI.
+
+- [ ] **Step 1: Write failing interaction tests**
+
+Cover opening the account panel, sending a code with the correct purpose, code login, password login with code, registration, readonly profile fields, password change, logout, and the absence of nickname/email/avatar editing controls.
+
+- [ ] **Step 2: Run homepage tests and verify RED**
+
+Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx`
+
+Expected: FAIL because the account panel is missing.
+
+- [ ] **Step 3: Implement the homepage UI**
+
+Use a fixed backdrop and responsive panel aligned to the existing grey-green palette. Keep labels explicit, use native form controls, expose request errors with `role="alert"`, and keep submit buttons disabled during requests. Implement a 60-second send-code countdown without adding a timer dependency.
+
+- [ ] **Step 4: Run homepage tests and verify GREEN**
+
+Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx`
+
+Expected: PASS.
+
+### Task 4: App composition, account entry, and protected routes
+
+**Files:**
+- Modify: `frontend/src/app/app.tsx`
+- Modify: `frontend/src/app/app.test.tsx`
+- Modify: `frontend/src/app/layout/app-header.tsx`
+- Modify: `frontend/src/app/layout/app-header.test.tsx`
+
+**Interfaces:**
+- Consumes: `createUserApis`, `AuthSessionProvider`, `ProtectedRoute`.
+- Produces: one shared User API instance, public homepage, and protected product routes.
+
+- [ ] **Step 1: Write failing route and header tests**
+
+Assert that a guest can render `/`, a guest entering `/quick-start` reaches `/?account=login&returnTo=%2Fquick-start`, and the header account entry displays “登录 / 注册” or the authenticated nickname.
+
+- [ ] **Step 2: Run app tests and verify RED**
+
+Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx`
+
+Expected: FAIL because authentication is not composed.
+
+- [ ] **Step 3: Compose auth once and protect routes**
+
+Create `userApis` in the App composition root. Wrap `AppShell` and `Routes` in `AuthSessionProvider`. Keep `/` public and wrap every production product route (including quick start, projects, characters, asset library, history, workflow editor, and formal Playtest) in `ProtectedRoute`; keep the development Demo route public.
+
+- [ ] **Step 4: Run app tests and verify GREEN**
+
+Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx`
+
+Expected: PASS.
+
+### Task 5: Full verification
+
+**Files:**
+- Modify only files required by failures in the authentication scope.
+
+**Interfaces:**
+- Consumes: all tasks above.
+- Produces: verified frontend authentication delivery.
+
+- [ ] **Step 1: Run full automated verification**
+
+Run:
+
+```powershell
+npm test
+npm run format:check
+npm run lint
+npm run typecheck
+npm run build
+```
+
+Expected: every command exits 0.
+
+- [ ] **Step 2: Inspect the final diff**
+
+Run: `git diff --check -- frontend/src/entities/user frontend/src/features/auth-session frontend/src/pages/home frontend/src/app`
+
+Expected: no whitespace errors and no backend file changes.
diff --git a/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md
new file mode 100644
index 00000000..0740f9c3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md
@@ -0,0 +1,157 @@
+# Uploaded Character Template Shortcut Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let Quick Start and Workflow Editor upload a character image and proceed directly to 32-frame action generation, while preserving the existing text-only character-generation path.
+
+**Architecture:** One shared `MediaApis` instance is composed in App. The workflow state machine gains an explicit uploaded-template transition; Quick Start owns the existing character creation/action orchestration, and Workflow Editor delegates to that same use case instead of duplicating it.
+
+**Tech Stack:** React 19, TypeScript 6, Vitest, existing Workflow Controller and MediaApis.
+
+## Global Constraints
+
+- Uploaded image plus non-empty text generates a `custom` action using that text as the action prompt.
+- Uploaded image plus blank text generates the default `idle` action.
+- No uploaded image preserves the existing character-description, generated-candidate and confirmation flow.
+- Uploaded images must never be represented as a successful backend image Generation task.
+- Complete animation generation remains 32 frames.
+- Add no dependency and no new business module.
+- Preserve all unrelated dirty-worktree changes.
+
+---
+
+### Task 1: Shared uploaded-template workflow transition and App composition
+
+**Files:**
+- Modify: `frontend/src/features/workflow-controller/workflow-state.test.ts`
+- Modify: `frontend/src/features/workflow-controller/workflow-state.ts`
+- Modify: `frontend/src/features/workflow-controller/controller.ts`
+- Modify: `frontend/src/app/app.tsx`
+- Test: `frontend/src/app/app-composition.test.tsx`
+
+**Interfaces:**
+- Produces: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl): WorkflowRun`.
+- Produces: one `createMediaApis()` instance passed into Quick Start composition.
+
+- [ ] **Step 1: Write the failing state test**
+
+Assert that accepting `media-upload-1` on an initial create-character run yields statuses `passed, passed, passed, active, locked`, stores the reference on character setup, exposes it as the template/candidate output, keeps `generationStatus` as `not_started`, and leaves exactly one active step.
+
+- [ ] **Step 2: Run the state test and verify RED**
+
+Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts`
+
+Expected: FAIL because `acceptUploadedCharacterTemplateState` does not exist.
+
+- [ ] **Step 3: Implement the pure transition and controller method**
+
+Add the pure state function and save its result through the controller. Reject blank references, non-active runs, and any active step other than `character-setup`. Do not create a Generation input or task ID.
+
+- [ ] **Step 4: Add shared App composition**
+
+Create `const mediaApis = createMediaApis()` beside other entity adapters, pass it to Quick Start, and wire Workflow Editor's uploaded-template callback to the Quick Start service method defined in Task 2.
+
+- [ ] **Step 5: Run focused tests and verify GREEN**
+
+Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts src/app/app-composition.test.tsx`
+
+Expected: PASS.
+
+### Task 2: Quick Start upload button and direct-action use case
+
+**Files:**
+- Modify: `frontend/src/pages/quick-start/service.test.ts`
+- Modify: `frontend/src/pages/quick-start/service.ts`
+- Modify: `frontend/src/pages/quick-start/index.test.tsx`
+- Modify: `frontend/src/pages/quick-start/index.tsx`
+
+**Interfaces:**
+- Consumes: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl)`.
+- Produces: `QuickStartService.startWithUploadedTemplate(file, actionDescription, signal?)`.
+- Produces: `QuickStartService.continueWithUploadedTemplate(runId, file, actionDescription, signal?)`, reused by Workflow Editor.
+
+- [ ] **Step 1: Write failing service tests**
+
+Cover non-empty text → `custom`, blank text → `idle`, `MediaApis.upload(file, 'reference-image', signal)`, no character-template Generation call, and upload failure before WorkflowRun creation/advance.
+
+- [ ] **Step 2: Write failing page tests**
+
+Assert an image button exists in the lower-right input actions, selecting an image allows blank-text submit, selected filename/removal are available, and removing the image restores the text-required rule.
+
+- [ ] **Step 3: Run Quick Start tests and verify RED**
+
+Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx`
+
+Expected: FAIL because the methods and upload UI do not exist.
+
+- [ ] **Step 4: Implement the minimal service behavior**
+
+Prepare a project using trimmed action text or the selected filename as the naming seed, upload through `MediaApis`, create the run only after upload succeeds, accept the uploaded template, then reuse the existing character creation/action-generation function. Existing `start(prompt)` stays unchanged.
+
+- [ ] **Step 5: Implement the minimal UI**
+
+Use one hidden `input[type=file][accept="image/*"]`, a visible lower-right button, filename/removal controls, conditional placeholder/help copy, submit guarding, and an AbortController for the in-flight upload.
+
+- [ ] **Step 6: Run Quick Start tests and verify GREEN**
+
+Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx`
+
+Expected: PASS.
+
+### Task 3: Workflow Editor uploaded-template shortcut
+
+**Files:**
+- Modify: `frontend/src/pages/workflow-editor/service.ts`
+- Modify: `frontend/src/pages/workflow-editor/index.test.tsx`
+- Modify: `frontend/src/pages/workflow-editor/index.tsx`
+- Modify: `frontend/src/pages/workflow-editor/workflow-canvas.tsx`
+- Modify: `frontend/src/pages/workflow-editor/workflow-editor.css`
+
+**Interfaces:**
+- Consumes: injected `continueWithUploadedTemplate(runId, file, actionDescription, signal?)` callback.
+- Produces: `WorkflowEditorService.continueWithUploadedTemplate(...)` for the page.
+
+- [ ] **Step 1: Write failing page/service tests**
+
+Assert the active character-setup node can return `{ description, file }`; with a file the page calls the uploaded-template service once and does not call `nextStep`, while without a file it keeps `updateCharacterSetup` plus `nextStep`.
+
+- [ ] **Step 2: Run Workflow Editor tests and verify RED**
+
+Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx`
+
+Expected: FAIL because the file control and service method do not exist.
+
+- [ ] **Step 3: Implement event and service delegation**
+
+Render the image input in the existing character-setup node. Extend delegated submit handling to pass the selected `File`. Route file submissions to the injected uploaded-template use case and text-only submissions to the unchanged path. Abort the upload when the run view unmounts.
+
+- [ ] **Step 4: Run Workflow Editor tests and verify GREEN**
+
+Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx`
+
+Expected: PASS.
+
+### Task 4: Integration, regression and review
+
+**Files:**
+- Modify only files required by concrete failures from the commands below.
+
+- [ ] **Step 1: Run the complete frontend suite**
+
+Run: `npm.cmd test`
+
+Expected: all tests pass.
+
+- [ ] **Step 2: Run production checks**
+
+Run: `npm.cmd run build`, `npm.cmd run lint`, `npm.cmd run format:check`, and repository `git diff --check`.
+
+Expected: all commands pass.
+
+- [ ] **Step 3: Review cross-entry consistency**
+
+Verify both entries share the same `MediaApis`, controller transition and character/action orchestration; verify text-only flows and historical runs remain unchanged.
+
+- [ ] **Step 4: Request final code review**
+
+Review for workflow-state validity, duplicate submission, abort behavior, error recovery, and accidental fake Generation results. Resolve all Critical and Important findings before completion.
diff --git a/docs/superpowers/specs/2026-08-05-home-auth-account-design.md b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md
new file mode 100644
index 00000000..8ee02371
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md
@@ -0,0 +1,86 @@
+# 首页登录与个人设置设计
+
+## 目标
+
+在现有 Windup 首页内完成真实邮箱认证和个人设置入口,严格对接 `xiaocheny214/DireSoul` 的 `feat/user-module` 后端,不伪造昵称编辑、头像、邮箱修改等后端不存在的能力。
+
+## 已确认的产品边界
+
+- 首页允许游客浏览。
+- 快速开始、工作流、项目、历史和正式 Playtest 路由需要登录。
+- 游客进入受保护路由时回到首页并打开登录界面;登录成功后恢复原目标。
+- 登录状态在浏览器关闭后继续保留,最长使用后端 7 天 Refresh Token 生命周期。
+- 个人资料只读展示邮箱、昵称、邮箱验证状态和账户状态。
+- 个人设置只允许修改密码与退出登录。
+- 不实现 OAuth、头像、昵称修改、邮箱修改或账号删除。
+
+## 后端契约
+
+接口来源为 `feat/user-module`:
+
+| 方法 | 路径 | 请求 |
+| --- | --- | --- |
+| POST | `/auth/send-code` | `{ email, purpose: 'login' \| 'register' \| 'reset_password' }` |
+| POST | `/auth/register` | `{ email, password, code, nickname? }` |
+| POST | `/auth/login` | `{ email, password, code }` |
+| POST | `/auth/login-by-code` | `{ email, code }` |
+| POST | `/auth/refresh` | `{ refresh_token }` |
+| POST | `/auth/logout` | `{ refresh_token }` |
+| GET | `/auth/me` | Bearer Access Token |
+| POST | `/auth/change-password` | `{ old_password, new_password }` |
+
+登录、注册和刷新返回 `{ access_token, refresh_token, user }`。Access Token 生命周期为 15 分钟,Refresh Token 生命周期为 7 天。
+
+## 前端结构
+
+保持现有 `app → pages → features → entities → shared`:
+
+- `entities/user`:用户类型、认证 DTO 转换和八个真实 HTTP 方法。
+- `features/auth-session`:唯一认证状态、Refresh Token 持久化、Access Token 内存保存、自动刷新和路由保护。
+- `pages/home/account-panel.tsx`:首页登录、注册和个人设置界面。
+- `app`:装配 User API、认证 Provider、受保护路由和顶栏账户入口。
+
+不增加 `application` 或 `capabilities`。
+
+## Token 生命周期
+
+Refresh Token 保存到 `localStorage` 的 `windup.auth.refresh-token`。Access Token 只保存在 React 内存状态,通过现有 `registerApiAccessTokenProvider` 注入全部业务请求。
+
+页面启动时:
+
+1. 没有 Refresh Token,进入游客状态。
+2. 有 Refresh Token,调用 `/auth/refresh` 轮换两个 Token。
+3. 使用新 Access Token 调用 `/auth/me`,取得完整用户资料。
+4. 任何刷新失败都清除本地 Refresh Token并进入游客状态。
+
+登录成功后根据 JWT `exp` 在到期前 60 秒自动刷新。浏览器从后台恢复时重新检查有效期。退出登录先调用后端;即使网络失败也清除本地会话,避免界面继续显示已登录。
+
+## 首页交互
+
+首页维持现有灰绿、墨黑、纸张质感的编辑式视觉。右上角增加账户按钮:游客显示“登录 / 注册”,登录后显示昵称,昵称为空时显示邮箱前缀。
+
+账户界面覆盖在首页之上:
+
+- 登录包含“验证码登录”和“密码登录”两个模式;后端要求密码登录也提交验证码。
+- 注册包含邮箱、验证码、密码和可选昵称。
+- 发码按钮带 60 秒倒计时,避免重复请求。
+- 个人设置显示账户资料、修改密码表单和退出登录按钮。
+- 所有请求错误使用后端统一错误消息,不把失败解释成成功。
+
+## 路由保护
+
+首页 `/` 和开发环境 `/playtest/demo` 保持公开。其余产品路由使用同一个 `ProtectedRoute`。游客访问时跳转到:
+
+`/?account=login&returnTo=<原 pathname + search>`
+
+登录成功只允许恢复以 `/` 开头的站内地址;非法或缺失目标回到首页。
+
+## 验收
+
+- User API 的八个方法严格匹配后端路径、字段和响应。
+- Refresh Token 可跨浏览器重启恢复会话,Access Token 不写入持久存储。
+- 注册、两种登录、刷新、资料读取、改密和退出都有错误状态。
+- 游客可以浏览首页,但不能进入受保护页面。
+- 登录成功恢复原目标。
+- 个人设置不存在后端未提供的编辑功能。
+- 全量测试、格式、Lint、类型检查和生产构建通过。
diff --git a/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md
new file mode 100644
index 00000000..08f03e15
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md
@@ -0,0 +1,58 @@
+# 上传角色母版直达动作生成设计
+
+## 目标
+
+Quick Start 与 Workflow Editor 都允许用户在首次输入时选择一张角色图片。选择图片后,前端将其上传为角色母版,跳过角色图片生成和候选选择,直接创建角色并生成 32 帧动作资产。未选择图片时,原有角色图片生成流程保持不变。
+
+## 用户交互
+
+### Quick Start
+
+- 在输入区域右下角增加图片上传按钮,只接受 `image/*`。
+- 选择图片后显示文件名并允许替换或移除;文件在用户提交时上传。
+- 有图片时,输入框文字解释为动作描述:非空生成 `custom` 动作,空白生成默认 `idle` 动作。
+- 没有图片时,输入框仍是角色描述并继续现有候选图流程,因此必须填写文字。
+
+### Workflow Editor
+
+- 在 `character-setup` 节点增加图片选择入口。
+- 有图片时,该节点中的文字解释为动作描述,并在一次提交后直接进入动作生成。
+- 没有图片时,文字仍是角色描述,继续角色图生成和候选确认。
+
+## 架构与数据流
+
+1. App 只创建一个现有 `MediaApis` 实例,并通过页面 Service 注入,页面不直接拼 HTTP 请求。
+2. 提交图片时调用现有 `MediaApis.upload(file, 'reference-image', signal)`,得到不透明的 `MediaReference`。
+3. Workflow Controller 增加明确的“采用已上传角色母版”状态转换:
+ - `character-setup` 标记为 `passed`,记录上传媒体引用;
+ - `character-template` 标记为 `passed`,输出该上传图片;
+ - `template-candidate` 标记为 `passed`,记录该图片已被选定;
+ - `action-generation` 标记为 `active`。
+4. 该转换不创建 Generation 图片任务,也不伪造后端生成成功。
+5. Quick Start 复用现有角色创建与动作生成编排。Workflow Editor 委托同一用例,不维护第二套角色或动作生成逻辑。
+6. 动作输入使用上传图片作为 `firstFrameUrl` 和参考媒体;文字非空时使用 `custom`,空白时使用 `idle`。
+7. 动作完成后继续复用现有审核、角色更新、Playtest 发布和历史恢复流程。
+
+## 一致性与失败处理
+
+- 图片类型由前端提前检查,后端仍做最终校验。
+- Quick Start 先创建项目,再上传图片;上传失败时不创建 WorkflowRun,也不开始生成。
+- Workflow Editor 上传失败时保持 `character-setup` 为 `active`,用户可以重试。
+- 重复点击提交时只允许一项在途上传;页面离开时中止仍在途请求。
+- 上传成功但动作任务提交失败时,WorkflowRun 按现有动作生成失败规则记录错误,不静默回退到角色图生成。
+- 历史 16 帧或无上传图片的 WorkflowRun 仍按原契约恢复。
+
+## 测试范围
+
+- Quick Start 页面:右下角上传按钮、选图后允许空文字提交、移除图片后恢复文字必填。
+- Quick Start Service:上传后不调用角色图片 Generation,前三步合法通过,动作生成接收上传引用和动作描述;空文字生成 `idle`。
+- Workflow Editor 页面:有图时一次提交直达上传用例,无图时仍调用原 `nextStep`。
+- Workflow 状态:上传快捷转换保持固定五节点顺序,恰好一个 active 步骤,并可被本地存储恢复。
+- 失败:上传失败不推进 WorkflowRun;动作提交失败沿用现有失败状态。
+- App 装配:两个入口共享同一个真实 `MediaApis`,没有页面级假上传实现。
+
+## 非目标
+
+- 不增加新的媒体模块、后端上传接口或图片生成接口。
+- 不修改 Playtest、导出格式、Cocos 或微信小程序导入。
+- 不为上传图片增加裁剪、抠图或编辑器。
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index 50f46bc6..0c326e65 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -1,6 +1,6 @@
# Windup 前端架构
-本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写为只提交模块边界与接口;实现按模块拆成后续 PR 陆续落地,首页是第一个。
+本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。
---
@@ -41,8 +41,6 @@ pages -> features -> entities -> shared
`app` 只做启动和路由,不构造服务、不向下注入。
-外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。
-
### 依赖规则
1. 只能向下依赖,不允许反向。
@@ -58,6 +56,7 @@ pages -> features -> entities -> shared
```text
ProjectApis CharacterApis ActionTemplateApis GenerationApis
+TaskApis
```
**不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。
@@ -70,7 +69,8 @@ ProjectApis CharacterApis ActionTemplateApis GenerationApis
`features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。
-Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。
+Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、
+任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。
步骤顺序固定八步:
@@ -78,23 +78,35 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断
角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出
```
-**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。
+**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,
+只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前
+产品流程,不是为了通用编排而写的可配置工作流。
+
+当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller
+不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。
-从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。
+快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围,
+当前 Controller 只实现一次推进一个步骤。
-快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。
+Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例,
+不能在组件渲染或路由切换时重复创建。
---
-## 5. 尚未包含
+## 5. 当前实现范围
+
+- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验
+- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切
+- Store、Controller 和纵向流程测试
+
+页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。
-- 真实请求与数据获取,`XxxApis` 目前只有接口
-- 首页之外的页面实现,其余七个路由仍是占位外壳
-- 图片上传模块(体量太小,不单独体现)
-- 穿戴道具相关(产品侧未设计)
-- 第三方登录
+### 恢复边界
-首页已按本文的分层落地:它不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏那三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,写死在页面文案里,改流程时要一并改。
+- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。
+- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力,
+ 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。
+- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。
---
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d513d30c..2e5a5724 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "windup-frontend",
"version": "0.0.0",
"dependencies": {
+ "gifenc": "^1.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.3.0"
@@ -19,6 +20,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "ajv": "^8.20.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
@@ -2202,6 +2204,23 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2406,6 +2425,30 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2439,6 +2482,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/gifenc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/gifenc/-/gifenc-1.0.3.tgz",
+ "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
+ "license": "MIT"
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2525,6 +2574,13 @@
}
}
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0a845f78..5c368ad6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite",
+ "dev": "tsc -b --pretty false && vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"format": "oxfmt",
@@ -25,6 +25,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "ajv": "^8.20.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
diff --git a/frontend/src/app/api-contract.test.ts b/frontend/src/app/api-contract.test.ts
new file mode 100644
index 00000000..30bbceff
--- /dev/null
+++ b/frontend/src/app/api-contract.test.ts
@@ -0,0 +1,94 @@
+/**
+ * 前后端契约测试:用真实后端响应快照(__fixtures__/)验证 adapter 解析。
+ *
+ * 样本取自本地后端真实响应(character 25 / task 70 / project 列表)。
+ * 后端 DTO 形状一旦变化,这里立刻暴露 —— 不再依赖手工联调发现。
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+import character25 from './__fixtures__/character-25.json'
+import characterList from './__fixtures__/character-list.json'
+import task71 from './__fixtures__/task-71.json'
+import projectList from './__fixtures__/project-list.json'
+
+/** 信封解包(与 http-client 相同语义:返回 data 字段) */
+function unwrap(envelope: { data: T }): T {
+ return envelope.data
+}
+
+vi.mock('@/shared/api', () => ({
+ get: vi.fn(async (path: string) => {
+ if (path.startsWith('/characters?project_id')) return unwrap(characterList)
+ if (path.startsWith('/characters/')) return unwrap(character25)
+ if (path.startsWith('/generation/tasks/')) return unwrap(task71)
+ if (path.startsWith('/projects')) return unwrap(projectList)
+ throw new Error(`未收录的契约样本路径:${path}`)
+ }),
+ getPage: vi.fn(async (path: string) => {
+ const envelope = path.startsWith('/characters?project_id') ? characterList : projectList
+ return {
+ items: envelope.data,
+ total: envelope.total,
+ page: envelope.page,
+ pageSize: envelope.page_size,
+ }
+ }),
+ post: vi.fn(),
+ patch: vi.fn(),
+}))
+
+import { createCharacterApis, createGenerationApis, createProjectApis } from '@/entities'
+
+describe('adapter contract (real backend snapshots)', () => {
+ it('character.get parses outfit, action and frames from real payload', async () => {
+ const apis = createCharacterApis()
+ const character = await apis.get('25')
+
+ expect(character.id).toBe('25')
+ expect(character.projectId).toBe('37')
+ expect(character.outfits).toHaveLength(1)
+
+ const outfit = character.outfits[0]!
+ expect(outfit.id).toBe('outfit-25-default')
+ expect(outfit.name).toBe('默认造型')
+ expect(outfit.characterTemplateUrl).toContain('reference-image')
+
+ const action = outfit.actions[0]!
+ expect(action.id).toBe('25-custom')
+ expect(action.type).toBe('custom')
+ expect(action.name).toBe('自定义动作')
+ expect(action.frames.length).toBeGreaterThan(5)
+ expect(action.frames[0]!.imageUrl).toContain('action-frame')
+ expect(action.frames[0]!.durationMs).toBeTypeOf('number')
+ })
+
+ it('character.listByProject returns an array directly (envelope already unwrapped)', async () => {
+ const apis = createCharacterApis()
+ const characters = await apis.listByProject('37')
+
+ expect(Array.isArray(characters)).toBe(true)
+ expect(characters.length).toBeGreaterThan(0)
+ expect(characters[0]!.outfits[0]!.id).toBe('outfit-25-default')
+ })
+
+ it('generation.get maps the backend task endpoint into one entity', async () => {
+ const apis = createGenerationApis()
+ const generation = await apis.get('37', '71')
+
+ expect(generation.id).toBe('71')
+ expect(generation.projectId).toBe('37')
+ expect(generation.status).toBe('completed')
+ expect(generation.type).toBe('complete_animation')
+ })
+
+ it('project.list returns paged projects with sprite size', async () => {
+ const apis = createProjectApis()
+ const paged = await apis.list()
+
+ expect(paged.items.length).toBeGreaterThan(0)
+ const project = paged.items[0]!
+ expect(project.spriteSize.width).toBeGreaterThan(0)
+ expect(project.spriteSize.height).toBeGreaterThan(0)
+ expect(project.id).toBeTruthy()
+ })
+})
diff --git a/frontend/src/app/app-composition.test.tsx b/frontend/src/app/app-composition.test.tsx
new file mode 100644
index 00000000..154d9382
--- /dev/null
+++ b/frontend/src/app/app-composition.test.tsx
@@ -0,0 +1,81 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const characterApisFactory = vi.hoisted(() =>
+ vi.fn(() => ({
+ get: vi.fn(() => new Promise(() => undefined)),
+ listByProject: vi.fn().mockResolvedValue([]),
+ create: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ })),
+)
+const projectApisFactory = vi.hoisted(() =>
+ vi.fn(() => ({
+ get: vi.fn(() => new Promise(() => undefined)),
+ list: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 }),
+ create: vi.fn(),
+ })),
+)
+const userApisFactory = vi.hoisted(() => vi.fn())
+const mediaApisFactory = vi.hoisted(() =>
+ vi.fn(() => ({
+ upload: vi.fn(),
+ })),
+)
+
+vi.mock('@/entities', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ createCharacterApis: characterApisFactory,
+ createProjectApis: projectApisFactory,
+ createMediaApis: mediaApisFactory,
+ createUserApis: userApisFactory,
+ }
+})
+
+import { App } from './app'
+
+afterEach(() => {
+ cleanup()
+ window.history.replaceState({}, '', '/')
+ vi.clearAllMocks()
+ vi.unstubAllEnvs()
+})
+
+describe('App Playtest composition', () => {
+ it('uses local authentication by default without creating the backend auth adapter', async () => {
+ window.history.replaceState({}, '', '/')
+
+ render()
+
+ expect(userApisFactory).not.toHaveBeenCalled()
+ expect(await screen.findByRole('button', { name: '登录 / 注册' })).toBeTruthy()
+ expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('让你的角色,真正登场。')
+ })
+
+ it('opens the account panel for local authentication', async () => {
+ window.history.replaceState({}, '', '/?account=login&returnTo=%2Fprojects')
+
+ render()
+
+ expect(await screen.findByRole('dialog', { name: '账户认证' })).toBeTruthy()
+ })
+
+ it('creates one shared Character and Project API instance for Playtest routes', () => {
+ window.history.replaceState({}, '', '/playtest/25/outfit-25-default')
+
+ render()
+
+ expect(characterApisFactory).toHaveBeenCalledTimes(1)
+ expect(projectApisFactory).toHaveBeenCalledTimes(1)
+ })
+
+ it('creates one shared Media API instance for both character-creation entries', () => {
+ render()
+
+ expect(mediaApisFactory).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx
new file mode 100644
index 00000000..a92f267d
--- /dev/null
+++ b/frontend/src/app/app.test.tsx
@@ -0,0 +1,108 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { App } from './app'
+
+const user = {
+ id: 7,
+ email: 'ada@example.test',
+ nickname: 'Ada',
+ emailVerifiedAt: null,
+ status: 'normal' as const,
+}
+
+const userApis = {
+ sendCode: vi.fn(async () => undefined),
+ register: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })),
+ login: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })),
+ loginByCode: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })),
+ refresh: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })),
+ logout: vi.fn(async () => undefined),
+ me: vi.fn(async () => user),
+ changePassword: vi.fn(async () => undefined),
+}
+
+vi.mock('@/entities', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, createUserApis: vi.fn(() => userApis) }
+})
+
+afterEach(() => {
+ cleanup()
+ window.localStorage.clear()
+ window.history.replaceState({}, '', '/')
+ vi.unstubAllEnvs()
+})
+
+function authenticate() {
+ vi.stubEnv('VITE_AUTH_MODE', 'backend')
+ window.localStorage.setItem('windup.auth.refresh-token', 'refresh')
+}
+
+describe('App', () => {
+ it('provides the authentication session required by the home page', async () => {
+ window.history.replaceState({}, '', '/')
+
+ render()
+
+ expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe(
+ '让你的角色,真正登场。',
+ )
+ })
+
+ it('allows a guest to render the home page', async () => {
+ render()
+
+ expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe(
+ '让你的角色,真正登场。',
+ )
+ })
+
+ it('redirects a guest quick-start visit to login', async () => {
+ vi.stubEnv('VITE_AUTH_MODE', 'backend')
+ window.history.replaceState({}, '', '/quick-start')
+
+ render()
+
+ await screen.findByRole('dialog', { name: '账户认证' })
+ expect(window.location.search).toBe('?account=login&returnTo=%2Fquick-start')
+ })
+
+ it('keeps the new-project route ahead of the dynamic project detail route', async () => {
+ window.history.replaceState({}, '', '/projects/new')
+ authenticate()
+
+ render()
+
+ expect(await screen.findByRole('heading', { name: '新建项目' })).toBeTruthy()
+ })
+
+ it('将项目完成版本的入口路由到历史记录', async () => {
+ window.history.replaceState({}, '', '/projects/project-1/history')
+ authenticate()
+
+ render()
+
+ expect(await screen.findByRole('heading', { name: '历史记录' })).toBeTruthy()
+ })
+
+ it('keeps the asset library separate from workflow history', async () => {
+ window.history.replaceState({}, '', '/projects/project-1/assets')
+ authenticate()
+
+ render()
+
+ expect(await screen.findByText('正在读取项目…')).toBeTruthy()
+ expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull()
+ })
+
+ it('provides a dedicated Playtest entry', async () => {
+ window.history.replaceState({}, '', '/playtest')
+ authenticate()
+
+ render()
+
+ expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy()
+ })
+})
diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx
index 06268ead..3e3904a4 100644
--- a/frontend/src/app/app.tsx
+++ b/frontend/src/app/app.tsx
@@ -1,37 +1,244 @@
-import { BrowserRouter, Route, Routes } from 'react-router'
+import { lazy, Suspense, useMemo } from 'react'
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
+import {
+ createCharacterApis,
+ createGenerationApis,
+ createMediaApis,
+ createPlaytestInspectionApis,
+ createProjectApis,
+ createUserApis,
+ createWorkflowRunStore,
+ type UserApis,
+} from '@/entities'
+import {
+ AuthModeProvider,
+ ProtectedRoute,
+ createLocalUserApis,
+ resolveAuthMode,
+} from '@/features/auth-session'
+import { createWorkflowController } from '@/features/workflow-controller'
import { AssetLibraryPage } from '@/pages/asset-library'
+import { CharacterDetailPage } from '@/pages/character-detail'
import { HomePage } from '@/pages/home'
+import { HistoryPage } from '@/pages/history'
import { NotFoundPage } from '@/pages/not-found'
+import { PlaytestEntryPage } from '@/pages/playtest/entry'
import { PlaytestPage } from '@/pages/playtest'
import { ProjectDetailPage } from '@/pages/project-detail'
+import { ProjectCreatePage } from '@/pages/projects/create-page'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
import { WorkflowEditorPage } from '@/pages/workflow-editor'
-import { AppShellRoute } from './layout'
+import { AppShell } from './layout'
+import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick-start/service'
+import { createWorkflowEditorService } from '@/pages/workflow-editor/service'
+
+const PlaytestDemoPage = import.meta.env.DEV
+ ? lazy(() =>
+ import('@/pages/playtest/demo-page').then(({ PlaytestDemoPage }) => ({
+ default: PlaytestDemoPage,
+ })),
+ )
+ : null
/**
* 路由表与全局外壳。
- * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
- * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。
+ * App 只装配一次共享接口实例,再把页面所需的最小接口集合传入对应路由。
*/
export function App() {
return (
-
- } />
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
)
}
+
+/** 路由声明独立导出,测试可在 MemoryRouter 中验证直达地址。 */
+export function AppRoutes({ userApis }: { userApis?: UserApis } = {}) {
+ const authMode = resolveAuthMode()
+ const services = useMemo(() => {
+ const sharedUserApis =
+ userApis ?? (authMode === 'backend' ? createUserApis() : createLocalUserApis())
+ const projectApis = createProjectApis()
+ const characterApis = createCharacterApis()
+ const inspectionApis = createPlaytestInspectionApis()
+ const generationApis = createGenerationApis()
+ const mediaApis = createMediaApis()
+ const store = createWorkflowRunStore()
+ const controller = createWorkflowController({ store, generationApis, characterApis })
+ const quickStart = createQuickStartService({
+ controller,
+ prepareProject: createAutoPrepareProject(projectApis),
+ characterApis,
+ mediaApis,
+ })
+ const workflowEditor = createWorkflowEditorService({
+ controller,
+ mediaApis,
+ getProject: (projectId) => projectApis.get(projectId),
+ prepareProject: async (input) => {
+ const project = await projectApis.create({
+ name: input.projectName,
+ perspective:
+ input.view === 'topdown'
+ ? 'top-down'
+ : input.view === 'isometric'
+ ? 'isometric'
+ : 'side',
+ directionalMovement:
+ input.directions === '8'
+ ? 'eight-way'
+ : input.directions === '4'
+ ? 'four-way'
+ : 'single',
+ spriteSize: { width: Number(input.canvasSize), height: Number(input.canvasSize) },
+ gameStyle: input.style || null,
+ })
+ return { id: project.id, spriteSize: project.spriteSize }
+ },
+ })
+ const playtestApis = {
+ projects: projectApis,
+ characters: characterApis,
+ inspections: inspectionApis,
+ }
+ return {
+ userApis: sharedUserApis,
+ projectApis,
+ characterApis,
+ quickStart,
+ workflowEditor,
+ store,
+ playtestApis,
+ }
+ }, [authMode, userApis])
+
+ return (
+ // 本地与真实认证共用会话和页面,差异只留在这里的适配器装配。
+
+
+
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ >
+ } />
+ } />
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ {PlaytestDemoPage ? (
+
+
+
+ }
+ />
+ ) : null}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+
+
+ )
+}
diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts
index f1c81c7f..c4279fdb 100644
--- a/frontend/src/app/index.ts
+++ b/frontend/src/app/index.ts
@@ -1 +1 @@
-export { App } from './app'
+export { App, AppRoutes } from './app'
diff --git a/frontend/src/app/layout/index.test.tsx b/frontend/src/app/layout/index.test.tsx
new file mode 100644
index 00000000..468c7a37
--- /dev/null
+++ b/frontend/src/app/layout/index.test.tsx
@@ -0,0 +1,113 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MemoryRouter } from 'react-router'
+
+import type { AuthSessionValue } from '@/features/auth-session'
+import { useAuthSession } from '@/features/auth-session'
+import { HomePage } from '@/pages/home'
+import { AppShell } from './index'
+
+vi.mock('@/features/auth-session', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useAuthSession: vi.fn() }
+})
+
+const mockedUseAuthSession = vi.mocked(useAuthSession)
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllEnvs()
+})
+
+beforeEach(() => {
+ vi.stubEnv('VITE_AUTH_MODE', 'backend')
+ const user = {
+ id: 7,
+ email: 'ada@example.test',
+ nickname: 'Ada',
+ emailVerifiedAt: null,
+ status: 'normal' as const,
+ }
+ const tokens = { accessToken: 'access', refreshToken: 'refresh', user }
+ mockedUseAuthSession.mockReset()
+ mockedUseAuthSession.mockReturnValue({
+ state: { status: 'guest', user: null },
+ sendCode: vi.fn(async () => undefined),
+ register: vi.fn(async () => tokens),
+ login: vi.fn(async () => tokens),
+ loginByCode: vi.fn(async () => tokens),
+ changePassword: vi.fn(async () => undefined),
+ logout: vi.fn(async () => undefined),
+ } satisfies AuthSessionValue)
+})
+
+describe('AppShell', () => {
+ it.each([
+ ['/', '首页'],
+ ['/workflow-editor/run-1', 'Workflow Editor'],
+ ])('为%s 使用全宽页面容器', (pathname) => {
+ render(
+
+
+ 页面内容
+
+ ,
+ )
+
+ expect(screen.getByRole('main').className).toContain('w-full')
+ expect(screen.getByRole('main').className).not.toContain('max-w-5xl')
+ })
+
+ it.each(['/playtest', '/playtest/demo', '/playtest/character-1/outfit-1'])(
+ '在独立 Playtest 工作台 %s 中保留返回入口与产品导航',
+ (pathname) => {
+ render(
+
+
+ Playtest 工作台
+
+ ,
+ )
+
+ expect(screen.getByRole('banner')).toBeTruthy()
+ expect(screen.getByRole('button', { name: '返回上一页' })).toBeTruthy()
+ expect(screen.getByRole('link', { name: '首页' }).getAttribute('href')).toBe('/')
+ expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects')
+ expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBe('page')
+ expect(screen.getByRole('link', { name: '创作' }).getAttribute('href')).toBe('/quick-start')
+ expect(screen.getAllByRole('main')).toHaveLength(1)
+ },
+ )
+
+ it('首页账户面板将 Header 与页面统一隔离,同时让 portal dialog 留在背景之外', () => {
+ render(
+
+
+
+
+ ,
+ )
+
+ const dialog = screen.getByRole('dialog', { name: '账户认证' })
+ const header = screen
+ .getByRole('link', { name: '返回 Windup 首页', hidden: true })
+ .closest('header')!
+ const background = header.parentElement!
+ const homeHeading = screen.getByRole('heading', { name: /真正登场/, hidden: true })
+
+ expect(background.contains(homeHeading)).toBe(true)
+ expect(background.getAttribute('inert')).toBe('')
+ expect(background.getAttribute('aria-hidden')).toBe('true')
+ expect(dialog.parentElement?.parentElement).toBe(document.body)
+ expect(dialog.closest('[inert]')).toBeNull()
+
+ fireEvent.click(within(dialog).getByRole('button', { name: '关闭账户面板' }))
+
+ expect(screen.queryByRole('dialog')).toBeNull()
+ const restoredHeader = screen.getByRole('link', { name: '返回 Windup 首页' }).closest('header')!
+ const restoredBackground = restoredHeader.parentElement!
+ expect(restoredBackground.getAttribute('inert')).toBeNull()
+ expect(restoredBackground.getAttribute('aria-hidden')).toBeNull()
+ })
+})
diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx
index b797b339..d2c9c520 100644
--- a/frontend/src/app/layout/index.tsx
+++ b/frontend/src/app/layout/index.tsx
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
-import { Link, Outlet } from 'react-router'
+import { useLocation } from 'react-router'
+
+import { AppHeader } from './app-header'
/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */
@@ -10,36 +12,33 @@ export interface AppShellProps {
/** 全站外壳,全局导航常驻。 */
export function AppShell({ children }: AppShellProps) {
- return (
-
-
- {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */}
-
{children}
-
- )
-}
+ const { pathname, search } = useLocation()
+ const isPlaytestWorkspace = pathname.startsWith('/playtest')
+ const isWorkflowWorkspace = pathname.startsWith('/workflow-editor')
+ const isProjectWorkspace =
+ /^\/projects\/[^/]+(?:\/|$)/u.test(pathname) && pathname !== '/projects/new'
+ const isHomePage = pathname === '/'
+ const isHomeAccountOpen = isHomePage && new URLSearchParams(search).has('account')
+ const pageClassName =
+ isPlaytestWorkspace || isWorkflowWorkspace || isProjectWorkspace
+ ? 'w-full px-0 pb-0 pt-0'
+ : isHomePage
+ ? 'w-full'
+ : 'mx-auto max-w-5xl px-6 pb-8 pt-24'
-/**
- * 外壳的路由形态,套在一组子路由外面。
- * 哪些页面带外壳是路由决策,写在 app 的路由表里;外壳自身不读 pathname、不判断自己该不该出现。
- */
-export function AppShellRoute() {
return (
-
-
-
+
+ {/* Playtest 保留产品导航;workflow-editor 和项目详情使用各自的工作台导航。 */}
+ {!isWorkflowWorkspace && !isProjectWorkspace &&
}
+ {isPlaytestWorkspace ? (
+
{children}
+ ) : (
+
{children}
+ )}
+
)
}
diff --git a/frontend/src/entities/character/api.test.ts b/frontend/src/entities/character/api.test.ts
new file mode 100644
index 00000000..a507e302
--- /dev/null
+++ b/frontend/src/entities/character/api.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createCharacterApis } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('character API adapter', () => {
+ it('preserves action playback metadata when saving the complete character tree', async () => {
+ const backendCharacter = {
+ id: 25,
+ project_id: 3,
+ description: null,
+ reference_image_url: null,
+ status: 1,
+ character_data: {
+ version: 1,
+ outfits: [
+ {
+ id: 'outfit-default',
+ name: 'Default',
+ description: null,
+ preview_url: null,
+ actions: [
+ {
+ id: 'idle',
+ type: 'idle',
+ name: 'Idle',
+ loop: true,
+ fps: 8,
+ frame_count: 1,
+ frames: [
+ { index: 0, image_url: '/idle-0.png', duration_ms: 125, root_motion: null },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ }
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(backendCharacter))
+ .mockResolvedValueOnce(jsonResponse(backendCharacter))
+ vi.stubGlobal('fetch', fetchMock)
+
+ const apis = createCharacterApis()
+ const character = await apis.get('25')
+ await apis.update(character)
+
+ expect(character.outfits[0]?.actions[0]?.loop).toBe(true)
+ expect(character.outfits[0]?.actions[0]?.expectedFrameCount).toBe(1)
+ const updateRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
+ const updateBody = JSON.parse(String(updateRequest.body)) as {
+ character_data: {
+ outfits: Array<{ actions: Array<{ loop: boolean; frame_count: number }> }>
+ }
+ }
+ expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true)
+ expect(updateBody.character_data.outfits[0]?.actions[0]?.frame_count).toBe(1)
+ })
+
+ it('loads every character page for a project instead of truncating after 100 items', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ listResponse(
+ Array.from({ length: 100 }, (_, index) => character(index + 1)),
+ 101,
+ 1,
+ 100,
+ ),
+ )
+ .mockResolvedValueOnce(listResponse([character(101)], 101, 2, 100))
+ vi.stubGlobal('fetch', fetchMock)
+
+ const result = await createCharacterApis().listByProject('3')
+
+ expect(result).toHaveLength(101)
+ expect(result[0]?.id).toBe('1')
+ expect(result[100]?.id).toBe('101')
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ 'http://127.0.0.1:8000/characters?project_id=3&page=1&page_size=100',
+ expect.any(Object),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ 'http://127.0.0.1:8000/characters?project_id=3&page=2&page_size=100',
+ expect.any(Object),
+ )
+ })
+})
+
+function character(id: number) {
+ return {
+ id,
+ project_id: 3,
+ description: null,
+ reference_image_url: null,
+ status: 1,
+ character_data: { version: 1, outfits: [] },
+ }
+}
+
+function listResponse(data: unknown[], total: number, page: number, pageSize: number) {
+ return new Response(
+ JSON.stringify({ code: 200, message: 'success', data, total, page, page_size: pageSize }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ )
+}
+
+function jsonResponse(data: unknown) {
+ return new Response(JSON.stringify({ code: 200, message: 'success', data }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts
new file mode 100644
index 00000000..d3159ebe
--- /dev/null
+++ b/frontend/src/entities/character/api.ts
@@ -0,0 +1,202 @@
+import type {
+ Action,
+ ActionType,
+ Character,
+ CharacterApis,
+ CreateCharacterInput,
+ Frame,
+ Outfit,
+} from '.'
+
+import { del, get, getPage, patch, post } from '@/shared/api'
+import type { Paged, PageQuery } from '@/shared/pagination'
+
+/* ─── 后端 DTO ─── */
+
+interface BackendFrame {
+ index: number
+ image_url: string
+ duration_ms: number | null
+ root_motion?: { dx: number; dy: number } | null
+}
+
+interface BackendAction {
+ id: string
+ type: string
+ name: string
+ loop: boolean
+ fps: number
+ frame_count: number
+ frames: BackendFrame[]
+}
+
+interface BackendOutfit {
+ id: string
+ name: string
+ description: string | null
+ preview_url: string | null
+ actions: BackendAction[]
+}
+
+interface BackendCharacterData {
+ version: number
+ outfits: BackendOutfit[]
+}
+
+interface BackendCharacter {
+ id: number
+ project_id: number
+ name?: string | null
+ description: string | null
+ reference_image_url: string | null
+ character_data: BackendCharacterData
+ status: number
+ create_at?: string
+ update_at?: string
+}
+
+/* ─── 映射 ─── */
+
+const ACTION_TYPE_SET = new Set(['walk', 'idle', 'attack', 'jump', 'custom'])
+
+function toActionType(raw: string): ActionType {
+ return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom'
+}
+
+function toFrame(raw: BackendFrame): Frame {
+ return {
+ imageUrl: raw.image_url,
+ durationMs: raw.duration_ms,
+ rootMotion: raw.root_motion ?? null,
+ }
+}
+
+function toAction(raw: BackendAction, outfitId: string): Action {
+ return {
+ id: raw.id,
+ outfitId,
+ name: raw.name,
+ expectedFrameCount: raw.frame_count,
+ loop: raw.loop,
+ kind: 'custom', // 后端不区分 preset/custom
+ type: toActionType(raw.type),
+ fps: raw.fps,
+ keyFrameIndex: null, // 后端不提供关键帧索引
+ frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame),
+ }
+}
+
+function toOutfit(raw: BackendOutfit, characterId: string): Outfit {
+ return {
+ id: raw.id,
+ characterId,
+ name: raw.name,
+ description: raw.description,
+ candidateCharacterTemplates: [], // 后端 character_data 不含候选
+ characterTemplateUrl: raw.preview_url,
+ baseFrames: [],
+ actions: raw.actions.map((a) => toAction(a, raw.id)),
+ }
+}
+
+function toCharacter(raw: BackendCharacter): Character {
+ const id = String(raw.id)
+ return {
+ id,
+ projectId: String(raw.project_id),
+ name: raw.name ?? null,
+ description: raw.description,
+ referenceImageUrl: raw.reference_image_url,
+ dataVersion: raw.character_data?.version ?? 1,
+ status: raw.status,
+ createdAt: raw.create_at ?? '',
+ updatedAt: raw.update_at ?? '',
+ outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)),
+ }
+}
+
+/* ─── 适配器 ─── */
+
+export function createCharacterApis(): CharacterApis {
+ async function listPageByProject(
+ projectId: string,
+ query: PageQuery = {},
+ ): Promise> {
+ const params = new URLSearchParams({ project_id: projectId })
+ if (query.page) params.set('page', String(query.page))
+ if (query.pageSize) params.set('page_size', String(query.pageSize))
+ const page = await getPage(`/characters?${params}`)
+ return { ...page, items: page.items.map(toCharacter) }
+ }
+
+ return {
+ async get(id: string): Promise {
+ const raw = await get(`/characters/${id}`)
+ return toCharacter(raw)
+ },
+
+ async listByProject(projectId: string): Promise {
+ const items: Character[] = []
+ let pageNumber = 1
+ for (;;) {
+ const page = await listPageByProject(projectId, { page: pageNumber, pageSize: 100 })
+ items.push(...page.items)
+ if (items.length >= page.total || page.items.length === 0) break
+ pageNumber += 1
+ }
+ return items
+ },
+
+ listPageByProject,
+
+ async create(input: CreateCharacterInput): Promise {
+ const raw = await post('/characters', {
+ project_id: Number(input.projectId),
+ name: input.name ?? null,
+ description: input.description,
+ reference_image_url: input.referenceImageUrl ?? null,
+ })
+ return toCharacter(raw)
+ },
+
+ async update(character: Character): Promise {
+ const payload = {
+ name: character.name ?? null,
+ description: character.description ?? null,
+ reference_image_url: character.referenceImageUrl ?? null,
+ character_data: {
+ version: character.dataVersion ?? 1,
+ outfits: character.outfits.map((outfit) => ({
+ id: outfit.id,
+ name: outfit.name,
+ description: outfit.description ?? null,
+ preview_url: outfit.characterTemplateUrl,
+ actions: outfit.actions.map((action) => ({
+ id: action.id,
+ type: action.type,
+ name: action.name,
+ loop: action.loop ?? false,
+ fps: action.fps,
+ frame_count: action.expectedFrameCount ?? action.frames.length,
+ frames: action.frames.map((frame, index) => ({
+ index,
+ image_url: frame.imageUrl,
+ duration_ms: frame.durationMs,
+ })),
+ })),
+ })),
+ },
+ }
+ const raw = await patch(`/characters/${character.id}`, payload)
+ const saved = toCharacter(raw)
+ if (saved.projectId !== character.projectId) {
+ throw new Error('后端未保存新的项目归属')
+ }
+ return saved
+ },
+
+ async remove(id: string): Promise {
+ await del(`/characters/${id}`)
+ },
+ }
+}
diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts
index 616b5db0..a8404b95 100644
--- a/frontend/src/entities/character/index.ts
+++ b/frontend/src/entities/character/index.ts
@@ -61,6 +61,13 @@ export interface Action {
id: string
outfitId: Outfit['id']
name: string
+ /**
+ * 生成端声明的完整帧数。旧的纯前端数据可以暂时缺省,但正式后端数据必须保留该值,
+ * 否则 Playtest 不能判断收到的 frames 是否完整。
+ */
+ expectedFrameCount?: number
+ /** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */
+ loop?: boolean
/** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */
kind: ActionKind
/** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */
@@ -92,6 +99,8 @@ export interface Outfit {
id: string
characterId: string
name: string
+ /** 造型说明来自 character_data;旧资产没有时为 null。 */
+ description?: string | null
/** 母版生成阶段返回的候选;生成完成前可以为空数组。 */
candidateCharacterTemplates: CharacterTemplateCandidate[]
/** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */
@@ -106,11 +115,20 @@ export interface Outfit {
* 项目下的角色资产;造型拥有各自的母版和动作帧。
*
* 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。
- * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。
+ * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的节点里,直到用户确认导出才整体写入。
*/
export interface Character {
id: string
projectId: string
+ /** 当前后端返回时用于资产库展示;旧记录没有时为空。 */
+ name?: string | null
+ /** 角色描述与参考图属于 Character 顶层后端字段。 */
+ description?: string | null
+ referenceImageUrl?: string | null
+ /** character_data.version,整棵更新时原样带回。 */
+ dataVersion?: number
+ /** 后端记录状态;当前 1 表示正常。 */
+ status?: number
/** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */
outfits: Outfit[]
createdAt: string
@@ -120,6 +138,7 @@ export interface Character {
/** 创建角色并发起母版生成所需的入参。 */
export interface CreateCharacterInput {
projectId: string
+ name?: string | null
/** 交给模型生成母版。 */
description: string
referenceImageUrl?: string | null
@@ -132,6 +151,11 @@ export interface CreateCharacterInput {
export interface CharacterApis {
get(id: Character['id']): Promise
listByProject(projectId: string): Promise
+ listPageByProject?(
+ projectId: string,
+ query?: import('@/shared/pagination').PageQuery,
+ ): Promise>
create(input: CreateCharacterInput): Promise
update(character: Character): Promise
+ remove(id: Character['id']): Promise
}
diff --git a/frontend/src/entities/constants.ts b/frontend/src/entities/constants.ts
new file mode 100644
index 00000000..62eafbdb
--- /dev/null
+++ b/frontend/src/entities/constants.ts
@@ -0,0 +1,14 @@
+export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const
+export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const
+export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const
+export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const
+export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const
+
+/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */
+export const WORKFLOW_NODE_ORDER = [
+ 'character-setup',
+ 'character-template',
+ 'action-first-frame',
+ 'action-full-frame',
+ 'review',
+] as const
diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts
new file mode 100644
index 00000000..ba58c6e8
--- /dev/null
+++ b/frontend/src/entities/generation/api.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { MediaReference } from '../media'
+import { createGenerationApis } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function generationTaskResponse() {
+ return new Response(
+ JSON.stringify({
+ code: 200,
+ message: 'success',
+ data: {
+ id: 11,
+ user_id: 1,
+ project_id: 7,
+ task_type: 'character_action',
+ status: 'pending',
+ input_payload: {},
+ result: null,
+ error_message: null,
+ },
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ )
+}
+
+describe('generation API adapter', () => {
+ it('requests 32 frames for a complete animation while keeping first-frame generation at one frame', async () => {
+ const fetchMock = vi.fn().mockImplementation(async () => generationTaskResponse())
+ vi.stubGlobal('fetch', fetchMock)
+ const api = createGenerationApis()
+
+ await api.create({
+ type: 'first_frame',
+ projectId: '7',
+ characterId: '9',
+ outfitId: 'outfit-9-default',
+ actionType: 'walk',
+ prompt: null,
+ referenceMedia: [],
+ })
+ await api.create({
+ type: 'complete_animation',
+ projectId: '7',
+ characterId: '9',
+ outfitId: 'outfit-9-default',
+ actionType: 'walk',
+ firstFrameUrl: 'https://cdn.example.com/first-frame.png',
+ prompt: null,
+ referenceMedia: ['media-reference-1' as MediaReference],
+ })
+
+ const firstFramePayload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record<
+ string,
+ unknown
+ >
+ const completeAnimationPayload = JSON.parse(
+ String(fetchMock.mock.calls[1]?.[1]?.body),
+ ) as Record
+ expect(firstFramePayload.num_frames).toBe(1)
+ expect(completeAnimationPayload.num_frames).toBe(32)
+ })
+})
diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts
new file mode 100644
index 00000000..012608d4
--- /dev/null
+++ b/frontend/src/entities/generation/api.ts
@@ -0,0 +1,210 @@
+import {
+ COMPLETE_ANIMATION_FRAME_COUNT,
+ type Generation,
+ type GenerationApis,
+ type GenerationEvent,
+ type GenerationInput,
+ type GenerationType,
+} from '.'
+
+import { get, post } from '@/shared/api'
+import { subscribeToEventStream } from '@/shared/api/stream'
+
+/* ─── 后端 DTO ─── */
+
+interface BackendGenerationTask {
+ id: number
+ user_id: number
+ project_id: number
+ task_type: string
+ status: string
+ input_payload: Record
+ result: unknown
+ error_message: string | null
+}
+
+/* ─── 映射 ─── */
+
+const STATUS_MAP: Record = {
+ pending: 'pending',
+ running: 'running',
+ completed: 'completed',
+ failed: 'failed',
+}
+
+const GENERATION_TYPE_MAP: Record = {
+ character_image: 'character_template',
+ character_template: 'character_template',
+ character_action: 'complete_animation',
+ first_frame: 'first_frame',
+ complete_animation: 'complete_animation',
+}
+
+function toGeneration(
+ raw: BackendGenerationTask,
+ expectedType?: T,
+): Generation {
+ const type = expectedType ?? ((GENERATION_TYPE_MAP[raw.task_type] ?? raw.task_type) as T)
+ return {
+ id: String(raw.id),
+ projectId: String(raw.project_id),
+ type,
+ status: STATUS_MAP[raw.status] ?? 'pending',
+ result: toGenerationResult(type, raw.result),
+ error: raw.error_message,
+ }
+}
+
+function toGenerationResult(type: GenerationType, value: unknown): Generation['result'] {
+ if (!value || typeof value !== 'object') return null
+ if (type === 'character_template') {
+ const result = value as { image_urls?: unknown }
+ return Array.isArray(result.image_urls)
+ ? {
+ type: 'character_template',
+ images: result.image_urls
+ .filter((url): url is string => typeof url === 'string' && url.length > 0)
+ .map((url) => ({ url })),
+ }
+ : null
+ }
+
+ const action = value as {
+ action_type?: unknown
+ frames?: readonly { index?: number; image_url?: unknown; duration_ms?: unknown }[]
+ }
+ const frames = Array.isArray(action.frames)
+ ? [...action.frames]
+ .sort((left, right) => (left.index ?? 0) - (right.index ?? 0))
+ .filter((frame) => typeof frame.image_url === 'string' && frame.image_url.length > 0)
+ .map((frame) => ({
+ url: frame.image_url as string,
+ durationMs: typeof frame.duration_ms === 'number' ? frame.duration_ms : null,
+ }))
+ : []
+ if (frames.length === 0) return null
+ if (type === 'first_frame') return { type: 'first_frame', image: frames[0]! }
+
+ const knownTypes = new Set(['walk', 'idle', 'attack', 'jump', 'custom'])
+ return {
+ type: 'complete_animation',
+ actionType:
+ typeof action.action_type === 'string' && knownTypes.has(action.action_type)
+ ? (action.action_type as 'walk' | 'idle' | 'attack' | 'jump' | 'custom')
+ : 'custom',
+ frames,
+ }
+}
+
+/* ─── 输入 → 后端请求体 ─── */
+
+function toBackendPayload(input: GenerationInput, userId: number) {
+ if (input.type === 'character_template') {
+ return {
+ user_id: userId,
+ project_id: Number(input.projectId),
+ prompt: input.prompt,
+ reference_image_url: input.referenceMedia[0] ?? null,
+ width: input.spriteWidth,
+ height: input.spriteHeight,
+ num_images: 4,
+ }
+ }
+
+ if (input.type === 'first_frame') {
+ return {
+ user_id: userId,
+ project_id: Number(input.projectId),
+ character_id: Number(input.characterId),
+ action_type: input.actionType,
+ custom_prompt: input.prompt,
+ reference_image_urls: input.referenceMedia.map(String),
+ num_frames: 1,
+ }
+ }
+
+ // complete_animation
+ return {
+ user_id: userId,
+ project_id: Number(input.projectId),
+ character_id: Number(input.characterId),
+ action_type: input.actionType,
+ custom_prompt: input.prompt,
+ reference_image_urls: [input.firstFrameUrl, ...input.referenceMedia.map(String)],
+ num_frames: COMPLETE_ANIMATION_FRAME_COUNT,
+ }
+}
+
+/* ─── 适配器 ─── */
+
+const GENERATION_ENDPOINTS: Record = {
+ character_template: '/generation/image',
+ first_frame: '/generation/action',
+ complete_animation: '/generation/action',
+}
+
+function streamUrl(projectId: string, id: string) {
+ const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000'
+ return `${baseUrl.replace(/\/$/u, '')}/generation/tasks/${encodeURIComponent(id)}/stream?project_id=${encodeURIComponent(projectId)}`
+}
+
+function parseTaskUpdate(data: string): GenerationEvent {
+ let value: unknown
+ try {
+ value = JSON.parse(data) as unknown
+ } catch (cause) {
+ throw new Error('task_update 不是有效 JSON', { cause })
+ }
+ if (!value || typeof value !== 'object') throw new Error('task_update 不是对象')
+ const event = value as Record
+ const taskId = event.task_id
+ const taskType = event.task_type
+ const status = event.status
+ if ((typeof taskId !== 'string' && typeof taskId !== 'number') || typeof taskType !== 'string') {
+ throw new Error('task_update 缺少任务标识或类型')
+ }
+ if (typeof status !== 'string' || !(status in STATUS_MAP)) {
+ throw new Error('task_update 状态无效')
+ }
+ const type = GENERATION_TYPE_MAP[taskType] ?? 'complete_animation'
+ return {
+ taskId: String(taskId),
+ type,
+ status: STATUS_MAP[status]!,
+ result: toGenerationResult(type, event.result),
+ error: typeof event.error_message === 'string' ? event.error_message : null,
+ }
+}
+
+export function createGenerationApis(): GenerationApis {
+ return {
+ async create(input: T): Promise> {
+ const endpoint = GENERATION_ENDPOINTS[input.type]
+ if (!endpoint) throw new Error(`未知的生成类型:${input.type}`)
+
+ const payload = toBackendPayload(input, 1) // TODO: 接入认证后替换 userId
+ const raw = await post(endpoint, payload)
+ return toGeneration(raw, input.type)
+ },
+
+ async get(projectId: string, id: string): Promise {
+ const raw = await get(
+ `/generation/tasks/${id}?project_id=${encodeURIComponent(projectId)}`,
+ )
+ return toGeneration(raw)
+ },
+
+ subscribe(projectId, id, onEvent, onError = () => undefined) {
+ return subscribeToEventStream(streamUrl(projectId, id), {
+ eventName: 'task_update',
+ onEvent(data) {
+ const event = parseTaskUpdate(data)
+ if (event.taskId !== id) throw new Error('task_update 与订阅任务不一致')
+ onEvent(event)
+ return event.status === 'completed' || event.status === 'failed'
+ },
+ onError,
+ })
+ },
+ }
+}
diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts
index cadb63d4..16b47030 100644
--- a/frontend/src/entities/generation/index.ts
+++ b/frontend/src/entities/generation/index.ts
@@ -4,26 +4,17 @@ import type { MediaReference } from '../media'
/**
* Generation 是业务数据,不是「调用图片生成能力」。
* 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。
- *
- * 后端只有 GenerationTask 一个实体,generation 与 task 指同一条记录;
- * `/generation/tasks/{task_id}` 里的 tasks 只是路径段,前端不为它另立实体。
- */
-
-/**
- * 后端 GenerationTask.status,与 WorkflowRevision.generationStatus 不是一回事:
- * 这里是单次生成任务的状态,那里是一个版本在生成阶段的汇总状态。
- * pending 表示已提交但尚未执行。
*/
-export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed'
-/**
- * 生成对应的三个前端可见异步步骤。
- * 它是前端工作流粒度,不等于后端 task_type——后端只有 character_image 与
- * character_action 两种,character_template 和 first_frame 都落在 character_image 上。
- * 完整动画内部可含视频生成、截帧和多次图像处理,但对前端仍是一次 Generation。
- */
+/** 生成对应的三个前端可见异步步骤。 */
export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation'
+/** 完整动作默认生成帧数;首帧生成仍固定为 1 帧。 */
+export const COMPLETE_ANIMATION_FRAME_COUNT = 32
+
+/** 后端单次生成任务的生命周期。 */
+export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed'
+
interface GenerationInputBase {
projectId: string
/** 可选参考媒体;没有参考图时传空数组。 */
@@ -35,6 +26,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase {
type: 'character_template'
/** 已由手动输入或 Quick Start 整理好的角色提示词。 */
prompt: string
+ /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */
+ spriteWidth: number
+ /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */
+ spriteHeight: number
}
/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */
@@ -74,15 +69,84 @@ export interface CharacterTemplateGenerationResult {
images: readonly GeneratedImage[]
}
+/**
+ * Generation.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。
+ *
+ * 兼容后端两种返回格式:
+ * - 旧版单图:`{ type, image_url: "..." }`
+ * - 新版多图:`{ type, image_urls: ["...", "..."] }`
+ */
+export function parseCharacterTemplateGenerationResult(
+ value: unknown,
+): CharacterTemplateGenerationResult | null {
+ if (
+ !isRecord(value) ||
+ (value.type !== 'character_template' && value.type !== 'character_image')
+ ) {
+ return null
+ }
+
+ // 优先使用 image_urls(多图),兼容 image_url(单图)
+ const rawUrls: string[] = []
+ if (Array.isArray(value.image_urls)) {
+ for (const item of value.image_urls) {
+ if (typeof item === 'string' && item.length > 0) rawUrls.push(item)
+ }
+ } else if (typeof value.image_url === 'string' && value.image_url.length > 0) {
+ rawUrls.push(value.image_url)
+ }
+
+ // 兼容旧版 images 数组格式
+ if (rawUrls.length === 0 && Array.isArray(value.images)) {
+ for (const image of value.images) {
+ if (isRecord(image) && typeof image.url === 'string' && image.url.length > 0) {
+ rawUrls.push(image.url)
+ }
+ }
+ }
+
+ if (rawUrls.length === 0) return null
+
+ const images: GeneratedImage[] = rawUrls.map((url) => ({ url }))
+ return { type: 'character_template', images }
+}
+
export interface FirstFrameGenerationResult {
type: 'first_frame'
image: GeneratedImage
}
+export interface GeneratedAnimationFrame extends GeneratedImage {
+ durationMs: number | null
+}
+
/** 帧顺序由数组位置表达。 */
export interface CompleteAnimationGenerationResult {
type: 'complete_animation'
- frames: readonly GeneratedImage[]
+ actionType: ActionType
+ frames: readonly GeneratedAnimationFrame[]
+}
+
+/** 校验已经过适配层归一化的完整动画结果,供本地持久化恢复使用。 */
+export function parseCompleteAnimationGenerationResult(
+ value: unknown,
+): CompleteAnimationGenerationResult | null {
+ if (!isRecord(value) || value.type !== 'complete_animation') return null
+ if (!['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType))) return null
+ if (!Array.isArray(value.frames) || value.frames.length === 0) return null
+ const frames: GeneratedAnimationFrame[] = []
+ for (const frame of value.frames) {
+ if (
+ !isRecord(frame) ||
+ typeof frame.url !== 'string' ||
+ frame.url.length === 0 ||
+ (frame.durationMs !== null && typeof frame.durationMs !== 'number')
+ ) {
+ return null
+ }
+ frames.push({ url: frame.url, durationMs: frame.durationMs as number | null })
+ }
+ return { type: 'complete_animation', actionType: value.actionType as ActionType, frames }
}
export type GenerationResult =
@@ -98,50 +162,49 @@ export type GenerationResultFor =
: CompleteAnimationGenerationResult
/**
- * 一次生成任务的完整快照,创建、查询和断线恢复都用它。
+ * 一次生成任务的完整快照。
* 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。
- *
- * TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。
- * 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。
*/
export interface Generation {
+ /** 创建接口返回的后端任务 ID。 */
id: string
projectId: string
/** 与创建时的输入判别字段保持同一字面量类型。 */
type: TType
- status: TaskStatus
+ status: GenerationTaskStatus
/** 完成前为 null;完成后形状由 type 决定。 */
result: GenerationResult | null
/** status 为 failed 时有值。 */
error: string | null
}
-/**
- * 一条状态变更事件。
- * 不含 projectId:后端事件 payload 只有 task_id、task_type、status,
- * 以及完成时的 result 和失败时的 error_message。
- */
+/** 后端任务状态变化映射成同一份 Generation 快照。 */
export interface GenerationEvent extends Omit<
Generation,
'id' | 'projectId'
> {
- /** 对应 Generation.id,字段名沿用后端事件里的 task_id。 */
+ /** 字段名对应后端事件中的 task_id,但语义上仍是 Generation.id。 */
taskId: Generation['id']
}
-/** Generation 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */
+/** Generation 对应的一组后端接口。 */
export interface GenerationApis {
/** 创建一次生成任务。 */
create(input: T): Promise>
+ /** 按所属项目和任务 ID 读取生成任务的最新快照。 */
+ get(projectId: Generation['projectId'], id: Generation['id']): Promise
/**
- * 按所属项目和任务 ID 读取最新快照。
- * projectId 不能从 id 推导,后端查询接口要求两者同时传入。
+ * 订阅任务状态。当前后端没有 SSE 时,实现可以封装轮询;调用方不感知传输方式。
+ * 返回取消订阅函数。
*/
- get(projectId: Generation['projectId'], id: Generation['id']): Promise
- /** 订阅状态变化,返回取消订阅函数。 */
subscribe(
projectId: Generation['projectId'],
id: Generation['id'],
onEvent: (event: GenerationEvent) => void,
+ onError?: (error: Error) => void,
): () => void
}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts
index 203359dd..635c71cf 100644
--- a/frontend/src/entities/index.ts
+++ b/frontend/src/entities/index.ts
@@ -1,17 +1,22 @@
/**
* entities 唯一公开入口。外部不得绕过本文件访问内部文件。
- * 本次只提交类型与接口,不提交实现。
+ * 外部只从这里使用实体契约与已经落地的实体能力。
*/
+/* 用户 —— 认证态与账户资料。 */
+export { createUserApis } from './user/api'
+export type { CreateUserApisOptions } from './user/api'
+export type { AuthTokens, User, UserApis } from './user'
+
/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */
export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project'
+export { createProjectApis } from './project/api'
export type {
CharacterPerspective,
CreateProjectInput,
DirectionalMovement,
Project,
ProjectApis,
- UpdateProjectInput,
} from './project'
/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */
@@ -29,45 +34,59 @@ export type {
FrameRootMotion,
Outfit,
} from './character'
+export { createCharacterApis } from './character/api'
-/* 动作模板 —— 能跨角色复用的配方 */
-export type { ActionTemplate, ActionTemplateApis } from './action-template'
-
-/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */
+/* 生成 —— 业务数据,不是「调用生成能力」 */
+export { CHARACTER_ACTION_FRAME_COUNT } from './generation'
+export { createGenerationApis } from './generation/api'
export type {
- CharacterTemplateGenerationInput,
- CharacterTemplateGenerationResult,
- CompleteAnimationGenerationInput,
- CompleteAnimationGenerationResult,
- FirstFrameGenerationInput,
- FirstFrameGenerationResult,
- GeneratedImage,
+ CharacterActionFrame,
+ CharacterActionGenerationInput,
+ CharacterActionOutput,
+ CharacterImageGenerationInput,
+ CharacterImageOutput,
Generation,
GenerationApis,
GenerationEvent,
GenerationInput,
GenerationResult,
GenerationResultFor,
+ GenerationTaskStatus,
GenerationType,
- TaskStatus,
} from './generation'
/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */
-export type { MediaReference } from './media'
+export { createMediaApis } from './media/api'
+export type { MediaApis, MediaCategory, MediaReference } from './media'
+
+/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */
+export { createPlaytestInspectionApis } from './playtest-inspection/api'
+export type {
+ PlaytestInspection,
+ PlaytestInspectionApis,
+ PlaytestInspectionStatus,
+ PlaytestInspectionTarget,
+ SavePlaytestInspectionInput,
+} from './playtest-inspection'
/* 工作流 —— 节点与运行状态都由前端管理 */
-export { WORKFLOW_STEP_ORDER } from './workflow-run'
+export { createWorkflowRunStore, WORKFLOW_NODE_ORDER } from './workflow-run'
export type {
+ CharacterSetupNodeInput,
+ CharacterSetupWorkflowNode,
+ CharacterTemplateWorkflowNode,
+ ActionFirstFrameWorkflowNode,
+ ActionFullFrameWorkflowNode,
CreateWorkflowRunInput,
ExportStatus,
GenerationStatus,
- WorkflowDriver,
- WorkflowStep,
- WorkflowStepStatus,
- WorkflowStepType,
- WorkflowRevision,
- WorkflowRevisionStatus,
+ WorkflowNode,
+ WorkflowNodeStatus,
+ WorkflowNodeType,
WorkflowRun,
+ WorkflowRunStore,
WorkflowRunPurpose,
WorkflowRunStatus,
+ WorkflowRevision,
+ CreateWorkflowRunStoreOptions,
} from './workflow-run'
diff --git a/frontend/src/entities/media/api.test.ts b/frontend/src/entities/media/api.test.ts
new file mode 100644
index 00000000..f879e9a4
--- /dev/null
+++ b/frontend/src/entities/media/api.test.ts
@@ -0,0 +1,190 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createMediaApis } from '@/entities'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+})
+
+describe('MediaApis.upload', () => {
+ it('把图片和默认查询分类交给后端,并返回经过校验的媒体引用', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ url: 'https://cdn.example.com/media/reference.png',
+ object_key: 'media/general/reference.png',
+ filename: 'reference.png',
+ content_type: 'image/png',
+ size: 4,
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const file = imageFile()
+
+ const result = await createMediaApis().upload(file)
+
+ expect(result).toBe('https://cdn.example.com/media/reference.png')
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('http://127.0.0.1:8000/media/upload?category=general')
+ expect(init.method).toBe('POST')
+ expect(new Headers(init.headers).has('Content-Type')).toBe(false)
+
+ const body = init.body as FormData
+ expect(body.get('file')).toBe(file)
+ expect(body.has('category')).toBe(false)
+ })
+
+ it('传递调用方选择的图片用途和取消信号', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ url: 'https://cdn.example.com/media/reference.png',
+ object_key: 'media/reference-image/reference.png',
+ filename: 'reference.png',
+ content_type: 'image/png',
+ size: 4,
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const controller = new AbortController()
+
+ await createMediaApis().upload(imageFile(), 'reference-image', controller.signal)
+
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ 'http://127.0.0.1:8000/media/upload?category=reference-image',
+ )
+ expect((init.body as FormData).has('category')).toBe(false)
+ expect(init.signal).toBe(controller.signal)
+ })
+
+ it('在请求发出前拒绝非图片文件', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const file = new File(['text'], 'notes.txt', { type: 'text/plain' })
+
+ await expect(createMediaApis().upload(file)).rejects.toThrow('仅支持图片文件')
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('后端地址未配置时明确失败,不把文件发送到访问者本机', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ vi.stubEnv('VITE_API_BASE_URL', '')
+
+ await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
+ name: 'UploadConfigurationError',
+ message: '媒体上传不可用:请配置 VITE_API_BASE_URL',
+ })
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('把 HTTP 200 中的后端业务失败作为真实错误抛出', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ code: 400, message: '仅支持图片文件', data: null }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ ),
+ )
+
+ await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
+ name: 'UploadRequestError',
+ status: 200,
+ code: 400,
+ message: '仅支持图片文件',
+ })
+ })
+
+ it('保留非成功 HTTP 响应的状态和后端错误信息', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ code: 503, message: '对象存储暂不可用', data: null }), {
+ status: 503,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ ),
+ )
+
+ await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
+ name: 'UploadRequestError',
+ status: 503,
+ code: 503,
+ message: '对象存储暂不可用',
+ })
+ })
+
+ it('拒绝无法解析的 JSON 响应', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response('not json', {
+ status: 200,
+ headers: { 'Content-Type': 'text/plain' },
+ }),
+ ),
+ )
+
+ await expect(createMediaApis().upload(imageFile())).rejects.toThrow(
+ '上传响应格式错误,无法解析 JSON',
+ )
+ })
+
+ it.each([
+ ['url 为空', { url: '' }],
+ ['object_key 缺失', { object_key: undefined }],
+ ['content_type 不是图片', { content_type: 'text/plain' }],
+ ['size 不是非负整数', { size: -1 }],
+ ])('拒绝不符合后端契约的成功数据:%s', async (_caseName, override) => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ jsonResponse({
+ url: 'https://cdn.example.com/media/reference.png',
+ object_key: 'media/general/reference.png',
+ filename: 'reference.png',
+ content_type: 'image/png',
+ size: 4,
+ ...override,
+ }),
+ ),
+ )
+
+ await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
+ name: 'MediaContractError',
+ })
+ })
+
+ it('不包装浏览器抛出的取消错误', async () => {
+ vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
+ const abortError = new DOMException('This operation was aborted', 'AbortError')
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError))
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(
+ createMediaApis().upload(imageFile(), 'reference-image', controller.signal),
+ ).rejects.toBe(abortError)
+ })
+})
+
+function imageFile(): File {
+ return new File(['wind'], 'reference.png', { type: 'image/png' })
+}
+
+function jsonResponse(data: unknown): Response {
+ return new Response(JSON.stringify({ code: 200, message: 'success', data }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
diff --git a/frontend/src/entities/media/api.ts b/frontend/src/entities/media/api.ts
new file mode 100644
index 00000000..14cf3d6d
--- /dev/null
+++ b/frontend/src/entities/media/api.ts
@@ -0,0 +1,81 @@
+import { upload as uploadRequest } from '@/shared/api/upload'
+
+import type { MediaApis, MediaCategory, MediaReference } from '.'
+
+/** 后端声称上传成功、但返回数据不符合 /media/upload 契约。 */
+export class MediaContractError extends Error {
+ constructor(message: string) {
+ super(`媒体上传响应格式错误:${message}`)
+ this.name = 'MediaContractError'
+ }
+}
+
+/** /media/upload 成功时 data 字段的后端原始形状。 */
+interface BackendMediaUpload {
+ url: string
+ object_key: string
+ filename: string
+ content_type: string
+ size: number
+}
+
+/**
+ * 创建真实媒体上传适配器。这里不缓存文件、不生成本地假 URL,也不吞掉错误;
+ * 只有服务端确认成功且完整响应通过运行时校验后,才交付 MediaReference。
+ */
+export function createMediaApis(): MediaApis {
+ return {
+ async upload(
+ file: File,
+ category: MediaCategory = 'general',
+ signal?: AbortSignal,
+ ): Promise {
+ // 与后端的 image/* 规则一致,尽早反馈可避免上传无效文件;后端仍是最终校验者。
+ if (!file.type.startsWith('image/')) {
+ throw new TypeError('仅支持图片文件')
+ }
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ // main 的 FastAPI 路由只把 file 声明为 File;category 未声明 Form,因此属于查询参数。
+ const query = new URLSearchParams({ category })
+ const result = await uploadRequest(`/media/upload?${query}`, formData, signal)
+ return parseMediaReference(result)
+ },
+ }
+}
+
+function parseMediaReference(value: unknown): MediaReference {
+ assertBackendMediaUpload(value)
+
+ // MediaReference 是不透明引用;当前后端明确约定用已校验的 url 回填业务数据。
+ return value.url as MediaReference
+}
+
+function assertBackendMediaUpload(value: unknown): asserts value is BackendMediaUpload {
+ if (!isRecord(value)) {
+ throw new MediaContractError('data 必须是对象')
+ }
+
+ assertNonEmptyString(value.url, 'url')
+ assertNonEmptyString(value.object_key, 'object_key')
+ assertNonEmptyString(value.filename, 'filename')
+
+ if (typeof value.content_type !== 'string' || !value.content_type.startsWith('image/')) {
+ throw new MediaContractError('content_type 必须是 image/*')
+ }
+ if (typeof value.size !== 'number' || !Number.isInteger(value.size) || value.size < 0) {
+ throw new MediaContractError('size 必须是非负整数')
+ }
+}
+
+function assertNonEmptyString(value: unknown, field: string): asserts value is string {
+ if (typeof value !== 'string' || value.trim() === '') {
+ throw new MediaContractError(`${field} 必须是非空字符串`)
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts
index 347e626b..ce7747f0 100644
--- a/frontend/src/entities/media/index.ts
+++ b/frontend/src/entities/media/index.ts
@@ -7,3 +7,17 @@ declare const mediaReferenceBrand: unique symbol
export type MediaReference = string & {
readonly [mediaReferenceBrand]: 'MediaReference'
}
+
+/** 上传媒体时的业务用途;值与后端 MediaCategory 枚举逐项对应。 */
+export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general'
+
+/**
+ * 媒体实体对页面和生成流程暴露的最小能力。
+ * signal 用于页面离开、用户取消或新上传替换旧上传时终止仍在途的请求。
+ */
+export interface MediaApis {
+ upload(file: File, category?: MediaCategory, signal?: AbortSignal): Promise
+}
+
+// 上层只能通过 @/entities 公共入口取得真实适配器,避免页面深度导入内部文件。
+export { createMediaApis, MediaContractError } from './api'
diff --git a/frontend/src/entities/playtest-inspection/api.test.ts b/frontend/src/entities/playtest-inspection/api.test.ts
new file mode 100644
index 00000000..adb19d7f
--- /dev/null
+++ b/frontend/src/entities/playtest-inspection/api.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createPlaytestInspectionApis } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('Playtest inspection API adapter', () => {
+ it('treats the backend business 404 as an empty current inspection', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ code: 404, message: '尚未核验', data: null }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ ),
+ )
+
+ await expect(
+ createPlaytestInspectionApis().get({
+ characterId: '25',
+ outfitId: 'default',
+ actionId: 'idle',
+ }),
+ ).resolves.toBeNull()
+ })
+
+ it('sends stable target IDs and maps the saved inspection', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 200,
+ message: '核验已保存',
+ data: {
+ id: 9,
+ character_id: 25,
+ outfit_id: 'default',
+ action_id: 'idle',
+ status: 'issues_found',
+ create_at: '2026-08-04T00:00:00Z',
+ update_at: '2026-08-04T00:01:00Z',
+ },
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ const saved = await createPlaytestInspectionApis().save({
+ characterId: '25',
+ outfitId: 'default',
+ actionId: 'idle',
+ status: 'issues_found',
+ })
+
+ expect(saved).toMatchObject({ id: '9', characterId: '25', status: 'issues_found' })
+ const request = fetchMock.mock.calls[0]?.[1] as RequestInit
+ expect(JSON.parse(String(request.body))).toEqual({
+ character_id: 25,
+ outfit_id: 'default',
+ action_id: 'idle',
+ status: 'issues_found',
+ })
+ })
+})
diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts
new file mode 100644
index 00000000..6c62412e
--- /dev/null
+++ b/frontend/src/entities/playtest-inspection/api.ts
@@ -0,0 +1,65 @@
+import { ApiError, get, post } from '@/shared/api'
+
+import type {
+ PlaytestInspection,
+ PlaytestInspectionApis,
+ PlaytestInspectionTarget,
+ SavePlaytestInspectionInput,
+} from '.'
+
+interface BackendPlaytestInspection {
+ id: number
+ character_id: number
+ outfit_id: string
+ action_id: string
+ status: PlaytestInspection['status']
+ create_at: string
+ update_at: string
+}
+
+function toInspection(raw: BackendPlaytestInspection): PlaytestInspection {
+ return {
+ id: String(raw.id),
+ characterId: String(raw.character_id),
+ outfitId: raw.outfit_id,
+ actionId: raw.action_id,
+ status: raw.status,
+ createdAt: raw.create_at,
+ updatedAt: raw.update_at,
+ }
+}
+
+function queryFor(target: PlaytestInspectionTarget): string {
+ const query = new URLSearchParams({
+ character_id: target.characterId,
+ outfit_id: target.outfitId,
+ action_id: target.actionId,
+ })
+ return query.toString()
+}
+
+export function createPlaytestInspectionApis(): PlaytestInspectionApis {
+ return {
+ async get(target) {
+ try {
+ const raw = await get(
+ `/playtest-inspections?${queryFor(target)}`,
+ )
+ return toInspection(raw)
+ } catch (cause) {
+ if (cause instanceof ApiError && cause.code === 404) return null
+ throw cause
+ }
+ },
+
+ async save(input: SavePlaytestInspectionInput) {
+ const raw = await post('/playtest-inspections', {
+ character_id: Number(input.characterId),
+ outfit_id: input.outfitId,
+ action_id: input.actionId,
+ status: input.status,
+ })
+ return toInspection(raw)
+ },
+ }
+}
diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts
new file mode 100644
index 00000000..2a160165
--- /dev/null
+++ b/frontend/src/entities/playtest-inspection/index.ts
@@ -0,0 +1,24 @@
+/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */
+export type PlaytestInspectionStatus = 'passed' | 'issues_found'
+
+export interface PlaytestInspectionTarget {
+ characterId: string
+ outfitId: string
+ actionId: string
+}
+
+export interface PlaytestInspection extends PlaytestInspectionTarget {
+ id: string
+ status: PlaytestInspectionStatus
+ createdAt: string
+ updatedAt: string
+}
+
+export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget {
+ status: PlaytestInspectionStatus
+}
+
+export interface PlaytestInspectionApis {
+ get(target: PlaytestInspectionTarget): Promise
+ save(input: SavePlaytestInspectionInput): Promise
+}
diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts
new file mode 100644
index 00000000..c83e4977
--- /dev/null
+++ b/frontend/src/entities/project/api.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createProjectApis } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('project API adapter', () => {
+ it('maps backend projects without losing server pagination metadata', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 200,
+ message: 'success',
+ data: [
+ {
+ id: 3,
+ user_id: 7,
+ project_name: '像素冒险',
+ character_perspective: 1,
+ directional_movement: 2,
+ sprite_width: 64,
+ sprite_height: 96,
+ workflow_id: null,
+ game_style: '明亮像素风',
+ sprite_sample_url: null,
+ create_at: '2026-08-01T00:00:00Z',
+ update_at: '2026-08-02T00:00:00Z',
+ },
+ ],
+ total: 21,
+ page: 2,
+ page_size: 10,
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ const result = await createProjectApis().list({ page: 2, pageSize: 10 })
+
+ expect(result).toMatchObject({ total: 21, page: 2, pageSize: 10 })
+ expect(result.items[0]).toMatchObject({
+ id: '3',
+ ownerId: '7',
+ name: '像素冒险',
+ perspective: 'side',
+ directionalMovement: 'four-way',
+ spriteSize: { width: 64, height: 96 },
+ })
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'http://127.0.0.1:8000/projects?page=2&page_size=10',
+ expect.any(Object),
+ )
+ })
+})
diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts
new file mode 100644
index 00000000..25eaba52
--- /dev/null
+++ b/frontend/src/entities/project/api.ts
@@ -0,0 +1,105 @@
+import type { CreateProjectInput, Project, ProjectApis, ProjectPageQuery } from '.'
+import type { Paged } from '@/shared/pagination'
+
+import { del, get, getPage, post } from '@/shared/api'
+
+/* ─── 后端 DTO ─── */
+
+interface BackendProject {
+ id: number
+ user_id: number
+ project_name: string
+ character_perspective: number
+ directional_movement: number
+ sprite_width: number
+ sprite_height: number
+ workflow_id: number | null
+ game_style: string | null
+ sprite_sample_url: string | null
+ create_at: string
+ update_at: string
+}
+
+/* ─── 映射 ─── */
+
+const PERSPECTIVE_MAP: Record = {
+ 1: 'side',
+ 2: 'top-down',
+ 3: 'isometric',
+}
+
+const MOVEMENT_MAP: Record = {
+ 1: 'single',
+ 2: 'four-way',
+ 3: 'eight-way',
+}
+
+function toProject(raw: BackendProject): Project {
+ return {
+ id: String(raw.id),
+ ownerId: String(raw.user_id),
+ workflowId: raw.workflow_id === null ? null : String(raw.workflow_id),
+ name: raw.project_name,
+ perspective: PERSPECTIVE_MAP[raw.character_perspective] ?? 'side',
+ directionalMovement: MOVEMENT_MAP[raw.directional_movement] ?? 'single',
+ spriteSize: { width: raw.sprite_width, height: raw.sprite_height },
+ gameStyle: raw.game_style,
+ sampleImageUrl: raw.sprite_sample_url,
+ createdAt: raw.create_at,
+ updatedAt: raw.update_at,
+ }
+}
+
+function toPositiveId(value: string | undefined, fallback: number, field: string): number {
+ const parsed = value === undefined ? fallback : Number(value)
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
+ throw new TypeError(`${field} 必须是正整数 ID`)
+ }
+ return parsed
+}
+
+function toCreatePayload(input: CreateProjectInput) {
+ return {
+ user_id: toPositiveId(input.ownerId, 1, 'ownerId'),
+ workflow_id:
+ input.workflowId === undefined || input.workflowId === null
+ ? (input.workflowId ?? null)
+ : toPositiveId(input.workflowId, 1, 'workflowId'),
+ project_name: input.name,
+ character_perspective: { side: 1, 'top-down': 2, isometric: 3 }[input.perspective],
+ directional_movement: { single: 1, 'four-way': 2, 'eight-way': 3 }[input.directionalMovement],
+ sprite_width: input.spriteSize.width,
+ sprite_height: input.spriteSize.height,
+ game_style: input.gameStyle ?? null,
+ sprite_sample_url: input.sampleImageUrl ?? null,
+ }
+}
+
+/* ─── 适配器 ─── */
+
+export function createProjectApis(): ProjectApis {
+ return {
+ async list(query?: ProjectPageQuery): Promise> {
+ const params = new URLSearchParams()
+ if (query?.page) params.set('page', String(query.page))
+ if (query?.pageSize) params.set('page_size', String(query.pageSize))
+ if (query?.ownerId) params.set('user_id', String(toPositiveId(query.ownerId, 1, 'ownerId')))
+ const qs = params.toString()
+ const result = await getPage(`/projects${qs ? `?${qs}` : ''}`)
+ return { ...result, items: result.items.map(toProject) }
+ },
+
+ async get(id: string): Promise {
+ const raw = await get(`/projects/${encodeURIComponent(id)}`)
+ return toProject(raw)
+ },
+
+ async create(input: CreateProjectInput): Promise {
+ return toProject(await post('/projects', toCreatePayload(input)))
+ },
+
+ async remove(id: string): Promise {
+ await del(`/projects/${encodeURIComponent(id)}`)
+ },
+ }
+}
diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts
index 4c711640..c4e5f797 100644
--- a/frontend/src/entities/project/index.ts
+++ b/frontend/src/entities/project/index.ts
@@ -5,6 +5,8 @@ export interface Project {
id: string
/** Project 所属用户 ID;认证来源尚未冻结。 */
ownerId: string
+ /** 后端关联的工作流 ID;旧数据或尚未关联时为 null。 */
+ workflowId?: string | null
name: string
/** 游戏视角,见 CHARACTER_PERSPECTIVE。 */
perspective: CharacterPerspective
@@ -27,6 +29,9 @@ export interface Project {
/** 新建项目的入参。 */
export interface CreateProjectInput {
+ /** 认证模块接入前可省略,由组合层使用当前开发用户。 */
+ ownerId?: string
+ workflowId?: string | null
name: string
perspective: CharacterPerspective
directionalMovement: DirectionalMovement
@@ -35,14 +40,8 @@ export interface CreateProjectInput {
sampleImageUrl?: string | null
}
-/** 更新项目设置的入参;未提供的字段保持不变。 */
-export interface UpdateProjectInput {
- name?: string
- perspective?: CharacterPerspective
- directionalMovement?: DirectionalMovement
- spriteSize?: { width: number; height: number }
- gameStyle?: string | null
- sampleImageUrl?: string | null
+export interface ProjectPageQuery extends PageQuery {
+ ownerId?: string
}
/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */
@@ -74,9 +73,8 @@ export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const
/** Project 对应的一组后端接口。 */
export interface ProjectApis {
- list(query?: PageQuery): Promise>
+ list(query?: ProjectPageQuery): Promise>
get(id: Project['id']): Promise
create(input: CreateProjectInput): Promise
- update(id: Project['id'], input: UpdateProjectInput): Promise
remove(id: Project['id']): Promise
}
diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts
new file mode 100644
index 00000000..d163c260
--- /dev/null
+++ b/frontend/src/entities/user/api.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { registerApiAccessTokenProvider } from '@/shared/api'
+import { createUserApis } from './api'
+
+describe('user API adapter', () => {
+ it('uses the authenticated backend contract and maps user responses to the entity shape', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => {
+ const path = new URL(String(input)).pathname
+ if (path === '/auth/me') return jsonResponse(backendUser)
+ if (
+ ['/auth/register', '/auth/login', '/auth/login-by-code', '/auth/refresh'].includes(path)
+ ) {
+ return jsonResponse({ access_token: 'access', refresh_token: 'refresh', user: backendUser })
+ }
+ return jsonResponse(null)
+ })
+ const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock })
+
+ await apis.sendCode({ email: 'a@b.com', purpose: 'login' })
+ await apis.register({
+ email: 'a@b.com',
+ password: 'password1',
+ code: '123456',
+ nickname: 'Ada',
+ })
+ await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })
+ await apis.loginByCode({ email: 'a@b.com', code: '123456' })
+ await apis.refresh('refresh')
+ await apis.logout('refresh')
+ expect(await apis.me()).toEqual({
+ id: 7,
+ email: 'a@b.com',
+ nickname: null,
+ emailVerifiedAt: null,
+ status: 'normal',
+ })
+ await apis.changePassword({ oldPassword: 'password1', newPassword: 'password2' })
+
+ expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({
+ accessToken: 'access',
+ refreshToken: 'refresh',
+ user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' },
+ })
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ 'https://api.example.test/auth/send-code',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'a@b.com', purpose: 'login' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ 'https://api.example.test/auth/register',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({
+ email: 'a@b.com',
+ password: 'password1',
+ code: '123456',
+ nickname: 'Ada',
+ }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 3,
+ 'https://api.example.test/auth/login',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 4,
+ 'https://api.example.test/auth/login-by-code',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'a@b.com', code: '123456' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 5,
+ 'https://api.example.test/auth/refresh',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ refresh_token: 'refresh' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 6,
+ 'https://api.example.test/auth/logout',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ refresh_token: 'refresh' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 7,
+ 'https://api.example.test/auth/me',
+ expect.any(Object),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 8,
+ 'https://api.example.test/auth/change-password',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ old_password: 'password1', new_password: 'password2' }),
+ }),
+ )
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 9,
+ 'https://api.example.test/auth/login',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }),
+ }),
+ )
+ })
+
+ it('maps a banned backend user status to the entity status', async () => {
+ const fetchMock = vi.fn(
+ async (): Promise => jsonResponse({ ...backendUser, status: 1 }),
+ )
+ const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock })
+
+ await expect(apis.me()).resolves.toMatchObject({ id: 7, status: 'banned' })
+ })
+
+ it('uses the registered session access token for authenticated requests by default', async () => {
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer session-access')
+ return jsonResponse(backendUser)
+ })
+ const unregister = registerApiAccessTokenProvider(() => 'session-access')
+
+ try {
+ const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock })
+
+ await expect(apis.me()).resolves.toMatchObject({ id: 7 })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ } finally {
+ unregister()
+ }
+ })
+
+ it('preserves an explicit access-token provider override', async () => {
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer explicit-access')
+ return jsonResponse(backendUser)
+ })
+ const unregister = registerApiAccessTokenProvider(() => 'session-access')
+
+ try {
+ const apis = createUserApis({
+ baseUrl: 'https://api.example.test',
+ fetchFn: fetchMock,
+ getAccessToken: () => 'explicit-access',
+ })
+
+ await expect(apis.me()).resolves.toMatchObject({ id: 7 })
+ } finally {
+ unregister()
+ }
+ })
+
+ it('rejects malformed user and token DTOs instead of treating them as valid authentication data', async () => {
+ const invalidUser = { ...backendUser, status: 2 }
+ const invalidTokens = { refresh_token: 'refresh', user: backendUser }
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(invalidUser))
+ .mockResolvedValueOnce(jsonResponse(invalidTokens))
+ const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock })
+
+ await expect(apis.me()).rejects.toMatchObject({ kind: 'invalid-response', data: invalidUser })
+ await expect(
+ apis.login({ email: 'a@b.com', password: 'password1', code: '123456' }),
+ ).rejects.toMatchObject({
+ kind: 'invalid-response',
+ data: invalidTokens,
+ })
+ })
+})
+
+const backendUser = {
+ id: 7,
+ email: 'a@b.com',
+ nickname: null,
+ email_verified_at: null,
+ status: 0,
+}
+
+function jsonResponse(data: unknown): Response {
+ return new Response(JSON.stringify({ code: 200, message: 'success', data }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts
new file mode 100644
index 00000000..53b86038
--- /dev/null
+++ b/frontend/src/entities/user/api.ts
@@ -0,0 +1,138 @@
+import type { AuthTokens, User, UserApis } from '.'
+
+import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api'
+import type { ApiClient, ApiClientOptions } from '@/shared/api'
+
+interface BackendUser {
+ id: number
+ email: string
+ nickname: string | null
+ email_verified_at: string | null
+ status: number
+}
+
+interface BackendAuthTokens {
+ access_token: string
+ refresh_token: string
+ user: BackendUser
+}
+
+export interface CreateUserApisOptions extends ApiClientOptions {
+ client?: ApiClient
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function invalidResponse(data: unknown): never {
+ throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data })
+}
+
+function toUser(raw: unknown): User {
+ if (
+ !isRecord(raw) ||
+ typeof raw.id !== 'number' ||
+ typeof raw.email !== 'string' ||
+ (typeof raw.nickname !== 'string' && raw.nickname !== null) ||
+ (typeof raw.email_verified_at !== 'string' && raw.email_verified_at !== null) ||
+ (raw.status !== 0 && raw.status !== 1)
+ ) {
+ invalidResponse(raw)
+ }
+
+ return {
+ id: raw.id,
+ email: raw.email,
+ nickname: raw.nickname,
+ emailVerifiedAt: raw.email_verified_at,
+ status: raw.status === 1 ? 'banned' : 'normal',
+ }
+}
+
+function toAuthTokens(raw: unknown): AuthTokens {
+ if (
+ !isRecord(raw) ||
+ typeof raw.access_token !== 'string' ||
+ typeof raw.refresh_token !== 'string' ||
+ !isRecord(raw.user)
+ ) {
+ invalidResponse(raw)
+ }
+
+ return {
+ accessToken: raw.access_token,
+ refreshToken: raw.refresh_token,
+ user: toUser(raw.user),
+ }
+}
+
+export function createUserApis(options: CreateUserApisOptions = {}): UserApis {
+ const { client, ...clientOptions } = options
+ const apiClient =
+ client ??
+ createApiClient({
+ ...clientOptions,
+ getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken,
+ })
+
+ return {
+ async sendCode(input): Promise {
+ await apiClient.request('/auth/send-code', { method: 'POST', json: input })
+ },
+
+ async register(input): Promise {
+ return toAuthTokens(
+ await apiClient.request('/auth/register', {
+ method: 'POST',
+ json: input,
+ }),
+ )
+ },
+
+ async login(input): Promise {
+ return toAuthTokens(
+ await apiClient.request('/auth/login', {
+ method: 'POST',
+ json: input,
+ }),
+ )
+ },
+
+ async loginByCode(input): Promise {
+ return toAuthTokens(
+ await apiClient.request('/auth/login-by-code', {
+ method: 'POST',
+ json: input,
+ }),
+ )
+ },
+
+ async refresh(refreshToken): Promise {
+ return toAuthTokens(
+ await apiClient.request('/auth/refresh', {
+ method: 'POST',
+ json: { refresh_token: refreshToken },
+ }),
+ )
+ },
+
+ async logout(refreshToken): Promise {
+ await apiClient.request('/auth/logout', {
+ method: 'POST',
+ json: { refresh_token: refreshToken },
+ })
+ },
+
+ async me(): Promise {
+ return toUser(await apiClient.request('/auth/me'))
+ },
+
+ async changePassword(input): Promise {
+ await apiClient.request('/auth/change-password', {
+ method: 'POST',
+ json: { old_password: input.oldPassword, new_password: input.newPassword },
+ })
+ },
+ }
+}
diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts
new file mode 100644
index 00000000..4ff9e737
--- /dev/null
+++ b/frontend/src/entities/user/index.ts
@@ -0,0 +1,35 @@
+/** 已认证用户的前端领域表示。 */
+export interface User {
+ id: number
+ email: string
+ nickname: string | null
+ emailVerifiedAt: string | null
+ status: 'normal' | 'banned'
+}
+
+/** 一次认证成功后由后端签发的访问与刷新令牌。 */
+export interface AuthTokens {
+ accessToken: string
+ refreshToken: string
+ user: User
+}
+
+/** 用户认证与账户设置的后端接口。 */
+export interface UserApis {
+ sendCode(input: {
+ email: string
+ purpose: 'login' | 'register' | 'reset_password'
+ }): Promise
+ register(input: {
+ email: string
+ password: string
+ code: string
+ nickname?: string
+ }): Promise
+ login(input: { email: string; password: string; code: string }): Promise
+ loginByCode(input: { email: string; code: string }): Promise
+ refresh(refreshToken: string): Promise
+ logout(refreshToken: string): Promise
+ me(): Promise
+ changePassword(input: { oldPassword: string; newPassword: string }): Promise
+}
diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts
new file mode 100644
index 00000000..62eafbdb
--- /dev/null
+++ b/frontend/src/entities/workflow-run/constants.ts
@@ -0,0 +1,14 @@
+export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const
+export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const
+export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const
+export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const
+export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const
+
+/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */
+export const WORKFLOW_NODE_ORDER = [
+ 'character-setup',
+ 'character-template',
+ 'action-first-frame',
+ 'action-full-frame',
+ 'review',
+] as const
diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts
index 2ad8c0b5..4fcdd480 100644
--- a/frontend/src/entities/workflow-run/index.ts
+++ b/frontend/src/entities/workflow-run/index.ts
@@ -1,112 +1,126 @@
-import type { Generation } from '../generation'
+import type {
+ Generation,
+ CharacterImageGenerationInput,
+ CharacterImageOutput,
+ CharacterActionGenerationInput,
+ CharacterActionOutput,
+} from '../generation'
+import type { MediaReference } from '../media'
+import {
+ EXPORT_STATUSES,
+ GENERATION_STATUSES,
+ WORKFLOW_PURPOSES,
+ WORKFLOW_RUN_STATUSES,
+ WORKFLOW_NODE_ORDER,
+ WORKFLOW_NODE_STATUSES,
+} from './constants'
-/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */
-export type WorkflowDriver = 'ai' | 'manual'
+export { WORKFLOW_NODE_ORDER } from './constants'
/** 创建 WorkflowRun 时要完成的用户意图。 */
-export type WorkflowRunPurpose = 'create_character' | 'add_action'
+export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number]
-/**
- * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。
- * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。
- */
-export const WORKFLOW_STEP_ORDER = [
- 'character-setup',
- 'character-template',
- 'template-candidate',
- 'action-setup',
- 'first-frame',
- 'complete-animation',
- 'review',
- 'export',
-] as const
-
-/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */
-export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number]
+/** 前端流程节点类型,与 WORKFLOW_NODE_ORDER 的成员保持一致。 */
+export type WorkflowNodeType = (typeof WORKFLOW_NODE_ORDER)[number]
/**
- * 步骤的可用性和执行结果;不直接复用后端任务状态。
+ * 节点的可用性和执行结果;不直接复用后端任务状态。
* locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。
*/
-export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed'
-
-/**
- * 单个版本的生命周期。
- * abandoned 表示停止沿用但仍保留为历史。
- */
-export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned'
+export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number]
/**
* 整次流程的汇总状态。
- * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。
- * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。
+ * interrupted 只表示用户主动停止自动推进,不等于 failed 或 completed。
*/
-export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed'
+export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number]
-/**
- * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。
- * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。
- */
-export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed'
+/** 生成阶段的汇总状态;素材准备期间为 not_started。 */
+export type GenerationStatus = (typeof GENERATION_STATUSES)[number]
-/** 当前版本在导出阶段的汇总状态。 */
-export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed'
+/** 导出阶段的汇总状态。 */
+export type ExportStatus = (typeof EXPORT_STATUSES)[number]
-/**
- * 一个 Revision 中已经进入执行线的流程步骤。
- * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。
- */
-export interface WorkflowStep {
+interface WorkflowNodeBase {
/** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */
id: string
- type: WorkflowStepType
- status: WorkflowStepStatus
- /** 进入步骤时保存的输入快照。 */
- input: unknown
- /** 步骤完成后的结果或引用;尚无结果时为 null。 */
- output: unknown
+ status: WorkflowNodeStatus
/**
- * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。
- * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次
- * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。
- * Generation 本身不认识步骤,反向关联不存在。
- *
- * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有
- * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。
+ * 本节点已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。
+ * 任务本身不认识节点,反向关联不存在。
*/
taskId: Generation['id'] | null
- /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */
- referenceStepIds: string[]
+ /**
+ * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。
+ * 它非 null 而 taskId 为 null 时不能重复提交。
+ */
+ submissionId: string | null
+ /** 节点失败后供页面解释原因;未失败时必须为 null。 */
+ error: string | null
+}
+
+/** 角色资料节点保存的输入;参考媒体为空表示仅使用文字描述。 */
+export interface CharacterSetupNodeInput {
+ description: string
+ referenceMedia: readonly MediaReference[]
+}
+
+export interface CharacterSetupWorkflowNode extends WorkflowNodeBase {
+ type: 'character-setup'
+ input: CharacterSetupNodeInput | null
+ output: null
+}
+
+export interface CharacterTemplateWorkflowNode extends WorkflowNodeBase {
+ type: 'character-template'
+ /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */
+ input: CharacterImageGenerationInput | null
+ output: CharacterImageOutput | null
+}
+
+/** 首帧生成节点:生成单帧角色动作候选。 */
+export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase {
+ type: 'action-first-frame'
+ input: CharacterActionGenerationInput | null
+ output: CharacterActionOutput | null
+}
+
+/** 完整帧率生成节点:基于首帧生成完整动画。 */
+export interface ActionFullFrameWorkflowNode extends WorkflowNodeBase {
+ type: 'action-full-frame'
+ input: CharacterActionGenerationInput | null
+ output: CharacterActionOutput | null
+}
+
+type RemainingWorkflowNodeType = Exclude<
+ WorkflowNodeType,
+ 'character-setup' | 'character-template' | 'action-first-frame' | 'action-full-frame'
+>
+
+interface RemainingWorkflowNode extends WorkflowNodeBase {
+ type: RemainingWorkflowNodeType
+ /** 审核的具体输入输出在对应纵切中继续收窄。 */
+ input: unknown
+ output: unknown
}
/**
- * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。
- *
- * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。
- * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现,
- * 避免真要做时改动波及 WorkflowRun 的持久化形状。
+ * 执行线中的流程节点。
+ * 前四个执行节点已冻结输入输出;后续进入对应纵切时再收窄。
*/
-export interface WorkflowRevision {
- id: string
- /** 首次创建的版本没有来源,因此为 null。 */
- basedOnRevisionId: string | null
- /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */
- restartStepId: string | null
- status: WorkflowRevisionStatus
- /**
- * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。
- * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。
- */
- steps: WorkflowStep[]
- generationStatus: GenerationStatus
- exportStatus: ExportStatus
- createdAt: string
-}
+export type WorkflowNode =
+ | CharacterSetupWorkflowNode
+ | CharacterTemplateWorkflowNode
+ | ActionFirstFrameWorkflowNode
+ | ActionFullFrameWorkflowNode
+ | RemainingWorkflowNode
/**
* 一次由前端推进的页面流程。
- * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。
- * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。
+ *
+ * 后端采用树状纯存储模型,不提供回退或版本历史能力。用户从旧节点重做时,
+ * 前端直接覆盖当前节点结果,不保留被废弃结果的历史链路。
+ * 一个 Character 复用同一条 Run;新增动作不会创建第二条 Run。
*/
export interface WorkflowRun {
id: string
@@ -115,28 +129,36 @@ export interface WorkflowRun {
characterId: string | null
/** 已有角色加动作时的目标造型;新建角色时为 null。 */
outfitId: string | null
+ /** 建立这条 Run 时的根意图;后续追加动作不会把 create_character 改写为 add_action。 */
purpose: WorkflowRunPurpose
- driver: WorkflowDriver
status: WorkflowRunStatus
- /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */
- currentRevisionId: string
- /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */
- revisions: WorkflowRevision[]
+ /**
+ * 当前执行线中的节点。前三个节点串行推进,之后可随时追加 action-generation / review
+ * 成对节点。多个 action-generation 可并发——互不阻塞。数组位置是节点顺序的唯一来源。
+ */
+ nodes: WorkflowNode[]
+ generationStatus: GenerationStatus
+ exportStatus: ExportStatus
/** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */
prompt: string | null
+ createdAt: string
}
-/** 两种入口共享的创建字段。 */
+/**
+ * @deprecated WorkflowRevision 已合并到 WorkflowRun,直接用 WorkflowRun。
+ */
+export type WorkflowRevision = WorkflowRun
+
+/** 创建 WorkflowRun 的共享字段。 */
interface CreateWorkflowRunInputBase {
projectId: string
- driver: WorkflowDriver
/** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */
prompt?: string
}
/**
* 创建 WorkflowRun 的输入。
- * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。
+ * add_action 分支把已有角色、造型、母版和基准帧设为必填。
*/
export type CreateWorkflowRunInput = CreateWorkflowRunInputBase &
(
@@ -155,3 +177,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase &
baseFrameUrls: readonly string[]
}
)
+
+export { createWorkflowRunStore } from './store'
+export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store'
diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts
new file mode 100644
index 00000000..3bf0c1a8
--- /dev/null
+++ b/frontend/src/entities/workflow-run/store.test.ts
@@ -0,0 +1,330 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { WORKFLOW_NODE_ORDER } from './constants'
+import type { WorkflowRun, WorkflowNode } from './index'
+import { createWorkflowRunStore } from './store'
+
+function createNodes(): WorkflowNode[] {
+ return WORKFLOW_NODE_ORDER.map((type, index) => {
+ const common = {
+ id: `run-1:${type}`,
+ status: index === 0 ? ('active' as const) : ('locked' as const),
+ taskId: null,
+ submissionId: null,
+ error: null,
+ }
+ if (type === 'character-setup') {
+ return {
+ ...common,
+ type,
+ input: { description: 'slime', referenceMedia: [] },
+ output: null,
+ }
+ }
+ if (type === 'character-template') {
+ return { ...common, type, input: null, output: null }
+ }
+ return { ...common, type, input: null, output: null } as WorkflowNode
+ })
+}
+
+function createRun(id = 'run-1'): WorkflowRun {
+ return {
+ id,
+ projectId: 'project-1',
+ characterId: null,
+ outfitId: null,
+ purpose: 'create_character',
+ status: 'active',
+ nodes: createNodes(),
+ generationStatus: 'not_started',
+ exportStatus: 'not_exported',
+ prompt: 'Create a slime',
+ createdAt: '2026-07-30T12:00:00.000Z',
+ }
+}
+
+/** 后端响应包装:Response { code, message, data: T } */
+function wrapResponse(data: T) {
+ return { code: 0, message: 'ok', data }
+}
+
+/** 前端 WorkflowRun → 后端 nodes[0] 载荷。与 store._toNodePayload 保持一致。 */
+function packNodes(run: WorkflowRun): Record {
+ return {
+ projectId: run.projectId,
+ characterId: run.characterId,
+ outfitId: run.outfitId,
+ purpose: run.purpose,
+ status: run.status,
+ nodes: run.nodes,
+ generationStatus: run.generationStatus,
+ exportStatus: run.exportStatus,
+ prompt: run.prompt,
+ createdAt: run.createdAt,
+ }
+}
+
+const BASE = '/workflow-runs'
+
+function createMockApi() {
+ // 后端内部使用前端 string ID 索引(保持简单);
+ // 响应时仍返回整数 ID,由被测 _fromBackend 转换回 string。
+ const runs = new Map()
+ let nextNumericId = 1
+
+ const fetch = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.url
+ const method = init?.method ?? 'GET'
+
+ // POST /workflow-runs → create
+ if (method === 'POST' && url === BASE) {
+ const body = JSON.parse((init?.body as string) ?? '{}')
+ const node = body.nodes?.[0] ?? {}
+ const runId = `run-${nextNumericId}`
+ const run: WorkflowRun = {
+ id: runId,
+ // projectId 优先从 nodes 取(保留原始前端 string 值),回退到 project_id
+ projectId:
+ (node.projectId as string) ?? String(body.project_id ?? ''),
+ characterId: node.characterId ?? null,
+ outfitId: node.outfitId ?? null,
+ purpose: node.purpose ?? 'create_character',
+ status: node.status ?? 'active',
+ nodes: node.nodes ?? [],
+ generationStatus: node.generationStatus ?? 'not_started',
+ exportStatus: node.exportStatus ?? 'not_exported',
+ prompt: node.prompt ?? null,
+ createdAt: node.createdAt ?? new Date().toISOString(),
+ }
+ runs.set(runId, run)
+ return wrapResponse({
+ id: nextNumericId++,
+ project_id: body.project_id,
+ nodes: [packNodes(run)],
+ status: 'active',
+ version: 1,
+ })
+ }
+
+ // GET /workflow-runs → list all (getByCharacter 回退)
+ if (method === 'GET' && url === BASE) {
+ return wrapResponse(
+ [...runs.entries()].map(([rid, run]) => ({
+ id: Number(rid.split('-')[1] ?? rid),
+ project_id: Number(run.projectId.split('-')[1] ?? run.projectId),
+ nodes: [packNodes(run)],
+ status: 'active',
+ version: 1,
+ })),
+ )
+ }
+
+ // GET /workflow-runs?project_id=X → list by project
+ // GET /workflow-runs?characterId=X → list by character
+ if (method === 'GET' && url.startsWith(`${BASE}?`)) {
+ const params = new URLSearchParams(url.split('?')[1])
+ const characterId = params.get('characterId')
+ const projectId = params.get('project_id')
+ const all = [...runs.entries()]
+ .filter(([, r]) => {
+ if (characterId) return r.characterId === characterId
+ if (projectId) return r.projectId === projectId
+ return true
+ })
+ .map(([rid, run]) => ({
+ id: Number(rid.split('-')[1] ?? rid),
+ project_id: Number(run.projectId.split('-')[1] ?? run.projectId),
+ nodes: [packNodes(run)],
+ status: 'active',
+ version: 1,
+ }))
+ return wrapResponse(all)
+ }
+
+ // GET /workflow-runs/{id} → get by ID
+ if (method === 'GET' && url.startsWith(`${BASE}/`)) {
+ const numericId = url.split('/').pop()!
+ const runId = `run-${numericId}`
+ const run = runs.get(runId)
+ if (!run) throw Object.assign(new Error('Not Found'), { status: 404 })
+ return wrapResponse({
+ id: Number(numericId),
+ project_id: Number(run.projectId.split('-')[1] ?? run.projectId),
+ nodes: [packNodes(run)],
+ status: 'active',
+ version: 1,
+ })
+ }
+
+ // PATCH /workflow-runs/{id} → update
+ if (method === 'PATCH' && url.startsWith(`${BASE}/`)) {
+ const numericId = url.split('/').pop()!
+ const runId = `run-${numericId}`
+ if (!runs.has(runId))
+ throw Object.assign(new Error('Not Found'), { status: 404 })
+ const body = JSON.parse((init?.body as string) ?? '{}')
+ const node = body.nodes?.[0] ?? {}
+ const existing = runs.get(runId)!
+ const updated: WorkflowRun = {
+ id: runId,
+ projectId: String(body.project_id ?? existing.projectId),
+ characterId:
+ node.characterId !== undefined
+ ? node.characterId
+ : existing.characterId,
+ outfitId:
+ node.outfitId !== undefined ? node.outfitId : existing.outfitId,
+ purpose: node.purpose ?? existing.purpose,
+ status: node.status ?? existing.status,
+ nodes: node.nodes ?? existing.nodes,
+ generationStatus:
+ node.generationStatus ?? existing.generationStatus,
+ exportStatus: node.exportStatus ?? existing.exportStatus,
+ prompt: node.prompt !== undefined ? node.prompt : existing.prompt,
+ createdAt: node.createdAt ?? existing.createdAt,
+ }
+ runs.set(runId, updated)
+ return wrapResponse({
+ id: Number(numericId),
+ project_id: Number(updated.projectId.split('-')[1] ?? updated.projectId),
+ nodes: [packNodes(updated)],
+ status: 'active',
+ version: 1,
+ })
+ }
+
+ throw Object.assign(new Error('Not Found'), { status: 404 })
+ })
+
+ return { runs, fetch }
+}
+
+describe('createWorkflowRunStore', () => {
+ it('creates a run and returns the server-persisted snapshot', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+
+ const run = await store.create({
+ projectId: 'project-1',
+ purpose: 'create_character',
+ prompt: 'A fire dragon',
+ })
+
+ expect(run.id).toBeTruthy()
+ expect(run.prompt).toBe('A fire dragon')
+ expect(run.purpose).toBe('create_character')
+ expect(api.fetch).toHaveBeenCalledWith(
+ BASE,
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
+ it('gets a run by ID', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+ const created = await store.create({
+ projectId: 'project-1',
+ purpose: 'create_character',
+ })
+
+ const found = await store.get(created.id)
+
+ expect(found?.id).toBe(created.id)
+ })
+
+ it('returns null when getting a non-existent run', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+
+ const result = await store.get('999')
+
+ expect(result).toBeNull()
+ })
+
+ it('does not disguise a server failure as a missing run', async () => {
+ const failure = Object.assign(new Error('Service Unavailable'), {
+ status: 503,
+ })
+ const store = createWorkflowRunStore({
+ api: { fetch: vi.fn().mockRejectedValue(failure) },
+ })
+
+ await expect(store.get('run-1')).rejects.toBe(failure)
+ await expect(store.getByCharacter('character-1')).rejects.toBe(failure)
+ })
+
+ it('finds the run bound to a character', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+ const created = await store.create({
+ projectId: 'project-1',
+ purpose: 'create_character',
+ })
+ created.characterId = 'character-1'
+ await store.save(created)
+
+ const found = await store.getByCharacter('character-1')
+
+ expect(found?.id).toBe(created.id)
+ expect(found?.characterId).toBe('character-1')
+ })
+
+ it('returns null when no run is bound to a character', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+
+ const result = await store.getByCharacter('missing')
+
+ expect(result).toBeNull()
+ })
+
+ it('lists runs by project', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+ await store.create({ projectId: 'project-1', purpose: 'create_character' })
+ await store.create({
+ projectId: 'project-1',
+ purpose: 'create_character',
+ prompt: '',
+ })
+
+ const runs = await store.list('project-1')
+
+ expect(runs).toHaveLength(2)
+ expect(runs[0]?.projectId).toBe('project-1')
+ expect(runs[1]?.projectId).toBe('project-1')
+ })
+
+ it('saves a run and persists changes', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+ const created = await store.create({
+ projectId: 'project-1',
+ purpose: 'create_character',
+ })
+
+ created.status = 'completed'
+ await store.save(created)
+
+ const reloaded = await store.get(created.id)
+ expect(reloaded?.status).toBe('completed')
+ })
+
+ it('creates an add_action run with required character fields', async () => {
+ const api = createMockApi()
+ const store = createWorkflowRunStore({ api })
+
+ const run = await store.create({
+ projectId: 'project-1',
+ purpose: 'add_action',
+ characterId: 'char-1',
+ outfitId: 'outfit-1',
+ characterTemplateUrl: 'https://example.com/template.png',
+ baseFrameUrls: ['https://example.com/frame1.png'],
+ })
+
+ expect(run.purpose).toBe('add_action')
+ expect(run.characterId).toBe('char-1')
+ })
+})
diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts
new file mode 100644
index 00000000..2f22ddd8
--- /dev/null
+++ b/frontend/src/entities/workflow-run/store.ts
@@ -0,0 +1,240 @@
+import type { CreateWorkflowRunInput, WorkflowRun } from "./index";
+
+/**
+ * WorkflowRun 持久化契约。
+ *
+ * 持久化走服务端 API,所有方法均为异步。前端不保留 localStorage 副本,
+ * 也不提供 subscribe / subscribeAll——状态变更由前端逻辑自身驱动。
+ *
+ * 后端 API 契约(对齐 commit 4246389b)
+ * --------------------------------
+ * POST /workflow-runs 创建执行记录
+ * GET /workflow-runs/{id} 获取执行记录(含 nodes JSONB)
+ * PATCH /workflow-runs/{id} 全量更新(含 nodes)
+ * DELETE /workflow-runs/{id} 软删除
+ *
+ * 后端只做存储,不感知节点结构。前端 WorkflowRun 的完整状态(除 id / projectId 外)
+ * 序列化到后端 nodes 字段。id/projectId 映射为后端顶层 id/project_id。
+ */
+export interface WorkflowRunStore {
+ /** 创建一条新的 WorkflowRun,返回服务端持久化后的完整快照。 */
+ create(input: CreateWorkflowRunInput): Promise;
+ /** 按 ID 读取 WorkflowRun 最新快照;不存在时返回 null。 */
+ get(runId: WorkflowRun["id"]): Promise;
+ /** 按已关联的 Character ID 查找唯一绑定的 WorkflowRun(客户端过滤)。 */
+ getByCharacter(characterId: string): Promise;
+ /** 列出当前项目下的全部 WorkflowRun。 */
+ list(projectId?: string): Promise;
+ /** 保存 WorkflowRun 最新状态到服务端。 */
+ save(run: WorkflowRun): Promise;
+}
+
+export interface CreateWorkflowRunStoreOptions {
+ /**
+ * HTTP 客户端,提供 fetch 方法。
+ * 不传时使用仅内存存储(测试友好)。
+ */
+ api?: { fetch(input: RequestInfo, init?: RequestInit): Promise };
+}
+
+// ── 序列化 ─────────────────────────────────────────────────────────────────
+
+/** 后端 WorkflowRun 响应形状(nodes JSONB 透传)。 */
+interface BackendWorkflowRun {
+ id: number;
+ project_id: number;
+ nodes: Record[];
+ status: string;
+ version: number;
+}
+
+/** 把前端 WorkflowRun 的丰富字段序列化到后端 nodes 载荷中。 */
+function _toNodePayload(run: WorkflowRun): Record {
+ return {
+ // projectId 同时写进 nodes:后端 project_id 是整数,前端用 string ID,
+ // 读取时优先从 nodes 还原以保持原始值。
+ projectId: run.projectId,
+ characterId: run.characterId,
+ outfitId: run.outfitId,
+ purpose: run.purpose,
+ status: run.status,
+ nodes: run.nodes,
+ generationStatus: run.generationStatus,
+ exportStatus: run.exportStatus,
+ prompt: run.prompt,
+ createdAt: run.createdAt,
+ };
+}
+
+/** 从后端响应重建前端 WorkflowRun。 */
+function _fromBackend(b: BackendWorkflowRun): WorkflowRun {
+ const node = b.nodes[0] ?? {};
+ return {
+ id: String(b.id),
+ // 优先从 nodes 取 projectId(保持前端原始 string 值),
+ // 不存时回退到后端 project_id。
+ projectId:
+ (node.projectId as string) ?? String(b.project_id),
+ characterId: (node.characterId as string) ?? null,
+ outfitId: (node.outfitId as string) ?? null,
+ purpose: (node.purpose as WorkflowRun["purpose"]) ?? "create_character",
+ status: (node.status as WorkflowRun["status"]) ?? "active",
+ nodes: (node.nodes as WorkflowRun["nodes"]) ?? [],
+ generationStatus:
+ (node.generationStatus as WorkflowRun["generationStatus"]) ??
+ "not_started",
+ exportStatus:
+ (node.exportStatus as WorkflowRun["exportStatus"]) ?? "not_exported",
+ prompt: (node.prompt as string | null) ?? null,
+ createdAt: (node.createdAt as string) ?? new Date().toISOString(),
+ };
+}
+
+// ── 内存存储(测试/过渡期) ──────────────────────────────────────────────────
+
+function createInMemoryStore(): WorkflowRunStore {
+ const runs = new Map();
+
+ return {
+ async create(input) {
+ const run: WorkflowRun = {
+ id: `run-${runs.size + 1}`,
+ projectId: input.projectId,
+ characterId:
+ "characterId" in input ? (input.characterId as string) : null,
+ outfitId: "outfitId" in input ? (input.outfitId as string) : null,
+ purpose: input.purpose,
+ status: "active",
+ nodes: [],
+ generationStatus: "not_started",
+ exportStatus: "not_exported",
+ prompt: input.prompt ?? null,
+ createdAt: new Date().toISOString(),
+ };
+ runs.set(run.id, structuredClone(run));
+ return structuredClone(run);
+ },
+
+ async get(runId) {
+ const run = runs.get(runId);
+ return run ? structuredClone(run) : null;
+ },
+
+ async getByCharacter(characterId) {
+ for (const run of runs.values()) {
+ if (run.characterId === characterId) return structuredClone(run);
+ }
+ return null;
+ },
+
+ async list(projectId) {
+ return [...runs.values()]
+ .filter((run) => !projectId || run.projectId === projectId)
+ .map((run) => structuredClone(run));
+ },
+
+ async save(run) {
+ runs.set(run.id, structuredClone(run));
+ },
+ };
+}
+
+// ── HTTP 存储 ──────────────────────────────────────────────────────────────
+
+function isNotFoundError(cause: unknown): boolean {
+ return (
+ typeof cause === "object" &&
+ cause !== null &&
+ "status" in cause &&
+ cause.status === 404
+ );
+}
+
+/**
+ * 创建 WorkflowRunStore。
+ * 传入 api 时走 HTTP 持久化(对齐后端 /workflow-runs 接口),
+ * 否则使用仅内存存储(用于测试和过渡期)。
+ */
+export function createWorkflowRunStore(
+ options: CreateWorkflowRunStoreOptions = {},
+): WorkflowRunStore {
+ const api = options.api;
+ if (!api) return createInMemoryStore();
+
+ /** 后端通用响应包装:Response { code, message, data: T } */
+ function _unwrap(response: unknown): T {
+ const r = response as { data?: T };
+ if (r.data !== undefined) return r.data;
+ return response as T;
+ }
+
+ return {
+ async create(input) {
+ const response = await api.fetch("/workflow-runs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ project_id: Number(input.projectId),
+ nodes: [
+ {
+ // 创建时前端 WorkflowRun 字段(除 id)全部进入 nodes
+ projectId: input.projectId,
+ characterId:
+ "characterId" in input ? input.characterId : null,
+ outfitId: "outfitId" in input ? input.outfitId : null,
+ purpose: input.purpose,
+ status: "active",
+ nodes: [],
+ generationStatus: "not_started",
+ exportStatus: "not_exported",
+ prompt: input.prompt ?? null,
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ }),
+ });
+ return _fromBackend(_unwrap(response) as BackendWorkflowRun);
+ },
+
+ async get(runId) {
+ try {
+ const response = await api.fetch(`/workflow-runs/${runId}`);
+ return _fromBackend(_unwrap(response) as BackendWorkflowRun);
+ } catch (cause) {
+ if (isNotFoundError(cause)) return null;
+ throw cause;
+ }
+ },
+
+ async getByCharacter(characterId) {
+ try {
+ // 后端无 characterId 查询参数,先全量拉取再客户端过滤。
+ const runs = await api.fetch("/workflow-runs");
+ const all = (_unwrap(runs) as BackendWorkflowRun[]).map(_fromBackend);
+ return all.find((r) => r.characterId === characterId) ?? null;
+ } catch (cause) {
+ if (isNotFoundError(cause)) return null;
+ throw cause;
+ }
+ },
+
+ async list(projectId) {
+ const query = projectId
+ ? `?project_id=${encodeURIComponent(projectId)}`
+ : "";
+ const response = await api.fetch(`/workflow-runs${query}`);
+ const items = _unwrap(response) as BackendWorkflowRun[];
+ return items.map(_fromBackend);
+ },
+
+ async save(run) {
+ await api.fetch(`/workflow-runs/${run.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ nodes: [_toNodePayload(run)],
+ }),
+ });
+ },
+ };
+}
diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx
new file mode 100644
index 00000000..c1b8609e
--- /dev/null
+++ b/frontend/src/features/auth-session/index.test.tsx
@@ -0,0 +1,348 @@
+// @vitest-environment jsdom
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { StrictMode, type ReactNode } from 'react'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AuthTokens, User, UserApis } from '@/entities/user'
+import { getApiAccessToken } from '@/shared/api'
+import {
+ AuthSessionProvider,
+ ProtectedRoute,
+ createLocalUserApis,
+ resolveAuthMode,
+ useAuthSession,
+} from '.'
+import { REFRESH_TOKEN_STORAGE_KEY } from './session-storage'
+
+const user: User = {
+ id: 7,
+ email: 'ada@example.test',
+ nickname: 'Ada',
+ emailVerifiedAt: '2026-08-05T00:00:00Z',
+ status: 'normal',
+}
+
+let session: ReturnType | undefined
+
+function SessionProbe() {
+ session = useAuthSession()
+ return
+}
+
+function LocationProbe() {
+ const location = useLocation()
+ return
+}
+
+function renderProvider(apis: UserApis, children: ReactNode = , strict = false) {
+ const tree = {children}
+ return render(strict ? {tree} : tree)
+}
+
+beforeEach(() => {
+ vi.stubEnv('VITE_AUTH_MODE', 'backend')
+ session = undefined
+ window.localStorage.clear()
+})
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.unstubAllEnvs()
+})
+
+describe('AuthSessionProvider', () => {
+ it('rotates the stored refresh token once in StrictMode, exposes it only through memory, then loads the current user', async () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh')
+ const rotated = tokens('rotated-access', 'rotated-refresh')
+ const apis = createApis({
+ refresh: vi.fn(async () => rotated),
+ me: vi.fn(async () => user),
+ })
+
+ renderProvider(apis, , true)
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('session-status').textContent).toBe('authenticated'),
+ )
+ expect(apis.refresh).toHaveBeenCalledTimes(1)
+ expect(apis.refresh).toHaveBeenCalledWith('stored-refresh')
+ expect(apis.me).toHaveBeenCalledTimes(1)
+ expect(getApiAccessToken()).toBe('rotated-access')
+ expect(window.localStorage.length).toBe(1)
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh')
+ })
+
+ it('stores the rotated refresh token and keeps the access token in memory after login', async () => {
+ const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) })
+ renderProvider(apis)
+ await waitFor(() => expect(session?.state.status).toBe('guest'))
+
+ await act(async () => {
+ await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' })
+ })
+
+ expect(session?.state).toEqual({ status: 'authenticated', user })
+ expect(getApiAccessToken()).toBe('login-access')
+ expect(window.localStorage.length).toBe(1)
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh')
+ })
+
+ it('unregisters its in-memory access token provider when unmounted', async () => {
+ const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) })
+ const view = renderProvider(apis)
+ await waitFor(() => expect(session?.state.status).toBe('guest'))
+ await act(async () => {
+ await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' })
+ })
+ expect(getApiAccessToken()).toBe('login-access')
+
+ view.unmount()
+
+ expect(getApiAccessToken()).toBeUndefined()
+ })
+
+ it('falls back to a cleared guest session when startup token rotation fails', async () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'revoked-refresh')
+ const apis = createApis({
+ refresh: vi.fn(async () => {
+ throw new Error('refresh revoked')
+ }),
+ })
+
+ renderProvider(apis)
+
+ await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null }))
+ expect(getApiAccessToken()).toBeNull()
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull()
+ })
+
+ it('does not restore a stale startup session after the user logs out', async () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh')
+ const startupRefresh = deferred()
+ const apis = createApis({ refresh: vi.fn(() => startupRefresh.promise) })
+ renderProvider(apis)
+ await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('stored-refresh'))
+
+ await act(async () => {
+ await session?.logout()
+ })
+ await act(async () => {
+ startupRefresh.resolve(tokens('stale-access', 'stale-refresh'))
+ await startupRefresh.promise
+ })
+
+ await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null }))
+ expect(getApiAccessToken()).toBeNull()
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull()
+ })
+
+ it('does not let a stale startup failure clear a newer login', async () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh')
+ const startupMe = deferred()
+ const apis = createApis({
+ refresh: vi.fn(async () => tokens('startup-access', 'startup-refresh')),
+ me: vi.fn(() => startupMe.promise),
+ login: vi.fn(async () => tokens('login-access', 'login-refresh')),
+ })
+ renderProvider(apis)
+ await waitFor(() => expect(apis.me).toHaveBeenCalledTimes(1))
+
+ await act(async () => {
+ await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' })
+ })
+ await act(async () => {
+ startupMe.reject(new Error('stale me failure'))
+ await Promise.resolve()
+ })
+
+ await waitFor(() => expect(session?.state).toEqual({ status: 'authenticated', user }))
+ expect(getApiAccessToken()).toBe('login-access')
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh')
+ })
+
+ it('clears the local session before surfacing a backend logout failure', async () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh')
+ const apis = createApis({
+ refresh: vi.fn(async () => tokens('access', 'rotated-refresh')),
+ me: vi.fn(async () => user),
+ logout: vi.fn(async () => {
+ throw new Error('backend unavailable')
+ }),
+ })
+ renderProvider(apis)
+ await waitFor(() => expect(session?.state.status).toBe('authenticated'))
+
+ await act(async () => {
+ await expect(session?.logout()).rejects.toThrow('backend unavailable')
+ })
+
+ expect(session?.state).toEqual({ status: 'guest', user: null })
+ expect(getApiAccessToken()).toBeNull()
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull()
+ expect(apis.logout).toHaveBeenCalledWith('rotated-refresh')
+ })
+
+ it('clears the session after a successful password change because the backend revokes refresh tokens', async () => {
+ const apis = createApis({ login: vi.fn(async () => tokens('access', 'refresh')) })
+ renderProvider(apis)
+ await waitFor(() => expect(session?.state.status).toBe('guest'))
+ await act(async () => {
+ await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' })
+ })
+
+ await act(async () => {
+ await session?.changePassword({ oldPassword: 'password1', newPassword: 'password2' })
+ })
+
+ expect(apis.changePassword).toHaveBeenCalledWith({
+ oldPassword: 'password1',
+ newPassword: 'password2',
+ })
+ expect(session?.state).toEqual({ status: 'guest', user: null })
+ expect(getApiAccessToken()).toBeNull()
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull()
+ })
+
+ it('refreshes an expiring JWT sixty seconds before expiry and rotates the refresh token', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-08-05T00:00:00Z'))
+ const expiringAccess = jwtExpiringAt(Date.now() + 120_000)
+ const refreshedAccess = jwtExpiringAt(Date.now() + 3_600_000)
+ const apis = createApis({ login: vi.fn(async () => tokens(expiringAccess, 'refresh-1')) })
+ vi.mocked(apis.refresh).mockResolvedValue(tokens(refreshedAccess, 'refresh-2'))
+ renderProvider(apis)
+ await act(async () => Promise.resolve())
+ await act(async () => {
+ await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' })
+ })
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_000)
+ })
+
+ expect(apis.refresh).toHaveBeenCalledWith('refresh-1')
+ expect(getApiAccessToken()).toBe(refreshedAccess)
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-2')
+ })
+
+ it('exposes every account action as a Promise-returning operation', async () => {
+ const apis = createApis()
+ renderProvider(apis)
+ await waitFor(() => expect(session?.state.status).toBe('guest'))
+
+ await expect(
+ session?.sendCode({ email: 'ada@example.test', purpose: 'login' }),
+ ).resolves.toBeUndefined()
+ await expect(
+ session?.register({ email: 'ada@example.test', password: 'password1', code: '123456' }),
+ ).resolves.toEqual(tokens('access', 'refresh'))
+ await expect(
+ session?.loginByCode({ email: 'ada@example.test', code: '123456' }),
+ ).resolves.toEqual(tokens('access', 'refresh'))
+ })
+})
+
+describe('resolveAuthMode', () => {
+ it('开发环境默认使用本地登录,生产环境始终使用真实后端认证', () => {
+ expect(resolveAuthMode('', true)).toBe('local')
+ expect(resolveAuthMode('local', true)).toBe('local')
+ expect(resolveAuthMode('backend', true)).toBe('backend')
+ expect(resolveAuthMode('local', false)).toBe('backend')
+ })
+})
+
+describe('createLocalUserApis', () => {
+ it('只保存本地用户资料,不把密码和验证码写入浏览器存储,并可恢复会话', async () => {
+ const apis = createLocalUserApis()
+ const authenticated = await apis.register({
+ email: 'ada@example.test',
+ password: 'password1',
+ code: '123456',
+ nickname: 'Ada',
+ })
+
+ expect(authenticated.user).toMatchObject({
+ email: 'ada@example.test',
+ nickname: 'Ada',
+ status: 'normal',
+ })
+ expect(JSON.stringify(window.localStorage)).not.toContain('password1')
+ expect(JSON.stringify(window.localStorage)).not.toContain('123456')
+
+ await expect(createLocalUserApis().refresh(authenticated.refreshToken)).resolves.toMatchObject({
+ user: authenticated.user,
+ })
+ })
+})
+
+describe('ProtectedRoute', () => {
+ it.each([
+ [
+ '/projects/7?tab=assets#frames',
+ '/?account=login&returnTo=%2Fprojects%2F7%3Ftab%3Dassets%23frames',
+ ],
+ ['//evil.example/path', '/?account=login'],
+ ])(
+ 'returns a guest from %s to the public login panel with only a safe same-site returnTo',
+ async (entry, expected) => {
+ const apis = createApis()
+ renderProvider(
+ apis,
+
+
+
+ Private
+
+ }
+ />
+ } />
+
+ ,
+ )
+
+ await waitFor(() => expect(screen.getByLabelText('location').textContent).toBe(expected))
+ expect(screen.queryByRole('heading', { name: 'Private' })).toBeNull()
+ },
+ )
+})
+
+function tokens(accessToken: string, refreshToken: string): AuthTokens {
+ return { accessToken, refreshToken, user }
+}
+
+function jwtExpiringAt(expiryTime: number): string {
+ const payload = btoa(JSON.stringify({ exp: Math.floor(expiryTime / 1000) }))
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replace(/=+$/, '')
+ return `header.${payload}.signature`
+}
+
+function createApis(overrides: Partial = {}): UserApis {
+ return {
+ sendCode: vi.fn(async () => undefined),
+ register: vi.fn(async () => tokens('access', 'refresh')),
+ login: vi.fn(async () => tokens('access', 'refresh')),
+ loginByCode: vi.fn(async () => tokens('access', 'refresh')),
+ refresh: vi.fn(async () => tokens('access', 'refresh')),
+ logout: vi.fn(async () => undefined),
+ me: vi.fn(async () => user),
+ changePassword: vi.fn(async () => undefined),
+ ...overrides,
+ }
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx
new file mode 100644
index 00000000..07e51580
--- /dev/null
+++ b/frontend/src/features/auth-session/index.tsx
@@ -0,0 +1,396 @@
+/* oxlint-disable react/only-export-components -- 该模块的公共契约同时导出 Provider、路由守卫和 hook。 */
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react'
+import { Navigate, useLocation } from 'react-router'
+
+import type { AuthTokens, User, UserApis } from '@/entities/user'
+import { registerApiAccessTokenProvider } from '@/shared/api'
+import { clearRefreshToken, loadRefreshToken, saveRefreshToken } from './session-storage'
+
+export type AuthSessionState =
+ | { status: 'booting'; user: null }
+ | { status: 'guest'; user: null }
+ | { status: 'authenticated'; user: User }
+
+export interface AuthSessionValue {
+ state: AuthSessionState
+ sendCode(input: Parameters[0]): Promise
+ register(input: Parameters[0]): Promise
+ login(input: Parameters[0]): Promise
+ loginByCode(input: Parameters[0]): Promise
+ changePassword(input: Parameters[0]): Promise
+ logout(): Promise
+}
+
+export interface AuthSessionProviderProps {
+ apis: UserApis
+ children: ReactNode
+}
+
+export type AuthMode = 'local' | 'backend'
+
+interface RefreshInFlight {
+ refreshToken: string
+ promise: Promise
+}
+
+interface BootstrapResult {
+ user: User
+}
+
+const AuthSessionContext = createContext(null)
+
+/**
+ * 本地开发默认使用浏览器内的开发登录,生产环境始终连接真实认证接口。
+ * 开发者仍可通过 VITE_AUTH_MODE=backend 在本地完整调试登录流程。
+ */
+export function resolveAuthMode(
+ value = import.meta.env.VITE_AUTH_MODE,
+ development = import.meta.env.DEV,
+): AuthMode {
+ if (!development) return 'backend'
+ return value === 'backend' ? 'backend' : 'local'
+}
+
+/**
+ * 按运行模式装配认证上下文;本地与后端适配器共用同一份会话逻辑。
+ */
+export function AuthModeProvider({ apis, children }: { apis: UserApis; children: ReactNode }) {
+ return {children}
+}
+
+const LOCAL_USER_STORAGE_KEY = 'windup.auth.local-user'
+
+/**
+ * 本地开发认证适配器。
+ *
+ * 它只保存可展示的用户资料,不保存或校验密码、验证码;生产构建不会装配它。
+ * 后端认证可用后只需切换 VITE_AUTH_MODE=backend,页面和会话逻辑无需重写。
+ */
+export function createLocalUserApis(): UserApis {
+ let currentUser = readLocalUser()
+
+ const authenticate = (email: string, nickname?: string): AuthTokens => {
+ const normalizedEmail = email.trim().toLowerCase()
+ if (!normalizedEmail) throw new Error('请输入邮箱')
+ currentUser = {
+ id: currentUser?.email === normalizedEmail ? currentUser.id : 1,
+ email: normalizedEmail,
+ nickname: nickname?.trim() || currentUser?.nickname || normalizedEmail.split('@')[0] || null,
+ emailVerifiedAt: currentUser?.emailVerifiedAt ?? new Date().toISOString(),
+ status: 'normal',
+ }
+ saveLocalUser(currentUser)
+ return localTokens(currentUser)
+ }
+
+ return {
+ async sendCode() {},
+ async register(input) {
+ return authenticate(input.email, input.nickname)
+ },
+ async login(input) {
+ return authenticate(input.email)
+ },
+ async loginByCode(input) {
+ return authenticate(input.email)
+ },
+ async refresh(refreshToken) {
+ currentUser = readLocalUser()
+ if (!currentUser || refreshToken !== localRefreshToken(currentUser)) {
+ throw new Error('本地登录已失效')
+ }
+ return localTokens(currentUser)
+ },
+ async logout() {},
+ async me() {
+ currentUser = readLocalUser()
+ if (!currentUser) throw new Error('本地用户不存在')
+ return currentUser
+ },
+ async changePassword() {
+ if (!currentUser) throw new Error('请先登录')
+ },
+ }
+}
+
+function localTokens(user: User): AuthTokens {
+ return {
+ accessToken: `local-access:${user.id}`,
+ refreshToken: localRefreshToken(user),
+ user,
+ }
+}
+
+function localRefreshToken(user: User): string {
+ return `local-refresh:${user.id}`
+}
+
+function readLocalUser(): User | null {
+ try {
+ const raw = globalThis.localStorage?.getItem(LOCAL_USER_STORAGE_KEY)
+ if (!raw) return null
+ const value: unknown = JSON.parse(raw)
+ if (
+ !isRecord(value) ||
+ typeof value.id !== 'number' ||
+ typeof value.email !== 'string' ||
+ (typeof value.nickname !== 'string' && value.nickname !== null) ||
+ (typeof value.emailVerifiedAt !== 'string' && value.emailVerifiedAt !== null) ||
+ (value.status !== 'normal' && value.status !== 'banned')
+ ) {
+ return null
+ }
+ return value as unknown as User
+ } catch {
+ return null
+ }
+}
+
+function saveLocalUser(user: User): void {
+ globalThis.localStorage?.setItem(LOCAL_USER_STORAGE_KEY, JSON.stringify(user))
+}
+
+export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) {
+ const [state, setState] = useState({ status: 'booting', user: null })
+ const [accessTokenVersion, setAccessTokenVersion] = useState(0)
+ const accessTokenRef = useRef(null)
+ const refreshTokenRef = useRef(null)
+ const sessionGenerationRef = useRef(0)
+ const refreshInFlightRef = useRef(null)
+ const bootstrapPromiseRef = useRef | null>(null)
+ const bootstrapGenerationRef = useRef(null)
+
+ const storeTokenMaterial = useCallback((tokens: AuthTokens) => {
+ accessTokenRef.current = tokens.accessToken
+ refreshTokenRef.current = tokens.refreshToken
+ saveRefreshToken(tokens.refreshToken)
+ setAccessTokenVersion((version) => version + 1)
+ }, [])
+
+ const applyTokens = useCallback(
+ (tokens: AuthTokens) => {
+ sessionGenerationRef.current += 1
+ storeTokenMaterial(tokens)
+ setState({ status: 'authenticated', user: tokens.user })
+ },
+ [storeTokenMaterial],
+ )
+
+ const clearSession = useCallback(() => {
+ sessionGenerationRef.current += 1
+ accessTokenRef.current = null
+ refreshTokenRef.current = null
+ clearRefreshToken()
+ setAccessTokenVersion((version) => version + 1)
+ setState({ status: 'guest', user: null })
+ }, [])
+
+ const rotateTokens = useCallback(
+ (refreshToken: string): Promise => {
+ const inFlight = refreshInFlightRef.current
+ if (inFlight?.refreshToken === refreshToken) return inFlight.promise
+
+ const promise = apis.refresh(refreshToken)
+ const current = { refreshToken, promise }
+ refreshInFlightRef.current = current
+ const clearInFlight = () => {
+ if (refreshInFlightRef.current === current) refreshInFlightRef.current = null
+ }
+ void promise.then(clearInFlight, clearInFlight)
+ return promise
+ },
+ [apis],
+ )
+
+ useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), [])
+
+ useEffect(() => {
+ let active = true
+
+ if (!bootstrapPromiseRef.current) {
+ const bootstrapGeneration = sessionGenerationRef.current
+ bootstrapGenerationRef.current = bootstrapGeneration
+ const persistedRefreshToken = loadRefreshToken()
+ bootstrapPromiseRef.current = persistedRefreshToken
+ ? rotateTokens(persistedRefreshToken).then(async (tokens) => {
+ if (sessionGenerationRef.current !== bootstrapGeneration) return undefined
+ storeTokenMaterial(tokens)
+ const user = await apis.me()
+ if (sessionGenerationRef.current !== bootstrapGeneration) return undefined
+ return { user }
+ })
+ : Promise.resolve(null)
+ }
+
+ void bootstrapPromiseRef.current.then(
+ (result) => {
+ if (
+ !active ||
+ bootstrapGenerationRef.current !== sessionGenerationRef.current ||
+ result === undefined
+ )
+ return
+ if (!result) {
+ clearSession()
+ return
+ }
+ setState({ status: 'authenticated', user: result.user })
+ },
+ () => {
+ if (active && bootstrapGenerationRef.current === sessionGenerationRef.current)
+ clearSession()
+ },
+ )
+
+ return () => {
+ active = false
+ }
+ }, [apis, clearSession, rotateTokens, storeTokenMaterial])
+
+ useEffect(() => {
+ const accessToken = accessTokenRef.current
+ const refreshAt = getRefreshTime(accessToken)
+ if (refreshAt === null) return
+
+ let cancelled = false
+ let timer: ReturnType | undefined
+
+ const schedule = () => {
+ const delay = Math.max(0, refreshAt - Date.now())
+ timer = setTimeout(
+ () => {
+ if (cancelled) return
+ if (Date.now() < refreshAt) {
+ schedule()
+ return
+ }
+
+ const refreshToken = refreshTokenRef.current
+ if (!refreshToken) return
+ void rotateTokens(refreshToken).then(
+ (tokens) => {
+ if (!cancelled && refreshTokenRef.current === refreshToken) applyTokens(tokens)
+ },
+ () => {
+ if (!cancelled && refreshTokenRef.current === refreshToken) clearSession()
+ },
+ )
+ },
+ Math.min(delay, 2_147_483_647),
+ )
+ }
+
+ schedule()
+ return () => {
+ cancelled = true
+ if (timer !== undefined) clearTimeout(timer)
+ }
+ }, [accessTokenVersion, applyTokens, clearSession, rotateTokens])
+
+ const sendCode = useCallback(
+ (input: Parameters[0]) => apis.sendCode(input),
+ [apis],
+ )
+
+ const register = useCallback(
+ async (input: Parameters[0]) => {
+ const tokens = await apis.register(input)
+ applyTokens(tokens)
+ return tokens
+ },
+ [apis, applyTokens],
+ )
+
+ const login = useCallback(
+ async (input: Parameters[0]) => {
+ const tokens = await apis.login(input)
+ applyTokens(tokens)
+ return tokens
+ },
+ [apis, applyTokens],
+ )
+
+ const loginByCode = useCallback(
+ async (input: Parameters[0]) => {
+ const tokens = await apis.loginByCode(input)
+ applyTokens(tokens)
+ return tokens
+ },
+ [apis, applyTokens],
+ )
+
+ const changePassword = useCallback(
+ async (input: Parameters[0]) => {
+ await apis.changePassword(input)
+ clearSession()
+ },
+ [apis, clearSession],
+ )
+
+ const logout = useCallback(async () => {
+ const refreshToken = refreshTokenRef.current
+ clearSession()
+ if (refreshToken) await apis.logout(refreshToken)
+ }, [apis, clearSession])
+
+ const value = useMemo(
+ () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }),
+ [changePassword, login, loginByCode, logout, register, sendCode, state],
+ )
+
+ return {children}
+}
+
+export function useAuthSession(): AuthSessionValue {
+ const session = useContext(AuthSessionContext)
+ if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用')
+ return session
+}
+
+export function ProtectedRoute({ children }: { children: ReactNode }) {
+ const { state } = useAuthSession()
+ const location = useLocation()
+
+ if (state.status === 'booting') return null
+ if (state.status === 'authenticated') return children
+
+ const returnTo = `${location.pathname}${location.search}${location.hash}`
+ const loginTarget = isSafeReturnTo(returnTo)
+ ? `/?account=login&returnTo=${encodeURIComponent(returnTo)}`
+ : '/?account=login'
+ return
+}
+
+function isSafeReturnTo(value: string): boolean {
+ return value.startsWith('/') && !value.startsWith('//')
+}
+
+function getRefreshTime(accessToken: string | null): number | null {
+ if (!accessToken) return null
+ const payload = accessToken.split('.')[1]
+ if (!payload) return null
+
+ try {
+ const base64 = payload.replaceAll('-', '+').replaceAll('_', '/')
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
+ const parsed: unknown = JSON.parse(globalThis.atob(padded))
+ if (!isRecord(parsed) || typeof parsed.exp !== 'number' || !Number.isFinite(parsed.exp))
+ return null
+ return parsed.exp * 1_000 - 60_000
+ } catch {
+ return null
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts
new file mode 100644
index 00000000..28af8652
--- /dev/null
+++ b/frontend/src/features/auth-session/session-storage.test.ts
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+ REFRESH_TOKEN_STORAGE_KEY,
+ clearRefreshToken,
+ loadRefreshToken,
+ saveRefreshToken,
+} from './session-storage'
+
+afterEach(() => {
+ window.localStorage.clear()
+})
+
+describe('auth session storage', () => {
+ it('persists only the refresh token under the authentication key', () => {
+ saveRefreshToken('refresh-token')
+
+ expect(window.localStorage.length).toBe(1)
+ expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token')
+ expect(loadRefreshToken()).toBe('refresh-token')
+ })
+
+ it('removes the persisted refresh token', () => {
+ window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-token')
+
+ clearRefreshToken()
+
+ expect(loadRefreshToken()).toBeNull()
+ })
+
+ it('treats unavailable or failing local storage as an empty best-effort store', () => {
+ const failingStorage = {
+ getItem(): string | null {
+ throw new DOMException('blocked')
+ },
+ setItem(): void {
+ throw new DOMException('blocked')
+ },
+ removeItem(): void {
+ throw new DOMException('blocked')
+ },
+ }
+
+ expect(loadRefreshToken(failingStorage)).toBeNull()
+ expect(() => saveRefreshToken('refresh-token', failingStorage)).not.toThrow()
+ expect(() => clearRefreshToken(failingStorage)).not.toThrow()
+ })
+})
diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts
new file mode 100644
index 00000000..06ba96ca
--- /dev/null
+++ b/frontend/src/features/auth-session/session-storage.ts
@@ -0,0 +1,40 @@
+export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token'
+
+type RefreshTokenStorage = Pick
+
+function getLocalStorage(): RefreshTokenStorage | null {
+ try {
+ return globalThis.localStorage
+ } catch {
+ return null
+ }
+}
+
+export function loadRefreshToken(
+ storage: RefreshTokenStorage | null = getLocalStorage(),
+): string | null {
+ try {
+ return storage?.getItem(REFRESH_TOKEN_STORAGE_KEY) ?? null
+ } catch {
+ return null
+ }
+}
+
+export function saveRefreshToken(
+ refreshToken: string,
+ storage: RefreshTokenStorage | null = getLocalStorage(),
+): void {
+ try {
+ storage?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken)
+ } catch {
+ // 持久化不可用时仍保留当前内存会话。
+ }
+}
+
+export function clearRefreshToken(storage: RefreshTokenStorage | null = getLocalStorage()): void {
+ try {
+ storage?.removeItem(REFRESH_TOKEN_STORAGE_KEY)
+ } catch {
+ // 清理是尽力而为;浏览器禁用存储时不能让应用崩溃。
+ }
+}
diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts
new file mode 100644
index 00000000..60a05ecf
--- /dev/null
+++ b/frontend/src/features/character-setup/index.test.ts
@@ -0,0 +1,10 @@
+import { expectTypeOf, it } from 'vitest'
+
+import type { CharacterSetupStepInput } from '@/entities'
+import type { CharacterSetupProps } from '.'
+
+it('submits WorkflowRun character setup input', () => {
+ expectTypeOf()
+ .parameter(0)
+ .toEqualTypeOf()
+})
diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts
index 44c9a7b0..13827b1c 100644
--- a/frontend/src/features/character-setup/index.ts
+++ b/frontend/src/features/character-setup/index.ts
@@ -1,7 +1,7 @@
-import type { CreateCharacterInput } from '@/entities'
+import type { CharacterSetupStepInput } from '@/entities'
/** 填写角色资料并提交母版生成。 */
export interface CharacterSetupProps {
projectId: string
- onSubmit(input: CreateCharacterInput): void
+ onSubmit(input: CharacterSetupStepInput): void
}
diff --git a/frontend/src/features/export-package/README.md b/frontend/src/features/export-package/README.md
new file mode 100644
index 00000000..d51b46be
--- /dev/null
+++ b/frontend/src/features/export-package/README.md
@@ -0,0 +1,47 @@
+# Export Package 模块
+
+本模块把已经通过质量检测的角色动作整理为可下载 ZIP。它不负责生成图片、保存历史记录或发布资产,只负责验证和导出。
+
+## 数据怎么走
+
+1. Playtest 或接口适配器组装 `ExportPackageModel`。
+2. `validateExportPackageModel` 检查角色、画布、生成记录、帧数、质量状态、锚点和脚底线。
+3. `createAssetExportPlan` 为每个动作方向生成稳定目录与三位连续帧名。
+4. `exportGameAssets` 读取透明 PNG,并检查图片尺寸是否与统一画布一致。
+5. 浏览器生成 Sprite Sheet,最后写入动画 `meta.json`、`schema.json`、README 与 ZIP。
+6. 可选 target 只在 `targets//` 下追加引擎文件,不修改通用层。
+
+## 导出结构
+
+```text
+Aster-character-1/
+ meta.json
+ schema.json
+ README.md
+ frames/Walk-south/Walk-south_000.png
+ atlas/Walk-south.png
+ targets//...
+```
+
+`meta.json` 的坐标原点在左上角,y 轴向下。`anchor` 是 0-1 归一化坐标,`foot_y` 是从画布顶部开始计算的像素值。
+
+## 为什么缺一帧就全部失败
+
+`expectedFrameCount` 表示后端声明的完整帧数,不能用 `frames.length` 自己推算。两者不同、图片读取失败、PNG 无透明信息、尺寸不一致或 `qualityStatus` 不是 `passed` 时,导出立即失败,不会用透明占位掩盖问题,也不会下载残缺包。
+
+## Cocos Creator 边界
+
+Issue #94 要求先用真实 Cocos Creator 3.x 验证图集切分数据、`.anim`、`.meta`、UUID 与小版本差异。当前仓库没有该实测结论,因此本模块只落地已确定的通用层和 target 扩展接口,不伪造 Cocos 原生文件。
+
+Cocos 坐标转换规则已经明确:通用锚点 `(x, y)` 转成 Creator 锚点 `(x, 1-y)`。等实测字段回填后,只需新增一个 `AssetExportTarget`,不改 `meta.json` 与通用打包逻辑。
+
+## 验证
+
+```bash
+npm test -- src/features/export-package
+npm run typecheck
+npm run lint
+npm run build
+```
+
+测试覆盖 Schema 校验、连续命名、图集输出、缺帧失败、质量门禁、图片释放和空 target 扩展。
diff --git a/frontend/src/features/export-package/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts
new file mode 100644
index 00000000..d135a773
--- /dev/null
+++ b/frontend/src/features/export-package/asset-export.test.ts
@@ -0,0 +1,269 @@
+/** @vitest-environment jsdom */
+import Ajv2020 from 'ajv/dist/2020.js'
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ createAssetExportPlan,
+ exportGameAssets,
+ type AssetExportRuntime,
+ type AssetExportTarget,
+} from './asset-export'
+import { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target'
+import { EXPORT_PACKAGE_JSON_SCHEMA_TEXT } from './contract'
+import type { ExportAction, ExportFrame, ExportPackageModel } from './model'
+
+function frame(index: number): ExportFrame {
+ return {
+ imageUrl: `/frames/walk-${index}.png`,
+ durationMs: 100,
+ rootMotion: { dx: index, dy: 0 },
+ keyFrame: index === 0,
+ }
+}
+
+function action(frameCount = 9): ExportAction {
+ return {
+ id: 'walk-abcdef12',
+ name: 'Walk / Forward',
+ type: 'walk',
+ fps: 10,
+ sequences: [
+ {
+ direction: 'south',
+ expectedFrameCount: frameCount,
+ loop: true,
+ anchor: { x: 0.5, y: 0.9 },
+ footY: 36,
+ qualityStatus: 'passed',
+ frames: Array.from({ length: frameCount }, (_, index) => frame(index)),
+ },
+ ],
+ }
+}
+
+const model: ExportPackageModel = {
+ characterId: 'character-1',
+ characterName: 'Aster',
+ outfitId: 'outfit-1',
+ outfitName: 'Explorer',
+ characterTemplateUrl: null,
+ baseFrameCount: 0,
+ canvas: { width: 32, height: 40 },
+ source: { workflowRunId: 'run-1', generationIds: ['generation-1'] },
+ actions: [action()],
+}
+
+/** 构造足够让契约检查识别为 RGBA PNG 的文件头,解码由测试运行时接管。 */
+function rgbaPng(): Blob {
+ const data = new Uint8Array(33)
+ data.set([137, 80, 78, 71, 13, 10, 26, 10], 0)
+ new DataView(data.buffer).setUint32(8, 13, false)
+ data.set([73, 72, 68, 82], 12)
+ data[25] = 6
+ return new Blob([data], { type: 'image/png' })
+}
+
+async function readStoredZip(blob: Blob): Promise