diff --git a/.gitignore b/.gitignore index 65fbc3f..27abb4f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ output/ .ruff_cache/ .pytest_cache/ .import_linter_cache/ + +# 依赖与构建产物由各子项目的 .gitignore 负责 diff --git a/api-reference.md b/api-reference.md new file mode 100644 index 0000000..ea910d8 --- /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/frontend-architecture-v3.md b/frontend-architecture-v3.md index ed137ec..0c326e6 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 --- @@ -56,6 +56,7 @@ pages -> features -> entities -> shared ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 @@ -68,7 +69,8 @@ ProjectApis CharacterApis ActionTemplateApis GenerationApis `features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 -Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 步骤顺序固定八步: @@ -76,23 +78,35 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 -从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 -快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 + +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 --- -## 5. 本次不包含 +## 5. 当前实现范围 + +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 -- 任何实现代码(真实请求、假数据、组件内部逻辑) -- 测试文件 -- 图片上传模块(体量太小,本次不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +### 恢复边界 -页面当前是占位外壳,只声明路由与模块边界。 +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 --- diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index ed90e36..48b5e8e 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -22,18 +22,16 @@ 图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 ---- - -## 二、前端预期有、后端目前没有 +动作任务的 `frames[]` 会完整映射为 WorkflowRun 的 `complete_animation` 结果,保留顺序和 +`duration_ms`。审核前为满足动作生成接口的 `character_id/outfit_id` 要求,前端会创建一条 +尚无动作的角色草稿;只有审核通过后才把完整动作写回该角色。当前后端没有草稿/已发布状态, +因此资产库暂以“造型至少包含一个动作”作为已发布资产的显示条件。 -**这些接口仍需要确定由后端提供,还是改为前端本地能力。** +--- -| 前端接口 | 后端情况 | -|---|---| -| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | +## 二、前端不声明的后端缺失能力 -前端已按服务端现状去掉生成任务的 `cancel`——后端没有取消能力,不声明前端用不到的接口。 +后端目前没有项目更新和生成任务取消接口,前端相应地不声明 `ProjectApis.update` 或生成取消方法。创建、查询和轮询统一由 `GenerationApis` 提供。 --- @@ -45,7 +43,7 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `GenerationApis.subscribe`,实现时可封装轮询 | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `GenerationApis.subscribe`;适配器先立即回放当前快照,再继续轮询 | | 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | | 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | | 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | @@ -61,7 +59,6 @@ ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不 | `delete_character` | 前端 `CharacterApis` 没有删除 | | `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | | `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | -| `MediaService.upload` | 前端本次未提交上传模块 | --- @@ -105,12 +102,10 @@ frames[] → index / image_url / duration_ms ## 待确认 -- [ ] `ActionTemplateApis` 由后端提供还是前端内置 - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 - [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 -- [ ] 上传模块何时提交 ## 已分工 diff --git a/frontend/ARCHITECTURE_GUARDRAILS.md b/frontend/ARCHITECTURE_GUARDRAILS.md new file mode 100644 index 0000000..ae61d68 --- /dev/null +++ b/frontend/ARCHITECTURE_GUARDRAILS.md @@ -0,0 +1,41 @@ +# Frontend Architecture Guardrails + +## Code Layers + +```text +app -> pages -> features -> entities -> shared + | + +-> workflow-controller -> entities +``` + +- `app` 只组装真实 API、共享 Controller、路由和全局外壳。 +- `pages` 负责一个路由场景,不定义后端 DTO 或第二套业务状态。 +- `features` 负责可复用的用户行为,例如审核、发布和下载包。 +- `features` 不依赖 `pages` 内部类型;需要共享的只读模型由 Feature 自己声明结构边界。 +- `workflow-controller` 是创作运行、步骤推进、Revision 和异步结果写回的唯一入口。 +- `entities` 保存领域类型、实体 API 契约及其 DTO 转换。 +- `shared` 只保存通用 HTTP、分页、UI、Hook 和工具,不能理解 Windup 业务词汇。 + +## State Ownership + +`WorkflowRun` 和 `WorkflowStep` 是 Quick Start 与 Workflow Editor 共用的唯一流程状态。画布节点只由步骤投影而来;连线和 URL 中的 `stepId` 只控制显示与聚焦,不能决定业务是否可推进。刷新恢复时,角色图与完整动作都通过同一 `GenerationApis.get/subscribe` 继续查询;完整动作结果必须保留全部帧,不能降级成首帧。 + +## Product Boundaries + +- `AssetLibrary` 展示后端已经保存的 `Character -> Outfit -> Action -> Frame` 资产树。 +- `History` 展示 `WorkflowRun` 的执行与版本记录。两者不能互相改名或合并。 +- `Review` 在创作流程中做通过或回推决定,会改变 WorkflowRun。 +- `Playtest` 只检查已发布资产,问题记录不会反向修改工作流或角色数据。 +- `Publish` 把审核通过的结果写入资产并进入 Playtest。 +- `ExportPackage` 只在 Playtest 中下载 Sprite Sheet 和清单文件,不等于发布。 + +## Backend Boundary + +页面和 Feature 不直接 `fetch`。通用传输逻辑在 `shared/api`;项目、角色、生成和媒体 DTO 分别在对应 `entities/*/api.ts` 映射。后端没有的能力不得用本地假成功、假 ID 或“跳过步骤”代替。 + +## Review Checklist + +- 是否复用了同一个 WorkflowController,而非在页面创建状态机? +- 是否区分资产库、历史、审核、预览、发布和下载? +- 是否由实体 API 隔离了后端字段与前端领域类型? +- 是否覆盖失败、刷新恢复、重复提交和终态转换? diff --git a/frontend/MODULES.md b/frontend/MODULES.md new file mode 100644 index 0000000..d18ad36 --- /dev/null +++ b/frontend/MODULES.md @@ -0,0 +1,31 @@ +# Frontend Modules + +目录层级不等于业务模块。本项目按下面六个职责模块协作,每个模块可以横跨 `pages`、`features` 和 `entities`。 + +## 1. Project Workspace + +管理项目列表、创建、详情和项目级画布约束。入口位于 `pages/projects`、`pages/project-create`、`pages/project-detail`,领域与后端转换位于 `entities/project`。 + +## 2. Character Assets + +管理已经发布的角色资产树和上传媒体。`pages/asset-library` 只展示 `Character -> Outfit -> Action -> Frame`;数据边界位于 `entities/character` 和 `entities/media`。它不是工作流历史。 + +## 3. Creation Workflow + +Quick Start 隐藏步骤自动推进,Workflow Editor 显式展示步骤;两者必须共享 `features/workflow-controller` 与 `entities/workflow-run`,不能各自维护节点状态。 + +## 4. Generation Execution + +`entities/generation` 统一表示异步生成任务,负责创建、查询和订阅。项目中不再存在重复的 `Task` 实体。Controller 负责把 Generation 结果写回正确的 WorkflowStep。 + +## 5. Review And Publishing + +`features/review` 表达审核通过或回推,`features/publish` 表达把完成版本发布为角色资产。审核改变运行状态;发布改变资产可见性,两者不是下载。 + +## 6. Playtest And Delivery + +`pages/playtest` 是只读预览与问题记录工作台。`features/export-package` 从预览模型生成 Sprite Sheet 和清单下载包;它不推进 WorkflowRun,也不替代 Publish。 + +## Supporting Layers + +`app` 统一组装路由、API 和共享 Controller;`shared` 提供不含业务含义的 HTTP、分页及通用能力。依赖规则详见 `ARCHITECTURE_GUARDRAILS.md`。 diff --git a/frontend/README.md b/frontend/README.md index 6e01fa3..002bc73 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,7 @@ npm run dev npm run format:check # 格式 npm run lint # 静态检查 npm run typecheck # 类型 -npm run test # 测试(本阶段无测试文件) +npm run test # 单元与纵向集成测试 npm run build # 构建 ``` @@ -23,8 +23,10 @@ CI 按上面顺序全跑一遍。 ## 结构 -模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 +六个业务模块见 `MODULES.md`,代码依赖规则见 `ARCHITECTURE_GUARDRAILS.md`。 -**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 +当前 Project、Character、Generation、Media 已接真实后端适配器。Quick Start 与 Workflow +Editor 共享同一套 `WorkflowRun` 和 Controller;项目页、资产库、历史记录与 Playtest 分别 +承担不同产品职责。Playtest 提供只读检查与独立下载包能力。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/package.json b/frontend/package.json index 0a845f7..47bcbcc 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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..ae0e455 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2241 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.2.7 + version: 19.2.8 + react-dom: + specifier: ^19.2.7 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.2 + version: 24.13.3 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + oxfmt: + specifier: ^0.61.0 + version: 0.61.0 + oxlint: + specifier: ^1.71.0 + version: 1.76.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + +packages: + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jest/types@27.0.2': + resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.10': + resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==} + + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.0': + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^2.0.0-alpha.3 + '@emnapi/runtime': ^2.0.0-alpha.3 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxfmt/binding-android-arm-eabi@0.61.0': + resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.61.0': + resolution: {integrity: sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.61.0': + resolution: {integrity: sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.61.0': + resolution: {integrity: sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.61.0': + resolution: {integrity: sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + resolution: {integrity: sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + resolution: {integrity: sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.61.0': + resolution: {integrity: sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.61.0': + resolution: {integrity: sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + resolution: {integrity: sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + resolution: {integrity: sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.61.0': + resolution: {integrity: sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.61.0': + resolution: {integrity: sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.61.0': + resolution: {integrity: sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.61.0': + resolution: {integrity: sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.61.0': + resolution: {integrity: sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.61.0': + resolution: {integrity: sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.61.0': + resolution: {integrity: sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.61.0': + resolution: {integrity: sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.76.0': + resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.76.0': + resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.76.0': + resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.76.0': + resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.76.0': + resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.76.0': + resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.76.0': + resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.76.0': + resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.76.0': + resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.76.0': + resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@16.0.11': + resolution: {integrity: sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==} + + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + ansi-styles@5.0.0: + resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + enhanced-resolve@5.24.4: + resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + engines: {node: '>=10.13.0'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + oxfmt@0.61.0: + resolution: {integrity: sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.76.0: + resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.0.2: + resolution: {integrity: sha512-mXKbbBPnYTG7Yra9qFBtqj+IXcsvxsvOBco3QHxtxTl+hHKq6QdzMZ+q0CtL4ORHZgwGImRr2XZUX2EWzORxig==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.1: + resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==} + + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@jest/types@27.0.2': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.13.3 + '@types/yargs': 16.0.11 + chalk: 4.0.0 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.10 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.4.10': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@oxfmt/binding-android-arm-eabi@0.61.0': + optional: true + + '@oxfmt/binding-android-arm64@0.61.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.61.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.61.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.61.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.61.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.61.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.61.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.61.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.61.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.61.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.76.0': + optional: true + + '@oxlint/binding-android-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-x64@1.76.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.76.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.76.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.76.0': + optional: true + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.4 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.0.2 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@16.0.11': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.0.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + chalk@4.0.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + enhanced-resolve@5.24.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + is-potential-custom-element-name@1.0.1: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + oxfmt@0.61.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.61.0 + '@oxfmt/binding-android-arm64': 0.61.0 + '@oxfmt/binding-darwin-arm64': 0.61.0 + '@oxfmt/binding-darwin-x64': 0.61.0 + '@oxfmt/binding-freebsd-x64': 0.61.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.61.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.61.0 + '@oxfmt/binding-linux-arm64-gnu': 0.61.0 + '@oxfmt/binding-linux-arm64-musl': 0.61.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.61.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.61.0 + '@oxfmt/binding-linux-riscv64-musl': 0.61.0 + '@oxfmt/binding-linux-s390x-gnu': 0.61.0 + '@oxfmt/binding-linux-x64-gnu': 0.61.0 + '@oxfmt/binding-linux-x64-musl': 0.61.0 + '@oxfmt/binding-openharmony-arm64': 0.61.0 + '@oxfmt/binding-win32-arm64-msvc': 0.61.0 + '@oxfmt/binding-win32-ia32-msvc': 0.61.0 + '@oxfmt/binding-win32-x64-msvc': 0.61.0 + + oxlint@1.76.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.76.0 + '@oxlint/binding-android-arm64': 1.76.0 + '@oxlint/binding-darwin-arm64': 1.76.0 + '@oxlint/binding-darwin-x64': 1.76.0 + '@oxlint/binding-freebsd-x64': 1.76.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 + '@oxlint/binding-linux-arm-musleabihf': 1.76.0 + '@oxlint/binding-linux-arm64-gnu': 1.76.0 + '@oxlint/binding-linux-arm64-musl': 1.76.0 + '@oxlint/binding-linux-ppc64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-musl': 1.76.0 + '@oxlint/binding-linux-s390x-gnu': 1.76.0 + '@oxlint/binding-linux-x64-gnu': 1.76.0 + '@oxlint/binding-linux-x64-musl': 1.76.0 + '@oxlint/binding-openharmony-arm64': 1.76.0 + '@oxlint/binding-win32-arm64-msvc': 1.76.0 + '@oxlint/binding-win32-ia32-msvc': 1.76.0 + '@oxlint/binding-win32-x64-msvc': 1.76.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.0.2: + dependencies: + '@jest/types': 27.0.2 + ansi-regex: 5.0.1 + ansi-styles: 5.0.0 + react-is: 17.0.1 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.1: {} + + react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + undici@7.29.0: {} + + vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/frontend/public/windup-mark.svg b/frontend/public/windup-mark.svg new file mode 100644 index 0000000..5b16f25 --- /dev/null +++ b/frontend/public/windup-mark.svg @@ -0,0 +1,16 @@ + + Windup + 侧视机械小鸟标志 + + + + + + + + + + + + + diff --git a/frontend/src/app/__fixtures__/character-25.json b/frontend/src/app/__fixtures__/character-25.json new file mode 100644 index 0000000..7dd37ae --- /dev/null +++ b/frontend/src/app/__fixtures__/character-25.json @@ -0,0 +1,114 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 25, + "project_id": 37, + "description": "Quick Start auto-created character", + "reference_image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "character_data": { + "version": 1, + "outfits": [ + { + "id": "outfit-25-default", + "name": "默认造型", + "description": null, + "preview_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "actions": [ + { + "id": "25-custom", + "type": "custom", + "name": "自定义动作", + "loop": false, + "fps": 8.0, + "frame_count": 16, + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + } + ] + } + ] + }, + "status": 1 + } +} diff --git a/frontend/src/app/__fixtures__/character-list.json b/frontend/src/app/__fixtures__/character-list.json new file mode 100644 index 0000000..2ae3ebc --- /dev/null +++ b/frontend/src/app/__fixtures__/character-list.json @@ -0,0 +1,119 @@ +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 25, + "project_id": 37, + "description": "Quick Start auto-created character", + "reference_image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "character_data": { + "version": 1, + "outfits": [ + { + "id": "outfit-25-default", + "name": "默认造型", + "description": null, + "preview_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "actions": [ + { + "id": "25-custom", + "type": "custom", + "name": "自定义动作", + "loop": false, + "fps": 8.0, + "frame_count": 16, + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + } + ] + } + ] + }, + "status": 1 + } + ], + "total": 1, + "page": 1, + "page_size": 20 +} diff --git a/frontend/src/app/__fixtures__/project-list.json b/frontend/src/app/__fixtures__/project-list.json new file mode 100644 index 0000000..f351cb5 --- /dev/null +++ b/frontend/src/app/__fixtures__/project-list.json @@ -0,0 +1,289 @@ +{ + "code": 200, + "message": "success", + "data": [ + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8n8o1o-3apc", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "game_style": null, + "sprite_sample_url": null, + "id": 37, + "create_at": "2026-07-31T07:51:20.519353", + "update_at": "2026-07-31T07:51:20.519357" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8mt8iw-yr6r", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 36, + "create_at": "2026-07-31T07:39:20.564137", + "update_at": "2026-07-31T07:39:20.564142" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8ms8eg-27j1", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 35, + "create_at": "2026-07-31T07:38:33.747176", + "update_at": "2026-07-31T07:38:33.747180" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒-ms8mn50f-rdt1", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 34, + "create_at": "2026-07-31T07:34:36.075849", + "update_at": "2026-07-31T07:34:36.075856" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8mk8sc-wfj2", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 33, + "create_at": "2026-07-31T07:32:20.998825", + "update_at": "2026-07-31T07:32:20.998832" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一个会画画的猪八戒-ms8mew94-eigx", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 32, + "create_at": "2026-07-31T07:28:11.487617", + "update_at": "2026-07-31T07:28:11.487625" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一只羊-ms8md46p-e42w", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 31, + "create_at": "2026-07-31T07:26:48.450021", + "update_at": "2026-07-31T07:26:48.450028" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8m0wjc-ctrm", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 30, + "create_at": "2026-07-31T07:17:18.660575", + "update_at": "2026-07-31T07:17:18.660579" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lu7xr-6d7o", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 29, + "create_at": "2026-07-31T07:12:06.841252", + "update_at": "2026-07-31T07:12:06.841255" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lr4x8-3fsn", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 28, + "create_at": "2026-07-31T07:09:42.968827", + "update_at": "2026-07-31T07:09:42.968831" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lhiv8-orai", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 27, + "create_at": "2026-07-31T07:02:14.483860", + "update_at": "2026-07-31T07:02:14.483867" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8kr027-1reg", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 26, + "create_at": "2026-07-31T06:41:37.045079", + "update_at": "2026-07-31T06:41:37.045082" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8kozxq-ik0z", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 25, + "create_at": "2026-07-31T06:40:03.578998", + "update_at": "2026-07-31T06:40:03.579002" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "verify-1785479517", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 24, + "create_at": "2026-07-31T06:31:57.337685", + "update_at": "2026-07-31T06:31:57.337693" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8jx4it-utjl", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 23, + "create_at": "2026-07-31T06:18:23.164354", + "update_at": "2026-07-31T06:18:23.164357" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8js6gp-343c", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 22, + "create_at": "2026-07-31T06:14:32.390674", + "update_at": "2026-07-31T06:14:32.390678" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8jkc4h-l12v", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 21, + "create_at": "2026-07-31T06:08:26.480760", + "update_at": "2026-07-31T06:08:26.480765" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8j3vnp-ofrw", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 20, + "create_at": "2026-07-31T05:55:38.646664", + "update_at": "2026-07-31T05:55:38.646668" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "e2e-test-1785477031", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 19, + "create_at": "2026-07-31T05:50:31.377093", + "update_at": "2026-07-31T05:50:31.377096" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一只小狗-ms8iwqj7-rvuz", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 18, + "create_at": "2026-07-31T05:50:05.405282", + "update_at": "2026-07-31T05:50:05.405289" + } + ], + "total": 37, + "page": 1, + "page_size": 20 +} diff --git a/frontend/src/app/__fixtures__/task-70.json b/frontend/src/app/__fixtures__/task-70.json new file mode 100644 index 0000000..4e96831 --- /dev/null +++ b/frontend/src/app/__fixtures__/task-70.json @@ -0,0 +1,29 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 70, + "user_id": 1, + "project_id": 37, + "task_type": "character_image", + "status": "completed", + "input_payload": { + "reference_image_url": null, + "prompt": "会画画的猪八戒,侧视像素风,手持画笔,在画板前", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 4 + }, + "result": { + "type": "character_image", + "image_urls": [ + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/a5039393515b4001a04620cc99faaa2d.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/429643d53e324aa5886eb42c908c028f.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3d1c26ed7d634ef3b36ca5d6ef7b6b1c.png" + ] + }, + "error_message": null + } +} diff --git a/frontend/src/app/__fixtures__/task-71.json b/frontend/src/app/__fixtures__/task-71.json new file mode 100644 index 0000000..c4cc816 --- /dev/null +++ b/frontend/src/app/__fixtures__/task-71.json @@ -0,0 +1,109 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 71, + "user_id": 1, + "project_id": 37, + "task_type": "character_action", + "status": "completed", + "input_payload": { + "character_id": 25, + "action_type": "custom", + "custom_prompt": "在画板上用画笔作画", + "reference_video_url": null, + "reference_image_urls": [ + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png" + ], + "num_frames": 16 + }, + "result": { + "type": "character_action", + "action_type": "custom", + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + }, + "error_message": null + } +} diff --git a/frontend/src/app/api-contract.test.ts b/frontend/src/app/api-contract.test.ts new file mode 100644 index 0000000..b3a3b7f --- /dev/null +++ b/frontend/src/app/api-contract.test.ts @@ -0,0 +1,85 @@ +/** + * 前后端契约测试:用真实后端响应快照(__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}`) + }), + 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.test.tsx b/frontend/src/app/app.test.tsx new file mode 100644 index 0000000..bc207e1 --- /dev/null +++ b/frontend/src/app/app.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { App } from './app' + +afterEach(() => { + cleanup() + window.history.replaceState({}, '', '/') +}) + +describe('App', () => { + it('keeps the new-project route ahead of the dynamic project detail route', () => { + window.history.replaceState({}, '', '/projects/new') + + render() + + expect(screen.getByRole('heading', { name: '新建项目' })).toBeTruthy() + }) + + it('将项目完成版本的入口路由到历史记录', () => { + window.history.replaceState({}, '', '/projects/project-1/history') + + render() + + expect(screen.getByRole('heading', { name: '历史记录' })).toBeTruthy() + }) + + it('keeps the asset library separate from workflow history', () => { + window.history.replaceState({}, '', '/projects/project-1/assets') + + render() + + expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull() + }) +}) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index b46ae87..1e22871 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,33 +1,119 @@ +import { useMemo } from 'react' import { BrowserRouter, Route, Routes } from 'react-router' +import { + createCharacterApis, + createGenerationApis, + createProjectApis, + createWorkflowRunStore, +} from '@/entities' +import { createWorkflowController } from '@/features/workflow-controller' import { AssetLibraryPage } from '@/pages/asset-library' import { HomePage } from '@/pages/home' +import { HistoryPage } from '@/pages/history' import { NotFoundPage } from '@/pages/not-found' +import { PlaytestDemoPage } from '@/pages/playtest/demo-page' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' import { AppShell } from './layout' +import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick-start/service' +import { createWorkflowEditorService } from '@/pages/workflow-editor/service' + +function PlaytestFromBackend() { + const apis = useMemo(() => ({ characters: createCharacterApis() }), []) + return +} /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 */ export function App() { + const services = useMemo(() => { + const projectApis = createProjectApis() + const characterApis = createCharacterApis() + const generationApis = createGenerationApis() + const store = createWorkflowRunStore() + const controller = createWorkflowController({ store, generationApis }) + const quickStart = createQuickStartService({ + controller, + prepareProject: createAutoPrepareProject(projectApis), + characterApis, + generationApis, + }) + const workflowEditor = createWorkflowEditorService({ + controller, + confirmCandidate: (runId, selectedImageUrl) => + quickStart.confirmCandidate(runId, selectedImageUrl), + getProject: (projectId) => projectApis.get(projectId), + approveReview: (runId) => quickStart.approveReview(runId), + 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 } + }, + }) + return { projectApis, characterApis, quickStart, workflowEditor, store } + }, []) + return ( } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } + /> + } /> + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + } /> } /> diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx new file mode 100644 index 0000000..b3d4ce8 --- /dev/null +++ b/frontend/src/app/layout/app-header.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppHeader } from './app-header' + +afterEach(cleanup) + +describe('AppHeader', () => { + it('保留三个产品入口,并将工作流路由归入创作', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') + expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page') + }) +}) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx new file mode 100644 index 0000000..e189a2a --- /dev/null +++ b/frontend/src/app/layout/app-header.tsx @@ -0,0 +1,97 @@ +import { Link, useLocation } from 'react-router' + +interface ProductNavigationItem { + to: string + label: string + compactLabel?: string + isActive: (pathname: string) => boolean +} + +const productNavigation: ProductNavigationItem[] = [ + { + to: '/', + label: '首页', + isActive: (pathname) => pathname === '/', + }, + { + to: '/projects', + label: '项目', + isActive: (pathname) => pathname.startsWith('/projects') || pathname.startsWith('/playtest'), + }, + { + to: '/quick-start', + label: '创作', + isActive: (pathname) => + pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor'), + }, +] + +function getWorkspaceLabel(pathname: string): { title: string; detail: string } { + if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) { + return { title: '项目与历史记录', detail: '角色、动作与完成版本' } + } + + if (pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor')) { + return { title: '创作工作流', detail: '设定、生成与审核' } + } + + return { title: '角色资产工作台', detail: 'Windup' } +} + +/** 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。 */ +export function AppHeader() { + const { pathname } = useLocation() + const workspace = getWorkspaceLabel(pathname) + + return ( +
+
+ + + Windup + + + + {workspace.title} + {workspace.detail} + +
+ + +
+ ) +} diff --git a/frontend/src/app/layout/index.test.tsx b/frontend/src/app/layout/index.test.tsx new file mode 100644 index 0000000..e6e356b --- /dev/null +++ b/frontend/src/app/layout/index.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppShell } from './index' + +afterEach(cleanup) + +describe('AppShell', () => { + it.each([ + ['/', '首页'], + ['/playtest/demo', 'Playtest'], + ['/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') + }) +}) diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index aa21b82..583f86e 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 } from 'react-router' +import { useLocation } from 'react-router' + +import { AppHeader } from './app-header' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -10,20 +12,26 @@ export interface AppShellProps { /** 全站外壳,全局导航常驻。 */ export function AppShell({ children }: AppShellProps) { + const { pathname } = useLocation() + const isPlaytestWorkspace = pathname.startsWith('/playtest/') + const isWorkflowWorkspace = pathname.startsWith('/workflow-editor') + const isHomePage = pathname === '/' + return (
- -
{children}
+ {/* workflow-editor 有自己的 studio-bar,不显示全局 header */} + {!isWorkflowWorkspace && } +
+ {children} +
) } diff --git a/frontend/src/architecture-boundaries.test.ts b/frontend/src/architecture-boundaries.test.ts new file mode 100644 index 0000000..6551173 --- /dev/null +++ b/frontend/src/architecture-boundaries.test.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SRC = join(process.cwd(), 'src') + +function sourceFiles(root: string): string[] { + return readdirSync(root).flatMap((name) => { + const path = join(root, name) + return statSync(path).isDirectory() ? sourceFiles(path) : /\.(ts|tsx)$/.test(name) ? [path] : [] + }) +} + +describe('frontend architecture boundaries', () => { + it('keeps removed duplicate models and page-local workflow state out of the tree', () => { + const removed = [ + 'app/adapters/index.ts', + 'entities/task/index.ts', + 'entities/action-template/index.ts', + 'pages/workflow-editor/state.ts', + 'pages/workflow-editor/types.ts', + ] + + expect(removed.filter((path) => existsSync(join(SRC, path)))).toEqual([]) + }) + + it('keeps shared independent from Windup business layers', () => { + const violations = sourceFiles(join(SRC, 'shared')).filter((path) => { + const source = readFileSync(path, 'utf8') + return /from ['"]@\/(app|pages|features|entities)\b/.test(source) + }) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) + + it('keeps transport calls behind shared api and entity adapters', () => { + const violations = sourceFiles(join(SRC, 'pages')).filter((path) => + /\bfetch\s*\(/.test(readFileSync(path, 'utf8')), + ) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) + + it('keeps reusable features independent from page implementations', () => { + const violations = sourceFiles(join(SRC, 'features')).filter((path) => + /from ['"]@\/pages\b/.test(readFileSync(path, 'utf8')), + ) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) +}) diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts deleted file mode 100644 index 2dafa39..0000000 --- a/frontend/src/entities/action-template/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -interface ActionTemplateBase { - id: string - name: string - prompt: string -} - -/** 系统内置模板没有项目归属;项目自定义模板必须携带所属 Project ID。 */ -export type ActionTemplate = ActionTemplateBase & - ({ scope: 'system'; projectId: null } | { scope: 'project'; projectId: string }) - -/** ActionTemplate 对应的一组后端接口。 */ -export interface ActionTemplateApis { - listAvailable(projectId: string): Promise -} diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts new file mode 100644 index 0000000..23eec25 --- /dev/null +++ b/frontend/src/entities/character/api.ts @@ -0,0 +1,160 @@ +import type { + Action, + ActionType, + Character, + CharacterApis, + CreateCharacterInput, + Frame, + Outfit, +} from '.' + +import { get, patch, post } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendFrame { + index: number + image_url: string + duration_ms: 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 + description: string | null + reference_image_url: string | null + character_data: BackendCharacterData + status: number +} + +/* ─── 映射 ─── */ + +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: null, // 后端不提供根位移 + } +} + +function toAction(raw: BackendAction, outfitId: string): Action { + return { + id: raw.id, + outfitId, + name: raw.name, + 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, + 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), + createdAt: '', // 后端列表不返回时间戳 + updatedAt: '', + outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)), + } +} + +/* ─── 适配器 ─── */ + +export function createCharacterApis(): CharacterApis { + return { + async get(id: string): Promise { + const raw = await get(`/characters/${id}`) + return toCharacter(raw) + }, + + async listByProject(projectId: string): Promise { + // http-client 已解包 ApiEnvelope,data 字段就是角色数组本身 + const raw = await get( + `/characters?project_id=${encodeURIComponent(projectId)}`, + ) + return raw.map(toCharacter) + }, + + async create(input: CreateCharacterInput): Promise { + const raw = await post('/characters', { + project_id: Number(input.projectId), + description: input.description, + reference_image_url: input.referenceImageUrl ?? null, + }) + return toCharacter(raw) + }, + + async update(character: Character): Promise { + const payload = { + character_data: { + version: 1, + outfits: character.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: null, + preview_url: outfit.characterTemplateUrl, + actions: outfit.actions.map((action) => ({ + id: action.id, + type: action.type, + name: action.name, + loop: false, + fps: action.fps, + frame_count: 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) + return toCharacter(raw) + }, + } +} diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 616b5db..fda981c 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -105,8 +105,8 @@ export interface Outfit { /** * 项目下的角色资产;造型拥有各自的母版和动作帧。 * - * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 - * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + * 这棵树只承载已完成版本的确认内容,因此其中的动作一律不带生成过程状态。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里;系统质检通过后,完成版本会写入历史记录。 */ export interface Character { id: string diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts new file mode 100644 index 0000000..3507c2f --- /dev/null +++ b/frontend/src/entities/generation/api.ts @@ -0,0 +1,201 @@ +import type { + Generation, + GenerationApis, + GenerationEvent, + GenerationInput, + GenerationType, +} from '.' + +import { get, post } from '@/shared/api' + +/* ─── 后端 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 toGenerationEvent(raw: BackendGenerationTask): GenerationEvent { + const generation = toGeneration(raw) + return { + taskId: generation.id, + type: generation.type, + status: generation.status, + result: generation.result, + error: generation.error, + } +} + +/* ─── 输入 → 后端请求体 ─── */ + +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: 16, + } +} + +/* ─── 适配器 ─── */ + +const GENERATION_ENDPOINTS: Record = { + character_template: '/generation/image', + first_frame: '/generation/action', + complete_animation: '/generation/action', +} + +const POLL_INTERVAL_MS = 2000 + +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) { + let active = true + let timer: ReturnType | null = null + + const poll = async () => { + if (!active) return + try { + const raw = await get( + `/generation/tasks/${id}?project_id=${encodeURIComponent(projectId)}`, + ) + if (!active) return + onEvent(toGenerationEvent(raw)) + if (raw.status === 'completed' || raw.status === 'failed') return + } catch { + // 短暂网络失败时保留恢复能力,下一轮继续读取同一 Generation。 + } + if (active) timer = setTimeout(poll, POLL_INTERVAL_MS) + } + + void poll() + return () => { + active = false + if (timer !== null) clearTimeout(timer) + } + }, + } +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d..e7cca6d 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -4,26 +4,14 @@ 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' +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ @@ -35,6 +23,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ @@ -74,15 +66,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 +159,48 @@ 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, ): () => 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 203359d..e903dee 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,17 +1,17 @@ /** * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * 外部只从这里使用实体契约与已经落地的实体能力。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ 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,11 +29,11 @@ export type { FrameRootMotion, Outfit, } from './character' +export { createCharacterApis } from './character/api' -/* 动作模板 —— 能跨角色复用的配方 */ -export type { ActionTemplate, ActionTemplateApis } from './action-template' - -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { parseCharacterTemplateGenerationResult } from './generation' +export { createGenerationApis } from './generation/api' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -41,6 +41,7 @@ export type { CompleteAnimationGenerationResult, FirstFrameGenerationInput, FirstFrameGenerationResult, + GeneratedAnimationFrame, GeneratedImage, Generation, GenerationApis, @@ -48,16 +49,22 @@ export type { 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' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, + ActionGenerationWorkflowStep, + CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, GenerationStatus, @@ -68,6 +75,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/media/api.ts b/frontend/src/entities/media/api.ts new file mode 100644 index 0000000..a7624f1 --- /dev/null +++ b/frontend/src/entities/media/api.ts @@ -0,0 +1,28 @@ +import type { MediaApis, MediaCategory, MediaReference } from '.' + +import { upload as uploadRequest } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendMediaUpload { + url: string + object_key: string + filename: string + content_type: string + size: number +} + +/* ─── 适配器 ─── */ + +export function createMediaApis(): MediaApis { + return { + async upload(file: File, category: MediaCategory = 'general'): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('category', category) + + const result = await uploadRequest('/media/upload', formData) + return result.url as MediaReference + }, + } +} diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts index 347e626..cd8f854 100644 --- a/frontend/src/entities/media/index.ts +++ b/frontend/src/entities/media/index.ts @@ -7,3 +7,11 @@ declare const mediaReferenceBrand: unique symbol export type MediaReference = string & { readonly [mediaReferenceBrand]: 'MediaReference' } + +/** 上传媒体时的业务用途;后端据此选择存储目录和校验规则。 */ +export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general' + +/** 媒体实体对应的后端能力。 */ +export interface MediaApis { + upload(file: File, category?: MediaCategory): Promise +} diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts new file mode 100644 index 0000000..262a044 --- /dev/null +++ b/frontend/src/entities/project/api.ts @@ -0,0 +1,110 @@ +import type { CreateProjectInput, Project, ProjectApis } from '.' +import type { Paged, PageQuery } from '@/shared/pagination' + +import { del, get, 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 PERSPECTIVE_REVERSE: Record = { + side: 1, + 'top-down': 2, + isometric: 3, +} + +const MOVEMENT_MAP: Record = { + 1: 'single', + 2: 'four-way', + 3: 'eight-way', +} + +const MOVEMENT_REVERSE: Record = { + single: 1, + 'four-way': 2, + 'eight-way': 3, +} + +function toProject(raw: BackendProject): Project { + return { + id: String(raw.id), + ownerId: String(raw.user_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 toCreatePayload(input: CreateProjectInput) { + return { + user_id: 1, // TODO: 接入认证后替换为实际用户 ID + project_name: input.name, + character_perspective: PERSPECTIVE_REVERSE[input.perspective], + directional_movement: MOVEMENT_REVERSE[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?: PageQuery): Promise> { + const params = new URLSearchParams() + if (query?.page) params.set('page', String(query.page)) + if (query?.pageSize) params.set('page_size', String(query.pageSize)) + const qs = params.toString() + // http-client 已解包 ApiEnvelope,data 字段就是项目数组本身 + const raw = await get(`/projects${qs ? `?${qs}` : ''}`) + return { + items: raw.map(toProject), + total: raw.length, + page: query?.page ?? 1, + pageSize: query?.pageSize ?? raw.length, + } + }, + + async get(id: string): Promise { + const raw = await get(`/projects/${id}`) + return toProject(raw) + }, + + async create(input: CreateProjectInput): Promise { + const raw = await post('/projects', toCreatePayload(input)) + return toProject(raw) + }, + + async remove(id: string): Promise { + await del(`/projects/${id}`) + }, + } +} diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 4c71164..07c0c17 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -35,16 +35,6 @@ 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 type CharacterPerspective = 'side' | 'top-down' | 'isometric' @@ -77,6 +67,5 @@ export interface ProjectApis { list(query?: PageQuery): 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/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 0000000..2507ad3 --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,16 @@ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] 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_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 当前产品工作流的固定五步,也是存储校验与进度 UI 的唯一顺序来源。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b..235e57a 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,26 +1,34 @@ -import type { Generation } from '../generation' +import type { + Generation, + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { WORKFLOW_STEP_ORDER } from './constants' /** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] /** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] /** * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 */ -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] @@ -28,63 +36,103 @@ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] * 步骤的可用性和执行结果;不直接复用后端任务状态。 * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] /** * 单个版本的生命周期。 * abandoned 表示停止沿用但仍保留为历史。 */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] /** * 整次流程的汇总状态。 * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * 后端 Generation 是否真正停止是独立问题;前端中断只停止自动推进与订阅。 */ -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 WorkflowStepBase { /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowStepType status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 + * 任务本身不认识步骤,反向关联不存在。 */ taskId: Generation['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, + * Controller 会把本地 Run 标为失败。它不是后端字段,也不冒充幂等键。 + */ + submissionId: string | null + /** 步骤失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } +/** 角色资料步骤保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupStepInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowStep extends WorkflowStepBase { + type: 'character-setup' + input: CharacterSetupStepInput | null + output: null +} + +export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterTemplateGenerationInput | null + output: CharacterTemplateGenerationResult | null +} + +export interface ActionGenerationWorkflowStep extends WorkflowStepBase { + type: 'action-generation' + input: CompleteAnimationGenerationInput | null + output: CompleteAnimationGenerationResult | null +} + +type RemainingWorkflowStepType = Exclude< + WorkflowStepType, + 'character-setup' | 'character-template' | 'action-generation' +> + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选确认与审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;后续三步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | ActionGenerationWorkflowStep + | RemainingWorkflowStep + /** * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 从历史步骤重开时,旧 Revision 保留为只读记录,新 Revision 引用它的重开步骤。 + * 新执行线中的下游步骤会清空并重新锁定,不能作为新生成的参考依据。 */ export interface WorkflowRevision { id: string @@ -94,8 +142,8 @@ export interface WorkflowRevision { restartStepId: string | null status: WorkflowRevisionStatus /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + * 当前版本固定保存全部五步;数组位置是步骤顺序的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 */ steps: WorkflowStep[] generationStatus: GenerationStatus @@ -155,3 +203,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 0000000..91e4c78 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_STEP_ORDER } from './constants' +import type { WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem(): string | null { + return this.value + } + + setItem(_key: string, value: string): void { + if (this.failOnSet) throw new Error('storage unavailable') + this.value = value + } +} + +function createSteps(): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => { + const common = { + id: `revision-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + 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 WorkflowStep + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-30T12:00:00.000Z', + }, + ], + prompt: 'Create a slime', + } +} + +function createLegacyRun(): unknown { + const run = createRun() + const revision = run.revisions[0] + if (!revision) throw new Error('Expected a revision') + + const [characterSetup, characterTemplate, templateCandidate, actionGeneration, review] = + revision.steps + if (!characterSetup || !characterTemplate || !templateCandidate || !actionGeneration || !review) { + throw new Error('Expected the five current workflow steps') + } + + return { + ...run, + revisions: [ + { + ...revision, + steps: [ + characterSetup, + characterTemplate, + templateCandidate, + { ...actionGeneration, id: 'revision-1:action-setup', type: 'action-setup' }, + { ...actionGeneration, id: 'revision-1:first-frame', type: 'first-frame' }, + { ...actionGeneration, id: 'revision-1:complete-animation', type: 'complete-animation' }, + review, + { ...review, id: 'revision-1:export', type: 'export' }, + ], + }, + ], + } +} + +function createRestartedRun(): WorkflowRun { + const source = createRun() + const sourceRevision = source.revisions[0]! + const sourceSteps = sourceRevision.steps.map((step) => + step.type === 'character-setup' || step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ) + const restartedSteps = sourceSteps.map((step, index) => { + const common = { + ...step, + id: `revision-2:${step.type}`, + taskId: null, + submissionId: null, + error: null, + } + if (index === 0) return { ...common, status: 'passed' as const, referenceStepIds: [step.id] } + if (index === 1) { + return { + ...common, + status: 'active' as const, + output: null, + referenceStepIds: [step.id], + } + } + return { ...common, status: 'locked' as const, input: null, output: null, referenceStepIds: [] } + }) as WorkflowStep[] + + return { + ...source, + currentRevisionId: 'revision-2', + revisions: [ + { ...sourceRevision, status: 'abandoned', steps: sourceSteps }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + status: 'active', + steps: restartedSteps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-31T03:00:00.000Z', + }, + ], + } +} + +describe('createWorkflowRunStore', () => { + it('lists cloned snapshots and notifies whole-store subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const listener = vi.fn() + const unsubscribe = store.subscribeAll(listener) + + store.save(createRun('run-1')) + store.save(createRun('run-2')) + + expect(store.list().map((run) => run.id)).toEqual(['run-1', 'run-2']) + expect(listener).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'run-1' }), + expect.objectContaining({ id: 'run-2' }), + ]) + unsubscribe() + store.save(createRun('run-3')) + expect(listener).toHaveBeenCalledTimes(2) + }) + it('stores a versioned snapshot and returns defensive clones', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const source = createRun() + + store.save(source) + source.prompt = 'mutated outside' + + const firstRead = store.get(source.id) + expect(firstRead?.prompt).toBe('Create a slime') + + firstRead!.revisions[0].steps[0].status = 'failed' + expect(store.get(source.id)?.revisions[0].steps[0].status).toBe('active') + + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates valid runs from localStorage', () => { + const run = createRun() + const storage = new TestStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [run], + }), + ) + + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + }) + + it('migrates version-three single-frame action output without dropping history', () => { + const run = createRun() + const revision = run.revisions[0]! + revision.steps = revision.steps.map((step) => { + if (step.type === 'character-setup') return { ...step, status: 'passed' } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed', + output: { type: 'character_template', images: [{ url: 'template.png' }] }, + } + } + if (step.type === 'template-candidate') return { ...step, status: 'passed' } + if (step.type === 'action-generation') { + return { + ...step, + status: 'passed', + input: { + type: 'complete_animation', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionType: 'idle', + firstFrameUrl: 'template.png', + prompt: null, + referenceMedia: ['template.png'], + }, + output: { type: 'first_frame', image: { url: 'frame.png' } }, + } as unknown as WorkflowStep + } + return { ...step, status: 'active' } + }) + const storage = new TestStorage(JSON.stringify({ version: 3, runs: [run] })) + + const restored = createWorkflowRunStore({ storage }).get(run.id) + const action = restored?.revisions[0]?.steps.find((step) => step.type === 'action-generation') + + expect(action?.output).toEqual({ + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'frame.png', durationMs: null }], + }) + }) + + it('migrates a version-one run to the fixed five-step model', () => { + const store = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ + version: 1, + runs: [createLegacyRun()], + }), + ), + }) + + expect(store.get('run-1')?.revisions[0]?.steps.map((step) => step.type)).toEqual([ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', + ]) + expect(store.get('run-1')?.revisions[0]?.exportStatus).toBe('not_exported') + }) + + it('migrates version-two runs and restores their restart history', () => { + const legacyRun = createRun() + const versionTwoStore = createWorkflowRunStore({ + storage: new TestStorage(JSON.stringify({ version: 2, runs: [legacyRun] })), + }) + const historyStore = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [createRestartedRun()] }), + ), + }) + + expect(versionTwoStore.get('run-1')).toEqual(legacyRun) + expect(historyStore.get('run-1')).toMatchObject({ + currentRevisionId: 'revision-2', + revisions: [ + { id: 'revision-1', status: 'abandoned' }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }, + ], + }) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], + [ + 'invalid run', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), currentRevisionId: 'missing-revision' }], + }), + ], + [ + 'inconsistent run status', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), status: 'failed' }], + }), + ], + [ + 'orphaned restart revision', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRestartedRun(), + revisions: [ + createRestartedRun().revisions[0], + { ...createRestartedRun().revisions[1], basedOnRevisionId: 'missing-revision' }, + ], + }, + ], + }), + ], + ])('ignores %s in localStorage', (_label, serialized) => { + const store = createWorkflowRunStore({ storage: new TestStorage(serialized) }) + + expect(store.get('run-1')).toBeNull() + }) + + it('keeps the memory snapshot and notifies subscribers when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + const listener = vi.fn() + const run = createRun() + + store.subscribe(run.id, listener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(listener).toHaveBeenCalledWith(run) + }) + + it('isolates subscriber values and stops notifications after unsubscribe', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + const unsubscribeFirst = store.subscribe(run.id, (savedRun) => { + savedRun.prompt = 'mutated by first listener' + }) + const unsubscribeSecond = store.subscribe(run.id, secondListener) + + store.save(run) + + expect(secondListener).toHaveBeenLastCalledWith(run) + expect(store.get(run.id)).toEqual(run) + + unsubscribeFirst() + unsubscribeSecond() + store.save({ ...run, prompt: 'new prompt' }) + + expect(secondListener).toHaveBeenCalledTimes(1) + }) + + it('does not let one failing subscriber block the saved state or other subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + + store.subscribe(run.id, () => { + throw new Error('render failed') + }) + store.subscribe(run.id, secondListener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(secondListener).toHaveBeenCalledWith(run) + }) + + it('uses the stable storage key by default', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ + storage: { + getItem: vi.fn(() => null), + setItem, + }, + }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 0000000..b85e124 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,493 @@ +import type { WorkflowRun } from './index' +import { + parseCharacterTemplateGenerationResult, + parseCompleteAnimationGenerationResult, +} from '../generation' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 4 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** + * 传 null 可显式创建仅内存存储;不传时在浏览器中使用 localStorage。 + * 该入口也让纯逻辑测试无需模拟完整 DOM。 + */ + storage?: WorkflowRunStorage | null +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isWorkflowStep(value: unknown): boolean { + if (!isRecord(value)) return false + + const commonFieldsAreValid = + typeof value.id === 'string' && + isMember(value.type, WORKFLOW_STEP_ORDER) && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + isStringArray(value.referenceStepIds) && + 'input' in value && + 'output' in value + if (!commonFieldsAreValid) return false + const error = value.error + if (!isNullableString(error)) return false + if ( + (value.status === 'failed' && (error === null || error.trim().length === 0)) || + (value.status !== 'failed' && error !== null) + ) { + return false + } + + if (value.type === 'character-setup') { + return ( + value.output === null && + (value.input === null || + (isRecord(value.input) && + typeof value.input.description === 'string' && + isStringArray(value.input.referenceMedia))) + ) + } + if (value.type === 'character-template') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'character_template' && + typeof value.input.projectId === 'string' && + typeof value.input.prompt === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCharacterTemplateGenerationResult(value.output) !== null) + ) + } + if (value.type === 'action-generation') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'complete_animation' && + typeof value.input.projectId === 'string' && + typeof value.input.characterId === 'string' && + typeof value.input.outfitId === 'string' && + typeof value.input.firstFrameUrl === 'string' && + typeof value.input.actionType === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCompleteAnimationGenerationResult(value.output) !== null) + ) + } + return true +} + +function isWorkflowRevision(value: unknown): boolean { + if (!isRecord(value)) return false + + return ( + typeof value.id === 'string' && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + Array.isArray(value.steps) && + value.steps.length === WORKFLOW_STEP_ORDER.length && + value.steps.every( + (step, index) => + isWorkflowStep(step) && isRecord(step) && step.type === WORKFLOW_STEP_ORDER[index], + ) && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions)) return false + + const fieldsAreValid = + typeof value.id === 'string' && + typeof value.projectId === 'string' && + isNullableString(value.characterId) && + isNullableString(value.outfitId) && + isMember(value.purpose, WORKFLOW_PURPOSES) && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + typeof value.currentRevisionId === 'string' && + value.revisions.length > 0 && + value.revisions.every(isWorkflowRevision) && + value.revisions.some( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) && + isNullableString(value.prompt) + if (!fieldsAreValid) return false + + const currentRevision = value.revisions.find( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) + if (!isRecord(currentRevision) || !Array.isArray(currentRevision.steps)) return false + if (!hasValidRevisionLine(value.revisions)) return false + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (currentRevision.status !== expectedRevisionStatus) return false + + const activeStepCount = currentRevision.steps.filter( + (step) => isRecord(step) && step.status === 'active', + ).length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return value.revisions.every( + (revision) => + isRecord(revision) && + Array.isArray(revision.steps) && + revision.steps.every((step) => { + if (!isRecord(step)) return false + const taskId = step.taskId + const submissionId = step.submissionId + if (taskId !== null && submissionId !== null) return false + if (taskId === null && submissionId === null) return true + // 只有 character-template 与 action-generation 允许在 active 步骤上 + // 持有任务 ID(角色图 / 动作生成任务,刷新后可恢复轮询) + return ( + (step.type === 'character-template' || step.type === 'action-generation') && + step.status === 'active' + ) + }), + ) +} + +function hasValidRevisionLine(revisions: unknown[]): boolean { + const seenRevisionIds = new Set() + const byId = new Map>() + + for (const [index, revision] of revisions.entries()) { + if ( + !isRecord(revision) || + typeof revision.id !== 'string' || + seenRevisionIds.has(revision.id) + ) { + return false + } + seenRevisionIds.add(revision.id) + + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if ( + typeof revision.basedOnRevisionId !== 'string' || + typeof revision.restartStepId !== 'string' + ) { + return false + } + const source = byId.get(revision.basedOnRevisionId) + if ( + !source || + !Array.isArray(source.steps) || + !source.steps.some( + (step) => + isRecord(step) && step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + + byId.set(revision.id, revision) + } + + return true +} + +function migrateVersionOneRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + + const revisions: unknown[] = [] + for (const revision of value.revisions) { + const migratedRevision = migrateVersionOneRevision(revision) + if (!migratedRevision) return null + revisions.push(migratedRevision) + } + + const migrated = { ...value, revisions } + return migrateVersionThreeRun(migrated) +} + +function migrateVersionTwoRun(value: unknown): WorkflowRun | null { + return migrateVersionThreeRun(value) +} + +function migrateVersionThreeRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + const revisions = value.revisions.map((revision) => { + if (!isRecord(revision) || !Array.isArray(revision.steps)) return revision + return { + ...revision, + steps: revision.steps.map((step) => { + if (!isRecord(step) || step.type !== 'action-generation' || !isRecord(step.output)) { + return step + } + if (step.output.type !== 'first_frame' || !isRecord(step.output.image)) return step + const url = step.output.image.url + if (typeof url !== 'string' || !url) return step + const actionType = + isRecord(step.input) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(step.input.actionType)) + ? step.input.actionType + : 'custom' + return { + ...step, + output: { + type: 'complete_animation', + actionType, + frames: [{ url, durationMs: null }], + }, + } + }), + } + }) + const migrated = { ...value, revisions } + return isWorkflowRun(migrated) ? migrated : null +} + +function migrateVersionOneRevision(value: unknown): Record | null { + if (!isRecord(value) || typeof value.id !== 'string' || !Array.isArray(value.steps)) return null + + const steps = value.steps + const legacyOrder = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', + ] as const + if ( + steps.length !== legacyOrder.length || + !steps.every((step, index) => isRecord(step) && step.type === legacyOrder[index]) + ) { + return null + } + + const [ + characterSetup, + characterTemplate, + templateCandidate, + actionSetup, + firstFrame, + animation, + review, + ] = steps + if ( + !isRecord(characterSetup) || + !isRecord(characterTemplate) || + !isRecord(templateCandidate) || + !isRecord(actionSetup) || + !isRecord(firstFrame) || + !isRecord(animation) || + !isRecord(review) + ) { + return null + } + + const actionSteps = [actionSetup, firstFrame, animation] + const collapsedAction = + actionSteps.find((step) => step.status === 'active') ?? + actionSteps.find((step) => step.status === 'failed') ?? + (actionSteps.every((step) => step.status === 'passed') ? animation : actionSetup) + const actionStepId = `${value.id}:action-generation` + const legacyActionIds = new Set( + actionSteps.map((step) => step.id).filter((id): id is string => typeof id === 'string'), + ) + + function migrateReferences(step: Record): Record { + const referenceStepIds = Array.isArray(step.referenceStepIds) + ? step.referenceStepIds.map((id) => (legacyActionIds.has(id) ? actionStepId : id)) + : step.referenceStepIds + return { ...step, referenceStepIds } + } + + return { + ...value, + steps: [ + migrateReferences(characterSetup), + migrateReferences(characterTemplate), + migrateReferences(templateCandidate), + { + ...migrateReferences(collapsedAction), + id: actionStepId, + type: 'action-generation', + }, + migrateReferences(review), + ], + } +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + + const persisted: unknown = JSON.parse(serialized) + if (!isRecord(persisted) || !Array.isArray(persisted.runs)) return [] + + if (persisted.version === WORKFLOW_RUN_STORAGE_VERSION) { + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } + + if (persisted.version === 1) { + return persisted.runs + .map(migrateVersionOneRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 2) { + return persisted.runs + .map(migrateVersionTwoRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 3) { + return persisted.runs + .map(migrateVersionThreeRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + return [] + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage + } catch { + return null + } +} + +/** + * WorkflowRun 的内存快照是当前会话的权威状态,localStorage 只负责刷新恢复。 + * 因此 save 先更新内存;浏览器拒绝写入时,本次运行仍能继续读取和订阅。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : structuredClone(run) + }, + + list() { + return [...runs.values()].map((run) => structuredClone(run)) + }, + + save(run) { + const savedRun = structuredClone(run) + runs.set(savedRun.id, savedRun) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 内存已成功更新;持久化失败不能中断当前会话中的工作流。 + } + + for (const listener of listeners.get(savedRun.id) ?? []) { + try { + listener(structuredClone(savedRun)) + } catch { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + const snapshot = [...runs.values()].map((run) => structuredClone(run)) + for (const listener of listListeners) { + try { + listener(snapshot.map((run) => structuredClone(run))) + } catch { + // 一个列表订阅方失败不能阻断其他页面刷新。 + } + } + }, + + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } +} 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 0000000..60a05ec --- /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 44c9a7b..13827b1 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/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts new file mode 100644 index 0000000..d9cfeb8 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -0,0 +1,181 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from 'vitest' + +import type { + ExportAction as PreviewAction, + ExportFrame as PreviewFrame, + ExportPackageModel as PlaytestPreviewModel, +} from './model' +import { createAssetExportPlan, exportGameAssets, type AssetExportRuntime } from './asset-export' + +function frame(index: number): PreviewFrame { + return { + imageUrl: `/frames/walk-${index}.png`, + durationMs: 100 + index, + rootMotion: { dx: index, dy: 0 }, + keyFrame: index === 0, + } +} + +function action(frameCount = 9): PreviewAction { + return { + id: 'walk-abcdef12', + name: 'Walk / Forward', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + frames: Array.from({ length: frameCount }, (_, index) => frame(index)), + }, + ], + } +} + +const model: PlaytestPreviewModel = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + actions: [action(), action(0)], +} + +async function readStoredZip(blob: Blob): Promise> { + const bytes = new Uint8Array(await blob.arrayBuffer()) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const decoder = new TextDecoder() + const entries = new Map() + let offset = 0 + + while (offset + 4 <= bytes.length && view.getUint32(offset, true) === 0x04034b50) { + const compressedSize = view.getUint32(offset + 18, true) + const nameLength = view.getUint16(offset + 26, true) + const extraLength = view.getUint16(offset + 28, true) + const nameStart = offset + 30 + const dataStart = nameStart + nameLength + extraLength + const name = decoder.decode(bytes.slice(nameStart, nameStart + nameLength)) + entries.set(name, bytes.slice(dataStart, dataStart + compressedSize)) + offset = dataStart + compressedSize + } + return entries +} + +function runtime(failingUrl: string | null = null): AssetExportRuntime { + return { + fetchFrame: vi.fn(async (url) => { + if (url === failingUrl) throw new Error('missing') + return new Blob([`original:${url}`], { type: 'image/png' }) + }), + decodeFrame: vi.fn(async (blob) => ({ + source: {} as CanvasImageSource, + width: blob.size % 2 === 0 ? 32 : 24, + height: 40, + close: vi.fn(), + })), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { + value: () => ({ clearRect: vi.fn(), drawImage: vi.fn() }), + }) + Object.defineProperty(canvas, 'toBlob', { + value: (callback: BlobCallback) => callback(new Blob(['sheet'], { type: 'image/png' })), + }) + return canvas + }), + } +} + +describe('asset export', () => { + it('plans non-empty sequences and wraps after eight columns', () => { + const plan = createAssetExportPlan(model) + + expect(plan).toHaveLength(1) + expect(plan[0]).toMatchObject({ + columns: 8, + rows: 2, + folder: 'actions/Walk-Forward-abcdef12/south', + }) + expect(plan[0]?.frames.map((item) => item.filename)).toEqual([ + '000.png', + '001.png', + '002.png', + '003.png', + '004.png', + '005.png', + '006.png', + '007.png', + '008.png', + ]) + }) + + it('creates a zip containing only original frames, a sprite sheet and animation json', async () => { + const phases: string[] = [] + const result = await exportGameAssets(model, runtime(), (phase) => phases.push(phase)) + const entries = await readStoredZip(result.blob) + const names = [...entries.keys()] + + expect(result).toMatchObject({ filename: 'windup-Aster-Explorer.zip', incomplete: false }) + expect(phases).toEqual(['collecting', 'rendering', 'packing']) + expect(names).toContain('actions/Walk-Forward-abcdef12/south/sprite-sheet.png') + expect(names).toContain('actions/Walk-Forward-abcdef12/south/animation.json') + expect(names.filter((name) => name.includes('/frames/'))).toHaveLength(9) + expect(names.some((name) => /manifest|audit/i.test(name))).toBe(false) + + const jsonBytes = entries.get('actions/Walk-Forward-abcdef12/south/animation.json') + const animation = JSON.parse(new TextDecoder().decode(jsonBytes)) + expect(animation.spriteSheet).toMatchObject({ columns: 8, rows: 2 }) + expect(animation.frames[0]).toMatchObject({ + index: 0, + source: 'frames/000.png', + available: true, + durationMs: 100, + keyFrame: true, + }) + }) + + it('keeps a transparent cell and marks a failed original frame unavailable', async () => { + const result = await exportGameAssets(model, runtime('/frames/walk-4.png')) + const entries = await readStoredZip(result.blob) + const animation = JSON.parse( + new TextDecoder().decode(entries.get('actions/Walk-Forward-abcdef12/south/animation.json')), + ) + + expect(result.incomplete).toBe(true) + expect(entries.has('actions/Walk-Forward-abcdef12/south/frames/004.png')).toBe(false) + expect(animation.frames[4]).toMatchObject({ + index: 4, + source: 'frames/004.png', + available: false, + }) + }) + + it('releases decoded images when sprite-sheet rendering fails', async () => { + const baseRuntime = runtime() + const closes: Array> = [] + const failingRuntime: AssetExportRuntime = { + ...baseRuntime, + decodeFrame: vi.fn(async () => { + const close = vi.fn() + closes.push(close) + return { source: {} as CanvasImageSource, width: 24, height: 40, close } + }), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { value: () => null }) + return canvas + }), + } + + await expect(exportGameAssets(model, failingRuntime)).rejects.toThrow( + '浏览器无法创建 Sprite Sheet', + ) + expect(closes).toHaveLength(9) + expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true) + }) +}) diff --git a/frontend/src/features/export-package/asset-export.ts b/frontend/src/features/export-package/asset-export.ts new file mode 100644 index 0000000..4310c6c --- /dev/null +++ b/frontend/src/features/export-package/asset-export.ts @@ -0,0 +1,366 @@ +import type { ExportAction, ExportFrame, ExportPackageModel, ExportSequence } from './model' + +export type AssetExportPhase = 'collecting' | 'rendering' | 'packing' + +export interface AssetExportResult { + blob: Blob + filename: string + incomplete: boolean +} + +export interface DecodedFrame { + source: CanvasImageSource + width: number + height: number + close(): void +} + +export interface AssetExportRuntime { + fetchFrame(url: string): Promise + decodeFrame(blob: Blob): Promise + createCanvas(width: number, height: number): HTMLCanvasElement +} + +export interface PlannedFrame { + frame: ExportFrame + index: number + filename: string +} + +export interface PlannedSequence { + action: ExportAction + sequence: ExportSequence + folder: string + columns: number + rows: number + frames: readonly PlannedFrame[] +} + +interface LoadedFrame extends PlannedFrame { + blob: Blob | null + decoded: DecodedFrame | null + sourceFilename: string +} + +interface ZipEntry { + name: string + data: Uint8Array +} + +function safeSegment(value: string, fallback: string): string { + const normalized = value + .normalize('NFKC') + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + return normalized || fallback +} + +function idSuffix(id: string): string { + return safeSegment(id, 'id').slice(-8) || 'id' +} + +function extensionFromUrl(imageUrl: string): string { + const match = imageUrl.split(/[?#]/, 1)[0]?.match(/\.([a-zA-Z0-9]{2,5})$/) + return match?.[1]?.toLowerCase() ?? 'png' +} + +function extensionForBlob(blob: Blob, imageUrl: string): string { + const byMime: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/avif': 'avif', + } + return byMime[blob.type.toLowerCase()] ?? extensionFromUrl(imageUrl) +} + +export function createAssetExportPlan(model: ExportPackageModel): readonly PlannedSequence[] { + return model.actions.flatMap((action) => { + const actionFolder = `${safeSegment(action.name, 'action')}-${idSuffix(action.id)}` + return action.sequences.flatMap((sequence) => { + if (sequence.frames.length === 0) return [] + const columns = Math.min(8, sequence.frames.length) + return [ + { + action, + sequence, + folder: `actions/${actionFolder}/${safeSegment(sequence.direction, 'default')}`, + columns, + rows: Math.ceil(sequence.frames.length / columns), + frames: sequence.frames.map((currentFrame, index) => ({ + frame: currentFrame, + index, + filename: `${String(index).padStart(3, '0')}.${extensionFromUrl(currentFrame.imageUrl)}`, + })), + }, + ] + }) + }) +} + +const defaultRuntime: AssetExportRuntime = { + async fetchFrame(url) { + const response = await fetch(url) + if (!response.ok) throw new Error(`图片读取失败:${response.status}`) + return response.blob() + }, + async decodeFrame(blob) { + const bitmap = await createImageBitmap(blob) + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + close: () => bitmap.close(), + } + }, + createCanvas(width, height) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas + }, +} + +function canvasPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob === null) reject(new Error('Sprite Sheet 编码失败')) + else resolve(blob) + }, 'image/png') + }) +} + +async function bytes(data: Blob | string): Promise { + if (typeof data === 'string') return new TextEncoder().encode(data) + return new Uint8Array(await data.arrayBuffer()) +} + +function uint32Table(): Uint32Array { + const table = new Uint32Array(256) + for (let value = 0; value < 256; value += 1) { + let current = value + for (let bit = 0; bit < 8; bit += 1) { + current = (current & 1) !== 0 ? 0xedb88320 ^ (current >>> 1) : current >>> 1 + } + table[value] = current >>> 0 + } + return table +} + +const CRC32_TABLE = uint32Table() + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff + for (const value of data) crc = (crc >>> 8) ^ (CRC32_TABLE[(crc ^ value) & 0xff] ?? 0) + return (crc ^ 0xffffffff) >>> 0 +} + +function dosDateTime(date: Date): { date: number; time: number } { + const year = Math.max(1980, date.getFullYear()) + return { + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + } +} + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + output.set(chunk, offset) + offset += chunk.length + } + return output +} + +function storedZip(entries: readonly ZipEntry[]): Blob { + const localChunks: Uint8Array[] = [] + const centralChunks: Uint8Array[] = [] + const encoder = new TextEncoder() + const timestamp = dosDateTime(new Date()) + let localOffset = 0 + + for (const entry of entries) { + const name = encoder.encode(entry.name) + const checksum = crc32(entry.data) + const local = new Uint8Array(30 + name.length) + const localView = new DataView(local.buffer) + localView.setUint32(0, 0x04034b50, true) + localView.setUint16(4, 20, true) + localView.setUint16(6, 0x0800, true) + localView.setUint16(8, 0, true) + localView.setUint16(10, timestamp.time, true) + localView.setUint16(12, timestamp.date, true) + localView.setUint32(14, checksum, true) + localView.setUint32(18, entry.data.length, true) + localView.setUint32(22, entry.data.length, true) + localView.setUint16(26, name.length, true) + local.set(name, 30) + localChunks.push(local, entry.data) + + const central = new Uint8Array(46 + name.length) + const centralView = new DataView(central.buffer) + centralView.setUint32(0, 0x02014b50, true) + centralView.setUint16(4, 20, true) + centralView.setUint16(6, 20, true) + centralView.setUint16(8, 0x0800, true) + centralView.setUint16(10, 0, true) + centralView.setUint16(12, timestamp.time, true) + centralView.setUint16(14, timestamp.date, true) + centralView.setUint32(16, checksum, true) + centralView.setUint32(20, entry.data.length, true) + centralView.setUint32(24, entry.data.length, true) + centralView.setUint16(28, name.length, true) + centralView.setUint32(42, localOffset, true) + central.set(name, 46) + centralChunks.push(central) + localOffset += local.length + entry.data.length + } + + const centralDirectory = concat(centralChunks) + const end = new Uint8Array(22) + const endView = new DataView(end.buffer) + endView.setUint32(0, 0x06054b50, true) + endView.setUint16(8, entries.length, true) + endView.setUint16(10, entries.length, true) + endView.setUint32(12, centralDirectory.length, true) + endView.setUint32(16, localOffset, true) + const output = concat([...localChunks, centralDirectory, end]) + const arrayBuffer = new ArrayBuffer(output.length) + new Uint8Array(arrayBuffer).set(output) + return new Blob([arrayBuffer], { type: 'application/zip' }) +} + +export async function exportGameAssets( + model: ExportPackageModel, + runtime: AssetExportRuntime = defaultRuntime, + onPhase?: (phase: AssetExportPhase) => void, +): Promise { + const plan = createAssetExportPlan(model) + onPhase?.('collecting') + const blobCache = new Map>() + let incomplete = false + + const loaded = await Promise.all( + plan.map(async (item) => ({ + item, + frames: await Promise.all( + item.frames.map(async (planned): Promise => { + try { + let pending = blobCache.get(planned.frame.imageUrl) + if (pending === undefined) { + pending = runtime.fetchFrame(planned.frame.imageUrl) + blobCache.set(planned.frame.imageUrl, pending) + } + const blob = await pending + const decoded = await runtime.decodeFrame(blob) + const extension = extensionForBlob(blob, planned.frame.imageUrl) + return { + ...planned, + blob, + decoded, + sourceFilename: `${String(planned.index).padStart(3, '0')}.${extension}`, + } + } catch { + incomplete = true + return { ...planned, blob: null, decoded: null, sourceFilename: planned.filename } + } + }), + ), + })), + ) + + onPhase?.('rendering') + const zipEntries: ZipEntry[] = [] + try { + for (const { item, frames } of loaded) { + const cellWidth = Math.max(1, ...frames.map((current) => current.decoded?.width ?? 0)) + const cellHeight = Math.max(1, ...frames.map((current) => current.decoded?.height ?? 0)) + const canvas = runtime.createCanvas(cellWidth * item.columns, cellHeight * item.rows) + const context = canvas.getContext('2d') + if (context === null) throw new Error('浏览器无法创建 Sprite Sheet') + context.clearRect(0, 0, canvas.width, canvas.height) + + const animationFrames = frames.map((current) => { + const column = current.index % item.columns + const row = Math.floor(current.index / item.columns) + const width = current.decoded?.width ?? 0 + const height = current.decoded?.height ?? 0 + const offsetX = Math.floor((cellWidth - width) / 2) + const offsetY = Math.floor((cellHeight - height) / 2) + if (current.decoded !== null) { + context.drawImage( + current.decoded.source, + column * cellWidth + offsetX, + row * cellHeight + offsetY, + ) + } + return { + index: current.index, + source: `frames/${current.sourceFilename}`, + available: current.blob !== null, + x: column * cellWidth + offsetX, + y: row * cellHeight + offsetY, + width, + height, + originalWidth: width, + originalHeight: height, + offsetX, + offsetY, + durationMs: current.frame.durationMs, + keyFrame: current.frame.keyFrame, + rootMotion: current.frame.rootMotion, + } + }) + + const sheet = await canvasPng(canvas) + zipEntries.push({ name: `${item.folder}/sprite-sheet.png`, data: await bytes(sheet) }) + for (const current of frames) { + if (current.blob === null) continue + zipEntries.push({ + name: `${item.folder}/frames/${current.sourceFilename}`, + data: await bytes(current.blob), + }) + } + zipEntries.push({ + name: `${item.folder}/animation.json`, + data: await bytes( + JSON.stringify( + { + schemaVersion: 1, + action: { + id: item.action.id, + name: item.action.name, + type: item.action.type, + }, + direction: item.sequence.direction, + fps: item.action.fps, + spriteSheet: { + file: 'sprite-sheet.png', + width: canvas.width, + height: canvas.height, + columns: item.columns, + rows: item.rows, + cellWidth, + cellHeight, + }, + frames: animationFrames, + }, + null, + 2, + ), + ), + }) + } + } finally { + loaded.forEach(({ frames }) => frames.forEach((current) => current.decoded?.close())) + } + + onPhase?.('packing') + return { + blob: storedZip(zipEntries), + filename: `windup-${safeSegment(model.characterName, 'character')}-${safeSegment(model.outfitName, 'outfit')}.zip`, + incomplete, + } +} diff --git a/frontend/src/features/export-package/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx new file mode 100644 index 0000000..56d07a4 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -0,0 +1,120 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel as PlaytestPreviewModel } from './model' +import { ExportPanel } from './export-panel' + +const model = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + actions: [ + { + id: 'walk-abcdef12', + name: 'Walk', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + frames: [ + { + imageUrl: '/walk.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + ], +} satisfies PlaytestPreviewModel + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel', () => { + it('warns about current quality issues without blocking export', () => { + render() + + expect(screen.getByText('当前核验存在 3 项质量问题,仍可导出')).toBeTruthy() + expect( + (screen.getByRole('button', { name: '导出游戏资产包' }) as HTMLButtonElement).disabled, + ).toBe(false) + }) + + it('shows progress, prevents duplicate export, downloads and revokes the object url', async () => { + let resolveExport: (value: { + blob: Blob + filename: string + incomplete: boolean + }) => void = () => { + throw new Error('export promise was not initialized') + } + const exporter = vi.fn( + ( + _model: PlaytestPreviewModel, + onPhase?: (phase: 'collecting' | 'rendering' | 'packing') => void, + ) => { + onPhase?.('rendering') + return new Promise<{ blob: Blob; filename: string; incomplete: boolean }>((resolve) => { + resolveExport = resolve + }) + }, + ) + const createObjectURL = vi.fn(() => 'blob:asset-package') + const revokeObjectURL = vi.fn() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + const button = screen.getByRole('button', { name: '导出游戏资产包' }) + fireEvent.click(button) + fireEvent.click(button) + expect(screen.getByText('正在生成图片')).toBeTruthy() + expect(exporter).toHaveBeenCalledTimes(1) + + resolveExport({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-Explorer.zip', + incomplete: false, + }) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:asset-package') + click.mockRestore() + }) + + it('warns for incomplete packages and allows retry after failure', async () => { + const exporter = vi + .fn() + .mockRejectedValueOnce(new Error('pack failed')) + .mockResolvedValueOnce({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-Explorer.zip', + incomplete: true, + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:retry'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '导出游戏资产包' })) + await waitFor(() => expect(screen.getByText('导出失败,可重试')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: '重新导出' })) + await waitFor(() => expect(screen.getByText('导出不完整,缺失图片已保留透明占位')).toBeTruthy()) + expect(exporter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx new file mode 100644 index 0000000..cea311a --- /dev/null +++ b/frontend/src/features/export-package/export-panel.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' + +import type { ExportPackageModel } from './model' +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportPhase, + type AssetExportResult, +} from './asset-export' + +export type AssetExporter = ( + model: ExportPackageModel, + onPhase?: (phase: AssetExportPhase) => void, +) => Promise + +export interface ExportPanelProps { + model: ExportPackageModel + qualityIssueCount?: number + exporter?: AssetExporter +} + +type ExportState = + | { status: 'idle' } + | { status: 'working'; phase: AssetExportPhase } + | { status: 'success'; incomplete: boolean } + | { status: 'failure' } + +const PHASE_LABELS: Readonly> = { + collecting: '正在整理素材', + rendering: '正在生成图片', + packing: '正在打包', +} + +const defaultExporter: AssetExporter = (model, onPhase) => + exportGameAssets(model, undefined, onPhase) + +export function ExportPanel({ + model, + qualityIssueCount = 0, + exporter = defaultExporter, +}: ExportPanelProps) { + const [state, setState] = useState({ status: 'idle' }) + const plan = createAssetExportPlan(model) + const working = state.status === 'working' + + const startExport = async () => { + if (working) return + setState({ status: 'working', phase: 'collecting' }) + try { + const result = await exporter(model, (phase) => setState({ status: 'working', phase })) + const url = URL.createObjectURL(result.blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = result.filename + anchor.click() + URL.revokeObjectURL(url) + setState({ status: 'success', incomplete: result.incomplete }) + } catch { + setState({ status: 'failure' }) + } + } + + return ( +
+
+

GAME ASSETS

+

资产导出

+

逐帧原图、Sprite Sheet 与动画 JSON

+
+ +
+
动作方向
+
{plan.length} 组
+
逐帧原图
+
+ {plan.reduce((total, item) => total + item.frames.length, 0)} 张 +
+
每行上限
+
8 帧
+
+ + {qualityIssueCount > 0 ? ( +

+ 当前核验存在 {qualityIssueCount} 项质量问题,仍可导出 +

+ ) : null} + + {state.status === 'working' ? ( +

+ {PHASE_LABELS[state.phase]} +

+ ) : state.status === 'failure' ? ( +

+ 导出失败,可重试 +

+ ) : state.status === 'success' ? ( +
+

下载完成

+ {state.incomplete ? ( +

+ 导出不完整,缺失图片已保留透明占位 +

+ ) : null} +
+ ) : null} + + + {plan.length === 0 ?

没有可导出的已确认动作

: null} +
+ ) +} diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts new file mode 100644 index 0000000..43dc84d --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,10 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportPanel } from './export-panel' +export type { ExportPackageModel } from './model' +export { + createAssetExportPlan, + exportGameAssets, + type AssetExportPhase, + type AssetExportResult, + type AssetExportRuntime, +} from './asset-export' diff --git a/frontend/src/features/export-package/model.ts b/frontend/src/features/export-package/model.ts new file mode 100644 index 0000000..28577bd --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,32 @@ +import type { ActionType, Frame } from '@/entities' + +/** 导出功能只依赖这份只读模型,不依赖 Playtest 页面内部实现。 */ +export interface ExportFrame { + imageUrl: string + durationMs: number + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +export interface ExportSequence { + direction: string + frames: readonly ExportFrame[] +} + +export interface ExportAction { + id: string + name: string + type: ActionType | 'crouch' + fps: number + sequences: readonly ExportSequence[] +} + +export interface ExportPackageModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + actions: readonly ExportAction[] +} diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 0000000..b8448d0 --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,63 @@ +/** 已经写入 Character 后端记录、可以由资产库和 Playtest 读取的目标。 */ +export interface PublishedAssetTarget { + characterId: string + outfitId: string + actionId?: string +} + +/** 发布后的页面入口。真正的资产写入由 CharacterApis 完成。 */ +export function buildPlaytestPath(target: PublishedAssetTarget): string { + const path = `/playtest/${encodeURIComponent(target.characterId)}/${encodeURIComponent(target.outfitId)}` + return target.actionId ? `${path}?actionId=${encodeURIComponent(target.actionId)}` : path +} + +const ACTION_NAMES: Record = { + idle: '待机', + walk: '行走', + jump: '跳跃', + attack: '攻击', + custom: '自定义动作', +} + +/** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */ +export async function publishWorkflowRun( + characterApis: CharacterApis, + run: WorkflowRun, +): Promise { + if (!run.characterId || !run.outfitId) throw new Error('工作流还没有关联角色与造型') + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + const step = revision?.steps.find((item) => item.type === 'action-generation') + if (step?.type !== 'action-generation' || step.status !== 'passed' || !step.output) { + throw new Error('动作生成尚未完成,不能发布') + } + const result = step.output + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('角色中没有找到工作流关联的造型') + const actionId = `${character.id}-${result.actionType}` + const action = { + id: actionId, + outfitId: outfit.id, + name: ACTION_NAMES[result.actionType], + kind: 'custom' as const, + type: result.actionType, + fps: 8, + keyFrameIndex: 0, + frames: result.frames.map((frame) => ({ + imageUrl: frame.url, + durationMs: frame.durationMs, + rootMotion: null, + })), + } + return characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { ...item, actions: [...item.actions.filter((old) => old.id !== actionId), action] } + : item, + ), + }) +} + +export { canPublishToPlaytest, workflowRunToCharacter } from './workflow-to-character' +import type { ActionType, Character, CharacterApis, WorkflowRun } from '@/entities' diff --git a/frontend/src/features/publish/workflow-to-character.ts b/frontend/src/features/publish/workflow-to-character.ts new file mode 100644 index 0000000..2246be0 --- /dev/null +++ b/frontend/src/features/publish/workflow-to-character.ts @@ -0,0 +1,140 @@ +/** + * WorkflowRun → Character 桥接层。 + * 从工作流步骤中提取数据,组装为 Playtest 可消费的 Character 实体。 + * 按 5 步流程读取:character-setup / character-template / template-candidate / + * action-generation / review。 + */ +import type { + Action, + Character, + CharacterTemplateCandidate, + Frame, + Outfit, + WorkflowRevision, + WorkflowRun, + WorkflowStep, +} from '@/entities' + +function getRevision(run: WorkflowRun): WorkflowRevision | null { + return run.revisions.find((r) => r.id === run.currentRevisionId) ?? null +} + +function getStep(revision: WorkflowRevision, type: string): WorkflowStep | null { + return revision.steps.find((s) => s.type === type) ?? null +} + +/** 从 character-template 步骤提取母版 URL */ +function extractCharacterTemplateUrl(revision: WorkflowRevision): string | null { + const step = getStep(revision, 'character-template') + const output = step?.output as { images?: Array<{ url: string }> } | null + if (!output?.images?.length) return null + return output.images[0]?.url ?? null +} + +/** 从 character-template 步骤提取候选列表 */ +function extractCandidates(revision: WorkflowRevision): CharacterTemplateCandidate[] { + const step = getStep(revision, 'character-template') + const output = step?.output as { images?: Array<{ url: string }> } | null + if (!output?.images) return [] + return output.images.map((img, i) => ({ + id: `candidate-${i}`, + imageUrl: img.url, + attemptId: `attempt-${Date.now()}`, + })) +} + +/** 从 character-setup 步骤提取角色描述 */ +function extractDescription(revision: WorkflowRevision): string { + const step = getStep(revision, 'character-setup') + const input = step?.input as { description?: string } | null + return input?.description?.trim() || '未命名角色' +} + +/** 动作生成结果的帧 URL 列表(兼容 first_frame 单帧与 complete_animation 多帧) */ +function extractActionResult(revision: WorkflowRevision) { + const step = getStep(revision, 'action-generation') + return step?.type === 'action-generation' ? step.output : null +} + +/** + * 将 WorkflowRun 转换为 Character 实体。 + * 如果关键步骤未完成,返回 null。 + */ +export function workflowRunToCharacter(run: WorkflowRun): Character | null { + const revision = getRevision(run) + if (!revision) return null + + const templateStep = getStep(revision, 'character-template') + + // 至少需要母版已生成 + if (!templateStep || templateStep.status !== 'passed') return null + + const characterTemplateUrl = extractCharacterTemplateUrl(revision) + const candidates = extractCandidates(revision) + const description = extractDescription(revision) + const actionResult = extractActionResult(revision) + + // 构建帧列表 + const frames: Frame[] = (actionResult?.frames ?? []).map((frame) => ({ + imageUrl: frame.url, + durationMs: frame.durationMs, + rootMotion: null, + })) + + // 构建动作 + const actions: Action[] = [] + if (frames.length > 0) { + actions.push({ + id: `${run.id}-action`, + outfitId: `${run.id}-outfit`, + name: description.length > 8 ? `${description.slice(0, 8)}…` : description || '动作', + kind: 'custom', + type: actionResult?.actionType ?? 'custom', + fps: 8, + keyFrameIndex: null, + frames, + }) + } + + // 构建造型 + const outfit: Outfit = { + id: `${run.id}-outfit`, + characterId: run.id, + name: '默认造型', + candidateCharacterTemplates: candidates, + characterTemplateUrl, + baseFrames: characterTemplateUrl ? [{ imageUrl: characterTemplateUrl }] : [], + actions, + } + + // 构建角色 + const character: Character = { + id: run.id, + projectId: run.projectId, + outfits: [outfit], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + return character +} + +/** + * 检查 WorkflowRun 是否已准备好导出到 Playtest。 + * 至少需要:母版已确认 + 动作生成完成。 + */ +export function canPublishToPlaytest(run: WorkflowRun): boolean { + const revision = getRevision(run) + if (!revision) return false + + const templateStep = getStep(revision, 'character-template') + const actionStep = getStep(revision, 'action-generation') + + const reviewStep = getStep(revision, 'review') + return ( + run.status === 'completed' && + templateStep?.status === 'passed' && + actionStep?.status === 'passed' && + reviewStep?.status === 'passed' + ) +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts index 3a48b66..3b9d154 100644 --- a/frontend/src/features/review/index.ts +++ b/frontend/src/features/review/index.ts @@ -1,12 +1,26 @@ -/** - * 逐帧查看已生成的动作,供用户在导出前过一遍。 - * - * 只看不改:服务端不返回质检结论,产品上也不设「打回此帧」, - * 所以这里没有任何写操作,帧数据不会因为查看而改变。 - */ -export interface ReviewProps { - /** 动作 ID 只在造型内唯一,调用方须自行持有所属造型,不能拿它跨角色定位。 */ - actionId: string - frameIndex: number - onSelectFrame(index: number): void +import type { WorkflowRun } from '@/entities' + +/** 工作流审核的用户决定。Playtest 中的逐帧检查不使用这套写操作。 */ +export type ReviewDecision = + | { kind: 'approve' } + | { kind: 'request_changes'; restartStepId: string } + +export interface ReviewSubmission { + runId: WorkflowRun['id'] + decision: ReviewDecision +} + +interface ReviewController { + approveReview(runId: WorkflowRun['id']): WorkflowRun + restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun +} + +/** 把审核决定交给唯一的 WorkflowController 执行,不在 Review Feature 中复制状态机。 */ +export function submitReview( + controller: ReviewController, + { runId, decision }: ReviewSubmission, +): WorkflowRun { + return decision.kind === 'approve' + ? controller.approveReview(runId) + : controller.restart(runId, decision.restartStepId) } diff --git a/frontend/src/features/workflow-controller/action-generation-task.ts b/frontend/src/features/workflow-controller/action-generation-task.ts new file mode 100644 index 0000000..220b260 --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,231 @@ +import type { + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { + beginActionGenerationState, + completeActionGenerationState, + getActiveStep, + getCurrentRevision, + recordActionGenerationTaskState, +} from './workflow-state' + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface ActionGenerationTask { + start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput): Promise + resume(runId: WorkflowRun['id']): Promise + stop(runId: WorkflowRun['id']): void +} + +interface CreateActionGenerationTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** 管理完整动作生成的提交、订阅和刷新恢复,页面只负责提供业务输入。 */ +export function createActionGenerationTask({ + store, + generationApis, + createSubmissionId, +}: CreateActionGenerationTaskOptions): ActionGenerationTask { + const submissions = new Map>() + const subscriptions = new Map() + + function requireRun(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function currentActionStep(runId: WorkflowRun['id']) { + const run = requireRun(runId) + const revision = getCurrentRevision(run) + const step = getActiveStep(revision) + return { run, revision, step } + } + + function start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const { run, revision, step } = currentActionStep(runId) + if (run.status !== 'active' || step?.type !== 'action-generation') return Promise.resolve(run) + if (step.taskId) { + subscribe(run, step.taskId) + return Promise.resolve(run) + } + if (step.submissionId) throw new Error('动作生成请求仍在等待后端确认,不能重复提交') + + const key = `${runId}:${revision.id}:${step.id}` + const pending = submissions.get(key) + if (pending) return pending + const submission = submit(runId, input).finally(() => submissions.delete(key)) + submissions.set(key, submission) + return submission + } + + async function submit(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const submissionId = createSubmissionId() + save(beginActionGenerationState(requireRun(runId), input, submissionId)) + try { + const generation = await generationApis.create(input) + const latest = requireRun(runId) + const revision = getCurrentRevision(latest) + const step = getActiveStep(revision) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + step?.type !== 'action-generation' || + step.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'complete_animation') { + throw new Error('生成任务类型与动作生成步骤不匹配') + } + const withTask = save(recordActionGenerationTaskState(latest, generation.id, input)) + if (latest.status === 'interrupted') return withTask + if (generation.status === 'pending' || generation.status === 'running') { + subscribe(withTask, generation.id) + return withTask + } + return applyTerminal(runId, generation.id, generation) + } catch (cause) { + const latest = store.get(runId) + if (latest?.status === 'active') { + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type === 'action-generation') { + save(completeActionGenerationState(latest, { error: message(cause, '动作生成请求失败') })) + } + } + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function subscribe(run: WorkflowRun, taskId: string) { + const key = `${run.id}:${taskId}` + if (subscriptions.has(key)) return + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + if (event.taskId !== taskId || event.status === 'pending' || event.status === 'running') + return + applyTerminal(run.id, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function applyTerminal( + runId: WorkflowRun['id'], + taskId: string, + task: Generation | GenerationEvent, + ) { + const latest = requireRun(runId) + if (latest.status !== 'active') return latest + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type !== 'action-generation' || step.taskId !== taskId) return latest + stopSubscription(runId, taskId) + if (task.status === 'failed') { + return save( + completeActionGenerationState(latest, { + error: task.error?.trim() || '动作生成任务失败', + }), + ) + } + const result = task.result + if ( + task.type !== 'complete_animation' || + result?.type !== 'complete_animation' || + result.frames.length === 0 + ) { + return save( + completeActionGenerationState(latest, { error: '动作生成完成但未返回有效动画帧' }), + ) + } + return save(completeActionGenerationState(latest, result as CompleteAnimationGenerationResult)) + } + + async function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return run + const step = getActiveStep(getCurrentRevision(run)) + if (step?.type !== 'action-generation') return run + if (step.submissionId && !step.taskId) { + return save( + completeActionGenerationState(run, { + error: '页面刷新时动作生成请求尚未返回任务 ID,请重新开始该步骤', + }), + ) + } + if (!step.taskId) { + if (step.input) return start(runId, step.input) + return save( + completeActionGenerationState(run, { + error: '动作生成尚未完成提交,请重新确认角色候选', + }), + ) + } + try { + const task = await generationApis.get(run.projectId, step.taskId) + if (task.status === 'pending' || task.status === 'running') { + subscribe(run, step.taskId) + return store.get(runId) + } + return applyTerminal(runId, step.taskId, task) + } catch (cause) { + const latest = store.get(runId) + if (!latest || latest.status !== 'active') return latest + return save( + completeActionGenerationState(latest, { + error: message(cause, '恢复动作生成任务失败'), + }), + ) + } + } + + function stopSubscription(runId: string, taskId: string) { + const key = `${runId}:${taskId}` + const active = subscriptions.get(key) + subscriptions.delete(key) + try { + active?.stop() + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, active] of subscriptions) { + if (active.runId !== runId) continue + subscriptions.delete(key) + try { + active.stop() + } catch { + // 同上。 + } + } + } + + return { start, resume, stop } +} + +function message(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 0000000..80002d7 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,464 @@ +import { + parseCharacterTemplateGenerationResult, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, +} from '@/entities' +import { + getActiveStep, + getCurrentRevision, + replaceWorkflowStep, + type WorkflowStepTarget, +} from './workflow-state' + +interface ApplyServerResultInput extends WorkflowStepTarget { + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface CharacterTemplateTask { + /** 启动或继续目标角色图步骤;同一实例内的重复调用共享一次提交。 */ + start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 停止指定运行记录的前端任务订阅,不改变 WorkflowRun 状态。 */ + stop(runId: WorkflowRun['id']): void +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Generation 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise { + const run = requireWorkflow(runId) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + revision.id !== target.revisionId || + !step || + step.type !== 'character-template' || + step.status !== 'active' + ) { + return Promise.resolve(run) + } + if (step.taskId) { + ensureTaskSubscription(run, target.revisionId, target.stepId, step.taskId) + return Promise.resolve(requireWorkflow(runId)) + } + if (!step.input) throw new Error('角色图生成步骤缺少输入快照') + return submit(runId, target) + } + + function submit(runId: WorkflowRun['id'], target: WorkflowStepTarget) { + const key = submissionKey(runId, target.revisionId, target.stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performSubmission(runId, target).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performSubmission( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === target.stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== target.revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, target.revisionId, target.stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createSubmissionId() + const submitting = replaceWorkflowStep(before, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === target.stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== target.revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + const typeOk = + generation.type === 'character_template' || generation.type === 'character_image' + // project_id 有一方为 null/undefined 时容忍(后端可能未返回);双方都有值时必须一致 + const projectOk = + generation.projectId == null || + latest.projectId == null || + String(generation.projectId) === String(latest.projectId) + if (!typeOk || !projectOk) { + throw new Error( + `生成任务返回的类型或项目与当前 WorkflowRun 不匹配 ` + + `(type: ${generation.type}, project: ${generation.projectId} vs ${latest.projectId})`, + ) + } + + const withTask = replaceWorkflowStep(latest, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, target.revisionId, target.stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markFailed(runId, target, null, submissionId, errorMessage(cause, '角色图生成请求失败')) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + handleGenerationEvent(run.id, { revisionId, stepId }, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleGenerationEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: GenerationEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markFailed(runId, target, taskId, null, event.error?.trim() || '角色图生成任务失败') + return + } + if (event.type !== 'character_template') { + markFailed(runId, target, taskId, null, '任务结果类型与角色图生成步骤不匹配') + return + } + applyServerResult(runId, { + ...target, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + const target = { revisionId: revision.id, stepId: activeStep.id } + + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markFailed( + run.id, + target, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await generationApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markFailed( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleGenerationEvent( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markFailed( + runId, + { revisionId: revision.id, stepId: step.id }, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markFailed( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== target.revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceWorkflowStep( + run, + target.revisionId, + target.stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revision.id, step.id, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + } + + return { start, resume, stop } +} + +function taskEvent(task: Generation): GenerationEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 0000000..89dd488 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,696 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + WORKFLOW_STEP_ORDER, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from '.' + +const NOW = '2026-07-30T12:00:00.000Z' + +type RunListener = (run: WorkflowRun) => void + +function cloneRun(run: WorkflowRun): WorkflowRun { + return structuredClone(run) +} + +function createMemoryStore() { + const runs = new Map() + const listeners = new Map>() + const listListeners = new Set<(runs: WorkflowRun[]) => void>() + + const get = vi.fn((runId: string): WorkflowRun | null => { + const run = runs.get(runId) + return run ? cloneRun(run) : null + }) + + const save = vi.fn((run: WorkflowRun): void => { + const snapshot = cloneRun(run) + runs.set(run.id, snapshot) + + for (const listener of listeners.get(run.id) ?? []) { + listener(cloneRun(snapshot)) + } + for (const listener of listListeners) listener([...runs.values()].map(cloneRun)) + }) + + const subscribe = vi.fn((runId: string, listener: RunListener): (() => void) => { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + } + }) + + const list = vi.fn(() => [...runs.values()].map(cloneRun)) + const subscribeAll = vi.fn((listener: (runs: WorkflowRun[]) => void) => { + listListeners.add(listener) + return () => listListeners.delete(listener) + }) + + return { get, list, save, subscribe, subscribeAll } +} + +function createIdFactory() { + let nextId = 0 + return vi.fn(() => `id-${++nextId}`) +} + +function deferNextGeneration(harness: ReturnType) { + let resolve!: (generation: Generation<'character_template'>) => void + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise + }) + vi.mocked(harness.generationApis.create).mockImplementationOnce( + async () => (await promise) as Generation, + ) + return { resolve } +} + +function pendingCharacterTemplateGeneration(): Generation<'character_template'> { + return { + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + } +} + +function createHarness() { + const store = createMemoryStore() + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: GenerationEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('GenerationApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + + const controller = createWorkflowController({ + store, + generationApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + generationApis, + subscribeTask, + store, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: GenerationEvent) { + const listener = taskListeners.get(`${projectId}:${taskId}`) + expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( + 'function', + ) + listener?.(event) + }, + } +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find(({ id }) => id === run.currentRevisionId) + if (!revision) { + throw new Error(`Current revision ${run.currentRevisionId} is missing`) + } + return revision +} + +function step(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + const workflowStep = currentRevision(run).steps.find((item) => item.type === type) + if (!workflowStep) { + throw new Error(`Workflow step ${type} is missing`) + } + return workflowStep +} + +async function createAiRun(harness: ReturnType) { + return harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }) +} + +const SPRITE_SIZE = { width: 64, height: 64 } + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed five steps and seeds AI input from the prompt', async () => { + const harness = createHarness() + + expect(harness.controller).toEqual( + expect.objectContaining({ + create: expect.any(Function), + getWorkflow: expect.any(Function), + subscribe: expect.any(Function), + updateCharacterSetup: expect.any(Function), + nextStep: expect.any(Function), + restart: expect.any(Function), + resume: expect.any(Function), + interrupt: expect.any(Function), + }), + ) + + const run = await createAiRun(harness) + const revision = currentRevision(run) + + expect(run.prompt).toBe('pixel knight') + expect(run.projectId).toBe('project-1') + expect(run.revisions).toHaveLength(1) + expect(run.currentRevisionId).toBe(revision.id) + expect(revision.basedOnRevisionId).toBeNull() + expect(revision.restartStepId).toBeNull() + expect(revision.createdAt).toBe(NOW) + expect(revision.steps.map(({ type }) => type)).toEqual(WORKFLOW_STEP_ORDER) + expect(revision.steps.map(({ status }) => status)).toEqual([ + 'active', + 'locked', + 'locked', + 'locked', + 'locked', + ]) + expect(step(run, 'character-setup').input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + + const allIds = [run.id, revision.id, ...revision.steps.map(({ id }) => id)] + expect(new Set(allIds).size).toBe(allIds.length) + expect(harness.store.save).toHaveBeenCalledWith(run) + }) + + it('persists the task id before subscribing and never submits the active generation twice', async () => { + const harness = createHarness() + + await startCharacterTemplate(harness) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(harness.generationApis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: 'project-1', + prompt: 'pixel knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }) + expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + + const taskSaveIndex = harness.store.save.mock.calls.findIndex(([savedRun]) => { + return step(savedRun, 'character-template').taskId === 'task-1' + }) + expect(taskSaveIndex).toBeGreaterThanOrEqual(0) + expect(harness.store.save.mock.invocationCallOrder[taskSaveIndex]).toBeLessThan( + harness.subscribeTask.mock.invocationCallOrder[0], + ) + + const createdRun = harness.store.save.mock.calls[0]?.[0] + if (!createdRun) throw new Error('Expected the created WorkflowRun to be saved') + const activeRun = harness.controller.getWorkflow(createdRun.id) + if (!activeRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(activeRun, 'character-setup').status).toBe('passed') + expect(step(activeRun, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + + await harness.controller.nextStep(activeRun.id) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('shares one submission when nextStep is called concurrently', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const first = harness.controller.nextStep(run.id, SPRITE_SIZE) + const second = harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + deferred.resolve(pendingCharacterTemplateGeneration()) + await Promise.all([first, second]) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('keeps an active submission alive when resume uses the same controller', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + const resumed = await harness.controller.resume(run.id) + + expect(resumed?.status).toBe('active') + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'active', + taskId: null, + submissionId: expect.any(String), + }) + + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('handles a terminal snapshot emitted synchronously when subscribing', async () => { + const harness = createHarness() + const stop = vi.fn() + harness.subscribeTask.mockImplementationOnce((_projectId, _taskId, onEvent) => { + onEvent({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/synchronous.png' }], + }, + }) + return stop + }) + + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(harness.controller.getWorkflow(run.id)!, 'template-candidate').status).toBe( + 'active', + ) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ignores another task result and advances only when the matching task completes', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'another-task', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/wrong.png' }], + }, + }) + + const unchangedRun = harness.controller.getWorkflow(run.id) + if (!unchangedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(unchangedRun, 'character-template')).toMatchObject({ + status: 'active', + output: null, + taskId: 'task-1', + }) + expect(step(unchangedRun, 'template-candidate').status).toBe('locked') + + const result = { + type: 'character_template' as const, + images: [{ url: 'https://example.com/knight.png' }], + } + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result, + }) + + const completedRun = harness.controller.getWorkflow(run.id) + if (!completedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(completedRun, 'character-template')).toMatchObject({ + status: 'passed', + output: result, + taskId: null, + }) + expect(step(completedRun, 'template-candidate').status).toBe('active') + }) + + it('marks the step, revision, generation, and run as failed when the task fails', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'model unavailable', + result: null, + }) + + const failedRun = harness.controller.getWorkflow(run.id) + if (!failedRun) throw new Error('Expected the WorkflowRun to remain available') + const revision = currentRevision(failedRun) + expect(step(failedRun, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: 'model unavailable', + }) + expect(revision.status).toBe('failed') + expect(revision.generationStatus).toBe('failed') + expect(failedRun.status).toBe('failed') + }) + + it('resumes a persisted in-flight task without creating another generation', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.generationApis.get).toHaveBeenCalledWith('project-1', 'task-1') + expect(resumeSubscribe).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('applies a completed task found during refresh before subscribing again', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/recovered.png' }], + }, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(resumed!, 'template-candidate').status).toBe('active') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe after interrupting while resume waits for the task query', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + let resolveTask!: (task: Generation) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { + ...harness.generationApis, + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumed = await resuming + + expect(resumed?.status).toBe('interrupted') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('fails safely after refresh when the request was sent before taskId arrived', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const uncertainSnapshot = harness.store.save.mock.calls + .map(([savedRun]) => savedRun) + .find((savedRun) => { + const template = step(savedRun, 'character-template') + return template.submissionId !== null && template.taskId === null + }) + if (!uncertainSnapshot) throw new Error('expected the submitting snapshot to be persisted') + harness.store.save(uncertainSnapshot) + vi.mocked(harness.generationApis.create).mockClear() + + const restoredController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + }) + + const restored = await restoredController.resume(run.id) + + expect(restored?.status).toBe('failed') + expect(step(restored!, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + }) + expect(harness.generationApis.create).not.toHaveBeenCalled() + }) + + it('records a task id that returns after the run was interrupted', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + await harness.controller.interrupt(run.id) + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + const interrupted = harness.controller.getWorkflow(run.id) + expect(interrupted?.status).toBe('interrupted') + expect(step(interrupted!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + submissionId: null, + }) + expect(harness.subscribeTask).not.toHaveBeenCalled() + }) + + it('keeps an interrupted run interrupted when a queued failure arrives late', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const queuedListener = harness.getTaskListener('project-1', 'task-1') + if (!queuedListener) throw new Error('expected an active task subscription') + + await harness.controller.interrupt(run.id) + queuedListener({ + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'late failure', + result: null, + }) + + expect(harness.controller.getWorkflow(run.id)?.status).toBe('interrupted') + }) + + it('publishes saved updates and can interrupt the current run', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const listener = vi.fn() + const unsubscribe = harness.controller.subscribe(run.id, listener) + + const updated = await harness.controller.updateCharacterSetup(run.id, { + description: 'revised knight', + referenceMedia: [], + }) + expect(step(updated, 'character-setup').input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + + const interrupted = await harness.controller.interrupt(run.id) + + expect(interrupted.status).toBe('interrupted') + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ id: run.id, status: 'interrupted' }), + ) + + unsubscribe() + }) + + it('completes the active action-generation step without throwing and activates review', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + const confirmed = harness.controller.confirmCandidate( + run.id, + 'https://example.com/candidate.png', + ) + + expect(step(confirmed, 'template-candidate')).toMatchObject({ + status: 'passed', + output: { selectedImageUrl: 'https://example.com/candidate.png' }, + }) + expect(step(confirmed, 'action-generation').status).toBe('active') + + const result = { + type: 'complete_animation' as const, + actionType: 'idle' as const, + frames: [{ url: 'https://example.com/frame.png', durationMs: 125 }], + } + const completed = harness.controller.completeActionGeneration(run.id, result) + + expect(step(completed, 'action-generation')).toMatchObject({ + status: 'passed', + output: result, + error: null, + }) + // 动作完成后 review 进入 active,保证刷新后 run 仍满足“恰好一个 active 步骤”的存储校验 + expect(step(completed, 'review').status).toBe('active') + }) + + it('marks the action-generation step failed when the result carries an error', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + harness.controller.confirmCandidate(run.id, 'https://example.com/candidate.png') + + const failed = harness.controller.completeActionGeneration(run.id, { + error: '动作生成完成但未返回有效帧图片', + }) + + expect(step(failed, 'action-generation')).toMatchObject({ + status: 'failed', + error: '动作生成完成但未返回有效帧图片', + }) + expect(step(failed, 'review').status).toBe('locked') + expect(failed.status).toBe('failed') + }) + + it('restarts from a passed stage as a new local revision', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + const advanced = harness.controller.getWorkflow(run.id) + if (!advanced) throw new Error('Expected the workflow to remain available') + const original = currentRevision(advanced) + const restarted = harness.controller.restart(advanced.id, `${original.id}:character-setup`) + + const revision = currentRevision(restarted) + expect(restarted.status).toBe('active') + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(revision).toMatchObject({ + id: 'id-4', + basedOnRevisionId: original.id, + restartStepId: `${original.id}:character-setup`, + createdAt: NOW, + }) + expect(step(restarted, 'character-setup')).toMatchObject({ + id: 'id-4:character-setup', + status: 'active', + referenceStepIds: [`${original.id}:character-setup`], + }) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..d973eb4 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,294 @@ +import type { + CharacterSetupStepInput, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + GenerationApis, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { createActionGenerationTask } from './action-generation-task' +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + approveReviewState, + completeActionGenerationState, + confirmCandidateState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + recordActionGenerationTaskState, + restartWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' + +/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput + +export interface WorkflowController { + /** 创建并保存一条纯前端运行记录。 */ + create(input: CreateWorkflowControllerInput): WorkflowRun + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + + /** 订阅指定运行记录的本地变化。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + + /** 修改当前角色资料步骤,页面无需知道步骤内部 ID。 */ + updateCharacterSetup(runId: WorkflowRun['id'], input: CharacterSetupStepInput): WorkflowRun + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成步骤需要传给后端做尺寸校验。 + */ + nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun + + /** 确认候选选择,推进到下一个步骤。 */ + confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun + + /** 动作生成完成后写回结果,标记 action-generation 为 passed。 */ + completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun + + /** 提交完整动作生成,并由 Controller 统一处理订阅和刷新恢复。 */ + startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ): Promise + + /** 审核通过后完成当前版本和整条运行;不在这里执行发布或下载。 */ + approveReview(runId: WorkflowRun['id']): WorkflowRun + + /** 动作生成任务提交后把任务 ID 落盘,供页面刷新后 resume 恢复轮询。 */ + recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun + + /** 记录动作生成关联的角色与造型 ID,供导出到 Playtest 使用(刷新后可恢复)。 */ + recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun + + /** 从当前执行线中一个已通过的节点创建新的本地 Revision。 */ + restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: GenerationApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + const actionGenerationTask = createActionGenerationTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + return save(updateCharacterSetupState(requireWorkflow(runId), input)) + } + + async function nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise { + const run = requireActiveWorkflow(requireWorkflow(runId)) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) + } + if (activeStep.type !== 'character-setup') { + throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) + } + + if (!spriteSize) throw new Error('推进角色资料步骤需要项目精灵图尺寸') + + const transitioned = advanceCharacterSetupState(run, spriteSize) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) + } + + function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return Promise.resolve(run) + const step = getActiveStep(getCurrentRevision(run)) + return step?.type === 'action-generation' + ? actionGenerationTask.resume(runId) + : characterTemplateTask.resume(runId) + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save(interruptWorkflowRunState(latest)) + } + + function confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun { + return save(confirmCandidateState(requireWorkflow(runId), selectedImageUrl)) + } + + function completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[completeActionGen] run not active:', run.status) + return run + } + const revision = getCurrentRevision(run) + const step = revision.steps.find((s) => s.type === 'action-generation') + if (!step || step.status !== 'active') { + console.warn('[completeActionGen] step not active:', step?.type, step?.status) + return run + } + return save(completeActionGenerationState(run, result)) + } + + function startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ) { + return actionGenerationTask.start(runId, input) + } + + function approveReview(runId: WorkflowRun['id']): WorkflowRun { + return save(approveReviewState(requireWorkflow(runId))) + } + + function recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordActionTask] run not active:', run.status) + return run + } + return save(recordActionGenerationTaskState(run, taskId)) + } + + function recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordCharacterRefs] run not active:', run.status) + return run + } + return save({ ...run, characterId: refs.characterId, outfitId: refs.outfitId }) + } + + function restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun { + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + return save( + restartWorkflowRunState(requireWorkflow(runId), stepId, { + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + return { + create, + getWorkflow, + subscribe, + updateCharacterSetup, + nextStep, + confirmCandidate, + completeActionGeneration, + startActionGeneration, + approveReview, + recordActionGenerationTask, + recordCharacterRefs, + restart, + resume, + interrupt, + } +} + +function createRuntimeId(scope: 'run' | 'revision' | 'submission') { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${scope}-${suffix}` +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce879..fcb6978 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,6 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' - -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ -export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 - */ -export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerInput, + CreateWorkflowControllerOptions, + WorkflowController, +} from './controller' diff --git a/frontend/src/features/workflow-controller/store-invariants.test.ts b/frontend/src/features/workflow-controller/store-invariants.test.ts new file mode 100644 index 0000000..9ea98ac --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,218 @@ +/** + * 状态机存储不变量穷举测试。 + * + * 每个状态转换点之后,run 必须满足 createWorkflowRunStore 的持久化校验 + * (刷新页面后能从 localStorage 恢复)。曾因 completeActionGeneration 后 + * review 未激活导致 active 步骤数为 0,刷新后 run 被校验过滤直接丢失。 + */ +import { describe, expect, it, vi } from 'vitest' + +import type { + Generation, + GenerationApis, + GenerationEvent, + GenerationInput, + WorkflowRun, +} from '@/entities' +import { createWorkflowRunStore } from '@/entities/workflow-run/store' +import { createWorkflowController } from '.' + +/** 内存版 localStorage:save 后重建 store 即模拟刷新恢复。 */ +function createRefreshableStore() { + let snapshot: string | null = null + const storage = { + getItem: (key: string) => (key === 'windup.workflow-runs' ? snapshot : null), + setItem: (_key: string, value: string) => { + snapshot = value + }, + } + const store = createWorkflowRunStore({ storage }) + return { + store, + /** 模拟刷新:用同一份 storage 快照重建 store。 */ + refresh(): typeof store { + return createWorkflowRunStore({ storage }) + }, + } +} + +function createHarness() { + const { store, refresh } = createRefreshableStore() + const taskListeners = new Map void>() + + const generationApis: GenerationApis = { + create: vi.fn( + async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation, + ), + get: vi.fn(async () => { + throw new Error('not used') + }), + subscribe: vi.fn( + (_projectId: string, taskId: string, onEvent: (e: GenerationEvent) => void) => { + taskListeners.set(taskId, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(taskId) + } + }, + ), + } + let idCounter = 0 + const controller = createWorkflowController({ + store, + generationApis, + createId: (scope) => `id-${scope}-${++idCounter}`, + now: () => '2026-07-31T12:00:00.000Z', + }) + + return { + store, + refresh, + taskListeners, + controller, + completeTemplateTask(taskId: string) { + const listener = taskListeners.get(taskId) + if (!listener) throw new Error(`missing listener ${taskId}`) + listener({ + taskId, + type: 'character_template', + status: 'completed', + error: null, + result: { type: 'character_template', images: [{ url: 'https://example.com/c.png' }] }, + }) + }, + } +} + +/** 断言 run 在刷新后仍可恢复(即通过 store 持久化校验)。 */ +function expectRefreshable( + harness: ReturnType, + runId: string, + label: string, +): WorkflowRun { + const restored = harness.refresh().get(runId) + expect(restored, `${label} 刷新后应可恢复`).not.toBeNull() + return restored! +} + +describe('store invariants across every state transition', () => { + it('every step of the happy path survives a refresh', async () => { + const harness = createHarness() + + // 1. 创建(character-setup active) + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + const r1 = expectRefreshable(harness, created.id, '创建后') + expect(r1.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 2. 提交角色图任务(character-template active + submissionId) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + const r2 = expectRefreshable(harness, created.id, '角色图任务提交后') + const templateStep2 = r2.revisions[0]!.steps.find((s) => s.type === 'character-template')! + expect(templateStep2.status).toBe('active') + expect(r2.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 3. 角色图完成(character-template passed → template-candidate active) + harness.completeTemplateTask('task-1') + const r3 = expectRefreshable(harness, created.id, '角色图完成后') + expect(r3.revisions[0]!.steps.find((s) => s.type === 'template-candidate')!.status).toBe( + 'active', + ) + + // 4. 确认候选(action-generation active) + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + const r4 = expectRefreshable(harness, created.id, '确认候选后') + expect(r4.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe( + 'active', + ) + expect(r4.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 5. 动作生成完成(action-generation passed → review active) + harness.controller.recordActionGenerationTask(created.id, 'task-action-1') + harness.controller.completeActionGeneration(created.id, { + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'https://example.com/f.png', durationMs: 125 }], + }) + const r5 = expectRefreshable(harness, created.id, '动作完成后') + const reviewStep = r5.revisions[0]!.steps.find((s) => s.type === 'review')! + expect(reviewStep.status).toBe('active') + expect(r5.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a failed action generation survives a refresh and stays failed', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + + harness.controller.completeActionGeneration(created.id, { error: '生成服务超时' }) + + const r = expectRefreshable(harness, created.id, '动作失败后') + expect(r.status).toBe('failed') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(0) + expect(r.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe('failed') + }) + + it('an interrupted run survives a refresh with exactly one active step', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + harness.controller.interrupt(created.id) + + const r = expectRefreshable(harness, created.id, '中断后') + expect(r.status).toBe('interrupted') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a restart from a passed step survives a refresh', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + const after = harness.controller.getWorkflow(created.id)! + const revision = after.revisions[0]! + const setupStep = revision.steps.find((s) => s.type === 'character-setup')! + + harness.controller.restart(created.id, setupStep.id) + + const r = expectRefreshable(harness, created.id, '重开后') + expect(r.revisions).toHaveLength(2) + expect(r.revisions[0]!.status).toBe('abandoned') + expect(r.revisions[1]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 0000000..62d8f41 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, +} from '@/entities' +import { createWorkflowController } from '.' + +describe('WorkflowRun first vertical slice', () => { + it('runs character setup through a completed character-template task', async () => { + const store = createWorkflowRunStore({ storage: null }) + const taskChannel: { listener?: (event: GenerationEvent) => void } = {} + + const createGeneration: GenerationApis['create'] = async ( + input: T, + ) => + ({ + id: 'task-character-template-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('not used in this slice') + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + delete taskChannel.listener + } + }), + } + const ids = ['run-1', 'revision-1'] + const controller = createWorkflowController({ + store, + generationApis, + createId: () => ids.shift() ?? 'unexpected-id', + now: () => '2026-07-30T12:00:00.000Z', + }) + + const created = await controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + await controller.nextStep(created.id, { width: 64, height: 64 }) + + const inFlight = store.get(created.id) + expect( + inFlight?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'active', + taskId: 'task-character-template-1', + }) + + const taskListener = taskChannel.listener + if (!taskListener) throw new Error('expected the task subscription to be active') + taskListener({ + taskId: 'task-character-template-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + + const completed = store.get(created.id) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'passed', + taskId: null, + output: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'template-candidate'), + ).toMatchObject({ status: 'active' }) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 0000000..5e2924a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceCharacterSetupState, + approveReviewState, + createWorkflowRunState, + restartWorkflowRunState, + updateCharacterSetupState, +} from './workflow-state' + +const CREATED_AT = '2026-07-31T02:40:00.000Z' + +function createRun() { + return createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }, + { + runId: 'run-1', + revisionId: 'revision-1', + createdAt: CREATED_AT, + }, + ) +} + +describe('workflow state transitions', () => { + it('creates the fixed five-step workflow and keeps export outside the step sequence', () => { + const run = createRun() + + expect(run).toMatchObject({ + id: 'run-1', + projectId: 'project-1', + status: 'active', + prompt: 'pixel knight', + currentRevisionId: 'revision-1', + }) + expect(run.revisions[0]?.createdAt).toBe(CREATED_AT) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'active' }, + { type: 'character-template', status: 'locked' }, + { type: 'template-candidate', status: 'locked' }, + { type: 'action-generation', status: 'locked' }, + { type: 'review', status: 'locked' }, + ]) + expect(run.revisions[0]?.exportStatus).toBe('not_exported') + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + it('normalizes character setup input before storing it', () => { + const updated = updateCharacterSetupState(createRun(), { + description: ' revised knight ', + referenceMedia: [], + }) + + expect(updated.revisions[0]?.steps[0]?.input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + }) + + it('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run, { width: 64, height: 64 }) + + expect(transitioned.target).toEqual({ + revisionId: 'revision-1', + stepId: 'revision-1:character-template', + }) + expect(transitioned.run.revisions[0]?.steps.slice(0, 3)).toMatchObject([ + { type: 'character-setup', status: 'passed' }, + { + type: 'character-template', + status: 'active', + input: { + type: 'character_template', + projectId: 'project-1', + prompt: 'revised knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) + + it('creates a new revision from a passed stage without retaining downstream outputs', () => { + const prepared = advanceCharacterSetupState(createRun(), { width: 64, height: 64 }).run + const sourceRevision = prepared.revisions[0]! + const run = { + ...prepared, + revisions: [ + { + ...sourceRevision, + steps: sourceRevision.steps.map((step) => + step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ), + }, + ], + } + + const restarted = restartWorkflowRunState(run, 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }) + + expect(restarted).toMatchObject({ + status: 'active', + currentRevisionId: 'revision-2', + }) + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(restarted.revisions[1]).toMatchObject({ + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }) + expect( + restarted.revisions[1]?.steps.map(({ type, status, referenceStepIds }) => ({ + type, + status, + referenceStepIds, + })), + ).toEqual([ + { + type: 'character-setup', + status: 'passed', + referenceStepIds: ['revision-1:character-setup'], + }, + { + type: 'character-template', + status: 'active', + referenceStepIds: ['revision-1:character-template'], + }, + { type: 'template-candidate', status: 'locked', referenceStepIds: [] }, + { type: 'action-generation', status: 'locked', referenceStepIds: [] }, + { type: 'review', status: 'locked', referenceStepIds: [] }, + ]) + }) + + it('rejects a restart from a stage that has not passed', () => { + expect(() => + restartWorkflowRunState(createRun(), 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }), + ).toThrow('只能从已通过的步骤重新开始') + }) + + it('completes the revision and run when the active review is approved', () => { + const run = createRun() + const readyForReview = { + ...run, + revisions: run.revisions.map((revision) => ({ + ...revision, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => ({ + ...step, + status: step.type === 'review' ? ('active' as const) : ('passed' as const), + })), + })), + } + + const completed = approveReviewState(readyForReview) + + expect(completed.status).toBe('completed') + expect(completed.revisions[0]?.status).toBe('completed') + expect(completed.revisions[0]?.steps.every((step) => step.status === 'passed')).toBe(true) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 0000000..8dc5760 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,476 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CompleteAnimationGenerationInput, + type CompleteAnimationGenerationResult, + type CreateWorkflowRunInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export interface RestartWorkflowRunStateOptions { + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + + return { + id: runId, + projectId: input.projectId, + characterId: null, + outfitId: null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision +} + +export function getActiveStep(revision: WorkflowRevision): WorkflowStep | null { + return revision.steps.find((step) => step.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, +): WorkflowRun { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupStepInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { + run: WorkflowRun + target: WorkflowStepTarget +} { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + if (activeStep.type !== 'character-setup') { + throw new Error(`当前步骤不是角色资料:${activeStep.type}`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, + } + + return { + run: { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + }, + target: { + revisionId: revision.id, + stepId: templateStep.id, + }, + } +} + +/** + * 确认候选选择:标记 template-candidate 为 passed,激活下一个步骤。 + */ +export function confirmCandidateState(run: WorkflowRun, selectedImageUrl: string): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + const revision = getCurrentRevision(run) + const candidateStep = revision.steps.find((step) => step.type === 'template-candidate') + if (!candidateStep || candidateStep.status !== 'active') { + throw new Error('当前只能确认处于 active 状态的候选步骤') + } + + const nextIndex = WORKFLOW_STEP_ORDER.indexOf('template-candidate') + 1 + const nextType = WORKFLOW_STEP_ORDER[nextIndex] + + return { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((step) => { + if (step.id === candidateStep.id && step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl }, + } + } + if (nextType && step.type === nextType) { + return { ...step, status: 'active' as const } + } + return step + }), + } + }), + } +} + +/** + * 动作生成完成:与 confirmCandidateState 对称。 + * + * 成功时把 action-generation 标记 passed 并激活 review 步骤;失败时标记 failed + * 并把整个 run 置为 failed。两个方向都保证「active 状态的 run 恰好有一个 active + * 步骤」,让刷新后的存储校验能够恢复这条运行记录。 + */ +export function completeActionGenerationState( + run: WorkflowRun, + result: CompleteAnimationGenerationResult | { error: string }, +): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可完成动作生成:${run.status}`) + const revision = getCurrentRevision(run) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active') { + throw new Error('当前只能完成处于 active 状态的动作生成步骤') + } + + const failed = result !== null && typeof result === 'object' && 'error' in result + const reviewStep = revision.steps.find((step) => step.type === 'review') + + const updated = replaceWorkflowStep( + run, + revision.id, + actionStep.id, + (current) => { + if (current.type !== 'action-generation') return current + return { + ...current, + status: failed ? ('failed' as const) : ('passed' as const), + output: failed ? null : result, + error: failed ? String((result as { error: string }).error) : null, + // 任务已终态,解除任务 ID 关联(存储校验要求终态步骤不持有任务 ID) + taskId: null, + submissionId: null, + } + }, + (current) => ({ + ...current, + status: failed ? ('failed' as const) : current.status, + generationStatus: failed ? ('failed' as const) : ('completed' as const), + steps: current.steps.map((step) => { + if (failed || !reviewStep || step.id !== reviewStep.id || step.type !== 'review') { + return step + } + return { ...step, status: 'active' as const } + }), + }), + ) + + return failed ? { ...updated, status: 'failed' as const } : updated +} + +/** 审核通过后结束当前版本和整条运行;发布与下载仍由后续独立功能处理。 */ +export function approveReviewState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可审核:${run.status}`) + const revision = getCurrentRevision(run) + const reviewStep = revision.steps.find((step) => step.type === 'review') + if (!reviewStep || reviewStep.status !== 'active') { + throw new Error('当前只能通过处于 active 状态的审核步骤') + } + + return { + ...run, + status: 'completed', + revisions: run.revisions.map((item) => + item.id === revision.id + ? { + ...item, + status: 'completed', + steps: item.steps.map((step) => + step.id === reviewStep.id + ? { ...step, status: 'passed' as const, error: null } + : step, + ), + } + : item, + ), + } +} + +/** + * 记录动作生成任务 ID:步骤保持 active,只是把 taskId 落盘,供刷新后 resume 恢复。 + */ +export function beginActionGenerationState( + run: WorkflowRun, + input: CompleteAnimationGenerationInput, + submissionId: string, +): WorkflowRun { + const revision = getCurrentRevision(requireActiveWorkflow(run)) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active' || actionStep.taskId) { + throw new Error('当前动作生成步骤不可重复提交') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, input, submissionId, error: null } + }) +} + +export function recordActionGenerationTaskState( + run: WorkflowRun, + taskId: string, + input?: CompleteAnimationGenerationInput, +): WorkflowRun { + if (run.status !== 'active' && run.status !== 'interrupted') { + throw new Error(`WorkflowRun 当前不可记录任务:${run.status}`) + } + const revision = getCurrentRevision(run) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active') { + throw new Error('当前只能为 active 状态的动作生成步骤记录任务') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, taskId, input: input ?? current.input, submissionId: null } + }) +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +/** + * 从已通过节点开启新的执行线。 + * + * 旧 Revision 保留为只读历史;重开点之前的结果作为新线参考,重开点及之后的结果 + * 不会进入新线。流程节点始终固定为五个,因此“移除下游”在数据中表现为清空它们的 + * 输入、输出与引用,并重新锁定。 + */ +export function restartWorkflowRunState( + run: WorkflowRun, + restartStepId: WorkflowStep['id'], + { revisionId, createdAt }: RestartWorkflowRunStateOptions, +): WorkflowRun { + const sourceRevision = getCurrentRevision(run) + const restartIndex = sourceRevision.steps.findIndex((step) => step.id === restartStepId) + const restartStep = sourceRevision.steps[restartIndex] + if (!restartStep || restartStep.status !== 'passed') { + throw new Error('只能从已通过的步骤重新开始') + } + + const steps = sourceRevision.steps.map((step, index) => { + if (index < restartIndex) return copyReferenceStep(step, revisionId) + if (index === restartIndex) return createRestartStep(step, revisionId) + + return lockFreshStep(step.type, revisionId, index, run.prompt) + }) + + const revision: WorkflowRevision = { + id: revisionId, + basedOnRevisionId: sourceRevision.id, + restartStepId: restartStep.id, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + } + + return { + ...run, + status: 'active', + currentRevisionId: revision.id, + revisions: [ + ...run.revisions.map((item) => + item.id === sourceRevision.id ? { ...item, status: 'abandoned' as const } : item, + ), + revision, + ], + } +} + +function copyReferenceStep(step: WorkflowStep, revisionId: WorkflowRevision['id']): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: `${revisionId}:${source.type}`, + status: 'passed', + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [step.id], + } +} + +function createRestartStep(step: WorkflowStep, revisionId: WorkflowRevision['id']): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: `${revisionId}:${source.type}`, + status: 'active', + taskId: null, + submissionId: null, + error: null, + output: null, + referenceStepIds: [step.id], + } as WorkflowStep +} + +function lockFreshStep( + type: WorkflowStepType, + revisionId: WorkflowRevision['id'], + index: number, + prompt: string | null, +): WorkflowStep { + return { + ...createInitialStep(type, revisionId, index, prompt), + status: 'locked', + referenceStepIds: [], + } +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: `${revisionId}:${type}`, + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index 016328b..9765112 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,9 +1,70 @@ -/** 资产库。 */ -export function AssetLibraryPage() { +/** 资产库页面:读取后端已经保存的 Character 树,不展示运行中的 WorkflowRun。 */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { Character, CharacterApis } from '@/entities' + +export function AssetLibraryPage({ apis }: { apis: CharacterApis }) { + const { projectId = '' } = useParams() + const [characters, setCharacters] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) return + void apis.listByProject(projectId).then( + (items) => + active && + setCharacters( + items.filter((character) => + character.outfits.some((outfit) => outfit.actions.length > 0), + ), + ), + (cause) => active && setError(cause instanceof Error ? cause.message : '资产加载失败'), + ) + return () => { + active = false + } + }, [apis, projectId]) + return ( -
-

资产库

-

本次只提交模块划分与接口,页面实现进后续 PR。

+
+

ASSET LIBRARY

+

资产库

+

项目中已经发布的角色、造型和动作。

+ {error && ( +

+ {error} +

+ )} + {!error && characters.length === 0 && ( +

+ 这个项目还没有已发布资产。 +

+ )} +
+ {characters.flatMap((character) => + character.outfits.map((outfit) => ( +
+ {outfit.characterTemplateUrl && ( + {`${outfit.name} + )} +

{outfit.name}

+

{outfit.actions.length} 个动作

+ + 进入预览台 + +
+ )), + )} +
) } diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx new file mode 100644 index 0000000..8152ed5 --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,112 @@ +/** + * 历史记录页面 — 读取 WorkflowRunStore 展示已完成的工作流。 + */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { WorkflowRun, WorkflowRunStore } from '@/entities' + +export function HistoryPage({ store }: { store: WorkflowRunStore }) { + const { projectId } = useParams() + const [runs, setRuns] = useState(() => filterRuns(store.list(), projectId)) + + useEffect(() => { + setRuns(filterRuns(store.list(), projectId)) + return store.subscribeAll((items) => setRuns(filterRuns(items, projectId))) + }, [projectId, store]) + + const completedRuns = runs.filter((r) => r.status === 'completed') + const activeRuns = runs.filter((r) => r.status === 'active' || r.status === 'interrupted') + + return ( +
+
+

+ HISTORY +

+

历史记录

+

已完成和进行中的创作记录。

+
+ + {activeRuns.length > 0 && ( +
+

进行中

+
+ {activeRuns.map((run) => ( + + ))} +
+
+ )} + + {completedRuns.length > 0 && ( +
+

已完成

+
+ {completedRuns.map((run) => ( + + ))} +
+
+ )} + + {runs.length === 0 && ( +
+

还没有创作记录。

+ + 开始创作 + +
+ )} +
+ ) +} + +function filterRuns(runs: WorkflowRun[], projectId?: string) { + return projectId ? runs.filter((run) => run.projectId === projectId) : runs +} + +function RunCard({ run }: { run: WorkflowRun }) { + const revision = run.revisions.find((r) => r.id === run.currentRevisionId) + const passedCount = revision?.steps.filter((s) => s.status === 'passed').length ?? 0 + const totalCount = revision?.steps.length ?? 0 + + const statusLabel = + run.status === 'completed' + ? '已完成' + : run.status === 'failed' + ? '失败' + : run.status === 'interrupted' + ? '已中断' + : '进行中' + + const statusColor = + run.status === 'completed' + ? 'text-[#3d6b4a]' + : run.status === 'failed' + ? 'text-[#8b332a]' + : 'text-[#687069]' + + return ( + +
+

+ RUN {run.id.slice(0, 8)} +

+

+ {run.prompt || `项目 ${run.projectId.slice(0, 8)}`} +

+

+ {passedCount} / {totalCount} 步骤完成 +

+
+ {statusLabel} + + ) +} diff --git a/frontend/src/pages/home/choice-card.test.tsx b/frontend/src/pages/home/choice-card.test.tsx new file mode 100644 index 0000000..6ea4e8e --- /dev/null +++ b/frontend/src/pages/home/choice-card.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { HomeChoiceCard } from './choice-card' + +afterEach(cleanup) + +describe('HomeChoiceCard', () => { + it('只根据输入渲染一个可导航入口', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start') + }) + + it('can expose a separate secondary destination without nesting links', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: /新建项目/ }).getAttribute('href')).toBe( + '/projects/new', + ) + expect(screen.getByRole('link', { name: '查看项目历史' }).getAttribute('href')).toBe( + '/projects', + ) + }) +}) diff --git a/frontend/src/pages/home/choice-card.tsx b/frontend/src/pages/home/choice-card.tsx new file mode 100644 index 0000000..86d7b85 --- /dev/null +++ b/frontend/src/pages/home/choice-card.tsx @@ -0,0 +1,110 @@ +import { Link } from 'react-router' + +export type HomeChoiceCardTone = 'light' | 'dark' + +export interface HomeChoiceCardProps { + to: string + eyebrow: string + index: string + title: string + description: string + actionLabel: string + secondaryAction?: { + to: string + label: string + } + tone?: HomeChoiceCardTone +} + +/** Home 专用入口卡;只接收显示内容与目标路由,不持有业务状态。 */ +export function HomeChoiceCard({ + to, + eyebrow, + index, + title, + description, + actionLabel, + secondaryAction, + tone = 'light', +}: HomeChoiceCardProps) { + const dark = tone === 'dark' + + return ( +
+ +
+ ) +} diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx new file mode 100644 index 0000000..4410ffa --- /dev/null +++ b/frontend/src/pages/home/index.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { HomePage } from './index' + +afterEach(cleanup) + +describe('HomePage', () => { + it('提供快速开始、新建项目和项目历史两个明确入口', () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: /真正登场/ })).toBeTruthy() + expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start') + expect(screen.getByRole('link', { name: /新建项目/ }).getAttribute('href')).toBe( + '/workflow-editor', + ) + expect(screen.getByRole('link', { name: '查看项目历史' }).getAttribute('href')).toBe( + '/projects', + ) + }) +}) diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 7a153ad..0675181 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -1,13 +1,95 @@ -/** 首页:入口与项目概览。 */ -export function HomePage() { - return -} +import { HomeChoiceCard } from './choice-card' -function PagePlaceholder({ title }: { title: string }) { +/** 根入口只负责提供两种制作入口,不持有工作流业务状态。 */ +export function HomePage() { return ( -
-

{title}

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
+
+ ) } diff --git a/frontend/src/pages/playtest/README.md b/frontend/src/pages/playtest/README.md new file mode 100644 index 0000000..44624f2 --- /dev/null +++ b/frontend/src/pages/playtest/README.md @@ -0,0 +1,133 @@ +# Playtest + +Playtest 是角色动画的只读调试和核验界面。它既能直接操控角色验证 idle、walk、jump +之间的响应,也能逐帧查看动作、时长与位移,并在不改变角色素材的前提下记录当前会话的 +整体核验结论。 + +## 入口 + +- **Demo:`/playtest/demo`**。直接使用明确标注的少年开发 fixture,方便独立查看 idle 和 + walk 动作。 +- **正式:`/playtest/:characterId/:outfitId`**。需要由应用提供 `PlaytestPageApis.characters.get`; + 页面只读取指定角色,再从中选择对应 Outfit。 + +正式入口未配置角色接口时,页面会明确显示“Playtest 角色接口尚未配置”。它不会加载或回退到 +Demo 少年数据。 + +## 数据与只读边界 + +Demo 数据来自 `public/playtest-fixtures/boy/` 的已提取少年帧,以及 +`testing/demo-character.ts` 中的开发 fixture。素材来源和用途说明见该目录的 `SOURCE.md`。 + +Playtest 不修改角色、造型、动作、帧、工作流或版本。核验结论和逐帧问题都只保留在当前页面, +不写入本地存储或后端;#70 目前没有独立核验接口,因此此处不猜测持久化合同。 + +#70 的 `Action` 只提供一组 `frames`,Playtest 在页面适配器中把它映射为 `default` 方向, +不会把多方向结构加回 Character 实体。实体的 `rootMotion` 表示相对动作首帧的绝对位置; +播放器使用前会换算为相邻帧增量,避免重复累计。 + +## 统一工作台 + +页面只有一套 `PlaybackController`。动作列表、方向选择、统一舞台、播放控制、时间线, +以及右侧“帧检查 / 问题记录 / 资产导出”三个页签共同读取当前动作、方向和帧。键盘、按钮或 +时间线改变帧后,舞台与右侧工具会同步更新,不存在隐藏的第二套状态。 + +- 左侧动作栏展开宽度为 190px,也可收起为窄条;三栏在宽屏共用相同高度和底边,右侧长内容 + 在栏内滚动,不再向下撑出额外空白。 +- 按住 A/D 时继续播放当前已选中的动作;仅当当前动作为 `walk` 时才设置镜像方向(A 面向左, + D 面向右)。松开后,若按键前处于暂停状态则恢复暂停,原本正在播放则继续播放。 +- 没有选中动作时,A/D 不执行任何操作。 +- ← / → 才是当前序列的上一帧 / 下一帧控制,长按时可连续切帧。 +- W 选择首个包含有效帧的 jump 动作并从首帧播放。 +- S 选择首个包含有效帧的 crouch 动作并从首帧播放。 +- jump/crouch 缺失时,W/S 不改变当前动作,也不会用其他动作图片冒充。 + +当前 Demo 少年包含 idle 和 walk,不包含 jump 或 crouch,因此界面会明确显示“未提供跳跃 +动作”和“未提供下蹲动作”。以后正式 Character 提供对应动作后,同一套控制器会自动启用 +W/S。 + +## 播放与核验 + +- 初始为暂停;切换动作、方向或手动选择帧时暂停。 +- 每帧的 `durationMs` 优先;未提供有效时长时才按动作 FPS 计算显示与播放间隔。 +- 关闭循环时,播放停留在末帧;开启循环时,末帧会回到首帧。 +- 舞台始终只渲染当前帧角色,不叠加上一帧、下一帧或其他动作的角色虚影。 +- 有横向根位移的角色到达舞台左右边界后会自动掉头并继续播放。边界按舞台和角色的实际显示 + 宽度计算;窗口尺寸改变后会重新钳制。自动掉头不显示文字,也不触发额外播报。 + +## 逐帧自动审核依据 + +逐帧审核会在浏览器中读取每张图片的 Alpha 像素,提供只读的几何检查依据。Alpha 大于 +24 的像素视为候选前景;算法按八邻域过滤小于 `max(4 像素, 最大连通区域的 0.2%)` 的 +孤立噪点,同时始终保留最大主体。 + +右侧分别展示三类位移,不能混为一个数: + +- **画面内额外漂移**:过滤噪点后,相邻帧透明像素质心的变化;自动连续性判断只使用它。 +- **预期根位移增量**:当前帧的 `rootMotion`;它是本次自动播放帧推进应累加的增量,y 正值 + 表示向上。 +- **合成预览位移**:播放器按每次自动播放推进累计根位移后的最终位置;它用于解释播放效果, + 不重复参与自动异常判断。 + +自动审核会输出带问题代码、严重程度和帧位置的结构化结果,覆盖图片不可用、空白主体、画布 +尺寸、边缘裁切、覆盖率、相邻重复帧、位移突变、脚底/高度/面积变化,以及画面位移与根位移 +方向矛盾。重复帧依据主体边界内归一化的 8×8 Alpha/亮度指纹判断,避免小型精灵被整张透明 +画布稀释;位移异常同时使用序列中位数、MAD 和按动作设置的绝对上限,避免整段异常素材用 +自身均值掩盖问题。 + +阈值按动作类别解释:idle 严格检查脚底和高度,walk 允许周期变化,jump 允许离地,crouch +允许主体高度下降,attack/custom 使用更宽的序列离群范围。画布规格从当前序列中出现次数最多 +的可读尺寸推断;脚底、高度、位移、边缘留白和方向判断阈值以原 256 px 规则为校准基线,按 +推断画布尺度换算。主体覆盖率、轮廓面积变化和指纹距离本身已经归一化,继续使用比例阈值。 +因此整组一致的 512×512 等素材不会被误判为画布错误,混入不同尺寸的帧仍会被标记。 + +这些结果只能发现位置、缩放、背景和轮廓突变,不能判断变脸、人体结构、动作语义或服装 +细节。图片加载失败、完全透明、Canvas 不可用或跨域像素受限时会明确显示“无法计算”,不会 +伪造为 0。检查结果不会修改 Frame、Character 或核验结论;当前 Action 尚无明确循环 +合同,因此不计算循环首尾接缝。 + +## 人工问题记录 + +“问题记录”页签把自动发现和人工标记分开显示。用户可以为当前动作、方向和帧选择问题类型、 +补充说明,并修改或删除人工记录。人工问题只保存在当前 React 会话,刷新后清空,不写入 +localStorage、Character、Frame 或后端。“核验通过 / 发现问题”的总体结论也只保存在当前 +React 会话,且不会被逐帧问题自动改变。 + +## 游戏资产导出 + +“资产导出”页签只导出包含帧的动作序列。单个 ZIP 中只有: + +1. 保留原 MIME/扩展名的逐帧原图; +2. 每个“动作 × 方向”一张 PNG Sprite Sheet; +3. 与每张 Sprite Sheet 对应的 `animation.json`。 + +Sprite Sheet 每行最多 8 帧,不缩放原图;不同尺寸使用序列最大宽高作为单元格并居中。图片 +加载失败时仍生成资产包:对应单元格保持透明,JSON 标记 `available: false`,页面提示导出 +不完整。已有自动或人工问题时,导出页会先提示问题数量但不阻断导出。切换右侧页签不会卸载 +进行中的导出,因此不能通过切换页签并发启动第二份任务。ZIP 不包含 manifest、审核结果、 +核验结论或开发说明。打包采用同一导出文件内的标准无压缩 ZIP 封装,不新增运行时依赖。 + +## 键盘控制 + +| 按键 | 操作 | +| ---------- | ------------------------------------------------ | +| A | 按住时继续播放当前动作(walk 时面向左);松开后恢复原播放状态 | +| D | 按住时继续播放当前动作(walk 时面向右);松开后恢复原播放状态 | +| W | 有 jump 动作时从首帧播放;缺少时不改变当前状态 | +| S | 有 crouch 动作时从首帧播放;缺少时不改变当前状态 | +| Space | 播放或暂停当前动作 | +| ← / → | 当前序列上一帧 / 下一帧,可长按连续切帧 | +| Home / End | 当前序列首帧 / 尾帧 | +| L | 切换循环 | + +焦点位于按钮、输入框、文本区域、选择器或可编辑文本区域时,Playtest 不拦截键盘控制。 +长按 Space、L、W 或 S 不会重复触发;A、D、方向键、Home 和 End 保持可连续触发。 + +## 视觉参考 + +三栏信息布局只参考 `live-demo-ui-components/animation-workbench.tsx` 的视觉层次。统一舞台保留 +旧工程的网格、地面线和透明背景视觉。`rootMotion` 是逐帧位移增量,只在自动播放实际推进 +时累计;它为 `null` 或零时角色原地播放。向左/向右镜像、位移累计、时间线和检查器均由同一 +`PlaybackController` 驱动,不使用前端写死的行走速度、重力或跳跃高度。Playtest 不导入 Cocos +运行时、旧 iframe 或微信小程序适配;当前导出只生成通用逐帧、Sprite Sheet 与 JSON,不生成 +Cocos 或小程序工程。 diff --git a/frontend/src/pages/playtest/demo-page.tsx b/frontend/src/pages/playtest/demo-page.tsx new file mode 100644 index 0000000..9bc227b --- /dev/null +++ b/frontend/src/pages/playtest/demo-page.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react' +import { useLocation } from 'react-router' + +import type { Character } from '@/entities/character' +import { + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_CHARACTER_ID, + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_OUTFIT_ID, +} from './testing/demo-character' +import { PlaytestWorkbench } from './workbench' + +interface DemoPageState { + characterImageUrl?: string +} + +/** + * 开发预览入口。 + * + * 当从 Quick Start 导出时,location.state 携带 characterImageUrl, + * 用选中的角色图覆盖 demo 角色的母版和 base frame。 + */ +export function PlaytestDemoPage() { + const location = useLocation() + const state = location.state as DemoPageState | null + const characterImageUrl = state?.characterImageUrl + + const character = useMemo( + () => + characterImageUrl + ? overrideCharacterImage(PLAYTEST_DEMO_CHARACTER, characterImageUrl) + : PLAYTEST_DEMO_CHARACTER, + [characterImageUrl], + ) + + return ( + + ) +} + +function overrideCharacterImage(base: Character, imageUrl: string): Character { + return { + ...base, + outfits: base.outfits.map((outfit) => ({ + ...outfit, + characterTemplateUrl: imageUrl, + candidateCharacterTemplates: [ + { + id: `${PLAYTEST_DEMO_CHARACTER_ID}-quick-start`, + imageUrl, + attemptId: 'quick-start-export', + }, + ], + baseFrames: [{ imageUrl }], + })), + } +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 0000000..81c09c2 --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,133 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' + +import { PlaytestPage, type PlaytestPageApis } from './index' + +const character: Character = { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: 'Explorer', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrames: [{ imageUrl: 'https://cdn.example.test/base.png' }], + actions: [ + { + id: 'idle', + outfitId: 'outfit-1', + name: 'Idle', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: [ + { + imageUrl: 'https://cdn.example.test/idle.png', + durationMs: 125, + rootMotion: null, + }, + ], + }, + ], + }, + ], +} + +function renderPage(apis?: PlaytestPageApis, initialEntry = '/playtest/character-1/outfit-1') { + render( + + + } /> + + , + ) +} + +afterEach(() => cleanup()) + +describe('PlaytestPage', () => { + it('shows an explicit unconfigured boundary instead of inventing character data', () => { + renderPage() + + expect(screen.getByText('Playtest 角色接口尚未配置')).toBeTruthy() + }) + + it('loads the requested character through the standard skeleton API only', async () => { + const apis: PlaytestPageApis = { + characters: { get: vi.fn().mockResolvedValue(character) }, + } + + renderPage(apis, '/playtest/character-1/outfit-1?actionId=idle') + + expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'character-1 · Explorer' })).toBeTruthy() + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith('character-1') + }) + + it.each([{ code: 404 }, { status: 404 }])( + 'maps a missing character response to a stable message', + async (error) => { + renderPage({ characters: { get: vi.fn().mockRejectedValue(error) } }) + + expect(await screen.findByText('角色不存在')).toBeTruthy() + }, + ) + + it('does not mislabel a transport failure as not found', async () => { + renderPage({ + characters: { get: vi.fn().mockRejectedValue(new Error('network unavailable')) }, + }) + + expect(await screen.findByText('角色读取失败')).toBeTruthy() + }) + + it('ignores a stale character response after the route identity changes', async () => { + let resolveFirst: ((value: Character) => void) | undefined + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [{ ...character.outfits[0], characterId: 'character-2' }], + } + const get = vi.fn().mockReturnValueOnce(firstRequest).mockResolvedValueOnce(secondCharacter) + let navigate: NavigateFunction | undefined + + function NavigationProbe() { + navigate = useNavigate() + return null + } + + render( + + + + } + /> + + , + ) + + await act(async () => navigate?.('/playtest/character-2/outfit-1')) + expect(await screen.findByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy() + + await act(async () => resolveFirst?.(character)) + await waitFor(() => + expect(screen.getByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy(), + ) + expect(get).toHaveBeenNthCalledWith(1, 'character-1') + expect(get).toHaveBeenNthCalledWith(2, 'character-2') + }) +}) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 9796e86..eea852b 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,9 +1,102 @@ -/** 核验台。 */ -export function PlaytestPage() { +import { useEffect, useState } from 'react' +import { useParams, useSearchParams } from 'react-router' + +import type { Character, CharacterApis } from '@/entities/character' + +import { PlaytestWorkbench } from './workbench' + +export interface PlaytestPageApis { + characters: Pick +} + +export interface PlaytestPageProps { + apis?: PlaytestPageApis +} + +interface PageData { + character: Character | null + error: string | null + loading: boolean +} + +const initialPageData: PageData = { character: null, error: null, loading: false } + +function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const identifiable = error as { code?: unknown; status?: unknown } + return ( + identifiable.code === 404 || + identifiable.code === '404' || + identifiable.status === 404 || + identifiable.status === '404' + ) +} + +/** + * 正式 Playtest 页面只读取 #70 已定义的 Character 接口。 + * 当接口未配置时,自动回退到内置 demo 角色素材。 + * 核验与自动分析结果均停留在页面会话,不写回资产树。 + */ +export function PlaytestPage({ apis }: PlaytestPageProps) { + const { characterId, outfitId } = useParams() + const [searchParams] = useSearchParams() + const initialActionId = searchParams.get('actionId') + const [data, setData] = useState(initialPageData) + + useEffect(() => { + // 正式入口未配置角色接口时明确提示,不加载也不回退到 Demo 少年数据 + if (apis === undefined) { + setData({ + character: null, + error: 'Playtest 角色接口尚未配置', + loading: false, + }) + return + } + if (characterId === undefined || outfitId === undefined) { + setData({ ...initialPageData, error: 'Playtest 路由参数不完整' }) + return + } + + let cancelled = false + setData({ ...initialPageData, loading: true }) + void apis.characters.get(characterId).then( + (character) => { + if (!cancelled) setData({ character, error: null, loading: false }) + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) + + return () => { + cancelled = true + } + }, [apis, characterId, outfitId]) + + if (data.error !== null) return {data.error} + if (data.loading || data.character === null) + return 加载 Playtest 数据中 + + return ( + + ) +} + +function PlaytestPageMessage({ children }: { children: string }) { return ( -
-

核验台

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
+
+

{children}

+
) } diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts new file mode 100644 index 0000000..c55992f --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,112 @@ +/// + +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const playtestDirectory = fileURLToPath(new URL('.', import.meta.url)) +const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url)) +const prohibitedImports = [ + 'live-demo-ui-components', + 'entities/workflow-run', + 'entities/generation', +] as const +const allowedEntityImports = ['@/entities/character'] as const + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || entry.name.includes('.test.')) { + return [] + } + return [path] + }) +} + +function allTypeScriptFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return allTypeScriptFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) return [] + return [path] + }) +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? '', + ) +} + +function entityEntry(file: string, dependency: string): string | null { + if (dependency === '@/entities') return '' + if (dependency.startsWith('@/entities/')) return dependency.slice('@/entities/'.length) + if (!dependency.startsWith('.')) return null + + const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency)) + if (relativeEntry.startsWith('..') || isAbsolute(relativeEntry)) return null + + return relativeEntry.replaceAll('\\', '/').replace(/\/index$/, '') +} + +describe('Playtest architecture boundaries', () => { + it('does not import forbidden application or live-demo implementation layers', () => { + // Catches the isolated Playtest slice reaching into unrelated product layers. + for (const file of sourceFiles(playtestDirectory)) { + const source = readFileSync(file, 'utf8') + for (const fragment of prohibitedImports) { + expect(source, `${file} must not contain ${fragment}`).not.toContain(fragment) + } + } + }) + + it('does not import application or capabilities roots or subpaths', () => { + // Catches bypassing the isolated Page through either a barrel or a deep implementation path. + for (const file of sourceFiles(playtestDirectory)) { + const dependencies = moduleSpecifiers(readFileSync(file, 'utf8')) + expect( + dependencies.filter( + (dependency) => + dependency === '@/application' || + dependency.startsWith('@/application/') || + dependency === '@/capabilities' || + dependency.startsWith('@/capabilities/'), + ), + `${file} must not import application or capabilities`, + ).toEqual([]) + } + }) + + it('imports Entity contracts only from the two approved direct entrypoints', () => { + // Catches alias or relative root barrels and deep paths that hide Playtest's dependencies. + for (const file of sourceFiles(playtestDirectory)) { + const entityImports = moduleSpecifiers(readFileSync(file, 'utf8')) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null) + + for (const { dependency, entry } of entityImports) { + expect(allowedEntityImports, `${file} imports ${dependency}`).toContain( + `@/entities/${entry}`, + ) + } + } + }) + + it('keeps the demo fixture out of formal Playtest source', () => { + // Catches a formal route silently importing the demo character as an API fallback. + const importers = allTypeScriptFiles(playtestDirectory).filter((file) => + readFileSync(file, 'utf8').includes('testing/demo-character'), + ) + + expect( + importers.every( + (file) => file === join(playtestDirectory, 'demo-page.tsx') || file.includes('.test.'), + ), + ).toBe(true) + expect(readFileSync(join(playtestDirectory, 'index.tsx'), 'utf8')).not.toContain( + 'testing/demo-character', + ) + }) +}) diff --git a/frontend/src/pages/playtest/testing/demo-character.test.ts b/frontend/src/pages/playtest/testing/demo-character.test.ts new file mode 100644 index 0000000..ecb6ab6 --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.test.ts @@ -0,0 +1,50 @@ +/// + +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +import { + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_OUTFIT_ID, +} from './demo-character' +import { createPreviewModel } from '../workbench/model/create-preview-model' + +describe('PLAYTEST_DEMO_CHARACTER', () => { + it('provides a confirmed boy outfit with complete idle and walk fixtures', () => { + const outfit = PLAYTEST_DEMO_CHARACTER.outfits.find(({ id }) => id === PLAYTEST_DEMO_OUTFIT_ID) + + expect(PLAYTEST_DEMO_CHARACTER.outfits).toHaveLength(1) + expect(PLAYTEST_DEMO_CHARACTER.outfits[0]?.id).toBe(PLAYTEST_DEMO_OUTFIT_ID) + expect(outfit).toBeDefined() + expect(PLAYTEST_DEMO_ACTION_ID).toBe('playtest-demo-boy-idle') + expect(outfit!.actions.map((action) => action.type)).toEqual(['idle', 'walk']) + expect(outfit!.actions.every((action) => action.frames.length === 8)).toBe(true) + + const walkFrames = outfit!.actions.find((action) => action.type === 'walk')!.frames + const walkAbsoluteOffsets = walkFrames.flatMap((frame) => + frame.rootMotion === null ? [] : [frame.rootMotion.dx], + ) + expect(walkAbsoluteOffsets).toEqual([4, 8, 12, 16, 20, 24]) + + const preview = createPreviewModel(PLAYTEST_DEMO_CHARACTER, PLAYTEST_DEMO_OUTFIT_ID) + if (!preview.ok) throw new Error('expected demo preview model') + const walkPreviewFrames = preview.model.actions.find((action) => action.type === 'walk')! + .sequences[0].frames + expect(walkPreviewFrames.map((frame) => frame.rootMotion?.dx ?? 0)).toEqual([ + 0, 4, 4, 4, 4, 4, 4, 0, + ]) + + const frames = outfit!.actions.flatMap((action) => action.frames) + expect(frames.some((frame) => frame.durationMs !== null)).toBe(true) + expect(frames.some((frame) => frame.rootMotion !== null)).toBe(true) + + const imageUrls = [ + outfit!.characterTemplateUrl, + ...outfit!.candidateCharacterTemplates.map((candidate) => candidate.imageUrl), + ...outfit!.baseFrames.map((frame) => frame.imageUrl), + ...frames.map((frame) => frame.imageUrl), + ] + expect(imageUrls.every((url) => url !== null && existsSync(new URL(url)))).toBe(true) + }) +}) diff --git a/frontend/src/pages/playtest/testing/demo-character.ts b/frontend/src/pages/playtest/testing/demo-character.ts new file mode 100644 index 0000000..da6df19 --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.ts @@ -0,0 +1,159 @@ +import type { Character } from '../../../entities/character' + +export const PLAYTEST_DEMO_CHARACTER_ID = 'playtest-demo-boy' +export const PLAYTEST_DEMO_OUTFIT_ID = 'playtest-demo-boy-default' +export const PLAYTEST_DEMO_ACTION_ID = 'playtest-demo-boy-idle' + +const fixtureUrls = { + 'base.png': new URL('./fixtures/boy/base.png', import.meta.url).href, + 'idle-01.png': new URL('./fixtures/boy/idle-01.png', import.meta.url).href, + 'idle-02.png': new URL('./fixtures/boy/idle-02.png', import.meta.url).href, + 'idle-03.png': new URL('./fixtures/boy/idle-03.png', import.meta.url).href, + 'idle-04.png': new URL('./fixtures/boy/idle-04.png', import.meta.url).href, + 'idle-05.png': new URL('./fixtures/boy/idle-05.png', import.meta.url).href, + 'idle-06.png': new URL('./fixtures/boy/idle-06.png', import.meta.url).href, + 'idle-07.png': new URL('./fixtures/boy/idle-07.png', import.meta.url).href, + 'idle-08.png': new URL('./fixtures/boy/idle-08.png', import.meta.url).href, + 'walk-01.png': new URL('./fixtures/boy/walk-01.png', import.meta.url).href, + 'walk-02.png': new URL('./fixtures/boy/walk-02.png', import.meta.url).href, + 'walk-03.png': new URL('./fixtures/boy/walk-03.png', import.meta.url).href, + 'walk-04.png': new URL('./fixtures/boy/walk-04.png', import.meta.url).href, + 'walk-05.png': new URL('./fixtures/boy/walk-05.png', import.meta.url).href, + 'walk-06.png': new URL('./fixtures/boy/walk-06.png', import.meta.url).href, + 'walk-07.png': new URL('./fixtures/boy/walk-07.png', import.meta.url).href, + 'walk-08.png': new URL('./fixtures/boy/walk-08.png', import.meta.url).href, +} as const + +const fixtureUrl = (name: keyof typeof fixtureUrls) => fixtureUrls[name] +const demoWalkStep = 4 + +const idleFrames = [ + { + imageUrl: fixtureUrl('idle-01.png'), + durationMs: 160, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-02.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-03.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-04.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-05.png'), + durationMs: 160, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-06.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-07.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-08.png'), + durationMs: null, + rootMotion: null, + }, +] + +const walkFrames = [ + { + imageUrl: fixtureUrl('walk-01.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('walk-02.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-03.png'), + durationMs: 120, + rootMotion: { dx: demoWalkStep * 2, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-04.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep * 3, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-05.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep * 4, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-06.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep * 5, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-07.png'), + durationMs: 120, + rootMotion: { dx: demoWalkStep * 6, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-08.png'), + durationMs: null, + rootMotion: null, + }, +] + +export const PLAYTEST_DEMO_CHARACTER: Character = { + id: PLAYTEST_DEMO_CHARACTER_ID, + projectId: 'playtest-demo-project', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: PLAYTEST_DEMO_OUTFIT_ID, + characterId: PLAYTEST_DEMO_CHARACTER_ID, + name: '默认造型', + candidateCharacterTemplates: [ + { + id: 'playtest-demo-boy-template', + imageUrl: fixtureUrl('base.png'), + attemptId: 'playtest-demo-boy-import', + }, + ], + characterTemplateUrl: fixtureUrl('base.png'), + baseFrames: [{ imageUrl: fixtureUrl('base.png') }], + actions: [ + { + id: PLAYTEST_DEMO_ACTION_ID, + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: '待机', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: idleFrames, + }, + { + id: 'playtest-demo-boy-walk', + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: '行走', + kind: 'preset', + type: 'walk', + fps: 10, + keyFrameIndex: 0, + frames: walkFrames, + }, + ], + }, + ], +} diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md new file mode 100644 index 0000000..16a42e6 --- /dev/null +++ b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md @@ -0,0 +1,7 @@ +# Playtest fixture source + +- Original project: `E:/Compressed/windup-asset-lab-codex-issue-14-workflow-skeleton_5/windup-asset-lab-codex-issue-14-workflow-skeleton/` +- Original character (`card.json`): `少年 · 默认角色` +- Original `importedFrom`: `default-demo-assets` + +These files are copied only as development test data for the Playtest workbench. diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/base.png b/frontend/src/pages/playtest/testing/fixtures/boy/base.png new file mode 100644 index 0000000..7755f3c Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/base.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png new file mode 100644 index 0000000..7755f3c Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png new file mode 100644 index 0000000..4cc3e9b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png new file mode 100644 index 0000000..649ba1b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png new file mode 100644 index 0000000..b998e0b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png new file mode 100644 index 0000000..cdd4e01 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png new file mode 100644 index 0000000..0b1ccb7 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png new file mode 100644 index 0000000..23a0356 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png new file mode 100644 index 0000000..67936b2 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png new file mode 100644 index 0000000..ab3e368 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png new file mode 100644 index 0000000..7e50772 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png new file mode 100644 index 0000000..15b97a2 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png new file mode 100644 index 0000000..c3ac40e Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png new file mode 100644 index 0000000..12970d8 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png new file mode 100644 index 0000000..d5fe4a9 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png new file mode 100644 index 0000000..5c9f309 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png new file mode 100644 index 0000000..802a06e Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png differ diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx new file mode 100644 index 0000000..f94546e --- /dev/null +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -0,0 +1,52 @@ +import { StatusPanel } from './status-panel' + +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface AcceptanceProps { + inspectionStatus: PlaytestInspectionStatus | null + onRecordStatus(status: PlaytestInspectionStatus): void +} + +function statusText(status: PlaytestInspectionStatus | null): string { + if (status === 'passed') return '通过' + if (status === 'issues_found') return '发现问题' + return '尚未核验' +} + +/** 本次浏览会话的临时核验结论,不写回 Character 或任何后端记录。 */ +export function Acceptance({ inspectionStatus, onRecordStatus }: AcceptanceProps) { + return ( +
+
+

ACCEPTANCE

+

本次核验

+
+ +

{statusText(inspectionStatus)}

+

仅保存在当前页面,不写入后端

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/action-selector.tsx b/frontend/src/pages/playtest/workbench/action-selector.tsx new file mode 100644 index 0000000..92edb4b --- /dev/null +++ b/frontend/src/pages/playtest/workbench/action-selector.tsx @@ -0,0 +1,51 @@ +import type { PreviewAction } from './model/types' + +export interface ActionSelectorProps { + actions: readonly PreviewAction[] + selectedActionId: string | null + onSelectAction(actionId: string): void +} + +function frameCount(action: PreviewAction): number { + return action.sequences.reduce((total, sequence) => total + sequence.frames.length, 0) +} + +export function ActionSelector({ actions, selectedActionId, onSelectAction }: ActionSelectorProps) { + return ( + + ) +} diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts new file mode 100644 index 0000000..a1d7089 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { measureFrameGeometry, type FramePixelData } from './frame-geometry' + +function createPixels( + width: number, + height: number, + visible: readonly { x: number; y: number; alpha: number }[], +): FramePixelData { + const data = new Uint8ClampedArray(width * height * 4) + + for (const pixel of visible) data[(pixel.y * width + pixel.x) * 4 + 3] = pixel.alpha + + return { data, width, height } +} + +describe('measureFrameGeometry', () => { + it('treats only alpha values greater than 24 as visible', () => { + // Catches the review algorithm including matte noise at the old Alpha cutoff. + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 24 }]))).toBeNull() + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 25 }]))).toMatchObject({ + opaquePixels: 1, + coverageRatio: 1, + }) + }) + + it('measures bounds, centroid, foot line, height, area and coverage from visible pixels', () => { + // Catches off-by-one bounds or a centroid derived from the box instead of real visible pixels. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 1, y: 1, alpha: 255 }, + { x: 2, y: 1, alpha: 255 }, + { x: 1, y: 2, alpha: 255 }, + { x: 2, y: 2, alpha: 255 }, + ]), + ) + + expect(geometry).toEqual({ + width: 4, + height: 4, + bounds: { left: 1, top: 1, right: 2, bottom: 2, width: 2, height: 2 }, + centroid: { x: 1.5, y: 1.5 }, + footY: 2, + subjectHeight: 2, + opaquePixels: 4, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }) + expect(geometry?.fingerprint).toHaveLength(64) + }) + + it('produces different compact fingerprints for different silhouettes with equal bounds', () => { + const leftTop = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 2, y: 0, alpha: 255 }, + { x: 3, y: 0, alpha: 255 }, + { x: 3, y: 1, alpha: 255 }, + { x: 3, y: 2, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + const leftBottom = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 0, y: 2, alpha: 255 }, + { x: 0, y: 3, alpha: 255 }, + { x: 1, y: 3, alpha: 255 }, + { x: 2, y: 3, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(leftTop?.bounds).toEqual(leftBottom?.bounds) + expect(leftTop?.fingerprint).not.toEqual(leftBottom?.fingerprint) + }) + + it('keeps small dark silhouettes distinguishable instead of diluting them across the canvas', () => { + const topLeft: Array<{ x: number; y: number; alpha: number }> = [] + const bottomRight: Array<{ x: number; y: number; alpha: number }> = [] + for (let y = 96; y < 160; y += 1) { + for (let x = 96; x < 160; x += 1) { + if (y < 128 || x < 112) topLeft.push({ x, y, alpha: 255 }) + if (y >= 128 || x >= 144) bottomRight.push({ x, y, alpha: 255 }) + } + } + const first = measureFrameGeometry(createPixels(256, 256, topLeft)) + const second = measureFrameGeometry(createPixels(256, 256, bottomRight)) + const distance = + first?.fingerprint?.reduce( + (total, value, index) => total + Math.abs(value - (second?.fingerprint?.[index] ?? value)), + 0, + ) ?? 0 + + expect(first?.bounds).toEqual(second?.bounds) + expect(distance / 64).toBeGreaterThan(0.02) + }) + + it('rejects an RGBA buffer whose dimensions do not match its length', () => { + // Catches silent geometry corruption when Canvas data and dimensions diverge. + expect(() => + measureFrameGeometry({ data: new Uint8ClampedArray(4), width: 2, height: 2 }), + ).toThrowError('RGBA 像素长度与画布尺寸不一致') + }) + + it('ignores a tiny isolated Alpha component outside the visible subject', () => { + // Catches one stray generated pixel moving the measured foot line and centroid. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 1, y: 1, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(geometry).toMatchObject({ + bounds: { left: 0, top: 0, right: 1, bottom: 1, width: 2, height: 2 }, + centroid: { x: 0.5, y: 0.5 }, + footY: 1, + opaquePixels: 4, + coverageRatio: 0.25, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts new file mode 100644 index 0000000..5338300 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts @@ -0,0 +1,185 @@ +export const ALPHA_THRESHOLD = 24 +const MIN_COMPONENT_PIXELS = 4 +const RELATIVE_COMPONENT_RATIO = 0.002 + +export interface FramePixelData { + data: Uint8ClampedArray + width: number + height: number +} + +export interface FrameGeometry { + width: number + height: number + bounds: { + left: number + top: number + right: number + bottom: number + width: number + height: number + } + centroid: { x: number; y: number } + footY: number + subjectHeight: number + opaquePixels: number + coverageRatio: number + /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */ + fingerprint?: readonly number[] +} + +function createFingerprint( + data: Uint8ClampedArray, + width: number, + subjectPixels: readonly number[], + bounds: { left: number; top: number; width: number; height: number }, +): readonly number[] { + const sums = new Float64Array(64) + const cellPixels = new Uint32Array(64) + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + const offset = index * 4 + const red = data[offset] ?? 0 + const green = data[offset + 1] ?? 0 + const blue = data[offset + 2] ?? 0 + const alpha = (data[offset + 3] ?? 0) / 255 + const luminance = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255 + const cellX = Math.min(7, Math.floor(((x - bounds.left) * 8) / bounds.width)) + const cellY = Math.min(7, Math.floor(((y - bounds.top) * 8) / bounds.height)) + const cell = cellY * 8 + cellX + sums[cell] += alpha * (0.25 + luminance * 0.75) + cellPixels[cell] += 1 + } + + return Array.from(sums, (sum, index) => { + const count = cellPixels[index] ?? 0 + return count === 0 ? 0 : Number((sum / count).toFixed(4)) + }) +} + +interface VisibleComponentsResult { + components: number[][] + largestSize: number +} + +function visibleComponents( + data: Uint8ClampedArray, + width: number, + height: number, +): VisibleComponentsResult { + const pixelCount = width * height + const visible = new Uint8Array(pixelCount) + const visited = new Uint8Array(pixelCount) + const components: number[][] = [] + const queue = new Int32Array(pixelCount) + let largestSize = 0 + + for (let index = 0; index < pixelCount; index += 1) { + const alpha = data[index * 4 + 3] + if (alpha !== undefined && alpha > ALPHA_THRESHOLD) visible[index] = 1 + } + + for (let start = 0; start < pixelCount; start += 1) { + if (visible[start] === 0 || visited[start] === 1) continue + + const component: number[] = [] + let head = 0 + let tail = 0 + queue[tail++] = start + visited[start] = 1 + + while (head < tail) { + const index = queue[head++] + component.push(index) + + const x = index % width + const y = Math.floor(index / width) + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) continue + const nextX = x + offsetX + const nextY = y + offsetY + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) continue + + const next = nextY * width + nextX + if (visible[next] === 0 || visited[next] === 1) continue + visited[next] = 1 + queue[tail++] = next + } + } + } + + if (component.length > largestSize) largestSize = component.length + components.push(component) + } + + return { components, largestSize } +} + +export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | null { + const { data, width, height } = pixels + + if (data.length !== width * height * 4) { + throw new RangeError('RGBA 像素长度与画布尺寸不一致') + } + + const { components, largestSize } = visibleComponents(data, width, height) + if (components.length === 0) return null + + const minimumSize = Math.min( + largestSize, + Math.max(MIN_COMPONENT_PIXELS, Math.ceil(largestSize * RELATIVE_COMPONENT_RATIO)), + ) + const subjectPixels = components.flatMap((component) => + component.length === largestSize || component.length >= minimumSize ? component : [], + ) + + let left = width + let top = height + let right = -1 + let bottom = -1 + let opaquePixels = 0 + let sumX = 0 + let sumY = 0 + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + left = Math.min(left, x) + top = Math.min(top, y) + right = Math.max(right, x) + bottom = Math.max(bottom, y) + opaquePixels += 1 + sumX += x + sumY += y + } + + const subjectWidth = right - left + 1 + const subjectHeight = bottom - top + 1 + + return { + width, + height, + bounds: { + left, + top, + right, + bottom, + width: subjectWidth, + height: subjectHeight, + }, + centroid: { x: sumX / opaquePixels, y: sumY / opaquePixels }, + footY: bottom, + subjectHeight, + opaquePixels, + coverageRatio: opaquePixels / (width * height), + fingerprint: createFingerprint(data, width, subjectPixels, { + left, + top, + width: subjectWidth, + height: subjectHeight, + }), + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts new file mode 100644 index 0000000..507435a --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { readImageGeometry } from './image-geometry' + +type ImageBehavior = 'load' | 'error' | 'pending' + +let imageBehavior: ImageBehavior +let crossOriginAtSourceAssignment: string | null +let lastAssignedSource: string +let canvasContext: Pick | null + +class FakeImage { + crossOrigin: string | null = null + naturalWidth = 2 + naturalHeight = 2 + onerror: OnErrorEventHandler | null = null + onload: ((this: GlobalEventHandlers, event: Event) => unknown) | null = null + private source = '' + + get src(): string { + return this.source + } + + set src(value: string) { + this.source = value + crossOriginAtSourceAssignment = this.crossOrigin + lastAssignedSource = value + if (value === '' || imageBehavior === 'pending') return + + queueMicrotask(() => { + if (imageBehavior === 'load') this.onload?.call(this as never, new Event('load')) + else this.onerror?.call(this as never, 'error', '', 0, 0, new Error('load failed')) + }) + } +} + +function pixelsWithAlpha(alpha: number): ImageData { + const data = new Uint8ClampedArray(2 * 2 * 4) + data[3] = alpha + return { data, width: 2, height: 2, colorSpace: 'srgb' } as ImageData +} + +beforeEach(() => { + imageBehavior = 'load' + crossOriginAtSourceAssignment = null + lastAssignedSource = '' + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(255)), + } + vi.stubGlobal('Image', FakeImage) + vi.spyOn(document, 'createElement').mockImplementation(((tagName: string) => { + if (tagName !== 'canvas') + return document.createElementNS('http://www.w3.org/1999/xhtml', tagName) + return { + width: 0, + height: 0, + getContext: () => canvasContext, + } as unknown as HTMLCanvasElement + }) as typeof document.createElement) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('readImageGeometry', () => { + it('requests anonymous image access before reading real Canvas pixels', async () => { + // Catches crossOrigin being assigned after src or a placeholder geometry replacing actual pixels. + const result = await readImageGeometry('https://cdn.example.test/frame.png') + + expect(crossOriginAtSourceAssignment).toBe('anonymous') + expect(result).toEqual({ + status: 'ready', + geometry: { + width: 2, + height: 2, + bounds: { left: 0, top: 0, right: 0, bottom: 0, width: 1, height: 1 }, + centroid: { x: 0, y: 0 }, + footY: 0, + subjectHeight: 1, + opaquePixels: 1, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }, + }) + }) + + it('reports asset, Canvas and transparent-frame failures instead of zero evidence', async () => { + // Catches unavailable evidence being silently presented as a successful zero measurement. + imageBehavior = 'error' + await expect(readImageGeometry('/missing.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片加载失败', + }) + + imageBehavior = 'load' + canvasContext = null + await expect(readImageGeometry('/no-canvas.png')).resolves.toEqual({ + status: 'unavailable', + reason: '浏览器无法读取图片像素', + }) + + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(24)), + } + await expect(readImageGeometry('/transparent.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片没有可见主体', + }) + }) + + it('reports a tainted Canvas as a cross-origin pixel failure', async () => { + // Catches signed remote images being mislabelled as transparent or valid when CORS blocks inspection. + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => { + throw new DOMException('tainted', 'SecurityError') + }), + } + + await expect(readImageGeometry('https://remote.example.test/frame.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片跨域,无法计算像素', + }) + }) + + it('cancels a pending image read through AbortSignal', async () => { + // Catches a stale sequence load surviving a direction switch and updating the new review. + imageBehavior = 'pending' + const controller = new AbortController() + const result = readImageGeometry('/slow.png', controller.signal) + + controller.abort() + + await expect(result).resolves.toEqual({ + status: 'unavailable', + reason: '分析已取消', + }) + expect(lastAssignedSource).toBe('') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts new file mode 100644 index 0000000..b8ff09d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts @@ -0,0 +1,76 @@ +import { measureFrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' + +function unavailable(reason: string): FrameGeometryResult { + return { status: 'unavailable', reason } +} + +function pixelReadFailure(error: unknown): FrameGeometryResult { + if (error instanceof DOMException && error.name === 'SecurityError') { + return unavailable('图片跨域,无法计算像素') + } + + return unavailable('浏览器无法读取图片像素') +} + +/** + * 每次读取创建独立 canvas。帧检查是低频操作(每帧 onload 一次), + * 不缓存可避免模块级状态在测试间泄漏(mock 无法重置)。 + */ +function getSharedContext(width: number, height: number): CanvasRenderingContext2D | null { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas.getContext('2d', { willReadFrequently: true }) +} + +export function readImageGeometry( + imageUrl: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + const image = new Image() + let settled = false + + const finish = (result: FrameGeometryResult) => { + if (settled) return + settled = true + image.onload = null + image.onerror = null + signal?.removeEventListener('abort', abort) + resolve(result) + } + const abort = () => { + image.src = '' + finish(unavailable('分析已取消')) + } + + image.crossOrigin = 'anonymous' + image.onload = () => { + const context = getSharedContext(image.naturalWidth, image.naturalHeight) + + if (context === null) { + finish(unavailable('浏览器无法读取图片像素')) + return + } + + try { + context.drawImage(image, 0, 0) + const imageData = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight) + const geometry = measureFrameGeometry(imageData) + finish(geometry === null ? unavailable('图片没有可见主体') : { status: 'ready', geometry }) + } catch (error) { + finish(pixelReadFailure(error)) + } + } + image.onerror = () => finish(unavailable('图片加载失败')) + + if (signal?.aborted) { + abort() + return + } + + signal?.addEventListener('abort', abort, { once: true }) + image.src = imageUrl + }) +} diff --git a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts new file mode 100644 index 0000000..37d85a0 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts @@ -0,0 +1,128 @@ +import type { PlaytestActionType } from '../model/types' +import type { FrameGeometry } from './frame-geometry' + +export interface CanvasBaseline { + width: number + height: number +} + +export interface LocalQualityPolicy { + expectedCanvas: CanvasBaseline | null + edgeMargin: { x: number; y: number } + minimumCoverageRatio: number + maximumCoverageRatio: number + /** 相邻帧指纹平均距离 ≤ 该值时判定为重复帧(duplicate_frame)。 */ + duplicateDistance: number + footDriftThreshold: number | null + heightDriftThreshold: number | null + heightAttentionThreshold: number | null + areaDeltaThresholdPercent: number + movementPadding: number + movementFloor: number + movementCeiling: number + rootMotionDirectionMinimum: number +} + +const REFERENCE_CANVAS_SIZE = 256 +const MINIMUM_COVERAGE_RATIO = 0.005 +const MAXIMUM_COVERAGE_RATIO = 0.65 +const DUPLICATE_DISTANCE = 0.02 +const AREA_DELTA_THRESHOLD_PERCENT = 28 + +function scaledPixels(referencePixels: number, scale: number): number { + return Number((referencePixels * scale).toFixed(4)) +} + +function inferCanvasBaseline(geometries: readonly FrameGeometry[]): CanvasBaseline | null { + const candidates = new Map< + string, + { canvas: CanvasBaseline; count: number; firstIndex: number } + >() + + geometries.forEach((geometry, index) => { + const key = `${geometry.width}x${geometry.height}` + const existing = candidates.get(key) + if (existing) existing.count += 1 + else { + candidates.set(key, { + canvas: { width: geometry.width, height: geometry.height }, + count: 1, + firstIndex: index, + }) + } + }) + + const selected = [...candidates.values()].sort( + (left, right) => right.count - left.count || left.firstIndex - right.firstIndex, + )[0] + return selected?.canvas ?? null +} + +function heightReferencePixels(actionType: PlaytestActionType): number | null { + if (actionType === 'jump' || actionType === 'crouch') return null + if (actionType === 'idle') return 7 + if (actionType === 'walk') return 12 + return 20 +} + +function movementCeilingReferencePixels(actionType: PlaytestActionType): number { + if (actionType === 'idle') return 6 + if (actionType === 'walk') return 24 + if (actionType === 'crouch') return 16 + if (actionType === 'jump') return 64 + return 48 +} + +/** + * Keeps the existing local heuristics, but scales pixel thresholds from the sequence's dominant + * canvas size. The 256px values are calibration references, not a required asset contract. + */ +export function deriveLocalQualityPolicy( + geometries: readonly FrameGeometry[], + actionType: PlaytestActionType, +): LocalQualityPolicy { + const expectedCanvas = inferCanvasBaseline(geometries) + if (expectedCanvas === null) { + return { + expectedCanvas: null, + edgeMargin: { x: 1, y: 1 }, + minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, + maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, + duplicateDistance: DUPLICATE_DISTANCE, + + footDriftThreshold: null, + heightDriftThreshold: null, + heightAttentionThreshold: null, + areaDeltaThresholdPercent: AREA_DELTA_THRESHOLD_PERCENT, + movementPadding: 2, + movementFloor: 6, + movementCeiling: movementCeilingReferencePixels(actionType), + rootMotionDirectionMinimum: 2, + } + } + + const horizontalScale = expectedCanvas.width / REFERENCE_CANVAS_SIZE + const verticalScale = expectedCanvas.height / REFERENCE_CANVAS_SIZE + const distanceScale = Math.sqrt(horizontalScale * verticalScale) + const heightReference = heightReferencePixels(actionType) + + return { + expectedCanvas, + edgeMargin: { + x: Math.max(1, scaledPixels(2, horizontalScale)), + y: Math.max(1, scaledPixels(2, verticalScale)), + }, + minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, + maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, + duplicateDistance: DUPLICATE_DISTANCE, + footDriftThreshold: scaledPixels(3, verticalScale), + heightDriftThreshold: + heightReference === null ? null : scaledPixels(heightReference, verticalScale), + heightAttentionThreshold: scaledPixels(7, verticalScale), + areaDeltaThresholdPercent: AREA_DELTA_THRESHOLD_PERCENT, + movementPadding: scaledPixels(2, distanceScale), + movementFloor: scaledPixels(6, distanceScale), + movementCeiling: scaledPixels(movementCeilingReferencePixels(actionType), distanceScale), + rootMotionDirectionMinimum: scaledPixels(2, distanceScale), + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts new file mode 100644 index 0000000..76608ea --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest' + +import type { FrameGeometry } from './frame-geometry' +import { buildSequenceEvidence, type FrameEvidenceInput } from './sequence-evidence' + +function geometry( + overrides: Partial< + Pick< + FrameGeometry, + 'width' | 'height' | 'footY' | 'subjectHeight' | 'opaquePixels' | 'coverageRatio' + > + > & { x?: number; y?: number; fingerprint?: readonly number[]; cropped?: boolean } = {}, +): FrameGeometry { + const subjectHeight = overrides.subjectHeight ?? 20 + + return { + width: overrides.width ?? 256, + height: overrides.height ?? 256, + bounds: { + left: overrides.cropped ? 0 : 100, + top: overrides.cropped ? 0 : 100, + right: overrides.cropped ? 9 : 109, + bottom: overrides.cropped ? subjectHeight - 1 : 100 + subjectHeight - 1, + width: 10, + height: subjectHeight, + }, + centroid: { x: overrides.x ?? 0, y: overrides.y ?? 0 }, + footY: overrides.footY ?? 100, + subjectHeight, + opaquePixels: overrides.opaquePixels ?? 100, + coverageRatio: overrides.coverageRatio ?? 0.25, + fingerprint: overrides.fingerprint, + } +} + +function ready( + value: FrameGeometry, + rootMotion: FrameEvidenceInput['rootMotion'] = null, +): FrameEvidenceInput { + return { geometry: { status: 'ready', geometry: value }, rootMotion } +} + +describe('buildSequenceEvidence', () => { + it('returns structured findings for incomplete, cropped and duplicate frames', () => { + const fingerprint = Array.from({ length: 64 }, (_, index) => index / 64) + const evidence = buildSequenceEvidence( + [ + ready(geometry({ cropped: true, fingerprint })), + ready(geometry({ fingerprint })), + { geometry: { status: 'unavailable', reason: '图片没有可见主体' }, rootMotion: null }, + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['subject_cropped', 'duplicate_frame', 'blank_subject']), + ) + expect(evidence.findings.find((finding) => finding.code === 'duplicate_frame')).toMatchObject({ + frameIndex: 1, + severity: 'warning', + }) + }) + + it('uses action-aware findings for foot and height changes', () => { + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 30 })), + ready(geometry({ footY: 112, subjectHeight: 15 })), + ] + + expect(buildSequenceEvidence(frames, 'idle').findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['foot_drift', 'height_drift']), + ) + expect( + buildSequenceEvidence(frames, 'jump').findings.map((finding) => finding.code), + ).not.toContain('foot_drift') + expect( + buildSequenceEvidence(frames, 'crouch').findings.map((finding) => finding.code), + ).not.toContain('height_drift') + }) + + it('flags motion outliers and root-motion direction contradictions', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 }), { dx: 1, dy: 0 }), + ready(geometry({ x: 2 }), { dx: 1, dy: 0 }), + ready(geometry({ x: -18 }), { dx: 5, dy: 0 }), + ], + 'walk', + ) + + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['motion_spike', 'root_motion_mismatch']), + ) + }) + + it('calculates a selected frame delta and the hand-derived sequence baseline', () => { + // Catches image-derived offsets being calculated from bounds or against the first frame instead of the previous frame. + const evidence = buildSequenceEvidence( + [ready(geometry()), ready(geometry({ x: 3, y: 4, opaquePixels: 80 }))], + 'walk', + ) + + expect(evidence.frames[0]?.previousDelta).toBeNull() + expect(evidence.frames[1]?.previousDelta).toEqual({ + dx: 3, + dy: 4, + distance: 5, + areaDeltaPercent: 20, + }) + expect(evidence.summary).toMatchObject({ + medianStep: 5, + maxStep: 5, + movementThreshold: 15, + maxAreaDeltaPercent: 20, + movementState: 'normal', + areaState: 'normal', + }) + }) + + it('infers a consistent canvas baseline instead of requiring 256 pixels', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, footY: 200, subjectHeight: 40 })), + ready(geometry({ width: 512, height: 512, footY: 205, subjectHeight: 60 })), + ], + 'walk', + ) + + expect(evidence.findings.map((finding) => finding.code)).not.toContain('canvas_size_mismatch') + expect(evidence.summary).toMatchObject({ + expectedCanvas: { width: 512, height: 512 }, + footThreshold: 6, + heightThreshold: 24, + footState: 'normal', + heightState: 'normal', + }) + }) + + it('flags only frames that disagree with the locally inferred canvas baseline', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512 })), + ready(geometry({ width: 512, height: 512 })), + ready(geometry({ width: 256, height: 256 })), + ], + 'idle', + ) + + expect( + evidence.findings + .filter((finding) => finding.code === 'canvas_size_mismatch') + .map((finding) => finding.frameIndex), + ).toEqual([2]) + expect(evidence.summary.expectedCanvas).toEqual({ width: 512, height: 512 }) + expect(evidence.frames[2]?.previousDelta).toBeNull() + expect(evidence.findings.map((finding) => finding.code)).not.toContain('motion_spike') + }) + + it('excludes non-baseline canvas frames from sequence-level measurements', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, footY: 200, subjectHeight: 40 })), + ready(geometry({ width: 512, height: 512, footY: 205, subjectHeight: 42 })), + ready(geometry({ width: 512, height: 512, footY: 202, subjectHeight: 41 })), + ready(geometry({ width: 256, height: 256, footY: 100, subjectHeight: 10 })), + ready(geometry({ width: 256, height: 256, footY: 120, subjectHeight: 20 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + expectedCanvas: { width: 512, height: 512 }, + footDrift: 5, + heightDrift: 2, + }) + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['canvas_size_mismatch']), + ) + expect(evidence.findings.map((finding) => finding.code)).not.toContain('foot_drift') + }) + + it('scales the local motion floor with the inferred canvas size', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, x: 0 })), + ready(geometry({ width: 512, height: 512, x: 2 })), + ready(geometry({ width: 512, height: 512, x: 4 })), + ready(geometry({ width: 512, height: 512, x: 14 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + maxStep: 10, + movementThreshold: 12, + movementState: 'normal', + }) + expect(evidence.findings.map((finding) => finding.code)).not.toContain('motion_spike') + }) + + it('does not compare across an unreadable middle frame', () => { + // Catches filtered valid frames becoming false neighbours and producing a misleading offset. + const evidence = buildSequenceEvidence( + [ + ready(geometry()), + { geometry: { status: 'unavailable', reason: '图片加载失败' }, rootMotion: null }, + ready(geometry({ x: 20, y: 20 })), + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.unavailableFrameCount).toBe(1) + expect(evidence.frames.map((frame) => frame.previousDelta)).toEqual([null, null, null]) + expect(evidence.summary.medianStep).toBeNull() + expect(evidence.summary.movementState).toBe('not_applicable') + }) + + it('marks excessive coverage, foot drift and height drift with action-aware states', () => { + // Catches jump lift being rejected as foot drift or foreground/background problems being hidden. + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 20, coverageRatio: 0.66 })), + ready(geometry({ footY: 104, subjectHeight: 28 })), + ] + + const walk = buildSequenceEvidence(frames, 'walk') + const jump = buildSequenceEvidence(frames, 'jump') + + expect(walk.frames[0]?.coverageState).toBe('anomaly') + expect(walk.summary).toMatchObject({ + footDrift: 4, + heightDrift: 8, + footState: 'anomaly', + heightThreshold: 12, + heightState: 'normal', + }) + expect(jump.summary.footState).toBe('attention') + }) + + it('flags a single movement spike against the sequence median', () => { + // Catches a sudden position jump being normalized away by the animation's ordinary movement. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 })), + ready(geometry({ x: 2 })), + ready(geometry({ x: 22 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + medianStep: 1, + maxStep: 20, + movementThreshold: 6, + movementState: 'anomaly', + }) + expect(evidence.frames.map((frame) => frame.movementState)).toEqual([ + 'not_applicable', + 'normal', + 'normal', + 'anomaly', + ]) + }) + + it('keeps an action-aware absolute movement ceiling when every step is large', () => { + // Catches an entire drifting sequence normalizing its own 100px jumps through the median. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 100 })), ready(geometry({ x: 200 }))], + 'walk', + ) + + expect(evidence.summary.movementThreshold).toBeLessThan(100) + expect(evidence.summary.movementState).toBe('anomaly') + expect(evidence.findings.map((finding) => finding.code)).toContain('motion_spike') + }) + + it('marks jump and crouch height variation as action-allowed attention', () => { + const frames = [ready(geometry({ subjectHeight: 20 })), ready(geometry({ subjectHeight: 60 }))] + + for (const actionType of ['jump', 'crouch'] as const) { + const evidence = buildSequenceEvidence(frames, actionType) + expect(evidence.summary.heightThreshold).toBeNull() + expect(evidence.summary.heightState).toBe('attention') + expect(evidence.findings.map((finding) => finding.code)).not.toContain('height_drift') + } + }) + + it('flags adjacent outline area changes over 28 percent', () => { + // Catches a character silhouette abruptly shrinking without a visible review warning. + const evidence = buildSequenceEvidence( + [ready(geometry({ opaquePixels: 100 })), ready(geometry({ opaquePixels: 70 }))], + 'attack', + ) + + expect(evidence.summary).toMatchObject({ + maxAreaDeltaPercent: 30, + areaState: 'anomaly', + }) + expect(evidence.frames[1]?.areaState).toBe('anomaly') + }) + + it('marks adjacency-only checks not applicable for one frame', () => { + // Catches the first frame displaying fabricated zero deltas as a successful comparison. + const evidence = buildSequenceEvidence([ready(geometry())], 'idle') + + expect(evidence.frames[0]).toMatchObject({ + previousDelta: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + expect(evidence.summary).toMatchObject({ + footDrift: null, + heightDrift: null, + medianStep: null, + movementThreshold: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + }) + + it('combines measured image drift with adjacent root-motion increments using an upward y axis', () => { + // Catches root motion being subtracted twice or image-space positive-down y being added as positive-up. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0, y: 10 }), null), ready(geometry({ x: 3, y: 14 }), { dx: 10, dy: 6 })], + 'walk', + ) + + expect(evidence.frames[0]).toMatchObject({ + expectedRootDelta: null, + composedPreviewDelta: null, + }) + expect(evidence.frames[1]?.expectedRootDelta).toMatchObject({ dx: 10, dy: 6 }) + expect(evidence.frames[1]?.expectedRootDelta?.distance).toBeCloseTo(Math.sqrt(136)) + expect(evidence.frames[1]?.composedPreviewDelta).toMatchObject({ dx: 13, dy: 2 }) + expect(evidence.frames[1]?.composedPreviewDelta?.distance).toBeCloseTo(Math.sqrt(173)) + expect(evidence.frames[1]?.movementState).toBe('normal') + }) + + it('treats each frame root motion as an increment instead of subtracting the previous frame', () => { + // Catches repeated per-frame dx values collapsing to zero and leaving a walking sprite in place. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0, y: 10 }), null), + ready(geometry({ x: 1, y: 11 }), { dx: 2, dy: 1 }), + ready(geometry({ x: 3, y: 12 }), { dx: 3, dy: 2 }), + ], + 'walk', + ) + + expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 }) + expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts new file mode 100644 index 0000000..8fbbfb8 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts @@ -0,0 +1,473 @@ +import type { Frame } from '@/entities/character' + +import type { PlaytestActionType } from '../model/types' +import type { FrameGeometry } from './frame-geometry' +import { deriveLocalQualityPolicy, type CanvasBaseline } from './quality-policy' + +export type EvidenceState = 'normal' | 'attention' | 'anomaly' | 'not_applicable' + +export type QualityFindingCode = + | 'image_unavailable' + | 'blank_subject' + | 'canvas_size_mismatch' + | 'subject_cropped' + | 'coverage_too_low' + | 'coverage_too_high' + | 'duplicate_frame' + | 'motion_spike' + | 'foot_drift' + | 'height_drift' + | 'area_spike' + | 'root_motion_mismatch' + +export interface QualityFinding { + code: QualityFindingCode + severity: 'warning' | 'error' + frameIndex: number | null + message: string + metrics: Readonly> +} + +export type FrameGeometryResult = + | { status: 'ready'; geometry: FrameGeometry } + | { status: 'unavailable'; reason: string } + +export interface FrameEvidenceInput { + geometry: FrameGeometryResult + rootMotion: Frame['rootMotion'] +} + +export interface AdjacentFrameDelta { + dx: number + dy: number + distance: number + areaDeltaPercent: number +} + +export interface MotionVector { + dx: number + dy: number + distance: number +} + +export interface FrameReviewEvidence { + geometry: FrameGeometry | null + unavailableReason: string | null + previousDelta: AdjacentFrameDelta | null + expectedRootDelta: MotionVector | null + composedPreviewDelta: MotionVector | null + canvasState: EvidenceState + coverageState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState +} + +export interface SequenceReviewEvidence { + complete: boolean + unavailableFrameCount: number + frames: readonly FrameReviewEvidence[] + findings: readonly QualityFinding[] + summary: { + footDrift: number | null + heightDrift: number | null + medianStep: number | null + maxStep: number | null + movementThreshold: number | null + heightThreshold: number | null + footThreshold: number | null + areaThresholdPercent: number + expectedCanvas: CanvasBaseline | null + maxAreaDeltaPercent: number | null + canvasState: EvidenceState + footState: EvidenceState + heightState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState + } +} + +function medianAbsoluteDeviation(values: readonly number[], center: number | null): number | null { + if (center === null || values.length === 0) return null + return median(values.map((value) => Math.abs(value - center))) +} + +function fingerprintDistance( + left: readonly number[] | undefined, + right: readonly number[] | undefined, +): number | null { + if ( + left === undefined || + right === undefined || + left.length === 0 || + left.length !== right.length + ) + return null + + const total = left.reduce( + (sum, value, index) => sum + Math.abs(value - (right[index] ?? value)), + 0, + ) + return total / left.length +} + +function isCropped(geometry: FrameGeometry, margin: { x: number; y: number }): boolean { + return ( + geometry.bounds.left < margin.x || + geometry.bounds.top < margin.y || + geometry.bounds.right >= geometry.width - margin.x || + geometry.bounds.bottom >= geometry.height - margin.y + ) +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null + + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + const upper = sorted[middle] + if (upper === undefined) return null + + if (sorted.length % 2 === 1) return upper + + const lower = sorted[middle - 1] + return lower === undefined ? upper : (lower + upper) / 2 +} + +function spread(values: readonly number[]): number | null { + if (values.length < 2) return null + return Math.max(...values) - Math.min(...values) +} + +function adjacentDelta(previous: FrameGeometry, current: FrameGeometry): AdjacentFrameDelta { + const dx = current.centroid.x - previous.centroid.x + const dy = current.centroid.y - previous.centroid.y + + return { + dx, + dy, + distance: Math.hypot(dx, dy), + areaDeltaPercent: + (Math.abs(current.opaquePixels - previous.opaquePixels) / + Math.max(current.opaquePixels, previous.opaquePixels)) * + 100, + } +} + +function motionVector(dx: number, dy: number): MotionVector { + return { dx, dy, distance: Math.hypot(dx, dy) } +} + +function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } { + return frame.rootMotion ?? { dx: 0, dy: 0 } +} + +export function buildSequenceEvidence( + inputs: readonly FrameEvidenceInput[], + actionType: PlaytestActionType, +): SequenceReviewEvidence { + const results = inputs.map((input) => input.geometry) + const readyGeometries = results.flatMap((result) => + result.status === 'ready' ? [result.geometry] : [], + ) + const policy = deriveLocalQualityPolicy(readyGeometries, actionType) + const isBaselineGeometry = (geometry: FrameGeometry): boolean => + policy.expectedCanvas !== null && + geometry.width === policy.expectedCanvas.width && + geometry.height === policy.expectedCanvas.height + const deltas = results.map((result, index): AdjacentFrameDelta | null => { + const previous = results[index - 1] + if (index === 0 || previous?.status !== 'ready' || result.status !== 'ready') return null + if (!isBaselineGeometry(previous.geometry) || !isBaselineGeometry(result.geometry)) return null + return adjacentDelta(previous.geometry, result.geometry) + }) + const rootDeltas = inputs.map((input, index): MotionVector | null => { + if (index === 0) return null + + const increment = rootMotion(input) + return motionVector(increment.dx, increment.dy) + }) + const availableDeltas = deltas.flatMap((delta) => (delta === null ? [] : [delta])) + const steps = availableDeltas.map((delta) => delta.distance) + const areaDeltas = availableDeltas.map((delta) => delta.areaDeltaPercent) + const medianStep = median(steps) + const movementMad = medianAbsoluteDeviation(steps, medianStep) + const relativeMovementThreshold = + medianStep === null + ? null + : Math.max( + medianStep * 2.6 + policy.movementPadding, + medianStep + (movementMad ?? 0) * 3 + policy.movementPadding, + policy.movementFloor, + ) + const movementThreshold = + relativeMovementThreshold === null + ? null + : Math.min(relativeMovementThreshold, policy.movementCeiling) + const maxStep = steps.length === 0 ? null : Math.max(...steps) + const maxAreaDeltaPercent = areaDeltas.length === 0 ? null : Math.max(...areaDeltas) + const baselineGeometries = readyGeometries.filter(isBaselineGeometry) + const footDrift = spread(baselineGeometries.map((geometry) => geometry.footY)) + const heightDrift = spread(baselineGeometries.map((geometry) => geometry.subjectHeight)) + const unavailableFrameCount = results.length - readyGeometries.length + const findings: QualityFinding[] = [] + const heightThreshold = policy.heightDriftThreshold + + const frames = results.map((result, index): FrameReviewEvidence => { + const expectedRootDelta = rootDeltas[index] ?? null + if (result.status === 'unavailable') { + return { + geometry: null, + unavailableReason: result.reason, + previousDelta: null, + expectedRootDelta, + composedPreviewDelta: null, + canvasState: 'not_applicable', + coverageState: 'not_applicable', + movementState: 'not_applicable', + areaState: 'not_applicable', + } + } + + const delta = deltas[index] ?? null + const composedPreviewDelta = + delta === null || expectedRootDelta === null + ? null + : motionVector(delta.dx + expectedRootDelta.dx, expectedRootDelta.dy - delta.dy) + return { + geometry: result.geometry, + unavailableReason: null, + previousDelta: delta, + expectedRootDelta, + composedPreviewDelta, + canvasState: + policy.expectedCanvas !== null && + result.geometry.width === policy.expectedCanvas.width && + result.geometry.height === policy.expectedCanvas.height + ? 'normal' + : 'anomaly', + coverageState: + result.geometry.coverageRatio < policy.minimumCoverageRatio || + result.geometry.coverageRatio > policy.maximumCoverageRatio + ? 'anomaly' + : 'normal', + movementState: + delta === null || movementThreshold === null + ? 'not_applicable' + : delta.distance > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + delta === null + ? 'not_applicable' + : delta.areaDeltaPercent > policy.areaDeltaThresholdPercent + ? 'anomaly' + : 'normal', + } + }) + + results.forEach((result, index) => { + if (result.status === 'unavailable') { + const blank = result.reason.includes('没有可见主体') + findings.push({ + code: blank ? 'blank_subject' : 'image_unavailable', + severity: 'error', + frameIndex: index, + message: blank ? '当前帧没有可见主体' : result.reason, + metrics: {}, + }) + return + } + + const { geometry } = result + if ( + policy.expectedCanvas !== null && + (geometry.width !== policy.expectedCanvas.width || + geometry.height !== policy.expectedCanvas.height) + ) { + findings.push({ + code: 'canvas_size_mismatch', + severity: 'error', + frameIndex: index, + message: '画布尺寸与当前序列基线不一致', + metrics: { + width: geometry.width, + height: geometry.height, + expectedWidth: policy.expectedCanvas.width, + expectedHeight: policy.expectedCanvas.height, + }, + }) + } + if (isCropped(geometry, policy.edgeMargin)) { + findings.push({ + code: 'subject_cropped', + severity: 'error', + frameIndex: index, + message: '主体接触画布边缘,可能发生裁切', + metrics: { + left: geometry.bounds.left, + top: geometry.bounds.top, + right: geometry.bounds.right, + bottom: geometry.bounds.bottom, + }, + }) + } + if (geometry.coverageRatio < policy.minimumCoverageRatio) { + findings.push({ + code: 'coverage_too_low', + severity: 'warning', + frameIndex: index, + message: '主体在画布中的占比过小', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } else if (geometry.coverageRatio > policy.maximumCoverageRatio) { + findings.push({ + code: 'coverage_too_high', + severity: 'error', + frameIndex: index, + message: '主体在画布中的占比过大', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } + + const previous = results[index - 1] + if (previous?.status !== 'ready') return + const duplicateDistance = fingerprintDistance( + previous.geometry.fingerprint, + geometry.fingerprint, + ) + if (duplicateDistance !== null && duplicateDistance <= policy.duplicateDistance) { + findings.push({ + code: 'duplicate_frame', + severity: 'warning', + frameIndex: index, + message: '当前帧与上一帧高度相似', + metrics: { distance: duplicateDistance }, + }) + } + + const delta = deltas[index] + if (delta !== null && movementThreshold !== null && delta.distance > movementThreshold) { + findings.push({ + code: 'motion_spike', + severity: 'error', + frameIndex: index, + message: '相邻帧出现异常位移突变', + metrics: { distance: delta.distance, threshold: movementThreshold }, + }) + } + if (delta !== null && delta.areaDeltaPercent > policy.areaDeltaThresholdPercent) { + findings.push({ + code: 'area_spike', + severity: 'warning', + frameIndex: index, + message: '相邻帧主体轮廓面积变化过大', + metrics: { percent: delta.areaDeltaPercent }, + }) + } + + const expected = rootDeltas[index] + if ( + delta !== null && + expected !== null && + expected.distance >= policy.rootMotionDirectionMinimum && + delta.distance >= policy.rootMotionDirectionMinimum + ) { + const dot = delta.dx * expected.dx + -delta.dy * expected.dy + if (dot < 0) { + findings.push({ + code: 'root_motion_mismatch', + severity: 'warning', + frameIndex: index, + message: '画面内位移方向与预期根位移矛盾', + metrics: { dotProduct: dot }, + }) + } + } + }) + + if ( + footDrift !== null && + policy.footDriftThreshold !== null && + footDrift > policy.footDriftThreshold && + actionType !== 'jump' + ) { + findings.push({ + code: 'foot_drift', + severity: 'error', + frameIndex: null, + message: '序列脚底线漂移超过动作允许范围', + metrics: { drift: footDrift, threshold: policy.footDriftThreshold }, + }) + } + if (heightDrift !== null && heightThreshold !== null && heightDrift > heightThreshold) { + findings.push({ + code: 'height_drift', + severity: 'warning', + frameIndex: null, + message: '序列主体高度变化超过动作允许范围', + metrics: { drift: heightDrift, threshold: heightThreshold }, + }) + } + + return { + complete: results.length > 0 && unavailableFrameCount === 0, + unavailableFrameCount, + frames, + findings, + summary: { + footDrift, + heightDrift, + medianStep, + maxStep, + movementThreshold, + heightThreshold, + footThreshold: policy.footDriftThreshold, + areaThresholdPercent: policy.areaDeltaThresholdPercent, + expectedCanvas: policy.expectedCanvas, + maxAreaDeltaPercent, + canvasState: + policy.expectedCanvas === null + ? 'not_applicable' + : readyGeometries.every( + (geometry) => + geometry.width === policy.expectedCanvas?.width && + geometry.height === policy.expectedCanvas?.height, + ) + ? 'normal' + : 'anomaly', + footState: + footDrift === null + ? 'not_applicable' + : policy.footDriftThreshold === null + ? 'not_applicable' + : footDrift <= policy.footDriftThreshold + ? 'normal' + : actionType === 'jump' + ? 'attention' + : 'anomaly', + heightState: + heightDrift === null + ? 'not_applicable' + : heightThreshold === null + ? policy.heightAttentionThreshold !== null && + heightDrift > policy.heightAttentionThreshold + ? 'attention' + : 'normal' + : heightDrift > heightThreshold + ? 'anomaly' + : 'normal', + movementState: + maxStep === null || movementThreshold === null + ? 'not_applicable' + : maxStep > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + maxAreaDeltaPercent === null + ? 'not_applicable' + : maxAreaDeltaPercent > policy.areaDeltaThresholdPercent + ? 'anomaly' + : 'normal', + }, + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx new file mode 100644 index 0000000..c259088 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx @@ -0,0 +1,203 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewSequence } from '../model/types' +import type { FrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' +import { useFrameReviewEvidence, type ImageGeometryReader } from './use-frame-review-evidence' + +function sequence(...imageUrls: string[]): PreviewSequence { + return { + direction: 'south', + frames: imageUrls.map((imageUrl) => ({ + imageUrl, + durationMs: 100, + rootMotion: null, + keyFrame: false, + })), + } +} + +function geometry(x: number, y = 10): FrameGeometryResult { + const value: FrameGeometry = { + width: 256, + height: 256, + bounds: { left: x, top: 0, right: x + 9, bottom: 19, width: 10, height: 20 }, + centroid: { x, y }, + footY: 19, + subjectHeight: 20, + opaquePixels: 100, + coverageRatio: 100 / (256 * 256), + } + return { status: 'ready', geometry: value } +} + +function deferred(): { + promise: Promise + resolve(value: T): void +} { + let resolvePromise: ((value: T) => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { + promise, + resolve(value) { + if (resolvePromise === null) throw new Error('deferred promise is not initialized') + resolvePromise(value) + }, + } +} + +afterEach(cleanup) + +describe('useFrameReviewEvidence', () => { + it('exposes loading before producing real sequence evidence', async () => { + // Catches the Inspector flashing fabricated zeros before image analysis completes. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const { result } = renderHook(() => + useFrameReviewEvidence(sequence('/frame-1.png'), 'walk', reader), + ) + + expect(result.current).toEqual({ status: 'loading', evidence: null }) + + await act(async () => pending.resolve(geometry(4))) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(4) + }) + + it('ignores a previous sequence result that resolves after a direction switch', async () => { + // Catches a slow old direction replacing the evidence for the currently selected direction. + const south = deferred() + const north = deferred() + const reader: ImageGeometryReader = (imageUrl) => + imageUrl.includes('south') ? south.promise : north.promise + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + rerender({ current: sequence('/north.png') }) + await act(async () => north.resolve(geometry(20))) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20)) + + await act(async () => south.resolve(geometry(2))) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20) + }) + + it('passes an abort signal to image reads and aborts stale sequence work', () => { + const pending = deferred() + const reader = vi.fn(() => pending.promise) + const { rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + const firstSignal = reader.mock.calls[0]?.[1] + expect(firstSignal).toBeInstanceOf(AbortSignal) + expect(firstSignal?.aborted).toBe(false) + + rerender({ current: sequence('/north.png') }) + + expect(firstSignal?.aborted).toBe(true) + expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal) + }) + + it('reuses settled image results when frames are selected or revisited', async () => { + // Catches frame navigation repeatedly decoding every image in the same review session. + const reader = vi.fn(async (imageUrl) => + geometry(imageUrl.includes('one') ? 1 : 2), + ) + const first = sequence('/one.png') + const second = sequence('/two.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: first } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + rerender({ current: first }) + expect(reader).toHaveBeenCalledTimes(1) + + rerender({ current: second }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: first }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(1)) + expect(reader).toHaveBeenCalledTimes(2) + }) + + it('retries an unavailable image when its sequence is revisited', async () => { + // A transient image failure must not become a permanent session-level cache entry. + let failedOnce = false + const reader = vi.fn(async (imageUrl) => { + if (imageUrl.includes('retry') && !failedOnce) { + failedOnce = true + return { status: 'unavailable', reason: 'temporary failure' } + } + return geometry(imageUrl.includes('retry') ? 9 : 2) + }) + const retrySequence = sequence('/retry.png') + const otherSequence = sequence('/other.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: retrySequence } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.complete).toBe(false) + + rerender({ current: otherSequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: retrySequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(9)) + + expect(reader).toHaveBeenCalledTimes(3) + }) + + it('stays idle without a review sequence and does not read an image', () => { + // Catches direct-control mode starting hidden Canvas work. + const reader = vi.fn() + const { result } = renderHook(() => useFrameReviewEvidence(null, null, reader)) + + expect(result.current).toEqual({ status: 'idle', evidence: null }) + expect(reader).not.toHaveBeenCalled() + }) + + it('does not update React state after the consumer unmounts', async () => { + // Catches an image completion writing into a removed Playtest workbench. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const mounted = renderHook(() => useFrameReviewEvidence(sequence('/slow.png'), 'walk', reader)) + + mounted.unmount() + await act(async () => pending.resolve(geometry(1))) + + expect(consoleError).not.toHaveBeenCalled() + }) + + it('keeps each preview frame root motion beside its measured geometry', async () => { + // Catches asynchronous image results losing their matching motion contract before aggregation. + const base = sequence('/first.png', '/second.png') + const motionSequence: PreviewSequence = { + ...base, + frames: [ + { ...base.frames[0]!, rootMotion: null }, + { ...base.frames[1]!, rootMotion: { dx: 10, dy: 6 } }, + ], + } + const reader: ImageGeometryReader = async (imageUrl) => + imageUrl.includes('first') ? geometry(0, 10) : geometry(3, 14) + const { result } = renderHook(() => useFrameReviewEvidence(motionSequence, 'walk', reader)) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[1]).toMatchObject({ + expectedRootDelta: { dx: 10, dy: 6 }, + composedPreviewDelta: { dx: 13, dy: 2 }, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts new file mode 100644 index 0000000..ad70061 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react' + +import type { PlaytestActionType, PreviewFrame, PreviewSequence } from '../model/types' +import { readImageGeometry } from './image-geometry' +import { + buildSequenceEvidence, + type FrameGeometryResult, + type SequenceReviewEvidence, +} from './sequence-evidence' + +export type FrameReviewEvidenceState = + | { status: 'idle'; evidence: null } + | { status: 'loading'; evidence: null } + | { status: 'ready'; evidence: SequenceReviewEvidence } + +export type ImageGeometryReader = ( + imageUrl: string, + signal?: AbortSignal, +) => Promise + +interface CachedReads { + reader: ImageGeometryReader + entries: Map +} + +interface ResolvedState { + key: string | null + value: FrameReviewEvidenceState +} + +const IDLE_STATE: FrameReviewEvidenceState = { status: 'idle', evidence: null } +const LOADING_STATE: FrameReviewEvidenceState = { status: 'loading', evidence: null } + +export function useFrameReviewEvidence( + sequence: PreviewSequence | null, + actionType: PlaytestActionType | null, + reader: ImageGeometryReader = readImageGeometry, +): FrameReviewEvidenceState { + const sequenceKey = + sequence === null || actionType === null + ? null + : JSON.stringify([ + actionType, + ...sequence.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + rootMotion: frame.rootMotion, + })), + ]) + const cache = useRef(null) + const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE }) + + if (cache.current === null || cache.current.reader !== reader) { + cache.current = { reader, entries: new Map() } + } + + useEffect(() => { + if (sequenceKey === null || actionType === null) { + setResolved({ key: null, value: IDLE_STATE }) + return + } + + const [, ...frameDescriptors] = JSON.parse(sequenceKey) as [ + PlaytestActionType, + ...Array<{ imageUrl: string; rootMotion: PreviewFrame['rootMotion'] }>, + ] + const imageUrls = frameDescriptors.map((frame) => frame.imageUrl) + + const controller = new AbortController() + let active = true + const reads = cache.current?.entries + const inFlight = new Map>() + + setResolved({ key: sequenceKey, value: LOADING_STATE }) + + const results = imageUrls.map((imageUrl) => { + const cached = reads?.get(imageUrl) + if (cached !== undefined) return Promise.resolve(cached) + + const existing = inFlight.get(imageUrl) + if (existing !== undefined) return existing + + const pending = reader(imageUrl, controller.signal) + .catch((): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' })) + .then((result) => { + if (!controller.signal.aborted && result.status === 'ready') reads?.set(imageUrl, result) + return result + }) + inFlight.set(imageUrl, pending) + return pending + }) + + void Promise.all(results).then((frameResults) => { + if (!active || controller.signal.aborted) return + + setResolved({ + key: sequenceKey, + value: { + status: 'ready', + evidence: buildSequenceEvidence( + frameResults.map((geometry, index) => ({ + geometry, + rootMotion: frameDescriptors[index]?.rootMotion ?? null, + })), + actionType, + ), + }, + }) + }) + + return () => { + active = false + controller.abort() + } + }, [actionType, reader, sequenceKey]) + + if (sequenceKey === null) return IDLE_STATE + return resolved.key === sequenceKey ? resolved.value : LOADING_STATE +} diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx new file mode 100644 index 0000000..3636cb9 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx @@ -0,0 +1,169 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ActionSelector } from './action-selector' +import { AnimationStage } from './animation-stage' +import { FrameTimeline } from './frame-timeline' +import { PlaybackControls } from './playback-controls' +import type { PreviewAction, PreviewFrame, PreviewSequence } from './model/types' + +const currentFrame: PreviewFrame = { + imageUrl: 'https://cdn.example.test/current.png', + durationMs: 100, + rootMotion: { dx: 12, dy: 5 }, + keyFrame: true, +} + +const nextFrame: PreviewFrame = { + ...currentFrame, + imageUrl: 'https://cdn.example.test/next.png', + keyFrame: false, +} + +const sequence: PreviewSequence = { + direction: 'south', + frames: [currentFrame, nextFrame], +} + +const actions: readonly PreviewAction[] = [ + { + id: 'walk', + name: '行走', + type: 'walk', + fps: 12, + sequences: [sequence], + }, + { + id: 'empty', + name: '空动作', + type: 'idle', + fps: 12, + sequences: [], + }, +] + +afterEach(cleanup) + +describe('playtest visual primitives', () => { + it('renders prop-supplied action names, FPS and actual frame counts while disabling empty actions', () => { + // Catches a selector replacing supplied actions with demo data or permitting a non-playable action. + const onSelectAction = vi.fn() + + render( + , + ) + + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('12 FPS') + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('2 帧') + expect((screen.getByRole('button', { name: /空动作/ }) as HTMLButtonElement).disabled).toBe( + true, + ) + + fireEvent.click(screen.getByRole('button', { name: /行走/ })) + expect(onSelectAction).toHaveBeenCalledWith('walk') + }) + + it('uses the real frame URL and reports image failure with the accumulated mirrored transform', () => { + // Catches the stage reading per-frame root motion instead of the accumulated owner state, inverting y incorrectly, or hiding load errors. + render( + , + ) + + const image = screen.getByRole('img', { name: '角色动画预览' }) + expect(image.getAttribute('src')).toBe(currentFrame.imageUrl) + expect(image.getAttribute('style')).toContain('translate(18px, -7px) scaleX(-1)') + expect(screen.getAllByRole('img')).toHaveLength(1) + + fireEvent.error(image) + expect(screen.getByText('当前帧图片加载失败')).toBeTruthy() + }) + + it('reports horizontal travel from the measured stage and actor widths', () => { + const onHorizontalBoundsChange = vi.fn() + const rect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const width = this.getAttribute('aria-label') === '动画预览舞台' ? 600 : 200 + return { + width, + height: 400, + x: 0, + y: 0, + top: 0, + right: width, + bottom: 400, + left: 0, + toJSON: () => ({}), + } + }) + + render( + , + ) + + fireEvent.load(screen.getByRole('img', { name: '角色动画预览' })) + expect(onHorizontalBoundsChange).toHaveBeenLastCalledWith({ minX: -200, maxX: 200 }) + rect.mockRestore() + }) + + it('surfaces key-frame markers and delegates timeline selection without its own playback behavior', () => { + // Catches a timeline dropping key-frame annotations or mutating playback rather than using its selection callback. + const onSelectFrame = vi.fn() + + render( + , + ) + + expect(screen.getByText('关键帧')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '第 2 帧' })) + expect(onSelectFrame).toHaveBeenCalledWith(1) + }) + + it('only relays playback controller callbacks', () => { + // Catches controls owning local play state instead of reporting user intent to the controller. + const onTogglePlaying = vi.fn() + const onNextFrame = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '播放' })) + fireEvent.click(screen.getByRole('button', { name: '下一帧' })) + expect(onTogglePlaying).toHaveBeenCalledTimes(1) + expect(onNextFrame).toHaveBeenCalledTimes(1) + expect(screen.getByText('A 左行走')).toBeTruthy() + expect(screen.getByText('D 右行走')).toBeTruthy() + expect(screen.getByText('未提供跳跃动作')).toBeTruthy() + expect(screen.getByText('下蹲动作可用')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx new file mode 100644 index 0000000..d3bca47 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { PreviewFrame } from './model/types' +import type { HorizontalStageBounds, StageOffset } from './stage-motion' + +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8] as const +const DEFAULT_ZOOM = 4 // 64px sprite at 4x = 256px on screen +/** 高分辨率精灵(>=192px)首次加载时用 1x,避免 256px 素材被默认放大到 1024px。 */ +const HIGH_RES_ZOOM = 1 +const HIGH_RES_THRESHOLD_PX = 192 + +export interface AnimationStageProps { + currentFrame: PreviewFrame | null + /** Accumulated playback position in world coordinates (positive y is up). */ + motionOffset: StageOffset + mirrored: boolean + showGrid: boolean + showChecker: boolean + onHorizontalBoundsChange?(bounds: HorizontalStageBounds | null): void +} + +export function AnimationStage({ + currentFrame, + motionOffset, + mirrored, + showGrid, + showChecker, + onHorizontalBoundsChange, +}: AnimationStageProps) { + const [failedImageUrl, setFailedImageUrl] = useState(null) + const [zoomIndex, setZoomIndex] = useState(() => ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) + const stageRef = useRef(null) + const imageRef = useRef(null) + + const zoom = ZOOM_LEVELS[zoomIndex] ?? DEFAULT_ZOOM + const zoomIn = useCallback(() => { + setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1)) + }, []) + const zoomOut = useCallback(() => { + setZoomIndex((i) => Math.max(i - 1, 0)) + }, []) + const resetZoom = useCallback(() => { + setZoomIndex(ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) + }, []) + + const reportHorizontalBounds = useCallback(() => { + if (onHorizontalBoundsChange === undefined) return + const stage = stageRef.current + const image = imageRef.current + if (stage === null || image === null) { + onHorizontalBoundsChange(null) + return + } + + const stageWidth = stage.getBoundingClientRect().width + const actorWidth = image.getBoundingClientRect().width + if (stageWidth <= 0 || actorWidth <= 0) { + onHorizontalBoundsChange(null) + return + } + const travel = Math.max(0, (stageWidth - actorWidth) / 2) + onHorizontalBoundsChange({ minX: -travel, maxX: travel }) + }, [onHorizontalBoundsChange]) + + useEffect(() => { + setFailedImageUrl(null) + }, [currentFrame?.imageUrl]) + + // Mouse wheel zoom on stage + useEffect(() => { + const stage = stageRef.current + if (stage === null) return + const handleWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return + e.preventDefault() + setZoomIndex((i) => { + if (e.deltaY < 0) return Math.min(i + 1, ZOOM_LEVELS.length - 1) + if (e.deltaY > 0) return Math.max(i - 1, 0) + return i + }) + } + stage.addEventListener('wheel', handleWheel, { passive: false }) + return () => stage.removeEventListener('wheel', handleWheel) + }, []) + + useEffect(() => { + reportHorizontalBounds() + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', reportHorizontalBounds) + return () => window.removeEventListener('resize', reportHorizontalBounds) + } + + const observer = new ResizeObserver(reportHorizontalBounds) + if (stageRef.current !== null) observer.observe(stageRef.current) + if (imageRef.current !== null) observer.observe(imageRef.current) + return () => observer.disconnect() + }, [currentFrame?.imageUrl, reportHorizontalBounds]) + + const imageFailed = currentFrame !== null && failedImageUrl === currentFrame.imageUrl + + return ( +
+ {showGrid ? ( +
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx new file mode 100644 index 0000000..76d98cf --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewFrame } from '../model/types' +import { AuditPanel } from './audit-panel' + +const frame: PreviewFrame = { + imageUrl: '/walk-03.png', + durationMs: 100, + rootMotion: { dx: 4, dy: 0 }, + keyFrame: false, +} + +afterEach(cleanup) + +describe('AuditPanel', () => { + it('marks the current frame and keeps automatic findings read-only', () => { + const onAdd = vi.fn() + render( + , + ) + + expect(screen.getByText('自动')).toBeTruthy() + expect(screen.getByText('主体接触画布边缘,可能发生裁切')).toBeTruthy() + fireEvent.change(screen.getByLabelText('问题类型'), { + target: { value: 'style_inconsistent' }, + }) + fireEvent.change(screen.getByLabelText('问题说明'), { + target: { value: '衣服颜色跳变' }, + }) + fireEvent.click(screen.getByRole('button', { name: '标记当前帧问题' })) + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'style_inconsistent', + actionId: 'walk', + direction: 'south', + frameIndex: 2, + imageUrl: '/walk-03.png', + note: '衣服颜色跳变', + }), + ) + }) + + it('edits and removes an existing manual issue', () => { + const onUpdate = vi.fn() + const onRemove = vi.fn() + render( + , + ) + + expect(screen.getByText('人工')).toBeTruthy() + fireEvent.change(screen.getByLabelText('人工问题类型'), { + target: { value: 'motion_direction' }, + }) + fireEvent.change(screen.getByLabelText('人工问题说明'), { + target: { value: '移动方向错误' }, + }) + fireEvent.click(screen.getByRole('button', { name: '删除人工问题' })) + + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_direction', '步幅突然变化') + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_discontinuity', '移动方向错误') + expect(onRemove).toHaveBeenCalledWith('manual-1') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx new file mode 100644 index 0000000..6241e5d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' + +import type { QualityFinding } from '../analysis/sequence-evidence' +import type { PlaytestDirection, PreviewFrame } from '../model/types' +import type { ManualAuditIssue, ManualIssueCategory } from './audit-session' + +const CATEGORY_LABELS: Readonly> = { + subject_cropped: '主体裁切', + transparency: '透明背景异常', + image_unavailable: '空白或加载失败', + duplicate_frame: '重复帧', + motion_discontinuity: '动作抖动或不连续', + motion_direction: '位移或方向错误', + style_inconsistent: '风格不一致', + other: '其他', +} + +let fallbackId = 0 + +function createIssueId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + fallbackId += 1 + return `manual-${Date.now()}-${fallbackId}` +} + +export interface AuditPanelProps { + actionId: string | null + actionName: string | null + direction: PlaytestDirection | null + frameIndex: number + frame: PreviewFrame | null + automaticFindings: readonly QualityFinding[] + issues: readonly ManualAuditIssue[] + onAdd(issue: ManualAuditIssue): void + onUpdate(id: string, category: ManualIssueCategory, note: string): void + onRemove(id: string): void +} + +export function AuditPanel({ + actionId, + actionName, + direction, + frameIndex, + frame, + automaticFindings, + issues, + onAdd, + onUpdate, + onRemove, +}: AuditPanelProps) { + const [category, setCategory] = useState('subject_cropped') + const [note, setNote] = useState('') + const canMark = actionId !== null && direction !== null && frame !== null + + return ( +
+
+

QUALITY ISSUES

+

问题记录

+

仅保留在当前 Playtest 会话

+
+ +
+

自动发现

+ {automaticFindings.length === 0 ? ( +

当前序列没有自动问题

+ ) : ( +
    + {automaticFindings.map((finding, index) => ( +
  • +
    + {finding.message} + 自动 +
    + + {finding.frameIndex === null ? '整段序列' : `第 ${finding.frameIndex + 1} 帧`} + +
  • + ))} +
+ )} +
+ +
+

+ 标记当前帧{actionName === null ? '' : ` · ${actionName} #${frameIndex + 1}`} +

+ + + + + ` + } else if (isPassed) { + bodyHtml = ` +
角色设定${statusLabel}
+

已提交角色设定

+ ` + } else { + bodyHtml = `
角色设定${statusLabel}
` + } + break + + case 'character-template': + if (isActive) { + bodyHtml = ` +
生成角色图生成中…
+
${Array.from( + { length: 81 }, + (_, i) => { + const x = (i % 9) - 4, + y = Math.floor(i / 9) - 4 + const ring = Math.max(Math.abs(x), Math.abs(y)) + return `` + }, + ).join('')}
+ 正在生成 6 张候选母版… + ` + } else if (isPassed) { + bodyHtml = ` +
生成角色图${statusLabel}
+

角色图已生成,下一步确认候选。

+ ` + } else { + bodyHtml = `
生成角色图${statusLabel}
` + } + break + + case 'template-candidate': + if (isActive) { + const templateStep = getCurrentRevision(run)?.steps.find( + (item) => item.type === 'character-template', + ) + const candidates = + templateStep?.type === 'character-template' ? (templateStep.output?.images ?? []) : [] + bodyHtml = ` +
确认候选${statusLabel}
+

从候选中选择一张作为身份母版。

+
+ ${candidates.map((candidate, index) => ``).join('')} +
+ ${candidates.length > 0 ? '' : '

生成结果中没有可用候选。

'} + ` + } else if (isPassed) { + bodyHtml = ` +
确认候选${statusLabel}
+

已确认身份母版。

+ ` + } else { + bodyHtml = `
确认候选${statusLabel}
` + } + break + + case 'action-generation': + if (isActive) { + bodyHtml = ` +
动作生成生成中…
+
正在生成 16 帧动作动画…
+
+ ${Array.from({ length: 16 }, (_, i) => `${String(i + 1).padStart(2, '0')}`).join('')} +
+ ` + } else if (isPassed) { + const frames = step.type === 'action-generation' ? (step.output?.frames ?? []) : [] + bodyHtml = ` +
动作生成${statusLabel}
+
+ ${frames.map((frame, i) => `动作第 ${i + 1} 帧${String(i + 1).padStart(2, '0')}`).join('')} +
+

动作帧已生成,进入审核。

+ ` + } else { + bodyHtml = `
动作生成${statusLabel}
` + } + break + + case 'review': { + const publishReady = canPublishToPlaytest(run) + + if (isActive) { + bodyHtml = ` +
审核${statusLabel}
+

检查所有动作是否符合预期。

+ + ` + } else if (isPassed) { + bodyHtml = ` +
审核已完成
+

全部完成!

+
+ +
+ ` + } else { + bodyHtml = `
审核${statusLabel}
` + } + break + } + + default: + bodyHtml = `
${meta.title}${statusLabel}
` + } + } + + const hasInput = step.type !== 'character-setup' + const hasOutput = step.type !== 'review' + const outputEnabled = isPassed || isActive + + return ` +
+ ${hasInput ? '' : ''} +
+ ${meta.eyebrow}

${meta.title}

+ +
+
${bodyHtml}
+ ${hasInput ? `` : ''} + ${hasOutput ? `` : ''} +
+ ` +} + +export function WorkflowCanvas({ + controller, + run, + unavailableReason, + onStepAction, +}: WorkflowCanvasProps) { + const rootRef = useRef(null) + const revision = getCurrentRevision(run) + + // 连线只表达 WorkflowStep 的先后关系,不再作为第二套业务状态门控按钮。 + useEffect(() => { + if (!revision) return + controller.renderWires() + }, [controller, revision]) + + // 绑定交互事件 + useEffect(() => { + if (!rootRef.current || !revision) return + controller.attach(rootRef.current) + const root = rootRef.current + const form = root.querySelector('#characterSetupForm') + const handleSetupSubmit = (event: Event) => { + event.preventDefault() + const description = new FormData(form!).get('description') + if (typeof description === 'string' && description.trim()) { + onStepAction?.('character-setup', 'submit', { description: description.trim() }) + } + } + form?.addEventListener('submit', handleSetupSubmit) + + const candidateButtons = Array.from( + root.querySelectorAll('[data-select-candidate]'), + ) + const confirmCandidate = root.querySelector('[data-confirm-candidate]') + const selectCandidate = (event: Event) => { + candidateButtons.forEach((button) => button.classList.remove('is-selected')) + const selected = event.currentTarget as HTMLButtonElement + selected.classList.add('is-selected') + if (confirmCandidate) { + confirmCandidate.dataset.candidateUrl = selected.dataset.candidateUrl + confirmCandidate.disabled = false + } + } + candidateButtons.forEach((button) => button.addEventListener('click', selectCandidate)) + const handleCandidateConfirm = () => { + const selectedImageUrl = confirmCandidate?.dataset.candidateUrl + if (selectedImageUrl) onStepAction?.('template-candidate', 'confirm', { selectedImageUrl }) + } + confirmCandidate?.addEventListener('click', handleCandidateConfirm) + + const approveReview = root.querySelector('[data-confirm-review]') + const handleReviewApprove = () => onStepAction?.('review', 'approve') + approveReview?.addEventListener('click', handleReviewApprove) + + return () => { + form?.removeEventListener('submit', handleSetupSubmit) + candidateButtons.forEach((button) => button.removeEventListener('click', selectCandidate)) + confirmCandidate?.removeEventListener('click', handleCandidateConfirm) + approveReview?.removeEventListener('click', handleReviewApprove) + controller.detach() + } + }, [controller, revision, onStepAction]) + + if (!revision) { + return ( +
+
+
+ + 无法加载工作流 + 找不到当前版本 + +
+
+
+ ) + } + + const visibleSteps = revision.steps.filter((step, index) => { + if (step.status !== 'locked') return true + return index <= 1 // 只显示前两步(character-setup 和 character-template) + }) + + return ( +
+
+ +
+ ) +} + +function getHintText(revision: WorkflowRevision): string { + const activeStep = revision.steps.find((s) => s.status === 'active') + if (!activeStep) return '所有步骤已完成' + const meta = NODE_TITLES[activeStep.type] + return meta ? `当前:${meta.title}` : `当前:${activeStep.type}` +} diff --git a/frontend/src/pages/workflow-editor/workflow-editor.css b/frontend/src/pages/workflow-editor/workflow-editor.css new file mode 100644 index 0000000..a1c699c --- /dev/null +++ b/frontend/src/pages/workflow-editor/workflow-editor.css @@ -0,0 +1,918 @@ +/* 工作流编辑器样式 */ +:root { + --wf-white: #dfe3df; + --wf-ink: #171817; + --wf-muted: #747973; + --wf-line: #e2e3de; + --wf-accent: #263f2d; + --wf-accent-soft: #e4ebe2; +} + +.workflow-app { + min-height: 100vh; + background: var(--wf-white); +} + +/* Studio Bar */ +.studio-bar { + position: sticky; + z-index: 20; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + padding: 0 clamp(16px, 3vw, 48px); + border-bottom: 1px solid rgba(31, 43, 34, 0.1); + background: rgba(223, 227, 223, 0.88); + backdrop-filter: blur(18px); +} +.studio-bar__left, +.studio-bar__right { + display: flex; + align-items: center; + gap: 12px; +} +.studio-bar__brand { + display: inline-flex; + gap: 8px; + align-items: center; + color: var(--wf-ink); + text-decoration: none; +} +.studio-bar__brand .product-brand__mark { + display: block; + width: 28px; + height: 28px; + background: var(--wf-ink); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") + center/contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") + center/contain no-repeat; +} +.studio-bar__brand b { + font-family: Georgia, 'Times New Roman', serif; + font-size: 16px; + font-weight: 700; +} +.studio-bar__project { + display: flex; + flex-direction: column; + gap: 1px; + padding-left: 12px; + border-left: 1px solid var(--wf-line); +} +.studio-bar__project b { + font-size: 12px; + font-weight: 600; +} +.studio-bar__project small { + font-size: 10px; + color: var(--wf-muted); +} +.studio-bar__nav { + display: flex; + gap: 4px; +} +.studio-bar__nav a { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 10px; + border-radius: 8px; + color: #5e635d; + font-size: 11px; + font-weight: 600; + text-decoration: none; +} +.studio-bar__nav a:hover { + background: rgba(255, 255, 255, 0.6); +} +.studio-bar__nav a.is-active { + color: var(--wf-accent); + background: var(--wf-accent-soft); +} +.studio-bar__actions { + display: flex; + gap: 4px; +} +.studio-bar__actions button { + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--wf-line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.6); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +/* Production Canvas */ +.production-canvas-workspace { + height: calc(100svh - 52px); + min-height: 660px; + overflow: hidden; + background: var(--wf-white); +} + +/* Project Setup */ +.project-setup { + display: grid; + width: 100%; + min-height: 100%; + place-items: center; + padding: 40px; + background: + radial-gradient(circle at 78% 18%, rgba(88, 111, 94, 0.08), transparent 28%), + linear-gradient(150deg, #f8f7f3, #ecece7 74%); +} +.project-setup__form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + max-width: 560px; + width: 100%; +} +.project-setup__form-head { + grid-column: 1 / -1; +} +.project-setup__form-head h2 { + margin: 0; + font: + 500 32px/1.1 Georgia, + 'Songti SC', + serif; +} +.project-setup__wide { + grid-column: 1 / -1; +} +.project-setup__form label { + display: grid; + gap: 6px; +} +.project-setup__form label span { + font-size: 11px; + font-weight: 600; +} +.project-setup__form input, +.project-setup__form select, +.project-setup__form textarea { + padding: 10px 12px; + border: 1px solid rgba(27, 38, 30, 0.15); + border-radius: 10px; + background: rgba(255, 255, 255, 0.7); + font-size: 13px; + font-family: inherit; +} +.project-setup__form footer { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + padding-top: 8px; +} +.button--primary { + padding: 12px 24px; + border: 1px solid var(--wf-accent); + border-radius: 12px; + background: var(--wf-accent); + color: #f2f5f1; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.button--primary:hover { + background: #1d3025; +} + +/* Setup Messages */ +.setup-notice { + margin: 12px 40px; + padding: 12px 16px; + border: 1px solid #c7a967; + border-radius: 12px; + background: #f4eddc; + color: #67552e; + font-size: 13px; +} +.setup-error { + margin: 12px 40px; + padding: 12px 16px; + border-radius: 12px; + background: #311b19; + color: #ffd3cc; + font-size: 13px; +} +.setup-loading { + margin: 12px 40px; + color: #687069; + font-size: 13px; +} + +/* Error View */ +.error-view { + padding: 80px 40px; +} +.error-view .overline { + font-family: ui-monospace, monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + color: #687069; +} +.error-view h1 { + margin: 16px 0 12px; + font: + 500 40px/1.1 Georgia, + 'Songti SC', + serif; +} +.error-view p { + color: #687069; + font-size: 14px; +} + +/* Studio Mode Gateway */ +.studio-mode-gateway { + position: relative; + display: grid; + width: 100%; + min-height: 100%; + grid-template-rows: auto 1fr auto; + gap: 34px; + padding: 112px clamp(30px, 6vw, 104px) 42px; + overflow: hidden; + background: + radial-gradient(circle at 78% 18%, rgba(88, 111, 94, 0.08), transparent 28%), + linear-gradient(150deg, #f8f7f3, #ecece7 74%); + animation: studio-mode-enter 520ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes studio-mode-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.studio-mode-gateway::before { + position: absolute; + inset: 0; + content: ''; + opacity: 0.28; + background-image: radial-gradient(circle, rgba(27, 31, 28, 0.24) 1px, transparent 1.2px); + background-size: 24px 24px; + pointer-events: none; +} +.studio-mode-gateway > * { + position: relative; + z-index: 1; +} +.studio-mode-gateway__header { + display: grid; + max-width: 720px; + gap: 11px; +} +.studio-mode-gateway__header .overline { + color: #6e756f; + font: 700 8px/1 monospace; + letter-spacing: 0.18em; +} +.studio-mode-gateway__header h1 { + margin: 0; + font: + 500 clamp(34px, 4.2vw, 62px)/1.04 Georgia, + 'Songti SC', + serif; + letter-spacing: -0.035em; +} +.studio-mode-gateway__header p { + max-width: 610px; + margin: 0; + color: #666b67; + font-size: 12px; + line-height: 1.75; +} +.studio-mode-gateway__choices { + display: grid; + min-height: 360px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} +.studio-mode-card { + position: relative; + display: grid; + min-height: 100%; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + gap: 24px 30px; + padding: clamp(26px, 3.5vw, 52px); + overflow: hidden; + border: 1px solid rgba(20, 20, 20, 0.12); + border-radius: 24px; + color: #1a1b1a; + background: rgba(250, 250, 247, 0.78); + box-shadow: + 0 26px 64px rgba(28, 35, 30, 0.09), + inset 0 1px rgba(255, 255, 255, 0.72); + text-align: left; + cursor: pointer; + transition: + border-color 240ms ease, + box-shadow 240ms ease, + transform 240ms cubic-bezier(0.22, 1, 0.36, 1); + text-decoration: none; +} +.studio-mode-card:hover { + border-color: rgba(40, 69, 48, 0.36); + box-shadow: + 0 34px 80px rgba(25, 36, 28, 0.15), + inset 0 1px #fff; + transform: translateY(-6px); +} +.studio-mode-card__eyebrow { + font: 700 9px/1 monospace; + letter-spacing: 0.14em; + color: var(--wf-muted); + text-transform: uppercase; +} +.studio-mode-card__index { + font: + 500 48px/1 Georgia, + serif; + color: rgba(23, 24, 23, 0.08); +} +.studio-mode-card__copy { + display: grid; + gap: 8px; + align-content: start; +} +.studio-mode-card__copy small { + font: 700 8px/1 monospace; + letter-spacing: 0.14em; + color: var(--wf-muted); +} +.studio-mode-card__copy b { + font: + 500 20px/1.2 Georgia, + 'Songti SC', + serif; + color: var(--wf-ink); +} +.studio-mode-card__copy p { + margin: 0; + font-size: 12px; + color: #666b67; + line-height: 1.6; +} +.studio-mode-card__action { + align-self: end; + font: + 600 12px/1 system-ui, + sans-serif; + color: var(--wf-accent); +} +.studio-mode-gateway__note { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 0; + border-top: 1px solid rgba(20, 20, 20, 0.08); + color: #666b67; + font-size: 11px; +} +.studio-mode-gateway__note i { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(38, 63, 45, 0.08); +} +.studio-mode-gateway__note b { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--wf-ink); +} +.studio-mode-gateway__note small { + font-size: 10px; +} + +/* Node Graph */ +.node-graph-workspace { + position: relative; + height: 100%; + overflow: hidden; +} +.node-canvas { + position: absolute; + inset: 0; + overflow: hidden; + cursor: grab; + touch-action: none; + user-select: none; + background-color: var(--wf-white); + background-image: radial-gradient(circle, rgba(46, 62, 50, 0.18) 1px, transparent 1.25px); + background-size: 21px 21px; +} +.node-canvas.is-panning { + cursor: grabbing; +} +.node-surface { + position: absolute; + top: 0; + left: 0; + width: 3000px; + height: 1000px; + transform-origin: 0 0; + will-change: transform; +} +.node-wires { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 3000px; + height: 1000px; + overflow: visible; + pointer-events: none; +} +.node-wire { + fill: none; + stroke-width: 3; + stroke: #69866f; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 2px 2px rgba(38, 55, 43, 0.18)); +} +.node-wire.is-suggested { + stroke: #b8b4aa; + stroke-dasharray: 7 6; + opacity: 0.5; +} + +/* Graph Node */ +.graph-node { + position: absolute; + z-index: 3; + width: 292px; + overflow: visible; + border: 1px solid rgba(27, 38, 30, 0.2); + border-radius: 12px; + background: rgba(249, 250, 247, 0.98); + box-shadow: 0 14px 36px rgba(30, 39, 32, 0.13); + cursor: default; +} +.graph-node.has-input { + border-color: rgba(66, 99, 75, 0.48); +} +.graph-node.is-dragging { + z-index: 8; + box-shadow: 0 23px 54px rgba(26, 37, 29, 0.2); +} +.graph-node > header { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-radius: 11px 11px 0 0; + color: #f2f5f1; + background: #26352b; + cursor: move; + user-select: none; +} +.graph-node > header span { + display: grid; + gap: 2px; +} +.graph-node > header small { + color: #aebcb1; + font-size: 6px; + font-weight: 800; + letter-spacing: 0.12em; +} +.graph-node > header h2 { + margin: 0; + font-size: 10px; + font-weight: 720; +} +.graph-node > header > i { + display: flex; + gap: 3px; +} +.graph-node > header > i b { + width: 3px; + height: 3px; + border-radius: 50%; + background: #89998c; +} +.graph-node__body { + display: grid; + gap: 10px; + padding: 13px; +} + +/* Graph Port */ +.graph-port { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + border: 3px solid #8fa292; + border-radius: 50%; + background: #f2f4f0; + transform: translateY(-50%); + cursor: pointer; + z-index: 5; +} +.graph-port--input { + left: -6px; +} +.graph-port--output { + right: -6px; +} +.graph-port[data-enabled='false'] { + border-color: #c8c2b7; + background: #ece9e1; + cursor: not-allowed; +} +.graph-port[data-enabled='true']:hover { + transform: translateY(-50%) scale(1.3); + border-color: var(--wf-accent); + background: var(--wf-accent-soft); +} + +/* Node Status */ +.node-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + background: rgba(27, 38, 30, 0.04); +} +.node-status span { + font-size: 8px; + color: #5a5f5a; +} +.node-status b { + font-size: 8px; + font-weight: 700; +} +.node-status--active b { + color: var(--wf-accent); +} +.node-status--passed b { + color: #3d6b4a; +} +.node-status--failed b { + color: #8b332a; +} + +/* Node API Notice */ +.node-api-notice { + display: grid; + gap: 8px; + padding: 10px; + border: 1px dashed #c7a967; + border-radius: 8px; + background: #f4eddc; +} +.node-api-notice p { + margin: 0; + font-size: 10px; + color: #67552e; + line-height: 1.5; +} + +/* Node Desc */ +.node-desc { + margin: 0; + font-size: 10px; + color: #5a5f5a; + line-height: 1.5; +} + +/* Node Action */ +.node-action { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px; + border: 1px solid var(--wf-accent); + border-radius: 8px; + background: var(--wf-accent); + color: #f2f5f1; + font-size: 10px; + font-weight: 700; + cursor: pointer; + text-decoration: none; + text-align: left; +} +.node-action:hover { + background: #1d3025; +} +.node-action strong { + font-size: 11px; +} +.node-action small { + font-size: 9px; + opacity: 0.8; +} +.node-action--secondary { + background: transparent; + color: var(--wf-accent); +} +.node-action--secondary:hover { + background: var(--wf-accent-soft); +} +.node-action.is-disabled, +.node-action[aria-disabled='true'] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* Node Action List */ +.node-action-list { + display: grid; + gap: 6px; +} +.node-action-option { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid rgba(27, 38, 30, 0.15); + border-radius: 8px; + background: rgba(255, 255, 255, 0.6); + cursor: pointer; + text-align: left; +} +.node-action-option:hover { + border-color: rgba(38, 63, 45, 0.3); + background: rgba(255, 255, 255, 0.9); +} +.node-action-option strong { + font-size: 11px; + font-weight: 600; +} +.node-action-option small { + font-size: 9px; + color: var(--wf-muted); +} + +/* Node Candidate List */ +.node-candidate-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} +.node-candidate { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 8px; + border: 1px solid rgba(27, 38, 30, 0.15); + border-radius: 8px; + background: rgba(255, 255, 255, 0.6); + cursor: pointer; +} +.node-candidate:hover { + border-color: rgba(38, 63, 45, 0.3); + background: rgba(255, 255, 255, 0.9); +} +.node-candidate small { + font-size: 9px; + font-weight: 600; +} + +/* Node Export Options */ +.node-export-options { + display: grid; + gap: 8px; +} + +/* Node Canvas Hint */ +.node-canvas-hint { + position: absolute; + bottom: 16px; + left: 50%; + z-index: 10; + display: flex; + min-height: 44px; + padding: 8px 16px; + transform: translateX(-50%); + border: 1px solid rgba(31, 43, 34, 0.1); + border-radius: 12px; + background: rgba(249, 250, 247, 0.88); + backdrop-filter: blur(18px); +} +.node-canvas-hint__copy { + display: flex; + flex-direction: column; + gap: 2px; +} +.node-canvas-hint__copy b { + font-size: 11px; + font-weight: 600; +} +.node-canvas-hint__copy > span { + font-size: 10px; + color: var(--wf-muted); +} + +/* Node Zoom */ +.node-zoom { + position: absolute; + bottom: 16px; + right: 16px; + z-index: 10; + display: flex; + gap: 4px; + align-items: center; + padding: 4px; + border: 1px solid rgba(31, 43, 34, 0.1); + border-radius: 10px; + background: rgba(249, 250, 247, 0.88); +} +.node-zoom button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: none; + border-radius: 6px; + background: transparent; + font-size: 14px; + cursor: pointer; +} +.node-zoom button:hover { + background: rgba(255, 255, 255, 0.8); +} +.node-zoom output { + min-width: 36px; + text-align: center; + font-family: ui-monospace, monospace; + font-size: 10px; +} + +/* Node Generation Dots — 9×9 grid with ring-based animation */ +.node-generation__dots { + display: grid; + grid-template-columns: repeat(9, 6px); + gap: 2px; + justify-content: center; + padding: 8px; +} +.node-generation__dots i { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(38, 63, 45, 0.08); +} +.node-generation__dots i.dot-ring-0 { + background: var(--wf-accent); + animation: dot-pulse 1.2s ease infinite; +} +.node-generation__dots i.dot-ring-1 { + background: rgba(38, 63, 45, 0.25); + animation: dot-pulse 1.2s ease 0.1s infinite; +} +.node-generation__dots i.dot-ring-2 { + background: rgba(38, 63, 45, 0.15); + animation: dot-pulse 1.2s ease 0.2s infinite; +} +@keyframes dot-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1.2); + } +} + +/* Node Frame Strip — per-frame arrival */ +.node-frame-strip { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 4px; +} +.node-frame-strip span { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.node-frame-strip span small { + font-size: 7px; + color: var(--wf-muted); +} +.node-frame-strip .is-arrived { + animation: frame-arrive 0.4s ease; +} +.node-frame-strip .is-pending i { + display: block; + width: 28px; + height: 28px; + border-radius: 4px; + background: rgba(27, 38, 30, 0.06); + animation: frame-pending 1.5s ease infinite; +} +@keyframes frame-arrive { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes frame-pending { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.5; + } +} + +/* Node Connect Surface — click-to-confirm overlay */ +.graph-node__connect-surface { + display: none; + position: absolute; + inset: 0; + z-index: 10; + border: 2px dashed var(--wf-accent); + border-radius: 12px; + background: rgba(38, 63, 45, 0.06); + cursor: pointer; + place-items: center; +} +.graph-node.is-waiting-connection .graph-node__connect-surface { + display: grid; +} +.graph-node__connect-surface span { + font-size: 10px; + font-weight: 600; + color: var(--wf-accent); +} + +/* Node Running State */ +.graph-node.is-running { + box-shadow: + 0 0 0 2px rgba(87, 121, 96, 0.18), + 0 18px 48px rgba(30, 45, 34, 0.18); +} + +/* Connection Flash Animation */ +.graph-node.is-connection-committed { + animation: connection-flash 0.9s ease; +} +@keyframes connection-flash { + 0% { + box-shadow: 0 0 0 0 rgba(38, 63, 45, 0.3); + } + 50% { + box-shadow: 0 0 0 8px rgba(38, 63, 45, 0.15); + } + 100% { + box-shadow: 0 14px 36px rgba(30, 39, 32, 0.13); + } +} + +/* Wire Draw Animation */ +.node-wire.is-new { + animation: wire-draw 0.6s ease forwards; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} +@keyframes wire-draw { + to { + stroke-dashoffset: 0; + } +} + +/* Port Highlight */ +.graph-port.is-connectable { + border-color: var(--wf-accent); + background: var(--wf-accent-soft); + box-shadow: 0 0 0 3px rgba(38, 63, 45, 0.15); + animation: port-pulse 1s ease infinite; +} +@keyframes port-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(38, 63, 45, 0.15); + } + 50% { + box-shadow: 0 0 0 6px rgba(38, 63, 45, 0.08); + } +} diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md index 1b3dddd..4d62b42 100644 --- a/frontend/src/shared/README.md +++ b/frontend/src/shared/README.md @@ -8,6 +8,7 @@ ## 现有内容 +- `api/` —— 通用 HTTP 请求、信封解包与传输错误;不认识任何业务 DTO。 - `pagination/` —— 与传输协议无关的分页请求与结果形状。 ## 后续允许放入 diff --git a/frontend/src/shared/api/http-client.ts b/frontend/src/shared/api/http-client.ts new file mode 100644 index 0000000..f610a3e --- /dev/null +++ b/frontend/src/shared/api/http-client.ts @@ -0,0 +1,89 @@ +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000' + +interface ApiEnvelope { + code: number + message: string + data: T +} + +export class ApiError extends Error { + /** HTTP 状态码。 */ + readonly status: number + /** 业务码(与 HTTP 状态码分离)。 */ + readonly code: number + + constructor(status: number, code: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } +} + +export async function request(path: string, init?: RequestInit): Promise { + const url = `${BASE_URL}${path}` + const headers = new Headers(init?.headers) + + if (!headers.has('Content-Type') && !(init?.body instanceof FormData)) { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(url, { ...init, headers }) + + if (!response.ok) { + let code = response.status + let message = response.statusText + try { + const body = (await response.json()) as Partial> + if (body.code !== undefined) code = body.code + if (body.message) message = body.message + } catch { + // 响应体不是 JSON,保留默认错误信息 + } + throw new ApiError(response.status, code, message) + } + + let envelope: ApiEnvelope + try { + envelope = (await response.json()) as ApiEnvelope + } catch { + throw new ApiError(response.status, response.status, '响应格式错误,无法解析 JSON') + } + if (envelope.data === null || envelope.data === undefined) { + throw new ApiError( + response.status, + envelope.code ?? response.status, + envelope.message || '服务端未返回数据', + ) + } + return envelope.data +} + +export function get(path: string): Promise { + return request(path) +} + +export function post(path: string, body: unknown): Promise { + return request(path, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function patch(path: string, body: unknown): Promise { + return request(path, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function del(path: string): Promise { + return request(path, { method: 'DELETE' }) +} + +export function upload(path: string, formData: FormData): Promise { + return request(path, { + method: 'POST', + body: formData, + }) +} diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..e693e20 --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,2 @@ +/** 通用 HTTP 能力。这里只处理传输协议,不包含项目、角色等业务映射。 */ +export { ApiError, del, get, patch, post, request, upload } from './http-client' diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..1bec57d --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.10","results":[[":frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts",{"duration":11.368200000000002,"failed":false}],[":frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts",{"duration":15.456199999999967,"failed":false}],[":frontend/src/pages/playtest/workbench/playback/playback-state.test.ts",{"duration":7.5692000000000235,"failed":false}],[":frontend/src/pages/playtest/playtest-boundaries.test.ts",{"duration":43.97460000000001,"failed":false}],[":frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts",{"duration":4.972399999999993,"failed":false}],[":frontend/src/pages/playtest/testing/demo-character.test.ts",{"duration":6.655599999999993,"failed":false}],[":frontend/src/pages/playtest/workbench/audit/audit-session.test.ts",{"duration":4.1259000000000015,"failed":false}]]} \ No newline at end of file