+
+ QUALITY ISSUES
+ 问题记录
+ 仅保留在当前 Playtest 会话
+
+
+
+
自动发现
+ {automaticFindings.length === 0 ? (
+
当前序列没有自动问题
+ ) : (
+
+ )}
+
+
+
+
+ 标记当前帧{actionName === null ? '' : ` · ${actionName} #${frameIndex + 1}`}
+
+
+
+
+
+
+
+
人工记录
+ {issues.length === 0 ? (
+
尚未标记人工问题
+ ) : (
+ issues.map((issue) => (
+
+
+ 人工
+
+ {issue.actionId} · {issue.direction} · 第 {issue.frameIndex + 1} 帧
+
+
+
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts b/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts
new file mode 100644
index 0000000..61198a0
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+
+import { reduceAuditSession, type ManualAuditIssue } from './audit-session'
+
+const issue: ManualAuditIssue = {
+ id: 'issue-1',
+ category: 'subject_cropped',
+ actionId: 'walk',
+ direction: 'south',
+ frameIndex: 2,
+ imageUrl: '/walk-03.png',
+ note: '右侧被裁切',
+}
+
+describe('reduceAuditSession', () => {
+ it('adds, updates and removes manual issues without mutating identity fields', () => {
+ const added = reduceAuditSession([], { type: 'add', issue })
+ const updated = reduceAuditSession(added, {
+ type: 'update',
+ id: issue.id,
+ category: 'style_inconsistent',
+ note: '衣服颜色跳变',
+ })
+ const removed = reduceAuditSession(updated, { type: 'remove', id: issue.id })
+
+ expect(added).toEqual([issue])
+ expect(updated[0]).toEqual({
+ ...issue,
+ category: 'style_inconsistent',
+ note: '衣服颜色跳变',
+ })
+ expect(updated[0]).toMatchObject({
+ actionId: 'walk',
+ direction: 'south',
+ frameIndex: 2,
+ imageUrl: '/walk-03.png',
+ })
+ expect(removed).toEqual([])
+ expect(added).not.toBe(updated)
+ })
+
+ it('ignores updates and removals for unknown issue ids', () => {
+ expect(
+ reduceAuditSession([issue], {
+ type: 'update',
+ id: 'missing',
+ category: 'other',
+ note: '无效更新',
+ }),
+ ).toEqual([issue])
+ expect(reduceAuditSession([issue], { type: 'remove', id: 'missing' })).toEqual([issue])
+ })
+})
diff --git a/frontend/src/pages/playtest/workbench/audit/audit-session.ts b/frontend/src/pages/playtest/workbench/audit/audit-session.ts
new file mode 100644
index 0000000..fd0eab5
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/audit/audit-session.ts
@@ -0,0 +1,41 @@
+import type { PlaytestDirection } from '../model/types'
+
+export type ManualIssueCategory =
+ | 'subject_cropped'
+ | 'transparency'
+ | 'image_unavailable'
+ | 'duplicate_frame'
+ | 'motion_discontinuity'
+ | 'motion_direction'
+ | 'style_inconsistent'
+ | 'other'
+
+export interface ManualAuditIssue {
+ id: string
+ category: ManualIssueCategory
+ actionId: string
+ direction: PlaytestDirection
+ frameIndex: number
+ imageUrl: string
+ note: string
+}
+
+export type AuditSessionAction =
+ | { type: 'add'; issue: ManualAuditIssue }
+ | { type: 'update'; id: string; category: ManualIssueCategory; note: string }
+ | { type: 'remove'; id: string }
+
+export function reduceAuditSession(
+ state: readonly ManualAuditIssue[],
+ action: AuditSessionAction,
+): readonly ManualAuditIssue[] {
+ if (action.type === 'add') return [...state, action.issue]
+ if (action.type === 'remove') {
+ if (!state.some((issue) => issue.id === action.id)) return state
+ return state.filter((issue) => issue.id !== action.id)
+ }
+ if (!state.some((issue) => issue.id === action.id)) return state
+ return state.map((issue) =>
+ issue.id === action.id ? { ...issue, category: action.category, note: action.note } : issue,
+ )
+}
diff --git a/frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx b/frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx
new file mode 100644
index 0000000..7da795b
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx
@@ -0,0 +1,244 @@
+/** @vitest-environment jsdom */
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import type { FrameReviewEvidenceState } from './analysis/use-frame-review-evidence'
+import { FrameReviewEvidencePanel } from './frame-review-evidence'
+
+const readyState: FrameReviewEvidenceState = {
+ status: 'ready',
+ evidence: {
+ complete: true,
+ unavailableFrameCount: 0,
+ findings: [],
+ frames: [
+ {
+ geometry: {
+ width: 256,
+ height: 256,
+ bounds: { left: 80, top: 30, right: 175, bottom: 236, width: 96, height: 207 },
+ centroid: { x: 127, y: 140 },
+ footY: 236,
+ subjectHeight: 207,
+ opaquePixels: 12_000,
+ coverageRatio: 0.1831,
+ },
+ unavailableReason: null,
+ previousDelta: null,
+ expectedRootDelta: null,
+ composedPreviewDelta: null,
+ canvasState: 'normal',
+ coverageState: 'normal',
+ movementState: 'not_applicable',
+ areaState: 'not_applicable',
+ },
+ {
+ geometry: {
+ width: 256,
+ height: 256,
+ bounds: { left: 83, top: 31, right: 174, bottom: 237, width: 92, height: 207 },
+ centroid: { x: 130, y: 144 },
+ footY: 237,
+ subjectHeight: 207,
+ opaquePixels: 9_600,
+ coverageRatio: 0.1465,
+ },
+ unavailableReason: null,
+ previousDelta: { dx: 3, dy: 4, distance: 5, areaDeltaPercent: 20 },
+ expectedRootDelta: { dx: 10, dy: 6, distance: Math.sqrt(136) },
+ composedPreviewDelta: { dx: 13, dy: 2, distance: Math.sqrt(173) },
+ canvasState: 'normal',
+ coverageState: 'normal',
+ movementState: 'normal',
+ areaState: 'normal',
+ },
+ ],
+ summary: {
+ footDrift: 1,
+ heightDrift: 0,
+ medianStep: 5,
+ maxStep: 5,
+ movementThreshold: 15,
+ heightThreshold: 12,
+ footThreshold: 3,
+ areaThresholdPercent: 28,
+ expectedCanvas: { width: 256, height: 256 },
+ maxAreaDeltaPercent: 20,
+ canvasState: 'normal',
+ footState: 'normal',
+ heightState: 'normal',
+ movementState: 'normal',
+ areaState: 'normal',
+ },
+ },
+}
+
+afterEach(cleanup)
+
+describe('FrameReviewEvidencePanel', () => {
+ it('keeps analysis loading distinct from measured zero values', () => {
+ // Catches the right panel presenting fabricated zero evidence before image decoding completes.
+ render(
+