From 00c286f7f5d25803c6367e98c0bf843aba979dac Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:49:27 +0800 Subject: [PATCH] feat(playtest): add standalone multi-action preview --- frontend/src/app/app.test.tsx | 20 + frontend/src/app/app.tsx | 24 +- frontend/src/app/layout/index.tsx | 10 +- frontend/src/entities/character/api.test.ts | 66 ++ frontend/src/entities/character/api.ts | 158 +++++ frontend/src/entities/character/index.ts | 3 + frontend/src/entities/index.ts | 12 + .../src/entities/playtest-inspection/api.ts | 65 ++ .../src/entities/playtest-inspection/index.ts | 24 + frontend/src/entities/project/api.ts | 80 +++ frontend/src/pages/playtest/catalog.test.tsx | 279 ++++++++ frontend/src/pages/playtest/catalog.tsx | 659 ++++++++++++++++++ frontend/src/pages/playtest/index.test.tsx | 180 +++++ frontend/src/pages/playtest/index.tsx | 171 ++++- frontend/src/pages/playtest/path.test.ts | 15 + frontend/src/pages/playtest/path.ts | 16 + .../playtest/playtest-boundaries.test.ts | 116 +++ .../pages/playtest/workbench/acceptance.tsx | 81 +++ .../playtest/workbench/action-selector.tsx | 208 ++++++ .../workbench/analysis/frame-geometry.test.ts | 129 ++++ .../workbench/analysis/frame-geometry.ts | 197 ++++++ .../workbench/analysis/image-geometry.test.ts | 146 ++++ .../workbench/analysis/image-geometry.ts | 76 ++ .../workbench/analysis/quality-policy.ts | 123 ++++ .../analysis/sequence-evidence.test.ts | 407 +++++++++++ .../workbench/analysis/sequence-evidence.ts | 498 +++++++++++++ .../use-frame-review-evidence.test.tsx | 203 ++++++ .../analysis/use-frame-review-evidence.ts | 106 +++ .../workbench/animation-stage.test.tsx | 184 +++++ .../playtest/workbench/animation-stage.tsx | 189 +++++ .../workbench/audit/audit-panel.test.tsx | 106 +++ .../playtest/workbench/audit/audit-panel.tsx | 189 +++++ .../workbench/audit/audit-session.test.ts | 53 ++ .../playtest/workbench/audit/audit-session.ts | 41 ++ .../workbench/frame-review-evidence.test.tsx | 244 +++++++ .../workbench/frame-review-evidence.tsx | 263 +++++++ .../playtest/workbench/frame-timeline.tsx | 58 ++ .../pages/playtest/workbench/index.test.tsx | 227 ++++++ .../src/pages/playtest/workbench/index.tsx | 426 +++++++++++ .../pages/playtest/workbench/inspector.tsx | 64 ++ .../model/create-preview-model.test.ts | 124 ++++ .../workbench/model/create-preview-model.ts | 94 +++ .../pages/playtest/workbench/model/types.ts | 45 ++ .../playtest/workbench/playback-controls.tsx | 94 +++ .../workbench/playback/playback-state.test.ts | 342 +++++++++ .../workbench/playback/playback-state.ts | 155 ++++ .../playback/use-playback-controller.test.tsx | 300 ++++++++ .../playback/use-playback-controller.ts | 155 ++++ .../playtest/workbench/stage-motion.test.tsx | 250 +++++++ .../pages/playtest/workbench/stage-motion.ts | 136 ++++ .../pages/playtest/workbench/status-panel.tsx | 26 + .../workbench/use-playtest-inspection.ts | 89 +++ .../workbench/use-playtest-keyboard.test.tsx | 265 +++++++ .../workbench/use-playtest-keyboard.ts | 91 +++ frontend/src/shared/api/http-client.test.ts | 48 ++ frontend/src/shared/api/http-client.ts | 101 +++ frontend/src/shared/api/index.ts | 2 + 57 files changed, 8424 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/app.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/playtest-inspection/api.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/api.ts create mode 100644 frontend/src/pages/playtest/catalog.test.tsx create mode 100644 frontend/src/pages/playtest/catalog.tsx create mode 100644 frontend/src/pages/playtest/index.test.tsx create mode 100644 frontend/src/pages/playtest/path.test.ts create mode 100644 frontend/src/pages/playtest/path.ts create mode 100644 frontend/src/pages/playtest/playtest-boundaries.test.ts create mode 100644 frontend/src/pages/playtest/workbench/acceptance.tsx create mode 100644 frontend/src/pages/playtest/workbench/action-selector.tsx create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/quality-policy.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.test.ts create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.ts create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-timeline.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.tsx create mode 100644 frontend/src/pages/playtest/workbench/inspector.tsx create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.ts create mode 100644 frontend/src/pages/playtest/workbench/model/types.ts create mode 100644 frontend/src/pages/playtest/workbench/playback-controls.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.test.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.ts create mode 100644 frontend/src/pages/playtest/workbench/status-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-inspection.ts create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts 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/index.ts diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx new file mode 100644 index 0000000..a1301f0 --- /dev/null +++ b/frontend/src/app/app.test.tsx @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { App } from './app' + +afterEach(() => { + cleanup() + window.history.replaceState({}, '', '/') +}) + +describe('App Playtest route', () => { + it('routes /playtest to the standalone Playtest catalog', () => { + window.history.replaceState({}, '', '/playtest') + + render() + + expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index b46ae87..3e98e81 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,20 +1,41 @@ +import { useMemo } from 'react' import { BrowserRouter, Route, Routes } from 'react-router' +import { createCharacterApis, createPlaytestInspectionApis, createProjectApis } from '@/entities' import { AssetLibraryPage } from '@/pages/asset-library' import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' import { PlaytestPage } from '@/pages/playtest' +import { PlaytestCatalogPage } from '@/pages/playtest/catalog' import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' import { AppShell } from './layout' +function PlaytestFromBackend() { + const apis = useMemo( + () => ({ + characters: createCharacterApis(), + projects: createProjectApis(), + inspections: createPlaytestInspectionApis(), + }), + [], + ) + + return +} + /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 */ export function App() { + const catalogApis = useMemo( + () => ({ projects: createProjectApis(), characters: createCharacterApis() }), + [], + ) + return ( @@ -27,7 +48,8 @@ export function App() { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index aa21b82..0c8ecb7 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { Link } from 'react-router' +import { Link, useLocation } from 'react-router' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -10,6 +10,9 @@ export interface AppShellProps { /** 全站外壳,全局导航常驻。 */ export function AppShell({ children }: AppShellProps) { + const { pathname } = useLocation() + const isPlaytest = pathname.startsWith('/playtest') + return (
-
{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 0000000..1ca38de --- /dev/null +++ b/frontend/src/entities/character/api.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('character API adapter', () => { + it('preserves an action loop flag 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) + 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 }> }> } + } + expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true) + }) +}) + +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 0000000..4359abe --- /dev/null +++ b/frontend/src/entities/character/api.ts @@ -0,0 +1,158 @@ +import type { Action, ActionType, Character, CharacterApis, Frame, Outfit } from '.' + +import { del, get, patch } from '@/shared/api' + +/* ─── 后端 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 + description: string | null + reference_image_url: string | null + character_data: BackendCharacterData + status: number +} + +/* ─── 映射 ─── */ + +const ACTION_TYPE_SET = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + +function toActionType(raw: string): ActionType { + return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom' +} + +function toFrame(raw: BackendFrame): Frame { + return { + imageUrl: raw.image_url, + durationMs: raw.duration_ms, + rootMotion: raw.root_motion ?? null, + } +} + +function toAction(raw: BackendAction, outfitId: string): Action { + return { + id: raw.id, + outfitId, + name: raw.name, + 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, + candidateCharacterTemplates: [], // 后端 character_data 不含候选 + characterTemplateUrl: raw.preview_url, + baseFrames: [], + actions: raw.actions.map((a) => toAction(a, raw.id)), + } +} + +function toCharacter(raw: BackendCharacter): Character { + const id = String(raw.id) + return { + id, + projectId: String(raw.project_id), + createdAt: '', // 后端列表不返回时间戳 + updatedAt: '', + outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)), + } +} + +/* ─── 适配器 ─── */ + +export function createCharacterApis(): Pick< + CharacterApis, + 'get' | 'listByProject' | 'update' | 'remove' +> { + return { + async get(id: string): Promise { + const raw = await get(`/characters/${id}`) + return toCharacter(raw) + }, + + async listByProject(projectId: string): Promise { + // http-client 已解包 ApiEnvelope,data 字段就是角色数组本身 + const raw = await get( + `/characters?project_id=${encodeURIComponent(projectId)}&page_size=100`, + ) + return raw.map(toCharacter) + }, + + async update(character: Character): Promise { + const payload = { + project_id: Number(character.projectId), + character_data: { + version: 1, + outfits: character.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: null, + preview_url: outfit.characterTemplateUrl, + actions: outfit.actions.map((action) => ({ + id: action.id, + type: action.type, + name: action.name, + loop: action.loop ?? false, + fps: action.fps, + frame_count: action.frames.length, + frames: action.frames.map((frame, index) => ({ + index, + image_url: frame.imageUrl, + duration_ms: frame.durationMs, + root_motion: frame.rootMotion, + })), + })), + })), + }, + } + 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 616b5db..c06751a 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -61,6 +61,8 @@ export interface Action { id: string outfitId: Outfit['id'] name: string + /** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */ + loop?: boolean /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ kind: ActionKind /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ @@ -134,4 +136,5 @@ export interface CharacterApis { listByProject(projectId: string): Promise create(input: CreateCharacterInput): Promise update(character: Character): Promise + remove(id: Character['id']): Promise } diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..9587693 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -5,6 +5,7 @@ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, @@ -29,6 +30,7 @@ export type { FrameRootMotion, Outfit, } from './character' +export { createCharacterApis } from './character/api' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' @@ -55,6 +57,16 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { 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 type { diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts new file mode 100644 index 0000000..6c62412 --- /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 0000000..2a16016 --- /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.ts b/frontend/src/entities/project/api.ts new file mode 100644 index 0000000..38fad5e --- /dev/null +++ b/frontend/src/entities/project/api.ts @@ -0,0 +1,80 @@ +import type { Project, ProjectApis } from '.' +import type { Paged, PageQuery } from '@/shared/pagination' + +import { del, get } 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), + 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, + } +} + +/* ─── 适配器 ─── */ + +export function createProjectApis(): Pick { + return { + async list(query?: PageQuery): Promise> { + const params = new URLSearchParams() + if (query?.page) params.set('page', String(query.page)) + if (query?.pageSize) params.set('page_size', String(query.pageSize)) + const qs = params.toString() + // http-client 已解包 ApiEnvelope,data 字段就是项目数组本身 + const raw = await get(`/projects${qs ? `?${qs}` : ''}`) + return { + items: raw.map(toProject), + total: raw.length, + page: query?.page ?? 1, + pageSize: query?.pageSize ?? raw.length, + } + }, + + async get(id: string): Promise { + const raw = await get(`/projects/${id}`) + return toProject(raw) + }, + + async remove(id: string): Promise { + await del(`/projects/${id}`) + }, + } +} diff --git a/frontend/src/pages/playtest/catalog.test.tsx b/frontend/src/pages/playtest/catalog.test.tsx new file mode 100644 index 0000000..f415508 --- /dev/null +++ b/frontend/src/pages/playtest/catalog.test.tsx @@ -0,0 +1,279 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' +import type { Project } from '@/entities/project' + +import { PlaytestCatalogPage, type PlaytestCatalogApis } from './catalog' + +const project: Project = { + id: '37', + ownerId: '1', + name: '灯笼守夜人', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 256, height: 256 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', +} + +const targetProject: Project = { + ...project, + id: '38', + name: '第二个项目', +} + +const character: Character = { + id: '25', + projectId: project.id, + createdAt: '', + updatedAt: '', + outfits: [ + { + id: 'outfit-25-default', + characterId: '25', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/character.png', + baseFrames: [], + actions: [ + { + id: '25-custom', + outfitId: 'outfit-25-default', + name: '挥舞灯笼', + kind: 'custom', + type: 'custom', + fps: 8, + keyFrameIndex: 0, + frames: [ + { imageUrl: 'https://cdn.example.test/frame.png', durationMs: 125, rootMotion: null }, + ], + }, + ], + }, + ], +} + +function renderCatalog(apis: PlaytestCatalogApis) { + render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PlaytestCatalogPage', () => { + it('loads published assets across projects and links directly to each action', async () => { + const apis: PlaytestCatalogApis = { + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: vi.fn(), + }, + } + + renderCatalog(apis) + + expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy() + expect(screen.getByRole('link', { name: '返回项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('heading', { name: '灯笼守夜人' })).toBeTruthy() + expect(screen.getByText('挥舞灯笼')).toBeTruthy() + expect(screen.getByRole('link', { name: '载入挥舞灯笼' }).getAttribute('href')).toBe( + '/playtest/25/outfit-25-default?actionId=25-custom', + ) + }) + + it('does not treat an outfit without actions as an importable asset', async () => { + const emptyCharacter: Character = { + ...character, + outfits: [{ ...character.outfits[0]!, actions: [] }], + } + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([emptyCharacter]), + update: vi.fn(), + remove: vi.fn(), + }, + }) + + expect(await screen.findByText('空文件夹')).toBeTruthy() + }) + + it('opens projects as folders and persists a dragged character in the target project', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update, + remove: vi.fn(), + }, + }) + + const cardTitle = await screen.findByText('默认造型') + const card = cardTitle.closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.dragEnter(targetFolder, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(update.mock.calls[0]?.[0].projectId).toBe(targetProject.id) + expect(await screen.findByText('已将角色资产移动到“第二个项目”')).toBeTruthy() + }) + + it('does not report a move as successful when the backend keeps the old project', async () => { + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update: vi.fn().mockResolvedValue(character), + remove: vi.fn(), + }, + }) + + const card = (await screen.findByText('默认造型')).closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + expect(await screen.findByText('后端未保存新的项目归属')).toBeTruthy() + expect(screen.queryByText('已将角色资产移动到“第二个项目”')).toBeNull() + expect(screen.getByRole('heading', { name: project.name })).toBeTruthy() + }) + + it('renames an asset and an action through the character update API', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update, + remove: vi.fn(), + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '重命名资产名称 默认造型' })) + fireEvent.change(screen.getByRole('textbox', { name: '资产名称' }), { + target: { value: '夜巡造型' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存资产名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update.mock.calls[0]?.[0].outfits[0].name).toBe('夜巡造型') + + fireEvent.click(screen.getByRole('button', { name: '重命名动作名称 挥舞灯笼' })) + fireEvent.change(screen.getByRole('textbox', { name: '动作名称' }), { + target: { value: '举起灯笼' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存动作名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)) + expect(update.mock.calls[1]?.[0].outfits[0].actions[0].name).toBe('举起灯笼') + }) + + it('deletes a single asset after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const remove = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '删除资产 默认造型' })) + + await waitFor(() => expect(remove).toHaveBeenCalledWith(character.id)) + expect(await screen.findByText('已删除资产“默认造型”')).toBeTruthy() + expect(screen.queryByText('默认造型')).toBeNull() + }) + + it('delegates atomic project cleanup to one backend request', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const removeCharacter = vi.fn().mockResolvedValue(undefined) + const removeProject = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: removeProject, + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: removeCharacter, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: `删除项目文件夹 ${project.name}` })) + + await waitFor(() => expect(removeProject).toHaveBeenCalledWith(project.id)) + expect(removeCharacter).not.toHaveBeenCalled() + expect(await screen.findByText(`已删除项目“${project.name}”`)).toBeTruthy() + expect(screen.queryByRole('region', { name: `${project.name}项目文件夹` })).toBeNull() + }) +}) diff --git a/frontend/src/pages/playtest/catalog.tsx b/frontend/src/pages/playtest/catalog.tsx new file mode 100644 index 0000000..d027d96 --- /dev/null +++ b/frontend/src/pages/playtest/catalog.tsx @@ -0,0 +1,659 @@ +import { type DragEvent, type FormEvent, type ReactNode, useEffect, useState } from 'react' +import { Link } from 'react-router' + +import type { Action, Character, CharacterApis, Outfit } from '@/entities/character' +import type { Project, ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from './path' + +export interface PlaytestCatalogApis { + projects: Pick + characters: Pick +} + +interface CatalogAsset { + project: Project + character: Character + outfit: Outfit +} + +interface CatalogState { + projects: Project[] + assets: CatalogAsset[] + error: string | null + loading: boolean +} + +interface CatalogData { + projects: Project[] + assets: CatalogAsset[] +} + +const INITIAL_STATE: CatalogState = { projects: [], assets: [], error: null, loading: true } + +/** Playtest 入口按项目组织可核验角色,移动和改名通过 Character 整树接口持久化。 */ +export function PlaytestCatalogPage({ apis }: { apis: PlaytestCatalogApis }) { + const [state, setState] = useState(INITIAL_STATE) + const [reloadKey, setReloadKey] = useState(0) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [busyCharacterIds, setBusyCharacterIds] = useState>(new Set()) + const [busyProjectIds, setBusyProjectIds] = useState>(new Set()) + const [draggedCharacterId, setDraggedCharacterId] = useState(null) + const [dropProjectId, setDropProjectId] = useState(null) + const [operationMessage, setOperationMessage] = useState(null) + + useEffect(() => { + let active = true + setState(INITIAL_STATE) + + void loadCatalog(apis).then( + ({ projects, assets }) => { + if (!active) return + setState({ projects, assets, error: null, loading: false }) + setSelectedProjectId((current) => + current && projects.some((project) => project.id === current) + ? current + : (projects[0]?.id ?? null), + ) + }, + (cause: unknown) => + active && + setState({ + projects: [], + assets: [], + error: errorMessage(cause, '资产加载失败'), + loading: false, + }), + ) + + return () => { + active = false + } + }, [apis, reloadKey]) + + const setCharacterBusy = (characterId: string, busy: boolean) => { + setBusyCharacterIds((current) => { + const next = new Set(current) + if (busy) next.add(characterId) + else next.delete(characterId) + return next + }) + } + + const persistCharacter = async (character: Character, successMessage: string) => { + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + const saved = await apis.characters.update(character) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + setState((current) => replaceCharacter(current, saved)) + setOperationMessage(successMessage) + } catch (cause) { + setOperationMessage(errorMessage(cause, '保存失败,请重试')) + throw cause + } finally { + setCharacterBusy(character.id, false) + } + } + + const moveCharacter = async (characterId: string, projectId: string) => { + const source = state.assets.find((asset) => asset.character.id === characterId) + const target = state.projects.find((project) => project.id === projectId) + if (!source || !target || source.character.projectId === projectId) return + + try { + await persistCharacter( + { ...source.character, projectId }, + `已将角色资产移动到“${target.name}”`, + ) + setSelectedProjectId(projectId) + } catch { + // persistCharacter 已在页面上保留具体错误。 + } + } + + const renameOutfit = async (character: Character, outfitId: string, name: string) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId ? { ...outfit, name } : outfit, + ), + }, + `资产已改名为“${name}”`, + ) + } + + const renameAction = async ( + character: Character, + outfitId: string, + actionId: string, + name: string, + ) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId + ? { + ...outfit, + actions: outfit.actions.map((action) => + action.id === actionId ? { ...action, name } : action, + ), + } + : outfit, + ), + }, + `动作已改名为“${name}”`, + ) + } + + const deleteAsset = async (character: Character, outfit: Outfit) => { + if (!window.confirm(`确定删除资产“${outfit.name}”吗?此操作无法撤销。`)) return + + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + if (character.outfits.length === 1) { + await apis.characters.remove(character.id) + setState((current) => ({ + ...current, + assets: current.assets.filter((asset) => asset.character.id !== character.id), + })) + } else { + const saved = await apis.characters.update({ + ...character, + outfits: character.outfits.filter((candidate) => candidate.id !== outfit.id), + }) + setState((current) => replaceCharacter(current, saved)) + } + setOperationMessage(`已删除资产“${outfit.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '资产删除失败,请重试')) + } finally { + setCharacterBusy(character.id, false) + } + } + + const deleteProject = async (project: Project) => { + if (!window.confirm(`确定删除项目“${project.name}”及其中全部角色资产吗?此操作无法撤销。`)) { + return + } + + setBusyProjectIds((current) => new Set(current).add(project.id)) + setOperationMessage(null) + try { + await apis.projects.remove(project.id) + setState((current) => ({ + ...current, + projects: current.projects.filter((candidate) => candidate.id !== project.id), + assets: current.assets.filter((asset) => asset.project.id !== project.id), + })) + setSelectedProjectId((current) => + current === project.id + ? (state.projects.find((candidate) => candidate.id !== project.id)?.id ?? null) + : current, + ) + setOperationMessage(`已删除项目“${project.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '项目删除失败,请重试')) + } finally { + setBusyProjectIds((current) => { + const next = new Set(current) + next.delete(project.id) + return next + }) + } + } + + const selectedProject = state.projects.find((project) => project.id === selectedProjectId) ?? null + const selectedAssets = selectedProject + ? state.assets.filter((asset) => asset.project.id === selectedProject.id) + : [] + + return ( +
+ + + 返回项目 + + +
+
+

+ PLAYTEST +

+

Playtest

+

选择项目和角色动作进行核验。

+
+ +
+ + {state.error ? {state.error} : null} + {operationMessage ? {operationMessage} : null} + + {!state.error && !state.loading && state.projects.length === 0 ? ( +
+

还没有项目。

+
+ ) : null} + +
+ + +
+ {selectedProject ? ( + <> +
+

当前文件夹

+
+

{selectedProject.name}

+ + {selectedAssets.length} 项资产 + +
+
+
+ {selectedAssets.length === 0 ? ( +

空文件夹

+ ) : ( + selectedAssets.map((asset) => ( + { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData( + 'application/x-windup-character', + asset.character.id, + ) + setDraggedCharacterId(asset.character.id) + }} + onDragEnd={() => { + setDraggedCharacterId(null) + setDropProjectId(null) + }} + onRenameOutfit={(name) => + renameOutfit(asset.character, asset.outfit.id, name) + } + onRenameAction={(actionId, name) => + renameAction(asset.character, asset.outfit.id, actionId, name) + } + onDelete={() => deleteAsset(asset.character, asset.outfit)} + /> + )) + )} +
+ + ) : ( +

请选择项目文件夹

+ )} +
+
+
+ ) +} + +function AssetCard({ + asset, + busy, + onDragStart, + onDragEnd, + onRenameOutfit, + onRenameAction, + onDelete, +}: { + asset: CatalogAsset + busy: boolean + onDragStart(event: DragEvent): void + onDragEnd(): void + onRenameOutfit(name: string): Promise + onRenameAction(actionId: string, name: string): Promise + onDelete(): Promise +}) { + const { character, outfit } = asset + + return ( +
+
+ {outfit.characterTemplateUrl ? ( + {`${outfit.name} + ) : ( + 暂无角色图 + )} +
+
+
+

角色 {character.id}

+ +
+ +

{outfit.name}

+
+

{outfit.actions.length} 个动作

+
+ {outfit.actions.map((action) => ( + onRenameAction(action.id, name)} + /> + ))} +
+
+
+ ) +} + +function ActionRow({ + character, + outfit, + action, + disabled, + onRename, +}: { + character: Character + outfit: Outfit + action: Action + disabled: boolean + onRename(name: string): Promise +}) { + return ( + + + {action.name} + + {action.frames.length} 帧 + + + + ) +} + +function EditableName({ + value, + label, + disabled, + onSave, + className = '', + children, +}: { + value: string + label: string + disabled: boolean + onSave(value: string): Promise + className?: string + children: ReactNode +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => setDraft(value), [value]) + + const submit = async (event: FormEvent) => { + event.preventDefault() + const name = draft.trim() + if (!name) { + setError('名称不能为空') + return + } + if (name === value) { + setEditing(false) + return + } + + setSaving(true) + setError(null) + try { + await onSave(name) + setEditing(false) + } catch (cause) { + setError(errorMessage(cause, '保存失败')) + } finally { + setSaving(false) + } + } + + if (editing) { + return ( +
void submit(event)} + onDragStart={(event) => event.stopPropagation()} + className={`grid min-w-0 grid-cols-[minmax(0,1fr)_32px_32px] gap-1 ${className}`} + > + setDraft(event.target.value)} + disabled={saving} + className="h-8 min-w-0 rounded-md border border-[#829087] px-2 text-xs outline-none focus:border-[#35583f]" + /> + + + {error ? {error} : null} +
+ ) + } + + return ( +
+ {children} + +
+ ) +} + +function CatalogAlert({ children, tone }: { children: string; tone: 'error' | 'status' }) { + return ( +
+

{children}

+
+ ) +} + +function replaceCharacter(state: CatalogState, character: Character): CatalogState { + const project = state.projects.find((candidate) => candidate.id === character.projectId) + if (!project) return state + + const otherAssets = state.assets.filter((asset) => asset.character.id !== character.id) + const updatedAssets = character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })) + + return { ...state, assets: [...otherAssets, ...updatedAssets] } +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message : fallback +} + +async function loadCatalog(apis: PlaytestCatalogApis): Promise { + const page = await apis.projects.list({ page: 1, pageSize: 100 }) + const charactersByProject = await Promise.all( + page.items.map(async (project) => ({ + project, + characters: await apis.characters.listByProject(project.id), + })), + ) + + return { + projects: page.items, + assets: charactersByProject.flatMap(({ project, characters }) => + characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })), + ), + ), + } +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 0000000..c661dba --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,180 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' + +import { PlaytestPage, type PlaytestPageApis } from './index' + +const character: Character = { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: 'Explorer', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrames: [{ imageUrl: 'https://cdn.example.test/base.png' }], + actions: [ + { + id: 'idle', + outfitId: 'outfit-1', + name: 'Idle', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: [ + { + imageUrl: 'https://cdn.example.test/idle.png', + durationMs: 125, + rootMotion: null, + }, + ], + }, + ], + }, + ], +} + +function renderPage(apis?: PlaytestPageApis, initialEntry = '/playtest/character-1/outfit-1') { + render( + + + } /> + + , + ) +} + +afterEach(() => cleanup()) + +describe('PlaytestPage', () => { + it('shows an explicit unconfigured boundary instead of inventing character data', () => { + renderPage() + + expect(screen.getByText('Playtest 角色接口尚未配置')).toBeTruthy() + }) + + it('loads the requested character through the standard skeleton API only', async () => { + const apis: PlaytestPageApis = { + characters: { + get: vi.fn().mockResolvedValue(character), + listByProject: vi.fn().mockResolvedValue([character]), + }, + } + + renderPage(apis, '/playtest/character-1/outfit-1?actionId=idle') + + expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'character-1 · Explorer' })).toBeTruthy() + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith('character-1') + }) + + it.each([{ code: 404 }, { status: 404 }])( + 'maps a missing character response to a stable message', + async (error) => { + renderPage({ + characters: { get: vi.fn().mockRejectedValue(error), listByProject: vi.fn() }, + }) + + expect(await screen.findByText('角色不存在')).toBeTruthy() + }, + ) + + it('does not mislabel a transport failure as not found', async () => { + renderPage({ + characters: { + get: vi.fn().mockRejectedValue(new Error('network unavailable')), + listByProject: vi.fn(), + }, + }) + + expect(await screen.findByText('角色读取失败')).toBeTruthy() + }) + + it('ignores a stale character response after the route identity changes', async () => { + let resolveFirst: ((value: Character) => void) | undefined + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [{ ...character.outfits[0], characterId: 'character-2' }], + } + const get = vi.fn().mockReturnValueOnce(firstRequest).mockResolvedValueOnce(secondCharacter) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) + let navigate: NavigateFunction | undefined + + function NavigationProbe() { + navigate = useNavigate() + return null + } + + render( + + + + } + /> + + , + ) + + await act(async () => navigate?.('/playtest/character-2/outfit-1')) + expect(await screen.findByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy() + + await act(async () => resolveFirst?.(character)) + await waitFor(() => + expect(screen.getByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy(), + ) + expect(get).toHaveBeenNthCalledWith(1, 'character-1') + expect(get).toHaveBeenNthCalledWith(2, 'character-2') + }) + + it('switches assets from the action sidebar without returning to the catalog', async () => { + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [ + { + ...character.outfits[0]!, + id: 'outfit-2', + characterId: 'character-2', + name: 'Knight', + actions: character.outfits[0]!.actions.map((action) => ({ + ...action, + outfitId: 'outfit-2', + name: 'Guard', + })), + }, + ], + } + const get = vi + .fn() + .mockImplementation(async (id: string) => + id === secondCharacter.id ? secondCharacter : character, + ) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) + + renderPage({ characters: { get, listByProject } }) + + const selector = await screen.findByRole('combobox', { name: '同项目资产' }) + fireEvent.click(selector) + const options = screen.getAllByRole('option') + expect(options).toHaveLength(2) + fireEvent.click(screen.getByRole('option', { name: /Knight/ })) + + expect(await screen.findByRole('heading', { name: 'character-2 · Knight' })).toBeTruthy() + expect(screen.getByRole('button', { name: /Guard/ })).toBeTruthy() + expect(get).toHaveBeenLastCalledWith('character-2') + }) +}) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 9796e86..6c29cac 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,9 +1,168 @@ -/** 核验台。 */ -export function PlaytestPage() { +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' + +import type { Character, CharacterApis } from '@/entities/character' +import type { PlaytestInspectionApis } from '@/entities/playtest-inspection' +import type { ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from './path' +import { PlaytestWorkbench, type PlaytestAssetOption } from './workbench' + +export interface PlaytestPageApis { + characters: Pick + projects?: Pick + inspections?: Pick +} + +export interface PlaytestPageProps { + apis?: PlaytestPageApis +} + +interface PageData { + character: Character | null + projectCharacters: Character[] + expectedCanvas: { width: number; height: number } | null + error: string | null + loading: boolean +} + +const initialPageData: PageData = { + character: null, + projectCharacters: [], + expectedCanvas: null, + error: null, + loading: false, +} + +function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const identifiable = error as { code?: unknown; status?: unknown } + return ( + identifiable.code === 404 || + identifiable.code === '404' || + identifiable.status === 404 || + identifiable.status === '404' + ) +} + +/** + * 正式 Playtest 页面只读取 #70 已定义的 Character 接口。 + * 当接口未配置时,自动回退到内置 demo 角色素材。 + * 核验与自动分析结果均停留在页面会话,不写回资产树。 + */ +export function PlaytestPage({ apis }: PlaytestPageProps) { + const { characterId, outfitId } = useParams() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const initialActionId = searchParams.get('actionId') + const [data, setData] = useState(initialPageData) + + useEffect(() => { + // 正式入口未配置角色接口时明确提示,不加载也不回退到 Demo 少年数据 + if (apis === undefined) { + setData({ + character: null, + projectCharacters: [], + expectedCanvas: null, + error: 'Playtest 角色接口尚未配置', + loading: false, + }) + return + } + if (characterId === undefined || outfitId === undefined) { + setData({ ...initialPageData, error: 'Playtest 路由参数不完整' }) + return + } + + let cancelled = false + setData({ ...initialPageData, loading: true }) + void apis.characters.get(characterId).then( + async (character) => { + let projectCharacters = [character] + let expectedCanvas: PageData['expectedCanvas'] = null + const [charactersResult, projectResult] = await Promise.allSettled([ + apis.characters.listByProject(character.projectId), + apis.projects?.get(character.projectId) ?? Promise.resolve(null), + ]) + if (charactersResult.status === 'fulfilled') projectCharacters = charactersResult.value + if (projectResult.status === 'fulfilled' && projectResult.value !== null) { + expectedCanvas = projectResult.value.spriteSize + } + if (!cancelled) { + setData({ character, projectCharacters, expectedCanvas, error: null, loading: false }) + } + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) + + return () => { + cancelled = true + } + }, [apis, characterId, outfitId]) + + if (data.error !== null) return {data.error} + if (data.loading || data.character === null) + return 加载 Playtest 数据中 + + const assetOptions = buildAssetOptions(data.projectCharacters) + + return ( +
+
+ + + 全部 Playtest + +
+ + navigate( + buildPlaytestPath({ + characterId: asset.characterId, + outfitId: asset.outfitId, + }), + ) + } + /> +
+ ) +} + +function buildAssetOptions(characters: Character[]): PlaytestAssetOption[] { + return characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ + key: `${character.id}:${outfit.id}`, + characterId: character.id, + outfitId: outfit.id, + name: `${outfit.name}(角色 ${character.id})`, + actionCount: outfit.actions.length, + })), + ) +} + +function PlaytestPageMessage({ children }: { children: string }) { return ( -
-

核验台

-

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

-
+
+

{children}

+
) } diff --git a/frontend/src/pages/playtest/path.test.ts b/frontend/src/pages/playtest/path.test.ts new file mode 100644 index 0000000..5615463 --- /dev/null +++ b/frontend/src/pages/playtest/path.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { buildPlaytestPath } from './path' + +describe('buildPlaytestPath', () => { + it('encodes resource identities and keeps the selected action in the query string', () => { + expect( + buildPlaytestPath({ + characterId: 'character/1', + outfitId: 'default outfit', + actionId: 'walk left', + }), + ).toBe('/playtest/character%2F1/default%20outfit?actionId=walk+left') + }) +}) diff --git a/frontend/src/pages/playtest/path.ts b/frontend/src/pages/playtest/path.ts new file mode 100644 index 0000000..dc0bd3a --- /dev/null +++ b/frontend/src/pages/playtest/path.ts @@ -0,0 +1,16 @@ +interface PlaytestPathTarget { + characterId: string + outfitId: string + actionId?: string +} + +/** + * 统一生成 Playtest 地址,避免目录页和工作台各自拼接路径。 + * actionId 放在查询参数中,因为它只决定当前预览动作,不改变角色和造型的资源身份。 + */ +export function buildPlaytestPath({ characterId, outfitId, actionId }: PlaytestPathTarget): string { + const pathname = `/playtest/${encodeURIComponent(characterId)}/${encodeURIComponent(outfitId)}` + if (actionId === undefined) return pathname + + return `${pathname}?${new URLSearchParams({ actionId }).toString()}` +} diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts new file mode 100644 index 0000000..c4ad9f6 --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,116 @@ +/// + +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const playtestDirectory = fileURLToPath(new URL('.', import.meta.url)) +const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url)) +const prohibitedImports = [ + 'live-demo-ui-components', + 'entities/workflow-run', + 'entities/generation', +] as const +const allowedEntityImports = [ + '@/entities/character', + '@/entities/playtest-inspection', + '@/entities/project', +] as const + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || entry.name.includes('.test.')) { + return [] + } + return [path] + }) +} + +function allTypeScriptFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return allTypeScriptFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) return [] + return [path] + }) +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? '', + ) +} + +function entityEntry(file: string, dependency: string): string | null { + if (dependency === '@/entities') return '' + if (dependency.startsWith('@/entities/')) return dependency.slice('@/entities/'.length) + if (!dependency.startsWith('.')) return null + + const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency)) + if (relativeEntry.startsWith('..') || isAbsolute(relativeEntry)) return null + + return relativeEntry.replaceAll('\\', '/').replace(/\/index$/, '') +} + +describe('Playtest architecture boundaries', () => { + it('does not import forbidden application or live-demo implementation layers', () => { + // Catches the isolated Playtest slice reaching into unrelated product layers. + for (const file of sourceFiles(playtestDirectory)) { + const source = readFileSync(file, 'utf8') + for (const fragment of prohibitedImports) { + expect(source, `${file} must not contain ${fragment}`).not.toContain(fragment) + } + } + }) + + it('does not import application or capabilities roots or subpaths', () => { + // Catches bypassing the isolated Page through either a barrel or a deep implementation path. + for (const file of sourceFiles(playtestDirectory)) { + const dependencies = moduleSpecifiers(readFileSync(file, 'utf8')) + expect( + dependencies.filter( + (dependency) => + dependency === '@/application' || + dependency.startsWith('@/application/') || + dependency === '@/capabilities' || + dependency.startsWith('@/capabilities/'), + ), + `${file} must not import application or capabilities`, + ).toEqual([]) + } + }) + + it('imports Entity contracts only from the approved direct entrypoints', () => { + // Catches alias or relative root barrels and deep paths that hide Playtest's dependencies. + for (const file of sourceFiles(playtestDirectory)) { + const entityImports = moduleSpecifiers(readFileSync(file, 'utf8')) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null) + + for (const { dependency, entry } of entityImports) { + expect(allowedEntityImports, `${file} imports ${dependency}`).toContain( + `@/entities/${entry}`, + ) + } + } + }) + + it('keeps the demo fixture out of formal Playtest source', () => { + // Catches a formal route silently importing the demo character as an API fallback. + const importers = allTypeScriptFiles(playtestDirectory).filter((file) => + readFileSync(file, 'utf8').includes('testing/demo-character'), + ) + + expect( + importers.every( + (file) => file === join(playtestDirectory, 'demo-page.tsx') || file.includes('.test.'), + ), + ).toBe(true) + expect(readFileSync(join(playtestDirectory, 'index.tsx'), 'utf8')).not.toContain( + 'testing/demo-character', + ) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx new file mode 100644 index 0000000..24649c7 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -0,0 +1,81 @@ +import { StatusPanel } from './status-panel' + +import type { PlaytestInspectionStatus } from '@/entities/playtest-inspection' + +export interface AcceptanceProps { + inspectionStatus: PlaytestInspectionStatus | null + available: boolean + canPass: boolean + loading: boolean + saving: boolean + error: string | null + onRecordStatus(status: PlaytestInspectionStatus): Promise +} + +function statusText(status: PlaytestInspectionStatus | null): string { + if (status === 'passed') return '通过' + if (status === 'issues_found') return '发现问题' + return '尚未核验' +} + +/** Playtest 自己的动作核验结论,不写回 Character 或创作历史。 */ +export function Acceptance({ + inspectionStatus, + available, + canPass, + loading, + saving, + error, + onRecordStatus, +}: AcceptanceProps) { + const detail = !available + ? '当前预览未连接核验记录' + : loading + ? '正在读取核验记录' + : saving + ? '正在保存核验记录' + : error + ? error + : inspectionStatus === null + ? canPass + ? '尚未保存核验结论' + : '当前帧尚未成功加载,不能标记通过' + : '已保存到 Playtest 核验记录' + + return ( +
+
+

ACCEPTANCE

+

本次核验

+
+ +

{statusText(inspectionStatus)}

+

{detail}

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/action-selector.tsx b/frontend/src/pages/playtest/workbench/action-selector.tsx new file mode 100644 index 0000000..b7930a3 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/action-selector.tsx @@ -0,0 +1,208 @@ +import { useId, useState } from 'react' + +import type { PreviewAction } from './model/types' + +export interface PlaytestAssetOption { + key: string + characterId: string + outfitId: string + name: string + actionCount: number +} + +export interface ActionSelectorProps { + assets: readonly PlaytestAssetOption[] + selectedAssetKey: string + actions: readonly PreviewAction[] + selectedActionId: string | null + onSelectAsset(asset: PlaytestAssetOption): void + onSelectAction(actionId: string): void +} + +function frameCount(action: PreviewAction): number { + return action.sequences.reduce((total, sequence) => total + sequence.frames.length, 0) +} + +function assetDisplayName(asset: PlaytestAssetOption): string { + const generatedSuffix = `(角色 ${asset.characterId})` + return asset.name.endsWith(generatedSuffix) + ? asset.name.slice(0, -generatedSuffix.length) + : asset.name +} + +function AssetIdentity({ + asset, + menuOpen = false, + showCaret = false, +}: { + asset: PlaytestAssetOption | null + menuOpen?: boolean + showCaret?: boolean +}) { + return ( + <> + + + + {asset === null ? '没有可预览角色' : assetDisplayName(asset)} + + {asset !== null ? ( + + 角色 {asset.characterId} · {asset.actionCount} 个动作 + + ) : null} + + + + ) +} + +export function ActionSelector({ + assets, + selectedAssetKey, + actions, + selectedActionId, + onSelectAsset, + onSelectAction, +}: ActionSelectorProps) { + const [assetMenuOpen, setAssetMenuOpen] = useState(false) + const assetMenuId = useId() + const selectedAsset = + assets.find((candidate) => candidate.key === selectedAssetKey) ?? assets[0] ?? null + const canSwitchAsset = assets.length > 1 + + return ( +
+
{ + if (!event.currentTarget.contains(event.relatedTarget)) setAssetMenuOpen(false) + }} + > +
+ + {canSwitchAsset ? '角色 / 造型' : '当前角色'} + + {canSwitchAsset ? ( + {assets.length} ITEMS + ) : null} +
+ {canSwitchAsset ? ( + + ) : ( +
+ +
+ )} + {canSwitchAsset && assetMenuOpen ? ( +
+ {assets.map((asset) => { + const selected = asset.key === selectedAssetKey + + return ( + + ) + })} +
+ ) : null} +
+
+
+

动作

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

QUALITY ISSUES

+

问题记录

+

仅保留在当前 Playtest 会话

+
+ +
+

自动发现

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

当前序列没有自动问题

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

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

+ +