+
{
+ if (!event.currentTarget.contains(event.relatedTarget)) setAssetMenuOpen(false)
+ }}
+ >
+
+
+ {canSwitchAsset ? '角色 / 造型' : '当前角色'}
+
+ {canSwitchAsset ? (
+ {assets.length} ITEMS
+ ) : null}
+
+ {canSwitchAsset ? (
+
+ ) : (
+
+ )}
+ {canSwitchAsset && assetMenuOpen ? (
+
+ ) : null}
+ {selectedAsset && (onRenameAsset || onDeleteAsset) ? (
+
+ {onRenameAsset ? (
+
+ ) : null}
+ {onDeleteAsset ? (
+
+ ) : null}
+
+ ) : null}
+
+ {renameTarget ? (
+
+ ) : null}
+ {deleteTarget ? (
+
+
+ 确认删除“{deleteTarget.value}”?此操作会写入后端。
+
+
+
+
+
+
+ ) : null}
+ {operationError ? (
+
+ {operationError}
+
+ ) : null}
+
+
+
动作
+
+ {actions.length} TOTAL
+ {onAddAction ? (
+
+ ) : null}
+
+
+
+ {actions.map((action) => {
+ const count = frameCount(action)
+ const selected = action.id === selectedActionId
+
+ return (
+
+
+ {onRenameAction || onDeleteAction ? (
+
+ {onRenameAction ? (
+
+ ) : null}
+ {onDeleteAction ? (
+
+ ) : null}
+
+ ) : null}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts
new file mode 100644
index 0000000..e9bc8f3
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from 'vitest'
+
+import { measureFrameGeometry, type FramePixelData } from './frame-geometry'
+
+function createPixels(
+ width: number,
+ height: number,
+ visible: readonly { x: number; y: number; alpha: number }[],
+): FramePixelData {
+ const data = new Uint8ClampedArray(width * height * 4)
+
+ for (const pixel of visible) data[(pixel.y * width + pixel.x) * 4 + 3] = pixel.alpha
+
+ return { data, width, height }
+}
+
+describe('measureFrameGeometry', () => {
+ it('treats only alpha values greater than 24 as visible', () => {
+ // Catches the review algorithm including matte noise at the old Alpha cutoff.
+ expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 24 }]))).toBeNull()
+ expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 25 }]))).toMatchObject({
+ opaquePixels: 1,
+ coverageRatio: 1,
+ })
+ })
+
+ it('measures bounds, centroid, foot line, height, area and coverage from visible pixels', () => {
+ // Catches off-by-one bounds or a centroid derived from the box instead of real visible pixels.
+ const geometry = measureFrameGeometry(
+ createPixels(4, 4, [
+ { x: 1, y: 1, alpha: 255 },
+ { x: 2, y: 1, alpha: 255 },
+ { x: 1, y: 2, alpha: 255 },
+ { x: 2, y: 2, alpha: 255 },
+ ]),
+ )
+
+ expect(geometry).toEqual({
+ width: 4,
+ height: 4,
+ bounds: { left: 1, top: 1, right: 2, bottom: 2, width: 2, height: 2 },
+ centroid: { x: 1.5, y: 1.5 },
+ footY: 2,
+ subjectHeight: 2,
+ opaquePixels: 4,
+ coverageRatio: 0.25,
+ fingerprint: expect.any(Array),
+ contentHash: expect.any(String),
+ })
+ expect(geometry?.fingerprint).toHaveLength(64)
+ })
+
+ it('produces different compact fingerprints for different silhouettes with equal bounds', () => {
+ const leftTop = measureFrameGeometry(
+ createPixels(4, 4, [
+ { x: 0, y: 0, alpha: 255 },
+ { x: 1, y: 0, alpha: 255 },
+ { x: 2, y: 0, alpha: 255 },
+ { x: 3, y: 0, alpha: 255 },
+ { x: 3, y: 1, alpha: 255 },
+ { x: 3, y: 2, alpha: 255 },
+ { x: 3, y: 3, alpha: 255 },
+ ]),
+ )
+ const leftBottom = measureFrameGeometry(
+ createPixels(4, 4, [
+ { x: 0, y: 0, alpha: 255 },
+ { x: 0, y: 1, alpha: 255 },
+ { x: 0, y: 2, alpha: 255 },
+ { x: 0, y: 3, alpha: 255 },
+ { x: 1, y: 3, alpha: 255 },
+ { x: 2, y: 3, alpha: 255 },
+ { x: 3, y: 3, alpha: 255 },
+ ]),
+ )
+
+ expect(leftTop?.bounds).toEqual(leftBottom?.bounds)
+ expect(leftTop?.fingerprint).not.toEqual(leftBottom?.fingerprint)
+ })
+
+ it('keeps small dark silhouettes distinguishable instead of diluting them across the canvas', () => {
+ const topLeft: Array<{ x: number; y: number; alpha: number }> = []
+ const bottomRight: Array<{ x: number; y: number; alpha: number }> = []
+ for (let y = 96; y < 160; y += 1) {
+ for (let x = 96; x < 160; x += 1) {
+ if (y < 128 || x < 112) topLeft.push({ x, y, alpha: 255 })
+ if (y >= 128 || x >= 144) bottomRight.push({ x, y, alpha: 255 })
+ }
+ }
+ const first = measureFrameGeometry(createPixels(256, 256, topLeft))
+ const second = measureFrameGeometry(createPixels(256, 256, bottomRight))
+ const distance =
+ first?.fingerprint?.reduce(
+ (total, value, index) => total + Math.abs(value - (second?.fingerprint?.[index] ?? value)),
+ 0,
+ ) ?? 0
+
+ expect(first?.bounds).toEqual(second?.bounds)
+ expect(distance / 64).toBeGreaterThan(0.02)
+ })
+
+ it('rejects an RGBA buffer whose dimensions do not match its length', () => {
+ // Catches silent geometry corruption when Canvas data and dimensions diverge.
+ expect(() =>
+ measureFrameGeometry({ data: new Uint8ClampedArray(4), width: 2, height: 2 }),
+ ).toThrowError('RGBA 像素长度与画布尺寸不一致')
+ })
+
+ it('ignores a tiny isolated Alpha component outside the visible subject', () => {
+ // Catches one stray generated pixel moving the measured foot line and centroid.
+ const geometry = measureFrameGeometry(
+ createPixels(4, 4, [
+ { x: 0, y: 0, alpha: 255 },
+ { x: 1, y: 0, alpha: 255 },
+ { x: 0, y: 1, alpha: 255 },
+ { x: 1, y: 1, alpha: 255 },
+ { x: 3, y: 3, alpha: 255 },
+ ]),
+ )
+
+ expect(geometry).toMatchObject({
+ bounds: { left: 0, top: 0, right: 1, bottom: 1, width: 2, height: 2 },
+ centroid: { x: 0.5, y: 0.5 },
+ footY: 1,
+ opaquePixels: 4,
+ coverageRatio: 0.25,
+ })
+ })
+})
diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts
new file mode 100644
index 0000000..9fff8c3
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts
@@ -0,0 +1,197 @@
+export const ALPHA_THRESHOLD = 24
+const MIN_COMPONENT_PIXELS = 4
+const RELATIVE_COMPONENT_RATIO = 0.002
+
+export interface FramePixelData {
+ data: Uint8ClampedArray
+ width: number
+ height: number
+}
+
+export interface FrameGeometry {
+ width: number
+ height: number
+ bounds: {
+ left: number
+ top: number
+ right: number
+ bottom: number
+ width: number
+ height: number
+ }
+ centroid: { x: number; y: number }
+ footY: number
+ subjectHeight: number
+ opaquePixels: number
+ coverageRatio: number
+ /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */
+ fingerprint?: readonly number[]
+ /** Exact RGBA content hash used to identify genuinely duplicated frames. */
+ contentHash?: string
+}
+
+function hashPixels(data: Uint8ClampedArray): string {
+ let hash = 0x811c9dc5
+ for (const value of data) {
+ hash ^= value
+ hash = Math.imul(hash, 0x01000193)
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0')
+}
+
+function createFingerprint(
+ data: Uint8ClampedArray,
+ width: number,
+ subjectPixels: readonly number[],
+ bounds: { left: number; top: number; width: number; height: number },
+): readonly number[] {
+ const sums = new Float64Array(64)
+ const cellPixels = new Uint32Array(64)
+
+ for (const index of subjectPixels) {
+ const x = index % width
+ const y = Math.floor(index / width)
+ const offset = index * 4
+ const red = data[offset] ?? 0
+ const green = data[offset + 1] ?? 0
+ const blue = data[offset + 2] ?? 0
+ const alpha = (data[offset + 3] ?? 0) / 255
+ const luminance = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255
+ const cellX = Math.min(7, Math.floor(((x - bounds.left) * 8) / bounds.width))
+ const cellY = Math.min(7, Math.floor(((y - bounds.top) * 8) / bounds.height))
+ const cell = cellY * 8 + cellX
+ sums[cell] += alpha * (0.25 + luminance * 0.75)
+ cellPixels[cell] += 1
+ }
+
+ return Array.from(sums, (sum, index) => {
+ const count = cellPixels[index] ?? 0
+ return count === 0 ? 0 : Number((sum / count).toFixed(4))
+ })
+}
+
+interface VisibleComponentsResult {
+ components: number[][]
+ largestSize: number
+}
+
+function visibleComponents(
+ data: Uint8ClampedArray,
+ width: number,
+ height: number,
+): VisibleComponentsResult {
+ const pixelCount = width * height
+ const visible = new Uint8Array(pixelCount)
+ const visited = new Uint8Array(pixelCount)
+ const components: number[][] = []
+ const queue = new Int32Array(pixelCount)
+ let largestSize = 0
+
+ for (let index = 0; index < pixelCount; index += 1) {
+ const alpha = data[index * 4 + 3]
+ if (alpha !== undefined && alpha > ALPHA_THRESHOLD) visible[index] = 1
+ }
+
+ for (let start = 0; start < pixelCount; start += 1) {
+ if (visible[start] === 0 || visited[start] === 1) continue
+
+ const component: number[] = []
+ let head = 0
+ let tail = 0
+ queue[tail++] = start
+ visited[start] = 1
+
+ while (head < tail) {
+ const index = queue[head++]
+ component.push(index)
+
+ const x = index % width
+ const y = Math.floor(index / width)
+ for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
+ for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
+ if (offsetX === 0 && offsetY === 0) continue
+ const nextX = x + offsetX
+ const nextY = y + offsetY
+ if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) continue
+
+ const next = nextY * width + nextX
+ if (visible[next] === 0 || visited[next] === 1) continue
+ visited[next] = 1
+ queue[tail++] = next
+ }
+ }
+ }
+
+ if (component.length > largestSize) largestSize = component.length
+ components.push(component)
+ }
+
+ return { components, largestSize }
+}
+
+export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | null {
+ const { data, width, height } = pixels
+
+ if (data.length !== width * height * 4) {
+ throw new RangeError('RGBA 像素长度与画布尺寸不一致')
+ }
+
+ const { components, largestSize } = visibleComponents(data, width, height)
+ if (components.length === 0) return null
+
+ const minimumSize = Math.min(
+ largestSize,
+ Math.max(MIN_COMPONENT_PIXELS, Math.ceil(largestSize * RELATIVE_COMPONENT_RATIO)),
+ )
+ const subjectPixels = components.flatMap((component) =>
+ component.length === largestSize || component.length >= minimumSize ? component : [],
+ )
+
+ let left = width
+ let top = height
+ let right = -1
+ let bottom = -1
+ let opaquePixels = 0
+ let sumX = 0
+ let sumY = 0
+
+ for (const index of subjectPixels) {
+ const x = index % width
+ const y = Math.floor(index / width)
+ left = Math.min(left, x)
+ top = Math.min(top, y)
+ right = Math.max(right, x)
+ bottom = Math.max(bottom, y)
+ opaquePixels += 1
+ sumX += x
+ sumY += y
+ }
+
+ const subjectWidth = right - left + 1
+ const subjectHeight = bottom - top + 1
+
+ return {
+ width,
+ height,
+ bounds: {
+ left,
+ top,
+ right,
+ bottom,
+ width: subjectWidth,
+ height: subjectHeight,
+ },
+ centroid: { x: sumX / opaquePixels, y: sumY / opaquePixels },
+ footY: bottom,
+ subjectHeight,
+ opaquePixels,
+ coverageRatio: opaquePixels / (width * height),
+ fingerprint: createFingerprint(data, width, subjectPixels, {
+ left,
+ top,
+ width: subjectWidth,
+ height: subjectHeight,
+ }),
+ contentHash: hashPixels(data),
+ }
+}
diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts
new file mode 100644
index 0000000..6b4f2ae
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts
@@ -0,0 +1,146 @@
+/** @vitest-environment jsdom */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { readImageGeometry } from './image-geometry'
+
+type ImageBehavior = 'load' | 'error' | 'pending'
+
+let imageBehavior: ImageBehavior
+let crossOriginAtSourceAssignment: string | null
+let lastAssignedSource: string
+let canvasContext: Pick