+
+ 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..05f500b
--- /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..47e3a90
--- /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/export/asset-export.test.ts b/frontend/src/pages/playtest/workbench/export/asset-export.test.ts
new file mode 100644
index 0000000..4536ae3
--- /dev/null
+++ b/frontend/src/pages/playtest/workbench/export/asset-export.test.ts
@@ -0,0 +1,180 @@
+/** @vitest-environment jsdom */
+import { describe, expect, it, vi } from "vitest";
+
+import type { PlaytestPreviewModel, PreviewAction, PreviewFrame } from "../model/types";
+import { createAssetExportPlan, exportGameAssets, type AssetExportRuntime } from "./asset-export";
+
+function frame(index: number): PreviewFrame {
+ return {
+ imageUrl: `/frames/walk-${index}.png`,
+ durationMs: 100 + index,
+ rootMotion: { dx: index, dy: 0 },
+ qc: "pending",
+ rejected: false,
+ keyFrame: index === 0,
+ };
+}
+
+function action(status: PreviewAction["status"], frameCount = 9): PreviewAction {
+ return {
+ id: `walk-${status}-abcdef12`,
+ name: "Walk / Forward",
+ type: "walk",
+ status,
+ fps: 10,
+ sequences: [
+ {
+ direction: "south",
+ frames: Array.from({ length: frameCount }, (_, index) => frame(index)),
+ },
+ ],
+ };
+}
+
+const model: PlaytestPreviewModel = {
+ characterId: "character-1",
+ characterName: "Aster",
+ outfitId: "outfit-1",
+ outfitName: "Explorer",
+ characterTemplateUrl: null,
+ baseFrameCount: 0,
+ actions: [action("confirmed"), action("candidate")],
+};
+
+async function readStoredZip(blob: Blob): Promise