+
+
{
+ if (!event.currentTarget.contains(event.relatedTarget)) setAssetMenuOpen(false)
+ }}
+ >
+
+
+ {canSwitchAsset ? '角色 / 造型' : '当前角色'}
+
+ {canSwitchAsset ? (
+ {assets.length} ITEMS
+ ) : null}
+
+ {canSwitchAsset ? (
+
+ ) : (
+
+ )}
+ {canSwitchAsset && assetMenuOpen ? (
+
+ ) : null}
+
+
+
+
动作
+
+ {actions.length} TOTAL
+ {onAddAction ? (
+
+ ) : null}
+
+
+
{actions.map((action) => {
const count = frameCount(action)
const selected = action.id === selectedActionId
@@ -29,23 +191,33 @@ export function ActionSelector({ actions, selectedActionId, onSelectAction }: Ac
aria-pressed={selected}
disabled={count === 0}
onClick={() => onSelectAction(action.id)}
- className={`flex w-full items-center justify-between rounded-lg border px-3 py-3 text-left transition-colors ${
+ className={`grid min-h-12 w-36 shrink-0 grid-cols-[4px_minmax(0,1fr)_auto] items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors lg:w-full ${
selected
- ? 'border-white/25 bg-white/12 text-white'
- : 'border-transparent text-white/65 hover:bg-white/5 hover:text-white'
+ ? 'border-white/12 bg-white/9 text-white'
+ : 'border-transparent text-white/60 hover:bg-white/5 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
-
- {action.name}
-
+
+
+ {action.name}
+
{action.fps} FPS
- {count} 帧
+
+ {count} 帧
+
)
})}
-
+
)
}
diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts
index a1d7089..e9bc8f3 100644
--- a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts
@@ -45,6 +45,7 @@ describe('measureFrameGeometry', () => {
opaquePixels: 4,
coverageRatio: 0.25,
fingerprint: expect.any(Array),
+ contentHash: expect.any(String),
})
expect(geometry?.fingerprint).toHaveLength(64)
})
diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts
index 5338300..9fff8c3 100644
--- a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts
@@ -26,6 +26,17 @@ export interface FrameGeometry {
coverageRatio: number
/** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */
fingerprint?: readonly number[]
+ /** Exact RGBA content hash used to identify genuinely duplicated frames. */
+ contentHash?: string
+}
+
+function hashPixels(data: Uint8ClampedArray): string {
+ let hash = 0x811c9dc5
+ for (const value of data) {
+ hash ^= value
+ hash = Math.imul(hash, 0x01000193)
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0')
}
function createFingerprint(
@@ -181,5 +192,6 @@ export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | nu
width: subjectWidth,
height: subjectHeight,
}),
+ contentHash: hashPixels(data),
}
}
diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts
index 507435a..6b4f2ae 100644
--- a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts
@@ -84,6 +84,7 @@ describe('readImageGeometry', () => {
opaquePixels: 1,
coverageRatio: 0.25,
fingerprint: expect.any(Array),
+ contentHash: expect.any(String),
},
})
})
diff --git a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts
index 37d85a0..4aa8c45 100644
--- a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts
@@ -11,8 +11,6 @@ export interface LocalQualityPolicy {
edgeMargin: { x: number; y: number }
minimumCoverageRatio: number
maximumCoverageRatio: number
- /** 相邻帧指纹平均距离 ≤ 该值时判定为重复帧(duplicate_frame)。 */
- duplicateDistance: number
footDriftThreshold: number | null
heightDriftThreshold: number | null
heightAttentionThreshold: number | null
@@ -26,7 +24,6 @@ export interface LocalQualityPolicy {
const REFERENCE_CANVAS_SIZE = 256
const MINIMUM_COVERAGE_RATIO = 0.005
const MAXIMUM_COVERAGE_RATIO = 0.65
-const DUPLICATE_DISTANCE = 0.02
const AREA_DELTA_THRESHOLD_PERCENT = 28
function scaledPixels(referencePixels: number, scale: number): number {
@@ -80,16 +77,15 @@ function movementCeilingReferencePixels(actionType: PlaytestActionType): number
export function deriveLocalQualityPolicy(
geometries: readonly FrameGeometry[],
actionType: PlaytestActionType,
+ expectedCanvasOverride: CanvasBaseline | null = null,
): LocalQualityPolicy {
- const expectedCanvas = inferCanvasBaseline(geometries)
+ const expectedCanvas = expectedCanvasOverride ?? inferCanvasBaseline(geometries)
if (expectedCanvas === null) {
return {
expectedCanvas: null,
edgeMargin: { x: 1, y: 1 },
minimumCoverageRatio: MINIMUM_COVERAGE_RATIO,
maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO,
- duplicateDistance: DUPLICATE_DISTANCE,
-
footDriftThreshold: null,
heightDriftThreshold: null,
heightAttentionThreshold: null,
@@ -114,7 +110,6 @@ export function deriveLocalQualityPolicy(
},
minimumCoverageRatio: MINIMUM_COVERAGE_RATIO,
maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO,
- duplicateDistance: DUPLICATE_DISTANCE,
footDriftThreshold: scaledPixels(3, verticalScale),
heightDriftThreshold:
heightReference === null ? null : scaledPixels(heightReference, verticalScale),
diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts
index 76608ea..bf7697e 100644
--- a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts
@@ -9,7 +9,13 @@ function geometry(
FrameGeometry,
'width' | 'height' | 'footY' | 'subjectHeight' | 'opaquePixels' | 'coverageRatio'
>
- > & { x?: number; y?: number; fingerprint?: readonly number[]; cropped?: boolean } = {},
+ > & {
+ x?: number
+ y?: number
+ fingerprint?: readonly number[]
+ contentHash?: string
+ cropped?: boolean
+ } = {},
): FrameGeometry {
const subjectHeight = overrides.subjectHeight ?? 20
@@ -30,6 +36,7 @@ function geometry(
opaquePixels: overrides.opaquePixels ?? 100,
coverageRatio: overrides.coverageRatio ?? 0.25,
fingerprint: overrides.fingerprint,
+ contentHash: overrides.contentHash,
}
}
@@ -42,11 +49,11 @@ function ready(
describe('buildSequenceEvidence', () => {
it('returns structured findings for incomplete, cropped and duplicate frames', () => {
- const fingerprint = Array.from({ length: 64 }, (_, index) => index / 64)
+ const contentHash = 'same-frame'
const evidence = buildSequenceEvidence(
[
- ready(geometry({ cropped: true, fingerprint })),
- ready(geometry({ fingerprint })),
+ ready(geometry({ cropped: true, contentHash })),
+ ready(geometry({ contentHash })),
{ geometry: { status: 'unavailable', reason: '图片没有可见主体' }, rootMotion: null },
],
'walk',
@@ -189,7 +196,7 @@ describe('buildSequenceEvidence', () => {
ready(geometry({ width: 512, height: 512, x: 4 })),
ready(geometry({ width: 512, height: 512, x: 14 })),
],
- 'walk',
+ 'attack',
)
expect(evidence.summary).toMatchObject({
@@ -353,4 +360,48 @@ describe('buildSequenceEvidence', () => {
expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 })
expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 })
})
+
+ it('does not mislabel merely similar adjacent frames as duplicates', () => {
+ const fingerprint = Array.from({ length: 64 }, () => 0.5)
+ const evidence = buildSequenceEvidence(
+ [
+ ready(geometry({ fingerprint, contentHash: 'frame-a' })),
+ ready(geometry({ fingerprint, contentHash: 'frame-b' })),
+ ],
+ 'attack',
+ )
+
+ expect(evidence.findings.map((finding) => finding.code)).not.toContain('duplicate_frame')
+ })
+
+ it('checks a loop boundary against the ordinary adjacent-frame baseline', () => {
+ const evidence = buildSequenceEvidence(
+ [ready(geometry({ x: 0 })), ready(geometry({ x: 20 })), ready(geometry({ x: 40 }))],
+ 'walk',
+ )
+
+ expect(evidence.summary.movementThreshold).toBe(24)
+ expect(evidence.findings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'motion_spike',
+ frameIndex: 0,
+ message: '循环首尾出现异常位移突变',
+ }),
+ ]),
+ )
+ })
+
+ it('uses the project canvas contract instead of accepting a consistently wrong sequence', () => {
+ const evidence = buildSequenceEvidence(
+ [ready(geometry({ width: 256, height: 256 })), ready(geometry({ width: 256, height: 256 }))],
+ 'idle',
+ { width: 512, height: 512 },
+ )
+
+ expect(evidence.summary.expectedCanvas).toEqual({ width: 512, height: 512 })
+ expect(
+ evidence.findings.filter((finding) => finding.code === 'canvas_size_mismatch'),
+ ).toHaveLength(2)
+ })
})
diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts
index 8fbbfb8..ce6a5d9 100644
--- a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts
@@ -91,23 +91,8 @@ function medianAbsoluteDeviation(values: readonly number[], center: number | nul
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 framesAreIdentical(left: FrameGeometry, right: FrameGeometry): boolean {
+ return left.contentHash !== undefined && left.contentHash === right.contentHash
}
function isCropped(geometry: FrameGeometry, margin: { x: number; y: number }): boolean {
@@ -164,12 +149,13 @@ function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } {
export function buildSequenceEvidence(
inputs: readonly FrameEvidenceInput[],
actionType: PlaytestActionType,
+ expectedCanvas: CanvasBaseline | null = null,
): SequenceReviewEvidence {
const results = inputs.map((input) => input.geometry)
const readyGeometries = results.flatMap((result) =>
result.status === 'ready' ? [result.geometry] : [],
)
- const policy = deriveLocalQualityPolicy(readyGeometries, actionType)
+ const policy = deriveLocalQualityPolicy(readyGeometries, actionType, expectedCanvas)
const isBaselineGeometry = (geometry: FrameGeometry): boolean =>
policy.expectedCanvas !== null &&
geometry.width === policy.expectedCanvas.width &&
@@ -180,6 +166,19 @@ export function buildSequenceEvidence(
if (!isBaselineGeometry(previous.geometry) || !isBaselineGeometry(result.geometry)) return null
return adjacentDelta(previous.geometry, result.geometry)
})
+ const checksLoopBoundary = actionType === 'idle' || actionType === 'walk'
+ const firstResult = results[0]
+ const lastResult = results.at(-1)
+ const closingDelta =
+ checksLoopBoundary &&
+ results.length > 1 &&
+ results.every((result) => result.status === 'ready') &&
+ firstResult?.status === 'ready' &&
+ lastResult?.status === 'ready' &&
+ isBaselineGeometry(firstResult.geometry) &&
+ isBaselineGeometry(lastResult.geometry)
+ ? adjacentDelta(lastResult.geometry, firstResult.geometry)
+ : null
const rootDeltas = inputs.map((input, index): MotionVector | null => {
if (index === 0) return null
@@ -331,17 +330,13 @@ export function buildSequenceEvidence(
const previous = results[index - 1]
if (previous?.status !== 'ready') return
- const duplicateDistance = fingerprintDistance(
- previous.geometry.fingerprint,
- geometry.fingerprint,
- )
- if (duplicateDistance !== null && duplicateDistance <= policy.duplicateDistance) {
+ if (framesAreIdentical(previous.geometry, geometry)) {
findings.push({
code: 'duplicate_frame',
severity: 'warning',
frameIndex: index,
- message: '当前帧与上一帧高度相似',
- metrics: { distance: duplicateDistance },
+ message: '当前帧与上一帧完全相同',
+ metrics: {},
})
}
@@ -385,6 +380,36 @@ export function buildSequenceEvidence(
}
})
+ if (closingDelta !== null && firstResult?.status === 'ready' && lastResult?.status === 'ready') {
+ if (framesAreIdentical(lastResult.geometry, firstResult.geometry)) {
+ findings.push({
+ code: 'duplicate_frame',
+ severity: 'warning',
+ frameIndex: 0,
+ message: '循环首帧与尾帧完全相同,可能产生停顿',
+ metrics: {},
+ })
+ }
+ if (movementThreshold !== null && closingDelta.distance > movementThreshold) {
+ findings.push({
+ code: 'motion_spike',
+ severity: 'error',
+ frameIndex: 0,
+ message: '循环首尾出现异常位移突变',
+ metrics: { distance: closingDelta.distance, threshold: movementThreshold },
+ })
+ }
+ if (closingDelta.areaDeltaPercent > policy.areaDeltaThresholdPercent) {
+ findings.push({
+ code: 'area_spike',
+ severity: 'warning',
+ frameIndex: 0,
+ message: '循环首尾轮廓面积变化过大',
+ metrics: { percent: closingDelta.areaDeltaPercent },
+ })
+ }
+ }
+
if (
footDrift !== null &&
policy.footDriftThreshold !== null &&
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
index c259088..8edf422 100644
--- 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
@@ -107,7 +107,7 @@ describe('useFrameReviewEvidence', () => {
expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal)
})
- it('reuses settled image results when frames are selected or revisited', async () => {
+ it('rereads revisited image URLs so regenerated content cannot reuse stale geometry', async () => {
// Catches frame navigation repeatedly decoding every image in the same review session.
const reader = vi.fn
(async (imageUrl) =>
geometry(imageUrl.includes('one') ? 1 : 2),
@@ -127,7 +127,7 @@ describe('useFrameReviewEvidence', () => {
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)
+ expect(reader).toHaveBeenCalledTimes(3)
})
it('retries an unavailable image when its sequence is revisited', async () => {
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
index ad70061..b57fbe8 100644
--- a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts
+++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts
@@ -1,7 +1,8 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useState } from 'react'
import type { PlaytestActionType, PreviewFrame, PreviewSequence } from '../model/types'
import { readImageGeometry } from './image-geometry'
+import type { CanvasBaseline } from './quality-policy'
import {
buildSequenceEvidence,
type FrameGeometryResult,
@@ -18,11 +19,6 @@ export type ImageGeometryReader = (
signal?: AbortSignal,
) => Promise
-interface CachedReads {
- reader: ImageGeometryReader
- entries: Map
-}
-
interface ResolvedState {
key: string | null
value: FrameReviewEvidenceState
@@ -35,56 +31,47 @@ export function useFrameReviewEvidence(
sequence: PreviewSequence | null,
actionType: PlaytestActionType | null,
reader: ImageGeometryReader = readImageGeometry,
+ expectedCanvas: CanvasBaseline | null = null,
): FrameReviewEvidenceState {
const sequenceKey =
sequence === null || actionType === null
? null
: JSON.stringify([
actionType,
+ expectedCanvas,
...sequence.frames.map((frame) => ({
imageUrl: frame.imageUrl,
rootMotion: frame.rootMotion,
})),
])
- const cache = useRef(null)
const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE })
- if (cache.current === null || cache.current.reader !== reader) {
- cache.current = { reader, entries: new Map() }
- }
-
useEffect(() => {
if (sequenceKey === null || actionType === null) {
setResolved({ key: null, value: IDLE_STATE })
return
}
- const [, ...frameDescriptors] = JSON.parse(sequenceKey) as [
+ const [, , ...frameDescriptors] = JSON.parse(sequenceKey) as [
PlaytestActionType,
+ CanvasBaseline | null,
...Array<{ imageUrl: string; rootMotion: PreviewFrame['rootMotion'] }>,
]
const imageUrls = frameDescriptors.map((frame) => frame.imageUrl)
const controller = new AbortController()
let active = true
- const 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
- })
+ const pending = reader(imageUrl, controller.signal).catch(
+ (): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' }),
+ )
inFlight.set(imageUrl, pending)
return pending
})
@@ -102,6 +89,7 @@ export function useFrameReviewEvidence(
rootMotion: frameDescriptors[index]?.rootMotion ?? null,
})),
actionType,
+ expectedCanvas,
),
},
})
@@ -111,7 +99,7 @@ export function useFrameReviewEvidence(
active = false
controller.abort()
}
- }, [actionType, reader, sequenceKey])
+ }, [actionType, expectedCanvas, reader, sequenceKey])
if (sequenceKey === null) return IDLE_STATE
return resolved.key === sequenceKey ? resolved.value : LOADING_STATE
diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx
index 3636cb9..b3d6d80 100644
--- a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx
+++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx
@@ -51,7 +51,22 @@ describe('playtest visual primitives', () => {
const onSelectAction = vi.fn()
render(
- ,
+ ,
)
expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('12 FPS')
@@ -161,9 +176,9 @@ describe('playtest visual primitives', () => {
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()
+ expect(screen.getByText('跳跃不可用,下蹲可用')).toBeTruthy()
+ expect(screen.getByRole('button', { name: '循环播放' }).getAttribute('aria-pressed')).toBe(
+ 'true',
+ )
})
})
diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx
index d3bca47..81b2d92 100644
--- a/frontend/src/pages/playtest/workbench/animation-stage.tsx
+++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx
@@ -102,7 +102,7 @@ export function AnimationStage({
+