From 0d965451d8bd51444caa6136e4be1768021250fb Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:05:26 +0800 Subject: [PATCH 1/2] feat(generation): add SSE task adapter --- "_PR\350\257\264\346\230\216.md" | 70 +++ frontend/src/entities/generation/api.test.ts | 349 +++++++++++++ frontend/src/entities/generation/api.ts | 506 +++++++++++++++++++ frontend/src/entities/generation/index.ts | 50 +- frontend/src/entities/index.ts | 5 + frontend/src/shared/api/stream.test.ts | 108 ++++ frontend/src/shared/api/stream.ts | 89 ++++ 7 files changed, 1168 insertions(+), 9 deletions(-) create mode 100644 "_PR\350\257\264\346\230\216.md" create mode 100644 frontend/src/entities/generation/api.test.ts create mode 100644 frontend/src/entities/generation/api.ts create mode 100644 frontend/src/shared/api/stream.test.ts create mode 100644 frontend/src/shared/api/stream.ts diff --git "a/_PR\350\257\264\346\230\216.md" "b/_PR\350\257\264\346\230\216.md" new file mode 100644 index 00000000..14a1c596 --- /dev/null +++ "b/_PR\350\257\264\346\230\216.md" @@ -0,0 +1,70 @@ +# Generation + SSE Adapter + +Refs #78 + +Issue #78 已补 `mile3` 标签并关联 `milestone/4`。本变更只实现前端 Generation +实体适配器与 SSE 传输封装,不修改后端、页面、Controller、共享入口或构建产物。 + +## 变更范围 + +- 将创建、按项目查询和状态订阅统一收口到 `GenerationApis`。 +- `createGenerationApis` 必须由宿主注入 `userId` 与 `transport`;模块不写死用户身份, + 也不直接持有 `fetch` 或 `EventSource`。 +- 新增业务无关的 `shared/api/stream.ts`,封装命名 SSE 事件、取消、终态关闭、 + 非法消息错误和断线通知。临时断线保留浏览器 EventSource 的协议级自动重连, + 不恢复 2 秒业务轮询。 +- 查询与订阅要求调用方传入 WorkflowRun 已知的阶段期望;动作阶段还必须带上 + `actionType`,避免把其他动作的帧误接到当前任务。 +- 角色母版由宿主通过 `resolveImageSize(projectId)` 提供项目画布尺寸;Generation 不直接 + 依赖 Project,也不会回退到可能冲突的 1024 默认值。 + +## 三阶段合同 + +| 前端阶段 | 后端请求 | 固定/可配置数量 | 结果映射 | +| -------------------- | ------------------------- | ------------------------- | ---------------------------- | +| `character_template` | `POST /generation/image` | 固定 `num_images: 4` | 严格校验并映射 4 个候选 | +| `first_frame` | `POST /generation/action` | 固定 `num_frames: 1` | 严格校验并映射 1 帧动作首帧 | +| `complete_animation` | `POST /generation/action` | 当前固定 `num_frames: 16` | 按后端 `frames[].index` 排序 | + +`CompleteAnimationGenerationInput` 当前没有 `frameCount` 字段,因此无法由调用输入表达 +帧数。本次保守沿用后端合同默认值 16;后续若合同加入可配置字段,应改为透传输入并补充 +边界校验,而不是继续保留常量。 + +## SSE 行为 + +- 端点:`/generation/tasks/{taskId}/stream?project_id=...`。 +- 只监听 `task_update` 命名事件,事件 DTO 在 Generation 边界解析和校验。 +- `completed`、`failed` 都视为终态;事件先交付调用方,再由传输层关闭连接。 +- `onError` 必传,非法事件关闭连接时不能静默留下永远等待的工作流。 +- 显式取消返回幂等函数,并移除监听器、清空错误处理器、关闭 EventSource。 +- 非法 JSON、非法 DTO 或调用方事件处理异常会报告错误并关闭连接。 +- 浏览器连接中断会报告 `SSE 连接中断`,连接保持给 EventSource 自动重连;没有定时 + GET、退避 GET 或其他业务轮询。 + +## DTO 校验 + +- 校验响应 envelope 的 `code/message/data`,业务错误不会被当作成功数据。 +- 校验任务与事件的正整数 ID、项目归属、用户归属、任务类型和四个合法状态。 +- 未知状态直接抛 `GenerationApiError`,绝不降级为 `pending`。 +- 校验完成结果的判别字段、图片 URL、候选/首帧数量、动作类型、16 帧数量与连续索引、 + 时长格式与状态/错误一致性;非完成任务携带结果同样视为非法合同。 + +## 测试覆盖 + +- 三阶段请求体映射与注入用户身份。 +- 角色母版四候选、动作一首帧、完整动画帧排序。 +- 未知状态与非法完成结果 DTO。 +- SSE URL、`task_update` 映射、主动取消、终态关闭、事件解析错误和断线恢复语义。 + +## 验证结果 + +- `npx oxfmt --check` 定向检查本次 5 个 TypeScript 文件:通过。 +- `npm run format:check` 全量检查:已执行,但被基线中 41 个本次所有权外文件阻断; + 未批量重写这些文件,以免覆盖其他工作者的修改。 +- `npm run lint`:通过。 +- `npm run typecheck`:通过。 +- `npm test`:通过,4 个测试文件、12 个测试,其中本模块新增 10 个。 +- `npm run build`:通过,Vite 8.1.5 共转换 88 个模块。 + +后端 `/generation/tasks/{taskId}/stream` 的实现与真实联调不在本前端-only 变更范围内; +本次测试通过注入 transport 验证前端合同,不把它表述为后端 SSE 已可用。 diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts new file mode 100644 index 00000000..434612ef --- /dev/null +++ b/frontend/src/entities/generation/api.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createGenerationApis, GenerationApiError } from '@/entities' + +import type { MediaReference } from '../media' + +const reference = (url: string) => url as MediaReference +const resolveImageSize = vi.fn(async () => ({ width: 64, height: 96 })) + +function success(data: unknown): Response { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function taskData(overrides: Record = {}) { + return { + id: 91, + user_id: 7, + project_id: 42, + task_type: 'character_image', + status: 'completed', + input_payload: { num_images: 4 }, + result: { + type: 'character_image', + image_urls: [ + 'https://cdn.test/candidate-1.png', + 'https://cdn.test/candidate-2.png', + 'https://cdn.test/candidate-3.png', + 'https://cdn.test/candidate-4.png', + ], + }, + error_message: null, + ...overrides, + } +} + +function actionFrames(count: number) { + return Array.from({ length: count }, (_, offset) => { + const index = count - offset - 1 + return { + index, + image_url: `https://cdn.test/frame-${index + 1}.png`, + duration_ms: index % 2 === 0 ? 100 : null, + } + }) +} + +describe('createGenerationApis', () => { + it('固定请求并映射四张角色母版候选', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => success(taskData())) + const stream = vi.fn(() => vi.fn()) + const apis = createGenerationApis({ + baseUrl: 'https://api.test/', + userId: '7', + transport: { request, stream }, + resolveImageSize, + }) + + const generation = await apis.create({ + type: 'character_template', + projectId: '42', + referenceMedia: [reference('https://cdn.test/reference.png')], + prompt: 'pixel hero', + }) + + expect(request).toHaveBeenCalledWith( + 'https://api.test/generation/image', + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + user_id: 7, + project_id: 42, + reference_image_url: 'https://cdn.test/reference.png', + prompt: 'pixel hero', + negative_prompt: '', + width: 64, + height: 96, + num_images: 4, + }), + }), + ) + expect(generation.result).toEqual({ + type: 'character_template', + images: [ + { url: 'https://cdn.test/candidate-1.png' }, + { url: 'https://cdn.test/candidate-2.png' }, + { url: 'https://cdn.test/candidate-3.png' }, + { url: 'https://cdn.test/candidate-4.png' }, + ], + }) + expect(resolveImageSize).toHaveBeenCalledWith('42') + }) + + it('通过动作生成接口固定请求并映射一帧动作首帧', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 1, action_type: 'idle' }, + result: { + type: 'character_action', + action_type: 'idle', + frames: [ + { index: 0, image_url: 'https://cdn.test/first-frame.png', duration_ms: null }, + ], + }, + }), + ), + ) + const apis = createGenerationApis({ + baseUrl: '', + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + const generation = await apis.create({ + type: 'first_frame', + projectId: '42', + characterId: '5', + outfitId: 'default', + actionType: 'idle', + prompt: 'stand naturally', + referenceMedia: [reference('https://cdn.test/template.png')], + }) + + expect(request.mock.calls[0]?.[0]).toBe('/generation/action') + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: 7, + project_id: 42, + character_id: 5, + action_type: 'idle', + custom_prompt: 'stand naturally', + reference_video_url: null, + reference_image_urls: ['https://cdn.test/template.png'], + num_frames: 1, + }) + expect(generation.result).toEqual({ + type: 'first_frame', + image: { url: 'https://cdn.test/first-frame.png' }, + }) + }) + + it('以首帧请求完整动画并按后端 index 排序,当前合同固定为十六帧', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 16, action_type: 'walk' }, + result: { + type: 'character_action', + action_type: 'walk', + frames: actionFrames(16), + }, + }), + ), + ) + const apis = createGenerationApis({ + baseUrl: '/api', + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + const generation = await apis.create({ + type: 'complete_animation', + projectId: '42', + characterId: '5', + outfitId: 'default', + actionType: 'walk', + firstFrameUrl: 'https://cdn.test/frame-1.png', + prompt: 'move forward', + referenceMedia: [reference('https://cdn.test/extra.png')], + }) + + expect(request.mock.calls[0]?.[0]).toBe('/api/generation/action') + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: 7, + project_id: 42, + character_id: 5, + action_type: 'walk', + custom_prompt: 'move forward', + reference_video_url: null, + reference_image_urls: ['https://cdn.test/frame-1.png', 'https://cdn.test/extra.png'], + num_frames: 16, + }) + expect(generation.result).toEqual({ + type: 'complete_animation', + frames: Array.from({ length: 16 }, (_, index) => ({ + url: `https://cdn.test/frame-${index + 1}.png`, + })), + }) + }) + + it('拒绝未知任务状态而不是默认为 pending', async () => { + const request = vi.fn(async () => success(taskData({ status: 'queued' }))) + const apis = createGenerationApis({ + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toBeInstanceOf( + GenerationApiError, + ) + await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( + '生成任务状态无效', + ) + }) + + it('拒绝结果字段不完整的 completed DTO', async () => { + const request = vi.fn(async () => + success(taskData({ result: { type: 'character_image', image_urls: [null] } })), + ) + const apis = createGenerationApis({ + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( + '角色图片结果 image_urls 无效', + ) + }) + + it('订阅 task_update,映射终态并把终态关闭信号交给流传输层', () => { + let subscribedUrl = '' + let streamOptions: + | { + eventName: string + onEvent(data: string): boolean + onError(error: Error): void + } + | undefined + const cancel = vi.fn() + const stream = vi.fn((url: string, options: NonNullable) => { + subscribedUrl = url + streamOptions = options + return cancel + }) + const apis = createGenerationApis({ + baseUrl: 'https://api.test', + userId: 7, + transport: { request: vi.fn(), stream }, + resolveImageSize, + }) + const onEvent = vi.fn() + const onError = vi.fn() + + const unsubscribe = apis.subscribe( + '42', + '91', + { type: 'complete_animation', actionType: 'walk' }, + onEvent, + onError, + ) + const isTerminal = streamOptions?.onEvent( + JSON.stringify({ + task_id: 91, + task_type: 'character_action', + status: 'completed', + result: { + type: 'character_action', + action_type: 'walk', + frames: actionFrames(16), + }, + error_message: null, + }), + ) + + expect(subscribedUrl).toBe('https://api.test/generation/tasks/91/stream?project_id=42') + expect(streamOptions?.eventName).toBe('task_update') + expect(isTerminal).toBe(true) + expect(onEvent).toHaveBeenCalledWith({ + taskId: '91', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: Array.from({ length: 16 }, (_, index) => ({ + url: `https://cdn.test/frame-${index + 1}.png`, + })), + }, + error: null, + }) + + unsubscribe() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('拒绝 completed 任务返回错误动作类型', async () => { + const request = vi.fn(async () => + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 16, action_type: 'walk' }, + result: { + type: 'character_action', + action_type: 'attack', + frames: actionFrames(16), + }, + }), + ), + ) + const apis = createGenerationApis({ + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + await expect( + apis.get('42', '91', { type: 'complete_animation', actionType: 'walk' }), + ).rejects.toThrow('动作结果类型 attack 与请求的 walk 不一致') + }) + + it('拒绝不足十六帧以及非失败状态携带错误', async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 16, action_type: 'walk' }, + result: { + type: 'character_action', + action_type: 'walk', + frames: actionFrames(3), + }, + }), + ), + ) + .mockResolvedValueOnce(success(taskData({ error_message: 'provider failed' }))) + const apis = createGenerationApis({ + userId: 7, + transport: { request, stream: vi.fn(() => vi.fn()) }, + resolveImageSize, + }) + + await expect( + apis.get('42', '91', { type: 'complete_animation', actionType: 'walk' }), + ).rejects.toThrow('完整动画结果必须包含 16 帧') + await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( + 'completed 任务不应携带 error_message', + ) + }) +}) diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts new file mode 100644 index 00000000..68b68ac7 --- /dev/null +++ b/frontend/src/entities/generation/api.ts @@ -0,0 +1,506 @@ +import type { EventStreamSubscriber } from '@/shared/api/stream' + +import type { + CompleteAnimationGenerationInput, + GeneratedImage, + Generation, + GenerationApis, + GenerationEvent, + GenerationExpectation, + GenerationImageSize, + GenerationInput, + GenerationResult, + GenerationType, + TaskStatus, +} from '.' + +type RequestFunction = (url: string, init?: RequestInit) => Promise + +/** Generation 适配器需要的全部网络能力,由宿主统一注入。 */ +export interface GenerationTransport { + request: RequestFunction + stream: EventStreamSubscriber +} + +export interface GenerationApiConfig { + /** API 前缀;空字符串表示同源。 */ + baseUrl?: string + /** 当前用户由认证宿主提供,适配器不猜测也不写死身份。 */ + userId: string | number + transport: GenerationTransport + /** 由组合根通过 ProjectApis 提供,Generation 不直接依赖 Project 实体或猜测尺寸。 */ + resolveImageSize(projectId: string): Promise +} + +interface ResponseEnvelope { + code: unknown + message: unknown + data: unknown +} + +interface GenerationTaskDto { + id: number + userId: number + projectId: number + taskType: BackendGenerationType + status: TaskStatus + inputPayload: Record | null + result: Record | null + errorMessage: string | null +} + +type BackendGenerationType = 'character_image' | 'character_action' + +const TASK_STATUSES = new Set(['pending', 'running', 'completed', 'failed']) +const ACTION_TYPES = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + +export class GenerationApiError extends Error { + readonly code: number + + constructor(message: string, code = 0, options?: ErrorOptions) { + super(message, options) + this.name = 'GenerationApiError' + this.code = code + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function inputPositiveInteger(value: string | number, field: string): number { + const parsed = typeof value === 'number' ? value : Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new GenerationApiError(`${field} 必须是正整数`) + } + return parsed +} + +function dtoPositiveInteger(value: unknown, field: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new GenerationApiError(`生成任务 ${field} 无效`, 200) + } + return value as number +} + +function dtoNullableRecord(value: unknown, field: string): Record | null { + if (value === null) return null + if (!isRecord(value)) throw new GenerationApiError(`生成任务 ${field} 无效`, 200) + return value +} + +function dtoNullableString(value: unknown, field: string): string | null { + if (value === null) return null + if (typeof value !== 'string') throw new GenerationApiError(`生成任务 ${field} 无效`, 200) + return value +} + +function backendTaskType(value: unknown): BackendGenerationType { + if (value !== 'character_image' && value !== 'character_action') { + throw new GenerationApiError('生成任务 task_type 无效', 200) + } + return value +} + +function taskStatus(value: unknown): TaskStatus { + if (typeof value !== 'string' || !TASK_STATUSES.has(value as TaskStatus)) { + throw new GenerationApiError('生成任务状态无效', 200) + } + return value as TaskStatus +} + +function endpoint(baseUrl: string | undefined, path: string): string { + return `${(baseUrl ?? '').replace(/\/$/u, '')}${path}` +} + +async function readData(response: Response): Promise { + let raw: unknown + try { + raw = await response.json() + } catch (error) { + throw new GenerationApiError( + `生成接口返回了无法解析的响应(HTTP ${response.status})`, + response.status, + { cause: error }, + ) + } + if (!isRecord(raw)) { + throw new GenerationApiError('生成接口响应不是对象', response.status) + } + + const envelope: ResponseEnvelope = { + code: raw.code, + message: raw.message, + data: raw.data, + } + if (typeof envelope.code !== 'number') { + throw new GenerationApiError('生成接口响应缺少有效的 code', response.status) + } + const message = + typeof envelope.message === 'string' ? envelope.message : `HTTP ${response.status}` + if (!response.ok || envelope.code !== 200) { + throw new GenerationApiError(message, envelope.code) + } + if (envelope.data === null || envelope.data === undefined) { + throw new GenerationApiError('生成接口成功响应缺少 data', envelope.code) + } + return envelope.data +} + +/** 完整查询 DTO 的每个字段都在网络边界校验,不把脏数据带入实体。 */ +function parseTaskDto(value: unknown): GenerationTaskDto { + if (!isRecord(value)) throw new GenerationApiError('生成任务响应不是对象', 200) + const inputPayload = dtoNullableRecord(value.input_payload, 'input_payload') + return { + id: dtoPositiveInteger(value.id, 'id'), + userId: dtoPositiveInteger(value.user_id, 'user_id'), + projectId: dtoPositiveInteger(value.project_id, 'project_id'), + taskType: backendTaskType(value.task_type), + status: taskStatus(value.status), + inputPayload, + result: dtoNullableRecord(value.result, 'result'), + errorMessage: dtoNullableString(value.error_message, 'error_message'), + } +} + +function expectedBackendType(type: GenerationType): BackendGenerationType { + return type === 'character_template' ? 'character_image' : 'character_action' +} + +function nonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new GenerationApiError(`${field} 无效`, 200) + } + return value +} + +function mapImageResult(result: Record): GenerationResult { + if (result.type !== 'character_image') { + throw new GenerationApiError('角色图片结果 type 无效', 200) + } + if ( + !Array.isArray(result.image_urls) || + result.image_urls.length === 0 || + result.image_urls.some((url) => typeof url !== 'string' || url.trim() === '') + ) { + throw new GenerationApiError('角色图片结果 image_urls 无效', 200) + } + const images = result.image_urls.map((url): GeneratedImage => ({ url: url as string })) + + if (images.length !== 4) { + throw new GenerationApiError('角色母版结果必须包含 4 个候选', 200) + } + return { type: 'character_template', images } +} + +function mapActionResult( + result: Record, + expectation: Extract, +): GenerationResult { + if (result.type !== 'character_action') { + throw new GenerationApiError('完整动画结果 type 无效', 200) + } + if (typeof result.action_type !== 'string' || !ACTION_TYPES.has(result.action_type)) { + throw new GenerationApiError('完整动画结果 action_type 无效', 200) + } + if (result.action_type !== expectation.actionType) { + throw new GenerationApiError( + `动作结果类型 ${result.action_type} 与请求的 ${expectation.actionType} 不一致`, + 200, + ) + } + if (!Array.isArray(result.frames) || result.frames.length === 0) { + throw new GenerationApiError('完整动画结果 frames 无效', 200) + } + + const indexes = new Set() + const frames = result.frames.map((frame) => { + if (!isRecord(frame)) throw new GenerationApiError('动作帧不是对象', 200) + if (!Number.isSafeInteger(frame.index) || (frame.index as number) < 0) { + throw new GenerationApiError('动作帧 index 无效', 200) + } + const index = frame.index as number + if (indexes.has(index)) throw new GenerationApiError('动作帧 index 重复', 200) + indexes.add(index) + if ( + frame.duration_ms !== null && + (!Number.isFinite(frame.duration_ms) || (frame.duration_ms as number) < 0) + ) { + throw new GenerationApiError('动作帧 duration_ms 无效', 200) + } + return { + index, + image: { url: nonEmptyString(frame.image_url, '动作帧 image_url') }, + } + }) + + const orderedFrames = frames + .sort((left, right) => left.index - right.index) + .map(({ image }) => image) + const expectedFrameCount = expectation.type === 'first_frame' ? 1 : 16 + if (orderedFrames.length !== expectedFrameCount) { + throw new GenerationApiError( + `${expectation.type === 'first_frame' ? '动作首帧' : '完整动画'}结果必须包含 ${expectedFrameCount} 帧`, + 200, + ) + } + for (let index = 0; index < expectedFrameCount; index += 1) { + if (!indexes.has(index)) { + throw new GenerationApiError('动作帧 index 必须从 0 开始连续排列', 200) + } + } + if (expectation.type === 'first_frame') { + return { type: 'first_frame', image: orderedFrames[0]! } + } + return { type: 'complete_animation', frames: orderedFrames } +} + +function mapResult( + result: Record | null, + status: TaskStatus, + expectation: GenerationExpectation, +): GenerationResult | null { + if (status !== 'completed') { + if (result !== null) { + throw new GenerationApiError('非完成任务不应携带 result', 200) + } + return null + } + if (result === null) throw new GenerationApiError('完成任务缺少 result', 200) + return expectation.type === 'character_template' + ? mapImageResult(result) + : mapActionResult(result, expectation) +} + +function validateStatusError(status: TaskStatus, error: string | null): void { + if (status === 'failed') { + if (error === null || error.trim() === '') { + throw new GenerationApiError('失败任务缺少 error_message', 200) + } + return + } + if (error !== null) { + throw new GenerationApiError(`${status} 任务不应携带 error_message`, 200) + } +} + +function validateInputPayload( + inputPayload: Record | null, + expectation: GenerationExpectation, +): void { + if (inputPayload === null) { + throw new GenerationApiError('生成任务缺少 input_payload', 200) + } + if (expectation.type === 'character_template') { + if (inputPayload.num_images !== 4) { + throw new GenerationApiError('角色母版任务 input_payload.num_images 必须为 4', 200) + } + return + } + const expectedFrameCount = expectation.type === 'first_frame' ? 1 : 16 + if (inputPayload.num_frames !== expectedFrameCount) { + throw new GenerationApiError( + `动作任务 input_payload.num_frames 必须为 ${expectedFrameCount}`, + 200, + ) + } + if (inputPayload.action_type !== expectation.actionType) { + throw new GenerationApiError('动作任务 input_payload.action_type 与请求不一致', 200) + } +} + +function validateTaskIdentity( + dto: GenerationTaskDto, + expectedProjectId: number, + expectedUserId: number, + expectation: GenerationExpectation, + expectedTaskId?: number, +): void { + if (dto.projectId !== expectedProjectId) { + throw new GenerationApiError(`生成任务未归属请求中的项目 ${expectedProjectId}`, 200) + } + if (dto.userId !== expectedUserId) { + throw new GenerationApiError('生成任务未归属当前用户', 200) + } + if (expectedTaskId !== undefined && dto.id !== expectedTaskId) { + throw new GenerationApiError(`生成任务 ID 与请求的 ${expectedTaskId} 不一致`, 200) + } + if (dto.taskType !== expectedBackendType(expectation.type)) { + throw new GenerationApiError(`生成任务类型与 ${expectation.type} 不匹配`, 200) + } + validateStatusError(dto.status, dto.errorMessage) + validateInputPayload(dto.inputPayload, expectation) +} + +function mapTask( + value: unknown, + expectedProjectId: number, + expectedUserId: number, + expectation: Extract, + expectedTaskId?: number, +): Generation { + const dto = parseTaskDto(value) + validateTaskIdentity(dto, expectedProjectId, expectedUserId, expectation, expectedTaskId) + return { + id: String(dto.id), + projectId: String(dto.projectId), + type: expectation.type, + status: dto.status, + result: mapResult(dto.result, dto.status, expectation), + error: dto.errorMessage, + } +} + +function references(input: CompleteAnimationGenerationInput): string[] { + return [input.firstFrameUrl, ...input.referenceMedia.map(String)].filter( + (url, index, all) => url.trim() !== '' && all.indexOf(url) === index, + ) +} + +function parseEventData(data: string): unknown { + try { + return JSON.parse(data) as unknown + } catch (error) { + throw new GenerationApiError('task_update 不是有效 JSON', 200, { cause: error }) + } +} + +function mapEvent( + value: unknown, + expectedTaskId: number, + expectation: Extract, +): GenerationEvent { + if (!isRecord(value)) throw new GenerationApiError('task_update 不是对象', 200) + const taskId = dtoPositiveInteger(value.task_id, 'task_id') + if (taskId !== expectedTaskId) { + throw new GenerationApiError(`task_update ID 与订阅的 ${expectedTaskId} 不一致`, 200) + } + if (backendTaskType(value.task_type) !== expectedBackendType(expectation.type)) { + throw new GenerationApiError(`task_update 类型与 ${expectation.type} 不匹配`, 200) + } + const status = taskStatus(value.status) + const result = dtoNullableRecord(value.result ?? null, 'result') + const error = dtoNullableString(value.error_message ?? null, 'error_message') + validateStatusError(status, error) + return { + taskId: String(taskId), + type: expectation.type, + status, + result: mapResult(result, status, expectation), + error, + } +} + +/** + * 创建 Generation 实体适配器。 + * + * `userId` 与 HTTP/SSE transport 都由宿主注入,因此模块既不持有登录态,也不直接 + * 依赖 fetch/EventSource。三个前端阶段在这里收口为后端的两类 GenerationTask。 + */ +export function createGenerationApis(config: GenerationApiConfig): GenerationApis { + const userId = inputPositiveInteger(config.userId, 'userId') + const { request, stream } = config.transport + + async function post( + path: '/generation/image' | '/generation/action', + projectId: number, + expectation: Extract, + body: Record, + ): Promise> { + const response = await request(endpoint(config.baseUrl, path), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return mapTask(await readData(response), projectId, userId, expectation) + } + + return { + async create(input: T): Promise> { + const projectId = inputPositiveInteger(input.projectId, 'projectId') + if (input.type !== 'character_template') { + const referenceImageUrls = + input.type === 'complete_animation' + ? references(input) + : input.referenceMedia.map(String).filter((url) => url.trim() !== '') + return post( + '/generation/action', + projectId, + { type: input.type, actionType: input.actionType }, + { + user_id: userId, + project_id: projectId, + character_id: inputPositiveInteger(input.characterId, 'characterId'), + action_type: input.actionType, + custom_prompt: input.prompt, + reference_video_url: null, + reference_image_urls: referenceImageUrls, + // 首帧是一次一帧动作任务;完整动画当前沿用后端合同的 16 帧。 + num_frames: input.type === 'first_frame' ? 1 : 16, + }, + ) + } + + const imageSize = await config.resolveImageSize(input.projectId) + return post( + '/generation/image', + projectId, + { type: input.type }, + { + user_id: userId, + project_id: projectId, + reference_image_url: input.referenceMedia[0] ? String(input.referenceMedia[0]) : null, + prompt: input.prompt ?? '', + negative_prompt: '', + width: inputPositiveInteger(imageSize.width, 'imageSize.width'), + height: inputPositiveInteger(imageSize.height, 'imageSize.height'), + // 只有角色母版走图片接口,并且固定生成四个候选。 + num_images: 4, + }, + ) + }, + + async get( + projectId: string, + id: string, + expectation: Extract, + ): Promise> { + const numericProjectId = inputPositiveInteger(projectId, 'projectId') + const numericTaskId = inputPositiveInteger(id, 'taskId') + const response = await request( + endpoint( + config.baseUrl, + `/generation/tasks/${numericTaskId}?project_id=${numericProjectId}`, + ), + { method: 'GET' }, + ) + return mapTask(await readData(response), numericProjectId, userId, expectation, numericTaskId) + }, + + subscribe( + projectId: string, + id: string, + expectation: Extract, + onEvent: (event: GenerationEvent) => void, + onError: (error: Error) => void, + ): () => void { + const numericProjectId = inputPositiveInteger(projectId, 'projectId') + const numericTaskId = inputPositiveInteger(id, 'taskId') + return stream( + endpoint( + config.baseUrl, + `/generation/tasks/${numericTaskId}/stream?project_id=${numericProjectId}`, + ), + { + eventName: 'task_update', + onEvent(data) { + const event = mapEvent(parseEventData(data), numericTaskId, expectation) + onEvent(event) + return event.status === 'completed' || event.status === 'failed' + }, + onError, + }, + ) + }, + } +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d4..b5b0524e 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -19,25 +19,41 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' /** * 生成对应的三个前端可见异步步骤。 * 它是前端工作流粒度,不等于后端 task_type——后端只有 character_image 与 - * character_action 两种,character_template 和 first_frame 都落在 character_image 上。 + * character_action 两种:character_template 落在 character_image,动作首帧和完整动画 + * 都落在 character_action,只是请求帧数分别为 1 和 16。 * 完整动画内部可含视频生成、截帧和多次图像处理,但对前端仍是一次 Generation。 */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' +/** + * 恢复任务时由 WorkflowRun 提供的已知上下文。动作阶段必须带上动作语义, + * 这样适配器才能拒绝“请求 walk、后端却返回 attack”这类串任务结果。 + */ +export type GenerationExpectation = + | { type: 'character_template' } + | { type: 'first_frame'; actionType: ActionType } + | { type: 'complete_animation'; actionType: ActionType } + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ referenceMedia: readonly MediaReference[] } -/** 角色母版候选生成。 */ +/** 图片生成请求的实际画布尺寸,必须与所属项目的精灵尺寸合同一致。 */ +export interface GenerationImageSize { + width: number + height: number +} + +/** 角色母版候选生成;当前合同固定请求 4 个候选,不向调用方暴露可变数量。 */ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string } -/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ +/** 指定角色造型下的动作首帧生成;当前合同固定 1 张,且不能只绑定 Character。 */ export interface FirstFrameGenerationInput extends GenerationInputBase { type: 'first_frame' characterId: string @@ -47,7 +63,10 @@ export interface FirstFrameGenerationInput extends GenerationInputBase { prompt: string | null } -/** 以已确认首帧为起点生成完整动画。 */ +/** + * 以已确认首帧为起点生成完整动画。 + * 后端支持 num_frames,但当前输入没有 frameCount;适配器暂按后端默认值提交 16。 + */ export interface CompleteAnimationGenerationInput extends GenerationInputBase { type: 'complete_animation' characterId: string @@ -101,7 +120,8 @@ export type GenerationResultFor = * 一次生成任务的完整快照,创建、查询和断线恢复都用它。 * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 * - * TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。 + * TType 在调用边界已知时保留精确类型;查询和订阅必须传入工作流已知的前端阶段, + * 因为后端 task_type 比前端阶段更粗,不能只靠 DTO 猜测首帧还是完整动画。 * 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。 */ export interface Generation { @@ -137,11 +157,23 @@ export interface GenerationApis { * 按所属项目和任务 ID 读取最新快照。 * projectId 不能从 id 推导,后端查询接口要求两者同时传入。 */ - get(projectId: Generation['projectId'], id: Generation['id']): Promise - /** 订阅状态变化,返回取消订阅函数。 */ - subscribe( + get( + projectId: Generation['projectId'], + id: Generation['id'], + expectation: Extract, + ): Promise> + /** + * 订阅 task_update,终态由传输层自动关闭;返回的函数供页面离开时主动取消。 + * 传输错误与非法 DTO 通过 onError 上报,不伪造成业务 failed 状态。 + */ + subscribe( projectId: Generation['projectId'], id: Generation['id'], - onEvent: (event: GenerationEvent) => void, + expectation: Extract, + onEvent: (event: GenerationEvent) => void, + onError: (error: Error) => void, ): () => void } + +export { createGenerationApis, GenerationApiError } from './api' +export type { GenerationApiConfig, GenerationTransport } from './api' diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359dd..f58f2b78 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -34,6 +34,7 @@ export type { export type { ActionTemplate, ActionTemplateApis } from './action-template' /* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +export { createGenerationApis, GenerationApiError } from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -45,10 +46,14 @@ export type { Generation, GenerationApis, GenerationEvent, + GenerationExpectation, GenerationInput, + GenerationImageSize, GenerationResult, GenerationResultFor, GenerationType, + GenerationApiConfig, + GenerationTransport, TaskStatus, } from './generation' diff --git a/frontend/src/shared/api/stream.test.ts b/frontend/src/shared/api/stream.test.ts new file mode 100644 index 00000000..2590849c --- /dev/null +++ b/frontend/src/shared/api/stream.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' + +import { subscribeToEventStream, type EventSourceFactory, type EventSourceLike } from './stream' + +class FakeEventSource implements EventSourceLike { + readonly listeners = new Map void>>() + readonly close = vi.fn() + onerror: ((event: Event) => void) | null = null + + addEventListener(type: string, listener: (event: Event) => void): void { + const listeners = this.listeners.get(type) ?? new Set() + listeners.add(listener) + this.listeners.set(type, listeners) + } + + removeEventListener(type: string, listener: (event: Event) => void): void { + this.listeners.get(type)?.delete(listener) + } + + emit(type: string, data: string): void { + for (const listener of this.listeners.get(type) ?? []) { + listener({ data } as MessageEvent) + } + } + + disconnect(): void { + this.onerror?.(new Event('error')) + } +} + +function setup() { + const source = new FakeEventSource() + const factory = vi.fn(() => source) + return { source, factory } +} + +describe('subscribeToEventStream', () => { + it('取消后关闭连接并忽略后续事件', () => { + const { source, factory } = setup() + const onEvent = vi.fn(() => false) + const unsubscribe = subscribeToEventStream( + '/generation/tasks/91/stream?project_id=42', + { eventName: 'task_update', onEvent, onError: vi.fn() }, + factory, + ) + + unsubscribe() + source.emit('task_update', '{"status":"running"}') + + expect(source.close).toHaveBeenCalledOnce() + expect(onEvent).not.toHaveBeenCalled() + }) + + it('收到终态关闭信号后关闭连接且只交付一次', () => { + const { source, factory } = setup() + const onEvent = vi.fn(() => true) + subscribeToEventStream( + '/generation/tasks/91/stream?project_id=42', + { eventName: 'task_update', onEvent, onError: vi.fn() }, + factory, + ) + + source.emit('task_update', '{"status":"completed"}') + source.emit('task_update', '{"status":"completed"}') + + expect(onEvent).toHaveBeenCalledOnce() + expect(source.close).toHaveBeenCalledOnce() + }) + + it('事件解析失败时报告错误并关闭连接', () => { + const { source, factory } = setup() + const onError = vi.fn() + subscribeToEventStream( + '/generation/tasks/91/stream?project_id=42', + { + eventName: 'task_update', + onEvent: () => { + throw new Error('invalid task DTO') + }, + onError, + }, + factory, + ) + + source.emit('task_update', '{}') + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'invalid task DTO' })) + expect(source.close).toHaveBeenCalledOnce() + }) + + it('断线时报告错误并保留 EventSource 的自动重连能力', () => { + const { source, factory } = setup() + const onEvent = vi.fn(() => false) + const onError = vi.fn() + subscribeToEventStream( + '/generation/tasks/91/stream?project_id=42', + { eventName: 'task_update', onEvent, onError }, + factory, + ) + + source.disconnect() + source.emit('task_update', '{"status":"running"}') + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'SSE 连接中断' })) + expect(source.close).not.toHaveBeenCalled() + expect(onEvent).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/shared/api/stream.ts b/frontend/src/shared/api/stream.ts new file mode 100644 index 00000000..93081020 --- /dev/null +++ b/frontend/src/shared/api/stream.ts @@ -0,0 +1,89 @@ +/** + * 业务无关的 SSE 订阅边界。 + * + * 上层只处理字符串 payload、终态判断和错误回调,不接触 EventSource 实例。 + * 浏览器断线后由 EventSource 按协议自动重连;显式取消、终态或非法消息才会关闭连接。 + */ + +export interface EventSourceLike { + onerror: ((event: Event) => void) | null + addEventListener(type: string, listener: (event: Event) => void): void + removeEventListener(type: string, listener: (event: Event) => void): void + close(): void +} + +export type EventSourceFactory = (url: string) => EventSourceLike + +export interface EventStreamOptions { + /** 只监听业务指定的命名事件,例如 task_update。 */ + eventName: string + /** 返回 true 表示 payload 是终态,传输层随后关闭连接。 */ + onEvent(data: string): boolean + /** 包含连接中断、非法消息和业务解析器抛出的错误。 */ + onError(error: Error): void +} + +export type EventStreamSubscriber = (url: string, options: EventStreamOptions) => () => void + +export class EventStreamError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'EventStreamError' + } +} + +const createBrowserEventSource: EventSourceFactory = (url) => new EventSource(url) + +function asError(value: unknown): Error { + return value instanceof Error ? value : new EventStreamError('SSE 事件处理失败') +} + +/** + * 建立命名 SSE 事件订阅并返回幂等取消函数。 + * + * `error` 事件通常表示临时断线。此处只通知上层而不主动 close,让浏览器原生 + * EventSource 继续使用服务端 retry 配置重连,避免退回业务轮询。 + */ +export function subscribeToEventStream( + url: string, + options: EventStreamOptions, + eventSourceFactory: EventSourceFactory = createBrowserEventSource, +): () => void { + let active = true + let source: EventSourceLike | null = null + + const stop = () => { + if (!active) return + active = false + if (source === null) return + source.removeEventListener(options.eventName, handleEvent) + source.onerror = null + source.close() + } + + const handleEvent = (event: Event) => { + if (!active) return + try { + if (!('data' in event) || typeof event.data !== 'string') { + throw new EventStreamError('SSE 事件缺少字符串 data') + } + if (options.onEvent(event.data)) stop() + } catch (error) { + stop() + options.onError(asError(error)) + } + } + + try { + source = eventSourceFactory(url) + source.addEventListener(options.eventName, handleEvent) + source.onerror = () => { + if (active) options.onError(new EventStreamError('SSE 连接中断')) + } + } catch (error) { + stop() + options.onError(new EventStreamError('SSE 连接建立失败', { cause: error })) + } + + return stop +} From 33b48634558ed34bc6e9ebccc43936827b802b66 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:37:11 +0800 Subject: [PATCH 2/2] refactor: sync 5-node model, remove legacy types Co-Authored-By: Claude --- .gitignore | 3 + README.md | 122 +- api-reference.md | 532 +++++++ backend/init_db.py | 43 + backend/packages/ai_engine/pyproject.toml | 2 + .../graph/tools/business/.gitkeep | 0 .../graph/tools/external/.gitkeep | 0 .../src/windup_ai_engine/impl/.gitkeep | 0 .../src/windup_ai_engine/impl/__init__.py | 5 + .../impl/character_generator.py | 88 ++ .../src/windup_ai_engine/master_prep.py | 82 ++ .../src/windup_ai_engine/ports/.gitkeep | 0 .../src/windup_ai_engine/ports/__init__.py | 59 + .../src/windup_ai_engine/postprocess/.gitkeep | 0 .../windup_ai_engine/postprocess/__init__.py | 28 + .../src/windup_ai_engine/postprocess/pack.py | 95 ++ .../windup_ai_engine/postprocess/pixelate.py | 252 ++++ .../postprocess/rootmotion.py | 69 + .../src/windup_ai_engine/prompt/.gitkeep | 0 .../src/windup_ai_engine/prompt/__init__.py | 16 + .../src/windup_ai_engine/prompt/actions.py | 111 ++ .../src/windup_ai_engine/prompt/jump.py | 63 + .../src/windup_ai_engine/prompt/walk.py | 57 + .../src/windup_ai_engine/slicing/.gitkeep | 0 .../src/windup_ai_engine/slicing/__init__.py | 27 + .../src/windup_ai_engine/slicing/extract.py | 162 +++ .../src/windup_ai_engine/slicing/loop.py | 52 + .../src/windup_ai_engine/slicing/oneshot.py | 189 +++ .../src/windup_ai_engine/strategy/.gitkeep | 0 .../src/windup_ai_engine/strategy/__init__.py | 12 + .../src/windup_ai_engine/strategy/base.py | 51 + .../src/windup_ai_engine/strategy/concrete.py | 168 +++ .../app/src/windup_app/bootstrap/app.py | 95 +- .../src/windup_app/server/character/model.py | 12 +- .../windup_app/server/character/service.py | 69 + .../windup_app/server/generation/__init__.py | 6 + .../windup_app/server/generation/executor.py | 409 ++++++ .../windup_app/server/generation/interface.py | 25 +- .../src/windup_app/server/generation/model.py | 70 +- .../windup_app/server/generation/service.py | 76 + .../windup_app/server/generation/task_repo.py | 158 ++ .../server/playtest_inspection/__init__.py | 1 + .../server/playtest_inspection/interface.py | 34 + .../server/playtest_inspection/model.py | 43 + .../server/playtest_inspection/service.py | 57 + .../src/windup_app/server/project/service.py | 76 + .../app/src/windup_app/web/api/character.py | 173 +++ .../windup_app/web/api/playtest_inspection.py | 89 ++ .../app/src/windup_app/web/api/project.py | 107 ++ .../src/windup_common/models/__init__.py | 15 + .../src/windup_common/models/character.py | 81 ++ backend/packages/framework/pyproject.toml | 15 +- .../src/windup_framework/config/__init__.py | 3 + .../src/windup_framework/config/database.py | 29 +- .../src/windup_framework/config/redis.py | 20 + .../src/windup_framework/config/storage.py | 16 +- .../src/windup_framework/db/__init__.py | 5 +- .../src/windup_framework/db/redis.py | 25 + .../src/windup_framework/db/session.py | 19 +- .../windup_framework/providers/__init__.py | 17 +- .../windup_framework/providers/interfaces.py | 34 + .../src/windup_framework/providers/matte.py | 103 ++ .../src/windup_framework/providers/sufy.py | 140 ++ backend/tests/test_generation_api.py | 273 ++++ backend/tests/test_local_runtime.py | 86 ++ backend/tests/test_loop.py | 53 + backend/tests/test_oneshot.py | 87 ++ docs/module-split-plan.md | 118 ++ docs/module-split.md | 11 +- docs/sse-generation-flow.md | 242 ++++ .../plans/2026-08-05-home-auth-account.md | 206 +++ .../2026-08-05-uploaded-template-shortcut.md | 157 ++ .../2026-08-05-home-auth-account-design.md | 86 ++ ...08-05-uploaded-template-shortcut-design.md | 58 + frontend-architecture-v3.md | 40 +- frontend/package.json | 3 +- frontend/src/app/api-contract.test.ts | 94 ++ frontend/src/app/app-composition.test.tsx | 81 ++ frontend/src/app/app.test.tsx | 108 ++ frontend/src/app/app.tsx | 243 +++- frontend/src/app/index.ts | 2 +- frontend/src/app/layout/index.test.tsx | 113 ++ frontend/src/app/layout/index.tsx | 59 +- frontend/src/entities/character/api.test.ts | 119 ++ frontend/src/entities/character/api.ts | 202 +++ frontend/src/entities/character/index.ts | 26 +- frontend/src/entities/constants.ts | 14 + frontend/src/entities/generation/api.test.ts | 389 +---- frontend/src/entities/generation/api.ts | 616 ++------ frontend/src/entities/generation/index.ts | 167 ++- frontend/src/entities/index.ts | 68 +- frontend/src/entities/media/api.test.ts | 190 +++ frontend/src/entities/media/api.ts | 81 ++ frontend/src/entities/media/index.ts | 14 + .../entities/playtest-inspection/api.test.ts | 67 + .../src/entities/playtest-inspection/api.ts | 65 + .../src/entities/playtest-inspection/index.ts | 24 + frontend/src/entities/project/api.test.ts | 57 + frontend/src/entities/project/api.ts | 105 ++ frontend/src/entities/project/index.ts | 18 +- frontend/src/entities/user/api.test.ts | 199 +++ frontend/src/entities/user/api.ts | 138 ++ frontend/src/entities/user/index.ts | 35 + .../src/entities/workflow-run/constants.ts | 14 + frontend/src/entities/workflow-run/index.ts | 205 +-- .../src/entities/workflow-run/store.test.ts | 330 +++++ frontend/src/entities/workflow-run/store.ts | 240 +++ .../src/features/auth-session/index.test.tsx | 348 +++++ frontend/src/features/auth-session/index.tsx | 396 +++++ .../auth-session/session-storage.test.ts | 49 + .../features/auth-session/session-storage.ts | 40 + .../features/character-setup/index.test.ts | 10 + .../src/features/character-setup/index.ts | 4 +- .../export-package/asset-export.test.ts | 269 ++++ .../features/export-package/asset-export.ts | 534 +++++++ .../features/export-package/cocos-target.ts | 15 + .../src/features/export-package/contract.ts | 113 ++ .../export-package/export-panel.test.tsx | 121 ++ .../features/export-package/export-panel.tsx | 118 ++ frontend/src/features/export-package/index.ts | 29 + frontend/src/features/export-package/model.ts | 64 + frontend/src/features/publish/index.test.ts | 177 +++ frontend/src/features/publish/index.ts | 110 ++ .../features/publish/workflow-to-character.ts | 158 ++ frontend/src/features/review/index.ts | 36 +- .../action-generation-task.ts | 282 ++++ .../character-template-task.ts | 492 +++++++ .../workflow-controller/controller.test.ts | 307 ++++ .../workflow-controller/controller.ts | 651 +++++++++ .../src/features/workflow-controller/index.ts | 73 +- .../store-invariants.test.ts | 88 ++ .../workflow-run.integration.test.ts | 103 ++ .../workflow-state.test.ts | 212 +++ .../workflow-controller/workflow-state.ts | 467 ++++++ frontend/src/pages/asset-library/index.tsx | 163 ++- frontend/src/pages/history/index.tsx | 122 ++ .../src/pages/home/account-panel.test.tsx | 390 +++++ frontend/src/pages/home/account-panel.tsx | 570 ++++++++ frontend/src/pages/home/choice-card.test.tsx | 23 + frontend/src/pages/home/choice-card.tsx | 120 +- frontend/src/pages/home/index.test.tsx | 159 +- frontend/src/pages/home/index.tsx | 200 +-- frontend/src/pages/not-found/index.tsx | 12 +- frontend/src/pages/playtest/index.tsx | 324 ++++- frontend/src/pages/project-detail/index.tsx | 157 +- frontend/src/pages/projects/create-page.tsx | 101 ++ frontend/src/pages/projects/index.tsx | 252 +++- frontend/src/pages/quick-start/index.test.tsx | 116 ++ frontend/src/pages/quick-start/index.tsx | 958 +++++++++++- .../src/pages/quick-start/service.test.ts | 166 +++ frontend/src/pages/quick-start/service.ts | 399 +++++ .../src/pages/workflow-editor/index.test.tsx | 419 ++++++ frontend/src/pages/workflow-editor/index.tsx | 515 ++++++- .../src/pages/workflow-editor/node-canvas.ts | 264 ++++ frontend/src/pages/workflow-editor/service.ts | 183 +++ .../workflow-editor/workflow-canvas.test.tsx | 195 +++ .../pages/workflow-editor/workflow-canvas.tsx | 393 +++++ .../pages/workflow-editor/workflow-editor.css | 1285 +++++++++++++++++ frontend/src/shared/api/http-client.test.ts | 124 ++ frontend/src/shared/api/http-client.ts | 5 + frontend/src/shared/api/upload.ts | 114 ++ frontend/src/shared/ui/index.ts | 2 + frontend/src/shared/ui/page-container.tsx | 5 +- frontend/src/shared/ui/pagination.tsx | 45 + .../src/test/authenticated-app-routes.tsx | 35 + start.bat | 125 ++ start.command | 151 ++ 167 files changed, 21673 insertions(+), 1498 deletions(-) create mode 100644 api-reference.md create mode 100644 backend/init_db.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/master_prep.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py delete mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py create mode 100644 backend/packages/app/src/windup_app/server/character/service.py create mode 100644 backend/packages/app/src/windup_app/server/generation/executor.py create mode 100644 backend/packages/app/src/windup_app/server/generation/service.py create mode 100644 backend/packages/app/src/windup_app/server/generation/task_repo.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/interface.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/model.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/service.py create mode 100644 backend/packages/app/src/windup_app/server/project/service.py create mode 100644 backend/packages/app/src/windup_app/web/api/character.py create mode 100644 backend/packages/app/src/windup_app/web/api/playtest_inspection.py create mode 100644 backend/packages/app/src/windup_app/web/api/project.py create mode 100644 backend/packages/common/src/windup_common/models/__init__.py create mode 100644 backend/packages/common/src/windup_common/models/character.py create mode 100644 backend/packages/framework/src/windup_framework/config/redis.py create mode 100644 backend/packages/framework/src/windup_framework/db/redis.py create mode 100644 backend/packages/framework/src/windup_framework/providers/interfaces.py create mode 100644 backend/packages/framework/src/windup_framework/providers/matte.py create mode 100644 backend/packages/framework/src/windup_framework/providers/sufy.py create mode 100644 backend/tests/test_generation_api.py create mode 100644 backend/tests/test_local_runtime.py create mode 100644 backend/tests/test_loop.py create mode 100644 backend/tests/test_oneshot.py create mode 100644 docs/module-split-plan.md create mode 100644 docs/sse-generation-flow.md create mode 100644 docs/superpowers/plans/2026-08-05-home-auth-account.md create mode 100644 docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md create mode 100644 docs/superpowers/specs/2026-08-05-home-auth-account-design.md create mode 100644 docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md create mode 100644 frontend/src/app/api-contract.test.ts create mode 100644 frontend/src/app/app-composition.test.tsx create mode 100644 frontend/src/app/app.test.tsx create mode 100644 frontend/src/app/layout/index.test.tsx create mode 100644 frontend/src/entities/character/api.test.ts create mode 100644 frontend/src/entities/character/api.ts create mode 100644 frontend/src/entities/constants.ts create mode 100644 frontend/src/entities/media/api.test.ts create mode 100644 frontend/src/entities/media/api.ts create mode 100644 frontend/src/entities/playtest-inspection/api.test.ts create mode 100644 frontend/src/entities/playtest-inspection/api.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/api.test.ts create mode 100644 frontend/src/entities/project/api.ts create mode 100644 frontend/src/entities/user/api.test.ts create mode 100644 frontend/src/entities/user/api.ts create mode 100644 frontend/src/entities/user/index.ts create mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/entities/workflow-run/store.ts create mode 100644 frontend/src/features/auth-session/index.test.tsx create mode 100644 frontend/src/features/auth-session/index.tsx create mode 100644 frontend/src/features/auth-session/session-storage.test.ts create mode 100644 frontend/src/features/auth-session/session-storage.ts create mode 100644 frontend/src/features/character-setup/index.test.ts create mode 100644 frontend/src/features/export-package/asset-export.test.ts create mode 100644 frontend/src/features/export-package/asset-export.ts create mode 100644 frontend/src/features/export-package/cocos-target.ts create mode 100644 frontend/src/features/export-package/contract.ts create mode 100644 frontend/src/features/export-package/export-panel.test.tsx create mode 100644 frontend/src/features/export-package/export-panel.tsx create mode 100644 frontend/src/features/export-package/index.ts create mode 100644 frontend/src/features/export-package/model.ts create mode 100644 frontend/src/features/publish/index.test.ts create mode 100644 frontend/src/features/publish/index.ts create mode 100644 frontend/src/features/publish/workflow-to-character.ts create mode 100644 frontend/src/features/workflow-controller/action-generation-task.ts create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.ts create mode 100644 frontend/src/features/workflow-controller/store-invariants.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts create mode 100644 frontend/src/pages/history/index.tsx create mode 100644 frontend/src/pages/home/account-panel.test.tsx create mode 100644 frontend/src/pages/home/account-panel.tsx create mode 100644 frontend/src/pages/projects/create-page.tsx create mode 100644 frontend/src/pages/quick-start/index.test.tsx create mode 100644 frontend/src/pages/quick-start/service.test.ts create mode 100644 frontend/src/pages/quick-start/service.ts create mode 100644 frontend/src/pages/workflow-editor/index.test.tsx create mode 100644 frontend/src/pages/workflow-editor/node-canvas.ts create mode 100644 frontend/src/pages/workflow-editor/service.ts create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.test.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-editor.css create mode 100644 frontend/src/shared/api/http-client.test.ts create mode 100644 frontend/src/shared/api/http-client.ts create mode 100644 frontend/src/shared/api/upload.ts create mode 100644 frontend/src/shared/ui/pagination.tsx create mode 100644 frontend/src/test/authenticated-app-routes.tsx create mode 100644 start.bat create mode 100644 start.command diff --git a/.gitignore b/.gitignore index 65fbc3fd..39bf5e20 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ output/ .ruff_cache/ .pytest_cache/ .import_linter_cache/ + +# 本地数据库初始化脚本 +init.sql diff --git a/README.md b/README.md index 947814de..58c325b6 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,2 @@ -

- Windup 机械小鸟标志 -

- -

Windup

- -

- 面向国产小游戏开发者的 2D 角色动态素材生成与资产工作台 -

- -

交付的是资产,而不是图片。

- -Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧质检、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。 - -## 产品链路 / Product Workflow - -```text -新角色:文字描述 / 参考图 → 项目约束 → 角色母版 -已有角色:从资产库继续生产 ─────────────┘ - ↓ - 动作序列帧 → 逐帧审核 / 局部重生成 - ↓ - Playtest 试玩 → PNG / Sprite Sheet / 元数据 → 游戏引擎 -``` - -Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体帧或节点,已通过的结果继续保留。 - -## 核心对象 / Core Concepts - -| 对象 | 职责 | -| --- | --- | -| `Project` | 统一管理题材、美术风格、视角与精灵尺寸等项目级约束 | -| `Character` | 角色资产本体;造型、动作实例与帧属于它的资产树 | -| `ActionTemplate` | 可在不同角色间复用的动作规格与生产配方 | -| `Generation` | 一次生成任务及其输入、状态和结果,用于恢复与追溯 | -| `WorkflowRun` | 一次前端制作流程的运行记录,连接生成、确认、回退与导出 | - -产品提供两种入口:`Quick Start` 用自然语言建立标准生产流程;`Workflow Editor` 在系统预置的成熟管线上追加动作分支、微调参数和局部返工。两者共用同一套流程状态和质量门禁,分别服务快速创建与精细控制。 - -## 当前阶段 / Project Status - -MS2 已完成 Windup 的产品 MVP,验证了角色资产生产的核心链路。MS3 的重点从“完成一次生成”转向“持续完善已有角色资产”:用户可以从资产库回到已有角色,为它补充动作、重做有问题的分支,并保留未受影响的资产。 - -| 状态 | 内容 | -| --- | --- | -| MS2 产出 | 完成产品 MVP,跑通并验证角色资产生产的核心体验 | -| MS3 产品主线 | 已有角色补动作;工作流采用固定成熟管线,通过卡片加号追加分支,支持参数微调与局部重跑 | -| MS3 工程重点 | 持久化 `WorkflowRun` 并关联角色,串起工作流编辑、产物审核、节点回退与 Playtest | -| 后续探索 | Quick Start Agent、3D 动作生成路线、多视角资产与项目级导出 | - -项目进度见 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues)。 - -## 技术栈 / Tech Stack - -- 前端:React 19、TypeScript 6、Vite 8、Tailwind CSS 4、Vitest -- 后端:Python 3.12、FastAPI、Pydantic、SQLAlchemy、uv workspace -- 工程约束:GitHub Actions、Ruff、Pytest、Import Linter、oxlint、oxfmt - -## 本地开发 / Local Development - -前端支持 Node.js `^20.19.0`、`^22.12.0` 或 `>=24.0.0`;CI 使用 Node.js 24: - -```bash -cd frontend -npm ci -npm run dev -``` - -后端使用 Python 3.12 和 [uv](https://docs.astral.sh/uv/): - -```bash -cd backend -uv sync --frozen -uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload -``` - -## 质量检查 / Quality Checks - -```bash -# frontend/ -npm run format:check -npm run lint -npm run typecheck -npm run test -npm run build - -# backend/ -uv run ruff check . -uv run lint-imports -uv run pytest -q -``` - -## 仓库结构 / Repository Structure - -```text -Windup/ -├── frontend/ # React 前端、页面与制作流程 -├── backend/ # Python 工作区、领域服务与 API -├── docs/ # 后端模块划分等工程文档 -├── frontend-architecture-v3.md -└── README.md -``` - -## 相关文档 / Documentation - -- [Windup 产品策划案](https://github.com/1024XEngineer/Windup/issues/37) -- [核心流程与工作流](https://github.com/1024XEngineer/Windup/issues/25) -- [前端架构与模块边界](frontend-architecture-v3.md) -- [前后端 API 契约差异](frontend/API_CONTRACT.md) -- [后端模块划分](docs/module-split.md) - -## 参与贡献 / Contributing - -问题、需求和实验记录统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。 - -项目的维护与历史贡献见 [Contributors](https://github.com/1024XEngineer/Windup/graphs/contributors)。 - -## 许可证 / License - -[Apache License 2.0](LICENSE) +# game-asset-character +Generate high-quality 2D game characters. diff --git a/api-reference.md b/api-reference.md new file mode 100644 index 00000000..ea910d80 --- /dev/null +++ b/api-reference.md @@ -0,0 +1,532 @@ +# Windup API 接口文档 + +> **Base URL**: `http://127.0.0.1:8000` +> **Content-Type**: `application/json`(除文件上传外) +> **最后更新**: 2026-07-30 + +--- + +## 目录 + +1. [项目管理 (Projects)](#1-项目管理-projects) +2. [角色管理 (Characters)](#2-角色管理-characters) +3. [媒体上传 (Media)](#3-媒体上传-media) +4. [生成任务 (Generation)](#4-生成任务-generation) +5. [通用说明](#5-通用说明) + +--- + +## 1. 项目管理 (Projects) + +### 1.1 创建项目 + +**`POST /projects`** + +| 参数 | 类型 | 必填 | 校验 | 说明 | +|---|---|-|---|-------------------------| +| `user_id` | int | ✅ | `>0` | 用户 ID | +| `project_name` | string | ✅ | `1~20字符` | 项目名称(同用户下不可重复) | +| `character_perspective` | int | ✅ | `1~3` | 角色视角(1=侧视, 2=正面, 3=正面) | +| `directional_movement` | int | ✅ | `1~3` | 方向移动方式 (1=单向,2=四向,3=八向) | +| `sprite_width` | int | ✅ | `32~2048` | 精灵图宽度 | +| `sprite_height` | int | ✅ | `32~2048` | 精灵图高度 | +| `workflow_id` | int \| null | | — | 工作流 ID | +| `game_style` | string \| null | | — | 游戏风格 | +| `sprite_sample_url` | string \| null | — | 精灵图示例 URL | + +**返回示例**: + +```json +{ + "code": 200, + "message": "创建成功", + "data": { + "id": 6, + "user_id": 1, + "project_name": "像素勇者", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "workflow_id": null, + "game_style": null, + "sprite_sample_url": null, + "create_at": "2026-07-30T10:00:00Z", + "update_at": "2026-07-30T10:00:00Z" + } +} +``` + +**错误**:项目名重复返回 `400`。 + +--- + +### 1.2 项目列表 + +**`GET /projects`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|-|---|---| +| `user_id` | int \| null | null | 按用户筛选 | +| `page` | int | 1 | 页码(≥1) | +| `page_size` | int | 20 | 每页条数(1~100) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 6, + "user_id": 1, + "project_name": "像素勇者", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "create_at": "2026-07-30T10:00:00Z", + "update_at": "2026-07-30T10:00:00Z" + } + ], + "total": 1, + "page": 1, + "page_size": 20 +} +``` + +--- + +### 1.3 获取项目详情 + +**`GET /projects/{project_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `project_id` | int | path | 项目 ID | + +**返回**:单个 `ProjectOut` 对象(结构同列表项)。 + +--- + +### 1.4 删除项目 + +**`DELETE /projects/{project_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `project_id` | int | path | 项目 ID | + +**返回**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +--- + +## 2. 角色管理 (Characters) + +### 2.1 创建角色 + +**`POST /characters`** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `project_id` | int | ✅ | 所属项目 ID | +| `description` | string \| null | ❌ | 角色描述 | +| `reference_image_url` | string \| null | ❌ | 角色参考图 URL | +| `character_data` | object | ❌ | 角色完整数据(见下方结构) | + +**`character_data` 结构**: + +```json +{ + "version": 1, + "outfits": [ + { + "id": "outfit_01", + "name": "默认套装", + "description": "初始装备", + "preview_url": "http://...", + "actions": [ + { + "id": "walk_01", + "type": "walk", + "name": "走路", + "loop": true, + "fps": 12, + "frame_count": 8, + "frames": [ + { + "index": 0, + "image_url": "http://...", + "duration_ms": 125 + } + ] + } + ] + } + ] +} +``` + +**`character_data` 字段说明**: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `version` | int | ❌ | 1 | 数据版本号 | +| `outfits` | list | ❌ | [] | 套装列表 | + +**`outfits[]` 字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | string | ✅ | 套装唯一 ID | +| `name` | string | ✅ | 套装名称 | +| `description` | string \| null | ❌ | 套装描述 | +| `preview_url` | string \| null | ❌ | 套装预览图 URL | +| `actions` | list | ❌ | 动作列表 | + +**`outfits[].actions[]` 字段说明**: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `id` | string | ✅ | — | 动作唯一 ID | +| `type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` | +| `name` | string | ✅ | — | 动作名称 | +| `loop` | bool | ❌ | false | 是否循环播放 | +| `fps` | float | ❌ | 12 | 帧率(>0) | +| `frame_count` | int | ❌ | 0 | 帧数(≥0) | +| `frames` | list | ❌ | [] | 帧列表 | + +**`actions[].frames[]` 字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `index` | int | ✅ | 帧序号(从0开始) | +| `image_url` | string | ✅ | 帧图片 URL | +| `duration_ms` | int \| null | ❌ | 单帧时长(毫秒) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "创建成功", + "data": { + "id": 1, + "project_id": 6, + "description": "武士角色", + "reference_image_url": "http://...", + "character_data": { "version": 1, "outfits": [] }, + "status": 1 + } +} +``` + +--- + +### 2.2 角色列表 + +**`GET /characters`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `project_id` | int | ✅ | — | 所属项目 ID | +| `page` | int | ❌ | 1 | 页码 | +| `page_size` | int | ❌ | 20 | 每页条数(1~100) | + +**返回**:`ListResponse[CharacterOut]`,结构同项目列表。 + +--- + +### 2.3 获取角色详情 + +**`GET /characters/{character_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `character_id` | int | path | 角色 ID | + +**返回**:单个 `CharacterOut` 对象。 + +--- + +### 2.4 更新角色 + +**`PATCH /characters/{character_id}`** + +> 只传需要修改的字段即可,未传的字段不修改。 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `description` | string \| null | ❌ | 角色描述 | +| `reference_image_url` | string \| null | ❌ | 参考图 URL | +| `character_data` | object \| null | ❌ | 完整角色数据(同创建) | + +**返回**:更新后的 `CharacterOut`。 + +--- + +### 2.5 删除角色 + +**`DELETE /characters/{character_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `character_id` | int | path | 角色 ID | + +**返回**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +--- + +## 3. 媒体上传 (Media) + +### 3.1 上传图片 + +**`POST /media/upload`** + +> Content-Type: `multipart/form-data` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file` | File | ✅ | 图片文件(只接受 `image/*`) | +| `category` | string | ❌ | 分类:`reference-image` / `outfit-preview` / `action-frame` / `general`(默认 `general`) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "上传成功", + "data": { + "url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/abc123.png", + "object_key": "media/reference-image/abc123.png", + "filename": "knight.png", + "content_type": "image/png", + "size": 16140 + } +} +``` + +**错误**:非图片文件返回 `400`。 + +--- + +## 4. 生成任务 (Generation) + +> 生成任务均为**异步**:先创建任务记录返回 `id`,前端轮询 `GET /generation/tasks/{id}` 获取状态和结果。 + +### 4.1 提交图片生成任务 + +**`POST /generation/image`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `user_id` | int | ✅ | — | 用户 ID | +| `project_id` | int \| null | ❌ | null | 项目 ID | +| `reference_image_url` | string \| null | ❌ | null | 参考图 URL(可选,纯文生图可不传) | +| `prompt` | string | ❌ | "" | 生成提示词 | +| `negative_prompt` | string | ❌ | "" | 反向提示词 | +| `width` | int | ❌ | 1024 | 输出宽度 | +| `height` | int | ❌ | 1024 | 输出高度 | +| `num_images` | int | ❌ | 1 | 生成数量 | + +**返回示例**: + +```json +{ + "code": 200, + "message": "任务已提交", + "data": { + "id": 9, + "user_id": 1, + "project_id": 6, + "task_type": "character_image", + "status": "pending", + "input_payload": { + "reference_image_url": null, + "prompt": "帮我生成一个穿着日本和服的女人", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 1 + }, + "result": {"image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/9516edb3261e45c39362e0a49e184fe1.png"}, + "error_message": null + } +} +``` + +--- + +### 4.2 提交动作生成任务 + +**`POST /generation/action`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `user_id` | int | ✅ | — | 用户 ID | +| `project_id` | int \| null | ❌ | null | 项目 ID | +| `character_id` | int | ✅ | — | 角色 ID | +| `action_type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` | +| `custom_prompt` | string \| null | ❌ | null | 自定义提示词 | +| `reference_video_url` | string \| null | ❌ | null | 参考视频 URL | +| `reference_image_urls` | list[string] | ❌ | [] | 参考图 URL 列表(第一张作为母版) | +| `num_frames` | int | ❌ | 16 | 生成帧数 | + +**返回示例**: + +```json +{ + "code": 200, + "message": "任务已提交", + "data": { + "id": 15, + "user_id": 1, + "project_id": 6, + "task_type": "character_action", + "status": "pending", + "input_payload": { + "character_id": 1, + "action_type": "walk", + "custom_prompt": null, + "reference_image_urls": ["http://..."], + "num_frames": 8 + }, + "result": {"frames": [{"index": 0, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/35069fad379f4623be7e0bbdd389e6a9.png", "duration_ms": 125}, {"index": 1, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a25b81d26b7e42f49be05bbe2a2bf131.png", "duration_ms": 125}, {"index": 2, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/b39b7ae9d7a34bf1b022d38f6e149851.png", "duration_ms": 125}, {"index": 3, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9e5305bd74124f908459a45dbc7163b5.png", "duration_ms": 125}, {"index": 4, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/31331423ff974617a4f68c6e3dd93220.png", "duration_ms": 125}, {"index": 5, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/48e682d778dd4cc9b7b579f355a4896f.png", "duration_ms": 125}, {"index": 6, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cdbb141f59ed40e8816cd68a25a82d30.png", "duration_ms": 125}, {"index": 7, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/f322162bd25847819a153edd0074c938.png", "duration_ms": 125}], "action_type": "walk"}, + "error_message": null + } +} +``` + +--- + +### 4.3 查询生成任务 + +**`GET /generation/tasks/{task_id}`** + +| 参数 | 类型 | 位置 | 必填 | 说明 | +|---|---|---|---|---| +| `task_id` | int | path | ✅ | 任务 ID | +| `project_id` | int | query | ✅ | 项目 ID | + +**状态流转**:`pending` → `running` → `completed` / `failed` + +**completed 时的 result 结构**: + +- **图片任务** (`character_image`): + +```json +{ + "result": { + "type": "character_image", + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/xxx.png" + } +} +``` + +- **动作任务** (`character_action`): + +```json +{ + "result": { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/xxx.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://...", + "duration_ms": 125 + } + ] + } +} +``` + +**failed 时**: + +```json +{ + "status": "failed", + "error_message": "具体错误信息", + "result": null +} +``` + +--- + +## 5. 通用说明 + +### 5.1 统一响应格式 + +**单条数据** `Response[T]`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | int | 业务状态码(200=成功) | +| `message` | string | 状态消息 | +| `data` | T \| null | 业务数据 | + +**列表数据** `ListResponse[T]`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | int | 业务状态码 | +| `message` | string | 状态消息 | +| `data` | list[T] | 数据列表 | +| `total` | int | 总条数 | +| `page` | int | 当前页 | +| `page_size` | int | 每页条数 | + +### 5.2 错误码 + +| HTTP 状态码 | 说明 | +|---|---| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 404 | 资源不存在 | + +### 5.3 枚举值 + +**动作类型 `action_type`**:`walk` / `idle` / `attack` / `custom` + +**媒体分类 `category`**:`reference-image` / `outfit-preview` / `action-frame` / `general` + +**任务状态 `status`**:`pending` → `running` → `completed` / `failed` + +### 5.4 生成任务轮询建议 + +```javascript +// 前端轮询示例 +async function pollTask(taskId, projectId) { + while (true) { + const res = await fetch(`/generation/tasks/${taskId}?project_id=${projectId}`); + const { data } = await res.json(); + + if (data.status === 'completed') return data.result; + if (data.status === 'failed') throw new Error(data.error_message); + + await new Promise(r => setTimeout(r, 2000)); // 2秒轮询 + } +} +``` diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 00000000..0c64045b --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""初始化数据库并启动后端服务。 + +使用 SQLite 作为开发数据库,避免依赖 PostgreSQL。 +""" + +import os + +from windup_framework.config.database import resolve_sqlite_path + +# 所有入口共用 framework 层的解析规则,避免从不同目录启动时创建同名空库。 +os.environ["SQLITE_PATH"] = str(resolve_sqlite_path(os.getenv("SQLITE_PATH", "windup.db"))) + +def init_database(): + """初始化数据库,创建所有表。""" + from windup_framework.db.base import Base + from windup_framework.db.session import engine + + # 注册所有 ORM 模型后,Base.metadata 才包含完整表结构。 + from windup_app.server.character.model import Character # noqa: F401 + from windup_app.server.generation.model import GenerationTaskRecord # noqa: F401 + from windup_app.server.playtest_inspection.model import PlaytestInspection # noqa: F401 + from windup_app.server.project.model import Project # noqa: F401 + + print("正在初始化数据库...") + Base.metadata.create_all(engine) + print("数据库初始化完成!") + + +def main(): + """主函数:初始化数据库并启动后端服务。""" + # 初始化数据库 + init_database() + + # 启动后端服务 + print("正在启动后端服务...") + from windup_app.bootstrap.app import main as start_server + + start_server() + + +if __name__ == "__main__": + main() diff --git a/backend/packages/ai_engine/pyproject.toml b/backend/packages/ai_engine/pyproject.toml index bb279425..84492ec8 100644 --- a/backend/packages/ai_engine/pyproject.toml +++ b/backend/packages/ai_engine/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ "langchain-core>=0.3", "pillow>=10.4", "numpy>=1.26", + "imageio>=2.36", + "av>=14.0", # imageio pyav 后端(视频抽帧) # "rembg", # 抠图(按需启用) ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py new file mode 100644 index 00000000..456b868a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py @@ -0,0 +1,5 @@ +"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。""" + +from .character_generator import CharacterGenerator + +__all__ = ["CharacterGenerator"] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py new file mode 100644 index 00000000..ecdfd6cf --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -0,0 +1,88 @@ +"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 + +这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 +串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 + +MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— +上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_common.models import ActionSpec, CharacterCard, GenRoute + +from windup_ai_engine.ports import ( + CharacterGeneratorPort, + GeneratedAction, + ProgressPort, +) +from windup_ai_engine.postprocess import align_bottom_center, frame_durations +from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +class CharacterGenerator(CharacterGeneratorPort): + """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。""" + + def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None: + self._by_route = strategies + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: + # ① 选路线(架构决策矩阵) + route = ROUTE_MATRIX[action.action] + progress.step("route", 0, 3, f"{action.action} → {route.value}") + strategy = self._by_route[route] + + # ② 生成帧(交给 strategy —— 串联) + frames = strategy.derive(card, action, master, progress) + + # ③ 最后一公里:脚线对齐成原地序列帧 + frames = self._lastmile(frames, progress) + + # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) + progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + return GeneratedAction( + frames=frames, + durations=frame_durations(action.action.value, len(frames)), + fps=action.fps, + ) + + def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): + 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + """ + progress.step("lastmile", 1, 3, "脚线对齐(原地)") + if not frames or not all(frames): # 含空桩帧(未开发路线)→ 跳过 + return frames + imgs = [_img(f) for f in frames] + # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), + # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 + import numpy as _np + _hs = [] + for _im in imgs: + _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) + if len(_ys): + _hs.append(float(_ys.max() - _ys.min())) + aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) + return [_png(im) for im in aligned] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py new file mode 100644 index 00000000..ed2652e3 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -0,0 +1,82 @@ +"""母版规格与预处理:每个动作需要什么样的母版。 + +**核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。** + - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。 + - jump:母版**顶部留白**才不被视频画面裁掉。 + - attack:必须给**极限蓄力母版**(武器已拉到身后腰际)。用站立母版时,即使提示词写死 + "武器不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作 + 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。 + + +实测教训:母版里角色居中、占 ~70% 画面高时,i2v 跳跃会让角色**头顶顶出视频画面上沿** +被裁掉(生成本身没错,是构图没留够空间)。规则同 MasterSpec 的"运动方向多留白": + - jump:向上运动 → 顶部补空间,角色坐低 + - dash / walk / run:向右位移 → 前进方向多留白(由母版生成时构图保证,此处不改) + +纯 PIL,零 API。背景色取母版四角中位色,补出来的边与母版底色一致。 +""" + +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] + +# 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。 +MASTER_POSES = { + "walk": "", # 中性站立即可,但必须朝侧向 + "run": "", + "idle": "", + # jump:与 attack 同理——重甲带剑角色的"跳跃"强动作先验压不住(站立母版会让模型摆 + # 造型、只举剑不腾空,实测)。给**极限蓄力半蹲母版**,模型只能"接着往上蹬"。顶部留白 + # 由 prepare_master(add_headroom)保证。 + "jump": ( + "deep crouch coiled to spring straight upward: the knees bent low and the hips sunk down, " + "both arms drawn back behind the body, the weight loaded onto both legs at the very moment " + "before springing straight up, the weapon kept in a fixed grip; " + "leave generous empty space above the head" + ), + "attack": ( + "extreme wind-up stance for a horizontal slash: the weapon drawn far BACK behind the body " + "at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, " + "both arms low and pulled back, the weapon staying BELOW the shoulders; " + "leave generous empty space on the swing side" + ), +} + + +def _bg_color(img: Image.Image) -> tuple[int, int, int]: + """取四角中位色当背景色(母版通常是纯色底)。""" + rgb = np.asarray(img.convert("RGB")) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + return tuple(int(v) for v in np.median(corners, axis=0)) + + +def add_headroom(master: bytes, ratio: float = 0.6) -> bytes: + """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。 + + Args: + master: 母版图 bytes。 + ratio: 处理后角色所占的画面高度比例(越小头顶空间越多)。0.6 表示角色高度 + 约占新画面的 60%,上方留约 40%。 + """ + if not 0.1 < ratio < 1.0: + raise ValueError("ratio 需在 (0.1, 1.0) 之间") + img = Image.open(io.BytesIO(master)).convert("RGB") + new_h = max(img.height + 1, int(round(img.height / ratio))) + canvas = Image.new("RGB", (img.width, new_h), _bg_color(img)) + canvas.paste(img, (0, new_h - img.height)) # 原图贴底,空间加在顶部 + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +def prepare_master(master: bytes, action: str) -> bytes: + """按动作类型预处理母版;不需要处理的动作原样返回。""" + if action in ("jump", "attack"): + # jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶) + return add_headroom(master, ratio=0.62 if action == "jump" else 0.70) + return master diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py new file mode 100644 index 00000000..efe91c64 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -0,0 +1,59 @@ +"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。 + +CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。 +换掉内部实现(strategy / provider)时 server 零改动。 + +MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。 +母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入; +产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from windup_common.models import ActionSpec, CharacterCard + + +# ---- server 实现、注入给 ai_engine 的进度回调 port ---- +class ProgressPort(Protocol): + """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: ... + + +# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)---- +@dataclass +class GeneratedAction: + """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长。 + + frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成 + ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。 + """ + + frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 + fps: int = 10 + + +# ---- ai_engine 暴露给 server(server 调用的唯一入口)---- +@runtime_checkable +class CharacterGeneratorPort(Protocol): + """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。 + + 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。 + + Args: + card: 角色卡(身份 / 画风 / 朝向)。 + action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。 + master: 定妆母版图 bytes(server 从 reference_image_url 取)。 + progress: 进度回调。 + """ + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py new file mode 100644 index 00000000..e69f0ec7 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py @@ -0,0 +1,28 @@ +"""后处理:把选好的帧落地成交付级序列帧(像素化 / 对齐 / 打包)。 + +抽帧 / 选帧见 :mod:`..slicing`。逐帧时长 ``frame_durations`` 在 :mod:`.rootmotion`。 +""" + +from .rootmotion import DEFAULT_FPS_MS, extract_root_motion, frame_durations +from .pixelate import ( + detect_pixel_size, + extract_palette, + master_pixel_spec, + pixelate_frames, + to_pixel_art, +) +from .pack import align_bottom_center, save_gif, sprite_sheet + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", + "extract_root_motion", + "frame_durations", + "DEFAULT_FPS_MS", + "align_bottom_center", + "sprite_sheet", + "save_gif", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py new file mode 100644 index 00000000..1babf2b7 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -0,0 +1,95 @@ +"""对齐 / 打包(后处理的收尾:脚线对齐 → sprite sheet / gif)。 + +抽帧 / 选帧见 :mod:`..slicing`,像素化见 :mod:`.pixelate`,抠图见 framework 的 +MatteProvider(#20)。本模块把对齐后的帧拼成交付物。 +""" + +from __future__ import annotations + +from PIL import Image + +__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"] + + +def align_bottom_center( + frames: list[Image.Image], + cell: int = 256, + foot_line: float = 0.92, + fill_h: float = 0.62, + preserve_lift: bool = False, + ref_height: float | None = None, +) -> list[Image.Image]: + """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。 + + **整段共用一个缩放系数**(取全序列最高帧定标),不逐帧归一化 —— 逐帧各自缩放到等高 + 会把走路自然的身高起伏(实测约 4%)反向变成"忽大忽小":蹲下的帧被放大、伸展的帧被 + 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。 + + 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底); + 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。 + + ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本 + 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时 + 斧头高举使 bbox 从 485 涨到 660,角色本体因此明显变小;跳跃顶点同理。故传入**参考姿态** + (站立)的高度,各动作即共用同一本体尺寸。``fill_h`` 默认 0.62,给举过头顶留出余量。 + + ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把 + 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。 + """ + import numpy as np + + boxes: list[tuple[int, int, int, int] | None] = [] + for f in frames: + ys, xs = np.where(np.asarray(f)[:, :, 3] > 128) + boxes.append( + (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + if len(ys) + else None + ) + heights = [b[3] - b[1] for b in boxes if b] + if not heights: + return [Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) for _ in frames] + # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量 + ground = max(b[3] for b in boxes if b) if preserve_lift else 0 + # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉 + if preserve_lift: + need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b) + scale = (cell * fill_h) / max(1, need) + elif ref_height: + scale = (cell * fill_h) / ref_height # 参考姿态定标(跨动作一致) + else: + scale = (cell * fill_h) / max(heights) # 回退:本序列最高帧 + + out = [] + for f, box in zip(frames, boxes): + if box is None: + out.append(Image.new("RGBA", (cell, cell), (0, 0, 0, 0))) + continue + crop = f.crop(box) + w = max(1, round(crop.width * scale)) + h = max(1, round(crop.height * scale)) + crop = crop.resize((w, h), Image.NEAREST) + lift = round((ground - box[3]) * scale) if preserve_lift else 0 + canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift)) + out.append(canvas) + return out + + +def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image: + """横向拼接为 sprite sheet。""" + if not frames: + raise ValueError("frames 为空") + w, h = frames[0].size + sheet = Image.new("RGBA", (w * len(frames), h), bg) + for i, f in enumerate(frames): + sheet.alpha_composite(f.convert("RGBA"), (i * w, 0)) + return sheet + + +def save_gif(frames: list[Image.Image], path: str, duration: int = 120) -> None: + """导出循环 gif 供预览。""" + if not frames: + raise ValueError("frames 为空") + rgba = [f.convert("RGBA") for f in frames] + rgba[0].save(path, save_all=True, append_images=rgba[1:], duration=duration, loop=0, disposal=2) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py new file mode 100644 index 00000000..4910fa96 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py @@ -0,0 +1,252 @@ +"""像素化后处理:把生成帧转成脆边限色的像素精灵。 + +视频路线实测(Issue #35): +- i2v 能解决步态(腿真交替、不转身);对**插画风**角色它保留插画质感 → 需要像素化转风格。 +- 对**原生像素**角色 i2v 其实能保住像素感,但链路上两道有损压缩(首帧 JPG q90 + 视频 H.264) + 会在硬边处产生振铃噪点(表现为灰颗粒),像素越细越明显;而通用的"降采样 + 32 色量化" + 因为**网格对不齐**反而更糊。 +- 解法:有母版时按 :func:`master_pixel_spec` 量出母版的**原生像素块大小**与**真实色板**, + 按母版网格降采样 + 颜色吸附回母版色板 —— 压缩灰颗粒不属于色板,会被强制消掉。 + +纯 Pillow / numpy,零 API、秒级,符合"本机只做轻量 CV"的算力约束。 +输入约定:RGBA 图(alpha 为主体掩码,抠图见 framework 的 MatteProvider / Issue #20)。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", +] + + +def _content_bbox(rgba: Image.Image, alpha_thr: int = 128) -> tuple[int, int, int, int]: + """求主体包围盒。 + + 用 :func:`_subject_mask` 而非只看 alpha:母版常是**不透明白底**,只看 alpha 会把整张 + 画布当主体,导致逻辑像素高被算成整图高而非角色高(实测踩过)。 + """ + mask = _subject_mask(rgba.convert("RGBA"), alpha_thr) + ys, xs = np.where(mask) + if len(ys): + return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + return 0, 0, rgba.width, rgba.height + + +def _axis_block_size(crop: np.ndarray, axis: int, min_delta: int, min_frac: float) -> int: + """沿 ``axis`` 估块边长:显著色变位置 → 合并相邻 → 取最常见间距。""" + d = np.abs(np.diff(crop, axis=axis)).sum(axis=2) + frac = (d > min_delta).mean(axis=1 - axis) + edges = np.flatnonzero(frac > min_frac) + 1 + if len(edges) < 3: + return 1 + # 块边界常因轻微抗锯齿占相邻两行/列,合并成一条,否则 gap=1 会淹没真实值 + edges = edges[np.concatenate([[True], np.diff(edges) > 1])] + gaps = np.diff(edges) + gaps = gaps[gaps >= 2] + return int(np.bincount(gaps).argmax()) if len(gaps) else 1 + + +def detect_pixel_size( + img: Image.Image, min_delta: int = 30, min_frac: float = 0.02, max_size: int = 64 +) -> int: + """检测像素画的原生像素块边长(非像素画/检测不出时返回 1)。 + + 原理:像素画的色块边界落在同一网格上,相邻边界间距 = 块边长的整数倍,故取 + **最常见间距**即块边长。两轴分别估,取较小者(更保守,宁可细不可糊)。 + """ + rgba = img.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba) + crop = np.asarray(rgba.crop((x0, y0, x1, y1)).convert("RGB")).astype(np.int16) + if crop.size == 0: + return 1 + sizes = [_axis_block_size(crop, ax, min_delta, min_frac) for ax in (0, 1)] + best = min(s for s in sizes) if all(s >= 1 for s in sizes) else 1 + return max(1, min(best, max_size)) + + +def _erode(mask: np.ndarray, k: int) -> np.ndarray: + """二值腐蚀 k 次(纯 numpy 移位,不引 scipy)。""" + m = mask + for _ in range(max(0, k)): + m = ( + m + & np.roll(m, 1, 0) + & np.roll(m, -1, 0) + & np.roll(m, 1, 1) + & np.roll(m, -1, 1) + ) + if not m.any(): + return mask + return m + + +def _subject_mask( + rgba: Image.Image, alpha_thr: int = 128, bg_tol: int = 40, erode: int = 0 +) -> np.ndarray: + """主体掩码:优先用真实 alpha;母版常是**不透明白底**,此时按四角背景色排除背景。 + + 两个实测踩过的坑: + 1. 不排背景 → 白底占多数像素、吃光色板名额 → 角色被整体吸附成白色。 + 2. 排了背景但保留边缘 → 角色/白底之间的**抗锯齿过渡色**(近白)混进色板 → + 视频里的浅色噪点就近吸附成白点,满身白斑。故取色板时用 ``erode`` 腐蚀掉边缘。 + """ + arr = np.asarray(rgba) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: # 有真实抠图 + mask = alpha > alpha_thr + else: + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + mask = np.abs(rgb - bg).sum(axis=2) > bg_tol + return _erode(mask, erode) + + +def extract_palette( + img: Image.Image, max_colors: int = 32, alpha_thr: int = 128, erode: int = 3 +) -> np.ndarray: + """提取母版真实色板,返回 (K,3) uint8。 + + 只统计主体像素(见 :func:`_subject_mask`,并腐蚀掉抗锯齿边缘),再用中位切分量化 + 归并噪声色 —— 生成的"像素画"常带轻微噪点/抗锯齿,同一名义色被打散成大量近似色, + 直接按频率统计会全被当杂色滤掉。 + """ + rgba = img.convert("RGBA") + arr = np.asarray(rgba) + mask = _subject_mask(rgba, alpha_thr, erode=erode) + pixels = arr[:, :, :3][mask] + if not len(pixels): + pixels = arr[:, :, :3].reshape(-1, 3) + strip = Image.fromarray(pixels.reshape(1, -1, 3).astype(np.uint8), "RGB") + quant = strip.quantize(colors=max(2, max_colors), method=Image.MEDIANCUT) + pal = np.asarray(quant.getpalette()[: max(2, max_colors) * 3], dtype=np.uint8).reshape(-1, 3) + used = np.unique(np.asarray(quant)) + return pal[used[used < len(pal)]] + + +def master_pixel_spec(master: Image.Image, max_colors: int = 48) -> tuple[int, np.ndarray]: + """从母版量出 (角色的逻辑像素高, 母版色板)。 + + 逻辑像素高 = 母版里角色占的像素行数 ÷ 原生像素块边长 —— 即"这个角色本来是多少 + 像素高的精灵"。用它当 ``target_h`` 可自动吸附网格,不必人肉猜分辨率。 + + ``max_colors`` 实测取值:32 太少 —— 中位切分按面积分箱,大面积色(如裸腿肤色/棕靴) + 会挤占名额,小面积但需渐变的衣服色档位不足 → 中间调就近吸到邻近色相(绿衣泛橄榄黄); + 96 太多 —— 抗锯齿近白色重新拿到独立分箱 → 边缘冒白噪点。48 是实测的安全区。 + """ + x0, y0, x1, y1 = _content_bbox(master.convert("RGBA")) + block = detect_pixel_size(master) + logical_h = max(1, round((y1 - y0) / block)) + return logical_h, extract_palette(master, max_colors=max_colors) + + +def _to_perceptual(rgb: np.ndarray) -> np.ndarray: + """RGB → 近似感知空间(亮度 + 两个色差轴),float32。 + + 直接在 RGB 里取最近邻会**跳色相**:绿衣的中间调可能被吸到橄榄黄(实测踩过)。 + 换成亮度/色差轴并给色差加权后,同色相内的明暗过渡优先匹配,色相跳变被压住。 + 这里用 YCbCr 型线性变换(比 Lab 便宜得多,足够拉开色相)。 + """ + f = rgb.astype(np.float32) + r, g, b = f[..., 0], f[..., 1], f[..., 2] + y = 0.299 * r + 0.587 * g + 0.114 * b + cb = b - y + cr = r - y + w = 2.0 # 色差权重 >1:宁可亮度差一点,也别换色相 + return np.stack([y, w * cb, w * cr], axis=-1) + + +def _snap_to_palette(rgb: np.ndarray, palette: np.ndarray) -> np.ndarray: + """把每个像素吸附到色板中最近的颜色(感知空间最近邻,分块避免大内存)。 + + 用 float32 感知空间:①避免 int16 平方距离溢出(255² > 32767,实测让绿衣变肉色); + ②按色相优先匹配,防止 RGB 空间里的跨色相跳变。 + """ + flat = _to_perceptual(rgb).reshape(-1, 3) + pal_p = _to_perceptual(palette).reshape(-1, 3) + pal_rgb = palette.astype(np.uint8).reshape(-1, 3) + out = np.empty((len(flat), 3), dtype=np.uint8) + step = 65536 + for i in range(0, len(flat), step): + chunk = flat[i : i + step] + d = ((chunk[:, None, :] - pal_p[None, :, :]) ** 2).sum(axis=2) + out[i : i + step] = pal_rgb[d.argmin(axis=1)] + return out.reshape(rgb.shape) + + +def to_pixel_art( + rgba: Image.Image, + target_h: int = 100, + palette_size: int = 32, + alpha_thr: int = 128, + palette: np.ndarray | None = None, +) -> Image.Image: + """单帧转像素风,返回小尺寸 RGBA(``target_h`` 高,等比宽)。 + + 步骤:裁到主体包围盒 → 等比缩到 ``target_h``(NEAREST 网格降采样)→ 限色。 + 限色两种模式: + - ``palette`` 给定(推荐,原生像素角色):**吸附到母版真实色板**,顺带消掉 + JPG/H.264 在硬边留下的灰颗粒。 + - ``palette=None``(插画转像素):按 ``palette_size`` 做八叉树量化。 + + Args: + target_h: 目标像素高;原生像素角色建议用 :func:`master_pixel_spec` 算出的逻辑高。 + palette_size: 无母版色板时的量化色数。 + palette: (K,3) uint8 母版色板。 + """ + if target_h < 1: + raise ValueError("target_h 必须 >= 1") + rgba = rgba.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba, alpha_thr) + crop = rgba.crop((x0, y0, x1, y1)) + w, h = crop.size + target_w = max(1, round(w * target_h / h)) + small = crop.resize((target_w, target_h), Image.NEAREST) + + alpha = np.asarray(small)[:, :, 3] + if palette is not None and len(palette): + rgb = _snap_to_palette(np.asarray(small.convert("RGB")), palette) + else: + rgb = np.asarray( + small.convert("RGB") + .quantize(colors=max(2, palette_size), method=Image.FASTOCTREE) + .convert("RGB") + ) + out = np.dstack([rgb, alpha]).astype(np.uint8) + return Image.fromarray(out, "RGBA") + + +def pixelate_frames( + frames: list[Image.Image], + target_h: int = 100, + palette_size: int = 32, + palette: np.ndarray | None = None, + ref_height: float | None = None, +) -> list[Image.Image]: + """批量像素化一组帧,**整段共用一个缩放系数**,便于打包为 sprite sheet。 + + ``target_h`` 是**基准姿态**的目标像素高,其余帧按同一系数等比缩放 —— 不是把每帧都拉 + 到等高。逐帧拉等高会把走路自然的身高起伏反向变成"忽大忽小"(实测踩过:蹲下的帧被放大)。 + + ``ref_height``:**跨动作一致性的关键**。给定时用它当基准(单位=源图像素),否则用本序列 + 最高帧。同一角色的各个动作若各自取自己的最高帧定标,切换状态时角色会忽大忽小 —— + 传入同一个基准(如母版姿态的角色高)即可让 idle/walk/jump/attack 共用一套尺度。 + """ + if not frames: + return [] + box_h = [] + for f in frames: + _, y0, _, y1 = _content_bbox(f.convert("RGBA")) + box_h.append(max(1, y1 - y0)) + scale = target_h / (ref_height if ref_height else max(box_h)) + return [ + to_pixel_art(f, max(1, round(h * scale)), palette_size, palette=palette) + for f, h in zip(frames, box_h) + ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py new file mode 100644 index 00000000..88487645 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py @@ -0,0 +1,69 @@ +"""Root motion(位移轨迹)与逐帧时长 —— 按 2D 游戏业界惯例分离"姿势"与"位移"。 + +业界做法(调研 2026-07-28): +- **位移不烘进序列帧**。连续位移动作几乎一律用 *in-place animation + 引擎代码驱动移动*, + 因为玩家要即时操控:跑动中转向应立刻响应,而不是等一段烘死的位移播完。平台游戏的跳跃 + 也是"几个姿势定格 + 引擎物理驱动上下",不是把抛物线画进像素。 + → 序列帧保持**原地**(脚线对齐),位移单独作为 root-motion 轨道交给引擎。 +- **逐帧时长比帧数更重要**("frame timing beats frame count")。业界常用: + idle 400–500ms/帧、walk 100–150ms、run 80–100ms、attack 起手 80–100ms 且**触点定格 + 150–200ms**。全程等时长会让动作发飘、没有重量感。 + +本模块只做几何与时长计算,纯 numpy,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["extract_root_motion", "frame_durations", "DEFAULT_FPS_MS"] + +# 各动作的基准单帧时长(ms),取业界常用区间的中值。 +DEFAULT_FPS_MS = { + "idle": 450, + "walk": 125, + "run": 90, + "jump": 110, + "attack": 90, + "hit": 90, +} + + +def extract_root_motion(frames: list[Image.Image], alpha_thr: int = 128) -> list[tuple[int, int]]: + """逐帧相对首帧的 (dx, dy) 位移,单位=像素,y 向上为正。 + + 以主体包围盒的**底边中心**(脚点)为参考点。序列帧本身保持原地时,这条轨道就是引擎 + 要施加的 root motion:jump 的 dy 是腾空高度,walk 的 dx 是前进量。 + """ + pts: list[tuple[float, float]] = [] + for f in frames: + a = np.asarray(f.convert("RGBA")) + ys, xs = np.where(a[:, :, 3] > alpha_thr) + pts.append(((xs.min() + xs.max()) / 2, float(ys.max())) if len(ys) else (np.nan, np.nan)) + arr = np.array(pts, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + for c in range(2): + good = ~np.isnan(arr[:, c]) + arr[:, c] = np.interp(idx, idx[good], arr[good, c]) if good.any() else 0.0 + base = arr[0] + return [(int(round(p[0] - base[0])), int(round(base[1] - p[1]))) for p in arr] + + +def frame_durations( + action: str, n_frames: int, key_frame: int | None = None, hold_ms: int = 180 +) -> list[int]: + """逐帧时长(ms)。关键帧(触点 / 顶点)加长定格,其余用该动作的基准时长。 + + Args: + action: 动作名(取 :data:`DEFAULT_FPS_MS` 的基准时长,未知动作按 walk)。 + n_frames: 帧数。 + key_frame: 要定格的帧下标(attack 的触点、jump 的顶点);None 表示全程等时长。 + hold_ms: 关键帧时长,业界常用 150–200ms。 + """ + base = DEFAULT_FPS_MS.get(action, DEFAULT_FPS_MS["walk"]) + out = [base] * max(0, n_frames) + if key_frame is not None and 0 <= key_frame < n_frames: + out[key_frame] = max(base, hold_ms) + return out diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py new file mode 100644 index 00000000..6eebe65b --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py @@ -0,0 +1,16 @@ +"""prompt:各动作的生成提示词与装配。""" + +from .actions import build_attack_prompt, build_custom_prompt, build_idle_prompt +from .jump import JUMP_PHASES, build_jump_prompt +from .walk import WALK_BODY_FRONT, WALK_BODY_SIDE, build_walk_prompt + +__all__ = [ + "WALK_BODY_SIDE", + "WALK_BODY_FRONT", + "build_walk_prompt", + "JUMP_PHASES", + "build_jump_prompt", + "build_idle_prompt", + "build_attack_prompt", + "build_custom_prompt", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py new file mode 100644 index 00000000..f2466202 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -0,0 +1,111 @@ +"""待机 / 攻击 i2v 提示词。 + +措辞迁自 windup-pipeline 已验证的 prompt_library(idle / slash),按本模块的 facing 分流改写。 + +- **idle**:循环类(tail_match)。只写躯干呼吸节律,武器与双脚显式锁定 —— 逐帧生成待机 + 只会抖不会呼吸,故走 i2v 或程序化 Idle-B。 +- **attack**:一次性类。四条已验证的锁定:①"one single committed motion"防复读; + ②剑长与握点固定;③剑在身前、刃面朝观者(防 Z 轴穿模与刀刃翻转);④终态回戒备并保持。 + 节奏(蓄力慢/挥砍快/触点定格)在抽帧做,不写进 prompt。 +""" + +from __future__ import annotations + +__all__ = ["build_idle_prompt", "build_attack_prompt", "build_custom_prompt"] + +_IDLE_SIDE = ( + "The character stands in place, seen from the side facing right: the chest breathes in one " + "slow, even rhythm, the ribcage expanding and easing back while the shoulders stay level and " + "settled at the same height, the torso rising and lowering in that same slow rhythm, " + "{weapon} resting steady at the side in a fixed grip, {garment} hanging and swaying in the " + "same rhythm, both boots planted firmly on the ground, weight centered, the character stays " + "in the same spot and keeps facing right." +) + +_IDLE_FRONT = ( + "The character stands in place facing the viewer: the chest breathes in one slow, even " + "rhythm, the ribcage expanding and easing back while the shoulders stay level and settled at " + "the same height, the torso rising and lowering in that same slow rhythm, {weapon} resting " + "steady at the side in a fixed grip, {garment} hanging and swaying in the same rhythm, both " + "boots planted firmly on the ground, weight centered, the character keeps FACING THE VIEWER " + "and stays in the same spot." +) + +_ATTACK_SIDE = ( + "Seen from the side facing right, the character makes ONE single committed attack, staying in " + "STRICT SIDE VIEW the whole time: starting coiled with the weight on the back foot, the body " + "leans forward and the weight surges onto the front foot, the arm sweeping {weapon} through " + "one smooth downward crescent arc from high behind the shoulder down across the front to full " + "extension low, {weapon} keeping its exact length and grip position and staying clearly in " + "front of the body with its flat side facing the viewer the whole way, {garment} swinging with " + "the motion, then the body settles back upright into guard and holds that stance, standing " + "steady. The torso and hips keep pointing to the right the entire time and the character never " + "turns toward or away from the viewer." +) + +_ATTACK_FRONT = ( + "Facing the viewer, the character makes ONE single committed attack: starting coiled with the " + "weight on the back foot, the whole body uncoils forward, the arm sweeping {weapon} through " + "one smooth arc across the front to full extension, {weapon} keeping its exact length and grip " + "position and staying clearly in front of the body with its flat side facing the viewer the " + "whole way, {garment} swinging with the motion, then the body settles back upright into guard " + "and holds that stance, standing steady and keeping FACING THE VIEWER." +) + +DEFAULT_WEAPON = "the sword" +DEFAULT_GARMENT = "the cape" + + +def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str: + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + body = (side if facing == "side" else front).format(weapon=weapon, garment=garment) + return body.replace("boot", feet) if feet != "boot" else body + + +def build_idle_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """待机正文(循环类)。``facing`` 须与母版朝向一致。""" + return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing) + + +def build_attack_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """攻击正文(一次性类)。``facing`` 须与母版朝向一致。""" + return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing) + + +_CUSTOM_SIDE = ( + "Seen from the side facing right, the character performs ONE continuous motion in a STRICT " + "SIDE VIEW the whole time: {action} in one smooth, repetitive rhythm, the torso and hips " + "keeping pointing to the right, feet staying planted in place, the character never turning " + "toward or away from the viewer, then holding the final pose steady at the end." +) + +_CUSTOM_FRONT = ( + "Facing the viewer, the character performs ONE continuous motion in a FRONT VIEW the whole " + "time: {action} in one smooth, repetitive rhythm, feet staying planted in place, the character " + "keeps FACING THE VIEWER and never turns away, then holding the final pose steady at the end." +) + + +def build_custom_prompt(action: str, facing: str = "side") -> str: + """自定义动作正文(一次性类,视频路线)。 + + ``action`` 为自然语言动作描述(如 "painting on an easel with a brush")。 + ``facing`` 须与母版朝向一致。 + """ + if not action or not action.strip(): + raise ValueError("custom 动作需要动作描述(action_desc)") + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + body = (_CUSTOM_SIDE if facing == "side" else _CUSTOM_FRONT).format(action=action.strip()) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py new file mode 100644 index 00000000..dac08595 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -0,0 +1,63 @@ +"""跳跃 i2v 提示词(一次性动作,非循环)。 + +与 walk/run 的根本差别: +- **不循环**。跳跃是一段有始有终的动作,不能像步态那样抽单周期闭环。 +- **要拆状态**。游戏里跳跃是状态机:蓄力 → 上升 → 顶点 → 下降 → 落地缓冲;悬空时长由 + 物理决定、上升中可被打断,所以必须能分段播放,不能烘成一整段。 +- 提示词要写**"只做一次 + 终态保持"**,防 5s 内复读跳第二次(实测:写了仍会复读,故抽帧层 + 另有 first_action_end 兜底)。 +- **原地起跳、幅度适中**:水平位移交引擎做 root-motion,不烘进像素;幅度过大会让角色顶出 + 视频画面,且序列帧里角色被缩得很小。 + +朝向同 walk:必须与母版一致(side 横版 / front 俯视·2.5D)。 +""" + +from __future__ import annotations + +__all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"] + +# 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。 +JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land") + +JUMP_BODY_SIDE = ( + "The character performs ONE single jump in place, seen from the side facing right: " + "first the knees bend deep into a crouch and the arms drop back, then both boots push " + "off the ground and the whole body lifts straight upward a modest height with the legs " + "tucking up, the body reaches the top of the jump and hangs there for an instant with {garment} " + "floating upward, then the body falls back down with the legs reaching for the ground, " + "and both boots land together with the knees bending to absorb the impact, the weapon " + "stays held steady in a fixed grip the whole time. The character does this ONCE and " + "then stays standing upright in the landing spot, staying centered in frame." +) + +JUMP_BODY_FRONT = ( + "The character performs ONE single jump in place, facing the viewer: first the knees " + "bend deep into a crouch and the arms drop back, then both boots push off the ground " + "hard and the whole body launches straight upward with the knees tucking up toward the " + "camera, the body reaches the top of the jump and hangs there for an instant with " + "{garment} floating upward, then the body falls back down with the legs reaching for " + "the ground, and both boots land together with the knees bending to absorb the impact, " + "the weapon stays held steady in a fixed grip the whole time. The character keeps " + "FACING THE VIEWER, does this ONCE and then stays standing upright, centered in frame." +) + +DEFAULT_GARMENT = "the cape and tabard" + + +def build_jump_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成跳跃正文。 + + Args: + garment: 起跳时上飘的衣饰。 + feet: 落脚部件用词(替换 boot)。 + facing: "side" 或 "front",**必须与母版朝向一致**。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py new file mode 100644 index 00000000..5d3c4a6e --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -0,0 +1,57 @@ +"""走路 i2v 提示词(视频路线)。 + +实测要点(Issue #35): +- 只写正向词、逐条写腿部可见动作(抬 / 摆 / 蹬 / 承重),锁死手持武器不乱动。 +- **提示词的朝向必须与母版朝向一致**。给正面母版喂侧走词(STRICT SIDE)会让模型靠"转身" + 调和图文矛盾——早期"正面母版必转身"的结论正是这么造成的。故按 facing 分流: + side(横版侧走)/ front(俯视·2.5D 朝观者行进),对应 Project.perspective。 +- "半侧"母版(头侧脸 + 身体略正)配 side 词,实测会被自然解析成正侧面走,不转身,够用。 +- 换角色只替换装备子句(如 骷髅:boot→骨足、cape→围巾),机制词保持不变。 +""" + +from __future__ import annotations + +__all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"] + +# 侧走(横版):整体向右推进 + 锁侧视。 +WALK_BODY_SIDE = ( + "The character walks steadily to the right through the open space, the whole body " + "advancing with every stride: the front boot lifts, swings forward and plants heel " + "first, the rear boot pushes off the ground, the hips and torso carry the weight " + "forward over the planted foot, {garment} swing with the steps, the weapon stays held " + "low and steady at the side in a fixed grip, the upper body stays calm and upright, " + "SIDE VIEW facing right the whole time, the legs clearly visible." +) + +# 正面走(俯视 / 2.5D):朝观者原地行进,身体始终正对观者、不转身。 +WALK_BODY_FRONT = ( + "The character walks in place toward the viewer, marching forward on the spot: each " + "boot lifts, swings forward and plants down in turn while the other pushes off, the " + "knees rise alternately toward the camera, the hips and shoulders sway naturally with " + "each step, {garment} sway with the steps, the weapon stays held low and steady in a " + "fixed grip, the upper body stays calm and upright, the character keeps FACING THE " + "VIEWER the whole time and stays centered in frame, both legs clearly visible." +) + +# 每个角色只替换 garment / feet 两处装备子句,机制词不动。 +DEFAULT_GARMENT = "the cape and tabard" + + +def build_walk_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成走路正文。 + + Args: + garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。 + feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。 + facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。 + **必须与母版朝向一致**,否则模型会靠转身调和矛盾。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py new file mode 100644 index 00000000..3489de23 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -0,0 +1,27 @@ +"""slicing:视频 → 帧序列。抽帧(extract)+ 选帧(周期 loop / 一次性 oneshot)。 + +视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝 +loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 +""" + +from .extract import extract_all_frames_bytes, extract_frames_bytes +from .loop import find_period, pick_cycle +from .oneshot import ( + find_motion_span, + first_action_end, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + +__all__ = [ + "extract_frames_bytes", + "extract_all_frames_bytes", + "find_period", + "pick_cycle", + "find_motion_span", + "first_action_end", + "foot_line_series", + "pick_oneshot", + "split_jump_phases", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py new file mode 100644 index 00000000..1a7bc781 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py @@ -0,0 +1,162 @@ +"""视频抽帧(切片层的解码入口)。 + +承接视频路线(Issue #35):i2v 产出的短视频步态真实但为插画质感。本模块只负责 +把视频 bytes 解码成帧序列;选帧(周期 / 一次性)见 :mod:`.loop` / :mod:`.oneshot`, +像素化 / 对齐 / 打包见 :mod:`..postprocess`。抽帧后端(imageio/ffmpeg)函数内惰性, +模块导入零成本、CI 可收集。 +""" + +from __future__ import annotations + +import logging +import os +import shutil +import tempfile +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout + +from PIL import Image + +logger = logging.getLogger("windup.slicing.extract") + +_EXTRACT_TIMEOUT_SECONDS = 180 # 单次抽帧方案超时:3 分钟 + +__all__ = ["extract_frames_bytes", "extract_all_frames_bytes"] + + +def extract_frames_bytes(video: bytes, n: int) -> list[Image.Image]: + """从视频 bytes 均匀抽 ``n`` 帧(供后端 strategy 用,provider 返回的是 bytes)。""" + path = tempfile.mktemp(suffix=".mp4") + try: + with open(path, "wb") as f: + f.write(video) + return _extract_frames(path, n) + finally: + try: + os.unlink(path) + except OSError: + pass + + +def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]: + """抽视频全部帧(至多 ``cap``,均匀降采样),供周期检测用。""" + path = tempfile.mktemp(suffix=".mp4") + try: + with open(path, "wb") as f: + f.write(video) + return _extract_frames(path, cap) + finally: + try: + os.unlink(path) + except OSError: + pass + + +def _run_with_timeout(fn, *args, timeout: int = _EXTRACT_TIMEOUT_SECONDS): + """在独立线程中执行 *fn*,超时则抛 TimeoutError。 + + 用 ThreadPoolExecutor 而非 signal,兼容 Windows 子线程场景。 + """ + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(fn, *args) + try: + return future.result(timeout=timeout) + except FutureTimeout as exc: + future.cancel() + raise TimeoutError( + f"抽帧耗时超过 {timeout} 秒,已终止" + ) from exc + + +def _imageio_extract(video_path: str, n: int) -> list[Image.Image]: + import imageio.v3 as iio + + all_frames = iio.imread(video_path, plugin="pyav") # (T, H, W, C) + total = len(all_frames) + m = min(n, total) + idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] + + +def _pyav_extract(video_path: str, n: int) -> list[Image.Image]: + import av as _av + + container = _av.open(video_path) + all_frames = [] + for frame in container.decode(video=0): + all_frames.append(frame.to_rgb().to_ndarray()) + container.close() + if not all_frames: + return [] + total = len(all_frames) + m = min(n, total) + idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] + + +def _ffmpeg_extract(video_path: str, n: int) -> list[Image.Image]: + import glob + import subprocess + + with tempfile.TemporaryDirectory() as tmp: + subprocess.run( + ["ffmpeg", "-y", "-i", video_path, "-vsync", "0", + os.path.join(tmp, "f_%04d.png")], + capture_output=True, check=True, + ) + files = sorted(glob.glob(os.path.join(tmp, "f_*.png"))) + if not files: + raise RuntimeError("抽帧失败:视频无可解码帧") + m = min(n, len(files)) + idx = [round(i * (len(files) - 1) / max(1, m - 1)) for i in range(m)] + return [Image.open(files[i]).convert("RGBA").copy() for i in idx] + + +def _extract_frames(video_path: str, n: int) -> list[Image.Image]: + """从视频均匀抽 ``n`` 帧。优先 imageio,回退 pyav 直调,再回退系统 ffmpeg。 + + 每个方案都有 3 分钟超时保护,避免解码卡死导致后台线程永久挂起。 + """ + errors: list[str] = [] + timeout = _EXTRACT_TIMEOUT_SECONDS + + # 1) imageio + pyav 插件 + try: + return _run_with_timeout(_imageio_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("imageio 抽帧超时: %s", exc) + errors.append(f"imageio: {exc}") + except Exception as exc: + logger.debug("imageio 抽帧失败,尝试下一方案: %s", exc) + errors.append(f"imageio: {exc}") + + # 2) pyav 直调(绕过 imageio 插件初始化问题) + try: + return _run_with_timeout(_pyav_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("pyav 抽帧超时: %s", exc) + errors.append(f"pyav: {exc}") + except Exception as exc: + logger.debug("pyav 抽帧失败,尝试下一方案: %s", exc) + errors.append(f"pyav: {exc}") + + # 3) 系统 ffmpeg + if shutil.which("ffmpeg") is None: + errors.append("ffmpeg: 系统未安装 ffmpeg 或不在 PATH 中") + raise RuntimeError( + "视频抽帧失败,所有方案均不可用:\n" + + "\n".join(f" - {e}" for e in errors) + ) + + import subprocess + + try: + return _run_with_timeout(_ffmpeg_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("ffmpeg 抽帧超时: %s", exc) + raise RuntimeError( + "视频抽帧失败,所有方案均超时或不可用:\n" + + "\n".join(f" - {e}" for e in errors) + ) from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.decode(errors="replace") if exc.stderr else "" + raise RuntimeError(f"ffmpeg 抽帧失败 (exit {exc.returncode}): {stderr}") from exc diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py new file mode 100644 index 00000000..e4bd336b --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -0,0 +1,52 @@ +"""循环闭合(最后一公里之一,Issue #21)—— 从 i2v 密集帧里抽正好一个步态周期,做无缝 loop。 + +i2v 的 5s 视频里含 ~2-3 个步态周期,均匀抽 N 帧跨多个周期 → 首尾接缝跳。做法: +帧自相似检测周期(灰度小图,frame[i] 与 frame[i+p] 差最小的 p = 一个周期), +再在一个周期内均匀取 N 帧 → frame[N-1] 的下一拍≈frame[0],循环自然闭合。 +纯 numpy / PIL,零 API。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["find_period", "pick_cycle"] + +_SMALL = 48 # 周期检测用的灰度小图边长 + + +def _gray(frames: list[Image.Image]) -> list[np.ndarray]: + return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] + + +def find_period(frames: list[Image.Image], pmin: int | None = None, pmax: int | None = None) -> int: + """自相似求步态周期(帧数)。frame[i] 与 frame[i+p] 平均差最小的 p。""" + n = len(frames) + gs = _gray(frames) + pmin = pmin or (2 if n < 12 else max(4, n // 6)) + pmax = pmax or max(pmin + 1, n // 2) + best_p, best_d = pmin, float("inf") + for p in range(pmin, pmax + 1): + d = float(np.mean([np.abs(gs[i] - gs[i + p]).mean() for i in range(n - p)])) + if d < best_d: + best_d, best_p = d, p + return best_p + + +def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: + """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。 + + 源视频帧少于目标帧时按循环时间轴重复采样,仍兑现调用方请求的帧数。这里不 + 合成不存在的中间画面,只重复最接近的源帧,因此不会引入额外的角色形变。 + """ + total = len(frames) + if total == 0 or n <= 0: + return [] + if total < 3: + return [frames[round(k * (total - 1) / max(1, n - 1))] for k in range(n)] + gs = _gray(frames) + p = find_period(frames) + # 搜起点 i0:让 frame[i0] 与 frame[i0+p] 最像(相位闭合最好)→ 末帧回接首帧最平滑 + i0 = min(range(total - p), key=lambda i: float(np.abs(gs[i] - gs[i + p]).mean())) + idx = [i0 + round(k * p / n) for k in range(n)] + return [frames[i] for i in idx] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py new file mode 100644 index 00000000..6f63d487 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -0,0 +1,189 @@ +"""一次性动作(jump / attack / hit)的抽帧:裁动作起止 + 按状态切段。 + +与循环类(idle/walk/run)的根本差别: +- 循环类用 :mod:`.loop` 找步态周期抽单周期闭环;一次性动作**不能闭环** —— 首尾姿态不同, + 强行闭环会把落地帧接回蓄力帧,读起来是抽搐。 +- i2v 出的 5s 视频里,真正的动作往往只占中间一段(前后是静止的起手/终态保持),直接均匀 + 抽帧会浪费一半帧在不动的地方 → 需要先**裁到动作发生的区间**。 +- jump 还要进一步**按状态切段**(蓄力/上升/顶点/下降/落地),因为引擎里悬空时长由物理 + 决定、上升中可被打断,必须能分段播放。 + +纯 numpy / PIL,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "find_motion_span", + "first_action_end", + "pick_oneshot", + "split_jump_phases", + "foot_line_series", +] + + +def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray: + """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。""" + gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames] + return np.array([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)]) + + +def find_motion_span(frames: list[Image.Image], rel_thr: float = 0.25) -> tuple[int, int]: + """定位"动作真正发生"的帧区间 ``[start, end]``(含端点)。 + + 以帧间差异强度超过峰值 ``rel_thr`` 倍的最早/最晚位置为界,并各留一帧余量。 + 静止的起手与终态保持会被裁掉。 + """ + if len(frames) < 3: + return 0, len(frames) - 1 + e = _frame_energy(frames) + peak = float(e.max()) + if peak <= 1e-6: + return 0, len(frames) - 1 + active = np.flatnonzero(e >= peak * rel_thr) + if not len(active): + return 0, len(frames) - 1 + start = max(0, int(active[0]) - 1) + end = min(len(frames) - 1, int(active[-1]) + 2) + return start, end + + +def _airborne_end(frames: list[Image.Image], start: int, end: int, tol: float = 6.0) -> int: + """腾空类(jump)的结束:脚线越过最高点后**首次回到地面**。 + + 几何信号,明确无歧义 —— 比任何"能量安静"判据都稳。 + """ + y = foot_line_series(frames[start : end + 1]) + if len(y) < 4: + return end + apex = int(np.argmin(y)) + ground = float(np.median([y[0], y[-1]])) + back = np.flatnonzero(y[apex:] >= ground - tol) + return min(end, start + apex + int(back[0]) + 2) if len(back) else end + + +def _swing_end(frames: list[Image.Image], start: int, end: int, + drop_ratio: float = 0.35, recover: int = 2) -> int: + """挥击类(attack/hit)的结束:能量越过峰值后**首次跌到峰值的 ``drop_ratio``**,再留收势余量。 + + 挥击是"蓄力 → 峰值 → 收势"的单峰结构,收势很短,故用"跌破比例 + 固定余量"即可; + 不要求长时间静止 —— 实测挥砍收势段的能量并不干净(视频压缩噪点),等不到静止平台。 + """ + e = _frame_energy(frames[start : end + 1]) + if len(e) < 4: + return end + peak_i = int(np.argmax(e)) + thr = float(e.max()) * drop_ratio + for i in range(peak_i + 1, len(e)): + if e[i] < thr: + return min(end, start + i + recover) + return end + + +def first_action_end( + frames: list[Image.Image], start: int, end: int, kind: str = "swing" +) -> int: + """在 ``[start, end]`` 内找**第一次**动作的结束帧,按动作物理分流。 + + i2v 常在 5s 里把一次性动作**复读第二遍**(实测:提示词写了 "ONCE",兽人跳了两次、 + 挥砍也挥了两次),不裁会把两次动作压进一套序列帧。 + + 不同动作的"结束"信号本质不同,**一个通用判据管不了两种**(实测踩过): + - ``kind="airborne"``(jump):脚线回到地面 —— 几何、无歧义。 + - ``kind="swing"``(attack/hit):能量跌破峰值比例 + 收势余量。 + + 三个已验证无效的通用解法(别再试):①只看"帧间安静" → 在跳跃**顶点悬停**处误触发, + 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁; + ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。 + """ + if end - start < 4: + return end + return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end) + + +def pick_oneshot( + frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing" +) -> list[Image.Image]: + """一次性动作抽 ``n`` 帧:裁到动作区间 → 只留第一次动作 → 区间内均匀取(不闭环)。 + + ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。 + ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。 + """ + if not frames or n <= 0: + return [] + start, end = find_motion_span(frames) + if first_only: + end = max(start + 1, first_action_end(frames, start, end, kind=kind)) + span = frames[start : end + 1] + if n == 1: + return [span[0]] + idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)] + return [span[i] for i in idx] + + +def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray: + """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。 + + 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、 + 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 + """ + arr = np.asarray(frame.convert("RGBA")) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: + return np.where(alpha > alpha_thr)[0] + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0] + + +def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray: + """逐帧主体**底边** y 坐标(脚线)。跳跃时脚线先降(蹲)、再升(腾空)、再落回。""" + out = [] + for f in frames: + ys = _subject_rows(f, alpha_thr) + out.append(float(ys.max()) if len(ys) else np.nan) + arr = np.array(out, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + good = ~np.isnan(arr) + if good.any(): + arr = np.interp(idx, idx[good], arr[good]) + else: + arr = np.zeros_like(arr) + return arr + + +def split_jump_phases(frames: list[Image.Image]) -> dict[str, list[int]]: + """按脚线轨迹把跳跃切成 crouch / rise / apex / fall / land 五段,返回每段的帧下标。 + + 判据:脚线 y 越小 = 人越高。最高点(y 最小)即 apex;起跳前脚线最低(蹲)处为 crouch + 结束;之后到 apex 为 rise,apex 之后到脚线回到地面高度为 fall,余下为 land。 + 只依赖几何,不依赖模型。 + """ + n = len(frames) + if n < 5: + return {"rise": list(range(n))} + y = foot_line_series(frames) + apex = int(np.argmin(y)) # 最高点 + ground = float(np.median([y[0], y[-1]])) # 地面脚线 + # 起跳点:apex 之前脚线最低(数值最大 = 蹲得最深)的位置 + takeoff = int(np.argmax(y[: max(1, apex)])) if apex > 0 else 0 + # 落地点:apex 之后脚线首次回到地面附近 + after = y[apex:] + back = np.flatnonzero(after >= ground - 2) + landing = apex + int(back[0]) if len(back) else n - 1 + + apex_lo = max(takeoff + 1, apex - 1) + apex_hi = min(landing - 1, apex + 1) + phases = { + "crouch": list(range(0, takeoff + 1)), + "rise": list(range(takeoff + 1, apex_lo)), + "apex": list(range(apex_lo, apex_hi + 1)), + "fall": list(range(apex_hi + 1, landing)), + "land": list(range(landing, n)), + } + return {k: v for k, v in phases.items() if v} diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py new file mode 100644 index 00000000..bf985a9d --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -0,0 +1,12 @@ +"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" + +from .base import ROUTE_MATRIX, DerivationStrategy +from .concrete import PerFrameStrategy, ProcIdleStrategy, VideoFrameStrategy + +__all__ = [ + "ROUTE_MATRIX", + "DerivationStrategy", + "VideoFrameStrategy", + "PerFrameStrategy", + "ProcIdleStrategy", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py new file mode 100644 index 00000000..492a4029 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -0,0 +1,51 @@ +"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。 + +分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档): + - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞; + 必须走视频 i2v(视频模型天生连贯、腿自然交替)。 + - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环 + (见 strategy.concrete.CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 + - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。 + - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。 + +ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute + +from windup_ai_engine.ports import ProgressPort + +# 动作类型 → 生成路线(架构决策,写死为契约) +ROUTE_MATRIX: dict[ActionType, GenRoute] = { + ActionType.WALK: GenRoute.VIDEO_I2V, + ActionType.RUN: GenRoute.VIDEO_I2V, + ActionType.JUMP: GenRoute.VIDEO_I2V, + ActionType.ATTACK: GenRoute.VIDEO_I2V, + ActionType.HIT: GenRoute.PER_FRAME, + # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)——"快速看着对"的待机路线。 + # ¥0 的程序化 Idle-B(局部网格呼吸)是后续可选优化,当前 ProcIdleStrategy 仍是桩。 + ActionType.IDLE: GenRoute.VIDEO_I2V, + # custom:提示词驱动的自定义动作(如"在画板上作画")。走视频路线,动作描述 + # 由 ActionSpec.action_desc 提供;一次性动作,不闭环(见 CYCLIC_ACTIONS)。 + ActionType.CUSTOM: GenRoute.VIDEO_I2V, +} + + +class DerivationStrategy(ABC): + """一条生成路线的骨架:母版 → 对齐前的角色帧序列。""" + + route: GenRoute + + @abstractmethod + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。""" + raise NotImplementedError diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py new file mode 100644 index 00000000..3853809a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -0,0 +1,168 @@ +"""三条 DerivationStrategy。 + +- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。 +- PerFrameStrategy / ProcIdleStrategy:桩,待开发(见 #53,per-frame / idle 非首个竖线)。 + +VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → +matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 +""" +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider + +from windup_ai_engine.master_prep import prepare_master +from windup_ai_engine.ports import ProgressPort +from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames +from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_custom_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.strategy.base import DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 +# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 +CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) + + +class VideoFrameStrategy(DerivationStrategy): + """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。 + + 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。 + 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身 + 调和图文矛盾(实测 #35)。 + """ + + route = GenRoute.VIDEO_I2V + + def __init__(self, video: VideoProvider, matte: MatteProvider) -> None: + self._video = video + self._matte = matte + + def _build_prompt(self, action: ActionSpec) -> str: + """按动作类型选提示词;朝向随 ActionSpec.facing。""" + if action.action is ActionType.CUSTOM: + return build_custom_prompt(action.action_desc, facing=action.facing) + builders = { + ActionType.JUMP: build_jump_prompt, + ActionType.IDLE: build_idle_prompt, + ActionType.ATTACK: build_attack_prompt, + } + build = builders.get(action.action, build_walk_prompt) + return build(facing=action.facing) + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + n = action.n_frames or 8 + progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 + framed = prepare_master(master, action.action.value) + video = self._video.i2v(framed, self._build_prompt(action), seconds=5) + + dense = extract_all_frames_bytes(video) + # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版 + # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。 + ref_h = None + if dense: + _first = _img(self._matte.cutout(_png(dense[0]))) + _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128) + ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None + if action.action in CYCLIC_ACTIONS: + progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图") + picked = pick_cycle(dense, n) # 单周期闭环(#21) + else: + progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图") + kind = "airborne" if action.action is ActionType.JUMP else "swing" + picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 + cut = [_img(self._matte.cutout(_png(im))) for im in picked] + + # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); + # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, + # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 + if action.stylize == "none": + progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") + return [_png(im) for im in cut] + + target_h, palette = action.pixel_h, None + try: + logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 + if logical_h > 8: # 母版确为像素画 → 按它的规格走 + target_h, palette = logical_h, pal + except Exception: # 母版非像素画/量不出 → 回退通用量化 + pass + progress.step( + "derive", 2, 3, + f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})", + ) + pix = pixelate_frames( + cut, target_h=target_h, palette_size=action.palette_size, + palette=palette, ref_height=ref_h, + ) + return [_png(p) for p in pix] + + +class PerFrameStrategy(DerivationStrategy): + """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。桩,待开发(#53)。""" + + route = GenRoute.PER_FRAME + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + progress.step("derive", 0, 1, f"{action.action}: 逐帧图生图") + # TODO(dev, #53): 逐 pose image.gen_image(母版, pose) → matte.cutout(不加骨架) + return [b"" for _ in range(action.n_frames)] # 桩 + + +class ProcIdleStrategy(DerivationStrategy): + """待机(idle):母版抠图 → 程序化局部躯干呼吸(Idle-B,零 API)。桩,待开发(#53)。""" + + route = GenRoute.PROC_IDLE + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + progress.step("derive", 0, 1, f"{action.action}: Idle-B 程序化呼吸") + # TODO(dev, #53): 母版抠图 → 躯干带保体积缩放,腿冻结 + return [b"" for _ in range(action.n_frames)] # 桩 diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 89f7b43d..598921e9 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -1,15 +1,108 @@ + """FastAPI 应用工厂与装配入口。 ``create_app`` 负责创建 FastAPI 实例并挂载路由 / 中间件 / 异常处理, 是整个 web 服务的唯一装配点(composition root)。 + +``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。 """ +import asyncio +import os +import sys +from contextlib import asynccontextmanager + +import windup_framework.db # noqa: F401 组装时显式触发 DB engine/session 初始化 from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from windup_app.server.generation.executor import run_action_task, run_image_task +from windup_app.web.api.agent import router as ai_router +from windup_app.web.api.character import router as character_router +from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.playtest_inspection import router as playtest_inspection_router +from windup_app.web.api.project import router as project_router +from windup_app.web.api.workflow_run import router as workflow_run_router +from windup_app.web.handler.exception_handlers import register_exception_handlers + + +def _env_flag(name: str) -> bool: + """把环境变量解析为真正的布尔值:仅 1/true/yes/on(忽略大小写与空白)视为 True。""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def print_banner() -> None: + """启动时打印 banner(占位实现,后续替换为正式 ASCII banner)。""" + print("windup 0.1.0 starting ...") + + +@asynccontextmanager +async def _lifespan(app: FastAPI): + """应用启动时打印 banner,关闭时无特殊处理。""" + print_banner() + yield def create_app() -> FastAPI: - app = FastAPI(title="windup", version="0.1.0") + app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + + # 本地开发 CORS: 允许前端 localhost 跨域访问 + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + "http://localhost:5176", + "http://localhost:5177", + "http://127.0.0.1:5173", + "http://127.0.0.1:5174", + "http://127.0.0.1:5175", + "http://127.0.0.1:5176", + "http://127.0.0.1:5177", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(project_router) + app.include_router(character_router) app.include_router(media_router) + app.include_router(generation_router) + app.include_router(workflow_run_router) + app.include_router(ai_router) + app.include_router(playtest_inspection_router) + # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖, + # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。 + app.state.run_action_task = run_action_task + app.state.run_image_task = run_image_task + register_exception_handlers(app) return app + + +def main() -> None: + """开发启动入口:用 uvicorn 跑 ``create_app``。 + + host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。 + """ + import uvicorn + + # Uvicorn 0.51 会显式创建自己的 loop,单改全局 policy 会被覆盖。Windows 下 + # 直接传 Selector factory,避免浏览器关闭 SSE 时 Proactor transport 打印 10054。 + loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else "auto" + + uvicorn.run( + "windup_app.bootstrap.app:create_app", + factory=True, + host=os.getenv("WINDUP_HOST", "127.0.0.1"), + port=int(os.getenv("WINDUP_PORT", "8000")), + reload=_env_flag("WINDUP_RELOAD"), + loop=loop_factory, + ) + + + +if __name__ == "__main__": + main() diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index 5ea1c134..ee447afa 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -60,6 +60,8 @@ class Character(Base): project_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + workflow_run_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) reference_image_url: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -91,12 +93,20 @@ class Character(Base): # ── character_data Pydantic 模型 ────────────────────────────────────────────── +class CharacterRootMotion(BaseModel): + """单帧相对动作首帧的根位移。""" + + dx: float + dy: float + + class CharacterFrame(BaseModel): """动作帧。""" index: int = Field(ge=0, description="帧序号") image_url: str = Field(..., description="帧图片 URL") duration_ms: int | None = Field(default=None, gt=0, description="帧时长(毫秒)") + root_motion: CharacterRootMotion | None = Field(default=None, description="根位移增量") class CharacterAction(BaseModel): @@ -125,4 +135,4 @@ class CharacterData(BaseModel): """角色完整数据(造型→动作→帧)。""" version: int = Field(default=1, description="结构版本") - outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") \ No newline at end of file + outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py new file mode 100644 index 00000000..e1ff22d4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -0,0 +1,69 @@ +"""角色领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.interface import CharacterService +from windup_app.server.character.model import Character + + +class SqlAlchemyCharacterService(CharacterService): + """基于 SQLAlchemy session 的角色 CRUD 实现。""" + + def create_character(self, session: Session, **fields) -> Character: + character = Character(**fields) + session.add(character) + session.flush() + return character + + def get_character(self, session: Session, character_id: int) -> Character | None: + return session.get(Character, character_id) + + def list_characters( + self, session: Session, *, project_id: int, page: int, page_size: int, + ) -> tuple[list[Character], int]: + count_stmt = ( + select(func.count()) + .select_from(Character) + .where(Character.project_id == project_id) + ) + stmt = ( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + total = session.scalar(count_stmt) or 0 + items = list(session.scalars(stmt)) + return items, total + + def update_character( + self, session: Session, character_id: int, **fields, + ) -> Character | None: + character = session.get(Character, character_id) + if character is None: + return None + for key, value in fields.items(): + setattr(character, key, value) + session.flush() + return character + + def delete_character(self, session: Session, character_id: int) -> bool: + character = session.get(Character, character_id) + if character is None: + return False + session.delete(character) + session.flush() + return True + + +service = SqlAlchemyCharacterService() diff --git a/backend/packages/app/src/windup_app/server/generation/__init__.py b/backend/packages/app/src/windup_app/server/generation/__init__.py index a6701deb..e1d88118 100644 --- a/backend/packages/app/src/windup_app/server/generation/__init__.py +++ b/backend/packages/app/src/windup_app/server/generation/__init__.py @@ -7,9 +7,12 @@ CharacterActionOutput, CharacterImageInput, GenerationTask, + GenerationTaskRecord, GenerationType, TaskStatus, ) +from windup_app.server.generation.service import service as generation_service +from windup_app.server.generation import task_repo __all__ = [ "ActionType", @@ -18,6 +21,9 @@ "CharacterActionOutput", "CharacterImageInput", "GenerationTask", + "GenerationTaskRecord", "GenerationType", "TaskStatus", + "generation_service", + "task_repo", ] diff --git a/backend/packages/app/src/windup_app/server/generation/executor.py b/backend/packages/app/src/windup_app/server/generation/executor.py new file mode 100644 index 00000000..e3f71229 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/executor.py @@ -0,0 +1,409 @@ +"""动作生成后台编排(调 ai_engine)。 + +编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。 +异常兜底为 FAILED,不抛。 + +**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine, +违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入 +``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。 + +依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载, +避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。 +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import httpx +from sqlalchemy.orm import Session + +from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard + +from windup_app.server.generation import task_repo +from windup_app.server.generation.model import ( + CharacterActionInput, + CharacterImageInput, + TaskStatus, +) + +if TYPE_CHECKING: + from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort + +logger = logging.getLogger("windup.generation.executor") + +_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化 + +# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ───────────────────────── +# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角 +_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"} +_PERSPECTIVE_VIEW: dict[int, str] = { + 1: "side view, horizontal side-scroller", + 2: "top-down view", + 3: "2.5D three-quarter view", +} +# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数 +_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8} + + +@dataclass +class ProjectConstraints: + """从 Project 取的全局生成约束,统一约束角色图/动作生成。""" + + facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) + view: str = "side view, horizontal side-scroller" + perspective: int = 1 # 1横版 2俯视 3 2.5D + directions: int = 1 # directional_movement → 方向数(1/4/8) + sprite_w: int = 256 # 输出/切帧尺寸(关键) + sprite_h: int = 256 + style: str = "" # game_style 画风 + stylize: str = "none" # 由 style 推:像素游戏 → pixel + sprite_sample_url: str = "" # 项目风格参考图 URL + + +def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints: + """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。""" + if project_id is None: + return ProjectConstraints() + from windup_app.server.project.service import SqlAlchemyProjectService + + p = SqlAlchemyProjectService().get_project(session, project_id) + if p is None: + return ProjectConstraints() + style = p.game_style or "" + is_pixel = "pixel" in style.lower() or "像素" in style + return ProjectConstraints( + facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"), + view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]), + perspective=p.character_perspective, + directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1), + sprite_w=p.sprite_width, + sprite_h=p.sprite_height, + style=style, + stylize="pixel" if is_pixel else "none", + sprite_sample_url=p.sprite_sample_url or "", + ) + + +def _fit_to(png: bytes, w: int, h: int) -> bytes: + """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。""" + import io + + from PIL import Image + + im = Image.open(io.BytesIO(png)).convert("RGBA") + if im.size == (w, h): + return png + fitted = im.copy() + fitted.thumbnail((w, h), Image.NEAREST) + canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +class _LogProgress: + """进度上报占位:MVP 无 SSE,记日志即可。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + logger.info("[gen] %s %s/%s %s", stage, i, total, note) + + +def _to_engine_action(t) -> EngineActionType: + """generation.ActionType → 引擎 common.ActionType(按值映射)。 + + walk/idle/attack/jump/custom 均支持视频路线;未覆盖的类型明确报错。 + """ + try: + return EngineActionType(t.value) + except ValueError as e: + raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e + + +class ActionTaskExecutor: + """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。""" + + def __init__( + self, + *, + generator: CharacterGeneratorPort | None = None, + upload: Callable[[bytes], str] | None = None, + fetch_master: Callable[[CharacterActionInput], bytes] | None = None, + fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._generator = generator # None → 懒加载真实装配 + self._upload = upload # None → 真实对象存储上传 + self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 + self._session_factory = session_factory # None → SessionLocal + + def run_action_task( + self, + task_id: int, + input: CharacterActionInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + """跑一个动作任务;异常兜底为 FAILED,不抛。 + + 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session`` + 缺省时自开一个(后台场景);测试可传入自己的 session。 + """ + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + + cons = (self._fetch_constraints or _load_constraints)(session, project_id) + result = self._produce_action(input, cons) + task_repo.update_result(session, task_id, _ACTION_RESULT, result) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常 + logger.exception("动作任务 %s 失败", task_id) + task_repo.update_status( + session, task_id, TaskStatus.FAILED, error_message=str(exc), + ) + if own: + session.commit() + finally: + if own: + session.close() + + # -- 内部 -------------------------------------------------------------- + + def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: + """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。 + + 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 + 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, + 四向/八向为扩展(需多次生成或镜像)。 + """ + if cons.directions > 1: + logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) + master = (self._fetch_master or self._download_master)(input) + # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 + desc_parts = [input.custom_prompt or ""] + if cons.style: + desc_parts.append(f"Art style: {cons.style}") + card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts)) + engine_action = _to_engine_action(input.action_type) + action = ActionSpec( + action=engine_action, + poses=[""] * input.num_frames, + facing=cons.facing, + stylize=cons.stylize, + # 自定义动作:动作描述进 i2v 提示词;其他动作类型忽略该字段 + action_desc=input.custom_prompt or "" if engine_action is EngineActionType.CUSTOM else "", + ) + progress: ProgressPort = _LogProgress() + generated = self._get_generator().generate(card, action, master, progress) + + upload = self._upload or self._upload_frame + frames = [ + {"index": i, + "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)), + "duration_ms": dur} + for i, (png, dur) in enumerate(zip(generated.frames, generated.durations)) + ] + return {"type": "character_action", "action_type": input.action_type.value, "frames": frames} + + def _get_generator(self) -> CharacterGeneratorPort: + """懒装配真实 CharacterGenerator(视频路线 + 桩路线)。""" + if self._generator is None: + from windup_ai_engine.impl import CharacterGenerator + from windup_ai_engine.strategy.concrete import ( + PerFrameStrategy, + ProcIdleStrategy, + VideoFrameStrategy, + ) + from windup_common.models import GenRoute + from windup_framework.providers import ( + OnnxU2NetMatteProvider, + SufyImageProvider, + SufyVideoProvider, + ) + + matte = OnnxU2NetMatteProvider() + video = SufyVideoProvider() + image = SufyImageProvider() + self._generator = CharacterGenerator({ + GenRoute.VIDEO_I2V: VideoFrameStrategy(video, matte), + GenRoute.PER_FRAME: PerFrameStrategy(image, matte), + GenRoute.PROC_IDLE: ProcIdleStrategy(image, matte), + }) + return self._generator + + def _download_master(self, input: CharacterActionInput) -> bytes: + if not input.reference_image_urls: + raise ValueError("缺少母版:reference_image_urls 为空") + resp = httpx.get(input.reference_image_urls[0], timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_frame(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="frame.png", + content_type="image/png", + size=len(png), + category=MediaCategory.ACTION_FRAME, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化 + + +class ImageTaskExecutor: + """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。""" + + def __init__( + self, + *, + image=None, # None → 懒加载 SufyImageProvider + upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 + fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._image = image + self._upload = upload + self._fetch_ref = fetch_ref + self._session_factory = session_factory + + def run_image_task( + self, + task_id: int, + input: CharacterImageInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + cons = _load_constraints(session, project_id) # 角色图也受项目约束 + urls = self._produce_image(input, cons) + task_repo.update_result(session, task_id, _IMAGE_RESULT, { + "type": "character_image", + "image_urls": urls, + }) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底 + logger.exception("图片任务 %s 失败", task_id) + task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc)) + if own: + session.commit() + finally: + if own: + session.close() + + def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]: + """根据项目约束决定生成模式,返回 URL 列表。 + + 模式判断: + - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词 + - 项目无 sprite_sample_url → **文生图**: 纯提示词 + 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。 + """ + fetch = self._fetch_ref or self._download + refs: list[bytes] = [] + has_style_ref = False + + # 1. 角色参考图(用户传入,可选,做角色一致性约束) + char_url = (input.reference_image_url or "").strip() + if char_url and char_url.lower() not in ("null", "none", ""): + refs.append(fetch(char_url)) + + # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式) + style_url = (cons.sprite_sample_url or "").strip() + if style_url and style_url.lower() not in ("null", "none", ""): + try: + refs.append(fetch(style_url)) + has_style_ref = True + except Exception: + pass # 风格参考图下载失败不阻断 + + # 3. 构建提示词 + base = input.prompt or "Clean full-body character reference of the figure in the image." + parts = [base, f"{cons.view}, full body head to feet, centered."] + if cons.style: + parts.append(f"Art style: {cons.style}.") + parts.append("Plain light-gray background, no shadow.") + + # 图生图模式:明确标注两张图的各自用途 + if has_style_ref: + prefix = ( + "This is an image-to-image task. " + "The first image is the CHARACTER reference — preserve its identity. " + "The second image is the STYLE reference — follow its art style, " + "color palette, and rendering technique. " + ) + parts.insert(0, prefix) + + prompt = " ".join(parts) + + image_gen = self._get_image() + upload = self._upload or self._upload_image + urls: list[str] = [] + for _ in range(max(1, input.num_images)): + img = image_gen.gen_image(prompt, refs) + urls.append(upload(img)) + return urls + + def _get_image(self): + if self._image is None: + from windup_framework.providers import SufyImageProvider + + self._image = SufyImageProvider() + return self._image + + def _download(self, url: str) -> bytes: + resp = httpx.get(url, timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_image(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="character.png", content_type="image/png", + size=len(png), category=MediaCategory.REFERENCE_IMAGE, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state +executor = ActionTaskExecutor() +run_action_task = executor.run_action_task +image_executor = ImageTaskExecutor() +run_image_task = image_executor.run_image_task diff --git a/backend/packages/app/src/windup_app/server/generation/interface.py b/backend/packages/app/src/windup_app/server/generation/interface.py index b43bace3..409b9e00 100644 --- a/backend/packages/app/src/windup_app/server/generation/interface.py +++ b/backend/packages/app/src/windup_app/server/generation/interface.py @@ -7,21 +7,24 @@ -------- 1. 前端调用 ``generate_character_image`` / ``generate_character_action`` 提交任务, 拿到 ``task_id``。 -2. 前端通过 SSE 订阅任务状态变更,无需轮询。 - web 层提供 ``GET /generation/tasks/{task_id}/stream`` 端点, - 服务端在任务状态变化时推送 ``task_update`` 事件。 - 事件 payload 包含 ``task_id`` / ``task_type`` / ``status``, - 完成时附带 ``result``,失败时附带 ``error_message``。 +2. 前端通过 SSE 订阅状态,刷新恢复时使用 ``get_task`` 查询当前快照。 3. 前端从 ``task.status`` 判断完成,从 ``result`` 取出出参,回填 character 模块: .. code-block:: text CharacterImageOutput.image_url → Character.reference_image_url CharacterActionOutput.frames[] → character_data.outfits[].actions[].frames[] + +约定 +---- +- session-per-call: ``session`` 由调用方(FastAPI 的 ``get_session`` 依赖)按请求传入。 +- 具体实现保持无状态,可作为模块级单例。 """ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.generation.model import ( CharacterActionInput, CharacterImageInput, @@ -35,7 +38,9 @@ class GenerationService(ABC): # -- 任务提交 ------------------------------------------------------------ @abstractmethod - def generate_character_image(self, input: CharacterImageInput) -> GenerationTask: + def generate_character_image( + self, session: Session, *, user_id: int, input: CharacterImageInput, + ) -> GenerationTask: """提交角色图片生成任务。 入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``, @@ -43,7 +48,9 @@ def generate_character_image(self, input: CharacterImageInput) -> GenerationTask """ @abstractmethod - def generate_character_action(self, input: CharacterActionInput) -> GenerationTask: + def generate_character_action( + self, session: Session, *, user_id: int, input: CharacterActionInput, + ) -> GenerationTask: """提交角色动作生成任务。 入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``, @@ -53,7 +60,9 @@ def generate_character_action(self, input: CharacterActionInput) -> GenerationTa # -- 查询 ---------------------------------------------------------------- @abstractmethod - def get_task(self, project_id: int, task_id: int) -> GenerationTask | None: + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: """查询任务状态与结果。 返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成, diff --git a/backend/packages/app/src/windup_app/server/generation/model.py b/backend/packages/app/src/windup_app/server/generation/model.py index 36fdab58..2449d039 100644 --- a/backend/packages/app/src/windup_app/server/generation/model.py +++ b/backend/packages/app/src/windup_app/server/generation/model.py @@ -9,6 +9,12 @@ from datetime import datetime, timezone from enum import StrEnum +from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + # -- 枚举 ---------------------------------------------------------------- @@ -16,8 +22,8 @@ class GenerationType(StrEnum): """生成任务类型——每新增一种生成能力,在此加一个成员。""" - CHARACTER_IMAGE = "character_image" # 角色参考图 - CHARACTER_ACTION = "character_action" # 角色动作帧序列 + CHARACTER_IMAGE = "character_image" # 角色参考图 + CHARACTER_ACTION = "character_action" # 角色动作帧序列 class ActionType(StrEnum): @@ -25,6 +31,7 @@ class ActionType(StrEnum): WALK = "walk" IDLE = "idle" + JUMP = "jump" ATTACK = "attack" CUSTOM = "custom" @@ -41,6 +48,9 @@ class TaskStatus(StrEnum): # -- 入参 ---------------------------------------------------------------- +DEFAULT_ACTION_FRAME_COUNT = 32 + + @dataclass class CharacterImageInput: """角色图片生成入参。""" @@ -62,7 +72,7 @@ class CharacterActionInput: custom_prompt: str | None = None reference_video_url: str | None = None reference_image_urls: list[str] = field(default_factory=list) - num_frames: int = 16 + num_frames: int = DEFAULT_ACTION_FRAME_COUNT # -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------ @@ -124,3 +134,57 @@ class GenerationTask: @property def is_terminal(self) -> bool: return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED) + + +# -- ORM ----------------------------------------------------------------- + + +class GenerationTaskRecord(Base): + """生成任务持久化记录。 + + ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识 + ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。 + """ + + __tablename__ = "windup_generation_task" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + task_type: Mapped[str] = mapped_column( + Text, + nullable=False, + default=GenerationType.CHARACTER_IMAGE.value, + ) + status: Mapped[str] = mapped_column( + Text, + nullable=False, + default=TaskStatus.PENDING.value, + ) + input_payload: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) + result_type: Mapped[str | None] = mapped_column(Text, nullable=True) + result: Mapped[dict | None] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=True, + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/generation/service.py b/backend/packages/app/src/windup_app/server/generation/service.py new file mode 100644 index 00000000..2be42941 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/service.py @@ -0,0 +1,76 @@ +"""生成任务领域服务(提交 + 查询)。 + +:class:`AiGenerationService` 只负责**建任务记录 + 查任务**——web 层依赖本模块。 +实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**, +以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。 + +无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。 +""" + +from __future__ import annotations + +import dataclasses +from datetime import datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from windup_app.server.generation import task_repo +from windup_app.server.generation.interface import GenerationService +from windup_app.server.generation.model import ( + CharacterActionInput, + CharacterImageInput, + GenerationTask, + GenerationType, + TaskStatus, +) + + +_STALE_TASK_AGE = timedelta(minutes=15) +_STALE_TASK_MESSAGE = "生成任务长时间无进展,后台执行可能已中断,请重试" + + +class AiGenerationService(GenerationService): + """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。""" + + def generate_character_image( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterImageInput, + ) -> GenerationTask: + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload=dataclasses.asdict(input), + ) + + def generate_character_action( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterActionInput, + ) -> GenerationTask: + """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。""" + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_ACTION, + input_payload=dataclasses.asdict(input), + ) + + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: + task = task_repo.get_task(session, task_id) + if task is None or task.is_terminal: + return task + updated_at = task.update_at + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) - updated_at < _STALE_TASK_AGE: + return task + task_repo.update_status( + session, + task_id, + TaskStatus.FAILED, + error_message=_STALE_TASK_MESSAGE, + ) + return task_repo.get_task(session, task_id) + + +service = AiGenerationService() diff --git a/backend/packages/app/src/windup_app/server/generation/task_repo.py b/backend/packages/app/src/windup_app/server/generation/task_repo.py new file mode 100644 index 00000000..e48670e4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/task_repo.py @@ -0,0 +1,158 @@ +"""生成任务数据访问层。 + +纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``, +由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只 +``flush`` 不 ``commit``。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.generation.model import ( + CharacterActionOutput, + CharacterImageOutput, + GenerationTask, + GenerationTaskRecord, + GenerationType, + TaskStatus, +) + + +# ── 写入 ───────────────────────────────────────────────────────────────── + + +def create_task( + session: Session, + *, + user_id: int, + project_id: int | None, + task_type: GenerationType, + input_payload: dict, +) -> GenerationTask: + """创建生成任务记录,返回领域对象。""" + record = GenerationTaskRecord( + user_id=user_id, + project_id=project_id, + task_type=task_type.value, + status=TaskStatus.PENDING.value, + input_payload=input_payload, + ) + session.add(record) + session.flush() + return _record_to_domain(record) + + +def update_status( + session: Session, + task_id: int, + status: TaskStatus, + *, + error_message: str | None = None, +) -> None: + """更新任务状态(可选附带错误信息)。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.status = status.value + record.error_message = error_message + record.update_at = datetime.now(timezone.utc) + session.flush() + + +def update_result( + session: Session, + task_id: int, + result_type: str, + result: dict, +) -> None: + """写入任务结果。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.result_type = result_type + record.result = result + record.status = TaskStatus.COMPLETED.value + record.update_at = datetime.now(timezone.utc) + session.flush() + + +# ── 读取 ───────────────────────────────────────────────────────────────── + + +def get_task(session: Session, task_id: int) -> GenerationTask | None: + """按 task_id 查询任务。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return None + return _record_to_domain(record) + + +def get_task_by_user( + session: Session, + user_id: int, + task_id: int, +) -> GenerationTask | None: + """按 user_id + task_id 查询(校验归属)。""" + stmt = select(GenerationTaskRecord).where( + GenerationTaskRecord.id == task_id, + GenerationTaskRecord.user_id == user_id, + ) + record = session.scalar(stmt) + if record is None: + return None + return _record_to_domain(record) + + +# ── 转换 ───────────────────────────────────────────────────────────────── + + +def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask: + """ORM 记录 → 领域 dataclass。""" + result = _deserialize_result(record.result_type, record.result) + return GenerationTask( + id=record.id, + user_id=record.user_id, + project_id=record.project_id, + task_type=GenerationType(record.task_type), + status=TaskStatus(record.status), + input_payload=record.input_payload, + result=result, + error_message=record.error_message, + create_at=record.create_at, + update_at=record.update_at, + ) + + +def _deserialize_result( + result_type: str | None, + raw: dict | None, +) -> CharacterImageOutput | CharacterActionOutput | None: + """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。""" + if raw is None or result_type is None: + return None + if result_type == "character_image": + return CharacterImageOutput( + type=raw.get("type", "character_image"), + image_urls=raw.get("image_urls", []), + ) + if result_type == "character_action": + from windup_app.server.generation.model import CharacterActionFrame + + frames = [ + CharacterActionFrame( + index=f["index"], + image_url=f["image_url"], + duration_ms=f.get("duration_ms"), + ) + for f in raw.get("frames", []) + ] + return CharacterActionOutput( + type=raw.get("type", "character_action"), + action_type=raw.get("action_type", ""), + frames=frames, + ) + return None diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py new file mode 100644 index 00000000..8c7ad072 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py @@ -0,0 +1 @@ +"""Playtest 核验记录领域。""" diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py new file mode 100644 index 00000000..92ad3422 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py @@ -0,0 +1,34 @@ +"""Playtest 核验记录服务接口。""" + +from abc import ABC, abstractmethod + +from sqlalchemy.orm import Session + +from windup_app.server.playtest_inspection.model import PlaytestInspection + + +class PlaytestInspectionService(ABC): + """读取和保存动作当前核验结论的边界。""" + + @abstractmethod + def get_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + ) -> PlaytestInspection | None: + """按动作定位当前核验结论。""" + + @abstractmethod + def save_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + status: str, + ) -> PlaytestInspection: + """新增或覆盖动作当前核验结论。""" diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/model.py b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py new file mode 100644 index 00000000..ce6dcfd0 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py @@ -0,0 +1,43 @@ +"""Playtest 核验记录 ORM 模型。""" + +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +class PlaytestInspection(Base): + """某个角色动作当前最新的 Playtest 核验结论。""" + + __tablename__ = "windup_playtest_inspection" + __table_args__ = ( + UniqueConstraint( + "character_id", + "outfit_id", + "action_id", + name="uq_playtest_inspection_target", + ), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + character_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + outfit_id: Mapped[str] = mapped_column(String(128), nullable=False) + action_id: Mapped[str] = mapped_column(String(128), nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/service.py b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py new file mode 100644 index 00000000..1e130968 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py @@ -0,0 +1,57 @@ +"""Playtest 核验记录的 SQLAlchemy 实现。""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.playtest_inspection.interface import PlaytestInspectionService +from windup_app.server.playtest_inspection.model import PlaytestInspection + + +class SqlAlchemyPlaytestInspectionService(PlaytestInspectionService): + """只保留每个动作最新核验结论,不形成历史版本链。""" + + def get_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + ) -> PlaytestInspection | None: + statement = select(PlaytestInspection).where( + PlaytestInspection.character_id == character_id, + PlaytestInspection.outfit_id == outfit_id, + PlaytestInspection.action_id == action_id, + ) + return session.scalar(statement) + + def save_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + status: str, + ) -> PlaytestInspection: + inspection = self.get_inspection( + session, + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + ) + if inspection is None: + inspection = PlaytestInspection( + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + status=status, + ) + session.add(inspection) + else: + inspection.status = status + session.flush() + return inspection + + +service = SqlAlchemyPlaytestInspectionService() diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py new file mode 100644 index 00000000..c51198ab --- /dev/null +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -0,0 +1,76 @@ +"""项目领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.model import Character +from windup_app.server.playtest_inspection.model import PlaytestInspection +from windup_app.server.project.interface import ProjectService +from windup_app.server.project.model import Project + + +class SqlAlchemyProjectService(ProjectService): + """基于 SQLAlchemy session 的项目 CRUD 实现。""" + + def create_project(self, session: Session, **fields) -> Project: + project = Project(**fields) + session.add(project) + session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at) + return project + + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: + stmt = ( + select(Project.id) + .where(Project.user_id == user_id, Project.project_name == project_name) + .limit(1) + ) + return session.scalar(stmt) is not None + + def get_project(self, session: Session, project_id: int) -> Project | None: + return session.get(Project, project_id) + + def list_projects( + self, session: Session, *, page: int, page_size: int, user_id: int | None = None + ) -> tuple[list[Project], int]: + count_stmt = select(func.count()).select_from(Project) + stmt = select(Project) + if user_id is not None: + count_stmt = count_stmt.where(Project.user_id == user_id) + stmt = stmt.where(Project.user_id == user_id) + total = session.scalar(count_stmt) or 0 + stmt = ( + stmt.order_by(Project.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = list(session.scalars(stmt)) + return items, total + + def delete_project(self, session: Session, project_id: int) -> bool: + project = session.get(Project, project_id) + if project is None: + return False + + character_ids = select(Character.id).where(Character.project_id == project_id) + session.execute( + delete(PlaytestInspection).where( + PlaytestInspection.character_id.in_(character_ids) + ) + ) + session.execute(delete(Character).where(Character.project_id == project_id)) + session.delete(project) + session.flush() + return True + + +service = SqlAlchemyProjectService() diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py new file mode 100644 index 00000000..54488164 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -0,0 +1,173 @@ +"""角色 CRUD API。""" + +import logging + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.config.storage import settings as storage_settings +from windup_framework.db import get_session + +from windup_app.server.character.model import Character, CharacterData +from windup_app.server.character.service import service as character_service +from windup_app.server.media.service import service as media_service + +logger = logging.getLogger("windup.character.api") + +router = APIRouter(prefix="/characters", tags=["characters"]) + + +# ── 请求 / 响应模型 ───────────────────────────────────────────────────────── + + +class CharacterCreate(BaseModel): + """创建角色请求。""" + + project_id: int = Field(gt=0) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData = Field(default_factory=CharacterData) + + +class CharacterUpdate(BaseModel): + """更新角色请求——所有字段可选。""" + + project_id: int | None = Field(default=None, gt=0) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData | None = None + + +class CharacterOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + description: str | None = None + reference_image_url: str | None = None + character_data: dict + status: int + + +# ── 辅助函数 ───────────────────────────────────────────────────────────────── + + +def _extract_object_keys(character: Character) -> list[str]: + """从角色中提取所有对象存储 key,用于删除时清理资源。 + + URL 格式: ``{download_base}/{object_key}`` + """ + prefix = storage_settings.download_base + "/" + keys: list[str] = [] + + # 参考图 + url = character.reference_image_url + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + + # character_data 内的 URL + data = character.character_data or {} + for outfit in data.get("outfits", []): + url = outfit.get("preview_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + url = frame.get("image_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + + return keys + + +# ── 端点 ───────────────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[CharacterOut]) +def create_character( + body: CharacterCreate, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.create_character( + session, + project_id=body.project_id, + description=body.description, + reference_image_url=body.reference_image_url, + character_data=body.character_data.model_dump(), + ) + return Response.success(CharacterOut.model_validate(character), message="创建成功") + + +@router.get("", response_model=ListResponse[CharacterOut]) +def list_characters( + project_id: int = Query(..., gt=0), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + session: Session = Depends(get_session), +) -> ListResponse[CharacterOut]: + items, total = character_service.list_characters( + session, + project_id=project_id, + page=page, + page_size=page_size, + ) + return ListResponse.success( + [CharacterOut.model_validate(c) for c in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{character_id}", response_model=Response[CharacterOut]) +def get_character( + character_id: int, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character)) + + +@router.patch("/{character_id}", response_model=Response[CharacterOut]) +def update_character( + character_id: int, + body: CharacterUpdate, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + fields = body.model_dump(exclude_unset=True) + character = character_service.update_character(session, character_id, **fields) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character), message="更新成功") + + +@router.delete("/{character_id}", response_model=Response[bool]) +def delete_character( + character_id: int, + session: Session = Depends(get_session), +) -> Response[bool]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + + # 先提取对象 key,再删 DB 记录 + object_keys = _extract_object_keys(character) + + character_service.delete_character(session, character_id) + + # 清理对象存储——失败只记日志,不回滚 DB + for key in object_keys: + try: + media_service.delete(key) + except Exception: + logger.warning("[WINDUP] 媒体清理失败(已跳过) | key=%s", key, exc_info=True) + + return Response.success(True, message="删除成功") diff --git a/backend/packages/app/src/windup_app/web/api/playtest_inspection.py b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py new file mode 100644 index 00000000..c2c01ae9 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py @@ -0,0 +1,89 @@ +"""Playtest 核验记录 API。""" + +from datetime import datetime +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import Response +from windup_framework.db import get_session + +from windup_app.server.character.service import service as character_service +from windup_app.server.playtest_inspection.service import service + +router = APIRouter(prefix="/playtest-inspections", tags=["playtest"]) + +InspectionStatus = Literal["passed", "issues_found"] + + +class PlaytestInspectionSave(BaseModel): + """保存动作当前核验结论的请求。""" + + character_id: int = Field(gt=0) + outfit_id: str = Field(min_length=1, max_length=128) + action_id: str = Field(min_length=1, max_length=128) + status: InspectionStatus + + +class PlaytestInspectionOut(PlaytestInspectionSave): + """动作当前核验结论。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + create_at: datetime + update_at: datetime + + +def _require_action( + session: Session, *, character_id: int, outfit_id: str, action_id: str +) -> None: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + + outfits = (character.character_data or {}).get("outfits", []) + outfit = next((item for item in outfits if item.get("id") == outfit_id), None) + if outfit is None: + raise BizException("造型不存在", code=BizCode.NOT_FOUND) + if not any(item.get("id") == action_id for item in outfit.get("actions", [])): + raise BizException("动作不存在", code=BizCode.NOT_FOUND) + + +@router.get("", response_model=Response[PlaytestInspectionOut]) +def get_playtest_inspection( + character_id: int = Query(..., gt=0), + outfit_id: str = Query(..., min_length=1, max_length=128), + action_id: str = Query(..., min_length=1, max_length=128), + session: Session = Depends(get_session), +) -> Response[PlaytestInspectionOut]: + inspection = service.get_inspection( + session, + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + ) + if inspection is None: + raise BizException("尚未核验", code=BizCode.NOT_FOUND) + return Response.success(PlaytestInspectionOut.model_validate(inspection)) + + +@router.post("", response_model=Response[PlaytestInspectionOut]) +def save_playtest_inspection( + body: PlaytestInspectionSave, + session: Session = Depends(get_session), +) -> Response[PlaytestInspectionOut]: + _require_action( + session, + character_id=body.character_id, + outfit_id=body.outfit_id, + action_id=body.action_id, + ) + inspection = service.save_inspection(session, **body.model_dump()) + return Response.success( + PlaytestInspectionOut.model_validate(inspection), message="核验已保存" + ) diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py new file mode 100644 index 00000000..723c5b40 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -0,0 +1,107 @@ +"""项目 CRUD API。""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.project.service import service + +logger = logging.getLogger("windup.project.api") + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class ProjectCreate(BaseModel): + """创建项目请求。""" + + user_id: int = Field(gt=0) + workflow_id: int | None = None + project_name: str = Field(min_length=1, max_length=64) + character_perspective: int = Field(ge=1, le=3) + directional_movement: int = Field(ge=1, le=3) + sprite_width: int = Field(ge=32, le=2048) + sprite_height: int = Field(ge=32, le=2048) + game_style: str | None = None + sprite_sample_url: str | None = None + + +class ProjectOut(ProjectCreate): + """项目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + create_at: datetime + update_at: datetime + + +@router.post("", response_model=Response[ProjectOut]) +def create_project( + body: ProjectCreate, session: Session = Depends(get_session) +) -> Response[ProjectOut]: + if service.project_name_exists( + session, user_id=body.user_id, project_name=body.project_name + ): + logger.warning( + "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s", + body.user_id, + body.project_name, + ) + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + try: + project = service.create_project(session, **body.model_dump()) + except IntegrityError: + logger.warning( + "[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s", + body.user_id, + body.project_name, + ) + session.rollback() + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(ProjectOut.model_validate(project), message="创建成功") + + +@router.get("", response_model=ListResponse[ProjectOut]) +def list_projects( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: int | None = Query(None, gt=0), + session: Session = Depends(get_session), +) -> ListResponse[ProjectOut]: + projects, total = service.list_projects( + session, page=page, page_size=page_size, user_id=user_id + ) + return ListResponse.success( + [ProjectOut.model_validate(item) for item in projects], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{project_id}", response_model=Response[ProjectOut]) +def get_project( + project_id: int, session: Session = Depends(get_session) +) -> Response[ProjectOut]: + project = service.get_project(session, project_id) + if project is None: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(ProjectOut.model_validate(project)) + + +@router.delete("/{project_id}", response_model=Response[bool]) +def delete_project( + project_id: int, session: Session = Depends(get_session) +) -> Response[bool]: + if not service.delete_project(session, project_id): + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(True, message="删除成功") diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py new file mode 100644 index 00000000..2cb791b8 --- /dev/null +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -0,0 +1,15 @@ +from windup_common.models.character import ( + ActionSpec, + ActionType, + AssetPackageRef, + CharacterCard, + GenRoute, +) + +__all__ = [ + "ActionType", + "GenRoute", + "CharacterCard", + "ActionSpec", + "AssetPackageRef", +] diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py new file mode 100644 index 00000000..cccda23f --- /dev/null +++ b/backend/packages/common/src/windup_common/models/character.py @@ -0,0 +1,81 @@ +"""共享 DTO —— 跨层契约(common,无内部依赖)。 + +产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举、资产包引用。 +仅定义结构,不含行为。ai_engine / app 均依赖此。 +""" +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class ActionType(str, Enum): + """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。""" + + IDLE = "idle" + WALK = "walk" + RUN = "run" + JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases) + ATTACK = "attack" # slash / thrust / dash 归此 + HIT = "hit" + CUSTOM = "custom" # 提示词驱动的自定义动作(走视频路线,动作描述见 ActionSpec.action_desc) + + +class GenRoute(str, Enum): + """生成路线 —— 实测挣得的分流依据(见 strategy 层 docstring)。""" + + VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) + PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) + PROC_IDLE = "proc_idle" # 待机:程序化局部呼吸(Idle-B) + + +class CharacterCard(BaseModel): + """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。""" + + name: str + desc: str # 身份描述(喂模型锁一致性) + palette: str = "" + view: str = "pseudo-side" # side / topdown / isometric + master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + version: str = "v1" + + +class ActionSpec(BaseModel): + """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化。""" + + action: ActionType + fps: int = 10 + loop: str = "linear" # none / linear / pingpong + poses: list[str] = Field(default_factory=list) + # 风格化:pixel=像素化(原生像素角色 i2v 后复原像素感);none=保留 i2v 插画质感。 + # 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由 CharacterCard 画风决定。 + stylize: str = "pixel" # pixel / none + pixel_h: int = 100 # 像素化目标高(角色像素行数) + palette_size: int = 32 # 色板色数 + # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective): + # side=横版侧走 / front=俯视·2.5D 朝观者。不一致会让模型靠转身调和图文矛盾。 + facing: str = "side" # side / front + # 自定义动作(custom)的自然语言动作描述,如 "the character is painting on an easel"。 + action_desc: str = "" # 仅 custom 使用;其他动作类型忽略 + + @property + def n_frames(self) -> int: + return len(self.poses) + + +class AssetPackageRef(BaseModel): + """生成产出 —— 引擎可用资产包的存储引用(二进制在对象存储)。""" + + character: str + action: ActionType + sheet_ref: str = "" # sprite sheet 存储 ref + frame_refs: list[str] = Field(default_factory=list) + plist_ref: str = "" # Cocos SpriteFrames + fps: int = 10 + # 引擎侧元数据(业界惯例:位移不烘进像素,交引擎驱动): + # root_motion 逐帧 (dx, dy) 像素位移,y 向上为正;durations 逐帧时长(ms), + # 关键帧(攻击触点 / 跳跃顶点)会加长定格 —— 等时长会让动作发飘、没重量感。 + root_motion: list[tuple[int, int]] = Field(default_factory=list) + durations: list[int] = Field(default_factory=list) + qa: dict = Field(default_factory=dict) diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml index 17726b58..a4550e9c 100644 --- a/backend/packages/framework/pyproject.toml +++ b/backend/packages/framework/pyproject.toml @@ -9,11 +9,22 @@ dependencies = [ "pydantic-settings>=2.4", "sqlalchemy>=2.0", "psycopg[binary]>=3.2", + "redis>=5.0", "httpx>=0.27", "pyjwt>=2.9", - # 以下两项按选型启用: + # AI 模型适配器(providers/):chat 走 langchain,video/image 走 httpx。 + "langchain-core>=0.3", + "langchain-openai>=0.3", + # 抠图 MatteProvider:onnxruntime 直跑 u2netp(替代 rembg,其 numba 老链在 3.12 无轮子)。 + # 上限 <1.24:onnxruntime 自 1.24 起砍了 macOS Intel(x86_64)轮子;1.23.x 仍覆盖 + # Intel/arm64/Linux + py3.12,保证 Intel Mac 也能装。API 与新版一致,不改抠图代码。 + "numpy>=1.26", + "onnxruntime>=1.17,<1.24", + "pillow>=10.4", + # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。 + "qiniu>=7.14", + # 以下按选型启用: # "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一) - # "minio", # 对象存储;若用 OSS/S3 换 oss2 / boto3 ] [tool.uv.sources] diff --git a/backend/packages/framework/src/windup_framework/config/__init__.py b/backend/packages/framework/src/windup_framework/config/__init__.py index 2f4cd973..8118ac71 100644 --- a/backend/packages/framework/src/windup_framework/config/__init__.py +++ b/backend/packages/framework/src/windup_framework/config/__init__.py @@ -2,13 +2,16 @@ from windup_framework.config.database import DatabaseSettings, settings from windup_framework.config.provider import AIProviderSettings, settings as provider_settings +from windup_framework.config.redis import RedisSettings, settings as redis_settings from windup_framework.config.storage import StorageSettings, settings as storage_settings __all__ = [ "AIProviderSettings", "DatabaseSettings", + "RedisSettings", "StorageSettings", "provider_settings", + "redis_settings", "settings", "storage_settings", ] diff --git a/backend/packages/framework/src/windup_framework/config/database.py b/backend/packages/framework/src/windup_framework/config/database.py index 6cb3d483..9ff015ed 100644 --- a/backend/packages/framework/src/windup_framework/config/database.py +++ b/backend/packages/framework/src/windup_framework/config/database.py @@ -1,20 +1,32 @@ -"""Postgres 数据库连接配置。 +"""数据库连接配置。 -从环境变量(或 ``.env``)读取,字段前缀 ``POSTGRES_``。 -本地开发默认值对应 Docker 容器 root/admin123@localhost:4000。 +优先使用 SQLite(通过 SQLITE_PATH 环境变量),否则回退到 PostgreSQL。 """ +import os +from pathlib import Path + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import URL +_BACKEND_ROOT = Path(__file__).resolve().parents[5] + + +def resolve_sqlite_path(value: str) -> Path: + """把本地 SQLite 相对路径固定解释为 backend 目录下的路径。""" + path = Path(value).expanduser() + if not path.is_absolute(): + path = _BACKEND_ROOT / path + return path.resolve() + + class DatabaseSettings(BaseSettings): """数据库连接配置。""" model_config = SettingsConfigDict( env_prefix="POSTGRES_", - # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录 env_file=("../.env", ".env"), env_file_encoding="utf-8", extra="ignore", @@ -31,11 +43,14 @@ class DatabaseSettings(BaseSettings): @property def url(self) -> str: - """SQLAlchemy 连接串(psycopg3 驱动)。 + """SQLAlchemy 连接串。 - 用 ``URL.create`` 构造以正确转义密码中的保留字符(``@ : /`` 等), - 再渲染为 str 以保持返回类型契约。 + 若设置 SQLITE_PATH 环境变量则使用 SQLite,否则连接 PostgreSQL。 """ + sqlite_path = os.getenv("SQLITE_PATH") + if sqlite_path: + return f"sqlite:///{resolve_sqlite_path(sqlite_path)}" + return URL.create( drivername="postgresql+psycopg", username=self.user, diff --git a/backend/packages/framework/src/windup_framework/config/redis.py b/backend/packages/framework/src/windup_framework/config/redis.py new file mode 100644 index 00000000..61f8b082 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/redis.py @@ -0,0 +1,20 @@ +"""Redis 连接配置。""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RedisSettings(BaseSettings): + """Redis 预留配置;只有 ``REDIS_ENABLED=true`` 时才允许创建客户端。""" + + model_config = SettingsConfigDict( + env_prefix="REDIS_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + enabled: bool = False + url: str = "redis://127.0.0.1:6379/0" + + +settings = RedisSettings() diff --git a/backend/packages/framework/src/windup_framework/config/storage.py b/backend/packages/framework/src/windup_framework/config/storage.py index 06eff8c0..7f12adf7 100644 --- a/backend/packages/framework/src/windup_framework/config/storage.py +++ b/backend/packages/framework/src/windup_framework/config/storage.py @@ -4,16 +4,22 @@ 本地开发需在 ``.env`` 填入 AccessKey / SecretKey / Bucket / 绑定域名。 """ +from pathlib import Path + +from dotenv import load_dotenv from pydantic_settings import BaseSettings, SettingsConfigDict +# 显式加载项目根目录的 .env,避免 CWD 不同时相对路径找不到文件 +_ROOT_ENV = Path(__file__).resolve().parents[6] / ".env" +load_dotenv(_ROOT_ENV, override=False) + class StorageSettings(BaseSettings): """七牛 Kodo 对象存储配置。""" model_config = SettingsConfigDict( env_prefix="QINIU_", - # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录 - env_file=("../.env", ".env"), + env_file=(_ROOT_ENV, ".env"), env_file_encoding="utf-8", extra="ignore", ) @@ -33,7 +39,11 @@ class StorageSettings(BaseSettings): @property def download_base(self) -> str: """下载 URL 基础域名,去掉末尾 ``/``,客户端拼接 key 即可。""" - return self.bucket_domain.rstrip("/") + domain = self.bucket_domain.rstrip("/") + if domain and not domain.startswith(("http://", "https://")): + # 七牛测试域名 SSL 证书可能不匹配,默认用 http + domain = f"http://{domain}" + return domain settings = StorageSettings() diff --git a/backend/packages/framework/src/windup_framework/db/__init__.py b/backend/packages/framework/src/windup_framework/db/__init__.py index 91f57b23..e745ebdb 100644 --- a/backend/packages/framework/src/windup_framework/db/__init__.py +++ b/backend/packages/framework/src/windup_framework/db/__init__.py @@ -1,6 +1,7 @@ -"""数据库基础设施:ORM 基类、engine、session 工厂。""" +"""数据库基础设施:ORM 基类、engine、session 工厂、Redis 客户端。""" from windup_framework.db.base import Base +from windup_framework.db.redis import get_redis from windup_framework.db.session import SessionLocal, engine, get_session -__all__ = ["Base", "SessionLocal", "engine", "get_session"] +__all__ = ["Base", "SessionLocal", "engine", "get_redis", "get_session"] diff --git a/backend/packages/framework/src/windup_framework/db/redis.py b/backend/packages/framework/src/windup_framework/db/redis.py new file mode 100644 index 00000000..693aa313 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/db/redis.py @@ -0,0 +1,25 @@ +"""按需创建 Redis 客户端;默认配置不会打开 Redis 连接。""" + +import redis + +from windup_framework.config.redis import settings as redis_settings + + +from windup_framework.config.redis import RedisSettings + + +def create_redis_client(settings: RedisSettings) -> redis.Redis | None: + """仅在显式启用时创建客户端;redis-py 会在首次命令时建立连接。""" + + if not settings.enabled: + return None + return redis.Redis.from_url(settings.url, decode_responses=True) + + +_client = create_redis_client(redis_settings) + + +def get_redis() -> redis.Redis | None: + """返回可选 Redis 客户端;默认关闭时返回 ``None``。""" + + return _client diff --git a/backend/packages/framework/src/windup_framework/db/session.py b/backend/packages/framework/src/windup_framework/db/session.py index a63b53bd..a767fa9b 100644 --- a/backend/packages/framework/src/windup_framework/db/session.py +++ b/backend/packages/framework/src/windup_framework/db/session.py @@ -12,12 +12,19 @@ from windup_framework.config.database import settings as db_settings -engine = create_engine( - db_settings.url, - pool_size=db_settings.pool_size, - max_overflow=db_settings.max_overflow, - pool_pre_ping=db_settings.pool_pre_ping, -) +database_url = db_settings.url +if database_url.startswith("sqlite:"): + engine = create_engine( + database_url, + connect_args={"check_same_thread": False}, + ) +else: + engine = create_engine( + database_url, + pool_size=db_settings.pool_size, + max_overflow=db_settings.max_overflow, + pool_pre_ping=db_settings.pool_pre_ping, + ) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 3524bbf3..61edb2f6 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -1,8 +1,15 @@ -"""按模型能力划分的 AI Provider 接口。""" +"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。""" from windup_framework.config.provider import AIProviderSettings from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client +from windup_framework.providers.interfaces import ( + ImageProvider, + MatteProvider, + VideoProvider, +) +from windup_framework.providers.matte import OnnxU2NetMatteProvider +from windup_framework.providers.sufy import SufyImageProvider, SufyVideoProvider from windup_framework.providers.video import create_video_client __all__ = [ @@ -10,4 +17,12 @@ "create_chat_model", "create_image_client", "create_video_client", + # 能力接口(ai_engine 依赖这些稳定契约) + "ImageProvider", + "VideoProvider", + "MatteProvider", + # 实现 + "SufyVideoProvider", + "SufyImageProvider", + "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py new file mode 100644 index 00000000..697962d5 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -0,0 +1,34 @@ +"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。 + +ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。 +实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测 +到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。 + +本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。 +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ImageProvider(Protocol): + """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。""" + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... + + +@runtime_checkable +class VideoProvider(Protocol): + """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。""" + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: ... + + +@runtime_checkable +class MatteProvider(Protocol): + """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" + + def cutout(self, frame: bytes) -> bytes: ... diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py new file mode 100644 index 00000000..050997b8 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -0,0 +1,103 @@ +"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。 + +为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python +3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认 +``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖, +3.12 干净可装、可进 lock。同模型(u2netp),同质量。 + +模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。 +onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。 +""" +from __future__ import annotations + +import io +import urllib.request +from pathlib import Path + +import numpy as np +from PIL import Image + +from .interfaces import MatteProvider + +# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。 +_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx" +_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx" + +# u2net 预处理常量(与 rembg 一致)。 +_MEAN = (0.485, 0.456, 0.406) +_STD = (0.229, 0.224, 0.225) +_SIZE = (320, 320) + + +class OnnxU2NetMatteProvider(MatteProvider): + """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" + + def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None: + self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE + self._model_url = model_url + self._session = None # 惰性 + + def _ensure_model(self) -> Path: + if not self._model_path.exists(): + self._model_path.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(self._model_url, self._model_path) + return self._model_path + + def _get_session(self): + if self._session is None: + try: + import onnxruntime as ort # 惰性:导入慢 + except ImportError: + return None # onnxruntime 不可用(如 macOS x86_64),走 Pillow 兜底 + self._session = ort.InferenceSession( + str(self._ensure_model()), providers=["CPUExecutionProvider"] + ) + return self._session + + def _predict_mask(self, img: Image.Image) -> Image.Image: + """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。""" + im = img.convert("RGB").resize(_SIZE, Image.LANCZOS) + ary = np.array(im).astype(np.float32) + ary = ary / max(float(ary.max()), 1e-6) + tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32) + for c in range(3): + tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c] + tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32) + + session = self._get_session() + pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :] + mi, ma = float(pred.min()), float(pred.max()) + pred = (pred - mi) / max(ma - mi, 1e-6) + mask = (pred.squeeze() * 255).astype(np.uint8) + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) + + def cutout(self, frame: bytes) -> bytes: + img = Image.open(io.BytesIO(frame)).convert("RGBA") + session = self._get_session() + if session is not None: + mask = self._predict_mask(img) + else: + mask = self._fallback_mask(img) + cut = Image.composite(img, Image.new("RGBA", img.size, (0, 0, 0, 0)), mask) + buf = io.BytesIO() + cut.save(buf, "PNG") + return buf.getvalue() + + @staticmethod + def _fallback_mask(img: Image.Image) -> Image.Image: + """Pillow 兜底:取四角主色做 chroma-key 式去背(精度远低于 u2netp,仅开发用)。""" + import numpy as np + + ary = np.array(img.convert("RGB")) + # 取四角 8×8 采样主色 + corners = np.concatenate([ + ary[:8, :8].reshape(-1, 3), + ary[:8, -8:].reshape(-1, 3), + ary[-8:, :8].reshape(-1, 3), + ary[-8:, -8:].reshape(-1, 3), + ]) + bg = corners.mean(axis=0) + diff = np.linalg.norm(ary.astype(float) - bg, axis=2) + # 阈值:距离 < 60 视为背景 + mask = (diff > 60).astype(np.uint8) * 255 + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py new file mode 100644 index 00000000..e7c52126 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -0,0 +1,140 @@ +"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。 + +视频走异步任务协议(2026-07-27 端到端实测): + POST /videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 +key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 +重依赖(rembg)惰性导入,保证模块导入零成本。 +""" +from __future__ import annotations + +import base64 +import io +import time + +import httpx + +from windup_framework.config.provider import AIProviderSettings, settings + +from .interfaces import ImageProvider, VideoProvider + +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 + + 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 + 会 VENDOR_FAILED(实测)。 + """ + from PIL import Image + + w, h = (int(x) for x in size.split("x")) + im = Image.open(io.BytesIO(frame)).convert("RGB") + pad = im.getpixel((0, 0)) + fitted = im.copy() + fitted.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), pad) + canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "JPEG", quality=90) + return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() + + +class SufyVideoProvider(VideoProvider): + """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。""" + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = DEFAULT_VIDEO_MODEL, + mode: str = "std", + poll_interval: float = 60.0, + max_min: int = 30, + ) -> None: + self._cfg = config + self._model = model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + body: dict = { + "model": self._model, + "prompt": prompt, + "size": size, + "seconds": str(seconds), + "mode": self._mode, + } + if self._model in _IMAGE_LIST_MODELS: + b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] + body["image_list"] = [{"image": b64}] + else: + body["input_reference"] = _first_frame_datauri(first_frame, size) + + with self._client() as client: + job = client.post("/videos", json=body).raise_for_status().json() + jid = job.get("id") + url = None + for _ in range(max(1, int(self._max_min * 60 // self._poll))): + time.sleep(self._poll) + st = client.get(f"/videos/{jid}").raise_for_status().json() + status = st.get("status") + if status == "completed": + vids = (st.get("task_result") or {}).get("videos") or [] + url = vids[0].get("url") if vids else None + break + if status in ("failed", "cancelled"): + raise RuntimeError(f"i2v 失败: {status}") + if not url: + raise RuntimeError("i2v 未取得视频 URL(超时或失败)") + return client.get(url).raise_for_status().content + + +class SufyImageProvider(ImageProvider): + """图像 provider:gemini 系图生图(OpenAI 兼容 ``/chat/completions``,返回 base64 图)。 + + 参考图 + 文字约束 → 生成一张图(角色基准图 CHARACTER_IMAGE / 逐帧图生图)。 + key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 + """ + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = "gemini-2.5-flash-image", + ) -> None: + self._cfg = config + self._model = model + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + import json + import re + + content: list = [{"type": "text", "text": prompt}] + for r in refs: + b64 = base64.b64encode(r).decode() + content.append( + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}} + ) + body = {"model": self._model, "messages": [{"role": "user", "content": content}]} + with httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) as client: + res = client.post(self._cfg.chat_completions_path, json=body).raise_for_status().json() + m = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})", json.dumps(res)) + if not m: + raise RuntimeError(f"图像响应无有效图: {json.dumps(res)[:200]}") + return base64.b64decode(m.group(1)) diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py new file mode 100644 index 00000000..b4448110 --- /dev/null +++ b/backend/tests/test_generation_api.py @@ -0,0 +1,273 @@ +"""生成任务 HTTP 边界回归测试。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from windup_app.bootstrap.app import create_app +from windup_app.server.generation import task_repo +from windup_app.server.generation.model import ( + ActionType, + CharacterActionInput, + GenerationTaskRecord, +) +from windup_app.server.generation.service import AiGenerationService +from windup_framework.db import Base, get_session + + +class _ImmediateThread: + """让测试中的后台任务立即运行,稳定复现提交与执行的先后顺序。""" + + def __init__(self, *, target, args, daemon): + self._target = target + self._args = args + + def start(self): + self._target(*self._args) + + +def _action_payload(): + return { + "user_id": 1, + "project_id": 1, + "character_id": 1, + "action_type": "walk", + "reference_image_urls": ["https://cdn.example.com/master.png"], + "num_frames": 2, + } + + +def _image_payload(): + return { + "user_id": 1, + "project_id": None, + "prompt": "像素角色", + "width": 64, + "height": 64, + "num_images": 1, + } + + +def test_action_request_defaults_to_32_frames(client, monkeypatch): + """调用方省略帧数时,HTTP 边界必须创建一项 32 帧动作任务。""" + captured_inputs = [] + + class _CaptureOnlyThread: + def __init__(self, *, target, args, daemon): + captured_inputs.append(args[1]) + + def start(self): + return None + + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _CaptureOnlyThread, + ) + payload = _action_payload() + payload.pop("num_frames") + + response = client.post("/generation/action", json=payload) + + assert response.status_code == 200 + assert response.json()["data"]["input_payload"]["num_frames"] == 32 + assert captured_inputs[0].num_frames == 32 + + +def test_action_task_is_committed_before_background_execution( + tmp_path, + monkeypatch, +): + """后台执行器必须能读到刚提交的任务并写入终态。""" + engine = create_engine(f"sqlite:///{tmp_path / 'generation.db'}") + Base.metadata.create_all(engine) + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + with session_local() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + def complete_task(task_id, _input, _project_id): + with session_local() as session: + task_repo.update_result( + session, + task_id, + "character_action", + { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "https://cdn.example.com/frame.png", + "duration_ms": 100, + } + ], + }, + ) + session.commit() + + app.state.run_action_task = complete_task + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _ImmediateThread, + ) + + with TestClient(app) as client: + submitted = client.post("/generation/action", json=_action_payload()).json()[ + "data" + ] + task = client.get( + f"/generation/tasks/{submitted['id']}", + params={"project_id": 1}, + ).json()["data"] + + assert task["status"] == "completed" + assert task["result"]["frames"][0]["image_url"].endswith("frame.png") + engine.dispose() + + +def test_image_task_is_committed_before_background_execution(tmp_path, monkeypatch): + """Quick Start 的首段图片任务也必须在后台执行前完成提交。""" + engine = create_engine(f"sqlite:///{tmp_path / 'image-generation.db'}") + Base.metadata.create_all(engine) + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + with session_local() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + def complete_task(task_id, _input, _project_id): + with session_local() as session: + task_repo.update_result( + session, + task_id, + "character_image", + { + "type": "character_image", + "image_urls": ["https://cdn.example.com/character.png"], + }, + ) + session.commit() + + app.state.run_image_task = complete_task + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _ImmediateThread, + ) + + with TestClient(app) as client: + submitted = client.post("/generation/image", json=_image_payload()).json()[ + "data" + ] + task = client.get( + f"/generation/tasks/{submitted['id']}", + params={"project_id": 1}, + ).json()["data"] + + assert task["status"] == "completed" + assert task["result"]["image_urls"] == ["https://cdn.example.com/character.png"] + engine.dispose() + + +def test_completed_task_stream_emits_terminal_snapshot(client, engine): + """晚于任务完成建立 SSE 连接时,也必须立刻收到可恢复的终态快照。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + action_input = CharacterActionInput( + character_id=1, + action_type=ActionType.WALK, + reference_image_urls=["https://cdn.example.com/master.png"], + num_frames=1, + ) + with session_local() as session: + task = AiGenerationService().generate_character_action( + session, + user_id=1, + project_id=1, + input=action_input, + ) + session.commit() + task_repo.update_result( + session, + task.id, + "character_action", + { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "https://cdn.example.com/frame.png", + "duration_ms": 100, + } + ], + }, + ) + session.commit() + task_id = task.id + + with client.stream( + "GET", + f"/generation/tasks/{task_id}/stream", + params={"project_id": 1}, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + event_lines = [line for line in body.splitlines() if line.startswith("data: ")] + assert len(event_lines) == 1 + payload = json.loads(event_lines[0].removeprefix("data: ")) + assert payload["task_id"] == task_id + assert payload["task_type"] == "character_action" + assert payload["status"] == "completed" + assert payload["result"]["frames"][0]["image_url"].endswith("frame.png") + + +def test_stale_incomplete_task_becomes_retryable_failure(client, engine): + """后台进程丢失的旧任务不能让 Quick Start 永久停在生成中。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + with session_local() as session: + task = AiGenerationService().generate_character_action( + session, + user_id=1, + project_id=1, + input=CharacterActionInput( + character_id=1, + action_type=ActionType.WALK, + reference_image_urls=["https://cdn.example.com/master.png"], + ), + ) + session.commit() + record = session.get(GenerationTaskRecord, task.id) + record.update_at = datetime.now(timezone.utc) - timedelta(minutes=16) + session.commit() + task_id = task.id + + response = client.get( + f"/generation/tasks/{task_id}", + params={"project_id": 1}, + ).json()["data"] + + assert response["status"] == "failed" + assert "请重试" in response["error_message"] diff --git a/backend/tests/test_local_runtime.py b/backend/tests/test_local_runtime.py new file mode 100644 index 00000000..b56b21ca --- /dev/null +++ b/backend/tests/test_local_runtime.py @@ -0,0 +1,86 @@ +"""本地启动入口的路径与 Windows 事件循环回归测试。""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import uvicorn + +from windup_app.bootstrap import app as app_module + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def test_init_db_resolves_relative_sqlite_path_from_backend_directory(tmp_path): + """无论从哪个目录启动,都必须复用 backend/windup.db,而不是创建新空库。""" + script = """ +import importlib.util +import os +from pathlib import Path + +path = Path(os.environ["WINDUP_INIT_DB"]) +spec = importlib.util.spec_from_file_location("windup_init_db", path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +print(os.environ["SQLITE_PATH"]) +""" + env = os.environ.copy() + env["SQLITE_PATH"] = "./windup.db" + env["WINDUP_INIT_DB"] = str(BACKEND_ROOT / "init_db.py") + + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + + assert Path(completed.stdout.strip()) == (BACKEND_ROOT / "windup.db").resolve() + + +def test_framework_resolves_relative_sqlite_path_from_backend_directory(tmp_path): + """直接运行 windup 入口时也必须使用同一份本地库。""" + script = """ +from windup_framework.db.session import engine +print(engine.url.database) +""" + env = os.environ.copy() + env["SQLITE_PATH"] = "./windup.db" + + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + + assert Path(completed.stdout.strip()) == (BACKEND_ROOT / "windup.db").resolve() + + +@pytest.mark.skipif(sys.platform != "win32", reason="仅验证 Windows asyncio 运行时") +def test_main_passes_selector_loop_factory_to_uvicorn(monkeypatch): + """Uvicorn 必须实际创建 Selector,而不是覆盖一个只写在全局 policy 的配置。""" + captured_options = {} + + def capture_run(*args, **kwargs): + captured_options.update(kwargs) + + monkeypatch.setattr(uvicorn, "run", capture_run) + app_module.main() + + loop_factory = captured_options["loop"] + loop = loop_factory() + try: + assert isinstance(loop, asyncio.SelectorEventLoop) + finally: + loop.close() diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py new file mode 100644 index 00000000..24ad5ff6 --- /dev/null +++ b/backend/tests/test_loop.py @@ -0,0 +1,53 @@ +"""循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。""" + +from PIL import Image + +from windup_ai_engine.slicing import find_period, pick_cycle + + +def _periodic_frames(period: int, cycles: int) -> list[Image.Image]: + """构造已知周期的帧序列:亮度按周期正弦变化(每帧一张纯灰图)。""" + import math + + frames = [] + for i in range(period * cycles): + v = int(128 + 100 * math.sin(2 * math.pi * i / period)) + frames.append(Image.new("RGB", (48, 48), (v, v, v))) + return frames + + +def test_find_period_detects_known_period(): + frames = _periodic_frames(period=20, cycles=5) + p = find_period(frames) + assert abs(p - 20) <= 1 # 检出周期 ≈ 真值 + + +def test_pick_cycle_returns_n_frames(): + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + assert len(out) == 8 + + +def test_pick_cycle_resamples_when_source_has_too_few_frames(): + frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8 + out = pick_cycle(frames, 8) + + assert len(out) == 8 + import numpy as np + + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + assert np.abs(last - first).mean() == 0 + + +def test_pick_cycle_closes_the_loop(): + # 取出的一周期,末帧的下一拍应接近首帧(亮度差小) + import numpy as np + + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + step = np.abs(np.asarray(out[1].convert("L"), float) - first).mean() + seam = np.abs(last - first).mean() + assert seam <= step * 2 + 5 # 回接缝不显著大于一个正常步幅 diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py new file mode 100644 index 00000000..d83e291e --- /dev/null +++ b/backend/tests/test_oneshot.py @@ -0,0 +1,87 @@ +"""一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.slicing import ( + find_motion_span, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + + +def _figure_at(y_bottom: int, size: int = 64, h: int = 20) -> Image.Image: + """在指定底边高度画一个方块"角色"(RGBA,其余透明)。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + top = max(0, y_bottom - h) + arr[top:y_bottom, size // 2 - 4 : size // 2 + 4] = (200, 60, 60, 255) + return Image.fromarray(arr, "RGBA") + + +def _jump_sequence() -> list[Image.Image]: + """合成跳跃:静止 → 蹲(底边下移)→ 升 → 顶点 → 落 → 静止。""" + ground, low, apex = 50, 52, 30 + ys = [ground] * 3 + [low, low] + [44, 38, apex, apex, 38, 44] + [ground] * 3 + return [_figure_at(y) for y in ys] + + +def test_find_motion_span_trims_static_head_and_tail(): + frames = _jump_sequence() + start, end = find_motion_span(frames) + assert start >= 1 # 前面的静止帧被裁掉 + assert end <= len(frames) - 2 # 后面的静止帧被裁掉 + assert end > start + + +def test_pick_oneshot_returns_n_and_does_not_wrap(): + frames = _jump_sequence() + out = pick_oneshot(frames, 6) + assert len(out) == 6 + # 一次性动作不闭环:首尾姿态应不同(闭环的话会几乎一样) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + assert np.abs(first - last).mean() >= 0 + + +def test_pick_oneshot_resamples_short_motion_span_to_requested_frame_count(): + """动作裁剪只剩少量源帧时,仍须兑现调用方请求的 32 帧契约。""" + frames = _jump_sequence() + + out = pick_oneshot(frames, 32) + + assert len(out) == 32 + assert out[0] is not frames[0] + assert out[-1] is not frames[-1] + + +def test_pick_oneshot_can_return_exactly_one_first_frame(): + frames = _jump_sequence() + + out = pick_oneshot(frames, 1) + + assert out[0] is not frames[0] + + +def test_foot_line_tracks_height(): + frames = _jump_sequence() + y = foot_line_series(frames) + assert y.argmin() in range(6, 10) # 最高点(y 最小)落在顶点附近 + assert y[0] > y.min() # 起始在地面,低于顶点 + + +def test_split_jump_phases_covers_all_frames_in_order(): + frames = _jump_sequence() + phases = split_jump_phases(frames) + assert "apex" in phases + idx = [i for seg in phases.values() for i in seg] + assert sorted(idx) == list(range(len(frames))) # 不重不漏 + # apex 段应在 rise 之后、fall 之前 + if "rise" in phases and "fall" in phases: + assert max(phases["rise"]) < min(phases["apex"]) + assert max(phases["apex"]) < min(phases["fall"]) + + +def test_split_jump_phases_short_input_is_safe(): + assert split_jump_phases([_figure_at(50)] * 3) diff --git a/docs/module-split-plan.md b/docs/module-split-plan.md new file mode 100644 index 00000000..e0089334 --- /dev/null +++ b/docs/module-split-plan.md @@ -0,0 +1,118 @@ +# 前端模块拆分规划(chaifen 系列) + +> 决策日期:2026-08-04 +> 核心源目录:`.pr70-playtest-worktree`(分支 `feat/playtest-on-module-skeleton`) +> 拆分目标目录:`chaifen/` +> 范围决策:**只拆前端**;后端不拆分(见「后端(决策:不拆分)」)。 + +## 1. 拆分模式(01-04 已验证) + +1. 每个模块 = `chaifen/0X-` worktree + 独立分支 + 独立 PR(推送到 `1024XEngineer/Windup`)。 +2. 每个 PR **只含模块自身文件**,通过「允许提交路径」白名单控制范围;配套源码在本地保留、不提交。 +3. 新模块基于上一个已验证提交创建,形成顺序依赖链。 +4. 每模块一份 `_PR说明.md` 管理文档(本地 exclude,不提交),记录范围、验证、改动记录。 + +## 2. 模块总表 + +### 已完成(01-06) + +| # | 模块 | 分支 | 内容 | 状态 | +|---|---|---|---|---| +| 01 | workflow-run-core | `feat/workflow-run-core-clean` | `entities/workflow-run`(model/store/service) | PR #86 open | +| 02 | workflow-controller | `feat/workflow-controller-coordinator` | `features/workflow-controller`(controller.ts + tests) | 已推送 `5ec5ba3`,未开 PR | +| 03 | playtest | `split/playtest` | 数据适配器(`shared/api`、`entities/character\|project\|playtest-inspection`)+ 预览/质量内核 + 页面与路由(`pages/playtest`,含 `app.tsx`/`layout` 集成) | 三个提交齐备(`8f9abeb`),未推送 | +| 04 | quick-start | `split/quick-start` | `pages/quick-start`(index/service + tests) | PR #95 draft | +| 05 | export-package | `feat/export-package` | `features/export-package` 全量 + 契约(`contract.ts`、`cocos-target.ts`、schema、gifenc.d.ts)+ package.json/lock | PR #97 draft,已推送 `3cbfbc9` | +| 06 | history | `feat/history-page` | `pages/history`(index + tests) | PR #105 draft,已推送 | + +### 进行中(07-09) + +| # | 模块 | 分支 | 内容 | 状态 | +|---|---|---|---|---| +| 07 | media-upload | `feat/media-upload` | `entities/media` 适配器 + `shared/api/upload.ts` | Refs #109;文件就绪、未提交 | +| 08 | generation-sse-adapter | `feat/generation-sse-adapter` | `entities/generation` 适配器 + `shared/api/stream.ts` | Refs #78;验证通过(lint/typecheck/test/build)、未提交 | +| 09 | asset-library | — | `pages/asset-library/index.tsx` | 目录已建,worktree 待初始化 | +| 12 | auth-session | `split/auth-session` | `entities/user`(index + api)+ `features/auth-session`(index + session-storage + 2 tests) | 已拆分到 chaifen/12-auth-session,未提交 | + +### 待拆(剩余) + +| # | 模块 | 包含文件(白名单) | 说明 | +|---|---|---|---| +| — | publish-review | `features/publish/index.ts`、`workflow-to-character.ts`、`features/review/index.ts` | 原计划 06,顺延 | +| — | workflow-editor | `pages/workflow-editor/index.tsx`、`node-canvas.ts`、`service.ts`、`workflow-canvas.tsx`、`workflow-editor.css` | 原计划 07 | +| — | projects | `pages/projects/index.tsx`、`create-page.tsx`、`detail-page.tsx` | 原计划 10 | +| — | app-shell | `app/` 剩余(layout/`__fixtures__`/api-contract.test)、`pages/not-found/index.tsx`、`main.tsx`、`index.css` | 最后收口 | +| — | 规划外残留 | `pages/home/*`、`features/character-setup/*`、`features/export/index.ts`、`features/generation/index.ts`、`entities/project/index.ts`、playtest 剩余 4 文件、`shared/pagination` | 见备注 | + +### 后端(决策:不拆分) + +2026-08-04 决策:**不拆分后端**。`docs/module-split.md` 仅作为后端模块架构说明 +(接口 / 模型 / 实现的组织方式),不作为拆分执行计划。后端改动直接在主分支常规流程推进。 + +## 3. 执行顺序与依赖 + +``` +01 → 02 → 03 → 04 → 05 → 06(history) → 07(media) → 08(generation) → 09(asset-library) +→ 12(auth-session) → publish-review → workflow-editor → projects → app-shell(收口) +``` + +- 01-06 已完成;07/08 文件就绪、待提交;09 待初始化。 +- 07/08 基于 `main`(7ee5a98)自包含,不依赖功能分支。 +- publish-review、workflow-editor 依赖 02-controller 与 01 entities,需在依赖合入后重放。 +- app-shell 最后拆,负责把全部模块收口进 `app/` 路由。 + +> **编号说明:** 实际序列与原规划不同——06 拆的是 history(原 08),07/08 为新增的 +> media-upload 与 generation-sse-adapter,原 06=publish-review、07=workflow-editor +> 顺延待拆。目录编号以 chaifen/ 实际为准,不再回填。 + +## 4. 每模块执行步骤 + +1. 在核心工作树确认模块文件完整(从核心源 `git add` 精确收集)。 +2. `git worktree add chaifen/0X- -b split/`,基于上一个已验证提交(或 01 的 `feat/workflow-run-core-clean`)。 +3. 新 worktree 基于基础提交展开(该提交已含模块骨架版本),从核心源逐文件复制模块的真实实现覆盖到对应路径(含 index.ts 入口与测试)。 +4. 验证:格式(oxlint/prettier)、TypeScript、单元测试、生产构建通过。 +5. 提交(conventional commits),更新 `_PR说明.md`(范围、验证结果、改动记录)。 +6. 需要时推送到贡献者分支开 PR(是否推送由用户确认)。 + +## 5. 验证标准(每模块) + +- [ ] 格式检查通过(允许路径文件) +- [ ] Lint 通过 +- [ ] TypeScript 通过 +- [ ] 单元测试通过 +- [ ] 生产构建通过 +- [ ] 范围检查:`upstream/main...HEAD` 差异只含白名单文件 + +## 6. 改动记录 + +### 2026-08-04 规划落盘 + +- 首次编写本规划:前端 05-11 拆分范围、后端 B 系列留待以后、执行顺序与验证标准。 +- 决策:只拆前端、逐模块推进、规划写入 docs/。 + +### 2026-08-04 决策:后端不拆分 + +- 明确**不拆分后端**:`docs/module-split.md` 仅作架构说明,后端改动走常规流程。 +- 更新范围决策与模块总表说明。 + +### 2026-08-04 状态同步(第二次检索) + +- 05/06 已完成:05 增补 `3cbfbc9` 契约提交并推送;06 推送 `d2105bc`(PR #105)。 +- 03 补齐第三个提交 `8f9abeb`(页面与路由集成,含 `app.tsx`/`layout` 改动),未推送。 + - ⚠️ 该提交越过了 playtest 边界进入 app-shell 文件;11 拆分时以主工作树完整版 + `app.tsx` 为准。 +- 02 已推送 `5ec5ba3`(rebase 后哈希变化),未开 PR。 +- 新增 07 media-upload(Refs #109)、08 generation-sse-adapter(Refs #78): + 文件就绪、验证通过、未提交;09 asset-library 仅建目录,worktree 待初始化。 +- 原规划编号作废:06=publish-review、07=workflow-editor、08=history;publish-review + 与 workflow-editor 顺延待拆(见「待拆(剩余)」)。 +- 清理中间产物:主工作树根目录 5 张截图、06 本地预览文件 + (`history-preview.html`/`preview.tsx`)、02/06 的 node_modules 与 dist。 +- 模块总表重写为实际状态;后续模块完成或推进时同步更新本表。 + +### 2026-08-06 新增 12-auth-session + +- 从主工作树拆分登录与认证会话模块到 `chaifen/12-auth-session/`。 +- 包含 `entities/user`(类型定义 + 后端 API 适配器)和 `features/auth-session`(Provider/hook/ProtectedRoute/本地开发适配器/session-storage + 完整测试)。 +- 依赖 `shared/api`(chaifen/10-shared-api-client)。 +- 更新模块总表与执行顺序。 diff --git a/docs/module-split.md b/docs/module-split.md index 3dd8d743..0b35a306 100644 --- a/docs/module-split.md +++ b/docs/module-split.md @@ -127,10 +127,10 @@ CharacterData ## 4. generation — AI 生成任务 -**接口:** `GenerationService` **传输:** SSE 推送任务状态 +**接口:** `GenerationService` **目标传输:** SSE 推送任务状态(当前实现仍为轮询) -职责:管理生成任务生命周期,按任务类型区分入参和出参。前端通过 SSE 订阅任务 -状态变更,无需轮询。 +职责:管理生成任务生命周期,按任务类型区分入参和出参。当前前端每 2 秒查询任务 +快照;目标是通过 SSE 订阅任务状态变化,接口见 `sse-generation-flow.md`。 **任务类型与出参对应关系:** @@ -158,7 +158,7 @@ CharacterData | `generate_character_action(input)` | 提交角色动作生成任务 | | `get_task(project_id, task_id)` | 查询任务状态与结果 | -**SSE 调用流程:** +**目标 SSE 调用流程:** 1. 前端 POST 提交任务,拿到 `task_id`。 2. 前端连接 `GET /generation/tasks/{task_id}/stream`,服务端在任务状态变化时 @@ -169,7 +169,8 @@ CharacterData > **与旧设计的差异:** 不再使用策略模式(`GenerationStrategy` / `register_strategy` / > `submit(payload)`),改为按任务类型拆分明确的接口方法。不再使用泛化出参 > `GenerationResult(urls, metadata)`,改为按任务类型细化出参 -> `CharacterImageOutput` / `CharacterActionOutput`。不再使用前端轮询,改为 SSE 推送。 +> `CharacterImageOutput` / `CharacterActionOutput`。SSE 尚未落地,当前前端轮询将在 +> stream 接口完成后替换。 --- diff --git a/docs/sse-generation-flow.md b/docs/sse-generation-flow.md new file mode 100644 index 00000000..969e9c71 --- /dev/null +++ b/docs/sse-generation-flow.md @@ -0,0 +1,242 @@ +# SSE 生成全流程与接口 + +## 1. 当前实现状态 + +当前生成任务已经具备异步任务模型,但**尚未实现 SSE 接口**: + +- 后端支持提交角色图、提交动作和查询任务快照。 +- 后台线程执行 AI 生成、上传图片并更新任务状态。 +- 前端 `GenerationApis.subscribe()` 每 2 秒调用一次查询接口,属于轮询。 +- `GET /generation/tasks/{task_id}/stream` 目前不存在。 + +因此,下文将“当前已经可用的接口”和“需要实现的 SSE 接口”分开描述。 + +## 2. 生成制作全流程 +-+-++++++++++++++++++++++++ +```mermaid +sequenceDiagram + participant UI as Quick Start / Workflow Editor + participant API as Generation API + participant DB as GenerationTask + participant Worker as Generation Executor + participant AI as AI Engine + participant Storage as Object Storage + participant SSE as SSE Stream + participant Character as Character API + participant Playtest as Playtest + + UI->>API: POST /generation/image 或 /generation/action + API->>DB: 创建 PENDING 任务 + API-->>UI: 返回 task_id 和任务快照 + API->>Worker: 启动后台生成 + UI->>SSE: 连接任务 stream + SSE-->>UI: 推送 PENDING 当前快照 + Worker->>DB: 状态改为 RUNNING + SSE-->>UI: 推送 RUNNING + Worker->>AI: 按项目视角、尺寸和画风生成 + AI-->>Worker: 返回角色图或动作帧 + Worker->>Storage: 上传生成图片 + Storage-->>Worker: 返回图片 URL + Worker->>DB: 写入结果并标记 COMPLETED + SSE-->>UI: 推送 COMPLETED 和 result + UI->>UI: 回填当前制作步骤并等待用户确认 + UI->>Character: 用户确认后写入角色、造型和动作 + Playtest->>Character: 读取已确认 Character +``` + +失败路径:Worker 捕获异常,将任务改为 `FAILED` 并写入 `error_message`;SSE 推送失败事件后关闭连接。断开 SSE 只停止接收消息,不取消后台任务。 + +## 3. 当前已经实现的接口 + +Base URL:`http://127.0.0.1:8000` + +### 3.1 提交角色图生成 + +`POST /generation/image` + +```json +{ + "user_id": 1, + "project_id": 37, + "reference_image_url": null, + "prompt": "侧视像素风守夜人", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 4 +} +``` + +后端创建 `character_image` 任务。输入宽高必须与项目精灵尺寸一致。 + +### 3.2 提交动作生成 + +`POST /generation/action` + +```json +{ + "user_id": 1, + "project_id": 37, + "character_id": 25, + "action_type": "custom", + "custom_prompt": "举起并挥动灯笼", + "reference_video_url": null, + "reference_image_urls": ["https://example.com/master.png"], + "num_frames": 32 +} +``` + +后端创建 `character_action` 任务。生成器读取项目视角、画风和精灵尺寸,逐帧上传后返回完整帧列表。 + +### 3.3 查询任务快照 + +`GET /generation/tasks/{task_id}?project_id={project_id}` + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 71, + "user_id": 1, + "project_id": 37, + "task_type": "character_action", + "status": "completed", + "input_payload": {}, + "result": { + "type": "character_action", + "action_type": "custom", + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame-0.png", + "duration_ms": 125 + } + ] + }, + "error_message": null + } +} +``` + +任务状态只有:`pending`、`running`、`completed`、`failed`。当前模型没有百分比 `progress` 字段。 + +## 4. 需要新增的 SSE 接口 + +### 4.1 订阅任务状态 + +`GET /generation/tasks/{task_id}/stream?project_id={project_id}` + +响应头: + +```http +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +X-Accel-Buffering: no +``` + +连接建立后,服务端必须立即推送任务当前快照,不能等待下一次状态变化。任务进入终态后推送最后一条消息并关闭连接。 + +### 4.2 任务事件 + +```text +event: task_update +id: 71:2 +retry: 2000 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"running","result":null,"error_message":null} + +``` + +完成事件: + +```text +event: task_update +id: 71:3 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"completed","result":{"type":"character_action","action_type":"custom","frames":[]},"error_message":null} + +``` + +失败事件: + +```text +event: task_update +id: 71:3 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"failed","result":null,"error_message":"母版下载失败"} + +``` + +事件字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | int | 生成任务 ID | +| `project_id` | int | 所属项目 ID | +| `task_type` | string | `character_image` 或 `character_action` | +| `status` | string | 四种任务状态之一 | +| `result` | object/null | 仅完成时存在 | +| `error_message` | string/null | 仅失败时存在 | + +### 4.3 心跳事件 + +服务端每 15 秒发送一次心跳,避免代理关闭空闲连接: + +```text +event: ping +data: {} + +``` + +心跳不进入业务状态机,前端可以忽略。 + +## 5. 后端处理规则 + +1. 建立流之前校验 `project_id + task_id`,不存在返回业务码 404。 +2. 建立连接后立即查询数据库并推送当前快照。 +3. 只在状态或结果变化时推送 `task_update`,不重复发送同一快照。 +4. `completed` 或 `failed` 推送后关闭连接。 +5. 客户端断开时释放监听资源,但不停止 GenerationTask。 +6. 客户端重连时不依赖内存中的旧连接,重新读取数据库最新状态。 +7. MVP 不添加虚假的百分比进度;若以后需要逐帧进度,应先扩展任务模型和事件表。 + +当前后台任务使用线程执行。单进程 MVP 可以使用任务通知队列唤醒 SSE;多进程部署时需要 Redis Pub/Sub、数据库通知或持久事件表,不能只依赖进程内 `asyncio.Queue`。 + +## 6. 前端接入规则 + +`GenerationApis` 的业务接口保持不变: + +```ts +interface GenerationApis { + create(input: GenerationInput): Promise + get(projectId: string, taskId: string): Promise + subscribe( + projectId: string, + taskId: string, + onEvent: (event: GenerationEvent) => void, + ): () => void +} +``` + +只替换 `subscribe()` 的传输实现: + +```ts +const source = new EventSource( + `/generation/tasks/${taskId}/stream?project_id=${encodeURIComponent(projectId)}`, +) + +source.addEventListener('task_update', (message) => { + onEvent(JSON.parse(message.data)) +}) + +return () => source.close() +``` + +Workflow Controller 继续消费统一的 `GenerationEvent`,不应知道底层使用 SSE 还是轮询。页面刷新后先调用 `get()` 读取任务最新快照;若仍是 `pending/running`,再恢复 SSE 订阅。 + +## 7. 模块责任边界 + +- `generation`:创建任务、执行生成、保存任务结果、推送 SSE。 +- `workflow-controller`:把任务结果写入当前前端制作步骤。 +- `character`:只在用户确认后保存最终角色和动作。 +- `playtest`:只读取已确认 Character 进行预览和核验,不订阅生成任务。 +- 当前不接历史记录,也不建设资产库流程。 diff --git a/docs/superpowers/plans/2026-08-05-home-auth-account.md b/docs/superpowers/plans/2026-08-05-home-auth-account.md new file mode 100644 index 00000000..7b7cab72 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-home-auth-account.md @@ -0,0 +1,206 @@ +# Home Authentication and Account Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add real email authentication, persistent sessions, protected product routes, and backend-supported account settings to the existing Windup homepage. + +**Architecture:** `entities/user` owns the exact backend contract; `features/auth-session` owns session state and token lifecycle; `pages/home` owns account UI; `app` performs composition and route protection. Refresh Token persists in localStorage while Access Token remains in memory and is exposed through the existing shared API token-provider boundary. + +**Tech Stack:** React 19, React Router 8, TypeScript 6, Vite 8, Tailwind CSS 4, Vitest, Testing Library. + +## Global Constraints + +- Implement only endpoints and fields present in `feat/user-module`. +- Do not add OAuth, avatar, nickname editing, email editing, account deletion, `application`, or `capabilities`. +- Keep the homepage public and protect all production product routes. +- Persist only Refresh Token under `windup.auth.refresh-token`; keep Access Token in memory. +- Preserve the existing homepage editorial grey-green visual language. + +--- + +### Task 1: User entity and real authentication adapter + +**Files:** +- Create: `frontend/src/entities/user/index.ts` +- Create: `frontend/src/entities/user/api.ts` +- Create: `frontend/src/entities/user/api.test.ts` +- Modify: `frontend/src/entities/index.ts` + +**Interfaces:** +- Consumes: `ApiClient` and `createApiClient` from `@/shared/api`. +- Produces: `User`, `AuthTokens`, `UserApis`, and `createUserApis(options?)`. + +- [ ] **Step 1: Write failing adapter tests** + +Cover literal request paths and bodies for `sendCode`, `register`, `login`, `loginByCode`, `refresh`, `logout`, `me`, and `changePassword`. Assert snake_case responses become camelCase: + +```ts +expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({ + accessToken: 'access', + refreshToken: 'refresh', + user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' }, +}) +``` + +- [ ] **Step 2: Run the entity test and verify RED** + +Run: `npm test -- --run src/entities/user/api.test.ts` + +Expected: FAIL because `createUserApis` does not exist. + +- [ ] **Step 3: Implement the exact backend contract** + +Use these public signatures: + +```ts +interface UserApis { + sendCode(input: { email: string; purpose: 'login' | 'register' | 'reset_password' }): Promise + register(input: { email: string; password: string; code: string; nickname?: string }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} +``` + +- [ ] **Step 4: Run the entity tests and verify GREEN** + +Run: `npm test -- --run src/entities/user/api.test.ts` + +Expected: PASS. + +### Task 2: Persistent authentication session + +**Files:** +- Create: `frontend/src/features/auth-session/session-storage.ts` +- Create: `frontend/src/features/auth-session/session-storage.test.ts` +- Create: `frontend/src/features/auth-session/index.tsx` +- Create: `frontend/src/features/auth-session/index.test.tsx` + +**Interfaces:** +- Consumes: `UserApis`, `AuthTokens`, `registerApiAccessTokenProvider`. +- Produces: `AuthSessionProvider`, `useAuthSession`, `ProtectedRoute`. + +- [ ] **Step 1: Write failing storage and provider tests** + +Test that only Refresh Token enters localStorage, bootstrap refreshes and fetches `/auth/me`, login stores the rotated Refresh Token, logout clears local state even when the backend rejects, and guests are redirected with a safe `returnTo`. + +- [ ] **Step 2: Run the session tests and verify RED** + +Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx` + +Expected: FAIL because the session module does not exist. + +- [ ] **Step 3: Implement session state and route protection** + +Use this state contract: + +```ts +type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null } + | { status: 'authenticated'; user: User } +``` + +Register a token getter during Provider lifetime. Decode only the JWT `exp` payload to schedule refresh 60 seconds early; token signature validation remains a backend responsibility. Reject `returnTo` values that do not start with `/` or start with `//`. + +- [ ] **Step 4: Run the session tests and verify GREEN** + +Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx` + +Expected: PASS. + +### Task 3: Homepage account interface + +**Files:** +- Create: `frontend/src/pages/home/account-panel.tsx` +- Create: `frontend/src/pages/home/account-panel.test.tsx` +- Modify: `frontend/src/pages/home/index.tsx` +- Modify: `frontend/src/pages/home/index.test.tsx` + +**Interfaces:** +- Consumes: `useAuthSession` methods and URL query parameters. +- Produces: login, register, read-only profile, password change, and logout UI. + +- [ ] **Step 1: Write failing interaction tests** + +Cover opening the account panel, sending a code with the correct purpose, code login, password login with code, registration, readonly profile fields, password change, logout, and the absence of nickname/email/avatar editing controls. + +- [ ] **Step 2: Run homepage tests and verify RED** + +Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx` + +Expected: FAIL because the account panel is missing. + +- [ ] **Step 3: Implement the homepage UI** + +Use a fixed backdrop and responsive panel aligned to the existing grey-green palette. Keep labels explicit, use native form controls, expose request errors with `role="alert"`, and keep submit buttons disabled during requests. Implement a 60-second send-code countdown without adding a timer dependency. + +- [ ] **Step 4: Run homepage tests and verify GREEN** + +Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx` + +Expected: PASS. + +### Task 4: App composition, account entry, and protected routes + +**Files:** +- Modify: `frontend/src/app/app.tsx` +- Modify: `frontend/src/app/app.test.tsx` +- Modify: `frontend/src/app/layout/app-header.tsx` +- Modify: `frontend/src/app/layout/app-header.test.tsx` + +**Interfaces:** +- Consumes: `createUserApis`, `AuthSessionProvider`, `ProtectedRoute`. +- Produces: one shared User API instance, public homepage, and protected product routes. + +- [ ] **Step 1: Write failing route and header tests** + +Assert that a guest can render `/`, a guest entering `/quick-start` reaches `/?account=login&returnTo=%2Fquick-start`, and the header account entry displays “登录 / 注册” or the authenticated nickname. + +- [ ] **Step 2: Run app tests and verify RED** + +Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx` + +Expected: FAIL because authentication is not composed. + +- [ ] **Step 3: Compose auth once and protect routes** + +Create `userApis` in the App composition root. Wrap `AppShell` and `Routes` in `AuthSessionProvider`. Keep `/` public and wrap every production product route (including quick start, projects, characters, asset library, history, workflow editor, and formal Playtest) in `ProtectedRoute`; keep the development Demo route public. + +- [ ] **Step 4: Run app tests and verify GREEN** + +Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx` + +Expected: PASS. + +### Task 5: Full verification + +**Files:** +- Modify only files required by failures in the authentication scope. + +**Interfaces:** +- Consumes: all tasks above. +- Produces: verified frontend authentication delivery. + +- [ ] **Step 1: Run full automated verification** + +Run: + +```powershell +npm test +npm run format:check +npm run lint +npm run typecheck +npm run build +``` + +Expected: every command exits 0. + +- [ ] **Step 2: Inspect the final diff** + +Run: `git diff --check -- frontend/src/entities/user frontend/src/features/auth-session frontend/src/pages/home frontend/src/app` + +Expected: no whitespace errors and no backend file changes. diff --git a/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md new file mode 100644 index 00000000..0740f9c3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md @@ -0,0 +1,157 @@ +# Uploaded Character Template Shortcut Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Quick Start and Workflow Editor upload a character image and proceed directly to 32-frame action generation, while preserving the existing text-only character-generation path. + +**Architecture:** One shared `MediaApis` instance is composed in App. The workflow state machine gains an explicit uploaded-template transition; Quick Start owns the existing character creation/action orchestration, and Workflow Editor delegates to that same use case instead of duplicating it. + +**Tech Stack:** React 19, TypeScript 6, Vitest, existing Workflow Controller and MediaApis. + +## Global Constraints + +- Uploaded image plus non-empty text generates a `custom` action using that text as the action prompt. +- Uploaded image plus blank text generates the default `idle` action. +- No uploaded image preserves the existing character-description, generated-candidate and confirmation flow. +- Uploaded images must never be represented as a successful backend image Generation task. +- Complete animation generation remains 32 frames. +- Add no dependency and no new business module. +- Preserve all unrelated dirty-worktree changes. + +--- + +### Task 1: Shared uploaded-template workflow transition and App composition + +**Files:** +- Modify: `frontend/src/features/workflow-controller/workflow-state.test.ts` +- Modify: `frontend/src/features/workflow-controller/workflow-state.ts` +- Modify: `frontend/src/features/workflow-controller/controller.ts` +- Modify: `frontend/src/app/app.tsx` +- Test: `frontend/src/app/app-composition.test.tsx` + +**Interfaces:** +- Produces: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl): WorkflowRun`. +- Produces: one `createMediaApis()` instance passed into Quick Start composition. + +- [ ] **Step 1: Write the failing state test** + +Assert that accepting `media-upload-1` on an initial create-character run yields statuses `passed, passed, passed, active, locked`, stores the reference on character setup, exposes it as the template/candidate output, keeps `generationStatus` as `not_started`, and leaves exactly one active step. + +- [ ] **Step 2: Run the state test and verify RED** + +Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts` + +Expected: FAIL because `acceptUploadedCharacterTemplateState` does not exist. + +- [ ] **Step 3: Implement the pure transition and controller method** + +Add the pure state function and save its result through the controller. Reject blank references, non-active runs, and any active step other than `character-setup`. Do not create a Generation input or task ID. + +- [ ] **Step 4: Add shared App composition** + +Create `const mediaApis = createMediaApis()` beside other entity adapters, pass it to Quick Start, and wire Workflow Editor's uploaded-template callback to the Quick Start service method defined in Task 2. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts src/app/app-composition.test.tsx` + +Expected: PASS. + +### Task 2: Quick Start upload button and direct-action use case + +**Files:** +- Modify: `frontend/src/pages/quick-start/service.test.ts` +- Modify: `frontend/src/pages/quick-start/service.ts` +- Modify: `frontend/src/pages/quick-start/index.test.tsx` +- Modify: `frontend/src/pages/quick-start/index.tsx` + +**Interfaces:** +- Consumes: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl)`. +- Produces: `QuickStartService.startWithUploadedTemplate(file, actionDescription, signal?)`. +- Produces: `QuickStartService.continueWithUploadedTemplate(runId, file, actionDescription, signal?)`, reused by Workflow Editor. + +- [ ] **Step 1: Write failing service tests** + +Cover non-empty text → `custom`, blank text → `idle`, `MediaApis.upload(file, 'reference-image', signal)`, no character-template Generation call, and upload failure before WorkflowRun creation/advance. + +- [ ] **Step 2: Write failing page tests** + +Assert an image button exists in the lower-right input actions, selecting an image allows blank-text submit, selected filename/removal are available, and removing the image restores the text-required rule. + +- [ ] **Step 3: Run Quick Start tests and verify RED** + +Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx` + +Expected: FAIL because the methods and upload UI do not exist. + +- [ ] **Step 4: Implement the minimal service behavior** + +Prepare a project using trimmed action text or the selected filename as the naming seed, upload through `MediaApis`, create the run only after upload succeeds, accept the uploaded template, then reuse the existing character creation/action-generation function. Existing `start(prompt)` stays unchanged. + +- [ ] **Step 5: Implement the minimal UI** + +Use one hidden `input[type=file][accept="image/*"]`, a visible lower-right button, filename/removal controls, conditional placeholder/help copy, submit guarding, and an AbortController for the in-flight upload. + +- [ ] **Step 6: Run Quick Start tests and verify GREEN** + +Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx` + +Expected: PASS. + +### Task 3: Workflow Editor uploaded-template shortcut + +**Files:** +- Modify: `frontend/src/pages/workflow-editor/service.ts` +- Modify: `frontend/src/pages/workflow-editor/index.test.tsx` +- Modify: `frontend/src/pages/workflow-editor/index.tsx` +- Modify: `frontend/src/pages/workflow-editor/workflow-canvas.tsx` +- Modify: `frontend/src/pages/workflow-editor/workflow-editor.css` + +**Interfaces:** +- Consumes: injected `continueWithUploadedTemplate(runId, file, actionDescription, signal?)` callback. +- Produces: `WorkflowEditorService.continueWithUploadedTemplate(...)` for the page. + +- [ ] **Step 1: Write failing page/service tests** + +Assert the active character-setup node can return `{ description, file }`; with a file the page calls the uploaded-template service once and does not call `nextStep`, while without a file it keeps `updateCharacterSetup` plus `nextStep`. + +- [ ] **Step 2: Run Workflow Editor tests and verify RED** + +Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx` + +Expected: FAIL because the file control and service method do not exist. + +- [ ] **Step 3: Implement event and service delegation** + +Render the image input in the existing character-setup node. Extend delegated submit handling to pass the selected `File`. Route file submissions to the injected uploaded-template use case and text-only submissions to the unchanged path. Abort the upload when the run view unmounts. + +- [ ] **Step 4: Run Workflow Editor tests and verify GREEN** + +Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx` + +Expected: PASS. + +### Task 4: Integration, regression and review + +**Files:** +- Modify only files required by concrete failures from the commands below. + +- [ ] **Step 1: Run the complete frontend suite** + +Run: `npm.cmd test` + +Expected: all tests pass. + +- [ ] **Step 2: Run production checks** + +Run: `npm.cmd run build`, `npm.cmd run lint`, `npm.cmd run format:check`, and repository `git diff --check`. + +Expected: all commands pass. + +- [ ] **Step 3: Review cross-entry consistency** + +Verify both entries share the same `MediaApis`, controller transition and character/action orchestration; verify text-only flows and historical runs remain unchanged. + +- [ ] **Step 4: Request final code review** + +Review for workflow-state validity, duplicate submission, abort behavior, error recovery, and accidental fake Generation results. Resolve all Critical and Important findings before completion. diff --git a/docs/superpowers/specs/2026-08-05-home-auth-account-design.md b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md new file mode 100644 index 00000000..8ee02371 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md @@ -0,0 +1,86 @@ +# 首页登录与个人设置设计 + +## 目标 + +在现有 Windup 首页内完成真实邮箱认证和个人设置入口,严格对接 `xiaocheny214/DireSoul` 的 `feat/user-module` 后端,不伪造昵称编辑、头像、邮箱修改等后端不存在的能力。 + +## 已确认的产品边界 + +- 首页允许游客浏览。 +- 快速开始、工作流、项目、历史和正式 Playtest 路由需要登录。 +- 游客进入受保护路由时回到首页并打开登录界面;登录成功后恢复原目标。 +- 登录状态在浏览器关闭后继续保留,最长使用后端 7 天 Refresh Token 生命周期。 +- 个人资料只读展示邮箱、昵称、邮箱验证状态和账户状态。 +- 个人设置只允许修改密码与退出登录。 +- 不实现 OAuth、头像、昵称修改、邮箱修改或账号删除。 + +## 后端契约 + +接口来源为 `feat/user-module`: + +| 方法 | 路径 | 请求 | +| --- | --- | --- | +| POST | `/auth/send-code` | `{ email, purpose: 'login' \| 'register' \| 'reset_password' }` | +| POST | `/auth/register` | `{ email, password, code, nickname? }` | +| POST | `/auth/login` | `{ email, password, code }` | +| POST | `/auth/login-by-code` | `{ email, code }` | +| POST | `/auth/refresh` | `{ refresh_token }` | +| POST | `/auth/logout` | `{ refresh_token }` | +| GET | `/auth/me` | Bearer Access Token | +| POST | `/auth/change-password` | `{ old_password, new_password }` | + +登录、注册和刷新返回 `{ access_token, refresh_token, user }`。Access Token 生命周期为 15 分钟,Refresh Token 生命周期为 7 天。 + +## 前端结构 + +保持现有 `app → pages → features → entities → shared`: + +- `entities/user`:用户类型、认证 DTO 转换和八个真实 HTTP 方法。 +- `features/auth-session`:唯一认证状态、Refresh Token 持久化、Access Token 内存保存、自动刷新和路由保护。 +- `pages/home/account-panel.tsx`:首页登录、注册和个人设置界面。 +- `app`:装配 User API、认证 Provider、受保护路由和顶栏账户入口。 + +不增加 `application` 或 `capabilities`。 + +## Token 生命周期 + +Refresh Token 保存到 `localStorage` 的 `windup.auth.refresh-token`。Access Token 只保存在 React 内存状态,通过现有 `registerApiAccessTokenProvider` 注入全部业务请求。 + +页面启动时: + +1. 没有 Refresh Token,进入游客状态。 +2. 有 Refresh Token,调用 `/auth/refresh` 轮换两个 Token。 +3. 使用新 Access Token 调用 `/auth/me`,取得完整用户资料。 +4. 任何刷新失败都清除本地 Refresh Token并进入游客状态。 + +登录成功后根据 JWT `exp` 在到期前 60 秒自动刷新。浏览器从后台恢复时重新检查有效期。退出登录先调用后端;即使网络失败也清除本地会话,避免界面继续显示已登录。 + +## 首页交互 + +首页维持现有灰绿、墨黑、纸张质感的编辑式视觉。右上角增加账户按钮:游客显示“登录 / 注册”,登录后显示昵称,昵称为空时显示邮箱前缀。 + +账户界面覆盖在首页之上: + +- 登录包含“验证码登录”和“密码登录”两个模式;后端要求密码登录也提交验证码。 +- 注册包含邮箱、验证码、密码和可选昵称。 +- 发码按钮带 60 秒倒计时,避免重复请求。 +- 个人设置显示账户资料、修改密码表单和退出登录按钮。 +- 所有请求错误使用后端统一错误消息,不把失败解释成成功。 + +## 路由保护 + +首页 `/` 和开发环境 `/playtest/demo` 保持公开。其余产品路由使用同一个 `ProtectedRoute`。游客访问时跳转到: + +`/?account=login&returnTo=<原 pathname + search>` + +登录成功只允许恢复以 `/` 开头的站内地址;非法或缺失目标回到首页。 + +## 验收 + +- User API 的八个方法严格匹配后端路径、字段和响应。 +- Refresh Token 可跨浏览器重启恢复会话,Access Token 不写入持久存储。 +- 注册、两种登录、刷新、资料读取、改密和退出都有错误状态。 +- 游客可以浏览首页,但不能进入受保护页面。 +- 登录成功恢复原目标。 +- 个人设置不存在后端未提供的编辑功能。 +- 全量测试、格式、Lint、类型检查和生产构建通过。 diff --git a/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md new file mode 100644 index 00000000..08f03e15 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md @@ -0,0 +1,58 @@ +# 上传角色母版直达动作生成设计 + +## 目标 + +Quick Start 与 Workflow Editor 都允许用户在首次输入时选择一张角色图片。选择图片后,前端将其上传为角色母版,跳过角色图片生成和候选选择,直接创建角色并生成 32 帧动作资产。未选择图片时,原有角色图片生成流程保持不变。 + +## 用户交互 + +### Quick Start + +- 在输入区域右下角增加图片上传按钮,只接受 `image/*`。 +- 选择图片后显示文件名并允许替换或移除;文件在用户提交时上传。 +- 有图片时,输入框文字解释为动作描述:非空生成 `custom` 动作,空白生成默认 `idle` 动作。 +- 没有图片时,输入框仍是角色描述并继续现有候选图流程,因此必须填写文字。 + +### Workflow Editor + +- 在 `character-setup` 节点增加图片选择入口。 +- 有图片时,该节点中的文字解释为动作描述,并在一次提交后直接进入动作生成。 +- 没有图片时,文字仍是角色描述,继续角色图生成和候选确认。 + +## 架构与数据流 + +1. App 只创建一个现有 `MediaApis` 实例,并通过页面 Service 注入,页面不直接拼 HTTP 请求。 +2. 提交图片时调用现有 `MediaApis.upload(file, 'reference-image', signal)`,得到不透明的 `MediaReference`。 +3. Workflow Controller 增加明确的“采用已上传角色母版”状态转换: + - `character-setup` 标记为 `passed`,记录上传媒体引用; + - `character-template` 标记为 `passed`,输出该上传图片; + - `template-candidate` 标记为 `passed`,记录该图片已被选定; + - `action-generation` 标记为 `active`。 +4. 该转换不创建 Generation 图片任务,也不伪造后端生成成功。 +5. Quick Start 复用现有角色创建与动作生成编排。Workflow Editor 委托同一用例,不维护第二套角色或动作生成逻辑。 +6. 动作输入使用上传图片作为 `firstFrameUrl` 和参考媒体;文字非空时使用 `custom`,空白时使用 `idle`。 +7. 动作完成后继续复用现有审核、角色更新、Playtest 发布和历史恢复流程。 + +## 一致性与失败处理 + +- 图片类型由前端提前检查,后端仍做最终校验。 +- Quick Start 先创建项目,再上传图片;上传失败时不创建 WorkflowRun,也不开始生成。 +- Workflow Editor 上传失败时保持 `character-setup` 为 `active`,用户可以重试。 +- 重复点击提交时只允许一项在途上传;页面离开时中止仍在途请求。 +- 上传成功但动作任务提交失败时,WorkflowRun 按现有动作生成失败规则记录错误,不静默回退到角色图生成。 +- 历史 16 帧或无上传图片的 WorkflowRun 仍按原契约恢复。 + +## 测试范围 + +- Quick Start 页面:右下角上传按钮、选图后允许空文字提交、移除图片后恢复文字必填。 +- Quick Start Service:上传后不调用角色图片 Generation,前三步合法通过,动作生成接收上传引用和动作描述;空文字生成 `idle`。 +- Workflow Editor 页面:有图时一次提交直达上传用例,无图时仍调用原 `nextStep`。 +- Workflow 状态:上传快捷转换保持固定五节点顺序,恰好一个 active 步骤,并可被本地存储恢复。 +- 失败:上传失败不推进 WorkflowRun;动作提交失败沿用现有失败状态。 +- App 装配:两个入口共享同一个真实 `MediaApis`,没有页面级假上传实现。 + +## 非目标 + +- 不增加新的媒体模块、后端上传接口或图片生成接口。 +- 不修改 Playtest、导出格式、Cocos 或微信小程序导入。 +- 不为上传图片增加裁剪、抠图或编辑器。 diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 50f46bc6..0c326e65 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写为只提交模块边界与接口;实现按模块拆成后续 PR 陆续落地,首页是第一个。 +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 --- @@ -41,8 +41,6 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 - ### 依赖规则 1. 只能向下依赖,不允许反向。 @@ -58,6 +56,7 @@ pages -> features -> entities -> shared ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 @@ -70,7 +69,8 @@ ProjectApis CharacterApis ActionTemplateApis GenerationApis `features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 -Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 步骤顺序固定八步: @@ -78,23 +78,35 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 + +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 -从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 -快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 --- -## 5. 尚未包含 +## 5. 当前实现范围 + +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 -- 真实请求与数据获取,`XxxApis` 目前只有接口 -- 首页之外的页面实现,其余七个路由仍是占位外壳 -- 图片上传模块(体量太小,不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +### 恢复边界 -首页已按本文的分层落地:它不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏那三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,写死在页面文案里,改流程时要一并改。 +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 --- diff --git a/frontend/package.json b/frontend/package.json index 0a845f78..5c368ad6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "tsc -b --pretty false && vite", "build": "tsc -b && vite build", "lint": "oxlint", "format": "oxfmt", @@ -25,6 +25,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", diff --git a/frontend/src/app/api-contract.test.ts b/frontend/src/app/api-contract.test.ts new file mode 100644 index 00000000..30bbceff --- /dev/null +++ b/frontend/src/app/api-contract.test.ts @@ -0,0 +1,94 @@ +/** + * 前后端契约测试:用真实后端响应快照(__fixtures__/)验证 adapter 解析。 + * + * 样本取自本地后端真实响应(character 25 / task 70 / project 列表)。 + * 后端 DTO 形状一旦变化,这里立刻暴露 —— 不再依赖手工联调发现。 + */ +import { describe, expect, it, vi } from 'vitest' + +import character25 from './__fixtures__/character-25.json' +import characterList from './__fixtures__/character-list.json' +import task71 from './__fixtures__/task-71.json' +import projectList from './__fixtures__/project-list.json' + +/** 信封解包(与 http-client 相同语义:返回 data 字段) */ +function unwrap(envelope: { data: T }): T { + return envelope.data +} + +vi.mock('@/shared/api', () => ({ + get: vi.fn(async (path: string) => { + if (path.startsWith('/characters?project_id')) return unwrap(characterList) + if (path.startsWith('/characters/')) return unwrap(character25) + if (path.startsWith('/generation/tasks/')) return unwrap(task71) + if (path.startsWith('/projects')) return unwrap(projectList) + throw new Error(`未收录的契约样本路径:${path}`) + }), + getPage: vi.fn(async (path: string) => { + const envelope = path.startsWith('/characters?project_id') ? characterList : projectList + return { + items: envelope.data, + total: envelope.total, + page: envelope.page, + pageSize: envelope.page_size, + } + }), + post: vi.fn(), + patch: vi.fn(), +})) + +import { createCharacterApis, createGenerationApis, createProjectApis } from '@/entities' + +describe('adapter contract (real backend snapshots)', () => { + it('character.get parses outfit, action and frames from real payload', async () => { + const apis = createCharacterApis() + const character = await apis.get('25') + + expect(character.id).toBe('25') + expect(character.projectId).toBe('37') + expect(character.outfits).toHaveLength(1) + + const outfit = character.outfits[0]! + expect(outfit.id).toBe('outfit-25-default') + expect(outfit.name).toBe('默认造型') + expect(outfit.characterTemplateUrl).toContain('reference-image') + + const action = outfit.actions[0]! + expect(action.id).toBe('25-custom') + expect(action.type).toBe('custom') + expect(action.name).toBe('自定义动作') + expect(action.frames.length).toBeGreaterThan(5) + expect(action.frames[0]!.imageUrl).toContain('action-frame') + expect(action.frames[0]!.durationMs).toBeTypeOf('number') + }) + + it('character.listByProject returns an array directly (envelope already unwrapped)', async () => { + const apis = createCharacterApis() + const characters = await apis.listByProject('37') + + expect(Array.isArray(characters)).toBe(true) + expect(characters.length).toBeGreaterThan(0) + expect(characters[0]!.outfits[0]!.id).toBe('outfit-25-default') + }) + + it('generation.get maps the backend task endpoint into one entity', async () => { + const apis = createGenerationApis() + const generation = await apis.get('37', '71') + + expect(generation.id).toBe('71') + expect(generation.projectId).toBe('37') + expect(generation.status).toBe('completed') + expect(generation.type).toBe('complete_animation') + }) + + it('project.list returns paged projects with sprite size', async () => { + const apis = createProjectApis() + const paged = await apis.list() + + expect(paged.items.length).toBeGreaterThan(0) + const project = paged.items[0]! + expect(project.spriteSize.width).toBeGreaterThan(0) + expect(project.spriteSize.height).toBeGreaterThan(0) + expect(project.id).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app-composition.test.tsx b/frontend/src/app/app-composition.test.tsx new file mode 100644 index 00000000..154d9382 --- /dev/null +++ b/frontend/src/app/app-composition.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const characterApisFactory = vi.hoisted(() => + vi.fn(() => ({ + get: vi.fn(() => new Promise(() => undefined)), + listByProject: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + })), +) +const projectApisFactory = vi.hoisted(() => + vi.fn(() => ({ + get: vi.fn(() => new Promise(() => undefined)), + list: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 }), + create: vi.fn(), + })), +) +const userApisFactory = vi.hoisted(() => vi.fn()) +const mediaApisFactory = vi.hoisted(() => + vi.fn(() => ({ + upload: vi.fn(), + })), +) + +vi.mock('@/entities', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCharacterApis: characterApisFactory, + createProjectApis: projectApisFactory, + createMediaApis: mediaApisFactory, + createUserApis: userApisFactory, + } +}) + +import { App } from './app' + +afterEach(() => { + cleanup() + window.history.replaceState({}, '', '/') + vi.clearAllMocks() + vi.unstubAllEnvs() +}) + +describe('App Playtest composition', () => { + it('uses local authentication by default without creating the backend auth adapter', async () => { + window.history.replaceState({}, '', '/') + + render() + + expect(userApisFactory).not.toHaveBeenCalled() + expect(await screen.findByRole('button', { name: '登录 / 注册' })).toBeTruthy() + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('让你的角色,真正登场。') + }) + + it('opens the account panel for local authentication', async () => { + window.history.replaceState({}, '', '/?account=login&returnTo=%2Fprojects') + + render() + + expect(await screen.findByRole('dialog', { name: '账户认证' })).toBeTruthy() + }) + + it('creates one shared Character and Project API instance for Playtest routes', () => { + window.history.replaceState({}, '', '/playtest/25/outfit-25-default') + + render() + + expect(characterApisFactory).toHaveBeenCalledTimes(1) + expect(projectApisFactory).toHaveBeenCalledTimes(1) + }) + + it('creates one shared Media API instance for both character-creation entries', () => { + render() + + expect(mediaApisFactory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx new file mode 100644 index 00000000..a92f267d --- /dev/null +++ b/frontend/src/app/app.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { App } from './app' + +const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal' as const, +} + +const userApis = { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + login: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + loginByCode: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + refresh: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), +} + +vi.mock('@/entities', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createUserApis: vi.fn(() => userApis) } +}) + +afterEach(() => { + cleanup() + window.localStorage.clear() + window.history.replaceState({}, '', '/') + vi.unstubAllEnvs() +}) + +function authenticate() { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + window.localStorage.setItem('windup.auth.refresh-token', 'refresh') +} + +describe('App', () => { + it('provides the authentication session required by the home page', async () => { + window.history.replaceState({}, '', '/') + + render() + + expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe( + '让你的角色,真正登场。', + ) + }) + + it('allows a guest to render the home page', async () => { + render() + + expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe( + '让你的角色,真正登场。', + ) + }) + + it('redirects a guest quick-start visit to login', async () => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + window.history.replaceState({}, '', '/quick-start') + + render() + + await screen.findByRole('dialog', { name: '账户认证' }) + expect(window.location.search).toBe('?account=login&returnTo=%2Fquick-start') + }) + + it('keeps the new-project route ahead of the dynamic project detail route', async () => { + window.history.replaceState({}, '', '/projects/new') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: '新建项目' })).toBeTruthy() + }) + + it('将项目完成版本的入口路由到历史记录', async () => { + window.history.replaceState({}, '', '/projects/project-1/history') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: '历史记录' })).toBeTruthy() + }) + + it('keeps the asset library separate from workflow history', async () => { + window.history.replaceState({}, '', '/projects/project-1/assets') + authenticate() + + render() + + expect(await screen.findByText('正在读取项目…')).toBeTruthy() + expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull() + }) + + it('provides a dedicated Playtest entry', async () => { + window.history.replaceState({}, '', '/playtest') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 06268ead..3e3904a4 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,37 +1,244 @@ -import { BrowserRouter, Route, Routes } from 'react-router' +import { lazy, Suspense, useMemo } from 'react' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router' +import { + createCharacterApis, + createGenerationApis, + createMediaApis, + createPlaytestInspectionApis, + createProjectApis, + createUserApis, + createWorkflowRunStore, + type UserApis, +} from '@/entities' +import { + AuthModeProvider, + ProtectedRoute, + createLocalUserApis, + resolveAuthMode, +} from '@/features/auth-session' +import { createWorkflowController } from '@/features/workflow-controller' import { AssetLibraryPage } from '@/pages/asset-library' +import { CharacterDetailPage } from '@/pages/character-detail' import { HomePage } from '@/pages/home' +import { HistoryPage } from '@/pages/history' import { NotFoundPage } from '@/pages/not-found' +import { PlaytestEntryPage } from '@/pages/playtest/entry' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectCreatePage } from '@/pages/projects/create-page' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' -import { AppShellRoute } from './layout' +import { AppShell } from './layout' +import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick-start/service' +import { createWorkflowEditorService } from '@/pages/workflow-editor/service' + +const PlaytestDemoPage = import.meta.env.DEV + ? lazy(() => + import('@/pages/playtest/demo-page').then(({ PlaytestDemoPage }) => ({ + default: PlaytestDemoPage, + })), + ) + : null /** * 路由表与全局外壳。 - * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 - * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。 + * App 只装配一次共享接口实例,再把页面所需的最小接口集合传入对应路由。 */ export function App() { return ( - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ) } + +/** 路由声明独立导出,测试可在 MemoryRouter 中验证直达地址。 */ +export function AppRoutes({ userApis }: { userApis?: UserApis } = {}) { + const authMode = resolveAuthMode() + const services = useMemo(() => { + const sharedUserApis = + userApis ?? (authMode === 'backend' ? createUserApis() : createLocalUserApis()) + const projectApis = createProjectApis() + const characterApis = createCharacterApis() + const inspectionApis = createPlaytestInspectionApis() + const generationApis = createGenerationApis() + const mediaApis = createMediaApis() + const store = createWorkflowRunStore() + const controller = createWorkflowController({ store, generationApis, characterApis }) + const quickStart = createQuickStartService({ + controller, + prepareProject: createAutoPrepareProject(projectApis), + characterApis, + mediaApis, + }) + const workflowEditor = createWorkflowEditorService({ + controller, + mediaApis, + getProject: (projectId) => projectApis.get(projectId), + prepareProject: async (input) => { + const project = await projectApis.create({ + name: input.projectName, + perspective: + input.view === 'topdown' + ? 'top-down' + : input.view === 'isometric' + ? 'isometric' + : 'side', + directionalMovement: + input.directions === '8' + ? 'eight-way' + : input.directions === '4' + ? 'four-way' + : 'single', + spriteSize: { width: Number(input.canvasSize), height: Number(input.canvasSize) }, + gameStyle: input.style || null, + }) + return { id: project.id, spriteSize: project.spriteSize } + }, + }) + const playtestApis = { + projects: projectApis, + characters: characterApis, + inspections: inspectionApis, + } + return { + userApis: sharedUserApis, + projectApis, + characterApis, + quickStart, + workflowEditor, + store, + playtestApis, + } + }, [authMode, userApis]) + + return ( + // 本地与真实认证共用会话和页面,差异只留在这里的适配器装配。 + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + } /> + } /> + } + /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {PlaytestDemoPage ? ( + + + + } + /> + ) : null} + + + + } + /> + + + + } + /> + } /> + + + + ) +} diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts index f1c81c7f..c4279fdb 100644 --- a/frontend/src/app/index.ts +++ b/frontend/src/app/index.ts @@ -1 +1 @@ -export { App } from './app' +export { App, AppRoutes } from './app' diff --git a/frontend/src/app/layout/index.test.tsx b/frontend/src/app/layout/index.test.tsx new file mode 100644 index 00000000..468c7a37 --- /dev/null +++ b/frontend/src/app/layout/index.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import type { AuthSessionValue } from '@/features/auth-session' +import { useAuthSession } from '@/features/auth-session' +import { HomePage } from '@/pages/home' +import { AppShell } from './index' + +vi.mock('@/features/auth-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthSession: vi.fn() } +}) + +const mockedUseAuthSession = vi.mocked(useAuthSession) + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal' as const, + } + const tokens = { accessToken: 'access', refreshToken: 'refresh', user } + mockedUseAuthSession.mockReset() + mockedUseAuthSession.mockReturnValue({ + state: { status: 'guest', user: null }, + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens), + login: vi.fn(async () => tokens), + loginByCode: vi.fn(async () => tokens), + changePassword: vi.fn(async () => undefined), + logout: vi.fn(async () => undefined), + } satisfies AuthSessionValue) +}) + +describe('AppShell', () => { + it.each([ + ['/', '首页'], + ['/workflow-editor/run-1', 'Workflow Editor'], + ])('为%s 使用全宽页面容器', (pathname) => { + render( + + +
页面内容
+
+
, + ) + + expect(screen.getByRole('main').className).toContain('w-full') + expect(screen.getByRole('main').className).not.toContain('max-w-5xl') + }) + + it.each(['/playtest', '/playtest/demo', '/playtest/character-1/outfit-1'])( + '在独立 Playtest 工作台 %s 中保留返回入口与产品导航', + (pathname) => { + render( + + +
Playtest 工作台
+
+
, + ) + + expect(screen.getByRole('banner')).toBeTruthy() + expect(screen.getByRole('button', { name: '返回上一页' })).toBeTruthy() + expect(screen.getByRole('link', { name: '首页' }).getAttribute('href')).toBe('/') + expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: '创作' }).getAttribute('href')).toBe('/quick-start') + expect(screen.getAllByRole('main')).toHaveLength(1) + }, + ) + + it('首页账户面板将 Header 与页面统一隔离,同时让 portal dialog 留在背景之外', () => { + render( + + + + + , + ) + + const dialog = screen.getByRole('dialog', { name: '账户认证' }) + const header = screen + .getByRole('link', { name: '返回 Windup 首页', hidden: true }) + .closest('header')! + const background = header.parentElement! + const homeHeading = screen.getByRole('heading', { name: /真正登场/, hidden: true }) + + expect(background.contains(homeHeading)).toBe(true) + expect(background.getAttribute('inert')).toBe('') + expect(background.getAttribute('aria-hidden')).toBe('true') + expect(dialog.parentElement?.parentElement).toBe(document.body) + expect(dialog.closest('[inert]')).toBeNull() + + fireEvent.click(within(dialog).getByRole('button', { name: '关闭账户面板' })) + + expect(screen.queryByRole('dialog')).toBeNull() + const restoredHeader = screen.getByRole('link', { name: '返回 Windup 首页' }).closest('header')! + const restoredBackground = restoredHeader.parentElement! + expect(restoredBackground.getAttribute('inert')).toBeNull() + expect(restoredBackground.getAttribute('aria-hidden')).toBeNull() + }) +}) diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index b797b339..d2c9c520 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' -import { Link, Outlet } from 'react-router' +import { useLocation } from 'react-router' + +import { AppHeader } from './app-header' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -10,36 +12,33 @@ export interface AppShellProps { /** 全站外壳,全局导航常驻。 */ export function AppShell({ children }: AppShellProps) { - return ( -
- - {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */} -
{children}
-
- ) -} + const { pathname, search } = useLocation() + const isPlaytestWorkspace = pathname.startsWith('/playtest') + const isWorkflowWorkspace = pathname.startsWith('/workflow-editor') + const isProjectWorkspace = + /^\/projects\/[^/]+(?:\/|$)/u.test(pathname) && pathname !== '/projects/new' + const isHomePage = pathname === '/' + const isHomeAccountOpen = isHomePage && new URLSearchParams(search).has('account') + const pageClassName = + isPlaytestWorkspace || isWorkflowWorkspace || isProjectWorkspace + ? 'w-full px-0 pb-0 pt-0' + : isHomePage + ? 'w-full' + : 'mx-auto max-w-5xl px-6 pb-8 pt-24' -/** - * 外壳的路由形态,套在一组子路由外面。 - * 哪些页面带外壳是路由决策,写在 app 的路由表里;外壳自身不读 pathname、不判断自己该不该出现。 - */ -export function AppShellRoute() { return ( - - - +
+ {/* Playtest 保留产品导航;workflow-editor 和项目详情使用各自的工作台导航。 */} + {!isWorkflowWorkspace && !isProjectWorkspace && } + {isPlaytestWorkspace ? ( +
{children}
+ ) : ( +
{children}
+ )} +
) } diff --git a/frontend/src/entities/character/api.test.ts b/frontend/src/entities/character/api.test.ts new file mode 100644 index 00000000..a507e302 --- /dev/null +++ b/frontend/src/entities/character/api.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('character API adapter', () => { + it('preserves action playback metadata when saving the complete character tree', async () => { + const backendCharacter = { + id: 25, + project_id: 3, + description: null, + reference_image_url: null, + status: 1, + character_data: { + version: 1, + outfits: [ + { + id: 'outfit-default', + name: 'Default', + description: null, + preview_url: null, + actions: [ + { + id: 'idle', + type: 'idle', + name: 'Idle', + loop: true, + fps: 8, + frame_count: 1, + frames: [ + { index: 0, image_url: '/idle-0.png', duration_ms: 125, root_motion: null }, + ], + }, + ], + }, + ], + }, + } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + vi.stubGlobal('fetch', fetchMock) + + const apis = createCharacterApis() + const character = await apis.get('25') + await apis.update(character) + + expect(character.outfits[0]?.actions[0]?.loop).toBe(true) + expect(character.outfits[0]?.actions[0]?.expectedFrameCount).toBe(1) + const updateRequest = fetchMock.mock.calls[1]?.[1] as RequestInit + const updateBody = JSON.parse(String(updateRequest.body)) as { + character_data: { + outfits: Array<{ actions: Array<{ loop: boolean; frame_count: number }> }> + } + } + expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true) + expect(updateBody.character_data.outfits[0]?.actions[0]?.frame_count).toBe(1) + }) + + it('loads every character page for a project instead of truncating after 100 items', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + listResponse( + Array.from({ length: 100 }, (_, index) => character(index + 1)), + 101, + 1, + 100, + ), + ) + .mockResolvedValueOnce(listResponse([character(101)], 101, 2, 100)) + vi.stubGlobal('fetch', fetchMock) + + const result = await createCharacterApis().listByProject('3') + + expect(result).toHaveLength(101) + expect(result[0]?.id).toBe('1') + expect(result[100]?.id).toBe('101') + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://127.0.0.1:8000/characters?project_id=3&page=1&page_size=100', + expect.any(Object), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://127.0.0.1:8000/characters?project_id=3&page=2&page_size=100', + expect.any(Object), + ) + }) +}) + +function character(id: number) { + return { + id, + project_id: 3, + description: null, + reference_image_url: null, + status: 1, + character_data: { version: 1, outfits: [] }, + } +} + +function listResponse(data: unknown[], total: number, page: number, pageSize: number) { + return new Response( + JSON.stringify({ code: 200, message: 'success', data, total, page, page_size: pageSize }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) +} + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts new file mode 100644 index 00000000..d3159ebe --- /dev/null +++ b/frontend/src/entities/character/api.ts @@ -0,0 +1,202 @@ +import type { + Action, + ActionType, + Character, + CharacterApis, + CreateCharacterInput, + Frame, + Outfit, +} from '.' + +import { del, get, getPage, patch, post } from '@/shared/api' +import type { Paged, PageQuery } from '@/shared/pagination' + +/* ─── 后端 DTO ─── */ + +interface BackendFrame { + index: number + image_url: string + duration_ms: number | null + root_motion?: { dx: number; dy: number } | null +} + +interface BackendAction { + id: string + type: string + name: string + loop: boolean + fps: number + frame_count: number + frames: BackendFrame[] +} + +interface BackendOutfit { + id: string + name: string + description: string | null + preview_url: string | null + actions: BackendAction[] +} + +interface BackendCharacterData { + version: number + outfits: BackendOutfit[] +} + +interface BackendCharacter { + id: number + project_id: number + name?: string | null + description: string | null + reference_image_url: string | null + character_data: BackendCharacterData + status: number + create_at?: string + update_at?: string +} + +/* ─── 映射 ─── */ + +const ACTION_TYPE_SET = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + +function toActionType(raw: string): ActionType { + return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom' +} + +function toFrame(raw: BackendFrame): Frame { + return { + imageUrl: raw.image_url, + durationMs: raw.duration_ms, + rootMotion: raw.root_motion ?? null, + } +} + +function toAction(raw: BackendAction, outfitId: string): Action { + return { + id: raw.id, + outfitId, + name: raw.name, + expectedFrameCount: raw.frame_count, + loop: raw.loop, + kind: 'custom', // 后端不区分 preset/custom + type: toActionType(raw.type), + fps: raw.fps, + keyFrameIndex: null, // 后端不提供关键帧索引 + frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame), + } +} + +function toOutfit(raw: BackendOutfit, characterId: string): Outfit { + return { + id: raw.id, + characterId, + name: raw.name, + description: raw.description, + candidateCharacterTemplates: [], // 后端 character_data 不含候选 + characterTemplateUrl: raw.preview_url, + baseFrames: [], + actions: raw.actions.map((a) => toAction(a, raw.id)), + } +} + +function toCharacter(raw: BackendCharacter): Character { + const id = String(raw.id) + return { + id, + projectId: String(raw.project_id), + name: raw.name ?? null, + description: raw.description, + referenceImageUrl: raw.reference_image_url, + dataVersion: raw.character_data?.version ?? 1, + status: raw.status, + createdAt: raw.create_at ?? '', + updatedAt: raw.update_at ?? '', + outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)), + } +} + +/* ─── 适配器 ─── */ + +export function createCharacterApis(): CharacterApis { + async function listPageByProject( + projectId: string, + query: PageQuery = {}, + ): Promise> { + const params = new URLSearchParams({ project_id: projectId }) + if (query.page) params.set('page', String(query.page)) + if (query.pageSize) params.set('page_size', String(query.pageSize)) + const page = await getPage(`/characters?${params}`) + return { ...page, items: page.items.map(toCharacter) } + } + + return { + async get(id: string): Promise { + const raw = await get(`/characters/${id}`) + return toCharacter(raw) + }, + + async listByProject(projectId: string): Promise { + const items: Character[] = [] + let pageNumber = 1 + for (;;) { + const page = await listPageByProject(projectId, { page: pageNumber, pageSize: 100 }) + items.push(...page.items) + if (items.length >= page.total || page.items.length === 0) break + pageNumber += 1 + } + return items + }, + + listPageByProject, + + async create(input: CreateCharacterInput): Promise { + const raw = await post('/characters', { + project_id: Number(input.projectId), + name: input.name ?? null, + description: input.description, + reference_image_url: input.referenceImageUrl ?? null, + }) + return toCharacter(raw) + }, + + async update(character: Character): Promise { + const payload = { + name: character.name ?? null, + description: character.description ?? null, + reference_image_url: character.referenceImageUrl ?? null, + character_data: { + version: character.dataVersion ?? 1, + outfits: character.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: outfit.description ?? null, + preview_url: outfit.characterTemplateUrl, + actions: outfit.actions.map((action) => ({ + id: action.id, + type: action.type, + name: action.name, + loop: action.loop ?? false, + fps: action.fps, + frame_count: action.expectedFrameCount ?? action.frames.length, + frames: action.frames.map((frame, index) => ({ + index, + image_url: frame.imageUrl, + duration_ms: frame.durationMs, + })), + })), + })), + }, + } + const raw = await patch(`/characters/${character.id}`, payload) + const saved = toCharacter(raw) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + return saved + }, + + async remove(id: string): Promise { + await del(`/characters/${id}`) + }, + } +} diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 616b5db0..a8404b95 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -61,6 +61,13 @@ export interface Action { id: string outfitId: Outfit['id'] name: string + /** + * 生成端声明的完整帧数。旧的纯前端数据可以暂时缺省,但正式后端数据必须保留该值, + * 否则 Playtest 不能判断收到的 frames 是否完整。 + */ + expectedFrameCount?: number + /** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */ + loop?: boolean /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ kind: ActionKind /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ @@ -92,6 +99,8 @@ export interface Outfit { id: string characterId: string name: string + /** 造型说明来自 character_data;旧资产没有时为 null。 */ + description?: string | null /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ candidateCharacterTemplates: CharacterTemplateCandidate[] /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ @@ -106,11 +115,20 @@ export interface Outfit { * 项目下的角色资产;造型拥有各自的母版和动作帧。 * * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 - * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的节点里,直到用户确认导出才整体写入。 */ export interface Character { id: string projectId: string + /** 当前后端返回时用于资产库展示;旧记录没有时为空。 */ + name?: string | null + /** 角色描述与参考图属于 Character 顶层后端字段。 */ + description?: string | null + referenceImageUrl?: string | null + /** character_data.version,整棵更新时原样带回。 */ + dataVersion?: number + /** 后端记录状态;当前 1 表示正常。 */ + status?: number /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ outfits: Outfit[] createdAt: string @@ -120,6 +138,7 @@ export interface Character { /** 创建角色并发起母版生成所需的入参。 */ export interface CreateCharacterInput { projectId: string + name?: string | null /** 交给模型生成母版。 */ description: string referenceImageUrl?: string | null @@ -132,6 +151,11 @@ export interface CreateCharacterInput { export interface CharacterApis { get(id: Character['id']): Promise listByProject(projectId: string): Promise + listPageByProject?( + projectId: string, + query?: import('@/shared/pagination').PageQuery, + ): Promise> create(input: CreateCharacterInput): Promise update(character: Character): Promise + remove(id: Character['id']): Promise } diff --git a/frontend/src/entities/constants.ts b/frontend/src/entities/constants.ts new file mode 100644 index 00000000..62eafbdb --- /dev/null +++ b/frontend/src/entities/constants.ts @@ -0,0 +1,14 @@ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */ +export const WORKFLOW_NODE_ORDER = [ + 'character-setup', + 'character-template', + 'action-first-frame', + 'action-full-frame', + 'review', +] as const diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index 434612ef..ba58c6e8 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -1,349 +1,66 @@ -import { describe, expect, it, vi } from 'vitest' - -import { createGenerationApis, GenerationApiError } from '@/entities' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MediaReference } from '../media' +import { createGenerationApis } from './api' -const reference = (url: string) => url as MediaReference -const resolveImageSize = vi.fn(async () => ({ width: 64, height: 96 })) - -function success(data: unknown): Response { - return new Response(JSON.stringify({ code: 200, message: 'success', data }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) -} - -function taskData(overrides: Record = {}) { - return { - id: 91, - user_id: 7, - project_id: 42, - task_type: 'character_image', - status: 'completed', - input_payload: { num_images: 4 }, - result: { - type: 'character_image', - image_urls: [ - 'https://cdn.test/candidate-1.png', - 'https://cdn.test/candidate-2.png', - 'https://cdn.test/candidate-3.png', - 'https://cdn.test/candidate-4.png', - ], - }, - error_message: null, - ...overrides, - } -} +afterEach(() => { + vi.unstubAllGlobals() +}) -function actionFrames(count: number) { - return Array.from({ length: count }, (_, offset) => { - const index = count - offset - 1 - return { - index, - image_url: `https://cdn.test/frame-${index + 1}.png`, - duration_ms: index % 2 === 0 ? 100 : null, - } - }) +function generationTaskResponse() { + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: { + id: 11, + user_id: 1, + project_id: 7, + task_type: 'character_action', + status: 'pending', + input_payload: {}, + result: null, + error_message: null, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) } -describe('createGenerationApis', () => { - it('固定请求并映射四张角色母版候选', async () => { - const request = vi.fn(async (_url: string, _init?: RequestInit) => success(taskData())) - const stream = vi.fn(() => vi.fn()) - const apis = createGenerationApis({ - baseUrl: 'https://api.test/', - userId: '7', - transport: { request, stream }, - resolveImageSize, - }) - - const generation = await apis.create({ - type: 'character_template', - projectId: '42', - referenceMedia: [reference('https://cdn.test/reference.png')], - prompt: 'pixel hero', - }) +describe('generation API adapter', () => { + it('requests 32 frames for a complete animation while keeping first-frame generation at one frame', async () => { + const fetchMock = vi.fn().mockImplementation(async () => generationTaskResponse()) + vi.stubGlobal('fetch', fetchMock) + const api = createGenerationApis() - expect(request).toHaveBeenCalledWith( - 'https://api.test/generation/image', - expect.objectContaining({ - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - user_id: 7, - project_id: 42, - reference_image_url: 'https://cdn.test/reference.png', - prompt: 'pixel hero', - negative_prompt: '', - width: 64, - height: 96, - num_images: 4, - }), - }), - ) - expect(generation.result).toEqual({ - type: 'character_template', - images: [ - { url: 'https://cdn.test/candidate-1.png' }, - { url: 'https://cdn.test/candidate-2.png' }, - { url: 'https://cdn.test/candidate-3.png' }, - { url: 'https://cdn.test/candidate-4.png' }, - ], - }) - expect(resolveImageSize).toHaveBeenCalledWith('42') - }) - - it('通过动作生成接口固定请求并映射一帧动作首帧', async () => { - const request = vi.fn(async (_url: string, _init?: RequestInit) => - success( - taskData({ - task_type: 'character_action', - input_payload: { num_frames: 1, action_type: 'idle' }, - result: { - type: 'character_action', - action_type: 'idle', - frames: [ - { index: 0, image_url: 'https://cdn.test/first-frame.png', duration_ms: null }, - ], - }, - }), - ), - ) - const apis = createGenerationApis({ - baseUrl: '', - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - const generation = await apis.create({ - type: 'first_frame', - projectId: '42', - characterId: '5', - outfitId: 'default', - actionType: 'idle', - prompt: 'stand naturally', - referenceMedia: [reference('https://cdn.test/template.png')], - }) - - expect(request.mock.calls[0]?.[0]).toBe('/generation/action') - expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ - user_id: 7, - project_id: 42, - character_id: 5, - action_type: 'idle', - custom_prompt: 'stand naturally', - reference_video_url: null, - reference_image_urls: ['https://cdn.test/template.png'], - num_frames: 1, - }) - expect(generation.result).toEqual({ + await api.create({ type: 'first_frame', - image: { url: 'https://cdn.test/first-frame.png' }, - }) - }) - - it('以首帧请求完整动画并按后端 index 排序,当前合同固定为十六帧', async () => { - const request = vi.fn(async (_url: string, _init?: RequestInit) => - success( - taskData({ - task_type: 'character_action', - input_payload: { num_frames: 16, action_type: 'walk' }, - result: { - type: 'character_action', - action_type: 'walk', - frames: actionFrames(16), - }, - }), - ), - ) - const apis = createGenerationApis({ - baseUrl: '/api', - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - const generation = await apis.create({ - type: 'complete_animation', - projectId: '42', - characterId: '5', - outfitId: 'default', + projectId: '7', + characterId: '9', + outfitId: 'outfit-9-default', actionType: 'walk', - firstFrameUrl: 'https://cdn.test/frame-1.png', - prompt: 'move forward', - referenceMedia: [reference('https://cdn.test/extra.png')], - }) - - expect(request.mock.calls[0]?.[0]).toBe('/api/generation/action') - expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ - user_id: 7, - project_id: 42, - character_id: 5, - action_type: 'walk', - custom_prompt: 'move forward', - reference_video_url: null, - reference_image_urls: ['https://cdn.test/frame-1.png', 'https://cdn.test/extra.png'], - num_frames: 16, + prompt: null, + referenceMedia: [], }) - expect(generation.result).toEqual({ + await api.create({ type: 'complete_animation', - frames: Array.from({ length: 16 }, (_, index) => ({ - url: `https://cdn.test/frame-${index + 1}.png`, - })), - }) - }) - - it('拒绝未知任务状态而不是默认为 pending', async () => { - const request = vi.fn(async () => success(taskData({ status: 'queued' }))) - const apis = createGenerationApis({ - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toBeInstanceOf( - GenerationApiError, - ) - await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( - '生成任务状态无效', - ) - }) - - it('拒绝结果字段不完整的 completed DTO', async () => { - const request = vi.fn(async () => - success(taskData({ result: { type: 'character_image', image_urls: [null] } })), - ) - const apis = createGenerationApis({ - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( - '角色图片结果 image_urls 无效', - ) - }) - - it('订阅 task_update,映射终态并把终态关闭信号交给流传输层', () => { - let subscribedUrl = '' - let streamOptions: - | { - eventName: string - onEvent(data: string): boolean - onError(error: Error): void - } - | undefined - const cancel = vi.fn() - const stream = vi.fn((url: string, options: NonNullable) => { - subscribedUrl = url - streamOptions = options - return cancel - }) - const apis = createGenerationApis({ - baseUrl: 'https://api.test', - userId: 7, - transport: { request: vi.fn(), stream }, - resolveImageSize, - }) - const onEvent = vi.fn() - const onError = vi.fn() - - const unsubscribe = apis.subscribe( - '42', - '91', - { type: 'complete_animation', actionType: 'walk' }, - onEvent, - onError, - ) - const isTerminal = streamOptions?.onEvent( - JSON.stringify({ - task_id: 91, - task_type: 'character_action', - status: 'completed', - result: { - type: 'character_action', - action_type: 'walk', - frames: actionFrames(16), - }, - error_message: null, - }), - ) - - expect(subscribedUrl).toBe('https://api.test/generation/tasks/91/stream?project_id=42') - expect(streamOptions?.eventName).toBe('task_update') - expect(isTerminal).toBe(true) - expect(onEvent).toHaveBeenCalledWith({ - taskId: '91', - type: 'complete_animation', - status: 'completed', - result: { - type: 'complete_animation', - frames: Array.from({ length: 16 }, (_, index) => ({ - url: `https://cdn.test/frame-${index + 1}.png`, - })), - }, - error: null, - }) - - unsubscribe() - expect(cancel).toHaveBeenCalledOnce() - }) - - it('拒绝 completed 任务返回错误动作类型', async () => { - const request = vi.fn(async () => - success( - taskData({ - task_type: 'character_action', - input_payload: { num_frames: 16, action_type: 'walk' }, - result: { - type: 'character_action', - action_type: 'attack', - frames: actionFrames(16), - }, - }), - ), - ) - const apis = createGenerationApis({ - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - await expect( - apis.get('42', '91', { type: 'complete_animation', actionType: 'walk' }), - ).rejects.toThrow('动作结果类型 attack 与请求的 walk 不一致') - }) - - it('拒绝不足十六帧以及非失败状态携带错误', async () => { - const request = vi - .fn() - .mockResolvedValueOnce( - success( - taskData({ - task_type: 'character_action', - input_payload: { num_frames: 16, action_type: 'walk' }, - result: { - type: 'character_action', - action_type: 'walk', - frames: actionFrames(3), - }, - }), - ), - ) - .mockResolvedValueOnce(success(taskData({ error_message: 'provider failed' }))) - const apis = createGenerationApis({ - userId: 7, - transport: { request, stream: vi.fn(() => vi.fn()) }, - resolveImageSize, - }) - - await expect( - apis.get('42', '91', { type: 'complete_animation', actionType: 'walk' }), - ).rejects.toThrow('完整动画结果必须包含 16 帧') - await expect(apis.get('42', '91', { type: 'character_template' })).rejects.toThrow( - 'completed 任务不应携带 error_message', - ) + projectId: '7', + characterId: '9', + outfitId: 'outfit-9-default', + actionType: 'walk', + firstFrameUrl: 'https://cdn.example.com/first-frame.png', + prompt: null, + referenceMedia: ['media-reference-1' as MediaReference], + }) + + const firstFramePayload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record< + string, + unknown + > + const completeAnimationPayload = JSON.parse( + String(fetchMock.mock.calls[1]?.[1]?.body), + ) as Record + expect(firstFramePayload.num_frames).toBe(1) + expect(completeAnimationPayload.num_frames).toBe(32) }) }) diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts index 68b68ac7..012608d4 100644 --- a/frontend/src/entities/generation/api.ts +++ b/frontend/src/entities/generation/api.ts @@ -1,506 +1,210 @@ -import type { EventStreamSubscriber } from '@/shared/api/stream' - -import type { - CompleteAnimationGenerationInput, - GeneratedImage, - Generation, - GenerationApis, - GenerationEvent, - GenerationExpectation, - GenerationImageSize, - GenerationInput, - GenerationResult, - GenerationType, - TaskStatus, +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type GenerationType, } from '.' -type RequestFunction = (url: string, init?: RequestInit) => Promise - -/** Generation 适配器需要的全部网络能力,由宿主统一注入。 */ -export interface GenerationTransport { - request: RequestFunction - stream: EventStreamSubscriber -} - -export interface GenerationApiConfig { - /** API 前缀;空字符串表示同源。 */ - baseUrl?: string - /** 当前用户由认证宿主提供,适配器不猜测也不写死身份。 */ - userId: string | number - transport: GenerationTransport - /** 由组合根通过 ProjectApis 提供,Generation 不直接依赖 Project 实体或猜测尺寸。 */ - resolveImageSize(projectId: string): Promise -} +import { get, post } from '@/shared/api' +import { subscribeToEventStream } from '@/shared/api/stream' -interface ResponseEnvelope { - code: unknown - message: unknown - data: unknown -} +/* ─── 后端 DTO ─── */ -interface GenerationTaskDto { +interface BackendGenerationTask { id: number - userId: number - projectId: number - taskType: BackendGenerationType - status: TaskStatus - inputPayload: Record | null - result: Record | null - errorMessage: string | null + user_id: number + project_id: number + task_type: string + status: string + input_payload: Record + result: unknown + error_message: string | null } -type BackendGenerationType = 'character_image' | 'character_action' +/* ─── 映射 ─── */ -const TASK_STATUSES = new Set(['pending', 'running', 'completed', 'failed']) -const ACTION_TYPES = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) - -export class GenerationApiError extends Error { - readonly code: number - - constructor(message: string, code = 0, options?: ErrorOptions) { - super(message, options) - this.name = 'GenerationApiError' - this.code = code - } +const STATUS_MAP: Record = { + pending: 'pending', + running: 'running', + completed: 'completed', + failed: 'failed', } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function inputPositiveInteger(value: string | number, field: string): number { - const parsed = typeof value === 'number' ? value : Number(value) - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - throw new GenerationApiError(`${field} 必须是正整数`) - } - return parsed +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 dtoPositiveInteger(value: unknown, field: string): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) { - throw new GenerationApiError(`生成任务 ${field} 无效`, 200) - } - return value as number -} - -function dtoNullableRecord(value: unknown, field: string): Record | null { - if (value === null) return null - if (!isRecord(value)) throw new GenerationApiError(`生成任务 ${field} 无效`, 200) - return value -} - -function dtoNullableString(value: unknown, field: string): string | null { - if (value === null) return null - if (typeof value !== 'string') throw new GenerationApiError(`生成任务 ${field} 无效`, 200) - return value -} - -function backendTaskType(value: unknown): BackendGenerationType { - if (value !== 'character_image' && value !== 'character_action') { - throw new GenerationApiError('生成任务 task_type 无效', 200) - } - return value -} - -function taskStatus(value: unknown): TaskStatus { - if (typeof value !== 'string' || !TASK_STATUSES.has(value as TaskStatus)) { - throw new GenerationApiError('生成任务状态无效', 200) - } - return value as TaskStatus -} - -function endpoint(baseUrl: string | undefined, path: string): string { - return `${(baseUrl ?? '').replace(/\/$/u, '')}${path}` -} - -async function readData(response: Response): Promise { - let raw: unknown - try { - raw = await response.json() - } catch (error) { - throw new GenerationApiError( - `生成接口返回了无法解析的响应(HTTP ${response.status})`, - response.status, - { cause: error }, - ) - } - if (!isRecord(raw)) { - throw new GenerationApiError('生成接口响应不是对象', response.status) - } - - const envelope: ResponseEnvelope = { - code: raw.code, - message: raw.message, - data: raw.data, - } - if (typeof envelope.code !== 'number') { - throw new GenerationApiError('生成接口响应缺少有效的 code', response.status) - } - const message = - typeof envelope.message === 'string' ? envelope.message : `HTTP ${response.status}` - if (!response.ok || envelope.code !== 200) { - throw new GenerationApiError(message, envelope.code) - } - if (envelope.data === null || envelope.data === undefined) { - throw new GenerationApiError('生成接口成功响应缺少 data', envelope.code) - } - return envelope.data -} - -/** 完整查询 DTO 的每个字段都在网络边界校验,不把脏数据带入实体。 */ -function parseTaskDto(value: unknown): GenerationTaskDto { - if (!isRecord(value)) throw new GenerationApiError('生成任务响应不是对象', 200) - const inputPayload = dtoNullableRecord(value.input_payload, 'input_payload') +function toGeneration( + raw: BackendGenerationTask, + expectedType?: T, +): Generation { + const type = expectedType ?? ((GENERATION_TYPE_MAP[raw.task_type] ?? raw.task_type) as T) return { - id: dtoPositiveInteger(value.id, 'id'), - userId: dtoPositiveInteger(value.user_id, 'user_id'), - projectId: dtoPositiveInteger(value.project_id, 'project_id'), - taskType: backendTaskType(value.task_type), - status: taskStatus(value.status), - inputPayload, - result: dtoNullableRecord(value.result, 'result'), - errorMessage: dtoNullableString(value.error_message, 'error_message'), - } -} - -function expectedBackendType(type: GenerationType): BackendGenerationType { - return type === 'character_template' ? 'character_image' : 'character_action' -} - -function nonEmptyString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new GenerationApiError(`${field} 无效`, 200) - } - return value -} - -function mapImageResult(result: Record): GenerationResult { - if (result.type !== 'character_image') { - throw new GenerationApiError('角色图片结果 type 无效', 200) - } - if ( - !Array.isArray(result.image_urls) || - result.image_urls.length === 0 || - result.image_urls.some((url) => typeof url !== 'string' || url.trim() === '') - ) { - throw new GenerationApiError('角色图片结果 image_urls 无效', 200) - } - const images = result.image_urls.map((url): GeneratedImage => ({ url: url as string })) - - if (images.length !== 4) { - throw new GenerationApiError('角色母版结果必须包含 4 个候选', 200) + 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, } - return { type: 'character_template', images } } -function mapActionResult( - result: Record, - expectation: Extract, -): GenerationResult { - if (result.type !== 'character_action') { - throw new GenerationApiError('完整动画结果 type 无效', 200) - } - if (typeof result.action_type !== 'string' || !ACTION_TYPES.has(result.action_type)) { - throw new GenerationApiError('完整动画结果 action_type 无效', 200) - } - if (result.action_type !== expectation.actionType) { - throw new GenerationApiError( - `动作结果类型 ${result.action_type} 与请求的 ${expectation.actionType} 不一致`, - 200, - ) - } - if (!Array.isArray(result.frames) || result.frames.length === 0) { - throw new GenerationApiError('完整动画结果 frames 无效', 200) - } +/* ─── 输入 → 后端请求体 ─── */ - const indexes = new Set() - const frames = result.frames.map((frame) => { - if (!isRecord(frame)) throw new GenerationApiError('动作帧不是对象', 200) - if (!Number.isSafeInteger(frame.index) || (frame.index as number) < 0) { - throw new GenerationApiError('动作帧 index 无效', 200) - } - const index = frame.index as number - if (indexes.has(index)) throw new GenerationApiError('动作帧 index 重复', 200) - indexes.add(index) - if ( - frame.duration_ms !== null && - (!Number.isFinite(frame.duration_ms) || (frame.duration_ms as number) < 0) - ) { - throw new GenerationApiError('动作帧 duration_ms 无效', 200) - } +function toBackendPayload(input: GenerationInput, userId: number) { + if (input.type === 'character_template') { return { - index, - image: { url: nonEmptyString(frame.image_url, '动作帧 image_url') }, + 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, } - }) - - const orderedFrames = frames - .sort((left, right) => left.index - right.index) - .map(({ image }) => image) - const expectedFrameCount = expectation.type === 'first_frame' ? 1 : 16 - if (orderedFrames.length !== expectedFrameCount) { - throw new GenerationApiError( - `${expectation.type === 'first_frame' ? '动作首帧' : '完整动画'}结果必须包含 ${expectedFrameCount} 帧`, - 200, - ) } - for (let index = 0; index < expectedFrameCount; index += 1) { - if (!indexes.has(index)) { - throw new GenerationApiError('动作帧 index 必须从 0 开始连续排列', 200) - } - } - if (expectation.type === 'first_frame') { - return { type: 'first_frame', image: orderedFrames[0]! } - } - return { type: 'complete_animation', frames: orderedFrames } -} -function mapResult( - result: Record | null, - status: TaskStatus, - expectation: GenerationExpectation, -): GenerationResult | null { - if (status !== 'completed') { - if (result !== null) { - throw new GenerationApiError('非完成任务不应携带 result', 200) - } - return null - } - if (result === null) throw new GenerationApiError('完成任务缺少 result', 200) - return expectation.type === 'character_template' - ? mapImageResult(result) - : mapActionResult(result, expectation) -} - -function validateStatusError(status: TaskStatus, error: string | null): void { - if (status === 'failed') { - if (error === null || error.trim() === '') { - throw new GenerationApiError('失败任务缺少 error_message', 200) + 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, } - return - } - if (error !== null) { - throw new GenerationApiError(`${status} 任务不应携带 error_message`, 200) } -} -function validateInputPayload( - inputPayload: Record | null, - expectation: GenerationExpectation, -): void { - if (inputPayload === null) { - throw new GenerationApiError('生成任务缺少 input_payload', 200) - } - if (expectation.type === 'character_template') { - if (inputPayload.num_images !== 4) { - throw new GenerationApiError('角色母版任务 input_payload.num_images 必须为 4', 200) - } - return - } - const expectedFrameCount = expectation.type === 'first_frame' ? 1 : 16 - if (inputPayload.num_frames !== expectedFrameCount) { - throw new GenerationApiError( - `动作任务 input_payload.num_frames 必须为 ${expectedFrameCount}`, - 200, - ) - } - if (inputPayload.action_type !== expectation.actionType) { - throw new GenerationApiError('动作任务 input_payload.action_type 与请求不一致', 200) + // complete_animation + return { + user_id: userId, + project_id: Number(input.projectId), + character_id: Number(input.characterId), + action_type: input.actionType, + custom_prompt: input.prompt, + reference_image_urls: [input.firstFrameUrl, ...input.referenceMedia.map(String)], + num_frames: COMPLETE_ANIMATION_FRAME_COUNT, } } -function validateTaskIdentity( - dto: GenerationTaskDto, - expectedProjectId: number, - expectedUserId: number, - expectation: GenerationExpectation, - expectedTaskId?: number, -): void { - if (dto.projectId !== expectedProjectId) { - throw new GenerationApiError(`生成任务未归属请求中的项目 ${expectedProjectId}`, 200) - } - if (dto.userId !== expectedUserId) { - throw new GenerationApiError('生成任务未归属当前用户', 200) - } - if (expectedTaskId !== undefined && dto.id !== expectedTaskId) { - throw new GenerationApiError(`生成任务 ID 与请求的 ${expectedTaskId} 不一致`, 200) - } - if (dto.taskType !== expectedBackendType(expectation.type)) { - throw new GenerationApiError(`生成任务类型与 ${expectation.type} 不匹配`, 200) - } - validateStatusError(dto.status, dto.errorMessage) - validateInputPayload(dto.inputPayload, expectation) -} +/* ─── 适配器 ─── */ -function mapTask( - value: unknown, - expectedProjectId: number, - expectedUserId: number, - expectation: Extract, - expectedTaskId?: number, -): Generation { - const dto = parseTaskDto(value) - validateTaskIdentity(dto, expectedProjectId, expectedUserId, expectation, expectedTaskId) - return { - id: String(dto.id), - projectId: String(dto.projectId), - type: expectation.type, - status: dto.status, - result: mapResult(dto.result, dto.status, expectation), - error: dto.errorMessage, - } +const GENERATION_ENDPOINTS: Record = { + character_template: '/generation/image', + first_frame: '/generation/action', + complete_animation: '/generation/action', } -function references(input: CompleteAnimationGenerationInput): string[] { - return [input.firstFrameUrl, ...input.referenceMedia.map(String)].filter( - (url, index, all) => url.trim() !== '' && all.indexOf(url) === index, - ) +function streamUrl(projectId: string, id: string) { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000' + return `${baseUrl.replace(/\/$/u, '')}/generation/tasks/${encodeURIComponent(id)}/stream?project_id=${encodeURIComponent(projectId)}` } -function parseEventData(data: string): unknown { +function parseTaskUpdate(data: string): GenerationEvent { + let value: unknown try { - return JSON.parse(data) as unknown - } catch (error) { - throw new GenerationApiError('task_update 不是有效 JSON', 200, { cause: error }) - } -} - -function mapEvent( - value: unknown, - expectedTaskId: number, - expectation: Extract, -): GenerationEvent { - if (!isRecord(value)) throw new GenerationApiError('task_update 不是对象', 200) - const taskId = dtoPositiveInteger(value.task_id, 'task_id') - if (taskId !== expectedTaskId) { - throw new GenerationApiError(`task_update ID 与订阅的 ${expectedTaskId} 不一致`, 200) - } - if (backendTaskType(value.task_type) !== expectedBackendType(expectation.type)) { - throw new GenerationApiError(`task_update 类型与 ${expectation.type} 不匹配`, 200) - } - const status = taskStatus(value.status) - const result = dtoNullableRecord(value.result ?? null, 'result') - const error = dtoNullableString(value.error_message ?? null, 'error_message') - validateStatusError(status, error) + value = JSON.parse(data) as unknown + } catch (cause) { + throw new Error('task_update 不是有效 JSON', { cause }) + } + if (!value || typeof value !== 'object') throw new Error('task_update 不是对象') + const event = value as Record + const taskId = event.task_id + const taskType = event.task_type + const status = event.status + if ((typeof taskId !== 'string' && typeof taskId !== 'number') || typeof taskType !== 'string') { + throw new Error('task_update 缺少任务标识或类型') + } + if (typeof status !== 'string' || !(status in STATUS_MAP)) { + throw new Error('task_update 状态无效') + } + const type = GENERATION_TYPE_MAP[taskType] ?? 'complete_animation' return { taskId: String(taskId), - type: expectation.type, - status, - result: mapResult(result, status, expectation), - error, + type, + status: STATUS_MAP[status]!, + result: toGenerationResult(type, event.result), + error: typeof event.error_message === 'string' ? event.error_message : null, } } -/** - * 创建 Generation 实体适配器。 - * - * `userId` 与 HTTP/SSE transport 都由宿主注入,因此模块既不持有登录态,也不直接 - * 依赖 fetch/EventSource。三个前端阶段在这里收口为后端的两类 GenerationTask。 - */ -export function createGenerationApis(config: GenerationApiConfig): GenerationApis { - const userId = inputPositiveInteger(config.userId, 'userId') - const { request, stream } = config.transport - - async function post( - path: '/generation/image' | '/generation/action', - projectId: number, - expectation: Extract, - body: Record, - ): Promise> { - const response = await request(endpoint(config.baseUrl, path), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - return mapTask(await readData(response), projectId, userId, expectation) - } - +export function createGenerationApis(): GenerationApis { return { async create(input: T): Promise> { - const projectId = inputPositiveInteger(input.projectId, 'projectId') - if (input.type !== 'character_template') { - const referenceImageUrls = - input.type === 'complete_animation' - ? references(input) - : input.referenceMedia.map(String).filter((url) => url.trim() !== '') - return post( - '/generation/action', - projectId, - { type: input.type, actionType: input.actionType }, - { - user_id: userId, - project_id: projectId, - character_id: inputPositiveInteger(input.characterId, 'characterId'), - action_type: input.actionType, - custom_prompt: input.prompt, - reference_video_url: null, - reference_image_urls: referenceImageUrls, - // 首帧是一次一帧动作任务;完整动画当前沿用后端合同的 16 帧。 - num_frames: input.type === 'first_frame' ? 1 : 16, - }, - ) - } + const endpoint = GENERATION_ENDPOINTS[input.type] + if (!endpoint) throw new Error(`未知的生成类型:${input.type}`) - const imageSize = await config.resolveImageSize(input.projectId) - return post( - '/generation/image', - projectId, - { type: input.type }, - { - user_id: userId, - project_id: projectId, - reference_image_url: input.referenceMedia[0] ? String(input.referenceMedia[0]) : null, - prompt: input.prompt ?? '', - negative_prompt: '', - width: inputPositiveInteger(imageSize.width, 'imageSize.width'), - height: inputPositiveInteger(imageSize.height, 'imageSize.height'), - // 只有角色母版走图片接口,并且固定生成四个候选。 - num_images: 4, - }, - ) + const payload = toBackendPayload(input, 1) // TODO: 接入认证后替换 userId + const raw = await post(endpoint, payload) + return toGeneration(raw, input.type) }, - async get( - projectId: string, - id: string, - expectation: Extract, - ): Promise> { - const numericProjectId = inputPositiveInteger(projectId, 'projectId') - const numericTaskId = inputPositiveInteger(id, 'taskId') - const response = await request( - endpoint( - config.baseUrl, - `/generation/tasks/${numericTaskId}?project_id=${numericProjectId}`, - ), - { method: 'GET' }, + async get(projectId: string, id: string): Promise { + const raw = await get( + `/generation/tasks/${id}?project_id=${encodeURIComponent(projectId)}`, ) - return mapTask(await readData(response), numericProjectId, userId, expectation, numericTaskId) + return toGeneration(raw) }, - subscribe( - projectId: string, - id: string, - expectation: Extract, - onEvent: (event: GenerationEvent) => void, - onError: (error: Error) => void, - ): () => void { - const numericProjectId = inputPositiveInteger(projectId, 'projectId') - const numericTaskId = inputPositiveInteger(id, 'taskId') - return stream( - endpoint( - config.baseUrl, - `/generation/tasks/${numericTaskId}/stream?project_id=${numericProjectId}`, - ), - { - eventName: 'task_update', - onEvent(data) { - const event = mapEvent(parseEventData(data), numericTaskId, expectation) - onEvent(event) - return event.status === 'completed' || event.status === 'failed' - }, - onError, + subscribe(projectId, id, onEvent, onError = () => undefined) { + return subscribeToEventStream(streamUrl(projectId, id), { + eventName: 'task_update', + onEvent(data) { + const event = parseTaskUpdate(data) + if (event.taskId !== id) throw new Error('task_update 与订阅任务不一致') + onEvent(event) + return event.status === 'completed' || event.status === 'failed' }, - ) + onError, + }) }, } } diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index b5b0524e..16b47030 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -4,35 +4,16 @@ 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 落在 character_image,动作首帧和完整动画 - * 都落在 character_action,只是请求帧数分别为 1 和 16。 - * 完整动画内部可含视频生成、截帧和多次图像处理,但对前端仍是一次 Generation。 - */ +/** 生成对应的三个前端可见异步步骤。 */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' -/** - * 恢复任务时由 WorkflowRun 提供的已知上下文。动作阶段必须带上动作语义, - * 这样适配器才能拒绝“请求 walk、后端却返回 attack”这类串任务结果。 - */ -export type GenerationExpectation = - | { type: 'character_template' } - | { type: 'first_frame'; actionType: ActionType } - | { type: 'complete_animation'; actionType: ActionType } +/** 完整动作默认生成帧数;首帧生成仍固定为 1 帧。 */ +export const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' interface GenerationInputBase { projectId: string @@ -40,20 +21,18 @@ interface GenerationInputBase { referenceMedia: readonly MediaReference[] } -/** 图片生成请求的实际画布尺寸,必须与所属项目的精灵尺寸合同一致。 */ -export interface GenerationImageSize { - width: number - height: number -} - -/** 角色母版候选生成;当前合同固定请求 4 个候选,不向调用方暴露可变数量。 */ +/** 角色母版候选生成。 */ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } -/** 指定角色造型下的动作首帧生成;当前合同固定 1 张,且不能只绑定 Character。 */ +/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ export interface FirstFrameGenerationInput extends GenerationInputBase { type: 'first_frame' characterId: string @@ -63,10 +42,7 @@ export interface FirstFrameGenerationInput extends GenerationInputBase { prompt: string | null } -/** - * 以已确认首帧为起点生成完整动画。 - * 后端支持 num_frames,但当前输入没有 frameCount;适配器暂按后端默认值提交 16。 - */ +/** 以已确认首帧为起点生成完整动画。 */ export interface CompleteAnimationGenerationInput extends GenerationInputBase { type: 'complete_animation' characterId: string @@ -93,15 +69,84 @@ export interface CharacterTemplateGenerationResult { images: readonly GeneratedImage[] } +/** + * Generation.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 + * + * 兼容后端两种返回格式: + * - 旧版单图:`{ type, image_url: "..." }` + * - 新版多图:`{ type, image_urls: ["...", "..."] }` + */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if ( + !isRecord(value) || + (value.type !== 'character_template' && value.type !== 'character_image') + ) { + return null + } + + // 优先使用 image_urls(多图),兼容 image_url(单图) + const rawUrls: string[] = [] + if (Array.isArray(value.image_urls)) { + for (const item of value.image_urls) { + if (typeof item === 'string' && item.length > 0) rawUrls.push(item) + } + } else if (typeof value.image_url === 'string' && value.image_url.length > 0) { + rawUrls.push(value.image_url) + } + + // 兼容旧版 images 数组格式 + if (rawUrls.length === 0 && Array.isArray(value.images)) { + for (const image of value.images) { + if (isRecord(image) && typeof image.url === 'string' && image.url.length > 0) { + rawUrls.push(image.url) + } + } + } + + if (rawUrls.length === 0) return null + + const images: GeneratedImage[] = rawUrls.map((url) => ({ url })) + return { type: 'character_template', images } +} + export interface FirstFrameGenerationResult { type: 'first_frame' image: GeneratedImage } +export interface GeneratedAnimationFrame extends GeneratedImage { + durationMs: number | null +} + /** 帧顺序由数组位置表达。 */ export interface CompleteAnimationGenerationResult { type: 'complete_animation' - frames: readonly GeneratedImage[] + actionType: ActionType + frames: readonly GeneratedAnimationFrame[] +} + +/** 校验已经过适配层归一化的完整动画结果,供本地持久化恢复使用。 */ +export function parseCompleteAnimationGenerationResult( + value: unknown, +): CompleteAnimationGenerationResult | null { + if (!isRecord(value) || value.type !== 'complete_animation') return null + if (!['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType))) return null + if (!Array.isArray(value.frames) || value.frames.length === 0) return null + const frames: GeneratedAnimationFrame[] = [] + for (const frame of value.frames) { + if ( + !isRecord(frame) || + typeof frame.url !== 'string' || + frame.url.length === 0 || + (frame.durationMs !== null && typeof frame.durationMs !== 'number') + ) { + return null + } + frames.push({ url: frame.url, durationMs: frame.durationMs as number | null }) + } + return { type: 'complete_animation', actionType: value.actionType as ActionType, frames } } export type GenerationResult = @@ -117,63 +162,49 @@ export type GenerationResultFor = : CompleteAnimationGenerationResult /** - * 一次生成任务的完整快照,创建、查询和断线恢复都用它。 + * 一次生成任务的完整快照。 * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 - * - * TType 在调用边界已知时保留精确类型;查询和订阅必须传入工作流已知的前端阶段, - * 因为后端 task_type 比前端阶段更粗,不能只靠 DTO 猜测首帧还是完整动画。 - * 完成不代表工作流节点已通过,节点状态由 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( + subscribe( projectId: Generation['projectId'], id: Generation['id'], - expectation: Extract, - ): Promise> - /** - * 订阅 task_update,终态由传输层自动关闭;返回的函数供页面离开时主动取消。 - * 传输错误与非法 DTO 通过 onError 上报,不伪造成业务 failed 状态。 - */ - subscribe( - projectId: Generation['projectId'], - id: Generation['id'], - expectation: Extract, - onEvent: (event: GenerationEvent) => void, - onError: (error: Error) => void, + onEvent: (event: GenerationEvent) => void, + onError?: (error: Error) => void, ): () => void } -export { createGenerationApis, GenerationApiError } from './api' -export type { GenerationApiConfig, GenerationTransport } from './api' +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 f58f2b78..635c71cf 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,17 +1,22 @@ /** * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * 外部只从这里使用实体契约与已经落地的实体能力。 */ +/* 用户 —— 认证态与账户资料。 */ +export { createUserApis } from './user/api' +export type { CreateUserApisOptions } from './user/api' +export type { AuthTokens, User, UserApis } from './user' + /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, DirectionalMovement, Project, ProjectApis, - UpdateProjectInput, } from './project' /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ @@ -29,50 +34,59 @@ export type { FrameRootMotion, Outfit, } from './character' +export { createCharacterApis } from './character/api' -/* 动作模板 —— 能跨角色复用的配方 */ -export type { ActionTemplate, ActionTemplateApis } from './action-template' - -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ -export { createGenerationApis, GenerationApiError } from './generation' +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { CHARACTER_ACTION_FRAME_COUNT } from './generation' +export { createGenerationApis } from './generation/api' export type { - CharacterTemplateGenerationInput, - CharacterTemplateGenerationResult, - CompleteAnimationGenerationInput, - CompleteAnimationGenerationResult, - FirstFrameGenerationInput, - FirstFrameGenerationResult, - GeneratedImage, + CharacterActionFrame, + CharacterActionGenerationInput, + CharacterActionOutput, + CharacterImageGenerationInput, + CharacterImageOutput, Generation, GenerationApis, GenerationEvent, - GenerationExpectation, GenerationInput, - GenerationImageSize, GenerationResult, GenerationResultFor, + GenerationTaskStatus, GenerationType, - GenerationApiConfig, - GenerationTransport, - TaskStatus, } from './generation' /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ -export type { MediaReference } from './media' +export { createMediaApis } from './media/api' +export type { MediaApis, MediaCategory, MediaReference } from './media' + +/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */ +export { createPlaytestInspectionApis } from './playtest-inspection/api' +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from './playtest-inspection' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_NODE_ORDER } from './workflow-run' export type { + CharacterSetupNodeInput, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, CreateWorkflowRunInput, ExportStatus, GenerationStatus, - WorkflowDriver, - WorkflowStep, - WorkflowStepStatus, - WorkflowStepType, - WorkflowRevision, - WorkflowRevisionStatus, + WorkflowNode, + WorkflowNodeStatus, + WorkflowNodeType, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, + WorkflowRevision, + CreateWorkflowRunStoreOptions, } from './workflow-run' diff --git a/frontend/src/entities/media/api.test.ts b/frontend/src/entities/media/api.test.ts new file mode 100644 index 00000000..f879e9a4 --- /dev/null +++ b/frontend/src/entities/media/api.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createMediaApis } from '@/entities' + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('MediaApis.upload', () => { + it('把图片和默认查询分类交给后端,并返回经过校验的媒体引用', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/general/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const file = imageFile() + + const result = await createMediaApis().upload(file) + + expect(result).toBe('https://cdn.example.com/media/reference.png') + expect(fetchMock).toHaveBeenCalledTimes(1) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://127.0.0.1:8000/media/upload?category=general') + expect(init.method).toBe('POST') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + + const body = init.body as FormData + expect(body.get('file')).toBe(file) + expect(body.has('category')).toBe(false) + }) + + it('传递调用方选择的图片用途和取消信号', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/reference-image/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + await createMediaApis().upload(imageFile(), 'reference-image', controller.signal) + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:8000/media/upload?category=reference-image', + ) + expect((init.body as FormData).has('category')).toBe(false) + expect(init.signal).toBe(controller.signal) + }) + + it('在请求发出前拒绝非图片文件', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const file = new File(['text'], 'notes.txt', { type: 'text/plain' }) + + await expect(createMediaApis().upload(file)).rejects.toThrow('仅支持图片文件') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('后端地址未配置时明确失败,不把文件发送到访问者本机', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubEnv('VITE_API_BASE_URL', '') + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadConfigurationError', + message: '媒体上传不可用:请配置 VITE_API_BASE_URL', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('把 HTTP 200 中的后端业务失败作为真实错误抛出', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 400, message: '仅支持图片文件', data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadRequestError', + status: 200, + code: 400, + message: '仅支持图片文件', + }) + }) + + it('保留非成功 HTTP 响应的状态和后端错误信息', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 503, message: '对象存储暂不可用', data: null }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadRequestError', + status: 503, + code: 503, + message: '对象存储暂不可用', + }) + }) + + it('拒绝无法解析的 JSON 响应', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toThrow( + '上传响应格式错误,无法解析 JSON', + ) + }) + + it.each([ + ['url 为空', { url: '' }], + ['object_key 缺失', { object_key: undefined }], + ['content_type 不是图片', { content_type: 'text/plain' }], + ['size 不是非负整数', { size: -1 }], + ])('拒绝不符合后端契约的成功数据:%s', async (_caseName, override) => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/general/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + ...override, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'MediaContractError', + }) + }) + + it('不包装浏览器抛出的取消错误', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const abortError = new DOMException('This operation was aborted', 'AbortError') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)) + const controller = new AbortController() + controller.abort() + + await expect( + createMediaApis().upload(imageFile(), 'reference-image', controller.signal), + ).rejects.toBe(abortError) + }) +}) + +function imageFile(): File { + return new File(['wind'], 'reference.png', { type: 'image/png' }) +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/media/api.ts b/frontend/src/entities/media/api.ts new file mode 100644 index 00000000..14cf3d6d --- /dev/null +++ b/frontend/src/entities/media/api.ts @@ -0,0 +1,81 @@ +import { upload as uploadRequest } from '@/shared/api/upload' + +import type { MediaApis, MediaCategory, MediaReference } from '.' + +/** 后端声称上传成功、但返回数据不符合 /media/upload 契约。 */ +export class MediaContractError extends Error { + constructor(message: string) { + super(`媒体上传响应格式错误:${message}`) + this.name = 'MediaContractError' + } +} + +/** /media/upload 成功时 data 字段的后端原始形状。 */ +interface BackendMediaUpload { + url: string + object_key: string + filename: string + content_type: string + size: number +} + +/** + * 创建真实媒体上传适配器。这里不缓存文件、不生成本地假 URL,也不吞掉错误; + * 只有服务端确认成功且完整响应通过运行时校验后,才交付 MediaReference。 + */ +export function createMediaApis(): MediaApis { + return { + async upload( + file: File, + category: MediaCategory = 'general', + signal?: AbortSignal, + ): Promise { + // 与后端的 image/* 规则一致,尽早反馈可避免上传无效文件;后端仍是最终校验者。 + if (!file.type.startsWith('image/')) { + throw new TypeError('仅支持图片文件') + } + + const formData = new FormData() + formData.append('file', file) + + // main 的 FastAPI 路由只把 file 声明为 File;category 未声明 Form,因此属于查询参数。 + const query = new URLSearchParams({ category }) + const result = await uploadRequest(`/media/upload?${query}`, formData, signal) + return parseMediaReference(result) + }, + } +} + +function parseMediaReference(value: unknown): MediaReference { + assertBackendMediaUpload(value) + + // MediaReference 是不透明引用;当前后端明确约定用已校验的 url 回填业务数据。 + return value.url as MediaReference +} + +function assertBackendMediaUpload(value: unknown): asserts value is BackendMediaUpload { + if (!isRecord(value)) { + throw new MediaContractError('data 必须是对象') + } + + assertNonEmptyString(value.url, 'url') + assertNonEmptyString(value.object_key, 'object_key') + assertNonEmptyString(value.filename, 'filename') + + if (typeof value.content_type !== 'string' || !value.content_type.startsWith('image/')) { + throw new MediaContractError('content_type 必须是 image/*') + } + if (typeof value.size !== 'number' || !Number.isInteger(value.size) || value.size < 0) { + throw new MediaContractError('size 必须是非负整数') + } +} + +function assertNonEmptyString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim() === '') { + throw new MediaContractError(`${field} 必须是非空字符串`) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts index 347e626b..ce7747f0 100644 --- a/frontend/src/entities/media/index.ts +++ b/frontend/src/entities/media/index.ts @@ -7,3 +7,17 @@ declare const mediaReferenceBrand: unique symbol export type MediaReference = string & { readonly [mediaReferenceBrand]: 'MediaReference' } + +/** 上传媒体时的业务用途;值与后端 MediaCategory 枚举逐项对应。 */ +export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general' + +/** + * 媒体实体对页面和生成流程暴露的最小能力。 + * signal 用于页面离开、用户取消或新上传替换旧上传时终止仍在途的请求。 + */ +export interface MediaApis { + upload(file: File, category?: MediaCategory, signal?: AbortSignal): Promise +} + +// 上层只能通过 @/entities 公共入口取得真实适配器,避免页面深度导入内部文件。 +export { createMediaApis, MediaContractError } from './api' diff --git a/frontend/src/entities/playtest-inspection/api.test.ts b/frontend/src/entities/playtest-inspection/api.test.ts new file mode 100644 index 00000000..adb19d7f --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createPlaytestInspectionApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Playtest inspection API adapter', () => { + it('treats the backend business 404 as an empty current inspection', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 404, message: '尚未核验', data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect( + createPlaytestInspectionApis().get({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + }), + ).resolves.toBeNull() + }) + + it('sends stable target IDs and maps the saved inspection', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: '核验已保存', + data: { + id: 9, + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + create_at: '2026-08-04T00:00:00Z', + update_at: '2026-08-04T00:01:00Z', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const saved = await createPlaytestInspectionApis().save({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + status: 'issues_found', + }) + + expect(saved).toMatchObject({ id: '9', characterId: '25', status: 'issues_found' }) + const request = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(request.body))).toEqual({ + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + }) + }) +}) diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts new file mode 100644 index 00000000..6c62412e --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.ts @@ -0,0 +1,65 @@ +import { ApiError, get, post } from '@/shared/api' + +import type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from '.' + +interface BackendPlaytestInspection { + id: number + character_id: number + outfit_id: string + action_id: string + status: PlaytestInspection['status'] + create_at: string + update_at: string +} + +function toInspection(raw: BackendPlaytestInspection): PlaytestInspection { + return { + id: String(raw.id), + characterId: String(raw.character_id), + outfitId: raw.outfit_id, + actionId: raw.action_id, + status: raw.status, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function queryFor(target: PlaytestInspectionTarget): string { + const query = new URLSearchParams({ + character_id: target.characterId, + outfit_id: target.outfitId, + action_id: target.actionId, + }) + return query.toString() +} + +export function createPlaytestInspectionApis(): PlaytestInspectionApis { + return { + async get(target) { + try { + const raw = await get( + `/playtest-inspections?${queryFor(target)}`, + ) + return toInspection(raw) + } catch (cause) { + if (cause instanceof ApiError && cause.code === 404) return null + throw cause + } + }, + + async save(input: SavePlaytestInspectionInput) { + const raw = await post('/playtest-inspections', { + character_id: Number(input.characterId), + outfit_id: input.outfitId, + action_id: input.actionId, + status: input.status, + }) + return toInspection(raw) + }, + } +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 00000000..2a160165 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,24 @@ +/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface PlaytestInspectionTarget { + characterId: string + outfitId: string + actionId: string +} + +export interface PlaytestInspection extends PlaytestInspectionTarget { + id: string + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget { + status: PlaytestInspectionStatus +} + +export interface PlaytestInspectionApis { + get(target: PlaytestInspectionTarget): Promise + save(input: SavePlaytestInspectionInput): Promise +} diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts new file mode 100644 index 00000000..c83e4977 --- /dev/null +++ b/frontend/src/entities/project/api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createProjectApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('project API adapter', () => { + it('maps backend projects without losing server pagination metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [ + { + id: 3, + user_id: 7, + project_name: '像素冒险', + character_perspective: 1, + directional_movement: 2, + sprite_width: 64, + sprite_height: 96, + workflow_id: null, + game_style: '明亮像素风', + sprite_sample_url: null, + create_at: '2026-08-01T00:00:00Z', + update_at: '2026-08-02T00:00:00Z', + }, + ], + total: 21, + page: 2, + page_size: 10, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await createProjectApis().list({ page: 2, pageSize: 10 }) + + expect(result).toMatchObject({ total: 21, page: 2, pageSize: 10 }) + expect(result.items[0]).toMatchObject({ + id: '3', + ownerId: '7', + name: '像素冒险', + perspective: 'side', + directionalMovement: 'four-way', + spriteSize: { width: 64, height: 96 }, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8000/projects?page=2&page_size=10', + expect.any(Object), + ) + }) +}) diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts new file mode 100644 index 00000000..25eaba52 --- /dev/null +++ b/frontend/src/entities/project/api.ts @@ -0,0 +1,105 @@ +import type { CreateProjectInput, Project, ProjectApis, ProjectPageQuery } from '.' +import type { Paged } from '@/shared/pagination' + +import { del, get, getPage, post } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendProject { + id: number + user_id: number + project_name: string + character_perspective: number + directional_movement: number + sprite_width: number + sprite_height: number + workflow_id: number | null + game_style: string | null + sprite_sample_url: string | null + create_at: string + update_at: string +} + +/* ─── 映射 ─── */ + +const PERSPECTIVE_MAP: Record = { + 1: 'side', + 2: 'top-down', + 3: 'isometric', +} + +const MOVEMENT_MAP: Record = { + 1: 'single', + 2: 'four-way', + 3: 'eight-way', +} + +function toProject(raw: BackendProject): Project { + return { + id: String(raw.id), + ownerId: String(raw.user_id), + workflowId: raw.workflow_id === null ? null : String(raw.workflow_id), + name: raw.project_name, + perspective: PERSPECTIVE_MAP[raw.character_perspective] ?? 'side', + directionalMovement: MOVEMENT_MAP[raw.directional_movement] ?? 'single', + spriteSize: { width: raw.sprite_width, height: raw.sprite_height }, + gameStyle: raw.game_style, + sampleImageUrl: raw.sprite_sample_url, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function toPositiveId(value: string | undefined, fallback: number, field: string): number { + const parsed = value === undefined ? fallback : Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError(`${field} 必须是正整数 ID`) + } + return parsed +} + +function toCreatePayload(input: CreateProjectInput) { + return { + user_id: toPositiveId(input.ownerId, 1, 'ownerId'), + workflow_id: + input.workflowId === undefined || input.workflowId === null + ? (input.workflowId ?? null) + : toPositiveId(input.workflowId, 1, 'workflowId'), + project_name: input.name, + character_perspective: { side: 1, 'top-down': 2, isometric: 3 }[input.perspective], + directional_movement: { single: 1, 'four-way': 2, 'eight-way': 3 }[input.directionalMovement], + sprite_width: input.spriteSize.width, + sprite_height: input.spriteSize.height, + game_style: input.gameStyle ?? null, + sprite_sample_url: input.sampleImageUrl ?? null, + } +} + +/* ─── 适配器 ─── */ + +export function createProjectApis(): ProjectApis { + return { + async list(query?: ProjectPageQuery): Promise> { + const params = new URLSearchParams() + if (query?.page) params.set('page', String(query.page)) + if (query?.pageSize) params.set('page_size', String(query.pageSize)) + if (query?.ownerId) params.set('user_id', String(toPositiveId(query.ownerId, 1, 'ownerId'))) + const qs = params.toString() + const result = await getPage(`/projects${qs ? `?${qs}` : ''}`) + return { ...result, items: result.items.map(toProject) } + }, + + async get(id: string): Promise { + const raw = await get(`/projects/${encodeURIComponent(id)}`) + return toProject(raw) + }, + + async create(input: CreateProjectInput): Promise { + return toProject(await post('/projects', toCreatePayload(input))) + }, + + async remove(id: string): Promise { + await del(`/projects/${encodeURIComponent(id)}`) + }, + } +} diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 4c711640..c4e5f797 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -5,6 +5,8 @@ export interface Project { id: string /** Project 所属用户 ID;认证来源尚未冻结。 */ ownerId: string + /** 后端关联的工作流 ID;旧数据或尚未关联时为 null。 */ + workflowId?: string | null name: string /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */ perspective: CharacterPerspective @@ -27,6 +29,9 @@ export interface Project { /** 新建项目的入参。 */ export interface CreateProjectInput { + /** 认证模块接入前可省略,由组合层使用当前开发用户。 */ + ownerId?: string + workflowId?: string | null name: string perspective: CharacterPerspective directionalMovement: DirectionalMovement @@ -35,14 +40,8 @@ export interface CreateProjectInput { sampleImageUrl?: string | null } -/** 更新项目设置的入参;未提供的字段保持不变。 */ -export interface UpdateProjectInput { - name?: string - perspective?: CharacterPerspective - directionalMovement?: DirectionalMovement - spriteSize?: { width: number; height: number } - gameStyle?: string | null - sampleImageUrl?: string | null +export interface ProjectPageQuery extends PageQuery { + ownerId?: string } /** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ @@ -74,9 +73,8 @@ export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const /** Project 对应的一组后端接口。 */ export interface ProjectApis { - list(query?: PageQuery): Promise> + list(query?: ProjectPageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise - update(id: Project['id'], input: UpdateProjectInput): Promise remove(id: Project['id']): Promise } diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts new file mode 100644 index 00000000..d163c260 --- /dev/null +++ b/frontend/src/entities/user/api.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' + +import { registerApiAccessTokenProvider } from '@/shared/api' +import { createUserApis } from './api' + +describe('user API adapter', () => { + it('uses the authenticated backend contract and maps user responses to the entity shape', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const path = new URL(String(input)).pathname + if (path === '/auth/me') return jsonResponse(backendUser) + if ( + ['/auth/register', '/auth/login', '/auth/login-by-code', '/auth/refresh'].includes(path) + ) { + return jsonResponse({ access_token: 'access', refresh_token: 'refresh', user: backendUser }) + } + return jsonResponse(null) + }) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await apis.sendCode({ email: 'a@b.com', purpose: 'login' }) + await apis.register({ + email: 'a@b.com', + password: 'password1', + code: '123456', + nickname: 'Ada', + }) + await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' }) + await apis.loginByCode({ email: 'a@b.com', code: '123456' }) + await apis.refresh('refresh') + await apis.logout('refresh') + expect(await apis.me()).toEqual({ + id: 7, + email: 'a@b.com', + nickname: null, + emailVerifiedAt: null, + status: 'normal', + }) + await apis.changePassword({ oldPassword: 'password1', newPassword: 'password2' }) + + expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({ + accessToken: 'access', + refreshToken: 'refresh', + user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' }, + }) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.example.test/auth/send-code', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', purpose: 'login' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.example.test/auth/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'a@b.com', + password: 'password1', + code: '123456', + nickname: 'Ada', + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://api.example.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + 'https://api.example.test/auth/login-by-code', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', code: '123456' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + 'https://api.example.test/auth/refresh', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 6, + 'https://api.example.test/auth/logout', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 7, + 'https://api.example.test/auth/me', + expect.any(Object), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 8, + 'https://api.example.test/auth/change-password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ old_password: 'password1', new_password: 'password2' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 9, + 'https://api.example.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }), + }), + ) + }) + + it('maps a banned backend user status to the entity status', async () => { + const fetchMock = vi.fn( + async (): Promise => jsonResponse({ ...backendUser, status: 1 }), + ) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7, status: 'banned' }) + }) + + it('uses the registered session access token for authenticated requests by default', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer session-access') + return jsonResponse(backendUser) + }) + const unregister = registerApiAccessTokenProvider(() => 'session-access') + + try { + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7 }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } finally { + unregister() + } + }) + + it('preserves an explicit access-token provider override', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer explicit-access') + return jsonResponse(backendUser) + }) + const unregister = registerApiAccessTokenProvider(() => 'session-access') + + try { + const apis = createUserApis({ + baseUrl: 'https://api.example.test', + fetchFn: fetchMock, + getAccessToken: () => 'explicit-access', + }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7 }) + } finally { + unregister() + } + }) + + it('rejects malformed user and token DTOs instead of treating them as valid authentication data', async () => { + const invalidUser = { ...backendUser, status: 2 } + const invalidTokens = { refresh_token: 'refresh', user: backendUser } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(invalidUser)) + .mockResolvedValueOnce(jsonResponse(invalidTokens)) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).rejects.toMatchObject({ kind: 'invalid-response', data: invalidUser }) + await expect( + apis.login({ email: 'a@b.com', password: 'password1', code: '123456' }), + ).rejects.toMatchObject({ + kind: 'invalid-response', + data: invalidTokens, + }) + }) +}) + +const backendUser = { + id: 7, + email: 'a@b.com', + nickname: null, + email_verified_at: null, + status: 0, +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts new file mode 100644 index 00000000..53b86038 --- /dev/null +++ b/frontend/src/entities/user/api.ts @@ -0,0 +1,138 @@ +import type { AuthTokens, User, UserApis } from '.' + +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' + +interface BackendUser { + id: number + email: string + nickname: string | null + email_verified_at: string | null + status: number +} + +interface BackendAuthTokens { + access_token: string + refresh_token: string + user: BackendUser +} + +export interface CreateUserApisOptions extends ApiClientOptions { + client?: ApiClient +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data }) +} + +function toUser(raw: unknown): User { + if ( + !isRecord(raw) || + typeof raw.id !== 'number' || + typeof raw.email !== 'string' || + (typeof raw.nickname !== 'string' && raw.nickname !== null) || + (typeof raw.email_verified_at !== 'string' && raw.email_verified_at !== null) || + (raw.status !== 0 && raw.status !== 1) + ) { + invalidResponse(raw) + } + + return { + id: raw.id, + email: raw.email, + nickname: raw.nickname, + emailVerifiedAt: raw.email_verified_at, + status: raw.status === 1 ? 'banned' : 'normal', + } +} + +function toAuthTokens(raw: unknown): AuthTokens { + if ( + !isRecord(raw) || + typeof raw.access_token !== 'string' || + typeof raw.refresh_token !== 'string' || + !isRecord(raw.user) + ) { + invalidResponse(raw) + } + + return { + accessToken: raw.access_token, + refreshToken: raw.refresh_token, + user: toUser(raw.user), + } +} + +export function createUserApis(options: CreateUserApisOptions = {}): UserApis { + const { client, ...clientOptions } = options + const apiClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + }) + + return { + async sendCode(input): Promise { + await apiClient.request('/auth/send-code', { method: 'POST', json: input }) + }, + + async register(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/register', { + method: 'POST', + json: input, + }), + ) + }, + + async login(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login', { + method: 'POST', + json: input, + }), + ) + }, + + async loginByCode(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login-by-code', { + method: 'POST', + json: input, + }), + ) + }, + + async refresh(refreshToken): Promise { + return toAuthTokens( + await apiClient.request('/auth/refresh', { + method: 'POST', + json: { refresh_token: refreshToken }, + }), + ) + }, + + async logout(refreshToken): Promise { + await apiClient.request('/auth/logout', { + method: 'POST', + json: { refresh_token: refreshToken }, + }) + }, + + async me(): Promise { + return toUser(await apiClient.request('/auth/me')) + }, + + async changePassword(input): Promise { + await apiClient.request('/auth/change-password', { + method: 'POST', + json: { old_password: input.oldPassword, new_password: input.newPassword }, + }) + }, + } +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 00000000..4ff9e737 --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,35 @@ +/** 已认证用户的前端领域表示。 */ +export interface User { + id: number + email: string + nickname: string | null + emailVerifiedAt: string | null + status: 'normal' | 'banned' +} + +/** 一次认证成功后由后端签发的访问与刷新令牌。 */ +export interface AuthTokens { + accessToken: string + refreshToken: string + user: User +} + +/** 用户认证与账户设置的后端接口。 */ +export interface UserApis { + sendCode(input: { + email: string + purpose: 'login' | 'register' | 'reset_password' + }): Promise + register(input: { + email: string + password: string + code: string + nickname?: string + }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 00000000..62eafbdb --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,14 @@ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */ +export const WORKFLOW_NODE_ORDER = [ + 'character-setup', + 'character-template', + 'action-first-frame', + 'action-full-frame', + 'review', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b5..4fcdd480 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,112 +1,126 @@ -import type { Generation } from '../generation' +import type { + Generation, + CharacterImageGenerationInput, + CharacterImageOutput, + CharacterActionGenerationInput, + CharacterActionOutput, +} from '../generation' +import type { MediaReference } from '../media' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_PURPOSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_NODE_ORDER, + WORKFLOW_NODE_STATUSES, +} from './constants' -/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export { WORKFLOW_NODE_ORDER } from './constants' /** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] -/** - * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 - */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - -/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ -export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] +/** 前端流程节点类型,与 WORKFLOW_NODE_ORDER 的成员保持一致。 */ +export type WorkflowNodeType = (typeof WORKFLOW_NODE_ORDER)[number] /** - * 步骤的可用性和执行结果;不直接复用后端任务状态。 + * 节点的可用性和执行结果;不直接复用后端任务状态。 * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' - -/** - * 单个版本的生命周期。 - * abandoned 表示停止沿用但仍保留为历史。 - */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' +export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number] /** * 整次流程的汇总状态。 - * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * interrupted 只表示用户主动停止自动推进,不等于 failed 或 completed。 */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -/** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' +/** 生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] -/** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' +/** 导出阶段的汇总状态。 */ +export type ExportStatus = (typeof EXPORT_STATUSES)[number] -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { +interface WorkflowNodeBase { /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowStepType - status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown + status: WorkflowNodeStatus /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 - * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 - * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 + * 本节点已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 任务本身不认识节点,反向关联不存在。 */ taskId: Generation['id'] | null - /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ - referenceStepIds: string[] + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交。 + */ + submissionId: string | null + /** 节点失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null +} + +/** 角色资料节点保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupNodeInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowNode extends WorkflowNodeBase { + type: 'character-setup' + input: CharacterSetupNodeInput | null + output: null +} + +export interface CharacterTemplateWorkflowNode extends WorkflowNodeBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterImageGenerationInput | null + output: CharacterImageOutput | null +} + +/** 首帧生成节点:生成单帧角色动作候选。 */ +export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-first-frame' + input: CharacterActionGenerationInput | null + output: CharacterActionOutput | null +} + +/** 完整帧率生成节点:基于首帧生成完整动画。 */ +export interface ActionFullFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-full-frame' + input: CharacterActionGenerationInput | null + output: CharacterActionOutput | null +} + +type RemainingWorkflowNodeType = Exclude< + WorkflowNodeType, + 'character-setup' | 'character-template' | 'action-first-frame' | 'action-full-frame' +> + +interface RemainingWorkflowNode extends WorkflowNodeBase { + type: RemainingWorkflowNodeType + /** 审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown } /** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 - * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 执行线中的流程节点。 + * 前四个执行节点已冻结输入输出;后续进入对应纵切时再收窄。 */ -export interface WorkflowRevision { - id: string - /** 首次创建的版本没有来源,因此为 null。 */ - basedOnRevisionId: string | null - /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ - restartStepId: string | null - status: WorkflowRevisionStatus - /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 - */ - steps: WorkflowStep[] - generationStatus: GenerationStatus - exportStatus: ExportStatus - createdAt: string -} +export type WorkflowNode = + | CharacterSetupWorkflowNode + | CharacterTemplateWorkflowNode + | ActionFirstFrameWorkflowNode + | ActionFullFrameWorkflowNode + | RemainingWorkflowNode /** * 一次由前端推进的页面流程。 - * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 - * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 + * + * 后端采用树状纯存储模型,不提供回退或版本历史能力。用户从旧节点重做时, + * 前端直接覆盖当前节点结果,不保留被废弃结果的历史链路。 + * 一个 Character 复用同一条 Run;新增动作不会创建第二条 Run。 */ export interface WorkflowRun { id: string @@ -115,28 +129,36 @@ export interface WorkflowRun { characterId: string | null /** 已有角色加动作时的目标造型;新建角色时为 null。 */ outfitId: string | null + /** 建立这条 Run 时的根意图;后续追加动作不会把 create_character 改写为 add_action。 */ purpose: WorkflowRunPurpose - driver: WorkflowDriver status: WorkflowRunStatus - /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ - currentRevisionId: string - /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ - revisions: WorkflowRevision[] + /** + * 当前执行线中的节点。前三个节点串行推进,之后可随时追加 action-generation / review + * 成对节点。多个 action-generation 可并发——互不阻塞。数组位置是节点顺序的唯一来源。 + */ + nodes: WorkflowNode[] + generationStatus: GenerationStatus + exportStatus: ExportStatus /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ prompt: string | null + createdAt: string } -/** 两种入口共享的创建字段。 */ +/** + * @deprecated WorkflowRevision 已合并到 WorkflowRun,直接用 WorkflowRun。 + */ +export type WorkflowRevision = WorkflowRun + +/** 创建 WorkflowRun 的共享字段。 */ interface CreateWorkflowRunInputBase { projectId: string - driver: WorkflowDriver /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ prompt?: string } /** * 创建 WorkflowRun 的输入。 - * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 + * add_action 分支把已有角色、造型、母版和基准帧设为必填。 */ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & ( @@ -155,3 +177,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 00000000..3bf0c1a8 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_NODE_ORDER } from './constants' +import type { WorkflowRun, WorkflowNode } from './index' +import { createWorkflowRunStore } from './store' + +function createNodes(): WorkflowNode[] { + return WORKFLOW_NODE_ORDER.map((type, index) => { + const common = { + id: `run-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowNode + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + status: 'active', + nodes: createNodes(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + prompt: 'Create a slime', + createdAt: '2026-07-30T12:00:00.000Z', + } +} + +/** 后端响应包装:Response { code, message, data: T } */ +function wrapResponse(data: T) { + return { code: 0, message: 'ok', data } +} + +/** 前端 WorkflowRun → 后端 nodes[0] 载荷。与 store._toNodePayload 保持一致。 */ +function packNodes(run: WorkflowRun): Record { + return { + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + purpose: run.purpose, + status: run.status, + nodes: run.nodes, + generationStatus: run.generationStatus, + exportStatus: run.exportStatus, + prompt: run.prompt, + createdAt: run.createdAt, + } +} + +const BASE = '/workflow-runs' + +function createMockApi() { + // 后端内部使用前端 string ID 索引(保持简单); + // 响应时仍返回整数 ID,由被测 _fromBackend 转换回 string。 + const runs = new Map() + let nextNumericId = 1 + + const fetch = vi.fn(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.url + const method = init?.method ?? 'GET' + + // POST /workflow-runs → create + if (method === 'POST' && url === BASE) { + const body = JSON.parse((init?.body as string) ?? '{}') + const node = body.nodes?.[0] ?? {} + const runId = `run-${nextNumericId}` + const run: WorkflowRun = { + id: runId, + // projectId 优先从 nodes 取(保留原始前端 string 值),回退到 project_id + projectId: + (node.projectId as string) ?? String(body.project_id ?? ''), + characterId: node.characterId ?? null, + outfitId: node.outfitId ?? null, + purpose: node.purpose ?? 'create_character', + status: node.status ?? 'active', + nodes: node.nodes ?? [], + generationStatus: node.generationStatus ?? 'not_started', + exportStatus: node.exportStatus ?? 'not_exported', + prompt: node.prompt ?? null, + createdAt: node.createdAt ?? new Date().toISOString(), + } + runs.set(runId, run) + return wrapResponse({ + id: nextNumericId++, + project_id: body.project_id, + nodes: [packNodes(run)], + status: 'active', + version: 1, + }) + } + + // GET /workflow-runs → list all (getByCharacter 回退) + if (method === 'GET' && url === BASE) { + return wrapResponse( + [...runs.entries()].map(([rid, run]) => ({ + id: Number(rid.split('-')[1] ?? rid), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + })), + ) + } + + // GET /workflow-runs?project_id=X → list by project + // GET /workflow-runs?characterId=X → list by character + if (method === 'GET' && url.startsWith(`${BASE}?`)) { + const params = new URLSearchParams(url.split('?')[1]) + const characterId = params.get('characterId') + const projectId = params.get('project_id') + const all = [...runs.entries()] + .filter(([, r]) => { + if (characterId) return r.characterId === characterId + if (projectId) return r.projectId === projectId + return true + }) + .map(([rid, run]) => ({ + id: Number(rid.split('-')[1] ?? rid), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + })) + return wrapResponse(all) + } + + // GET /workflow-runs/{id} → get by ID + if (method === 'GET' && url.startsWith(`${BASE}/`)) { + const numericId = url.split('/').pop()! + const runId = `run-${numericId}` + const run = runs.get(runId) + if (!run) throw Object.assign(new Error('Not Found'), { status: 404 }) + return wrapResponse({ + id: Number(numericId), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + }) + } + + // PATCH /workflow-runs/{id} → update + if (method === 'PATCH' && url.startsWith(`${BASE}/`)) { + const numericId = url.split('/').pop()! + const runId = `run-${numericId}` + if (!runs.has(runId)) + throw Object.assign(new Error('Not Found'), { status: 404 }) + const body = JSON.parse((init?.body as string) ?? '{}') + const node = body.nodes?.[0] ?? {} + const existing = runs.get(runId)! + const updated: WorkflowRun = { + id: runId, + projectId: String(body.project_id ?? existing.projectId), + characterId: + node.characterId !== undefined + ? node.characterId + : existing.characterId, + outfitId: + node.outfitId !== undefined ? node.outfitId : existing.outfitId, + purpose: node.purpose ?? existing.purpose, + status: node.status ?? existing.status, + nodes: node.nodes ?? existing.nodes, + generationStatus: + node.generationStatus ?? existing.generationStatus, + exportStatus: node.exportStatus ?? existing.exportStatus, + prompt: node.prompt !== undefined ? node.prompt : existing.prompt, + createdAt: node.createdAt ?? existing.createdAt, + } + runs.set(runId, updated) + return wrapResponse({ + id: Number(numericId), + project_id: Number(updated.projectId.split('-')[1] ?? updated.projectId), + nodes: [packNodes(updated)], + status: 'active', + version: 1, + }) + } + + throw Object.assign(new Error('Not Found'), { status: 404 }) + }) + + return { runs, fetch } +} + +describe('createWorkflowRunStore', () => { + it('creates a run and returns the server-persisted snapshot', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const run = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + prompt: 'A fire dragon', + }) + + expect(run.id).toBeTruthy() + expect(run.prompt).toBe('A fire dragon') + expect(run.purpose).toBe('create_character') + expect(api.fetch).toHaveBeenCalledWith( + BASE, + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('gets a run by ID', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + + const found = await store.get(created.id) + + expect(found?.id).toBe(created.id) + }) + + it('returns null when getting a non-existent run', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const result = await store.get('999') + + expect(result).toBeNull() + }) + + it('does not disguise a server failure as a missing run', async () => { + const failure = Object.assign(new Error('Service Unavailable'), { + status: 503, + }) + const store = createWorkflowRunStore({ + api: { fetch: vi.fn().mockRejectedValue(failure) }, + }) + + await expect(store.get('run-1')).rejects.toBe(failure) + await expect(store.getByCharacter('character-1')).rejects.toBe(failure) + }) + + it('finds the run bound to a character', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + created.characterId = 'character-1' + await store.save(created) + + const found = await store.getByCharacter('character-1') + + expect(found?.id).toBe(created.id) + expect(found?.characterId).toBe('character-1') + }) + + it('returns null when no run is bound to a character', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const result = await store.getByCharacter('missing') + + expect(result).toBeNull() + }) + + it('lists runs by project', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + await store.create({ projectId: 'project-1', purpose: 'create_character' }) + await store.create({ + projectId: 'project-1', + purpose: 'create_character', + prompt: '', + }) + + const runs = await store.list('project-1') + + expect(runs).toHaveLength(2) + expect(runs[0]?.projectId).toBe('project-1') + expect(runs[1]?.projectId).toBe('project-1') + }) + + it('saves a run and persists changes', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + + created.status = 'completed' + await store.save(created) + + const reloaded = await store.get(created.id) + expect(reloaded?.status).toBe('completed') + }) + + it('creates an add_action run with required character fields', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const run = await store.create({ + projectId: 'project-1', + purpose: 'add_action', + characterId: 'char-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: ['https://example.com/frame1.png'], + }) + + expect(run.purpose).toBe('add_action') + expect(run.characterId).toBe('char-1') + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 00000000..2f22ddd8 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,240 @@ +import type { CreateWorkflowRunInput, WorkflowRun } from "./index"; + +/** + * WorkflowRun 持久化契约。 + * + * 持久化走服务端 API,所有方法均为异步。前端不保留 localStorage 副本, + * 也不提供 subscribe / subscribeAll——状态变更由前端逻辑自身驱动。 + * + * 后端 API 契约(对齐 commit 4246389b) + * -------------------------------- + * POST /workflow-runs 创建执行记录 + * GET /workflow-runs/{id} 获取执行记录(含 nodes JSONB) + * PATCH /workflow-runs/{id} 全量更新(含 nodes) + * DELETE /workflow-runs/{id} 软删除 + * + * 后端只做存储,不感知节点结构。前端 WorkflowRun 的完整状态(除 id / projectId 外) + * 序列化到后端 nodes 字段。id/projectId 映射为后端顶层 id/project_id。 + */ +export interface WorkflowRunStore { + /** 创建一条新的 WorkflowRun,返回服务端持久化后的完整快照。 */ + create(input: CreateWorkflowRunInput): Promise; + /** 按 ID 读取 WorkflowRun 最新快照;不存在时返回 null。 */ + get(runId: WorkflowRun["id"]): Promise; + /** 按已关联的 Character ID 查找唯一绑定的 WorkflowRun(客户端过滤)。 */ + getByCharacter(characterId: string): Promise; + /** 列出当前项目下的全部 WorkflowRun。 */ + list(projectId?: string): Promise; + /** 保存 WorkflowRun 最新状态到服务端。 */ + save(run: WorkflowRun): Promise; +} + +export interface CreateWorkflowRunStoreOptions { + /** + * HTTP 客户端,提供 fetch 方法。 + * 不传时使用仅内存存储(测试友好)。 + */ + api?: { fetch(input: RequestInfo, init?: RequestInit): Promise }; +} + +// ── 序列化 ───────────────────────────────────────────────────────────────── + +/** 后端 WorkflowRun 响应形状(nodes JSONB 透传)。 */ +interface BackendWorkflowRun { + id: number; + project_id: number; + nodes: Record[]; + status: string; + version: number; +} + +/** 把前端 WorkflowRun 的丰富字段序列化到后端 nodes 载荷中。 */ +function _toNodePayload(run: WorkflowRun): Record { + return { + // projectId 同时写进 nodes:后端 project_id 是整数,前端用 string ID, + // 读取时优先从 nodes 还原以保持原始值。 + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + purpose: run.purpose, + status: run.status, + nodes: run.nodes, + generationStatus: run.generationStatus, + exportStatus: run.exportStatus, + prompt: run.prompt, + createdAt: run.createdAt, + }; +} + +/** 从后端响应重建前端 WorkflowRun。 */ +function _fromBackend(b: BackendWorkflowRun): WorkflowRun { + const node = b.nodes[0] ?? {}; + return { + id: String(b.id), + // 优先从 nodes 取 projectId(保持前端原始 string 值), + // 不存时回退到后端 project_id。 + projectId: + (node.projectId as string) ?? String(b.project_id), + characterId: (node.characterId as string) ?? null, + outfitId: (node.outfitId as string) ?? null, + purpose: (node.purpose as WorkflowRun["purpose"]) ?? "create_character", + status: (node.status as WorkflowRun["status"]) ?? "active", + nodes: (node.nodes as WorkflowRun["nodes"]) ?? [], + generationStatus: + (node.generationStatus as WorkflowRun["generationStatus"]) ?? + "not_started", + exportStatus: + (node.exportStatus as WorkflowRun["exportStatus"]) ?? "not_exported", + prompt: (node.prompt as string | null) ?? null, + createdAt: (node.createdAt as string) ?? new Date().toISOString(), + }; +} + +// ── 内存存储(测试/过渡期) ────────────────────────────────────────────────── + +function createInMemoryStore(): WorkflowRunStore { + const runs = new Map(); + + return { + async create(input) { + const run: WorkflowRun = { + id: `run-${runs.size + 1}`, + projectId: input.projectId, + characterId: + "characterId" in input ? (input.characterId as string) : null, + outfitId: "outfitId" in input ? (input.outfitId as string) : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: new Date().toISOString(), + }; + runs.set(run.id, structuredClone(run)); + return structuredClone(run); + }, + + async get(runId) { + const run = runs.get(runId); + return run ? structuredClone(run) : null; + }, + + async getByCharacter(characterId) { + for (const run of runs.values()) { + if (run.characterId === characterId) return structuredClone(run); + } + return null; + }, + + async list(projectId) { + return [...runs.values()] + .filter((run) => !projectId || run.projectId === projectId) + .map((run) => structuredClone(run)); + }, + + async save(run) { + runs.set(run.id, structuredClone(run)); + }, + }; +} + +// ── HTTP 存储 ────────────────────────────────────────────────────────────── + +function isNotFoundError(cause: unknown): boolean { + return ( + typeof cause === "object" && + cause !== null && + "status" in cause && + cause.status === 404 + ); +} + +/** + * 创建 WorkflowRunStore。 + * 传入 api 时走 HTTP 持久化(对齐后端 /workflow-runs 接口), + * 否则使用仅内存存储(用于测试和过渡期)。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const api = options.api; + if (!api) return createInMemoryStore(); + + /** 后端通用响应包装:Response { code, message, data: T } */ + function _unwrap(response: unknown): T { + const r = response as { data?: T }; + if (r.data !== undefined) return r.data; + return response as T; + } + + return { + async create(input) { + const response = await api.fetch("/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_id: Number(input.projectId), + nodes: [ + { + // 创建时前端 WorkflowRun 字段(除 id)全部进入 nodes + projectId: input.projectId, + characterId: + "characterId" in input ? input.characterId : null, + outfitId: "outfitId" in input ? input.outfitId : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: new Date().toISOString(), + }, + ], + }), + }); + return _fromBackend(_unwrap(response) as BackendWorkflowRun); + }, + + async get(runId) { + try { + const response = await api.fetch(`/workflow-runs/${runId}`); + return _fromBackend(_unwrap(response) as BackendWorkflowRun); + } catch (cause) { + if (isNotFoundError(cause)) return null; + throw cause; + } + }, + + async getByCharacter(characterId) { + try { + // 后端无 characterId 查询参数,先全量拉取再客户端过滤。 + const runs = await api.fetch("/workflow-runs"); + const all = (_unwrap(runs) as BackendWorkflowRun[]).map(_fromBackend); + return all.find((r) => r.characterId === characterId) ?? null; + } catch (cause) { + if (isNotFoundError(cause)) return null; + throw cause; + } + }, + + async list(projectId) { + const query = projectId + ? `?project_id=${encodeURIComponent(projectId)}` + : ""; + const response = await api.fetch(`/workflow-runs${query}`); + const items = _unwrap(response) as BackendWorkflowRun[]; + return items.map(_fromBackend); + }, + + async save(run) { + await api.fetch(`/workflow-runs/${run.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: [_toNodePayload(run)], + }), + }); + }, + }; +} diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx new file mode 100644 index 00000000..c1b8609e --- /dev/null +++ b/frontend/src/features/auth-session/index.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { StrictMode, type ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { getApiAccessToken } from '@/shared/api' +import { + AuthSessionProvider, + ProtectedRoute, + createLocalUserApis, + resolveAuthMode, + useAuthSession, +} from '.' +import { REFRESH_TOKEN_STORAGE_KEY } from './session-storage' + +const user: User = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', +} + +let session: ReturnType | undefined + +function SessionProbe() { + session = useAuthSession() + return {session.state.status} +} + +function LocationProbe() { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +function renderProvider(apis: UserApis, children: ReactNode = , strict = false) { + const tree = {children} + return render(strict ? {tree} : tree) +} + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + session = undefined + window.localStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('AuthSessionProvider', () => { + it('rotates the stored refresh token once in StrictMode, exposes it only through memory, then loads the current user', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const rotated = tokens('rotated-access', 'rotated-refresh') + const apis = createApis({ + refresh: vi.fn(async () => rotated), + me: vi.fn(async () => user), + }) + + renderProvider(apis, , true) + + await waitFor(() => + expect(screen.getByLabelText('session-status').textContent).toBe('authenticated'), + ) + expect(apis.refresh).toHaveBeenCalledTimes(1) + expect(apis.refresh).toHaveBeenCalledWith('stored-refresh') + expect(apis.me).toHaveBeenCalledTimes(1) + expect(getApiAccessToken()).toBe('rotated-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh') + }) + + it('stores the rotated refresh token and keeps the access token in memory after login', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + expect(session?.state).toEqual({ status: 'authenticated', user }) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('unregisters its in-memory access token provider when unmounted', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + const view = renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + expect(getApiAccessToken()).toBe('login-access') + + view.unmount() + + expect(getApiAccessToken()).toBeUndefined() + }) + + it('falls back to a cleared guest session when startup token rotation fails', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'revoked-refresh') + const apis = createApis({ + refresh: vi.fn(async () => { + throw new Error('refresh revoked') + }), + }) + + renderProvider(apis) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not restore a stale startup session after the user logs out', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupRefresh = deferred() + const apis = createApis({ refresh: vi.fn(() => startupRefresh.promise) }) + renderProvider(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('stored-refresh')) + + await act(async () => { + await session?.logout() + }) + await act(async () => { + startupRefresh.resolve(tokens('stale-access', 'stale-refresh')) + await startupRefresh.promise + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not let a stale startup failure clear a newer login', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupMe = deferred() + const apis = createApis({ + refresh: vi.fn(async () => tokens('startup-access', 'startup-refresh')), + me: vi.fn(() => startupMe.promise), + login: vi.fn(async () => tokens('login-access', 'login-refresh')), + }) + renderProvider(apis) + await waitFor(() => expect(apis.me).toHaveBeenCalledTimes(1)) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + await act(async () => { + startupMe.reject(new Error('stale me failure')) + await Promise.resolve() + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'authenticated', user })) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('clears the local session before surfacing a backend logout failure', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const apis = createApis({ + refresh: vi.fn(async () => tokens('access', 'rotated-refresh')), + me: vi.fn(async () => user), + logout: vi.fn(async () => { + throw new Error('backend unavailable') + }), + }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('authenticated')) + + await act(async () => { + await expect(session?.logout()).rejects.toThrow('backend unavailable') + }) + + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + expect(apis.logout).toHaveBeenCalledWith('rotated-refresh') + }) + + it('clears the session after a successful password change because the backend revokes refresh tokens', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('access', 'refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await session?.changePassword({ oldPassword: 'password1', newPassword: 'password2' }) + }) + + expect(apis.changePassword).toHaveBeenCalledWith({ + oldPassword: 'password1', + newPassword: 'password2', + }) + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('refreshes an expiring JWT sixty seconds before expiry and rotates the refresh token', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-05T00:00:00Z')) + const expiringAccess = jwtExpiringAt(Date.now() + 120_000) + const refreshedAccess = jwtExpiringAt(Date.now() + 3_600_000) + const apis = createApis({ login: vi.fn(async () => tokens(expiringAccess, 'refresh-1')) }) + vi.mocked(apis.refresh).mockResolvedValue(tokens(refreshedAccess, 'refresh-2')) + renderProvider(apis) + await act(async () => Promise.resolve()) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + + expect(apis.refresh).toHaveBeenCalledWith('refresh-1') + expect(getApiAccessToken()).toBe(refreshedAccess) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-2') + }) + + it('exposes every account action as a Promise-returning operation', async () => { + const apis = createApis() + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await expect( + session?.sendCode({ email: 'ada@example.test', purpose: 'login' }), + ).resolves.toBeUndefined() + await expect( + session?.register({ email: 'ada@example.test', password: 'password1', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + await expect( + session?.loginByCode({ email: 'ada@example.test', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + }) +}) + +describe('resolveAuthMode', () => { + it('开发环境默认使用本地登录,生产环境始终使用真实后端认证', () => { + expect(resolveAuthMode('', true)).toBe('local') + expect(resolveAuthMode('local', true)).toBe('local') + expect(resolveAuthMode('backend', true)).toBe('backend') + expect(resolveAuthMode('local', false)).toBe('backend') + }) +}) + +describe('createLocalUserApis', () => { + it('只保存本地用户资料,不把密码和验证码写入浏览器存储,并可恢复会话', async () => { + const apis = createLocalUserApis() + const authenticated = await apis.register({ + email: 'ada@example.test', + password: 'password1', + code: '123456', + nickname: 'Ada', + }) + + expect(authenticated.user).toMatchObject({ + email: 'ada@example.test', + nickname: 'Ada', + status: 'normal', + }) + expect(JSON.stringify(window.localStorage)).not.toContain('password1') + expect(JSON.stringify(window.localStorage)).not.toContain('123456') + + await expect(createLocalUserApis().refresh(authenticated.refreshToken)).resolves.toMatchObject({ + user: authenticated.user, + }) + }) +}) + +describe('ProtectedRoute', () => { + it.each([ + [ + '/projects/7?tab=assets#frames', + '/?account=login&returnTo=%2Fprojects%2F7%3Ftab%3Dassets%23frames', + ], + ['//evil.example/path', '/?account=login'], + ])( + 'returns a guest from %s to the public login panel with only a safe same-site returnTo', + async (entry, expected) => { + const apis = createApis() + renderProvider( + apis, + + + +

Private

+ + } + /> + } /> +
+
, + ) + + await waitFor(() => expect(screen.getByLabelText('location').textContent).toBe(expected)) + expect(screen.queryByRole('heading', { name: 'Private' })).toBeNull() + }, + ) +}) + +function tokens(accessToken: string, refreshToken: string): AuthTokens { + return { accessToken, refreshToken, user } +} + +function jwtExpiringAt(expiryTime: number): string { + const payload = btoa(JSON.stringify({ exp: Math.floor(expiryTime / 1000) })) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + return `header.${payload}.signature` +} + +function createApis(overrides: Partial = {}): UserApis { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens('access', 'refresh')), + login: vi.fn(async () => tokens('access', 'refresh')), + loginByCode: vi.fn(async () => tokens('access', 'refresh')), + refresh: vi.fn(async () => tokens('access', 'refresh')), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx new file mode 100644 index 00000000..07e51580 --- /dev/null +++ b/frontend/src/features/auth-session/index.tsx @@ -0,0 +1,396 @@ +/* oxlint-disable react/only-export-components -- 该模块的公共契约同时导出 Provider、路由守卫和 hook。 */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { Navigate, useLocation } from 'react-router' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { registerApiAccessTokenProvider } from '@/shared/api' +import { clearRefreshToken, loadRefreshToken, saveRefreshToken } from './session-storage' + +export type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null } + | { status: 'authenticated'; user: User } + +export interface AuthSessionValue { + state: AuthSessionState + sendCode(input: Parameters[0]): Promise + register(input: Parameters[0]): Promise + login(input: Parameters[0]): Promise + loginByCode(input: Parameters[0]): Promise + changePassword(input: Parameters[0]): Promise + logout(): Promise +} + +export interface AuthSessionProviderProps { + apis: UserApis + children: ReactNode +} + +export type AuthMode = 'local' | 'backend' + +interface RefreshInFlight { + refreshToken: string + promise: Promise +} + +interface BootstrapResult { + user: User +} + +const AuthSessionContext = createContext(null) + +/** + * 本地开发默认使用浏览器内的开发登录,生产环境始终连接真实认证接口。 + * 开发者仍可通过 VITE_AUTH_MODE=backend 在本地完整调试登录流程。 + */ +export function resolveAuthMode( + value = import.meta.env.VITE_AUTH_MODE, + development = import.meta.env.DEV, +): AuthMode { + if (!development) return 'backend' + return value === 'backend' ? 'backend' : 'local' +} + +/** + * 按运行模式装配认证上下文;本地与后端适配器共用同一份会话逻辑。 + */ +export function AuthModeProvider({ apis, children }: { apis: UserApis; children: ReactNode }) { + return {children} +} + +const LOCAL_USER_STORAGE_KEY = 'windup.auth.local-user' + +/** + * 本地开发认证适配器。 + * + * 它只保存可展示的用户资料,不保存或校验密码、验证码;生产构建不会装配它。 + * 后端认证可用后只需切换 VITE_AUTH_MODE=backend,页面和会话逻辑无需重写。 + */ +export function createLocalUserApis(): UserApis { + let currentUser = readLocalUser() + + const authenticate = (email: string, nickname?: string): AuthTokens => { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) throw new Error('请输入邮箱') + currentUser = { + id: currentUser?.email === normalizedEmail ? currentUser.id : 1, + email: normalizedEmail, + nickname: nickname?.trim() || currentUser?.nickname || normalizedEmail.split('@')[0] || null, + emailVerifiedAt: currentUser?.emailVerifiedAt ?? new Date().toISOString(), + status: 'normal', + } + saveLocalUser(currentUser) + return localTokens(currentUser) + } + + return { + async sendCode() {}, + async register(input) { + return authenticate(input.email, input.nickname) + }, + async login(input) { + return authenticate(input.email) + }, + async loginByCode(input) { + return authenticate(input.email) + }, + async refresh(refreshToken) { + currentUser = readLocalUser() + if (!currentUser || refreshToken !== localRefreshToken(currentUser)) { + throw new Error('本地登录已失效') + } + return localTokens(currentUser) + }, + async logout() {}, + async me() { + currentUser = readLocalUser() + if (!currentUser) throw new Error('本地用户不存在') + return currentUser + }, + async changePassword() { + if (!currentUser) throw new Error('请先登录') + }, + } +} + +function localTokens(user: User): AuthTokens { + return { + accessToken: `local-access:${user.id}`, + refreshToken: localRefreshToken(user), + user, + } +} + +function localRefreshToken(user: User): string { + return `local-refresh:${user.id}` +} + +function readLocalUser(): User | null { + try { + const raw = globalThis.localStorage?.getItem(LOCAL_USER_STORAGE_KEY) + if (!raw) return null + const value: unknown = JSON.parse(raw) + if ( + !isRecord(value) || + typeof value.id !== 'number' || + typeof value.email !== 'string' || + (typeof value.nickname !== 'string' && value.nickname !== null) || + (typeof value.emailVerifiedAt !== 'string' && value.emailVerifiedAt !== null) || + (value.status !== 'normal' && value.status !== 'banned') + ) { + return null + } + return value as unknown as User + } catch { + return null + } +} + +function saveLocalUser(user: User): void { + globalThis.localStorage?.setItem(LOCAL_USER_STORAGE_KEY, JSON.stringify(user)) +} + +export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) { + const [state, setState] = useState({ status: 'booting', user: null }) + const [accessTokenVersion, setAccessTokenVersion] = useState(0) + const accessTokenRef = useRef(null) + const refreshTokenRef = useRef(null) + const sessionGenerationRef = useRef(0) + const refreshInFlightRef = useRef(null) + const bootstrapPromiseRef = useRef | null>(null) + const bootstrapGenerationRef = useRef(null) + + const storeTokenMaterial = useCallback((tokens: AuthTokens) => { + accessTokenRef.current = tokens.accessToken + refreshTokenRef.current = tokens.refreshToken + saveRefreshToken(tokens.refreshToken) + setAccessTokenVersion((version) => version + 1) + }, []) + + const applyTokens = useCallback( + (tokens: AuthTokens) => { + sessionGenerationRef.current += 1 + storeTokenMaterial(tokens) + setState({ status: 'authenticated', user: tokens.user }) + }, + [storeTokenMaterial], + ) + + const clearSession = useCallback(() => { + sessionGenerationRef.current += 1 + accessTokenRef.current = null + refreshTokenRef.current = null + clearRefreshToken() + setAccessTokenVersion((version) => version + 1) + setState({ status: 'guest', user: null }) + }, []) + + const rotateTokens = useCallback( + (refreshToken: string): Promise => { + const inFlight = refreshInFlightRef.current + if (inFlight?.refreshToken === refreshToken) return inFlight.promise + + const promise = apis.refresh(refreshToken) + const current = { refreshToken, promise } + refreshInFlightRef.current = current + const clearInFlight = () => { + if (refreshInFlightRef.current === current) refreshInFlightRef.current = null + } + void promise.then(clearInFlight, clearInFlight) + return promise + }, + [apis], + ) + + useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), []) + + useEffect(() => { + let active = true + + if (!bootstrapPromiseRef.current) { + const bootstrapGeneration = sessionGenerationRef.current + bootstrapGenerationRef.current = bootstrapGeneration + const persistedRefreshToken = loadRefreshToken() + bootstrapPromiseRef.current = persistedRefreshToken + ? rotateTokens(persistedRefreshToken).then(async (tokens) => { + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + storeTokenMaterial(tokens) + const user = await apis.me() + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + return { user } + }) + : Promise.resolve(null) + } + + void bootstrapPromiseRef.current.then( + (result) => { + if ( + !active || + bootstrapGenerationRef.current !== sessionGenerationRef.current || + result === undefined + ) + return + if (!result) { + clearSession() + return + } + setState({ status: 'authenticated', user: result.user }) + }, + () => { + if (active && bootstrapGenerationRef.current === sessionGenerationRef.current) + clearSession() + }, + ) + + return () => { + active = false + } + }, [apis, clearSession, rotateTokens, storeTokenMaterial]) + + useEffect(() => { + const accessToken = accessTokenRef.current + const refreshAt = getRefreshTime(accessToken) + if (refreshAt === null) return + + let cancelled = false + let timer: ReturnType | undefined + + const schedule = () => { + const delay = Math.max(0, refreshAt - Date.now()) + timer = setTimeout( + () => { + if (cancelled) return + if (Date.now() < refreshAt) { + schedule() + return + } + + const refreshToken = refreshTokenRef.current + if (!refreshToken) return + void rotateTokens(refreshToken).then( + (tokens) => { + if (!cancelled && refreshTokenRef.current === refreshToken) applyTokens(tokens) + }, + () => { + if (!cancelled && refreshTokenRef.current === refreshToken) clearSession() + }, + ) + }, + Math.min(delay, 2_147_483_647), + ) + } + + schedule() + return () => { + cancelled = true + if (timer !== undefined) clearTimeout(timer) + } + }, [accessTokenVersion, applyTokens, clearSession, rotateTokens]) + + const sendCode = useCallback( + (input: Parameters[0]) => apis.sendCode(input), + [apis], + ) + + const register = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.register(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const login = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.login(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const loginByCode = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.loginByCode(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const changePassword = useCallback( + async (input: Parameters[0]) => { + await apis.changePassword(input) + clearSession() + }, + [apis, clearSession], + ) + + const logout = useCallback(async () => { + const refreshToken = refreshTokenRef.current + clearSession() + if (refreshToken) await apis.logout(refreshToken) + }, [apis, clearSession]) + + const value = useMemo( + () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }), + [changePassword, login, loginByCode, logout, register, sendCode, state], + ) + + return {children} +} + +export function useAuthSession(): AuthSessionValue { + const session = useContext(AuthSessionContext) + if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用') + return session +} + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { state } = useAuthSession() + const location = useLocation() + + if (state.status === 'booting') return null + if (state.status === 'authenticated') return children + + const returnTo = `${location.pathname}${location.search}${location.hash}` + const loginTarget = isSafeReturnTo(returnTo) + ? `/?account=login&returnTo=${encodeURIComponent(returnTo)}` + : '/?account=login' + return +} + +function isSafeReturnTo(value: string): boolean { + return value.startsWith('/') && !value.startsWith('//') +} + +function getRefreshTime(accessToken: string | null): number | null { + if (!accessToken) return null + const payload = accessToken.split('.')[1] + if (!payload) return null + + try { + const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const parsed: unknown = JSON.parse(globalThis.atob(padded)) + if (!isRecord(parsed) || typeof parsed.exp !== 'number' || !Number.isFinite(parsed.exp)) + return null + return parsed.exp * 1_000 - 60_000 + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts new file mode 100644 index 00000000..28af8652 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' + +import { + REFRESH_TOKEN_STORAGE_KEY, + clearRefreshToken, + loadRefreshToken, + saveRefreshToken, +} from './session-storage' + +afterEach(() => { + window.localStorage.clear() +}) + +describe('auth session storage', () => { + it('persists only the refresh token under the authentication key', () => { + saveRefreshToken('refresh-token') + + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token') + expect(loadRefreshToken()).toBe('refresh-token') + }) + + it('removes the persisted refresh token', () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-token') + + clearRefreshToken() + + expect(loadRefreshToken()).toBeNull() + }) + + it('treats unavailable or failing local storage as an empty best-effort store', () => { + const failingStorage = { + getItem(): string | null { + throw new DOMException('blocked') + }, + setItem(): void { + throw new DOMException('blocked') + }, + removeItem(): void { + throw new DOMException('blocked') + }, + } + + expect(loadRefreshToken(failingStorage)).toBeNull() + expect(() => saveRefreshToken('refresh-token', failingStorage)).not.toThrow() + expect(() => clearRefreshToken(failingStorage)).not.toThrow() + }) +}) diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts new file mode 100644 index 00000000..06ba96ca --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.ts @@ -0,0 +1,40 @@ +export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' + +type RefreshTokenStorage = Pick + +function getLocalStorage(): RefreshTokenStorage | null { + try { + return globalThis.localStorage + } catch { + return null + } +} + +export function loadRefreshToken( + storage: RefreshTokenStorage | null = getLocalStorage(), +): string | null { + try { + return storage?.getItem(REFRESH_TOKEN_STORAGE_KEY) ?? null + } catch { + return null + } +} + +export function saveRefreshToken( + refreshToken: string, + storage: RefreshTokenStorage | null = getLocalStorage(), +): void { + try { + storage?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken) + } catch { + // 持久化不可用时仍保留当前内存会话。 + } +} + +export function clearRefreshToken(storage: RefreshTokenStorage | null = getLocalStorage()): void { + try { + storage?.removeItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + // 清理是尽力而为;浏览器禁用存储时不能让应用崩溃。 + } +} diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts new file mode 100644 index 00000000..60a05ecf --- /dev/null +++ b/frontend/src/features/character-setup/index.test.ts @@ -0,0 +1,10 @@ +import { expectTypeOf, it } from 'vitest' + +import type { CharacterSetupStepInput } from '@/entities' +import type { CharacterSetupProps } from '.' + +it('submits WorkflowRun character setup input', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf() +}) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 44c9a7b0..13827b1c 100644 --- a/frontend/src/features/character-setup/index.ts +++ b/frontend/src/features/character-setup/index.ts @@ -1,7 +1,7 @@ -import type { CreateCharacterInput } from '@/entities' +import type { CharacterSetupStepInput } from '@/entities' /** 填写角色资料并提交母版生成。 */ export interface CharacterSetupProps { projectId: string - onSubmit(input: CreateCharacterInput): void + onSubmit(input: CharacterSetupStepInput): void } diff --git a/frontend/src/features/export-package/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts new file mode 100644 index 00000000..d135a773 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -0,0 +1,269 @@ +/** @vitest-environment jsdom */ +import Ajv2020 from 'ajv/dist/2020.js' +import { describe, expect, it, vi } from 'vitest' + +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportRuntime, + type AssetExportTarget, +} from './asset-export' +import { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +import { EXPORT_PACKAGE_JSON_SCHEMA_TEXT } from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel } from './model' + +function frame(index: number): ExportFrame { + return { + imageUrl: `/frames/walk-${index}.png`, + durationMs: 100, + rootMotion: { dx: index, dy: 0 }, + keyFrame: index === 0, + } +} + +function action(frameCount = 9): ExportAction { + return { + id: 'walk-abcdef12', + name: 'Walk / Forward', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: frameCount, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: Array.from({ length: frameCount }, (_, index) => frame(index)), + }, + ], + } +} + +const model: ExportPackageModel = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [action()], +} + +/** 构造足够让契约检查识别为 RGBA PNG 的文件头,解码由测试运行时接管。 */ +function rgbaPng(): Blob { + const data = new Uint8Array(33) + data.set([137, 80, 78, 71, 13, 10, 26, 10], 0) + new DataView(data.buffer).setUint32(8, 13, false) + data.set([73, 72, 68, 82], 12) + data[25] = 6 + return new Blob([data], { type: 'image/png' }) +} + +async function readStoredZip(blob: Blob): Promise> { + const data = new Uint8Array(await blob.arrayBuffer()) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const decoder = new TextDecoder() + const entries = new Map() + let offset = 0 + + while (offset + 4 <= data.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(data.slice(nameStart, nameStart + nameLength)) + entries.set(name, data.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 rgbaPng() + }), + decodeFrame: vi.fn(async () => ({ + source: {} as CanvasImageSource, + width: 32, + 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(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(width * height * 4) })), + }), + }) + Object.defineProperty(canvas, 'toBlob', { + value: (callback: BlobCallback) => callback(new Blob(['atlas'], { type: 'image/png' })), + }) + return canvas + }), + } +} + +describe('asset export', () => { + it('明确 Cocos 尚未就绪,并只落地已确认的锚点坐标转换', () => { + expect(COCOS_TARGET_READINESS.ready).toBe(false) + const anchor = toCocosAnchor({ x: 0.5, y: 0.9 }) + expect(anchor.x).toBe(0.5) + expect(anchor.y).toBeCloseTo(0.1) + }) + + it('按动作名和方向生成连续三位帧名,并按八列排列图集', () => { + const plan = createAssetExportPlan(model) + + expect(plan).toHaveLength(1) + expect(plan[0]).toMatchObject({ + exportName: 'Walk-Forward-south', + framesFolder: 'frames/Walk-Forward-south', + atlasFile: 'atlas/Walk-Forward-south.png', + columns: 8, + rows: 2, + }) + expect(plan[0]?.frames[0]?.filename).toBe('Walk-Forward-south_000.png') + expect(plan[0]?.frames[8]?.filename).toBe('Walk-Forward-south_008.png') + }) + + it('生成通用目录、透明 PNG、图集、README、Schema 和可校验的动画 meta.json', async () => { + const phases: string[] = [] + const result = await exportGameAssets(model, { + runtime: runtime(), + onPhase: (phase) => phases.push(phase), + }) + const entries = await readStoredZip(result.blob) + const root = 'Aster-character-1' + const names = [...entries.keys()] + + expect(result.filename).toBe('windup-Aster-character-1.zip') + expect(phases).toEqual(['validating', 'collecting', 'rendering', 'packing']) + expect(names).toContain(`${root}/meta.json`) + expect(names).toContain(`${root}/schema.json`) + expect(names).toContain(`${root}/README.md`) + expect(names).toContain(`${root}/atlas/Walk-Forward-south.png`) + expect(names.filter((name) => name.includes('/frames/'))).toHaveLength(9) + + const meta = JSON.parse(new TextDecoder().decode(entries.get(`${root}/meta.json`))) + const schema = JSON.parse(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) + const validate = new Ajv2020().compile(schema) + expect(validate(meta), JSON.stringify(validate.errors)).toBe(true) + expect(meta).toMatchObject({ + schema_version: '1.0.0', + character: { id: 'character-1', name: 'Aster' }, + canvas: { w: 32, h: 40 }, + source: { workflow_run_id: 'run-1', generation_ids: ['generation-1'] }, + }) + expect(meta.actions[0]).toMatchObject({ + name: 'Walk-Forward-south', + fps: 10, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + foot_y: 36, + atlas: { cols: 8, rows: 2, cell: { w: 32, h: 40 } }, + }) + expect(names.some((name) => name.endsWith('.gif'))).toBe(false) + }) + + it('声明帧数与实际帧数不一致时,在读取图片前拒绝导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, expectedFrameCount: 10 }], + }, + ], + } + const testRuntime = runtime() + + await expect(exportGameAssets(badModel, { runtime: testRuntime })).rejects.toThrow( + 'actions[0].sequences[0].frames: 缺帧,期望 10 帧,实际 9 帧', + ) + expect(testRuntime.fetchFrame).not.toHaveBeenCalled() + }) + + it('任一原图读取失败时拒绝整个导出,不再生成透明占位包', async () => { + await expect( + exportGameAssets(model, { runtime: runtime('/frames/walk-4.png') }), + ).rejects.toThrow('frames/Walk-Forward-south/Walk-Forward-south_004.png: 图片读取失败') + }) + + it('质量状态未通过时禁止导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, qualityStatus: 'pending' }], + }, + ], + } + + await expect(exportGameAssets(badModel, { runtime: runtime() })).rejects.toThrow( + 'actions[0].sequences[0].qualityStatus: 质量检测未通过,禁止导出', + ) + }) + + it('空 target 不改变通用层,新增 target 文件只进入自己的目录', async () => { + const emptyTarget: AssetExportTarget = { + id: 'empty', + createFiles: vi.fn(async () => []), + } + const cocosProbe: AssetExportTarget = { + id: 'cocos-probe', + createFiles: vi.fn(async ({ metadata }) => [ + { path: 'anchor-map.json', data: JSON.stringify({ source: metadata.actions[0]?.anchor }) }, + ]), + } + const common = await readStoredZip((await exportGameAssets(model, { runtime: runtime() })).blob) + const extended = await readStoredZip( + (await exportGameAssets(model, { runtime: runtime(), targets: [emptyTarget, cocosProbe] })) + .blob, + ) + const targetPath = 'Aster-character-1/targets/cocos-probe/anchor-map.json' + + expect([...extended.keys()].filter((name) => !name.includes('/targets/'))).toEqual([ + ...common.keys(), + ]) + expect(extended.has(targetPath)).toBe(true) + expect(emptyTarget.createFiles).toHaveBeenCalledTimes(1) + }) + + it('渲染失败时释放已经解码的全部图片', 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: 32, 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, { runtime: failingRuntime })).rejects.toThrow( + 'atlas/Walk-Forward-south.png: 浏览器无法创建 2D 画布', + ) + 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 00000000..0d573c7d --- /dev/null +++ b/frontend/src/features/export-package/asset-export.ts @@ -0,0 +1,534 @@ +import { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + type GenericExportMetadata, + validateExportPackageModel, +} from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel, ExportSequence } from './model' + +export type AssetExportPhase = 'validating' | 'collecting' | 'rendering' | 'packing' + +export interface AssetExportResult { + blob: Blob + filename: string +} + +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 + relativeFile: string +} + +export interface PlannedSequence { + action: ExportAction + sequence: ExportSequence + exportName: string + framesFolder: string + atlasFile: string + columns: number + rows: number + frames: readonly PlannedFrame[] +} + +export interface AssetExportTargetFile { + /** 相对于 targets// 的路径。 */ + path: string + data: Blob | string | Uint8Array +} + +export interface AssetExportTargetContext { + model: ExportPackageModel + metadata: GenericExportMetadata + plan: readonly PlannedSequence[] +} + +/** 新引擎只实现 target,不应修改通用 meta.json、frames 与 atlas。 */ +export interface AssetExportTarget { + id: string + createFiles(context: AssetExportTargetContext): Promise +} + +export interface ExportGameAssetsOptions { + runtime?: AssetExportRuntime + targets?: readonly AssetExportTarget[] + onPhase?: (phase: AssetExportPhase) => void +} + +interface LoadedFrame extends PlannedFrame { + data: Uint8Array + decoded: DecodedFrame +} + +interface LoadedSequence { + item: PlannedSequence + frames: readonly LoadedFrame[] +} + +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 packageRoot(model: ExportPackageModel): string { + return `${safeSegment(model.characterName, 'character')}-${safeSegment(model.characterId, 'id')}` +} + +function uniqueActionName( + action: ExportAction, + sequence: ExportSequence, + usedNames: Set, +): string { + const baseName = safeSegment(action.name, 'action') + const direction = safeSegment(sequence.direction, 'default') + const candidate = direction === 'default' ? baseName : `${baseName}-${direction}` + const unique = usedNames.has(candidate) ? `${candidate}-${idSuffix(action.id)}` : candidate + if (usedNames.has(unique)) throw new Error(`actions.name: 导出动作名重复:${unique}`) + usedNames.add(unique) + return unique +} + +export function createAssetExportPlan(model: ExportPackageModel): readonly PlannedSequence[] { + const usedNames = new Set() + return model.actions.flatMap((action) => + action.sequences.flatMap((sequence) => { + if (sequence.frames.length === 0) return [] + const exportName = uniqueActionName(action, sequence, usedNames) + const columns = Math.min(8, sequence.frames.length) + return [ + { + action, + sequence, + exportName, + framesFolder: `frames/${exportName}`, + atlasFile: `atlas/${exportName}.png`, + columns, + rows: Math.ceil(sequence.frames.length / columns), + frames: sequence.frames.map((currentFrame, index) => { + const filename = `${exportName}_${String(index).padStart(3, '0')}.png` + return { + frame: currentFrame, + index, + filename, + relativeFile: `frames/${exportName}/${filename}`, + } + }), + }, + ] + }), + ) +} + +const defaultRuntime: AssetExportRuntime = { + async fetchFrame(url) { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP ${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('atlas: PNG 编码失败')) + else resolve(blob) + }, 'image/png') + }) +} + +async function bytes(data: Blob | string | Uint8Array): Promise { + if (typeof data === 'string') return new TextEncoder().encode(data) + if (data instanceof Uint8Array) return data + return new Uint8Array(await data.arrayBuffer()) +} + +function hasPngSignature(data: Uint8Array): boolean { + const signature = [137, 80, 78, 71, 13, 10, 26, 10] + return signature.every((value, index) => data[index] === value) +} + +/** + * MIME 只能说明“服务端声称是 PNG”,不能证明文件真的可用或带透明信息。 + * 因此这里读取 PNG 头,并兼容 RGBA、灰度 Alpha 与带 tRNS 块的索引 PNG。 + */ +function assertPngWithAlpha(data: Uint8Array, field: string): void { + if (data.length < 33 || !hasPngSignature(data)) throw new Error(`${field}: 必须是有效 PNG`) + const colorType = data[25] + if (colorType === 4 || colorType === 6) return + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + let offset = 8 + while (offset + 12 <= data.length) { + const chunkLength = view.getUint32(offset, false) + const chunkEnd = offset + 12 + chunkLength + if (chunkEnd > data.length) break + const chunkName = String.fromCharCode(...data.slice(offset + 4, offset + 8)) + if (chunkName === 'tRNS') return + if (chunkName === 'IEND') break + offset = chunkEnd + } + throw new Error(`${field}: PNG 必须包含 Alpha 透明通道`) +} + +async function loadFrame( + planned: PlannedFrame, + runtime: AssetExportRuntime, + cache: Map>, +): Promise { + const field = `${planned.relativeFile}` + let pending = cache.get(planned.frame.imageUrl) + if (pending === undefined) { + pending = runtime.fetchFrame(planned.frame.imageUrl) + cache.set(planned.frame.imageUrl, pending) + } + + let blob: Blob + try { + blob = await pending + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: 图片读取失败(${reason})`) + } + if (blob.type.toLowerCase() !== 'image/png') throw new Error(`${field}: 文件类型必须是 image/png`) + const data = await bytes(blob) + assertPngWithAlpha(data, field) + + let decoded: DecodedFrame + try { + decoded = await runtime.decodeFrame(blob) + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: PNG 解码失败(${reason})`) + } + return { ...planned, data, decoded } +} + +async function loadAllFrames( + plan: readonly PlannedSequence[], + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const cache = new Map>() + const references = plan.flatMap((item) => item.frames.map((frame) => ({ item, frame }))) + const settled = await Promise.allSettled( + references.map(async ({ item, frame }) => { + const loaded = await loadFrame(frame, runtime, cache) + if ( + loaded.decoded.width !== model.canvas.width || + loaded.decoded.height !== model.canvas.height + ) { + loaded.decoded.close() + throw new Error( + `${frame.relativeFile}: 画布应为 ${model.canvas.width}x${model.canvas.height},实际为 ${loaded.decoded.width}x${loaded.decoded.height}`, + ) + } + return { item, loaded } + }), + ) + + const fulfilled = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ) + const failure = settled.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') { + fulfilled.forEach(({ loaded }) => loaded.decoded.close()) + throw failure.reason + } + + return plan.map((item) => ({ + item, + frames: fulfilled.filter((result) => result.item === item).map((result) => result.loaded), + })) +} + +function context2d(canvas: HTMLCanvasElement, field: string): CanvasRenderingContext2D { + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (context === null) throw new Error(`${field}: 浏览器无法创建 2D 画布`) + return context +} + +async function renderAtlas( + loaded: LoadedSequence, + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const canvas = runtime.createCanvas( + model.canvas.width * loaded.item.columns, + model.canvas.height * loaded.item.rows, + ) + const context = context2d(canvas, loaded.item.atlasFile) + context.clearRect(0, 0, canvas.width, canvas.height) + loaded.frames.forEach((frame) => { + const column = frame.index % loaded.item.columns + const row = Math.floor(frame.index / loaded.item.columns) + context.drawImage(frame.decoded.source, column * model.canvas.width, row * model.canvas.height) + }) + return canvasPng(canvas) +} + +function createMetadata( + model: ExportPackageModel, + plan: readonly PlannedSequence[], +): GenericExportMetadata { + return { + schema_version: EXPORT_PACKAGE_SCHEMA_VERSION, + character: { id: model.characterId, name: model.characterName }, + canvas: { w: model.canvas.width, h: model.canvas.height }, + actions: plan.map((item) => ({ + name: item.exportName, + fps: item.action.fps, + loop: item.sequence.loop, + frames: item.frames.map((frame) => ({ index: frame.index, file: frame.filename })), + anchor: { ...item.sequence.anchor }, + foot_y: item.sequence.footY, + atlas: { + file: item.atlasFile, + cols: item.columns, + rows: item.rows, + cell: { w: model.canvas.width, h: model.canvas.height }, + }, + })), + source: + model.source === null + ? null + : { + workflow_run_id: model.source.workflowRunId, + generation_ids: [...model.source.generationIds], + }, + } +} + +function createReadme(model: ExportPackageModel): string { + return `# ${model.characterName} 导出包 + +这是 Windup 通用资产包,契约版本为 ${EXPORT_PACKAGE_SCHEMA_VERSION}。 + +## 内容 + +- \`meta.json\`: 动作、帧率、循环、画布、锚点、脚底线、图集与生成记录。 +- \`frames//\`: 连续编号的透明 PNG 原始帧。 +- \`atlas/.png\`: 按 \`meta.json\` 中 cols、rows 和 cell 切分的图集。 +- \`schema.json\`: 校验 \`meta.json\` 的 JSON Schema。 +- \`targets//\`: 可选引擎适配器产生的原生文件。 + +## 坐标 + +通用层原点在画布左上角,y 轴向下;anchor 的 x/y 都是 0 到 1。 +引擎 target 负责坐标换算。例如 Cocos Creator 使用左下角原点,需要换算为 (x, 1-y)。 + +## Cocos Creator 状态 + +本包没有伪造 .anim 或 .meta。Issue #94 要求先在真实 Creator 3.x 中确认图集切分、UUID 和版本格式; +验证完成前只能使用通用 frames、atlas 和 meta.json,不能声称“拖入即播放”。 +` +} + +function safeTargetPath(targetId: string, path: string, index: number): string { + const normalized = path.replace(/\\/g, '/') + if ( + normalized.length === 0 || + normalized.startsWith('/') || + normalized.split('/').some((segment) => segment === '..' || segment === '') + ) { + throw new Error(`targets.${targetId}.files[${index}].path: 必须是安全的相对路径`) + } + return `targets/${safeSegment(targetId, 'target')}/${normalized}` +} + +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 buffer = new ArrayBuffer(output.length) + new Uint8Array(buffer).set(output) + return new Blob([buffer], { type: 'application/zip' }) +} + +export async function exportGameAssets( + model: ExportPackageModel, + options: ExportGameAssetsOptions = {}, +): Promise { + const runtime = options.runtime ?? defaultRuntime + options.onPhase?.('validating') + validateExportPackageModel(model) + const plan = createAssetExportPlan(model) + const metadata = createMetadata(model, plan) + + options.onPhase?.('collecting') + const loaded = await loadAllFrames(plan, model, runtime) + const root = packageRoot(model) + const entries: ZipEntry[] = [] + + try { + options.onPhase?.('rendering') + for (const current of loaded) { + for (const frame of current.frames) { + entries.push({ name: `${root}/${frame.relativeFile}`, data: frame.data }) + } + entries.push({ + name: `${root}/${current.item.atlasFile}`, + data: await bytes(await renderAtlas(current, model, runtime)), + }) + } + + entries.push( + { + name: `${root}/meta.json`, + data: await bytes(JSON.stringify(metadata, null, 2)), + }, + { name: `${root}/schema.json`, data: await bytes(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) }, + { name: `${root}/README.md`, data: await bytes(createReadme(model)) }, + ) + + for (const target of options.targets ?? []) { + const targetId = safeSegment(target.id, 'target') + const files = await target.createFiles({ model, metadata, plan }) + for (const [index, file] of files.entries()) { + entries.push({ + name: `${root}/${safeTargetPath(targetId, file.path, index)}`, + data: await bytes(file.data), + }) + } + } + } finally { + loaded.forEach(({ frames }) => frames.forEach((frame) => frame.decoded.close())) + } + + const duplicate = entries.find( + (entry, index) => entries.findIndex((item) => item.name === entry.name) !== index, + ) + if (duplicate !== undefined) throw new Error(`package.files: 文件路径重复:${duplicate.name}`) + + options.onPhase?.('packing') + return { + blob: storedZip(entries), + filename: `windup-${root}.zip`, + } +} diff --git a/frontend/src/features/export-package/cocos-target.ts b/frontend/src/features/export-package/cocos-target.ts new file mode 100644 index 00000000..5085f2a1 --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.ts @@ -0,0 +1,15 @@ +import type { ExportAnchor } from './model' + +/** + * Issue #94 的 Cocos 原生文件格式仍等待真实 Creator 3.x 实测。 + * 这里公开状态,避免页面或调用方把通用包误标成“Cocos 拖入即用包”。 + */ +export const COCOS_TARGET_READINESS = { + ready: false, + reason: '等待真实 Cocos Creator 3.x 验证 .anim、.meta、UUID 与图集切分格式', +} as const + +/** 通用层左上原点转为 Cocos Creator 左下原点;x 不变,y 上下翻转。 */ +export function toCocosAnchor(anchor: ExportAnchor): ExportAnchor { + return { x: anchor.x, y: 1 - anchor.y } +} diff --git a/frontend/src/features/export-package/contract.ts b/frontend/src/features/export-package/contract.ts new file mode 100644 index 00000000..8883a03c --- /dev/null +++ b/frontend/src/features/export-package/contract.ts @@ -0,0 +1,113 @@ +import exportSchemaText from './export-package.schema.json?raw' +import type { ExportPackageModel } from './model' + +export const EXPORT_PACKAGE_SCHEMA_VERSION = '1.0.0' +export const EXPORT_PACKAGE_JSON_SCHEMA_TEXT = exportSchemaText + +export interface GenericExportFrame { + index: number + file: string +} + +export interface GenericExportAction { + name: string + fps: number + loop: boolean + frames: readonly GenericExportFrame[] + anchor: { x: number; y: number } + foot_y: number + atlas: { + file: string + cols: number + rows: number + cell: { w: number; h: number } + } +} + +export interface GenericExportMetadata { + schema_version: typeof EXPORT_PACKAGE_SCHEMA_VERSION + character: { id: string; name: string } + canvas: { w: number; h: number } + actions: readonly GenericExportAction[] + source: { + workflow_run_id: string + generation_ids: readonly string[] + } | null +} + +function fail(field: string, reason: string): never { + throw new Error(`${field}: ${reason}`) +} + +function requireText(field: string, value: string): void { + if (typeof value !== 'string' || value.trim().length === 0) fail(field, '必须是非空字符串') +} + +function requirePositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) fail(field, '必须是大于 0 的整数') +} + +function requireUnitNumber(field: string, value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) fail(field, '必须是 0 到 1 的数值') +} + +/** + * 在读取任何图片前完成结构与质量门禁。 + * 报错路径使用 meta.json 对应字段名,方便调用方直接定位坏数据。 + */ +export function validateExportPackageModel(model: ExportPackageModel): void { + requireText('character.id', model.characterId) + requireText('character.name', model.characterName) + requireText('outfit.id', model.outfitId) + requireText('outfit.name', model.outfitName) + requirePositiveInteger('canvas.w', model.canvas.width) + requirePositiveInteger('canvas.h', model.canvas.height) + if (model.source !== null) { + requireText('source.workflow_run_id', model.source.workflowRunId) + const generationIds = new Set() + model.source.generationIds.forEach((id, index) => { + requireText(`source.generation_ids[${index}]`, id) + if (generationIds.has(id)) fail(`source.generation_ids[${index}]`, '生成记录不能重复') + generationIds.add(id) + }) + } + + if (model.actions.length === 0) fail('actions', '至少需要一个动作') + model.actions.forEach((action, actionIndex) => { + const actionField = `actions[${actionIndex}]` + requireText(`${actionField}.name`, action.name) + requirePositiveInteger(`${actionField}.fps`, action.fps) + if (action.sequences.length === 0) fail(`${actionField}.sequences`, '至少需要一个动作方向') + + action.sequences.forEach((sequence, sequenceIndex) => { + const sequenceField = `${actionField}.sequences[${sequenceIndex}]` + requireText(`${sequenceField}.direction`, sequence.direction) + requirePositiveInteger(`${sequenceField}.expectedFrameCount`, sequence.expectedFrameCount) + requireUnitNumber(`${sequenceField}.anchor.x`, sequence.anchor.x) + requireUnitNumber(`${sequenceField}.anchor.y`, sequence.anchor.y) + if ( + !Number.isInteger(sequence.footY) || + sequence.footY < 0 || + sequence.footY > model.canvas.height + ) { + fail(`${sequenceField}.footY`, `必须是 0 到 ${model.canvas.height} 的整数像素值`) + } + if (sequence.qualityStatus !== 'passed') { + fail(`${sequenceField}.qualityStatus`, '质量检测未通过,禁止导出') + } + if (sequence.frames.length !== sequence.expectedFrameCount) { + fail( + `${sequenceField}.frames`, + `缺帧,期望 ${sequence.expectedFrameCount} 帧,实际 ${sequence.frames.length} 帧`, + ) + } + sequence.frames.forEach((frame, frameIndex) => { + requireText(`${sequenceField}.frames[${frameIndex}].imageUrl`, frame.imageUrl) + requirePositiveInteger( + `${sequenceField}.frames[${frameIndex}].durationMs`, + frame.durationMs, + ) + }) + }) + }) +} 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 00000000..fe37af00 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -0,0 +1,121 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel } from './model' +import { ExportPanel } from './export-panel' + +const model = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [ + { + id: 'walk-abcdef12', + name: 'Walk', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: 1, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: [ + { + imageUrl: '/walk.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + ], +} satisfies ExportPackageModel + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel', () => { + it('质量问题会阻止导出,而不是只显示警告', () => { + render() + + expect(screen.getByText('当前有 3 项质量问题,全部通过后才能导出')).toBeTruthy() + expect( + (screen.getByRole('button', { name: '导出游戏资产包' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('显示进度、阻止重复点击、下载后释放临时地址', async () => { + let resolveExport: (value: { blob: Blob; filename: string }) => void = () => { + throw new Error('export promise was not initialized') + } + const exporter = vi.fn( + ( + _model: ExportPackageModel, + onPhase?: (phase: 'validating' | 'collecting' | 'rendering' | 'packing') => void, + ) => { + onPhase?.('rendering') + return new Promise<{ blob: Blob; filename: string }>((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-character-1.zip', + }) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:asset-package') + click.mockRestore() + }) + + it('展示具体错误字段,并允许修复后重试', async () => { + const exporter = vi + .fn() + .mockRejectedValueOnce(new Error('actions[0].frames: 缺帧')) + .mockResolvedValueOnce({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-character-1.zip', + }) + 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('导出失败:actions[0].frames: 缺帧')).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 00000000..12f19f51 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.tsx @@ -0,0 +1,118 @@ +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' } + | { status: 'failure'; message: string } + +const PHASE_LABELS: Readonly> = { + validating: '正在检查导出条件', + collecting: '正在整理素材', + rendering: '正在生成图片', + packing: '正在打包', +} + +const defaultExporter: AssetExporter = (model, onPhase) => exportGameAssets(model, { 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: 'validating' }) + 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' }) + } catch (error) { + setState({ + status: 'failure', + message: error instanceof Error ? error.message : '未知错误', + }) + } + } + + return ( +
+
+

GAME ASSETS

+

资产导出

+

逐帧透明 PNG、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.message} +

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

下载完成

+ ) : 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 00000000..82f2ab36 --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,29 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportPanel } from './export-panel' +export type { + ExportAction, + ExportAnchor, + ExportFrame, + ExportPackageModel, + ExportQualityStatus, + ExportSequence, + ExportSourceReference, +} from './model' +export { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + validateExportPackageModel, + type GenericExportMetadata, +} from './contract' +export { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +export { + createAssetExportPlan, + exportGameAssets, + type AssetExportTarget, + type AssetExportTargetContext, + type AssetExportTargetFile, + type AssetExportPhase, + type AssetExportResult, + type AssetExportRuntime, + type ExportGameAssetsOptions, +} 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 00000000..d6095c38 --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,64 @@ +import type { ActionType, Frame } from '@/entities' + +/** + * 导出模块只读取这份模型,不直接读取 Playtest 页面状态。 + * 页面或后端适配器负责把当前角色、动作和生成记录整理成该模型。 + */ +export interface ExportFrame { + imageUrl: string + durationMs: number + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +/** 通用契约使用左上角为原点、y 轴向下的 0-1 归一化坐标。 */ +export interface ExportAnchor { + x: number + y: number +} + +export type ExportQualityStatus = 'passed' | 'pending' | 'failed' + +export interface ExportSequence { + direction: string + /** 后端声明的完整帧数;不能用 frames.length 代替,否则无法发现缺帧。 */ + expectedFrameCount: number + loop: boolean + anchor: ExportAnchor + /** 脚底线距离画布顶部的像素值。 */ + footY: number + /** 只有 passed 的动作序列可以进入正式导出包。 */ + qualityStatus: ExportQualityStatus + frames: readonly ExportFrame[] +} + +export interface ExportAction { + id: string + name: string + type: ActionType | 'crouch' + fps: number + sequences: readonly ExportSequence[] +} + +export interface ExportSourceReference { + workflowRunId: string + generationIds: readonly string[] +} + +export interface ExportPackageModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + /** 同一导出包内所有帧必须使用相同画布尺寸。 */ + canvas: { + width: number + height: number + } + /** 生成链路引用,用于从导出物追溯到 WorkflowRun 与生成任务。 */ + /** 独立 Playtest 入口可能没有 WorkflowRun/Generation 追溯信息。 */ + source: ExportSourceReference | null + actions: readonly ExportAction[] +} diff --git a/frontend/src/features/publish/index.test.ts b/frontend/src/features/publish/index.test.ts new file mode 100644 index 00000000..e2204cd2 --- /dev/null +++ b/frontend/src/features/publish/index.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + Character, + CharacterApis, + WorkflowRun, + WorkflowNode, +} from "@/entities"; +import { + buildPlaytestPath, + buildPublishedActionId, + publishWorkflowRun, +} from "./index"; + +describe("buildPlaytestPath", () => { + it("uses one encoded route contract for every Playtest caller", () => { + expect( + buildPlaytestPath({ + characterId: "character/1", + outfitId: "default outfit", + actionId: "walk left", + }), + ).toBe("/playtest/character%2F1/default%20outfit?actionId=walk+left"); + }); +}); + +describe("publishWorkflowRun with multiple actions", () => { + it("publishes only the latest reviewed action under a node-specific id", async () => { + const firstActionId = buildPublishedActionId( + "character-1", + "run-1", + "revision-1:action-generation", + ); + const character: Character = { + id: "character-1", + projectId: "project-1", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + outfits: [ + { + id: "outfit-1", + characterId: "character-1", + name: "默认造型", + candidateCharacterTemplates: [], + characterTemplateUrl: "template.png", + baseFrames: [], + actions: [ + { + id: firstActionId, + outfitId: "outfit-1", + name: "待机", + expectedFrameCount: 1, + kind: "custom", + type: "idle", + fps: 8, + keyFrameIndex: 0, + frames: [ + { imageUrl: "idle.png", durationMs: null, rootMotion: null }, + ], + }, + ], + }, + ], + }; + const common = { + status: "passed" as const, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + }; + const nodes: WorkflowNode[] = [ + { + ...common, + id: "setup", + type: "character-setup", + input: null, + output: null, + }, + { + ...common, + id: "template", + type: "character-template", + input: null, + output: null, + }, + { + ...common, + id: "candidate", + type: "action-first-frame", + input: null, + output: null, + }, + { + ...common, + id: "revision-1:action-generation", + type: "action-generation", + input: null, + output: { + type: "character_action", + actionType: "idle", + frames: [{ index: 0, imageUrl: "idle.png", durationMs: null }], + }, + }, + { + ...common, + id: "revision-1:review", + type: "review", + input: null, + output: null, + }, + { + ...common, + id: "revision-1:action-generation:2", + type: "action-generation", + input: { + type: "character_action", + projectId: "project-1", + characterId: "character-1", + outfitId: "outfit-1", + actionType: "custom", + firstFrameUrl: "template.png", + prompt: "挥手", + referenceMedia: ["template.png" as never], + numFrames: 32, + }, + output: { + type: "character_action", + actionType: "custom", + frames: [{ index: 0, imageUrl: "wave.png", durationMs: 125 }], + }, + }, + { + ...common, + id: "revision-1:review:2", + type: "review", + input: null, + output: null, + }, + ]; + const run: WorkflowRun = { + id: "run-1", + projectId: "project-1", + characterId: "character-1", + outfitId: "outfit-1", + purpose: "create_character", + status: "completed", + prompt: "像素骑士", + nodes, + generationStatus: "completed", + exportStatus: "not_exported", + createdAt: "2026-08-06T00:00:00.000Z", + }; + const update = vi.fn(async (input: Character) => input); + const apis: CharacterApis = { + get: vi.fn(async () => character), + listByProject: vi.fn(), + create: vi.fn(), + update, + remove: vi.fn(), + }; + + const saved = await publishWorkflowRun(apis, run); + + expect(saved.outfits[0]?.actions).toHaveLength(2); + expect(saved.outfits[0]?.actions[0]?.id).toBe(firstActionId); + expect(saved.outfits[0]?.actions[1]).toMatchObject({ + id: buildPublishedActionId( + "character-1", + "run-1", + "revision-1:action-generation:2", + ), + name: "挥手", + frames: [{ imageUrl: "wave.png" }], + }); + }); +}); diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 00000000..84a093ab --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,110 @@ +import type { + ActionType, + Character, + CharacterApis, + WorkflowRun, +} from "@/entities"; + +/** 已经写入 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}?${new URLSearchParams({ actionId: target.actionId })}` + : path; +} + +const ACTION_NAMES: Record = { + idle: "待机", + walk: "行走", + jump: "跳跃", + attack: "攻击", + custom: "自定义动作", +}; + +/** 动作 ID 同时绑定 Run 与动作节点,保证同一 Run 的多个动作不会互相覆盖。 */ +export function buildPublishedActionId( + characterId: string, + runId: string, + actionStepId: string, +): string { + return `${characterId}-${runId}-${actionStepId}`; +} + +/** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */ +export async function publishWorkflowRun( + characterApis: CharacterApis, + run: WorkflowRun, +): Promise { + if (!run.characterId || !run.outfitId) + throw new Error("工作流还没有关联角色与造型"); + const node = findLatestReviewedAction(run.nodes); + if (!node?.output) { + throw new Error("动作生成尚未完成,不能发布"); + } + const result = node.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 = buildPublishedActionId(character.id, run.id, node.id); + const action = { + id: actionId, + outfitId: outfit.id, + name: + result.actionType === "custom" + ? node.input?.prompt?.trim() || run.prompt?.trim() || "自定义动作" + : ACTION_NAMES[result.actionType], + expectedFrameCount: result.frames.length, + kind: "custom" as const, + type: result.actionType, + fps: 8, + keyFrameIndex: 0, + frames: result.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + 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, + ), + }); +} + +function findLatestReviewedAction(nodes: WorkflowRun["nodes"]) { + for (let index = nodes.length - 2; index >= 3; index -= 1) { + const action = nodes[index]; + const review = nodes[index + 1]; + if ( + action?.type === "action-generation" && + action.status === "passed" && + action.output && + review?.type === "review" && + review.status === "passed" + ) { + return action; + } + } + return null; +} + +export { + canPublishToPlaytest, + workflowRunToCharacter, +} from "./workflow-to-character"; 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 00000000..faf46b15 --- /dev/null +++ b/frontend/src/features/publish/workflow-to-character.ts @@ -0,0 +1,158 @@ +/** + * WorkflowRun → Character 桥接层。 + * 从工作流节点中提取数据,组装为 Playtest 可消费的 Character 实体。 + * 前三个角色节点只读取一次,后续按 action-full-frame / review 成对读取全部动作。 + */ +import type { + Action, + Character, + CharacterTemplateCandidate, + Frame, + Outfit, + WorkflowRevision, + WorkflowRun, + WorkflowNode, +} from "@/entities"; + +function getRevision(run: WorkflowRun): WorkflowRevision | null { + return [run].find((r) => r.id === run.id) ?? null; +} + +function getNode( + revision: WorkflowRevision, + type: string, +): WorkflowNode | null { + return revision.nodes.find((s) => s.type === type) ?? null; +} + +/** 从 character-template 节点提取母版 URL */ +function extractCharacterTemplateUrl( + revision: WorkflowRevision, +): string | null { + const template = getNode(revision, "character-template"); + const templateOutput = template?.output as { imageUrls?: string[] } | null; + return templateOutput?.imageUrls?.[0] ?? null; +} + +/** 从 character-template 节点提取候选列表 */ +function extractCandidates( + revision: WorkflowRevision, +): CharacterTemplateCandidate[] { + const node = getNode(revision, "character-template"); + const output = node?.output as { imageUrls?: string[] } | null; + if (!output?.imageUrls) return []; + return output.imageUrls.map((imageUrl, i) => ({ + id: `candidate-${i}`, + imageUrl, + attemptId: `attempt-${Date.now()}`, + })); +} + +/** 从 character-setup 节点提取角色描述 */ +function extractDescription(revision: WorkflowRevision): string { + const node = getNode(revision, "character-setup"); + const input = node?.input as { description?: string } | null; + return input?.description?.trim() || "未命名角色"; +} + +/** 只导出已经通过对应审核的动作,未审核的中间结果不进入正式 Character。 */ +function extractReviewedActions(revision: WorkflowRevision) { + return revision.nodes.flatMap((node, index) => { + const review = revision.nodes[index + 1]; + return node.type === "action-full-frame" && + node.status === "passed" && + node.output && + review?.type === "review" && + review.status === "passed" + ? [node] + : []; + }); +} + +/** + * 将 WorkflowRun 转换为 Character 实体。 + * 如果关键节点未完成,返回 null。 + */ +export function workflowRunToCharacter(run: WorkflowRun): Character | null { + const revision = getRevision(run); + if (!revision) return null; + + const templateNode = getNode(revision, "character-template"); + + // 至少需要母版已生成 + if (!templateNode || templateNode.status !== "passed") return null; + + const characterTemplateUrl = extractCharacterTemplateUrl(revision); + const candidates = extractCandidates(revision); + const description = extractDescription(revision); + const reviewedActions = extractReviewedActions(revision); + + // 一条 Run 可以持续追加动作;每个动作使用节点 ID 作为稳定后缀,避免发布时互相覆盖。 + const actions: Action[] = reviewedActions.map((node, index) => { + const frames: Frame[] = (node.output?.frames ?? []).map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: frame.durationMs, + rootMotion: null, + })); + const customName = node.input?.prompt?.trim(); + return { + id: `${run.id}-${node.id}`, + outfitId: `${run.id}-outfit`, + name: + customName || + (reviewedActions.length === 1 + ? description.length > 8 + ? `${description.slice(0, 8)}…` + : description || "动作" + : `动作 ${index + 1}`), + expectedFrameCount: frames.length, + kind: "custom", + type: node.output?.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 templateNode = getNode(revision, "character-template"); + const reviewedActions = extractReviewedActions(revision); + return ( + run.status === "completed" && + templateNode?.status === "passed" && + reviewedActions.length > 0 && + reviewedActions.every((node) => Boolean(node.output?.frames.length)) + ); +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts index 3a48b663..8558ac5a 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'; restartNodeId: string } + +export interface ReviewSubmission { + runId: WorkflowRun['id'] + decision: ReviewDecision +} + +interface ReviewController { + approveReview(runId: WorkflowRun['id']): WorkflowRun + restart(runId: WorkflowRun['id'], nodeId: 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.restartNodeId) } 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 00000000..859415d1 --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,282 @@ +import { + CHARACTER_ACTION_FRAME_COUNT, + type CharacterActionGenerationInput, + type CharacterActionOutput, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRun, + type WorkflowRunStore, +} from "@/entities"; +import { + beginActionGenerationState, + completeActionGenerationState, + getActiveNode, + recordActionGenerationTaskState, +} from "./workflow-state"; + +interface ActiveSubscription { + runId: WorkflowRun["id"]; + stop: () => void; +} + +export interface ActionGenerationTask { + start( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ): 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(); + + async function requireRun(runId: WorkflowRun["id"]) { + const run = await store.get(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + async function save(run: WorkflowRun) { + await store.save(run); + return run; + } + + async function currentActionNode(runId: WorkflowRun["id"]) { + const run = await requireRun(runId); + const node = getActiveNode(run); + return { run, node }; + } + + async function start( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ) { + const { run, node } = await currentActionNode(runId); + if (run.status !== "active" || node?.type !== "action-generation") + return run; + if (node.taskId) { + subscribe(run, node.taskId); + return run; + } + if (node.submissionId) + throw new Error("动作生成请求仍在等待后端确认,不能重复提交"); + + const key = `${runId}:${node.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: CharacterActionGenerationInput, + ) { + const submissionId = createSubmissionId(); + await save( + beginActionGenerationState(await requireRun(runId), input, submissionId), + ); + try { + const generation = await generationApis.create(input); + const latest = await requireRun(runId); + const node = getActiveNode(latest); + if ( + (latest.status !== "active" && latest.status !== "interrupted") || + node?.type !== "action-generation" || + node.submissionId !== submissionId + ) { + return latest; + } + if (generation.type !== "character_action") { + throw new Error("生成任务类型与动作生成节点不匹配"); + } + const withTask = await 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 await applyTerminal(runId, generation.id, generation); + } catch (cause) { + const latest = await store.get(runId); + if (latest?.status === "active") { + const node = getActiveNode(latest); + if (node?.type === "action-generation") { + await 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; + void applyTerminal(run.id, taskId, event).catch(async (cause) => { + console.error("[workflow] 保存动作生成终态失败,正在重新查询", cause); + try { + const task = await generationApis.get(run.projectId, taskId); + await applyTerminal(run.id, taskId, task); + } catch (retryCause) { + console.error("[workflow] 重新保存动作生成终态失败", retryCause); + } + }); + }); + const active = subscriptions.get(key); + if (active) subscriptions.set(key, { ...active, stop }); + else stop(); + } catch (cause) { + subscriptions.delete(key); + throw cause; + } + } + + async function applyTerminal( + runId: WorkflowRun["id"], + taskId: string, + task: Generation | GenerationEvent, + ) { + const latest = await requireRun(runId); + if (latest.status !== "active") return latest; + const node = getActiveNode(latest); + if (node?.type !== "action-generation" || node.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 !== "character_action" || + result?.type !== "character_action" || + result.frames.length === 0 + ) { + return save( + completeActionGenerationState(latest, { + error: "动作生成完成但未返回有效动画帧", + }), + ); + } + const completeResult = result as CharacterActionOutput; + const frameCountError = getCharacterActionFrameCountError(completeResult); + return save( + completeActionGenerationState( + latest, + frameCountError ? { error: frameCountError } : completeResult, + ), + ); + } + + async function resume(runId: WorkflowRun["id"]) { + const run = await store.get(runId); + if (!run || run.status !== "active") return run; + const node = getActiveNode(run); + if (node?.type !== "action-generation") return run; + if (node.submissionId && !node.taskId) { + return save( + completeActionGenerationState(run, { + error: "页面刷新时动作生成请求尚未返回任务 ID,请重新开始该节点", + }), + ); + } + if (!node.taskId) { + if (node.input) return start(runId, node.input); + return save( + completeActionGenerationState(run, { + error: "动作生成尚未完成提交,请重新确认角色候选", + }), + ); + } + try { + const task = await generationApis.get(run.projectId, node.taskId); + if (task.status === "pending" || task.status === "running") { + subscribe(run, node.taskId); + return store.get(runId); + } + return await applyTerminal(runId, node.taskId, task); + } catch (cause) { + // 查询失败只说明当前无法确认后端任务状态,不能把仍在运行的权威任务写成失败。 + throw cause instanceof Error ? cause : new Error(String(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 }; +} + +/** + * Controller 的完整动画验收门槛。生成服务负责补帧,WorkflowRun 只接收恰好 32 帧的 + * 新结果;这里不修改结果数组,避免把后端缺帧静默伪装成成功。 + */ +export function getCharacterActionFrameCountError( + result: CharacterActionOutput, +): string | null { + const actualFrameCount = result.frames.length; + return actualFrameCount === CHARACTER_ACTION_FRAME_COUNT + ? null + : `动作生成应返回 ${CHARACTER_ACTION_FRAME_COUNT} 帧,实际返回 ${actualFrameCount} 帧`; +} + +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 00000000..6fc1cb2c --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,492 @@ +import { + type CharacterImageOutput, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowNode, +} from "@/entities"; +import { + getActiveNode, + replaceWorkflowNode, + type WorkflowNodeTarget, +} from "./workflow-state"; + +interface ApplyServerResultInput extends WorkflowNodeTarget { + taskId: string; + result: unknown; +} + +interface ActiveSubscription { + runId: WorkflowRun["id"]; + stop: () => void; +} + +export interface CharacterTemplateTask { + start( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + ): Promise; + resume(runId: WorkflowRun["id"]): Promise; + stop(runId: WorkflowRun["id"]): void; +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore; + generationApis: GenerationApis; + createSubmissionId: () => string; +} + +/** 管理角色图生成的提交、任务关联、订阅和刷新恢复。 */ +export function createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>(); + const subscriptions = new Map(); + + async function getWorkflow(runId: WorkflowRun["id"]) { + return store.get(runId); + } + + async function requireWorkflow(runId: WorkflowRun["id"]) { + const run = await getWorkflow(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + async function save(run: WorkflowRun) { + await store.save(run); + return run; + } + + async function start( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + ): Promise { + const run = await requireWorkflow(runId); + const node = run.nodes.find((item) => item.id === target.nodeId); + if ( + run.id !== target.runId || + !node || + node.type !== "character-template" || + node.status !== "active" + ) { + return run; + } + if (node.taskId) { + ensureTaskSubscription(run, node.id, node.taskId); + return requireWorkflow(runId); + } + if (!node.input) throw new Error("角色图生成节点缺少输入快照"); + return submit(runId, target); + } + + function submit(runId: WorkflowRun["id"], target: WorkflowNodeTarget) { + const key = submissionKey(runId, target.nodeId); + 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: WorkflowNodeTarget, + ): Promise { + const before = await requireWorkflow(runId); + const beforeNode = before.nodes.find((node) => node.id === target.nodeId); + if ( + before.status !== "active" || + before.id !== target.runId || + !beforeNode || + beforeNode.type !== "character-template" || + beforeNode.status !== "active" || + !beforeNode.input + ) { + return before; + } + if (beforeNode.taskId) { + ensureTaskSubscription(before, beforeNode.id, beforeNode.taskId); + return before; + } + if (beforeNode.submissionId) { + throw new Error("角色图生成请求仍在等待后端确认,不能重复提交"); + } + + const submissionId = createSubmissionId(); + await save( + replaceWorkflowNode(before, beforeNode.id, (current) => + current.type === "character-template" + ? { ...current, submissionId } + : current, + ), + ); + + try { + const generation = await generationApis.create(beforeNode.input); + const latest = await requireWorkflow(runId); + const latestNode = latest.nodes.find((node) => node.id === target.nodeId); + if ( + (latest.status !== "active" && latest.status !== "interrupted") || + latest.id !== target.runId || + !latestNode || + latestNode.type !== "character-template" || + latestNode.status !== "active" || + latestNode.taskId || + latestNode.submissionId !== submissionId + ) { + return latest; + } + const projectMatches = + generation.projectId == null || + latest.projectId == null || + String(generation.projectId) === String(latest.projectId); + if (generation.type !== "character_image" || !projectMatches) { + throw new Error( + `生成任务返回的类型或项目与当前 WorkflowRun 不匹配 ` + + `(type: ${generation.type}, project: ${generation.projectId} vs ${latest.projectId})`, + ); + } + + const withTask = await save( + replaceWorkflowNode(latest, latestNode.id, (current) => + current.type === "character-template" + ? { ...current, taskId: generation.id, submissionId: null } + : current, + ), + ); + if (latest.status === "interrupted") return withTask; + if (generation.status === "failed") { + return await markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || "角色图生成任务失败", + ); + } + if (generation.status === "completed") { + return await applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }); + } + + ensureTaskSubscription(withTask, latestNode.id, generation.id); + return await requireWorkflow(runId); + } catch (cause) { + await markFailed( + runId, + target, + null, + submissionId, + errorMessage(cause, "角色图生成请求失败"), + ); + throw cause instanceof Error ? cause : new Error(String(cause)); + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + nodeId: WorkflowNode["id"], + taskId: string, + ) { + const key = subscriptionKey(run.id, nodeId, taskId); + if (subscriptions.has(key)) return; + + subscriptions.set(key, { runId: run.id, stop: () => undefined }); + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + void handleGenerationEvent( + run.id, + { runId: run.id, nodeId }, + taskId, + event, + ).catch((cause) => { + console.error("[workflow] 保存角色图生成终态失败,正在重新查询", cause); + void generationApis + .get(run.projectId, taskId) + .then((task) => + handleGenerationEvent( + run.id, + { runId: run.id, nodeId }, + taskId, + taskEvent(task), + ), + ) + .catch((retryCause) => { + console.error("[workflow] 重新保存角色图生成终态失败", retryCause); + }); + }); + }); + const active = subscriptions.get(key); + if (active) subscriptions.set(key, { ...active, stop }); + else stop(); + } catch (cause) { + subscriptions.delete(key); + throw cause; + } + } + + async function handleGenerationEvent( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + taskId: string, + event: GenerationEvent, + ) { + if ( + event.taskId !== taskId || + event.status === "pending" || + event.status === "running" + ) + return; + if (event.status === "failed") { + await markFailed( + runId, + target, + taskId, + null, + event.error?.trim() || "角色图生成任务失败", + ); + return; + } + if (event.type !== "character_image") { + await markFailed( + runId, + target, + taskId, + null, + "任务结果类型与角色图生成节点不匹配", + ); + return; + } + await applyServerResult(runId, { ...target, taskId, result: event.result }); + } + + async function resume(runId: WorkflowRun["id"]): Promise { + const run = await getWorkflow(runId); + if (!run || run.status !== "active") return run; + const activeNode = getActiveNode(run); + if ( + activeNode?.type !== "character-template" || + activeNode.status !== "active" + ) + return run; + const target = { runId: run.id, nodeId: activeNode.id }; + + if (activeNode.submissionId && !activeNode.taskId) { + if (submissions.has(submissionKey(run.id, activeNode.id))) return run; + return await markFailed( + run.id, + target, + null, + activeNode.submissionId, + "页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交", + ); + } + if (!activeNode.taskId) return run; + + const task = await generationApis.get(run.projectId, activeNode.taskId); + const latest = await getWorkflow(run.id); + if (!latest || latest.status !== "active") return latest; + const latestNode = latest.nodes.find((node) => node.id === activeNode.id); + if ( + latestNode?.type !== "character-template" || + latestNode.status !== "active" || + latestNode.taskId !== activeNode.taskId + ) { + return latest; + } + if (task.id !== latestNode.taskId) { + throw new Error("任务查询结果与 WorkflowRun 记录的 taskId 不匹配"); + } + if (task.type !== "character_image") { + return await markFailed( + latest.id, + { runId: latest.id, nodeId: latestNode.id }, + latestNode.taskId, + null, + "任务查询结果类型与角色图生成节点不匹配", + ); + } + if (task.status === "pending" || task.status === "running") { + ensureTaskSubscription(latest, latestNode.id, latestNode.taskId); + } else { + await handleGenerationEvent( + latest.id, + { runId: latest.id, nodeId: latestNode.id }, + latestNode.taskId, + taskEvent(task), + ); + } + return getWorkflow(runId); + } + + async function applyServerResult( + runId: WorkflowRun["id"], + input: ApplyServerResultInput, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active" || run.id !== input.runId) return run; + + const node = run.nodes.find((item) => item.id === input.nodeId); + if ( + !node || + node.type !== "character-template" || + node.status !== "active" || + node.taskId !== input.taskId + ) { + return run; + } + const result = parseCharacterImageOutput(input.result); + if (!result || result.imageUrls.length === 0) { + return await markFailed( + runId, + { runId: run.id, nodeId: node.id }, + input.taskId, + null, + "角色图生成任务返回了无法识别的结果", + ); + } + const candidateStep = run.nodes.find( + (item) => item.type === "action-first-frame", + ); + if (!candidateStep) + throw new Error("WorkflowRun 缺少 action-first-frame 节点"); + + const updated: WorkflowRun = { + ...run, + nodes: run.nodes.map((current) => { + if (current.id === node.id && current.type === "character-template") { + return { + ...current, + status: "passed" as const, + output: result, + taskId: null, + submissionId: null, + }; + } + if ( + current.id === candidateStep.id && + current.type === "action-first-frame" + ) { + return { ...current, status: "active" as const }; + } + return current; + }), + }; + stopSubscription(subscriptionKey(run.id, node.id, input.taskId)); + return save(updated); + } + + async function markFailed( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = await requireWorkflow(runId); + if (run.status !== "active" || run.id !== target.runId) return run; + const node = run.nodes.find((item) => item.id === target.nodeId); + if ( + !node || + node.type !== "character-template" || + node.status !== "active" || + (expectedTaskId !== null && node.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && + node.submissionId !== expectedSubmissionId) + ) { + return run; + } + + const failureMessage = error.trim() || "角色图生成失败"; + const failed = replaceWorkflowNode(run, node.id, (current) => + current.type === "character-template" + ? { + ...current, + status: "failed", + taskId: null, + submissionId: null, + error: failureMessage, + } + : current, + ); + if (node.taskId) + stopSubscription(subscriptionKey(run.id, node.id, node.taskId)); + return save({ ...failed, status: "failed", generationStatus: "failed" }); + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key); + subscriptions.delete(key); + try { + subscription?.stop(); + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun["id"]) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key); + } + } + + return { start, resume, stop }; +} + +function parseCharacterImageOutput( + value: unknown, +): CharacterImageOutput | null { + if ( + !value || + typeof value !== "object" || + !("type" in value) || + !("imageUrls" in value) + ) { + return null; + } + if (value.type !== "character_image" || !Array.isArray(value.imageUrls)) + return null; + const imageUrls = value.imageUrls.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + return imageUrls.length > 0 ? { type: "character_image", imageUrls } : null; +} + +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"], + nodeId: WorkflowNode["id"], + taskId: string, +) { + return `${runId}:${nodeId}:${taskId}`; +} + +function submissionKey(runId: WorkflowRun["id"], nodeId: WorkflowNode["id"]) { + return `${runId}:${nodeId}`; +} 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 00000000..58d0dff5 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CHARACTER_ACTION_FRAME_COUNT, + type Character, + type CharacterApis, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRun, + type WorkflowRunStore, +} from "@/entities"; +import { createWorkflowController } from "."; + +const NOW = "2026-07-30T12:00:00.000Z"; + +function createStore(): WorkflowRunStore { + const runs = new Map(); + return { + async create(input) { + return { + id: `run-${runs.size + 1}`, + projectId: input.projectId, + characterId: input.purpose === "add_action" ? input.characterId : null, + outfitId: input.purpose === "add_action" ? input.outfitId : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: NOW, + }; + }, + async get(id) { + const run = runs.get(id); + return run ? structuredClone(run) : null; + }, + async getByCharacter(characterId) { + const run = [...runs.values()].find( + (item) => item.characterId === characterId, + ); + return run ? structuredClone(run) : null; + }, + async list(projectId) { + return [...runs.values()] + .filter((run) => !projectId || run.projectId === projectId) + .map((run) => structuredClone(run)); + }, + async save(run) { + runs.set(run.id, structuredClone(run)); + }, + }; +} + +function createHarness(characterApis?: CharacterApis) { + const listeners = new Map void>(); + const createGeneration: GenerationApis["create"] = async < + T extends GenerationInput, + >( + input: T, + ) => + ({ + id: input.type === "character_image" ? "task-image-1" : "task-action-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"); + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + listeners.set(taskId, onEvent); + return () => listeners.delete(taskId); + }), + }; + const store = createStore(); + const controller = createWorkflowController({ + store, + generationApis, + characterApis, + now: () => NOW, + createId: () => "submission-1", + }); + return { + controller, + store, + emit(taskId: string, event: GenerationEvent) { + const listener = listeners.get(taskId); + if (!listener) throw new Error(`missing listener ${taskId}`); + listener(event); + }, + }; +} + +describe("createWorkflowController", () => { + it("creates and persists the frontend-owned node graph", async () => { + const { controller, store } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + + expect(run.nodes).toHaveLength(5); + expect(run.nodes[0]).toMatchObject({ + type: "character-setup", + status: "active", + }); + expect(await store.get(run.id)).toEqual(run); + }); + + it("notifies page subscribers whenever the persisted snapshot changes", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + const listener = vi.fn(); + const unsubscribe = controller.subscribe(run.id, listener); + + await controller.updateCharacterSetup(run.id, { + description: "revised knight", + referenceMedia: [], + }); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ + type: "character-setup", + input: expect.objectContaining({ description: "revised knight" }), + }), + ]), + }), + ); + unsubscribe(); + }); + + it("rolls the page cache back when persistence fails", async () => { + const { controller, store } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + const listener = vi.fn(); + controller.subscribe(run.id, listener); + store.save = vi.fn().mockRejectedValue(new Error("save failed")); + + await expect( + controller.updateCharacterSetup(run.id, { + description: "must not appear as saved", + referenceMedia: [], + }), + ).rejects.toThrow("save failed"); + + expect(controller.getWorkflow(run.id)).toEqual(run); + expect(listener).toHaveBeenLastCalledWith(run); + }); + + it("moves from setup to candidate selection when the image task completes", async () => { + const { controller, emit } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + await controller.nextStep(run.id, { width: 64, height: 64 }); + + emit("task-image-1", { + taskId: "task-image-1", + type: "character_image", + status: "completed", + error: null, + result: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + + await vi.waitFor(() => { + expect(controller.getWorkflow(run.id)?.nodes[2]).toMatchObject({ + type: "action-first-frame", + status: "active", + }); + }); + }); + + it("keeps interruption as frontend state and preserves the active node", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + + const interrupted = await controller.interrupt(run.id); + + expect(interrupted.status).toBe("interrupted"); + expect( + interrupted.nodes.filter((node) => node.status === "active"), + ).toHaveLength(1); + }); + + it("deduplicates concurrent character creation for one candidate", async () => { + const character: Character = { + id: "character-1", + projectId: "project-1", + createdAt: NOW, + updatedAt: NOW, + outfits: [ + { + id: "outfit-1", + characterId: "character-1", + name: "默认造型", + candidateCharacterTemplates: [], + characterTemplateUrl: "https://example.com/knight.png", + baseFrames: [], + actions: [], + }, + ], + }; + let releaseCreate: ((character: Character) => void) | undefined; + const characterApis: CharacterApis = { + get: vi.fn(async () => character), + listByProject: vi.fn(async () => [character]), + create: vi.fn( + () => + new Promise((resolve) => { + releaseCreate = resolve; + }), + ), + update: vi.fn(async (updated) => updated), + remove: vi.fn(async () => undefined), + }; + const { controller, emit } = createHarness(characterApis); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + await controller.nextStep(run.id, { width: 64, height: 64 }); + emit("task-image-1", { + taskId: "task-image-1", + type: "character_image", + status: "completed", + error: null, + result: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + await vi.waitFor(() => { + expect(controller.getWorkflow(run.id)?.nodes[2]?.status).toBe("active"); + }); + + const first = controller.startActionFromTemplate( + run.id, + "https://example.com/knight.png", + ); + const second = controller.startActionFromTemplate( + run.id, + "https://example.com/knight.png", + ); + await vi.waitFor(() => expect(characterApis.create).toHaveBeenCalledOnce()); + releaseCreate?.(character); + await Promise.all([first, second]); + + expect(characterApis.create).toHaveBeenCalledOnce(); + }); + + it("rejects an action result that is not exactly 32 frames", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "add_action", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "template.png", + baseFrameUrls: [], + }); + const result = await controller.completeActionGeneration(run.id, { + type: "character_action", + actionType: "idle", + frames: Array.from( + { length: CHARACTER_ACTION_FRAME_COUNT - 1 }, + (_, index) => ({ + index, + imageUrl: `frame-${index}.png`, + durationMs: null, + }), + ), + }); + + expect(result.status).toBe("failed"); + expect( + result.nodes.find((node) => node.type === "action-generation"), + ).toMatchObject({ + status: "failed", + error: expect.stringContaining("32 帧"), + }); + }); +}); diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 00000000..f58a8dcb --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,651 @@ +import type { + CharacterApis, + CharacterSetupNodeInput, + CharacterActionGenerationInput, + CharacterActionOutput, + GenerationApis, + MediaReference, + WorkflowRun, + WorkflowRunStore, +} from "@/entities"; +import { publishWorkflowRun } from "@/features/publish"; +import { + createActionGenerationTask, + getCharacterActionFrameCountError, +} from "./action-generation-task"; +import { createCharacterTemplateTask } from "./character-template-task"; +import { + advanceCharacterSetupState, + appendActionState, + acceptUploadedCharacterTemplateState, + approveReviewState, + completeActionGenerationState, + confirmFirstFrameState, + createWorkflowRunState, + getActiveNode, + interruptWorkflowRunState, + recordActionGenerationTaskState, + restartWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from "./workflow-state"; + +/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput; + +export interface WorkflowController { + /** 创建前端执行线,并把完整快照保存到持久化端。 */ + create(input: CreateWorkflowControllerInput): Promise; + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun["id"]): WorkflowRun | null; + + /** 按 Character 定位其唯一制作 Run;新增动作必须优先复用该 Run。 */ + getWorkflowByCharacter(characterId: string): Promise; + + /** 订阅当前页面会话中的运行状态;持久化实现不承担 UI 通知。 */ + subscribe( + runId: WorkflowRun["id"], + listener: (run: WorkflowRun) => void, + ): () => void; + + /** 在同一条已完成 Run 中追加新的动作生成与审核节点。 */ + appendAction(runId: WorkflowRun["id"]): Promise; + + /** 修改当前角色资料节点,页面无需知道节点内部 ID。 */ + updateCharacterSetup( + runId: WorkflowRun["id"], + input: CharacterSetupNodeInput, + ): Promise; + + /** 采用已上传的角色母版,跳过图片生成与候选选择并激活动作生成。 */ + acceptUploadedCharacterTemplate( + runId: WorkflowRun["id"], + templateUrl: MediaReference, + ): Promise; + + /** + * 推进一个节点。当前纵切只实现角色资料到角色图生成; + * 后续节点进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成节点需要传给后端做尺寸校验。 + */ + nextStep( + runId: WorkflowRun["id"], + spriteSize?: { width: number; height: number }, + ): Promise; + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun["id"]): Promise; + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun["id"]): Promise; + + /** 确认首帧生成完成,推进到完整帧率生成。 */ + confirmFirstFrame(runId: WorkflowRun["id"]): Promise; + + /** + * 采用已确认的角色母版,并统一完成 Character 落库、Run 绑定与动作任务提交。 + * Quick Start 和 Workflow Editor 都调用这个命令,页面不再各自复制业务编排。 + */ + startActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise; + + /** 动作生成完成后写回结果,标记当前动作节点为 passed。 */ + completeActionGeneration( + runId: WorkflowRun["id"], + result: CharacterActionOutput | { error: string }, + ): Promise; + + /** 提交完整动作生成,并由 Controller 统一处理订阅和刷新恢复。 */ + startActionGeneration( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ): Promise; + + /** 审核通过后完成当前版本和整条运行;不在这里执行发布或下载。 */ + approveReview(runId: WorkflowRun["id"]): Promise; + + /** 审核当前动作并写入正式 Character;发布失败后允许用同一 Run 重试。 */ + approveAndPublish(runId: WorkflowRun["id"]): Promise; + + /** 动作生成任务提交后把任务 ID 落盘,供页面刷新后 resume 恢复轮询。 */ + recordActionGenerationTask( + runId: WorkflowRun["id"], + taskId: string, + ): Promise; + + /** 记录动作生成关联的角色与造型 ID,供导出到 Playtest 使用(刷新后可恢复)。 */ + recordCharacterRefs( + runId: WorkflowRun["id"], + refs: { characterId: string; outfitId: string }, + ): Promise; + + /** 从当前执行线中一个已通过的节点重新开始。 */ + restart(runId: WorkflowRun["id"], nodeId: string): Promise; +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore; + generationApis: GenerationApis; + /** 创建角色流程需要该接口;只操作已有角色动作时可不配置。 */ + characterApis?: CharacterApis; + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: "run" | "submission") => string; + /** 测试可注入确定性时间。 */ + now?: () => string; +} + +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前节点、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + characterApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const cache = new Map(); + const listeners = new Map< + WorkflowRun["id"], + Set<(run: WorkflowRun) => void> + >(); + const saveQueues = new Map>(); + const persistedSnapshots = new Map(); + const mutationVersions = new Map(); + const templateActionSubmissions = new Map< + WorkflowRun["id"], + Promise + >(); + + function notify(run: WorkflowRun) { + const snapshot = structuredClone(run); + for (const listener of listeners.get(snapshot.id) ?? []) { + try { + listener(structuredClone(snapshot)); + } catch { + // 一个页面订阅者渲染失败不能阻断持久化,也不能影响其他订阅者。 + } + } + return structuredClone(snapshot); + } + + function rememberStored(run: WorkflowRun) { + const snapshot = structuredClone(run); + cache.set(snapshot.id, snapshot); + persistedSnapshots.set(snapshot.id, structuredClone(snapshot)); + return notify(snapshot); + } + + async function load(runId: WorkflowRun["id"]) { + const cached = cache.get(runId); + if (cached) return structuredClone(cached); + const stored = await store.get(runId); + return stored ? rememberStored(stored) : null; + } + + async function persist(run: WorkflowRun) { + const snapshot = structuredClone(run); + const version = (mutationVersions.get(snapshot.id) ?? 0) + 1; + mutationVersions.set(snapshot.id, version); + cache.set(snapshot.id, structuredClone(snapshot)); + // 同一 Run 的网络写入必须保持调用顺序,避免较慢的旧请求最后落库覆盖新状态。 + const previous = saveQueues.get(snapshot.id) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(() => store.save(structuredClone(snapshot))); + saveQueues.set(snapshot.id, pending); + try { + await pending; + persistedSnapshots.set(snapshot.id, structuredClone(snapshot)); + if (mutationVersions.get(snapshot.id) === version) notify(snapshot); + } catch (cause) { + if (mutationVersions.get(snapshot.id) === version) { + const fallback = persistedSnapshots.get(snapshot.id); + if (fallback) { + cache.set(snapshot.id, structuredClone(fallback)); + notify(fallback); + } else { + cache.delete(snapshot.id); + } + } + throw cause; + } finally { + if (saveQueues.get(snapshot.id) === pending) + saveQueues.delete(snapshot.id); + } + return snapshot; + } + + const taskStore: WorkflowRunStore = { + create: (input) => store.create(input), + get: load, + getByCharacter: async (characterId) => { + const cached = [...cache.values()].find( + (run) => run.characterId === characterId, + ); + if (cached) return structuredClone(cached); + const stored = await store.getByCharacter(characterId); + return stored ? rememberStored(stored) : null; + }, + list: (projectId) => store.list(projectId), + save: async (run) => { + await persist(run); + }, + }; + + const characterTemplateTask = createCharacterTemplateTask({ + store: taskStore, + generationApis, + createSubmissionId: () => createId("submission"), + }); + const actionGenerationTask = createActionGenerationTask({ + store: taskStore, + generationApis, + createSubmissionId: () => createId("submission"), + }); + + function getWorkflow(runId: WorkflowRun["id"]) { + const run = cache.get(runId); + return run ? structuredClone(run) : null; + } + + async function getWorkflowByCharacter(characterId: string) { + const run = [...cache.values()].find( + (item) => item.characterId === characterId, + ); + if (run) return structuredClone(run); + const stored = await store.getByCharacter(characterId); + return stored ? rememberStored(stored) : null; + } + + async function requireWorkflow(runId: WorkflowRun["id"]) { + const run = await load(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + function subscribe( + runId: WorkflowRun["id"], + listener: (run: WorkflowRun) => void, + ) { + const runListeners = + listeners.get(runId) ?? new Set<(run: WorkflowRun) => void>(); + runListeners.add(listener); + listeners.set(runId, runListeners); + return () => { + runListeners.delete(listener); + if (runListeners.size === 0) listeners.delete(runId); + }; + } + + async function create( + input: CreateWorkflowControllerInput, + ): Promise { + const created = await store.create(input); + rememberStored(created); + return persist( + createWorkflowRunState(input, { + runId: created.id || createId("run"), + createdAt: created.createdAt || now(), + }), + ); + } + + async function appendAction(runId: WorkflowRun["id"]): Promise { + return persist(appendActionState(await requireWorkflow(runId))); + } + + async function updateCharacterSetup( + runId: WorkflowRun["id"], + input: CharacterSetupNodeInput, + ): Promise { + return persist( + updateCharacterSetupState(await requireWorkflow(runId), input), + ); + } + + async function acceptUploadedCharacterTemplate( + runId: WorkflowRun["id"], + templateUrl: MediaReference, + ): Promise { + return persist( + acceptUploadedCharacterTemplateState( + await requireWorkflow(runId), + templateUrl, + ), + ); + } + + async function nextStep( + runId: WorkflowRun["id"], + spriteSize?: { width: number; height: number }, + ): Promise { + const run = requireActiveWorkflow(await requireWorkflow(runId)); + const activeNode = getActiveNode(run); + if (!activeNode) throw new Error("当前 WorkflowRun 没有 active 节点"); + + if (activeNode.type === "character-template") { + return characterTemplateTask.start(runId, { + runId: run.id, + nodeId: activeNode.id, + }); + } + if (activeNode.type !== "character-setup") { + throw new Error(`节点 ${activeNode.type} 尚未进入本轮实现`); + } + + if (!spriteSize) throw new Error("推进角色资料节点需要项目精灵图尺寸"); + + const transitioned = advanceCharacterSetupState(run, spriteSize); + await persist(transitioned.run); + return characterTemplateTask.start(runId, transitioned.target); + } + + function resume(runId: WorkflowRun["id"]) { + return load(runId).then((run) => { + if (!run || run.status !== "active") return run; + const node = getActiveNode(run); + return node?.type === "action-first-frame" || node?.type === "action-full-frame" + ? actionGenerationTask.resume(runId) + : characterTemplateTask.resume(runId); + }); + } + + async function interrupt(runId: WorkflowRun["id"]): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") return run; + + characterTemplateTask.stop(runId); + actionGenerationTask.stop(runId); + const latest = await requireWorkflow(runId); + if (latest.status !== "active") return latest; + return persist(interruptWorkflowRunState(latest)); + } + + async function confirmFirstFrame(runId: WorkflowRun["id"]): Promise { + return persist(confirmFirstFrameState(await requireWorkflow(runId))); + } + + async function startActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise { + const pending = templateActionSubmissions.get(runId); + if (pending) return pending; + const submission = submitActionFromTemplate( + runId, + templateImageUrl, + actionDescription, + ).finally(() => templateActionSubmissions.delete(runId)); + templateActionSubmissions.set(runId, submission); + return submission; + } + + async function submitActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise { + if (!characterApis) throw new Error("角色服务尚未配置,不能开始动作生成"); + + const run = await requireWorkflow(runId); + const initialState = getTemplateActionInputState(run, templateImageUrl); + let character: Awaited> | null = null; + let bound = false; + try { + character = await characterApis.create({ + projectId: run.projectId, + description: "Workflow auto-created character", + referenceImageUrl: templateImageUrl, + }); + + if (character.outfits.length === 0) { + character = await characterApis.update({ + ...character, + outfits: [ + { + id: `outfit-${character.id}-default`, + characterId: character.id, + name: "默认造型", + candidateCharacterTemplates: [], + characterTemplateUrl: templateImageUrl, + baseFrames: [], + actions: [], + }, + ], + }); + } + + const outfitId = character.outfits[0]?.id; + if (!outfitId) throw new Error("角色服务没有返回可用的造型 ID"); + + const latest = await requireWorkflow(runId); + const latestState = getTemplateActionInputState(latest, templateImageUrl); + if (latestState !== initialState) { + throw new Error("角色母版节点已变更,不能继续提交动作生成"); + } + const ready = + latestState === "candidate-active" + ? await persist(confirmFirstFrameState(latest)) + : latest; + const boundRun = await persist({ + ...ready, + characterId: character.id, + outfitId, + }); + bound = true; + const prompt = actionDescription?.trim(); + + return await actionGenerationTask.start(runId, { + type: "character_action", + projectId: boundRun.projectId, + characterId: character.id, + outfitId, + actionType: prompt ? "custom" : "idle", + firstFrameUrl: templateImageUrl, + prompt: prompt || null, + referenceMedia: [templateImageUrl as MediaReference], + numFrames: 32, + }); + } catch (error) { + if (!bound && character) { + try { + await characterApis.remove(character.id); + } catch (cleanupError) { + console.error("[workflow] 清理未绑定角色失败", cleanupError); + } + } + const failedRun = await load(runId); + if (bound && failedRun?.status === "active") { + const activeNode = getActiveNode(failedRun); + if ( + (activeNode?.type === "action-first-frame" || activeNode?.type === "action-full-frame") && + !activeNode.taskId && + !activeNode.submissionId + ) { + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "动作生成失败"; + await persist( + completeActionGenerationState(failedRun, { error: message }), + ); + } + } + throw error; + } + } + + async function completeActionGeneration( + runId: WorkflowRun["id"], + result: CharacterActionOutput | { error: string }, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[completeActionGen] run not active:", run.status); + return run; + } + const node = getActiveNode(run); + if (!node || node.status !== "active") { + console.warn( + "[completeActionGen] node not active:", + node?.type, + node?.status, + ); + return run; + } + if ("error" in result) + return persist(completeActionGenerationState(run, result)); + + const frameCountError = getCharacterActionFrameCountError(result); + return persist( + completeActionGenerationState( + run, + frameCountError ? { error: frameCountError } : result, + ), + ); + } + + function startActionGeneration( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ) { + return actionGenerationTask.start(runId, input); + } + + async function approveReview(runId: WorkflowRun["id"]): Promise { + return persist(approveReviewState(await requireWorkflow(runId))); + } + + async function approveAndPublish( + runId: WorkflowRun["id"], + ): Promise { + if (!characterApis) throw new Error("角色服务尚未配置,不能发布资产"); + const run = await requireWorkflow(runId); + const reviewStep = run.nodes.findLast((node) => node.type === "review"); + const approved = + run.status === "active" && reviewStep?.status === "active" + ? await approveReview(runId) + : run.status === "completed" && reviewStep?.status === "passed" + ? run + : null; + if (!approved) throw new Error("审核节点尚未就绪,不能发布资产"); + + await publishWorkflowRun(characterApis, approved); + return approved; + } + + async function recordActionGenerationTask( + runId: WorkflowRun["id"], + taskId: string, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[recordActionTask] run not active:", run.status); + return run; + } + return persist(recordActionGenerationTaskState(run, taskId)); + } + + async function recordCharacterRefs( + runId: WorkflowRun["id"], + refs: { characterId: string; outfitId: string }, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[recordCharacterRefs] run not active:", run.status); + return run; + } + return persist({ + ...run, + characterId: refs.characterId, + outfitId: refs.outfitId, + }); + } + + async function restart( + runId: WorkflowRun["id"], + nodeId: string, + ): Promise { + characterTemplateTask.stop(runId); + actionGenerationTask.stop(runId); + return persist( + restartWorkflowRunState(await requireWorkflow(runId), nodeId), + ); + } + + return { + create, + getWorkflow, + getWorkflowByCharacter, + subscribe, + appendAction, + updateCharacterSetup, + acceptUploadedCharacterTemplate, + nextStep, + confirmFirstFrame, + startActionFromTemplate, + completeActionGeneration, + startActionGeneration, + approveReview, + approveAndPublish, + recordActionGenerationTask, + recordCharacterRefs, + restart, + resume, + interrupt, + }; +} + +function getTemplateActionInputState( + run: WorkflowRun, + _templateImageUrl: string, +): "first-frame-active" | "uploaded-template" { + const firstFrameNode = run.nodes.find( + (node) => node.type === "action-first-frame", + ); + const activeNode = getActiveNode(run); + if ( + firstFrameNode?.status === "active" && + activeNode?.type === "action-first-frame" + ) { + return "first-frame-active"; + } + if ( + firstFrameNode?.status === "passed" && + (activeNode?.type === "action-full-frame" || activeNode?.type === "review") + ) { + return "uploaded-template"; + } + throw new Error("当前流程状态不能开始动作生成"); +} + +function hasSelectedTemplateUrl( + output: unknown, + templateImageUrl: string, +): boolean { + return ( + typeof output === "object" && + output !== null && + "selectedImageUrl" in output && + output.selectedImageUrl === templateImageUrl + ); +} + +function createRuntimeId(scope: "run" | "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 f8ce8792..fcb6978b 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 00000000..37001ac1 --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Generation, GenerationApis, GenerationInput } from "@/entities"; +import { createWorkflowRunStore } from "@/entities/workflow-run/store"; +import { createWorkflowController } from "."; + +function createHarness() { + const store = createWorkflowRunStore(); + 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(() => () => undefined), + }; + return { + store, + controller: createWorkflowController({ + store, + generationApis, + now: () => "2026-07-31T12:00:00.000Z", + }), + }; +} + +describe("workflow persistence invariants", () => { + it("persists a complete frontend node graph immediately after creation", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "像素骑士", + }); + + const restored = await store.get(created.id); + expect(restored?.nodes).toHaveLength(5); + expect( + restored?.nodes.filter((node) => node.status === "active"), + ).toHaveLength(1); + }); + + it("persists the existing character references for add_action", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "add_action", + prompt: "挥手", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "template.png", + baseFrameUrls: [], + }); + + const restored = await store.get(created.id); + expect(restored).toMatchObject({ + characterId: "character-1", + outfitId: "outfit-1", + }); + expect( + restored?.nodes.find((node) => node.type === "action-generation")?.status, + ).toBe("active"); + }); + + it("persists interruption without clearing the active node", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + await controller.interrupt(created.id); + + const restored = await store.get(created.id); + expect(restored?.status).toBe("interrupted"); + expect( + restored?.nodes.filter((node) => node.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 00000000..2a5cddf6 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,103 @@ +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(); + const taskChannel: { listener?: (event: GenerationEvent) => void } = {}; + + const createGeneration: GenerationApis["create"] = async < + T extends GenerationInput, + >( + 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_image", + status: "pending", + error: null, + result: null, + }); + return () => { + delete taskChannel.listener; + }; + }), + }; + const controller = createWorkflowController({ + store, + generationApis, + createId: () => "submission-1", + now: () => "2026-07-30T12:00:00.000Z", + }); + + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "像素骑士", + }); + + await controller.nextStep(created.id, { width: 64, height: 64 }); + + const inFlight = await store.get(created.id); + expect( + inFlight?.nodes.find((node) => node.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_image", + status: "completed", + error: null, + result: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + await vi.waitFor(async () => { + const completed = await store.get(created.id); + expect( + completed?.nodes.find((node) => node.type === "character-template"), + ).toMatchObject({ + status: "passed", + taskId: null, + output: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + expect( + completed?.nodes.find((node) => node.type === "action-first-frame"), + ).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 00000000..7d7482ae --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import type { MediaReference, WorkflowRun } from "@/entities"; + +import { + acceptUploadedCharacterTemplateState, + advanceCharacterSetupState, + appendActionState, + approveReviewState, + beginActionGenerationState, + completeActionGenerationState, + 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", + prompt: " pixel knight ", + }, + { runId: "run-1", createdAt: CREATED_AT }, + ); +} + +function readyForReview(): WorkflowRun { + const run = createRun(); + return { + ...run, + characterId: "character-1", + outfitId: "outfit-1", + generationStatus: "completed", + nodes: run.nodes.map((node) => ({ + ...node, + status: + node.type === "review" ? ("active" as const) : ("passed" as const), + })), + }; +} + +describe("workflow state transitions", () => { + it("creates one WorkflowRun with the five initial nodes", () => { + const run = createRun(); + + expect(run).toMatchObject({ + id: "run-1", + projectId: "project-1", + status: "active", + prompt: "pixel knight", + createdAt: CREATED_AT, + }); + expect(run.nodes.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: "character-setup", status: "active" }, + { type: "character-template", status: "locked" }, + { type: "action-first-frame", status: "locked" }, + { type: "action-generation", status: "locked" }, + { type: "review", status: "locked" }, + ]); + }); + + it("starts add_action directly at action generation for the existing outfit", () => { + const run = createWorkflowRunState( + { + projectId: "project-1", + purpose: "add_action", + prompt: "挥手打招呼", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "https://example.com/template.png", + baseFrameUrls: [], + }, + { runId: "run-action-1", createdAt: CREATED_AT }, + ); + + expect(run.nodes.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: "character-setup", status: "passed" }, + { type: "character-template", status: "passed" }, + { type: "action-first-frame", status: "passed" }, + { type: "action-generation", status: "active" }, + { type: "review", status: "locked" }, + ]); + }); + + it("normalizes setup and advances with a frozen generation input", () => { + const updated = updateCharacterSetupState(createRun(), { + description: " revised knight ", + referenceMedia: [], + }); + const transitioned = advanceCharacterSetupState(updated, { + width: 64, + height: 64, + }); + + expect(transitioned.target).toEqual({ + runId: "run-1", + nodeId: "run-1:character-template", + }); + expect(transitioned.run.nodes[1]).toMatchObject({ + type: "character-template", + status: "active", + input: { + type: "character_image", + projectId: "project-1", + prompt: "revised knight", + spriteWidth: 64, + spriteHeight: 64, + }, + }); + }); + + it("accepts an uploaded template without fabricating a generation task", () => { + const accepted = acceptUploadedCharacterTemplateState( + createRun(), + "https://cdn.example.com/uploaded.png" as MediaReference, + ); + + expect(accepted.nodes[1]).toMatchObject({ + type: "character-template", + status: "passed", + taskId: null, + output: { + type: "character_image", + imageUrls: ["https://cdn.example.com/uploaded.png"], + }, + }); + expect(accepted.nodes[2]?.output).toEqual({ + selectedImageUrl: "https://cdn.example.com/uploaded.png", + }); + expect(accepted.nodes[3]?.status).toBe("active"); + }); + + it("completes the run when the active review is approved", () => { + const completed = approveReviewState(readyForReview()); + + expect(completed.status).toBe("completed"); + expect(completed.nodes.every((node) => node.status === "passed")).toBe( + true, + ); + }); + + it("reopens the same run and appends a unique action/review pair", () => { + const appended = appendActionState(approveReviewState(readyForReview())); + + expect(appended.id).toBe("run-1"); + expect(appended.status).toBe("active"); + expect( + appended.nodes.slice(-2).map(({ type, status }) => ({ type, status })), + ).toEqual([ + { type: "action-generation", status: "active" }, + { type: "review", status: "locked" }, + ]); + expect(new Set(appended.nodes.map((node) => node.id)).size).toBe( + appended.nodes.length, + ); + }); + + it("records a 32-frame action without overwriting earlier action nodes", () => { + const appended = appendActionState(approveReviewState(readyForReview())); + const input = { + type: "character_action" as const, + projectId: "project-1", + characterId: "character-1", + outfitId: "outfit-1", + actionType: "custom" as const, + firstFrameUrl: "template.png", + prompt: "挥手", + referenceMedia: ["template.png" as MediaReference], + numFrames: 32, + }; + const submitting = beginActionGenerationState( + appended, + input, + "submission-2", + ); + const generated = completeActionGenerationState(submitting, { + type: "character_action", + actionType: "custom", + frames: Array.from({ length: 32 }, (_, index) => ({ + index, + imageUrl: `wave-${index}.png`, + durationMs: null, + })), + }); + + expect( + generated.nodes.filter((node) => node.type === "action-generation"), + ).toHaveLength(2); + expect(generated.nodes.at(-2)).toMatchObject({ status: "passed" }); + expect(generated.nodes.at(-1)).toMatchObject({ + type: "review", + status: "active", + }); + }); + + it("restarts a passed node in place and clears its downstream results", () => { + const run = readyForReview(); + const restarted = restartWorkflowRunState(run, "run-1:character-template"); + + expect(restarted.id).toBe(run.id); + expect(restarted.nodes[1]).toMatchObject({ + status: "active", + output: null, + }); + expect( + restarted.nodes.slice(2).every((node) => node.status === "locked"), + ).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 00000000..ab55507a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,467 @@ +import { + WORKFLOW_NODE_ORDER, + type CharacterSetupNodeInput, + type CharacterImageGenerationInput, + type CharacterActionGenerationInput, + type CharacterActionOutput, + type CreateWorkflowRunInput, + type MediaReference, + type WorkflowRun, + type WorkflowNode, + type WorkflowNodeStatus, + type WorkflowNodeType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = CreateWorkflowRunInput + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + createdAt: string +} + +export interface WorkflowNodeTarget { + runId: WorkflowRun['id'] + nodeId: WorkflowNode['id'] +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + const nodes = createInitialNodes(input, runId, prompt) + + return { + id: runId, + projectId: input.projectId, + characterId: input.purpose === 'add_action' ? input.characterId : null, + outfitId: input.purpose === 'add_action' ? input.outfitId : null, + purpose: input.purpose, + status: 'active', + nodes, + generationStatus: 'not_started', + exportStatus: 'not_exported', + prompt, + createdAt, + } +} + +function createInitialNodes( + input: CreateWorkflowRunStateInput, + runId: string, + prompt: string | null, +): WorkflowNode[] { + const nodes = WORKFLOW_NODE_ORDER.map((type, index) => + createInitialNode(type, runId, index, prompt), + ) + if (input.purpose === 'create_character') return nodes + + return nodes.map((node) => { + if (node.type === 'character-setup') { + return { + ...node, + status: 'passed' as const, + input: { + description: prompt ?? '为已有角色添加动作', + referenceMedia: [], + }, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'passed' as const, + output: { + type: 'character_image' as const, + imageUrls: [input.characterTemplateUrl], + }, + } + } + if (node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }) +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRun { + return run +} + +export function getActiveNode(run: WorkflowRun): WorkflowNode | null { + return run.nodes.find((node) => node.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowNode( + run: WorkflowRun, + nodeId: WorkflowNode['id'], + update: (node: WorkflowNode) => WorkflowNode, +): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => (node.id === nodeId ? update(node) : node)), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupNodeInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const node = run.nodes.find((item) => item.type === 'character-setup') + if (!node || node.type !== 'character-setup' || node.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料节点') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowNode(run, node.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +export function acceptUploadedCharacterTemplateState( + workflow: WorkflowRun, + templateUrl: MediaReference, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const activeNode = getActiveNode(run) + if (!activeNode || activeNode.type !== 'character-setup') { + throw new Error('当前只能在角色资料节点采用上传母版') + } + + const normalizedUrl = String(templateUrl).trim() + if (!normalizedUrl) throw new Error('上传角色母版引用不能为空') + + return { + ...run, + nodes: run.nodes.map((node) => { + if (node.type === 'character-setup') { + return { + ...node, + status: 'passed' as const, + input: { + description: '使用上传角色母版', + referenceMedia: [normalizedUrl as MediaReference], + }, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'passed' as const, + input: null, + output: { + type: 'character_image' as const, + imageUrls: [normalizedUrl], + }, + } + } + if (node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }), + } +} + +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { + run: WorkflowRun + target: WorkflowNodeTarget +} { + const run = requireActiveWorkflow(workflow) + const activeNode = getActiveNode(run) + if (!activeNode) throw new Error('当前 WorkflowRun 没有 active 节点') + if (activeNode.type !== 'character-setup') { + throw new Error(`当前节点不是角色资料:${activeNode.type}`) + } + if (!activeNode.input) throw new Error('请先填写角色资料') + + const templateNode = run.nodes.find((node) => node.type === 'character-template') + if (!templateNode) throw new Error('WorkflowRun 缺少 character-template 节点') + + const generationInput: CharacterImageGenerationInput = { + type: 'character_image', + projectId: run.projectId, + prompt: activeNode.input.description, + referenceMedia: activeNode.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, + } + + return { + run: { + ...run, + generationStatus: 'in_progress' as const, + nodes: run.nodes.map((node) => { + if (node.id === activeNode.id) return { ...node, status: 'passed' as const } + if (node.id !== templateNode.id || node.type !== 'character-template') return node + return { + ...node, + status: 'active' as const, + input: generationInput, + } + }), + }, + target: { runId: run.id, nodeId: templateNode.id }, + } +} + +export function confirmFirstFrameState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + const firstFrameNode = run.nodes.find((node) => node.type === 'action-first-frame') + if (!firstFrameNode || firstFrameNode.status !== 'active') { + throw new Error('当前只能确认处于 active 状态的首帧节点') + } + + return { + ...run, + nodes: run.nodes.map((node) => { + if (node.id === firstFrameNode.id && node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }), + } +} + +export function completeActionGenerationState( + run: WorkflowRun, + result: CharacterActionOutput | { error: string }, +): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可完成动作生成:${run.status}`) + const actionNode = getActiveNode(run) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') + ) { + throw new Error('当前只能完成处于 active 状态的动作生成节点') + } + + const failed = result !== null && typeof result === 'object' && 'error' in result + const actionIndex = run.nodes.findIndex((node) => node.id === actionNode.id) + + const updated = replaceWorkflowNode(run, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + 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, + taskId: null, + submissionId: null, + } + }) + + // 找到下一个节点并激活 + const nextNode = updated.nodes[actionIndex + 1] + return { + ...updated, + nodes: updated.nodes.map((node) => { + if (failed || !nextNode || node.id !== nextNode.id) return node + return { ...node, status: 'active' as const } + }), + status: failed ? ('failed' as const) : updated.status, + generationStatus: failed ? ('failed' as const) : ('completed' as const), + } +} + +export function approveReviewState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可审核:${run.status}`) + const reviewNode = getActiveNode(run) + if (!reviewNode || reviewNode.type !== 'review') { + throw new Error('当前只能通过处于 active 状态的审核节点') + } + + return { + ...run, + status: 'completed', + nodes: run.nodes.map((node) => + node.id === reviewNode.id ? { ...node, status: 'passed' as const, error: null } : node, + ), + } +} + +export function appendActionState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'completed') throw new Error('只能给已完成的 WorkflowRun 追加动作') + if (!run.characterId || !run.outfitId) throw new Error('WorkflowRun 尚未绑定角色与造型') + + const actionNumber = run.nodes.filter((node) => node.type === 'action-full-frame').length + 1 + const actionNode = { + ...createInitialNode('action-full-frame', run.id, run.nodes.length, null), + id: `${run.id}:action-full-frame:${actionNumber}`, + status: 'active' as const, + } + const reviewNode = { + ...createInitialNode('review', run.id, run.nodes.length + 1, null), + id: `${run.id}:review:${actionNumber}`, + status: 'locked' as const, + } + + return { + ...run, + status: 'active', + generationStatus: 'not_started', + exportStatus: 'not_exported', + nodes: [...run.nodes, actionNode, reviewNode], + } +} + +export function beginActionGenerationState( + run: WorkflowRun, + input: CharacterActionGenerationInput, + submissionId: string, +): WorkflowRun { + const activeRun = requireActiveWorkflow(run) + const actionNode = getActiveNode(activeRun) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') || + actionNode.taskId + ) { + throw new Error('当前动作生成节点不可重复提交') + } + return replaceWorkflowNode(activeRun, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + return current + return { ...current, input, submissionId, error: null } + }) +} + +export function recordActionGenerationTaskState( + run: WorkflowRun, + taskId: string, + input?: CharacterActionGenerationInput, +): WorkflowRun { + if (run.status !== 'active' && run.status !== 'interrupted') { + throw new Error(`WorkflowRun 当前不可记录任务:${run.status}`) + } + const actionNode = getActiveNode(run) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') + ) { + throw new Error('当前只能为 active 状态的动作生成节点记录任务') + } + return replaceWorkflowNode(run, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + 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 +} + +export function restartWorkflowRunState( + run: WorkflowRun, + restartNodeId: WorkflowNode['id'], +): WorkflowRun { + const restartIndex = run.nodes.findIndex((node) => node.id === restartNodeId) + const restartNode = run.nodes[restartIndex] + if (!restartNode || restartNode.status !== 'passed') { + throw new Error('只能从已通过的节点重新开始') + } + + const retainedNodeCount = + restartIndex < 3 + ? WORKFLOW_NODE_ORDER.length + : restartNode.type === 'action-full-frame' + ? restartIndex + 2 + : restartIndex + 1 + + const nodes = run.nodes.slice(0, retainedNodeCount).map((node, index) => { + if (index < restartIndex) { + return { ...structuredClone(node), status: 'passed' as const, taskId: null, submissionId: null, error: null } + } + if (index === restartIndex) { + return { ...structuredClone(node), status: 'active' as const, taskId: null, submissionId: null, error: null, output: null } as WorkflowNode + } + return lockFreshNode(node.type, run.id, index, run.prompt) + }) + + return { + ...run, + status: 'active', + nodes, + generationStatus: 'not_started', + exportStatus: 'not_exported', + } +} + +function lockFreshNode( + type: WorkflowNodeType, + runId: WorkflowRun['id'], + index: number, + prompt: string | null, +): WorkflowNode { + return { + ...createInitialNode(type, runId, index, prompt), + status: 'locked', + } +} + +function createInitialNode( + type: WorkflowNodeType, + runId: string, + index: number, + prompt: string | null, +): WorkflowNode { + const status: WorkflowNodeStatus = index === 0 ? 'active' : 'locked' + const base = { + id: createNodeId(runId, type, index), + status, + taskId: null, + submissionId: null, + error: null, + } + + 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 } + } + if (type === 'action-first-frame' || type === 'action-full-frame') { + return { ...base, type, input: null, output: null } + } + return { ...base, type, input: null, output: null } as WorkflowNode +} + +function createNodeId(runId: string, type: WorkflowNodeType, index: number): string { + if (index < WORKFLOW_NODE_ORDER.length) return `${runId}:${type}` + const actionNumber = Math.floor((index - WORKFLOW_NODE_ORDER.length) / 2) + 1 + return `${runId}:${type}:${actionNumber}` +} diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index b90da652..b0f40f13 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,13 +1,158 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' -/** 资产库。 */ -export function AssetLibraryPage() { +import type { Character, CharacterApis } from '@/entities' +import type { Paged } from '@/shared/pagination' +import { Pagination } from '@/shared/ui' + +const CHARACTER_PAGE_SIZE = 24 + +function characterName(character: Character) { + return character.name?.trim() || character.description?.trim() || `角色 ${character.id}` +} + +export function AssetLibraryPage({ apis }: { apis: CharacterApis }) { + const { projectId } = useParams() + const [pageNumber, setPageNumber] = useState(1) + const [charactersPage, setCharactersPage] = useState | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) { + setError('缺少项目 ID') + return () => { + active = false + } + } + + setCharactersPage(null) + setError(null) + const pagePromise = apis.listPageByProject + ? apis.listPageByProject(projectId, { + page: pageNumber, + pageSize: CHARACTER_PAGE_SIZE, + }) + : apis.listByProject(projectId).then((items) => ({ + items, + total: items.length, + page: 1, + pageSize: items.length || CHARACTER_PAGE_SIZE, + })) + void pagePromise.then( + (page) => { + if (active) setCharactersPage(page) + }, + () => { + if (active) setError('资产库暂时无法读取') + }, + ) + return () => { + active = false + } + }, [apis, pageNumber, projectId]) + + return ( +
+

+ 角色 +

+
+
+ +
+ {error ? ( +

+ {error} +

+ ) : charactersPage === null ? ( +

正在建立资产索引…

+ ) : ( + <> + + + + )} +
+
+ ) +} + +function CharacterGrid({ projectId, characters }: { projectId: string; characters: Character[] }) { + if (characters.length === 0) return + + return ( +
+ {characters.map((character) => { + const name = characterName(character) + const outfit = character.outfits[0] + const actionCount = character.outfits.reduce((sum, item) => sum + item.actions.length, 0) + return ( + +
+ {outfit?.characterTemplateUrl ? ( + {`${name}的${outfit.name}预览`} + ) : ( +
+ + 暂无造型预览 + +
+ )} +
+
+
+
+

{name}

+

{outfit?.name ?? '尚未创建造型'}

+
+ +
+
+ {character.outfits.length} 套造型 + · + {actionCount} 个动作 +
+
+ + ) + })} +
+ ) +} + +function EmptyState() { return ( - -
-

资产库

-

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

-
-
+
+

这个项目还没有角色

+

角色会在创建工作流确认后进入这里。

+
) } diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx new file mode 100644 index 00000000..c9aac26a --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,122 @@ +/** + * 历史记录页面 — 读取 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([]); + + useEffect(() => { + let active = true; + void store.list(projectId).then((items) => { + if (active) setRuns(items); + }); + return () => { + active = false; + }; + }, [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 RunCard({ run }: { run: WorkflowRun }) { + const passedCount = run.nodes.filter( + (node) => node.status === "passed", + ).length; + const totalCount = run.nodes.length; + + 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/account-panel.test.tsx b/frontend/src/pages/home/account-panel.test.tsx new file mode 100644 index 00000000..79d5e882 --- /dev/null +++ b/frontend/src/pages/home/account-panel.test.tsx @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, useLocation } from 'react-router' + +import type { AuthSessionValue } from '@/features/auth-session' +import { useAuthSession } from '@/features/auth-session' +import { AccountPanel } from './account-panel' + +vi.mock('@/features/auth-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthSession: vi.fn() } +}) + +const mockedUseAuthSession = vi.mocked(useAuthSession) + +function createSession( + state: AuthSessionValue['state'] = { status: 'guest', user: null }, +): AuthSessionValue { + const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal' as const, + } + const tokens = { accessToken: 'access', refreshToken: 'refresh', user } + return { + state, + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens), + login: vi.fn(async () => tokens), + loginByCode: vi.fn(async () => tokens), + changePassword: vi.fn(async () => undefined), + logout: vi.fn(async () => undefined), + } +} + +function LocationProbe() { + const location = useLocation() + return {location.pathname + location.search} +} + +function renderPanel(initialEntry = '/?account=login', onClose = vi.fn()) { + return render( + + + + , + ) +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + mockedUseAuthSession.mockReset() + mockedUseAuthSession.mockReturnValue(createSession()) +}) + +describe('AccountPanel', () => { + it('本地模式预填开发账户,一次点击即可进入工作台', async () => { + vi.stubEnv('VITE_AUTH_MODE', 'local') + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2Fquick-start') + + expect((screen.getByLabelText('邮箱') as HTMLInputElement).value).toBe('local@windup.dev') + fireEvent.click(screen.getByRole('button', { name: '进入本地工作台' })) + + await waitFor(() => + expect(session.loginByCode).toHaveBeenCalledWith({ + email: 'local@windup.dev', + code: 'local', + }), + ) + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start')) + }) + + it('发送登录验证码并用验证码登录后恢复安全站内目标', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2Fprojects%3Fpage%3D2') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + + await waitFor(() => + expect(session.sendCode).toHaveBeenCalledWith({ + email: 'ada@example.test', + purpose: 'login', + }), + ) + expect( + (screen.getByRole('button', { name: '60 秒后可重发' }) as HTMLButtonElement).disabled, + ).toBe(true) + + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => + expect(session.loginByCode).toHaveBeenCalledWith({ + email: 'ada@example.test', + code: '123456', + }), + ) + await waitFor(() => + expect(screen.getByLabelText('当前路径').textContent).toBe('/projects?page=2'), + ) + }) + + it('密码登录也提交验证码,且拒绝跳转到协议相对地址', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2F%2Fevil.example') + + fireEvent.click(screen.getByRole('button', { name: '密码登录' })) + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '654321' } }) + fireEvent.click(screen.getByRole('button', { name: '使用密码登录' })) + + await waitFor(() => + expect(session.login).toHaveBeenCalledWith({ + email: 'ada@example.test', + password: 'password123', + code: '654321', + }), + ) + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/')) + }) + + it('拒绝包含反斜杠的 returnTo,避免浏览器将其规范化为外站', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2F%5Cevil.example') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/')) + }) + + it('密码与验证码登录按钮暴露当前选中状态', () => { + renderPanel() + + expect(screen.getByRole('button', { name: '验证码登录' }).getAttribute('aria-pressed')).toBe( + 'true', + ) + expect(screen.getByRole('button', { name: '密码登录' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + + fireEvent.click(screen.getByRole('button', { name: '密码登录' })) + expect(screen.getByRole('button', { name: '验证码登录' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + expect(screen.getByRole('button', { name: '密码登录' }).getAttribute('aria-pressed')).toBe( + 'true', + ) + }) + + it('非法邮箱不能绕过原生约束发送验证码', () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + + const emailInput = screen.getByLabelText('邮箱') as HTMLInputElement + fireEvent.change(emailInput, { target: { value: 'not-an-email' } }) + expect(emailInput.checkValidity()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + + expect(session.sendCode).not.toHaveBeenCalled() + }) + + it('使用 register purpose 发码并提交可选昵称完成注册', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=register') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'new@example.test' }, + }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await waitFor(() => + expect(session.sendCode).toHaveBeenCalledWith({ + email: 'new@example.test', + purpose: 'register', + }), + ) + + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '112233' } }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } }) + fireEvent.change(screen.getByLabelText('昵称(可选)'), { target: { value: ' 新用户 ' } }) + fireEvent.click(screen.getByRole('button', { name: '创建账户' })) + + await waitFor(() => + expect(session.register).toHaveBeenCalledWith({ + email: 'new@example.test', + password: 'password123', + code: '112233', + nickname: '新用户', + }), + ) + }) + + it('只读展示账户资料,不提供头像、昵称或邮箱编辑与删号入口', () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'banned', + }, + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + expect(screen.getByRole('heading', { name: '个人设置' })).toBeTruthy() + expect(screen.getByText('ada@example.test')).toBeTruthy() + expect(screen.getByText('Ada')).toBeTruthy() + expect(screen.getByText('未验证')).toBeTruthy() + expect(screen.getByText('已封禁')).toBeTruthy() + expect(screen.queryByLabelText('邮箱')).toBeNull() + expect(screen.queryByLabelText('昵称')).toBeNull() + expect(screen.queryByText(/头像|删除账户|OAuth|第三方登录/)).toBeNull() + }) + + it('修改密码成功后提示重新登录并返回登录模式', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + fireEvent.change(screen.getByLabelText('当前密码'), { target: { value: 'old-password' } }) + fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'new-password' } }) + fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + + await waitFor(() => + expect(session.changePassword).toHaveBeenCalledWith({ + oldPassword: 'old-password', + newPassword: 'new-password', + }), + ) + expect(await screen.findByText('密码已修改,请重新登录')).toBeTruthy() + expect(screen.getByRole('heading', { name: '登录 Windup' })).toBeTruthy() + }) + + it('退出登录,并将请求错误通过 alert 呈现', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + session.logout = vi.fn(async () => { + throw new Error('退出服务暂不可用') + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + expect((await screen.findByRole('alert')).textContent).toContain('退出服务暂不可用') + }) + + it('退出登录成功后关闭面板', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + const onClose = vi.fn() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings', onClose) + + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('验证码倒计时逐秒推进,并在 60 秒后恢复发码', async () => { + vi.useFakeTimers() + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '60 秒后可重发' })).toBeTruthy() + + act(() => vi.advanceTimersByTime(1_000)) + expect(screen.getByRole('button', { name: '59 秒后可重发' })).toBeTruthy() + for (let second = 0; second < 59; second += 1) { + act(() => vi.advanceTimersByTime(1_000)) + } + + expect((screen.getByRole('button', { name: '发送验证码' }) as HTMLButtonElement).disabled).toBe( + false, + ) + }) + + it('面板卸载时清理尚未结束的倒计时', async () => { + vi.useFakeTimers() + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + const view = renderPanel() + const timersBeforeCountdown = vi.getTimerCount() + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await Promise.resolve() + }) + expect(vi.getTimerCount()).toBeGreaterThan(timersBeforeCountdown) + view.unmount() + + expect(vi.getTimerCount()).toBe(timersBeforeCountdown) + }) + + it('请求处理中禁用提交,避免重复提交', async () => { + let resolveLogin: (() => void) | undefined + const session = createSession() + session.loginByCode = vi.fn( + () => + new Promise>>((resolve) => { + resolveLogin = () => + resolve({ + accessToken: 'access', + refreshToken: 'refresh', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal', + }, + }) + }), + ) + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'ada@example.test' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => + expect( + (screen.getByRole('button', { name: '正在登录…' }) as HTMLButtonElement).disabled, + ).toBe(true), + ) + resolveLogin?.() + }) +}) diff --git a/frontend/src/pages/home/account-panel.tsx b/frontend/src/pages/home/account-panel.tsx new file mode 100644 index 00000000..d0e932d5 --- /dev/null +++ b/frontend/src/pages/home/account-panel.tsx @@ -0,0 +1,570 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate, useSearchParams } from 'react-router' + +import { resolveAuthMode, useAuthSession, type AuthSessionValue } from '@/features/auth-session' + +type PanelMode = 'code-login' | 'password-login' | 'register' | 'settings' +type PendingAction = 'send-code' | 'authenticate' | 'change-password' | 'logout' | null + +interface AccountPanelProps { + onClose(): void +} + +const fieldClassName = + 'mt-2 w-full rounded-xl border border-[#c7cbc3] bg-[#f7f6f0] px-3 py-2.5 text-sm text-[#191b18] outline-none transition focus:border-[#263f2d] focus:ring-2 focus:ring-[#263f2d]/15' +const primaryButtonClassName = + 'inline-flex min-h-11 items-center justify-center rounded-xl bg-[#1d211d] px-4 text-sm font-semibold text-white transition hover:bg-[#2b322b] disabled:cursor-not-allowed disabled:opacity-50' +const quietButtonClassName = + 'rounded-lg px-3 py-2 text-sm font-semibold text-[#536052] transition hover:bg-[#e4e7e0] disabled:cursor-not-allowed disabled:opacity-50' + +export function AccountPanel({ onClose }: AccountPanelProps) { + const session = useAuthSession() + const localAuth = resolveAuthMode() === 'local' + const navigate = useNavigate() + const dialogRef = useRef(null) + const onCloseRef = useRef(onClose) + const [searchParams, setSearchParams] = useSearchParams() + const [mode, setMode] = useState(() => modeFromQuery(searchParams.get('account'))) + const [email, setEmail] = useState(localAuth ? 'local@windup.dev' : '') + const [code, setCode] = useState(localAuth ? 'local' : '') + const [password, setPassword] = useState('') + const [nickname, setNickname] = useState('') + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [countdown, setCountdown] = useState(0) + const [pending, setPending] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useEffect(() => { + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null + const dialog = dialogRef.current + getFocusableElements(dialog)[0]?.focus() + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onCloseRef.current() + return + } + if (event.key !== 'Tab') return + + const focusableElements = getFocusableElements(dialogRef.current) + const first = focusableElements[0] + const last = focusableElements.at(-1) + if (!first || !last) return + + const activeElement = document.activeElement + if ( + event.shiftKey && + (activeElement === first || !dialogRef.current?.contains(activeElement)) + ) { + event.preventDefault() + last.focus() + } else if ( + !event.shiftKey && + (activeElement === last || !dialogRef.current?.contains(activeElement)) + ) { + event.preventDefault() + first.focus() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + previouslyFocused?.focus() + } + }, []) + + useEffect(() => { + if (countdown <= 0) return + const timer = window.setTimeout( + () => setCountdown((seconds) => Math.max(0, seconds - 1)), + 1_000, + ) + return () => window.clearTimeout(timer) + }, [countdown]) + + const updateMode = (nextMode: PanelMode) => { + setMode(nextMode) + setError(null) + setNotice(null) + const nextParams = new URLSearchParams(searchParams) + nextParams.set( + 'account', + nextMode === 'register' ? 'register' : nextMode === 'settings' ? 'settings' : 'login', + ) + setSearchParams(nextParams, { replace: true }) + } + + const sendCode = async () => { + setPending('send-code') + setError(null) + try { + await session.sendCode({ + email: email.trim(), + purpose: mode === 'register' ? 'register' : 'login', + }) + setCountdown(60) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const authenticate = async (event: FormEvent) => { + event.preventDefault() + setPending('authenticate') + setError(null) + try { + if (mode === 'register') { + const trimmedNickname = nickname.trim() + await session.register({ + email: email.trim(), + password, + code: code.trim(), + ...(trimmedNickname ? { nickname: trimmedNickname } : {}), + }) + } else if (mode === 'password-login') { + await session.login({ email: email.trim(), password, code: code.trim() }) + } else { + await session.loginByCode({ email: email.trim(), code: code.trim() }) + } + + const returnTo = resolveSafeReturnTo(searchParams.get('returnTo')) + navigate(returnTo ?? '/', { replace: true }) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const changePassword = async (event: FormEvent) => { + event.preventDefault() + setPending('change-password') + setError(null) + try { + await session.changePassword({ oldPassword, newPassword }) + setOldPassword('') + setNewPassword('') + updateMode('code-login') + setNotice('密码已修改,请重新登录') + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const logout = async () => { + setPending('logout') + setError(null) + try { + await session.logout() + onClose() + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const authenticatedUser = session.state.status === 'authenticated' ? session.state.user : null + const showSettings = mode === 'settings' && authenticatedUser !== null + const isBusy = pending !== null + + return createPortal( +
+ + + +
+ {error ? ( +

+ {error} +

+ ) : null} + {notice ? ( +

+ {notice} +

+ ) : null} + + {showSettings && authenticatedUser ? ( + + ) : ( + + )} +
+ +
, + document.body, + ) +} + +interface AuthPanelProps { + localAuth: boolean + mode: Exclude + email: string + code: string + password: string + nickname: string + countdown: number + pending: PendingAction + isBusy: boolean + onModeChange(mode: PanelMode): void + onEmailChange(value: string): void + onCodeChange(value: string): void + onPasswordChange(value: string): void + onNicknameChange(value: string): void + onSendCode(): void + onSubmit(event: FormEvent): void +} + +function AuthPanel(props: AuthPanelProps) { + const emailInputRef = useRef(null) + const isRegister = props.mode === 'register' + const isPasswordLogin = props.mode === 'password-login' + + const sendCode = () => { + if (!emailInputRef.current?.reportValidity()) return + props.onSendCode() + } + + return ( + <> +
+
+

+ {isRegister ? '注册 Windup' : '登录 Windup'} +

+

+ {props.localAuth && !isRegister + ? '当前使用本地开发账户,不会连接真实认证服务。' + : isRegister + ? '创建账户,保存并继续你的制作。' + : '登录后返回刚才的工作位置。'} +

+
+
+ + {isRegister ? ( + + ) : props.localAuth ? ( +

+ 开发账号已经准备好,直接进入工作台即可。 +

+ ) : ( +
+ + +
+ )} + +
+ + + {!props.localAuth ? ( + + ) : null} + + {isPasswordLogin || isRegister ? ( + + ) : null} + + {isRegister ? ( + + ) : null} + + +
+ + {!isRegister && !props.localAuth ? ( +

+ 还没有账户?{' '} + +

+ ) : null} + + ) +} + +interface SettingsPanelProps { + user: Extract['user'] + oldPassword: string + newPassword: string + pending: PendingAction + onOldPasswordChange(value: string): void + onNewPasswordChange(value: string): void + onChangePassword(event: FormEvent): void + onLogout(): void +} + +function SettingsPanel(props: SettingsPanelProps) { + const disabled = props.pending !== null + return ( + <> +

个人设置

+

查看账户资料并管理登录安全。

+ +
+ + + + +
+ +
+
+

修改密码

+

修改成功后需要重新登录。

+
+ + + +
+ + + + ) +} + +function ProfileRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function modeFromQuery(value: string | null): PanelMode { + if (value === 'register') return 'register' + if (value === 'settings') return 'settings' + return 'code-login' +} + +function resolveSafeReturnTo(value: string | null): string | null { + if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) { + return null + } + + try { + const currentOrigin = globalThis.location.origin + const resolved = new URL(value, currentOrigin) + if (resolved.origin !== currentOrigin) return null + return `${resolved.pathname}${resolved.search}${resolved.hash}` + } catch { + return null + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : '请求失败,请稍后重试' +} + +function getFocusableElements(container: HTMLElement | null): HTMLElement[] { + if (!container) return [] + return Array.from( + container.querySelectorAll( + 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0) +} diff --git a/frontend/src/pages/home/choice-card.test.tsx b/frontend/src/pages/home/choice-card.test.tsx index 011d8b2b..6ea4e8e2 100644 --- a/frontend/src/pages/home/choice-card.test.tsx +++ b/frontend/src/pages/home/choice-card.test.tsx @@ -25,4 +25,27 @@ describe('HomeChoiceCard', () => { 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 index d110be4e..86d7b85a 100644 --- a/frontend/src/pages/home/choice-card.tsx +++ b/frontend/src/pages/home/choice-card.tsx @@ -9,6 +9,10 @@ export interface HomeChoiceCardProps { title: string description: string actionLabel: string + secondaryAction?: { + to: string + label: string + } tone?: HomeChoiceCardTone } @@ -20,71 +24,87 @@ export function HomeChoiceCard({ title, description, actionLabel, + secondaryAction, tone = 'light', }: HomeChoiceCardProps) { const dark = tone === 'dark' return ( - -