diff --git a/frontend/src/pages/playtest/README.md b/frontend/src/pages/playtest/README.md new file mode 100644 index 0000000..7863c51 --- /dev/null +++ b/frontend/src/pages/playtest/README.md @@ -0,0 +1,128 @@ +# Playtest + +Playtest 是角色动画的只读调试和核验界面。它既能直接操控角色验证 idle、walk、jump +之间的响应,也能逐帧查看动作、时长、位移与质量标记,并在不改变角色素材的前提下记录 +整体核验结论。 + +## 入口 + +- **Demo:`/playtest/demo`**。直接使用明确标注的少年开发 fixture,方便独立查看 idle 和 + walk 动作。 +- **正式:`/playtest/:characterId/:outfitId`**。需要由应用提供 `PlaytestPageAPIs`;页面读取 + 指定角色和最新核验结论。 + +正式入口未配置 APIs 时,页面会明确显示“Playtest 后端接口尚未配置”。它不会加载或回退到 +Demo 少年数据。 + +## 数据与只读边界 + +Demo 数据来自 `public/playtest-fixtures/boy/` 的已提取少年帧,以及 +`testing/demo-character.ts` 中的开发 fixture。素材来源和用途说明见该目录的 `SOURCE.md`。 + +Playtest 不修改角色、造型、动作、帧、工作流或版本。正式入口唯一可保存的内容是独立的 +Playtest 核验结论;Demo 中的核验状态仅保留在当前页面,不写入本地存储或后端。 + +## 统一工作台 + +页面只有一套 `PlaybackController`。动作列表、方向选择、统一舞台、播放控制、时间线, +以及右侧“帧检查 / 问题记录 / 资产导出”三个页签共同读取当前动作、方向和帧。键盘、按钮或 +时间线改变帧后,舞台与右侧工具会同步更新,不存在隐藏的第二套状态。 + +- 左侧动作栏展开宽度为 190px,也可收起为窄条;三栏在宽屏共用相同高度和底边,右侧长内容 + 在栏内滚动,不再向下撑出额外空白。 +- 按住 A/D 时临时播放 `walk` 的逐帧动画,并按各帧 `rootMotion` 移动;松开后,若按键前处于 + 暂停状态则恢复暂停,原本正在播放则继续播放。A 面向左,D 面向右。 +- 没有可播放 `walk` 时,A/D 不改变当前动作、镜像或播放状态。 +- ← / → 才是当前序列的上一帧 / 下一帧控制,长按时可连续切帧。 +- W 选择首个包含有效帧的 jump 动作并从首帧播放。 +- S 选择首个包含有效帧的 crouch 动作并从首帧播放。 +- jump/crouch 缺失时,W/S 不改变当前动作,也不会用其他动作图片冒充。 + +当前 Demo 少年包含 idle 和 walk,不包含 jump 或 crouch,因此界面会明确显示“未提供跳跃 +动作”和“未提供下蹲动作”。以后正式 Character 提供对应动作后,同一套控制器会自动启用 +W/S。 + +## 播放与核验 + +- 初始为暂停;切换动作、方向或手动选择帧时暂停。 +- 每帧的 `durationMs` 优先;未提供有效时长时才按动作 FPS 计算显示与播放间隔。 +- 关闭循环时,播放停留在末帧;开启循环时,末帧会回到首帧。 +- 页面会以无障碍实时播报当前动作、帧位置和播放状态。 +- 舞台始终只渲染当前帧角色,不叠加上一帧、下一帧或其他动作的角色虚影。 +- 有横向根位移的角色到达舞台左右边界后会自动掉头并继续播放。边界按舞台和角色的实际显示 + 宽度计算;窗口尺寸改变后会重新钳制。自动掉头不显示文字,也不触发额外播报。 + +## 逐帧自动审核依据 + +逐帧审核会在浏览器中读取每张图片的 Alpha 像素,提供只读的几何检查依据。Alpha 大于 +24 的像素视为候选前景;算法按八邻域过滤小于 `max(4 像素, 最大连通区域的 0.2%)` 的 +孤立噪点,同时始终保留最大主体。 + +右侧分别展示三类位移,不能混为一个数: + +- **画面内额外漂移**:过滤噪点后,相邻帧透明像素质心的变化;自动连续性判断只使用它。 +- **预期根位移增量**:当前帧的 `rootMotion`;它是本次自动播放帧推进应累加的增量,y 正值 + 表示向上。 +- **合成预览位移**:播放器按每次自动播放推进累计根位移后的最终位置;它用于解释播放效果, + 不重复参与自动异常判断。 + +自动审核会输出带问题代码、严重程度和帧位置的结构化结果,覆盖图片不可用、空白主体、画布 +尺寸、边缘裁切、覆盖率、相邻重复帧、位移突变、脚底/高度/面积变化,以及画面位移与根位移 +方向矛盾。重复帧依据主体边界内归一化的 8×8 Alpha/亮度指纹判断,避免小型精灵被整张透明 +画布稀释;位移异常同时使用序列中位数、MAD 和按动作设置的绝对上限,避免整段异常素材用 +自身均值掩盖问题。 + +阈值按动作类别解释:idle 严格检查脚底和高度,walk 允许周期变化,jump 允许离地,crouch +允许主体高度下降,attack/custom 使用更宽的序列离群范围。画布 256×256、主体覆盖率 65%、 +脚底漂移 3 px 和轮廓面积变化 28% 仍作为绝对安全线。 + +这些结果只能发现位置、缩放、背景和轮廓突变,不能判断变脸、人体结构、动作语义或服装 +细节。图片加载失败、完全透明、Canvas 不可用或跨域像素受限时会明确显示“无法计算”,不会 +伪造为 0。检查结果不会修改 `Frame.qc`、Character 或核验记录;当前 Action 尚无明确循环 +合同,因此不计算循环首尾接缝。 + +## 人工问题记录 + +“问题记录”页签把自动发现和人工标记分开显示。用户可以为当前动作、方向和帧选择问题类型、 +补充说明,并修改或删除人工记录。人工问题只保存在当前 React 会话,刷新后清空,不写入 +localStorage、Character、Frame 或后端。后端“核验通过 / 发现问题”的总体结论仍在独立区域 +由用户提交,不会被逐帧问题自动改变。 + +## 游戏资产导出 + +“资产导出”页签只导出状态为 `confirmed` 且包含帧的动作序列。单个 ZIP 中只有: + +1. 保留原 MIME/扩展名的逐帧原图; +2. 每个“动作 × 方向”一张 PNG Sprite Sheet; +3. 与每张 Sprite Sheet 对应的 `animation.json`。 + +Sprite Sheet 每行最多 8 帧,不缩放原图;不同尺寸使用序列最大宽高作为单元格并居中。图片 +加载失败时仍生成资产包:对应单元格保持透明,JSON 标记 `available: false`,页面提示导出 +不完整。已有自动或人工问题时,导出页会先提示问题数量但不阻断导出。切换右侧页签不会卸载 +进行中的导出,因此不能通过切换页签并发启动第二份任务。ZIP 不包含 manifest、审核结果、 +核验结论或开发说明。打包采用同一导出文件内的标准无压缩 ZIP 封装,不新增运行时依赖。 + +## 键盘控制 + +| 按键 | 操作 | +| ---------- | ------------------------------------------------ | +| A | 按住时面向左并播放 Walk;松开后恢复原播放状态 | +| D | 按住时面向右并播放 Walk;松开后恢复原播放状态 | +| W | 有 jump 动作时从首帧播放;缺少时不改变当前状态 | +| S | 有 crouch 动作时从首帧播放;缺少时不改变当前状态 | +| Space | 播放或暂停当前动作 | +| ← / → | 当前序列上一帧 / 下一帧,可长按连续切帧 | +| Home / End | 当前序列首帧 / 尾帧 | +| L | 切换循环 | + +焦点位于按钮、输入框、文本区域、选择器或可编辑文本区域时,Playtest 不拦截键盘控制。 +长按 Space、L、W 或 S 不会重复触发;A、D、方向键、Home 和 End 保持可连续触发。 + +## 视觉参考 + +三栏信息布局只参考 `live-demo-ui-components/animation-workbench.tsx` 的视觉层次。统一舞台保留 +旧工程的网格、地面线和透明背景视觉。`rootMotion` 是逐帧位移增量,只在自动播放实际推进 +时累计;它为 `null` 或零时角色原地播放。向左/向右镜像、位移累计、时间线和检查器均由同一 +`PlaybackController` 驱动,不使用前端写死的行走速度、重力或跳跃高度。Playtest 不导入 Cocos +运行时、旧 iframe 或微信小程序适配;当前导出只生成通用逐帧、Sprite Sheet 与 JSON,不生成 +Cocos 或小程序工程。 diff --git a/frontend/src/pages/playtest/demo-page.tsx b/frontend/src/pages/playtest/demo-page.tsx new file mode 100644 index 0000000..8a06c58 --- /dev/null +++ b/frontend/src/pages/playtest/demo-page.tsx @@ -0,0 +1,18 @@ +import { + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_OUTFIT_ID, +} from "./testing/demo-character"; +import { PlaytestWorkbench } from "./workbench"; + +/** Explicit development fixture entry point. It intentionally bypasses all production APIs. */ +export function PlaytestDemoPage() { + return ( + + ); +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 0000000..8f5c3af --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,268 @@ +// @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 type { PlaytestInspection } from "@/entities/playtest-inspection"; + +import { PlaytestPage, type PlaytestPageAPIs } from "./index"; +import { PLAYTEST_DEMO_ACTION_ID, PLAYTEST_DEMO_CHARACTER } from "./testing/demo-character"; + +function createCharacter(characterId: string, outfitId: string, name: string): Character { + return { + ...PLAYTEST_DEMO_CHARACTER, + id: characterId, + name, + outfits: PLAYTEST_DEMO_CHARACTER.outfits.map((outfit) => ({ + ...outfit, + id: outfitId, + characterId, + actions: outfit.actions.map((action) => ({ ...action, outfitId })), + })), + }; +} + +const character = createCharacter("character-1", "outfit-1", "正式角色"); + +const inspection: PlaytestInspection = { + id: "inspection-1", + characterId: "character-1", + outfitId: "outfit-1", + source: { runId: "run-1", revisionId: "revision-1" }, + status: "passed", + createdAt: "2026-07-30T00:00:00.000Z", + updatedAt: "2026-07-30T00:00:00.000Z", +}; + +function NavigationProbe({ onReady }: { onReady(navigate: NavigateFunction): void }) { + onReady(useNavigate()); + return null; +} + +function renderPage(apis?: PlaytestPageAPIs, initialEntry = "/playtest/character-1/outfit-1") { + let navigate: NavigateFunction | null = null; + const rendered = render( + + (navigate = value)} /> + + } /> + + , + ); + + return { + ...rendered, + navigate(to: string) { + if (navigate === null) throw new Error("navigation was not initialized"); + act(() => navigate?.(to)); + }, + }; +} + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (reason?: unknown) => void = () => undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +afterEach(cleanup); + +describe("PlaytestPage", () => { + it("states that the backend APIs are not configured instead of rendering the demo", () => { + renderPage(); + + expect(screen.getByText("Playtest 后端接口尚未配置")).toBeTruthy(); + expect( + screen.queryByRole("heading", { name: /少年 · Playtest 演示角色 · 默认造型/ }), + ).toBeNull(); + }); + + it("reads only the requested character and its latest inspection", async () => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockResolvedValue(character) }, + inspections: { + getLatest: vi.fn().mockResolvedValue(inspection), + record: vi.fn(), + }, + }; + + renderPage(apis); + + await waitFor(() => + expect(screen.getByRole("heading", { name: /正式角色 · 默认造型/ })).toBeTruthy(), + ); + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith("character-1"); + expect(apis.inspections.getLatest).toHaveBeenCalledExactlyOnceWith({ + characterId: "character-1", + outfitId: "outfit-1", + }); + expect(screen.getByText("run-1 / revision-1")).toBeTruthy(); + }); + + it.each([{ code: 404 }, { status: 404 }])( + "reports a missing character for a recognizable 404 error: %o", + async (notFoundError) => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockRejectedValue(notFoundError) }, + inspections: { + getLatest: vi.fn(), + record: vi.fn(), + }, + }; + + renderPage(apis); + + expect(await screen.findByText("角色不存在")).toBeTruthy(); + expect(apis.inspections.getLatest).not.toHaveBeenCalled(); + }, + ); + + it("reports a character read failure for other character errors", async () => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockRejectedValue(new Error("network unavailable")) }, + inspections: { + getLatest: vi.fn(), + record: vi.fn(), + }, + }; + + renderPage(apis); + + expect(await screen.findByText("角色读取失败")).toBeTruthy(); + expect(apis.inspections.getLatest).not.toHaveBeenCalled(); + }); + + it("reports an inspection read failure after the character was loaded", async () => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockResolvedValue(character) }, + inspections: { + getLatest: vi.fn().mockRejectedValue(new Error("inspection unavailable")), + record: vi.fn(), + }, + }; + + renderPage(apis); + + expect(await screen.findByText("核验记录读取失败")).toBeTruthy(); + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith("character-1"); + expect(apis.inspections.getLatest).toHaveBeenCalledExactlyOnceWith({ + characterId: "character-1", + outfitId: "outfit-1", + }); + expect(screen.queryByText("角色读取失败")).toBeNull(); + }); + + it("synchronizes the initial action when only the actionId query changes", async () => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockResolvedValue(character) }, + inspections: { + getLatest: vi.fn().mockResolvedValue(null), + record: vi.fn(), + }, + }; + const walkActionId = "playtest-demo-boy-walk"; + const mounted = renderPage(apis, `/playtest/character-1/outfit-1?actionId=${walkActionId}`); + + await waitFor(() => + expect(screen.getByRole("button", { name: /行走/ }).getAttribute("aria-pressed")).toBe( + "true", + ), + ); + mounted.navigate(`/playtest/character-1/outfit-1?actionId=${PLAYTEST_DEMO_ACTION_ID}`); + + await waitFor(() => + expect(screen.getByRole("button", { name: /待机/ }).getAttribute("aria-pressed")).toBe( + "true", + ), + ); + expect(apis.characters.get).toHaveBeenCalledTimes(1); + expect(apis.inspections.getLatest).toHaveBeenCalledTimes(1); + }); + + it("records a persisted conclusion through the inspection API only", async () => { + const apis: PlaytestPageAPIs = { + characters: { get: vi.fn().mockResolvedValue(character) }, + inspections: { + getLatest: vi.fn().mockResolvedValue(null), + record: vi.fn().mockResolvedValue({ ...inspection, status: "passed" }), + }, + }; + + renderPage(apis); + const recordButton = await screen.findByRole("button", { name: "核验通过" }); + fireEvent.click(recordButton); + + await waitFor(() => + expect(apis.inspections.record).toHaveBeenCalledExactlyOnceWith({ + characterId: "character-1", + outfitId: "outfit-1", + status: "passed", + }), + ); + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith("character-1"); + }); + + it("ignores an old record success after navigating to another asset", async () => { + const oldRecord = deferred(); + const character2 = createCharacter("character-2", "outfit-2", "第二个正式角色"); + const apis: PlaytestPageAPIs = { + characters: { + get: vi.fn((characterId: string) => + Promise.resolve(characterId === "character-1" ? character : character2), + ), + }, + inspections: { + getLatest: vi.fn().mockResolvedValue(null), + record: vi.fn(() => oldRecord.promise), + }, + }; + const mounted = renderPage(apis); + fireEvent.click(await screen.findByRole("button", { name: "核验通过" })); + mounted.navigate("/playtest/character-2/outfit-2"); + await screen.findByRole("heading", { name: /第二个正式角色 · 默认造型/ }); + + await act(async () => { + oldRecord.resolve({ + ...inspection, + characterId: "character-1", + outfitId: "outfit-1", + }); + await oldRecord.promise; + }); + + expect(screen.queryByText("已保存:通过")).toBeNull(); + }); + + it("ignores an old record failure after navigating to another asset", async () => { + const oldRecord = deferred(); + const character2 = createCharacter("character-2", "outfit-2", "第二个正式角色"); + const apis: PlaytestPageAPIs = { + characters: { + get: vi.fn((characterId: string) => + Promise.resolve(characterId === "character-1" ? character : character2), + ), + }, + inspections: { + getLatest: vi.fn().mockResolvedValue(null), + record: vi.fn(() => oldRecord.promise), + }, + }; + const mounted = renderPage(apis); + fireEvent.click(await screen.findByRole("button", { name: "核验通过" })); + mounted.navigate("/playtest/character-2/outfit-2"); + await screen.findByRole("heading", { name: /第二个正式角色 · 默认造型/ }); + + await act(async () => { + oldRecord.reject(new Error("old request failed")); + await oldRecord.promise.catch(() => undefined); + }); + + expect(screen.queryByText("Playtest 核验结论保存失败")).toBeNull(); + }); +}); diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx new file mode 100644 index 0000000..9942bb2 --- /dev/null +++ b/frontend/src/pages/playtest/index.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import { useParams, useSearchParams } from "react-router"; + +import type { Character, CharacterAPIs } from "@/entities/character"; +import type { + PlaytestInspection, + PlaytestInspectionAPIs, + PlaytestInspectionStatus, +} from "@/entities/playtest-inspection"; + +import { PlaytestWorkbench } from "./workbench"; + +export interface PlaytestPageAPIs { + characters: Pick; + inspections: Pick; +} + +export interface PlaytestPageProps { + apis?: PlaytestPageAPIs; +} + +interface PageData { + character: Character | null; + inspection: PlaytestInspection | null; + error: string | null; + loading: boolean; +} + +const initialPageData: PageData = { + character: null, + inspection: null, + error: null, + loading: false, +}; + +interface PageIdentity { + apis: PlaytestPageAPIs | undefined; + characterId: string | undefined; + outfitId: string | undefined; +} + +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" + ); +} + +export function PlaytestPage({ apis }: PlaytestPageProps) { + const { characterId, outfitId } = useParams(); + const [searchParams] = useSearchParams(); + const initialActionId = searchParams.get("actionId"); + const [data, setData] = useState(initialPageData); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const pageIdentityRef = useRef({ + apis, + characterId, + outfitId, + }); + const saveRequestVersionRef = useRef(0); + + useEffect(() => { + pageIdentityRef.current = { apis, characterId, outfitId }; + saveRequestVersionRef.current += 1; + + if (apis === undefined) { + setData({ ...initialPageData, error: "Playtest 后端接口尚未配置" }); + setSaving(false); + setSaveError(null); + return; + } + + if (characterId === undefined || outfitId === undefined) { + setData({ ...initialPageData, error: "Playtest 路由参数不完整" }); + setSaving(false); + setSaveError(null); + return; + } + + let cancelled = false; + setData({ ...initialPageData, loading: true }); + setSaving(false); + setSaveError(null); + + void (async () => { + let character: Character; + try { + character = await apis.characters.get(characterId); + } catch (error) { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? "角色不存在" : "角色读取失败", + loading: false, + }); + } + return; + } + + if (cancelled) return; + + try { + const inspection = await apis.inspections.getLatest({ characterId, outfitId }); + if (!cancelled) setData({ character, inspection, error: null, loading: false }); + } catch { + if (!cancelled) { + setData({ + character, + inspection: null, + error: "核验记录读取失败", + loading: false, + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [apis, characterId, outfitId]); + + const recordStatus = (status: PlaytestInspectionStatus) => { + if (apis === undefined || characterId === undefined || outfitId === undefined) return; + + const requestIdentity = pageIdentityRef.current; + const requestVersion = saveRequestVersionRef.current + 1; + saveRequestVersionRef.current = requestVersion; + setSaving(true); + setSaveError(null); + void apis.inspections.record({ characterId, outfitId, status }).then( + (inspection) => { + if ( + pageIdentityRef.current !== requestIdentity || + saveRequestVersionRef.current !== requestVersion + ) + return; + setData((current) => ({ ...current, inspection })); + setSaving(false); + }, + () => { + if ( + pageIdentityRef.current !== requestIdentity || + saveRequestVersionRef.current !== requestVersion + ) + return; + setSaving(false); + setSaveError("Playtest 核验结论保存失败"); + }, + ); + }; + + if (data.error !== null) return {data.error}; + if (data.loading || data.character === null) + return 加载 Playtest 数据中; + + return ( + + ); +} + +function PlaytestPageMessage({ children }: { children: string }) { + return ( +
+

{children}

+
+ ); +} diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts new file mode 100644 index 0000000..5254bfd --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,110 @@ +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"] as const; + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || entry.name.includes(".test.")) { + return []; + } + return [path]; + }); +} + +function allTypeScriptFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return allTypeScriptFiles(path); + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) return []; + return [path]; + }); +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? "", + ); +} + +function entityEntry(file: string, dependency: string): string | null { + if (dependency === "@/entities") return ""; + if (dependency.startsWith("@/entities/")) return dependency.slice("@/entities/".length); + if (!dependency.startsWith(".")) return null; + + const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency)); + if (relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) return null; + + return relativeEntry.replaceAll("\\", "/").replace(/\/index$/, ""); +} + +describe("Playtest architecture boundaries", () => { + it("does not import forbidden application or live-demo implementation layers", () => { + // Catches the isolated Playtest slice reaching into unrelated product layers. + for (const file of sourceFiles(playtestDirectory)) { + const source = readFileSync(file, "utf8"); + for (const fragment of prohibitedImports) { + expect(source, `${file} must not contain ${fragment}`).not.toContain(fragment); + } + } + }); + + it("does not import application or capabilities roots or subpaths", () => { + // Catches bypassing the isolated Page through either a barrel or a deep implementation path. + for (const file of sourceFiles(playtestDirectory)) { + const dependencies = moduleSpecifiers(readFileSync(file, "utf8")); + expect( + dependencies.filter( + (dependency) => + dependency === "@/application" || + dependency.startsWith("@/application/") || + dependency === "@/capabilities" || + dependency.startsWith("@/capabilities/"), + ), + `${file} must not import application or capabilities`, + ).toEqual([]); + } + }); + + it("imports Entity contracts only from the two approved direct entrypoints", () => { + // Catches alias or relative root barrels and deep paths that hide Playtest's dependencies. + for (const file of sourceFiles(playtestDirectory)) { + const entityImports = moduleSpecifiers(readFileSync(file, "utf8")) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null); + + for (const { dependency, entry } of entityImports) { + expect(allowedEntityImports, `${file} imports ${dependency}`).toContain( + `@/entities/${entry}`, + ); + } + } + }); + + it("keeps the demo fixture out of formal Playtest source", () => { + // Catches a formal route silently importing the demo character as an API fallback. + const importers = allTypeScriptFiles(playtestDirectory).filter((file) => + readFileSync(file, "utf8").includes("testing/demo-character"), + ); + + expect( + importers.every( + (file) => file === join(playtestDirectory, "demo-page.tsx") || file.includes(".test."), + ), + ).toBe(true); + expect(readFileSync(join(playtestDirectory, "index.tsx"), "utf8")).not.toContain( + "testing/demo-character", + ); + }); +}); diff --git a/frontend/src/pages/playtest/testing/demo-character.test.ts b/frontend/src/pages/playtest/testing/demo-character.test.ts new file mode 100644 index 0000000..4a4fafc --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.test.ts @@ -0,0 +1,45 @@ +import { existsSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_OUTFIT_ID, +} from "./demo-character"; + +describe("PLAYTEST_DEMO_CHARACTER", () => { + it("provides a confirmed boy outfit with complete idle and walk fixtures", () => { + const outfit = PLAYTEST_DEMO_CHARACTER.outfits.find(({ id }) => id === PLAYTEST_DEMO_OUTFIT_ID); + + expect(PLAYTEST_DEMO_CHARACTER.outfits).toHaveLength(1); + expect(PLAYTEST_DEMO_CHARACTER.outfits[0]?.id).toBe(PLAYTEST_DEMO_OUTFIT_ID); + expect(outfit).toBeDefined(); + expect(PLAYTEST_DEMO_ACTION_ID).toBe("playtest-demo-boy-idle"); + expect(outfit!.actions.map((action) => action.type)).toEqual(["idle", "walk"]); + expect(outfit!.actions.every((action) => action.sequences[0]?.frames.length === 8)).toBe(true); + expect(outfit!.actions.every((action) => action.status === "confirmed")).toBe(true); + + const walkFrames = outfit!.actions.find((action) => action.type === "walk")!.sequences[0]! + .frames; + const walkSteps = walkFrames.flatMap((frame) => + frame.rootMotion === null ? [] : [frame.rootMotion.dx], + ); + expect(walkSteps).toEqual([4, 4, 4, 4, 4, 4]); + expect(walkSteps.reduce((sum, step) => sum + step, 0)).toBe(24); + + const frames = outfit!.actions.flatMap((action) => action.sequences[0]!.frames); + expect(frames.some((frame) => frame.durationMs !== null)).toBe(true); + expect(frames.some((frame) => frame.rootMotion !== null)).toBe(true); + expect(frames.some((frame) => frame.qc === "passed")).toBe(true); + expect(frames.some((frame) => frame.qc === "pending")).toBe(true); + expect(frames.some((frame) => frame.rejected)).toBe(true); + + const imageUrls = [ + outfit!.characterTemplateUrl, + ...outfit!.candidateCharacterTemplates.map((candidate) => candidate.imageUrl), + ...outfit!.baseFrames.map((frame) => frame.imageUrl), + ...frames.map((frame) => frame.imageUrl), + ]; + expect(imageUrls.every((url) => url !== null && existsSync(new URL(url)))).toBe(true); + }); +}); diff --git a/frontend/src/pages/playtest/testing/demo-character.ts b/frontend/src/pages/playtest/testing/demo-character.ts new file mode 100644 index 0000000..05cff11 --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.ts @@ -0,0 +1,192 @@ +import type { Character, Frame } from "../../../entities/character"; + +export const PLAYTEST_DEMO_CHARACTER_ID = "playtest-demo-boy"; +export const PLAYTEST_DEMO_OUTFIT_ID = "playtest-demo-boy-default"; +export const PLAYTEST_DEMO_ACTION_ID = "playtest-demo-boy-idle"; + +const fixtureUrls = { + "base.png": new URL("./fixtures/boy/base.png", import.meta.url).href, + "idle-01.png": new URL("./fixtures/boy/idle-01.png", import.meta.url).href, + "idle-02.png": new URL("./fixtures/boy/idle-02.png", import.meta.url).href, + "idle-03.png": new URL("./fixtures/boy/idle-03.png", import.meta.url).href, + "idle-04.png": new URL("./fixtures/boy/idle-04.png", import.meta.url).href, + "idle-05.png": new URL("./fixtures/boy/idle-05.png", import.meta.url).href, + "idle-06.png": new URL("./fixtures/boy/idle-06.png", import.meta.url).href, + "idle-07.png": new URL("./fixtures/boy/idle-07.png", import.meta.url).href, + "idle-08.png": new URL("./fixtures/boy/idle-08.png", import.meta.url).href, + "walk-01.png": new URL("./fixtures/boy/walk-01.png", import.meta.url).href, + "walk-02.png": new URL("./fixtures/boy/walk-02.png", import.meta.url).href, + "walk-03.png": new URL("./fixtures/boy/walk-03.png", import.meta.url).href, + "walk-04.png": new URL("./fixtures/boy/walk-04.png", import.meta.url).href, + "walk-05.png": new URL("./fixtures/boy/walk-05.png", import.meta.url).href, + "walk-06.png": new URL("./fixtures/boy/walk-06.png", import.meta.url).href, + "walk-07.png": new URL("./fixtures/boy/walk-07.png", import.meta.url).href, + "walk-08.png": new URL("./fixtures/boy/walk-08.png", import.meta.url).href, +} as const; + +const fixtureUrl = (name: keyof typeof fixtureUrls) => fixtureUrls[name]; +const demoWalkStep = 4; + +const idleFrames: Frame[] = [ + { + imageUrl: fixtureUrl("idle-01.png"), + durationMs: 160, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-02.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-03.png"), + durationMs: null, + rootMotion: null, + qc: "pending", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-04.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-05.png"), + durationMs: 160, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-06.png"), + durationMs: null, + rootMotion: null, + qc: "pending", + rejected: true, + }, + { + imageUrl: fixtureUrl("idle-07.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("idle-08.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, +]; + +const walkFrames: Frame[] = [ + { + imageUrl: fixtureUrl("walk-01.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-02.png"), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-03.png"), + durationMs: 120, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "pending", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-04.png"), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-05.png"), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-06.png"), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "pending", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-07.png"), + durationMs: 120, + rootMotion: { dx: demoWalkStep, dy: 0 }, + qc: "passed", + rejected: false, + }, + { + imageUrl: fixtureUrl("walk-08.png"), + durationMs: null, + rootMotion: null, + qc: "passed", + rejected: false, + }, +]; + +export const PLAYTEST_DEMO_CHARACTER: Character = { + id: PLAYTEST_DEMO_CHARACTER_ID, + projectId: "playtest-demo-project", + name: "少年 · Playtest 演示角色", + createdAt: "2026-07-30T00:00:00.000Z", + updatedAt: "2026-07-30T00:00:00.000Z", + outfits: [ + { + id: PLAYTEST_DEMO_OUTFIT_ID, + characterId: PLAYTEST_DEMO_CHARACTER_ID, + name: "默认造型", + candidateCharacterTemplates: [ + { + id: "playtest-demo-boy-template", + imageUrl: fixtureUrl("base.png"), + generationId: "playtest-demo-boy-import", + }, + ], + characterTemplateUrl: fixtureUrl("base.png"), + baseFrames: [{ direction: "default", imageUrl: fixtureUrl("base.png") }], + actions: [ + { + id: PLAYTEST_DEMO_ACTION_ID, + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: "待机", + source: "action_template", + type: "idle", + status: "confirmed", + fps: 8, + sequences: [{ direction: "default", keyFrameIndex: 0, frames: idleFrames }], + }, + { + id: "playtest-demo-boy-walk", + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: "行走", + source: "action_template", + type: "walk", + status: "confirmed", + fps: 10, + sequences: [{ direction: "default", keyFrameIndex: 0, frames: walkFrames }], + }, + ], + }, + ], +}; diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md new file mode 100644 index 0000000..16a42e6 --- /dev/null +++ b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md @@ -0,0 +1,7 @@ +# Playtest fixture source + +- Original project: `E:/Compressed/windup-asset-lab-codex-issue-14-workflow-skeleton_5/windup-asset-lab-codex-issue-14-workflow-skeleton/` +- Original character (`card.json`): `少年 · 默认角色` +- Original `importedFrom`: `default-demo-assets` + +These files are copied only as development test data for the Playtest workbench. diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/base.png b/frontend/src/pages/playtest/testing/fixtures/boy/base.png new file mode 100644 index 0000000..7755f3c Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/base.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png new file mode 100644 index 0000000..7755f3c Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png new file mode 100644 index 0000000..4cc3e9b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png new file mode 100644 index 0000000..649ba1b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png new file mode 100644 index 0000000..b998e0b Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png new file mode 100644 index 0000000..cdd4e01 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png new file mode 100644 index 0000000..0b1ccb7 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png new file mode 100644 index 0000000..23a0356 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png new file mode 100644 index 0000000..67936b2 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png new file mode 100644 index 0000000..ab3e368 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png new file mode 100644 index 0000000..7e50772 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png new file mode 100644 index 0000000..15b97a2 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png new file mode 100644 index 0000000..c3ac40e Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png new file mode 100644 index 0000000..12970d8 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png new file mode 100644 index 0000000..d5fe4a9 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png new file mode 100644 index 0000000..5c9f309 Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png differ diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png new file mode 100644 index 0000000..802a06e Binary files /dev/null and b/frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png differ diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx new file mode 100644 index 0000000..3ad398e --- /dev/null +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -0,0 +1,87 @@ +import type { PlaytestInspectionStatus } from "@/entities/playtest-inspection"; + +import { StatusPanel } from "./status-panel"; + +export interface AcceptanceProps { + inspectionMode: "demo" | "persisted"; + inspectionStatus: PlaytestInspectionStatus | null; + saving?: boolean; + saveError?: string | null; + onRecordStatus(status: PlaytestInspectionStatus): void; +} + +function statusText(status: PlaytestInspectionStatus | null): string { + if (status === "passed") return "通过"; + if (status === "issues_found") return "发现问题"; + return "尚未核验"; +} + +export function Acceptance({ + inspectionMode, + inspectionStatus, + saving = false, + saveError = null, + onRecordStatus, +}: AcceptanceProps) { + const isDemo = inspectionMode === "demo"; + + return ( +
+
+

ACCEPTANCE

+

核验状态

+
+ + {isDemo ? ( + <> +

本地核验:{statusText(inspectionStatus)}

+

未提交后端

+ + ) : ( +

+ {inspectionStatus === null + ? "尚无已保存核验" + : `已保存:${statusText(inspectionStatus)}`} +

+ )} +
+ {saving ? ( +

+ 正在保存核验结论 +

+ ) : null} + {saveError !== null ? ( +
+ + {saveError} + +
+ ) : null} +
+ + +
+
+ ); +} 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..3853aa6 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/action-selector.tsx @@ -0,0 +1,51 @@ +import type { PreviewAction } from "./model/types"; + +export interface ActionSelectorProps { + actions: readonly PreviewAction[]; + selectedActionId: string | null; + onSelectAction(actionId: string): void; +} + +function frameCount(action: PreviewAction): number { + return action.sequences.reduce((total, sequence) => total + sequence.frames.length, 0); +} + +export function ActionSelector({ actions, selectedActionId, onSelectAction }: ActionSelectorProps) { + return ( + + ); +} diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts new file mode 100644 index 0000000..77a358d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { measureFrameGeometry, type FramePixelData } from "./frame-geometry"; + +function createPixels( + width: number, + height: number, + visible: readonly { x: number; y: number; alpha: number }[], +): FramePixelData { + const data = new Uint8ClampedArray(width * height * 4); + + for (const pixel of visible) data[(pixel.y * width + pixel.x) * 4 + 3] = pixel.alpha; + + return { data, width, height }; +} + +describe("measureFrameGeometry", () => { + it("treats only alpha values greater than 24 as visible", () => { + // Catches the review algorithm including matte noise at the old Alpha cutoff. + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 24 }]))).toBeNull(); + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 25 }]))).toMatchObject({ + opaquePixels: 1, + coverageRatio: 1, + }); + }); + + it("measures bounds, centroid, foot line, height, area and coverage from visible pixels", () => { + // Catches off-by-one bounds or a centroid derived from the box instead of real visible pixels. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 1, y: 1, alpha: 255 }, + { x: 2, y: 1, alpha: 255 }, + { x: 1, y: 2, alpha: 255 }, + { x: 2, y: 2, alpha: 255 }, + ]), + ); + + expect(geometry).toEqual({ + width: 4, + height: 4, + bounds: { left: 1, top: 1, right: 2, bottom: 2, width: 2, height: 2 }, + centroid: { x: 1.5, y: 1.5 }, + footY: 2, + subjectHeight: 2, + opaquePixels: 4, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }); + expect(geometry?.fingerprint).toHaveLength(64); + }); + + it("produces different compact fingerprints for different silhouettes with equal bounds", () => { + const leftTop = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 2, y: 0, alpha: 255 }, + { x: 3, y: 0, alpha: 255 }, + { x: 3, y: 1, alpha: 255 }, + { x: 3, y: 2, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ); + const leftBottom = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 0, y: 2, alpha: 255 }, + { x: 0, y: 3, alpha: 255 }, + { x: 1, y: 3, alpha: 255 }, + { x: 2, y: 3, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ); + + expect(leftTop?.bounds).toEqual(leftBottom?.bounds); + expect(leftTop?.fingerprint).not.toEqual(leftBottom?.fingerprint); + }); + + it("keeps small dark silhouettes distinguishable instead of diluting them across the canvas", () => { + const topLeft: Array<{ x: number; y: number; alpha: number }> = []; + const bottomRight: Array<{ x: number; y: number; alpha: number }> = []; + for (let y = 96; y < 160; y += 1) { + for (let x = 96; x < 160; x += 1) { + if (y < 128 || x < 112) topLeft.push({ x, y, alpha: 255 }); + if (y >= 128 || x >= 144) bottomRight.push({ x, y, alpha: 255 }); + } + } + const first = measureFrameGeometry(createPixels(256, 256, topLeft)); + const second = measureFrameGeometry(createPixels(256, 256, bottomRight)); + const distance = + first?.fingerprint?.reduce( + (total, value, index) => total + Math.abs(value - (second?.fingerprint?.[index] ?? value)), + 0, + ) ?? 0; + + expect(first?.bounds).toEqual(second?.bounds); + expect(distance / 64).toBeGreaterThan(0.02); + }); + + it("rejects an RGBA buffer whose dimensions do not match its length", () => { + // Catches silent geometry corruption when Canvas data and dimensions diverge. + expect(() => + measureFrameGeometry({ data: new Uint8ClampedArray(4), width: 2, height: 2 }), + ).toThrowError("RGBA 像素长度与画布尺寸不一致"); + }); + + it("ignores a tiny isolated Alpha component outside the visible subject", () => { + // Catches one stray generated pixel moving the measured foot line and centroid. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 1, y: 1, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ); + + expect(geometry).toMatchObject({ + bounds: { left: 0, top: 0, right: 1, bottom: 1, width: 2, height: 2 }, + centroid: { x: 0.5, y: 0.5 }, + footY: 1, + opaquePixels: 4, + coverageRatio: 0.25, + }); + }); +}); diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts new file mode 100644 index 0000000..c4bc63c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts @@ -0,0 +1,178 @@ +export const ALPHA_THRESHOLD = 24; +const MIN_COMPONENT_PIXELS = 4; +const RELATIVE_COMPONENT_RATIO = 0.002; + +export interface FramePixelData { + data: Uint8ClampedArray; + width: number; + height: number; +} + +export interface FrameGeometry { + width: number; + height: number; + bounds: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }; + centroid: { x: number; y: number }; + footY: number; + subjectHeight: number; + opaquePixels: number; + coverageRatio: number; + /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */ + fingerprint?: readonly number[]; +} + +function createFingerprint( + data: Uint8ClampedArray, + width: number, + subjectPixels: readonly number[], + bounds: { left: number; top: number; width: number; height: number }, +): readonly number[] { + const sums = new Float64Array(64); + const cellPixels = new Uint32Array(64); + + for (let y = 0; y < bounds.height; y += 1) { + for (let x = 0; x < bounds.width; x += 1) { + const cellX = Math.min(7, Math.floor((x * 8) / bounds.width)); + const cellY = Math.min(7, Math.floor((y * 8) / bounds.height)); + cellPixels[cellY * 8 + cellX] += 1; + } + } + + 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)); + sums[cellY * 8 + cellX] += alpha * (0.25 + luminance * 0.75); + } + + return Array.from(sums, (sum, index) => { + const count = cellPixels[index] ?? 0; + return count === 0 ? 0 : Number((sum / count).toFixed(4)); + }); +} + +function visibleComponents(data: Uint8ClampedArray, width: number, height: number): number[][] { + const visible = new Uint8Array(width * height); + const visited = new Uint8Array(width * height); + const components: number[][] = []; + + for (let index = 0; index < visible.length; index += 1) { + const alpha = data[index * 4 + 3]; + if (alpha !== undefined && alpha > ALPHA_THRESHOLD) visible[index] = 1; + } + + for (let start = 0; start < visible.length; start += 1) { + if (visible[start] === 0 || visited[start] === 1) continue; + + const component: number[] = []; + const queue = [start]; + visited[start] = 1; + + for (let head = 0; head < queue.length; head += 1) { + const index = queue[head]; + if (index === undefined) continue; + 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.push(next); + } + } + } + + components.push(component); + } + + return components; +} + +export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | null { + const { data, width, height } = pixels; + + if (data.length !== width * height * 4) { + throw new RangeError("RGBA 像素长度与画布尺寸不一致"); + } + + const components = visibleComponents(data, width, height); + if (components.length === 0) return null; + + const largestSize = Math.max(...components.map((component) => component.length)); + const minimumSize = Math.min( + largestSize, + Math.max(MIN_COMPONENT_PIXELS, Math.ceil(largestSize * RELATIVE_COMPONENT_RATIO)), + ); + const subjectPixels = components.flatMap((component) => + component.length === largestSize || component.length >= minimumSize ? component : [], + ); + + let left = width; + let top = height; + let right = -1; + let bottom = -1; + let opaquePixels = 0; + let sumX = 0; + let sumY = 0; + + for (const index of subjectPixels) { + const x = index % width; + const y = Math.floor(index / width); + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + opaquePixels += 1; + sumX += x; + sumY += y; + } + + const subjectWidth = right - left + 1; + const subjectHeight = bottom - top + 1; + + return { + width, + height, + bounds: { + left, + top, + right, + bottom, + width: subjectWidth, + height: subjectHeight, + }, + centroid: { x: sumX / opaquePixels, y: sumY / opaquePixels }, + footY: bottom, + subjectHeight, + opaquePixels, + coverageRatio: opaquePixels / (width * height), + fingerprint: createFingerprint(data, width, subjectPixels, { + left, + top, + width: subjectWidth, + height: subjectHeight, + }), + }; +} diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts new file mode 100644 index 0000000..a80199b --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readImageGeometry } from "./image-geometry"; + +type ImageBehavior = "load" | "error" | "pending"; + +let imageBehavior: ImageBehavior; +let crossOriginAtSourceAssignment: string | null; +let lastAssignedSource: string; +let canvasContext: Pick | null; + +class FakeImage { + crossOrigin: string | null = null; + naturalWidth = 2; + naturalHeight = 2; + onerror: OnErrorEventHandler | null = null; + onload: ((this: GlobalEventHandlers, event: Event) => unknown) | null = null; + private source = ""; + + get src(): string { + return this.source; + } + + set src(value: string) { + this.source = value; + crossOriginAtSourceAssignment = this.crossOrigin; + lastAssignedSource = value; + if (value === "" || imageBehavior === "pending") return; + + queueMicrotask(() => { + if (imageBehavior === "load") this.onload?.call(this as never, new Event("load")); + else this.onerror?.call(this as never, "error", "", 0, 0, new Error("load failed")); + }); + } +} + +function pixelsWithAlpha(alpha: number): ImageData { + const data = new Uint8ClampedArray(2 * 2 * 4); + data[3] = alpha; + return { data, width: 2, height: 2, colorSpace: "srgb" } as ImageData; +} + +beforeEach(() => { + imageBehavior = "load"; + crossOriginAtSourceAssignment = null; + lastAssignedSource = ""; + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(255)), + }; + vi.stubGlobal("Image", FakeImage); + vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { + if (tagName !== "canvas") + return document.createElementNS("http://www.w3.org/1999/xhtml", tagName); + return { + width: 0, + height: 0, + getContext: () => canvasContext, + } as unknown as HTMLCanvasElement; + }) as typeof document.createElement); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("readImageGeometry", () => { + it("requests anonymous image access before reading real Canvas pixels", async () => { + // Catches crossOrigin being assigned after src or a placeholder geometry replacing actual pixels. + const result = await readImageGeometry("https://cdn.example.test/frame.png"); + + expect(crossOriginAtSourceAssignment).toBe("anonymous"); + expect(result).toEqual({ + status: "ready", + geometry: { + width: 2, + height: 2, + bounds: { left: 0, top: 0, right: 0, bottom: 0, width: 1, height: 1 }, + centroid: { x: 0, y: 0 }, + footY: 0, + subjectHeight: 1, + opaquePixels: 1, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }, + }); + }); + + it("reports asset, Canvas and transparent-frame failures instead of zero evidence", async () => { + // Catches unavailable evidence being silently presented as a successful zero measurement. + imageBehavior = "error"; + await expect(readImageGeometry("/missing.png")).resolves.toEqual({ + status: "unavailable", + reason: "图片加载失败", + }); + + imageBehavior = "load"; + canvasContext = null; + await expect(readImageGeometry("/no-canvas.png")).resolves.toEqual({ + status: "unavailable", + reason: "浏览器无法读取图片像素", + }); + + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(24)), + }; + await expect(readImageGeometry("/transparent.png")).resolves.toEqual({ + status: "unavailable", + reason: "图片没有可见主体", + }); + }); + + it("reports a tainted Canvas as a cross-origin pixel failure", async () => { + // Catches signed remote images being mislabelled as transparent or valid when CORS blocks inspection. + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => { + throw new DOMException("tainted", "SecurityError"); + }), + }; + + await expect(readImageGeometry("https://remote.example.test/frame.png")).resolves.toEqual({ + status: "unavailable", + reason: "图片跨域,无法计算像素", + }); + }); + + it("cancels a pending image read through AbortSignal", async () => { + // Catches a stale sequence load surviving a direction switch and updating the new review. + imageBehavior = "pending"; + const controller = new AbortController(); + const result = readImageGeometry("/slow.png", controller.signal); + + controller.abort(); + + await expect(result).resolves.toEqual({ + status: "unavailable", + reason: "分析已取消", + }); + expect(lastAssignedSource).toBe(""); + }); +}); diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts new file mode 100644 index 0000000..419a108 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts @@ -0,0 +1,68 @@ +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("浏览器无法读取图片像素"); +} + +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 canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + + if (context === null) { + finish(unavailable("浏览器无法读取图片像素")); + return; + } + + try { + context.drawImage(image, 0, 0); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + 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/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts new file mode 100644 index 0000000..9d19681 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; + +import type { FrameGeometry } from "./frame-geometry"; +import { buildSequenceEvidence, type FrameEvidenceInput } from "./sequence-evidence"; + +function geometry( + overrides: Partial< + Pick< + FrameGeometry, + "width" | "height" | "footY" | "subjectHeight" | "opaquePixels" | "coverageRatio" + > + > & { x?: number; y?: number; fingerprint?: readonly number[]; cropped?: boolean } = {}, +): FrameGeometry { + const subjectHeight = overrides.subjectHeight ?? 20; + + return { + width: overrides.width ?? 256, + height: overrides.height ?? 256, + bounds: { + left: overrides.cropped ? 0 : 100, + top: overrides.cropped ? 0 : 100, + right: overrides.cropped ? 9 : 109, + bottom: overrides.cropped ? subjectHeight - 1 : 100 + subjectHeight - 1, + width: 10, + height: subjectHeight, + }, + centroid: { x: overrides.x ?? 0, y: overrides.y ?? 0 }, + footY: overrides.footY ?? 100, + subjectHeight, + opaquePixels: overrides.opaquePixels ?? 100, + coverageRatio: overrides.coverageRatio ?? 0.25, + fingerprint: overrides.fingerprint, + }; +} + +function ready( + value: FrameGeometry, + rootMotion: FrameEvidenceInput["rootMotion"] = null, +): FrameEvidenceInput { + return { geometry: { status: "ready", geometry: value }, rootMotion }; +} + +describe("buildSequenceEvidence", () => { + it("returns structured findings for incomplete, cropped and duplicate frames", () => { + const fingerprint = Array.from({ length: 64 }, (_, index) => index / 64); + const evidence = buildSequenceEvidence( + [ + ready(geometry({ cropped: true, fingerprint })), + ready(geometry({ fingerprint })), + { geometry: { status: "unavailable", reason: "图片没有可见主体" }, rootMotion: null }, + ], + "walk", + ); + + expect(evidence.complete).toBe(false); + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(["subject_cropped", "duplicate_frame", "blank_subject"]), + ); + expect(evidence.findings.find((finding) => finding.code === "duplicate_frame")).toMatchObject({ + frameIndex: 1, + severity: "warning", + }); + }); + + it("uses action-aware findings for foot and height changes", () => { + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 30 })), + ready(geometry({ footY: 112, subjectHeight: 15 })), + ]; + + expect(buildSequenceEvidence(frames, "idle").findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(["foot_drift", "height_drift"]), + ); + expect( + buildSequenceEvidence(frames, "jump").findings.map((finding) => finding.code), + ).not.toContain("foot_drift"); + expect( + buildSequenceEvidence(frames, "crouch").findings.map((finding) => finding.code), + ).not.toContain("height_drift"); + }); + + it("flags motion outliers and root-motion direction contradictions", () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 }), { dx: 1, dy: 0 }), + ready(geometry({ x: 2 }), { dx: 1, dy: 0 }), + ready(geometry({ x: -18 }), { dx: 5, dy: 0 }), + ], + "walk", + ); + + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(["motion_spike", "root_motion_mismatch"]), + ); + }); + + it("calculates a selected frame delta and the hand-derived sequence baseline", () => { + // Catches image-derived offsets being calculated from bounds or against the first frame instead of the previous frame. + const evidence = buildSequenceEvidence( + [ready(geometry()), ready(geometry({ x: 3, y: 4, opaquePixels: 80 }))], + "walk", + ); + + expect(evidence.frames[0]?.previousDelta).toBeNull(); + expect(evidence.frames[1]?.previousDelta).toEqual({ + dx: 3, + dy: 4, + distance: 5, + areaDeltaPercent: 20, + }); + expect(evidence.summary).toMatchObject({ + medianStep: 5, + maxStep: 5, + movementThreshold: 15, + maxAreaDeltaPercent: 20, + movementState: "normal", + areaState: "normal", + }); + }); + + it("does not compare across an unreadable middle frame", () => { + // Catches filtered valid frames becoming false neighbours and producing a misleading offset. + const evidence = buildSequenceEvidence( + [ + ready(geometry()), + { geometry: { status: "unavailable", reason: "图片加载失败" }, rootMotion: null }, + ready(geometry({ x: 20, y: 20 })), + ], + "walk", + ); + + expect(evidence.complete).toBe(false); + expect(evidence.unavailableFrameCount).toBe(1); + expect(evidence.frames.map((frame) => frame.previousDelta)).toEqual([null, null, null]); + expect(evidence.summary.medianStep).toBeNull(); + expect(evidence.summary.movementState).toBe("not_applicable"); + }); + + it("marks excessive coverage, foot drift and height drift with action-aware states", () => { + // Catches jump lift being rejected as foot drift or foreground/background problems being hidden. + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 20, coverageRatio: 0.66 })), + ready(geometry({ footY: 104, subjectHeight: 28 })), + ]; + + const walk = buildSequenceEvidence(frames, "walk"); + const jump = buildSequenceEvidence(frames, "jump"); + + expect(walk.frames[0]?.coverageState).toBe("anomaly"); + expect(walk.summary).toMatchObject({ + footDrift: 4, + heightDrift: 8, + footState: "anomaly", + heightThreshold: 12, + heightState: "normal", + }); + expect(jump.summary.footState).toBe("attention"); + }); + + it("flags a single movement spike against the sequence median", () => { + // Catches a sudden position jump being normalized away by the animation's ordinary movement. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 })), + ready(geometry({ x: 2 })), + ready(geometry({ x: 22 })), + ], + "walk", + ); + + expect(evidence.summary).toMatchObject({ + medianStep: 1, + maxStep: 20, + movementThreshold: 6, + movementState: "anomaly", + }); + expect(evidence.frames.map((frame) => frame.movementState)).toEqual([ + "not_applicable", + "normal", + "normal", + "anomaly", + ]); + }); + + it("keeps an action-aware absolute movement ceiling when every step is large", () => { + // Catches an entire drifting sequence normalizing its own 100px jumps through the median. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 100 })), ready(geometry({ x: 200 }))], + "walk", + ); + + expect(evidence.summary.movementThreshold).toBeLessThan(100); + expect(evidence.summary.movementState).toBe("anomaly"); + expect(evidence.findings.map((finding) => finding.code)).toContain("motion_spike"); + }); + + it("marks jump and crouch height variation as action-allowed attention", () => { + const frames = [ready(geometry({ subjectHeight: 20 })), ready(geometry({ subjectHeight: 60 }))]; + + for (const actionType of ["jump", "crouch"] as const) { + const evidence = buildSequenceEvidence(frames, actionType); + expect(evidence.summary.heightThreshold).toBeNull(); + expect(evidence.summary.heightState).toBe("attention"); + expect(evidence.findings.map((finding) => finding.code)).not.toContain("height_drift"); + } + }); + + it("flags adjacent outline area changes over 28 percent", () => { + // Catches a character silhouette abruptly shrinking without a visible review warning. + const evidence = buildSequenceEvidence( + [ready(geometry({ opaquePixels: 100 })), ready(geometry({ opaquePixels: 70 }))], + "attack", + ); + + expect(evidence.summary).toMatchObject({ + maxAreaDeltaPercent: 30, + areaState: "anomaly", + }); + expect(evidence.frames[1]?.areaState).toBe("anomaly"); + }); + + it("marks adjacency-only checks not applicable for one frame", () => { + // Catches the first frame displaying fabricated zero deltas as a successful comparison. + const evidence = buildSequenceEvidence([ready(geometry())], "idle"); + + expect(evidence.frames[0]).toMatchObject({ + previousDelta: null, + movementState: "not_applicable", + areaState: "not_applicable", + }); + expect(evidence.summary).toMatchObject({ + footDrift: null, + heightDrift: null, + medianStep: null, + movementThreshold: null, + movementState: "not_applicable", + areaState: "not_applicable", + }); + }); + + it("combines measured image drift with adjacent root-motion increments using an upward y axis", () => { + // Catches root motion being subtracted twice or image-space positive-down y being added as positive-up. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0, y: 10 }), null), ready(geometry({ x: 3, y: 14 }), { dx: 10, dy: 6 })], + "walk", + ); + + expect(evidence.frames[0]).toMatchObject({ + expectedRootDelta: null, + composedPreviewDelta: null, + }); + expect(evidence.frames[1]?.expectedRootDelta).toMatchObject({ dx: 10, dy: 6 }); + expect(evidence.frames[1]?.expectedRootDelta?.distance).toBeCloseTo(Math.sqrt(136)); + expect(evidence.frames[1]?.composedPreviewDelta).toMatchObject({ dx: 13, dy: 2 }); + expect(evidence.frames[1]?.composedPreviewDelta?.distance).toBeCloseTo(Math.sqrt(173)); + expect(evidence.frames[1]?.movementState).toBe("normal"); + }); + + it("treats each frame root motion as an increment instead of subtracting the previous frame", () => { + // Catches repeated per-frame dx values collapsing to zero and leaving a walking sprite in place. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0, y: 10 }), null), + ready(geometry({ x: 1, y: 11 }), { dx: 2, dy: 1 }), + ready(geometry({ x: 3, y: 12 }), { dx: 3, dy: 2 }), + ], + "walk", + ); + + expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 }); + expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 }); + }); +}); diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts new file mode 100644 index 0000000..2bedca1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts @@ -0,0 +1,444 @@ +import type { Frame } from "@/entities/character"; + +import type { PlaytestActionType } from "../model/types"; +import type { FrameGeometry } from "./frame-geometry"; + +const EXPECTED_CANVAS_SIZE = 256; +const MAX_COVERAGE_RATIO = 0.65; +const MAX_FOOT_DRIFT = 3; +const MAX_HEIGHT_DRIFT = 7; +const MAX_AREA_DELTA_PERCENT = 28; +const MIN_COVERAGE_RATIO = 0.005; +const DUPLICATE_DISTANCE = 0.02; + +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; + 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; + maxAreaDeltaPercent: number | null; + canvasState: EvidenceState; + footState: EvidenceState; + heightState: EvidenceState; + movementState: EvidenceState; + areaState: EvidenceState; + }; +} + +function medianAbsoluteDeviation(values: readonly number[], center: number | null): number | null { + if (center === null || values.length === 0) return null; + return median(values.map((value) => Math.abs(value - center))); +} + +function fingerprintDistance( + left: readonly number[] | undefined, + right: readonly number[] | undefined, +): number | null { + if ( + left === undefined || + right === undefined || + left.length === 0 || + left.length !== right.length + ) + return null; + + const total = left.reduce( + (sum, value, index) => sum + Math.abs(value - (right[index] ?? value)), + 0, + ); + return total / left.length; +} + +function isCropped(geometry: FrameGeometry): boolean { + return ( + geometry.bounds.left <= 1 || + geometry.bounds.top <= 1 || + geometry.bounds.right >= geometry.width - 2 || + geometry.bounds.bottom >= geometry.height - 2 + ); +} + +function heightFindingThreshold(actionType: PlaytestActionType): number | null { + if (actionType === "jump" || actionType === "crouch") return null; + if (actionType === "idle") return 7; + if (actionType === "walk") return 12; + return 20; +} + +function absoluteMovementThreshold(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; +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const upper = sorted[middle]; + if (upper === undefined) return null; + + if (sorted.length % 2 === 1) return upper; + + const lower = sorted[middle - 1]; + return lower === undefined ? upper : (lower + upper) / 2; +} + +function spread(values: readonly number[]): number | null { + if (values.length < 2) return null; + return Math.max(...values) - Math.min(...values); +} + +function adjacentDelta(previous: FrameGeometry, current: FrameGeometry): AdjacentFrameDelta { + const dx = current.centroid.x - previous.centroid.x; + const dy = current.centroid.y - previous.centroid.y; + + return { + dx, + dy, + distance: Math.hypot(dx, dy), + areaDeltaPercent: + (Math.abs(current.opaquePixels - previous.opaquePixels) / + Math.max(current.opaquePixels, previous.opaquePixels)) * + 100, + }; +} + +function motionVector(dx: number, dy: number): MotionVector { + return { dx, dy, distance: Math.hypot(dx, dy) }; +} + +function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } { + return frame.rootMotion ?? { dx: 0, dy: 0 }; +} + +export function buildSequenceEvidence( + inputs: readonly FrameEvidenceInput[], + actionType: PlaytestActionType, +): SequenceReviewEvidence { + const results = inputs.map((input) => input.geometry); + const readyGeometries = results.flatMap((result) => + result.status === "ready" ? [result.geometry] : [], + ); + const deltas = results.map((result, index): AdjacentFrameDelta | null => { + const previous = results[index - 1]; + if (index === 0 || previous?.status !== "ready" || result.status !== "ready") return null; + return adjacentDelta(previous.geometry, result.geometry); + }); + const rootDeltas = inputs.map((input, index): MotionVector | null => { + if (index === 0) return null; + + const increment = rootMotion(input); + return motionVector(increment.dx, increment.dy); + }); + const availableDeltas = deltas.flatMap((delta) => (delta === null ? [] : [delta])); + const steps = availableDeltas.map((delta) => delta.distance); + const areaDeltas = availableDeltas.map((delta) => delta.areaDeltaPercent); + const medianStep = median(steps); + const movementMad = medianAbsoluteDeviation(steps, medianStep); + const relativeMovementThreshold = + medianStep === null + ? null + : Math.max(medianStep * 2.6 + 2, medianStep + (movementMad ?? 0) * 3 + 2, 6); + const movementThreshold = + relativeMovementThreshold === null + ? null + : Math.min(relativeMovementThreshold, absoluteMovementThreshold(actionType)); + const maxStep = steps.length === 0 ? null : Math.max(...steps); + const maxAreaDeltaPercent = areaDeltas.length === 0 ? null : Math.max(...areaDeltas); + const footDrift = spread(readyGeometries.map((geometry) => geometry.footY)); + const heightDrift = spread(readyGeometries.map((geometry) => geometry.subjectHeight)); + const unavailableFrameCount = results.length - readyGeometries.length; + const findings: QualityFinding[] = []; + const heightThreshold = heightFindingThreshold(actionType); + + 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, + 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, + coverageState: result.geometry.coverageRatio > MAX_COVERAGE_RATIO ? "anomaly" : "normal", + movementState: + delta === null || movementThreshold === null + ? "not_applicable" + : delta.distance > movementThreshold + ? "anomaly" + : "normal", + areaState: + delta === null + ? "not_applicable" + : delta.areaDeltaPercent > MAX_AREA_DELTA_PERCENT + ? "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 (geometry.width !== EXPECTED_CANVAS_SIZE || geometry.height !== EXPECTED_CANVAS_SIZE) { + findings.push({ + code: "canvas_size_mismatch", + severity: "error", + frameIndex: index, + message: "画布尺寸与预期规格不一致", + metrics: { width: geometry.width, height: geometry.height }, + }); + } + if (isCropped(geometry)) { + 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 < MIN_COVERAGE_RATIO) { + findings.push({ + code: "coverage_too_low", + severity: "warning", + frameIndex: index, + message: "主体在画布中的占比过小", + metrics: { coverageRatio: geometry.coverageRatio }, + }); + } else if (geometry.coverageRatio > MAX_COVERAGE_RATIO) { + findings.push({ + code: "coverage_too_high", + severity: "error", + frameIndex: index, + message: "主体在画布中的占比过大", + metrics: { coverageRatio: geometry.coverageRatio }, + }); + } + + const previous = results[index - 1]; + if (previous?.status !== "ready") return; + const duplicateDistance = fingerprintDistance( + previous.geometry.fingerprint, + geometry.fingerprint, + ); + if (duplicateDistance !== null && duplicateDistance <= DUPLICATE_DISTANCE) { + findings.push({ + code: "duplicate_frame", + severity: "warning", + frameIndex: index, + message: "当前帧与上一帧高度相似", + metrics: { distance: duplicateDistance }, + }); + } + + const delta = deltas[index]; + if (delta !== null && movementThreshold !== null && delta.distance > movementThreshold) { + findings.push({ + code: "motion_spike", + severity: "error", + frameIndex: index, + message: "相邻帧出现异常位移突变", + metrics: { distance: delta.distance, threshold: movementThreshold }, + }); + } + if (delta !== null && delta.areaDeltaPercent > MAX_AREA_DELTA_PERCENT) { + 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 >= 2 && delta.distance >= 2) { + const dot = delta.dx * expected.dx + -delta.dy * expected.dy; + if (dot < 0) { + findings.push({ + code: "root_motion_mismatch", + severity: "warning", + frameIndex: index, + message: "画面内位移方向与预期根位移矛盾", + metrics: { dotProduct: dot }, + }); + } + } + }); + + if (footDrift !== null && footDrift > MAX_FOOT_DRIFT && actionType !== "jump") { + findings.push({ + code: "foot_drift", + severity: "error", + frameIndex: null, + message: "序列脚底线漂移超过动作允许范围", + metrics: { drift: footDrift, threshold: MAX_FOOT_DRIFT }, + }); + } + 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, + maxAreaDeltaPercent, + canvasState: + readyGeometries.length === 0 + ? "not_applicable" + : readyGeometries.every( + (geometry) => + geometry.width === EXPECTED_CANVAS_SIZE && + geometry.height === EXPECTED_CANVAS_SIZE, + ) + ? "normal" + : "anomaly", + footState: + footDrift === null + ? "not_applicable" + : footDrift <= MAX_FOOT_DRIFT + ? "normal" + : actionType === "jump" + ? "attention" + : "anomaly", + heightState: + heightDrift === null + ? "not_applicable" + : heightThreshold === null + ? heightDrift > MAX_HEIGHT_DRIFT + ? "attention" + : "normal" + : heightDrift > heightThreshold + ? "anomaly" + : "normal", + movementState: + maxStep === null || movementThreshold === null + ? "not_applicable" + : maxStep > movementThreshold + ? "anomaly" + : "normal", + areaState: + maxAreaDeltaPercent === null + ? "not_applicable" + : maxAreaDeltaPercent > MAX_AREA_DELTA_PERCENT + ? "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..ba75864 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx @@ -0,0 +1,205 @@ +/** @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, + qc: "pending", + rejected: false, + keyFrame: false, + })), + }; +} + +function geometry(x: number, y = 10): FrameGeometryResult { + const value: FrameGeometry = { + width: 256, + height: 256, + bounds: { left: x, top: 0, right: x + 9, bottom: 19, width: 10, height: 20 }, + centroid: { x, y }, + footY: 19, + subjectHeight: 20, + opaquePixels: 100, + coverageRatio: 100 / (256 * 256), + }; + return { status: "ready", geometry: value }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolvePromise: ((value: T) => void) | null = null; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + promise, + resolve(value) { + if (resolvePromise === null) throw new Error("deferred promise is not initialized"); + resolvePromise(value); + }, + }; +} + +afterEach(cleanup); + +describe("useFrameReviewEvidence", () => { + it("exposes loading before producing real sequence evidence", async () => { + // Catches the Inspector flashing fabricated zeros before image analysis completes. + const pending = deferred(); + const reader: ImageGeometryReader = () => pending.promise; + const { result } = renderHook(() => + useFrameReviewEvidence(sequence("/frame-1.png"), "walk", reader), + ); + + expect(result.current).toEqual({ status: "loading", evidence: null }); + + await act(async () => pending.resolve(geometry(4))); + + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(4); + }); + + it("ignores a previous sequence result that resolves after a direction switch", async () => { + // Catches a slow old direction replacing the evidence for the currently selected direction. + const south = deferred(); + const north = deferred(); + const reader: ImageGeometryReader = (imageUrl) => + imageUrl.includes("south") ? south.promise : north.promise; + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, "walk", reader), + { initialProps: { current: sequence("/south.png") } }, + ); + + rerender({ current: sequence("/north.png") }); + await act(async () => north.resolve(geometry(20))); + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20)); + + await act(async () => south.resolve(geometry(2))); + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20); + }); + + it("passes an abort signal to image reads and aborts stale sequence work", () => { + const pending = deferred(); + const reader = vi.fn(() => pending.promise); + const { rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, "walk", reader), + { initialProps: { current: sequence("/south.png") } }, + ); + + const firstSignal = reader.mock.calls[0]?.[1]; + expect(firstSignal).toBeInstanceOf(AbortSignal); + expect(firstSignal?.aborted).toBe(false); + + rerender({ current: sequence("/north.png") }); + + expect(firstSignal?.aborted).toBe(true); + expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal); + }); + + it("reuses settled image results when frames are selected or revisited", async () => { + // Catches frame navigation repeatedly decoding every image in the same review session. + const reader = vi.fn(async (imageUrl) => + geometry(imageUrl.includes("one") ? 1 : 2), + ); + const first = sequence("/one.png"); + const second = sequence("/two.png"); + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, "walk", reader), + { initialProps: { current: first } }, + ); + + await waitFor(() => expect(result.current.status).toBe("ready")); + rerender({ current: first }); + expect(reader).toHaveBeenCalledTimes(1); + + rerender({ current: second }); + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)); + rerender({ current: first }); + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(1)); + expect(reader).toHaveBeenCalledTimes(2); + }); + + it("retries an unavailable image when its sequence is revisited", async () => { + // A transient image failure must not become a permanent session-level cache entry. + let failedOnce = false; + const reader = vi.fn(async (imageUrl) => { + if (imageUrl.includes("retry") && !failedOnce) { + failedOnce = true; + return { status: "unavailable", reason: "temporary failure" }; + } + return geometry(imageUrl.includes("retry") ? 9 : 2); + }); + const retrySequence = sequence("/retry.png"); + const otherSequence = sequence("/other.png"); + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, "walk", reader), + { initialProps: { current: retrySequence } }, + ); + + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.evidence?.complete).toBe(false); + + rerender({ current: otherSequence }); + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)); + rerender({ current: retrySequence }); + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(9)); + + expect(reader).toHaveBeenCalledTimes(3); + }); + + it("stays idle without a review sequence and does not read an image", () => { + // Catches direct-control mode starting hidden Canvas work. + const reader = vi.fn(); + const { result } = renderHook(() => useFrameReviewEvidence(null, null, reader)); + + expect(result.current).toEqual({ status: "idle", evidence: null }); + expect(reader).not.toHaveBeenCalled(); + }); + + it("does not update React state after the consumer unmounts", async () => { + // Catches an image completion writing into a removed Playtest workbench. + const pending = deferred(); + const reader: ImageGeometryReader = () => pending.promise; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const mounted = renderHook(() => useFrameReviewEvidence(sequence("/slow.png"), "walk", reader)); + + mounted.unmount(); + await act(async () => pending.resolve(geometry(1))); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + it("keeps each preview frame root motion beside its measured geometry", async () => { + // Catches asynchronous image results losing their matching motion contract before aggregation. + const base = sequence("/first.png", "/second.png"); + const motionSequence: PreviewSequence = { + ...base, + frames: [ + { ...base.frames[0]!, rootMotion: null }, + { ...base.frames[1]!, rootMotion: { dx: 10, dy: 6 } }, + ], + }; + const reader: ImageGeometryReader = async (imageUrl) => + imageUrl.includes("first") ? geometry(0, 10) : geometry(3, 14); + const { result } = renderHook(() => useFrameReviewEvidence(motionSequence, "walk", reader)); + + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.evidence?.frames[1]).toMatchObject({ + expectedRootDelta: { dx: 10, dy: 6 }, + composedPreviewDelta: { dx: 13, dy: 2 }, + }); + }); +}); diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts new file mode 100644 index 0000000..07bded4 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; + +import type { PlaytestActionType, PreviewSequence } from "../model/types"; +import { readImageGeometry } from "./image-geometry"; +import { + buildSequenceEvidence, + type FrameGeometryResult, + type SequenceReviewEvidence, +} from "./sequence-evidence"; + +export type FrameReviewEvidenceState = + | { status: "idle"; evidence: null } + | { status: "loading"; evidence: null } + | { status: "ready"; evidence: SequenceReviewEvidence }; + +export type ImageGeometryReader = ( + imageUrl: string, + signal?: AbortSignal, +) => Promise; + +interface CachedReads { + reader: ImageGeometryReader; + entries: Map; +} + +interface ResolvedState { + key: string | null; + value: FrameReviewEvidenceState; +} + +const IDLE_STATE: FrameReviewEvidenceState = { status: "idle", evidence: null }; +const LOADING_STATE: FrameReviewEvidenceState = { status: "loading", evidence: null }; + +export function useFrameReviewEvidence( + sequence: PreviewSequence | null, + actionType: PlaytestActionType | null, + reader: ImageGeometryReader = readImageGeometry, +): FrameReviewEvidenceState { + const frameDescriptors = + sequence?.frames.map((frame) => ({ imageUrl: frame.imageUrl, rootMotion: frame.rootMotion })) ?? + []; + const imageUrls = frameDescriptors.map((frame) => frame.imageUrl); + const sequenceKey = + sequence === null || actionType === null + ? null + : JSON.stringify([actionType, ...frameDescriptors]); + const cache = useRef(null); + const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE }); + + if (cache.current === null || cache.current.reader !== reader) { + cache.current = { reader, entries: new Map() }; + } + + useEffect(() => { + if (sequenceKey === null || actionType === null) { + setResolved({ key: null, value: IDLE_STATE }); + return; + } + + const controller = new AbortController(); + let active = true; + const reads = cache.current?.entries; + const inFlight = new Map>(); + + setResolved({ key: sequenceKey, value: LOADING_STATE }); + + const results = imageUrls.map((imageUrl) => { + const cached = reads?.get(imageUrl); + if (cached !== undefined) return Promise.resolve(cached); + + const existing = inFlight.get(imageUrl); + if (existing !== undefined) return existing; + + const pending = reader(imageUrl, controller.signal) + .catch((): FrameGeometryResult => ({ status: "unavailable", reason: "图片分析失败" })) + .then((result) => { + if (!controller.signal.aborted && result.status === "ready") reads?.set(imageUrl, result); + return result; + }); + inFlight.set(imageUrl, pending); + return pending; + }); + + void Promise.all(results).then((frameResults) => { + if (!active || controller.signal.aborted) return; + + setResolved({ + key: sequenceKey, + value: { + status: "ready", + evidence: buildSequenceEvidence( + frameResults.map((geometry, index) => ({ + geometry, + rootMotion: frameDescriptors[index]?.rootMotion ?? null, + })), + actionType, + ), + }, + }); + }); + + return () => { + active = false; + controller.abort(); + }; + }, [actionType, reader, sequenceKey]); + + if (sequenceKey === null) return IDLE_STATE; + return resolved.key === sequenceKey ? resolved.value : LOADING_STATE; +} diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx new file mode 100644 index 0000000..d0aa07c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx @@ -0,0 +1,177 @@ +/** @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 }, + qc: "passed", + rejected: false, + keyFrame: true, +}; + +const nextFrame: PreviewFrame = { + ...currentFrame, + imageUrl: "https://cdn.example.test/next.png", + qc: "failed", + rejected: true, + keyFrame: false, +}; + +const sequence: PreviewSequence = { + direction: "south", + frames: [currentFrame, nextFrame], +}; + +const actions: readonly PreviewAction[] = [ + { + id: "walk", + name: "行走", + type: "walk", + status: "confirmed", + fps: 12, + sequences: [sequence], + }, + { + id: "empty", + name: "空动作", + type: "idle", + status: "candidate", + fps: 12, + sequences: [], + }, +]; + +afterEach(cleanup); + +describe("playtest visual primitives", () => { + it("renders prop-supplied action names, statuses 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("confirmed"); + 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 frame markers and delegates timeline selection without its own playback behavior", () => { + // Catches a timeline dropping QC/rejection annotations or mutating playback rather than using its selection callback. + const onSelectFrame = vi.fn(); + + render( + , + ); + + expect(screen.getByText("关键帧")).toBeTruthy(); + expect(screen.getByText("QC: failed")).toBeTruthy(); + expect(screen.getByText("已驳回")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "第 2 帧" })); + expect(onSelectFrame).toHaveBeenCalledWith(1); + }); + + it("only relays playback controller callbacks", () => { + // Catches controls owning local play state instead of reporting user intent to the controller. + const onTogglePlaying = vi.fn(); + const onNextFrame = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "播放" })); + fireEvent.click(screen.getByRole("button", { name: "下一帧" })); + expect(onTogglePlaying).toHaveBeenCalledTimes(1); + expect(onNextFrame).toHaveBeenCalledTimes(1); + expect(screen.getByText("A 左行走")).toBeTruthy(); + expect(screen.getByText("D 右行走")).toBeTruthy(); + expect(screen.getByText("未提供跳跃动作")).toBeTruthy(); + expect(screen.getByText("下蹲动作可用")).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx new file mode 100644 index 0000000..e4203f8 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { PreviewFrame } from "./model/types"; +import type { HorizontalStageBounds, StageOffset } from "./stage-motion"; + +export interface AnimationStageProps { + currentFrame: PreviewFrame | null; + /** Accumulated playback position in world coordinates (positive y is up). */ + motionOffset: StageOffset; + mirrored: boolean; + showGrid: boolean; + showChecker: boolean; + onHorizontalBoundsChange?(bounds: HorizontalStageBounds | null): void; +} + +export function AnimationStage({ + currentFrame, + motionOffset, + mirrored, + showGrid, + showChecker, + onHorizontalBoundsChange, +}: AnimationStageProps) { + const [failedImageUrl, setFailedImageUrl] = useState(null); + const stageRef = useRef(null); + const imageRef = useRef(null); + + const reportHorizontalBounds = useCallback(() => { + if (onHorizontalBoundsChange === undefined) return; + const stage = stageRef.current; + const image = imageRef.current; + if (stage === null || image === null) { + onHorizontalBoundsChange(null); + return; + } + + const stageWidth = stage.getBoundingClientRect().width; + const actorWidth = image.getBoundingClientRect().width; + if (stageWidth <= 0 || actorWidth <= 0) { + onHorizontalBoundsChange(null); + return; + } + const travel = Math.max(0, (stageWidth - actorWidth) / 2); + onHorizontalBoundsChange({ minX: -travel, maxX: travel }); + }, [onHorizontalBoundsChange]); + + useEffect(() => { + setFailedImageUrl(null); + }, [currentFrame?.imageUrl]); + + 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..9180f2c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx @@ -0,0 +1,108 @@ +/** @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 }, + qc: "pending", + rejected: false, + 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..6c93617 --- /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}`} +

+ +