Add auto archived overlay recordings for live analysis

这个提交包含在:
cryptocommuniums-afk
2026-03-16 11:59:51 +08:00
父节点 e3fe9a8e7b
当前提交 4fb2d092d7
修改 7 个文件,包含 377 行新增60 行删除

查看文件

@@ -68,6 +68,28 @@ test("live camera starts analysis and produces scores", async ({ page }) => {
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
});
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
await installAppMocks(page, { authenticated: true, videos: [] });
await page.goto("/live-camera");
await page.getByRole("button", { name: "下一步" }).click();
await page.getByRole("button", { name: "下一步" }).click();
await page.getByRole("button", { name: "下一步" }).click();
await page.getByRole("button", { name: /启用摄像头/ }).click();
await expect(page.getByTestId("live-camera-analyze-button")).toBeVisible();
await page.getByTestId("live-camera-analyze-button").click();
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
await page.getByRole("button", { name: "结束分析" }).click();
await expect(page.getByText("分析结果已保存")).toBeVisible({ timeout: 8_000 });
await page.goto("/videos");
await expect(page.getByTestId("video-card")).toHaveCount(1);
await expect(page.getByText("实时分析录像").first()).toBeVisible();
await expect(page.getByText("实时分析").first()).toBeVisible();
});
test("recorder flow archives a session and exposes it in videos", async ({ page }) => {
await installAppMocks(page, { authenticated: true, videos: [] });

查看文件

@@ -10,6 +10,19 @@ type MockUser = {
skillLevel: string;
trainingGoals: string | null;
ntrpRating: number;
manualNtrpRating: number | null;
manualNtrpCapturedAt: string | null;
heightCm: number | null;
weightKg: number | null;
sprintSpeedScore: number | null;
explosivePowerScore: number | null;
agilityScore: number | null;
enduranceScore: number | null;
flexibilityScore: number | null;
coreStabilityScore: number | null;
shoulderMobilityScore: number | null;
hipMobilityScore: number | null;
assessmentNotes: string | null;
totalSessions: number;
totalMinutes: number;
totalShots: number;
@@ -103,6 +116,19 @@ function buildUser(name = "TestPlayer"): MockUser {
skillLevel: "beginner",
trainingGoals: null,
ntrpRating: 2.8,
manualNtrpRating: 2.5,
manualNtrpCapturedAt: nowIso(),
heightCm: 178,
weightKg: 68,
sprintSpeedScore: 4,
explosivePowerScore: 4,
agilityScore: 4,
enduranceScore: 3,
flexibilityScore: 3,
coreStabilityScore: 4,
shoulderMobilityScore: 3,
hipMobilityScore: 4,
assessmentNotes: "每周可练 3 次,右肩偶尔偏紧。",
totalSessions: 12,
totalMinutes: 320,
totalShots: 280,
@@ -115,6 +141,7 @@ function buildUser(name = "TestPlayer"): MockUser {
}
function buildStats(user: MockUser) {
const hasSystemNtrp = user.ntrpRating != null;
return {
ntrpRating: user.ntrpRating,
totalSessions: user.totalSessions,
@@ -186,9 +213,45 @@ function buildStats(user: MockUser) {
matchReadiness: 70,
},
},
trainingProfileStatus: {
hasSystemNtrp,
isComplete: true,
missingFields: [],
effectiveNtrp: user.ntrpRating,
ntrpSource: hasSystemNtrp ? "system" : "manual",
assessmentSnapshot: {
heightCm: user.heightCm,
weightKg: user.weightKg,
sprintSpeedScore: user.sprintSpeedScore,
explosivePowerScore: user.explosivePowerScore,
agilityScore: user.agilityScore,
enduranceScore: user.enduranceScore,
flexibilityScore: user.flexibilityScore,
coreStabilityScore: user.coreStabilityScore,
shoulderMobilityScore: user.shoulderMobilityScore,
hipMobilityScore: user.hipMobilityScore,
assessmentNotes: user.assessmentNotes,
},
},
};
}
async function readTrpcInput(route: Route, operationIndex: number) {
const url = new URL(route.request().url());
const rawSearchInput = url.searchParams.get("input");
if (rawSearchInput) {
const parsed = JSON.parse(rawSearchInput);
return parsed?.json ?? parsed?.[operationIndex]?.json ?? null;
}
const postData = route.request().postData();
if (!postData) return null;
const parsed = JSON.parse(postData);
return parsed?.json ?? parsed?.[operationIndex]?.json ?? parsed?.[String(operationIndex)]?.json ?? null;
}
function buildMediaSession(user: MockUser, title: string): MockMediaSession {
return {
id: "session-e2e",
@@ -254,7 +317,7 @@ async function fulfillJson(route: Route, body: unknown) {
async function handleTrpc(route: Route, state: MockAppState) {
const url = new URL(route.request().url());
const operations = url.pathname.replace("/api/trpc/", "").split(",");
const results = operations.map((operation) => {
const results = await Promise.all(operations.map(async (operation, operationIndex) => {
switch (operation) {
case "auth.me":
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
@@ -267,16 +330,34 @@ async function handleTrpc(route: Route, state: MockAppState) {
return trpcResult({ user: state.user, isNew: false });
case "profile.stats":
return trpcResult(buildStats(state.user));
case "profile.update": {
const input = await readTrpcInput(route, operationIndex);
state.user = {
...state.user,
...input,
updatedAt: nowIso(),
manualNtrpCapturedAt:
input?.manualNtrpRating !== undefined
? input.manualNtrpRating == null
? null
: nowIso()
: state.user.manualNtrpCapturedAt,
};
return trpcResult({ success: true });
}
case "plan.active":
return trpcResult(state.activePlan);
case "plan.list":
return trpcResult(state.activePlan ? [state.activePlan] : []);
case "plan.generate":
case "plan.generate": {
const input = await readTrpcInput(route, operationIndex);
const durationDays = Number(input?.durationDays ?? 7);
const skillLevel = input?.skillLevel ?? state.user.skillLevel;
state.activePlan = {
id: 200,
title: `${state.user.name} 的训练计划`,
skillLevel: "beginner",
durationDays: 7,
skillLevel,
durationDays,
version: 1,
adjustmentNotes: null,
exercises: [
@@ -305,7 +386,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
return trpcResult({
taskId: createTask(state, {
type: "training_plan_generate",
title: "7天训练计划生成",
title: `${durationDays}天训练计划生成`,
result: {
kind: "training_plan_generate",
planId: state.activePlan.id,
@@ -313,6 +394,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
},
}).id,
});
}
case "plan.adjust":
return trpcResult({
taskId: createTask(state, {
@@ -326,6 +408,22 @@ async function handleTrpc(route: Route, state: MockAppState) {
});
case "video.list":
return trpcResult(state.videos);
case "video.upload": {
const input = await readTrpcInput(route, operationIndex);
const video = {
id: state.nextVideoId++,
title: input?.title || `实时分析录像 ${state.nextVideoId}`,
url: `/uploads/${state.nextVideoId}.${input?.format || "webm"}`,
format: input?.format || "webm",
fileSize: input?.fileSize || 1024 * 1024,
duration: input?.duration || 60,
exerciseType: input?.exerciseType || "live_analysis",
analysisStatus: "completed",
createdAt: nowIso(),
};
state.videos = [video, ...state.videos];
return trpcResult({ videoId: video.id, url: video.url });
}
case "analysis.list":
return trpcResult(state.analyses);
case "analysis.liveSessionList":
@@ -465,7 +563,7 @@ async function handleTrpc(route: Route, state: MockAppState) {
default:
return trpcResult(null);
}
});
}));
await fulfillJson(route, results);
}
@@ -655,6 +753,11 @@ export async function installAppMocks(
value: async () => undefined,
});
Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", {
configurable: true,
value: () => new MediaStream(),
});
class FakeMediaRecorder extends EventTarget {
state = "inactive";
mimeType = "video/webm";