Improve live camera relay buffering

这个提交包含在:
cryptocommuniums-afk
2026-03-17 09:51:47 +08:00
父节点 63dbfd2787
当前提交 f3f7e1982c
修改 8 个文件,包含 2536 行新增1205 行删除

查看文件

@@ -22,7 +22,9 @@ test("training page shows plan generation flow", async ({ page }) => {
await page.goto("/training");
await expect(page.getByTestId("training-title")).toBeVisible();
const generateButton = page.getByRole("button", { name: "生成训练计划" }).last();
const generateButton = page
.getByRole("button", { name: "生成训练计划" })
.last();
await expect(generateButton).toBeVisible();
await generateButton.click();
await expect(page).toHaveURL(/\/training$/);
@@ -68,23 +70,40 @@ test("live camera starts analysis and produces scores", async ({ page }) => {
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
});
test("live camera switches into viewer mode when another device already owns analysis", async ({ page }) => {
test("live camera switches into viewer mode when another device already owns analysis", async ({
page,
}) => {
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
await page.goto("/live-camera");
await expect(page.getByText("同步观看模式")).toBeVisible();
await expect(page.getByText(/同步观看|重新同步/).first()).toBeVisible();
await expect(page.getByText("当前设备已锁定为观看模式")).toBeVisible();
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("其他设备实时分析");
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("移动端");
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("均衡模式");
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("猩猩");
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText(
"其他设备实时分析"
);
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText(
"移动端"
);
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText(
"均衡模式"
);
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText(
"猩猩"
);
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
});
test("live camera recovers mojibake viewer titles before rendering", async ({ page }) => {
const state = await installAppMocks(page, { authenticated: true, liveViewerMode: true });
const mojibakeTitle = Buffer.from("服务端同步烟雾测试", "utf8").toString("latin1");
test("live camera recovers mojibake viewer titles before rendering", async ({
page,
}) => {
const state = await installAppMocks(page, {
authenticated: true,
liveViewerMode: true,
});
const mojibakeTitle = Buffer.from("服务端同步烟雾测试", "utf8").toString(
"latin1"
);
if (state.liveRuntime.runtimeSession) {
state.liveRuntime.runtimeSession.title = mojibakeTitle;
state.liveRuntime.runtimeSession.snapshot = {
@@ -94,11 +113,15 @@ test("live camera recovers mojibake viewer titles before rendering", async ({ pa
}
await page.goto("/live-camera");
await expect(page.getByRole("heading", { name: "服务端同步烟雾测试" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "服务端同步烟雾测试" })
).toBeVisible();
await expect(page.getByText(mojibakeTitle)).toHaveCount(0);
});
test("live camera no longer opens viewer peer retries when server relay is active", async ({ page }) => {
test("live camera no longer opens viewer peer retries when server relay is active", async ({
page,
}) => {
const state = await installAppMocks(page, {
authenticated: true,
liveViewerMode: true,
@@ -109,10 +132,12 @@ test("live camera no longer opens viewer peer retries when server relay is activ
await expect(page.getByText("同步观看模式")).toBeVisible();
await expect.poll(() => state.viewerSignalConflictRemaining).toBe(1);
await expect.poll(() => state.mediaSession?.viewerCount ?? 0).toBe(0);
await expect(page.locator('img[alt="同步中的实时分析画面"]')).toBeVisible();
await expect(page.getByTestId("live-camera-viewer-video")).toBeVisible();
});
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
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");
@@ -126,7 +151,9 @@ test("live camera archives overlay videos into the library after analysis stops"
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
await page.getByRole("button", { name: "结束分析" }).click();
await expect(page.getByText("分析结果已保存")).toBeVisible({ timeout: 8_000 });
await expect(page.getByText("分析结果已保存")).toBeVisible({
timeout: 8_000,
});
await page.goto("/videos");
await expect(page.getByTestId("video-card")).toHaveCount(1);
@@ -134,7 +161,9 @@ test("live camera archives overlay videos into the library after analysis stops"
await expect(page.getByText("实时分析").first()).toBeVisible();
});
test("recorder flow archives a session and exposes it in videos", async ({ page }) => {
test("recorder flow archives a session and exposes it in videos", async ({
page,
}) => {
await installAppMocks(page, { authenticated: true, videos: [] });
await page.setViewportSize({ width: 390, height: 844 });
@@ -145,7 +174,9 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
await expect(focusShell).toBeVisible();
await focusShell.getByTestId("recorder-start-camera-button").click();
await expect(focusShell.getByTestId("recorder-start-recording-button")).toBeVisible();
await expect(
focusShell.getByTestId("recorder-start-recording-button")
).toBeVisible();
await focusShell.getByTestId("recorder-start-recording-button").click();
await expect(focusShell.getByTestId("recorder-marker-button")).toBeVisible();
@@ -154,17 +185,23 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
await expect(page.getByText("手动标记")).toBeVisible();
await focusShell.getByTestId("recorder-finish-button").click();
await expect(focusShell.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 });
await expect(focusShell.getByTestId("recorder-reset-button")).toBeVisible({
timeout: 8_000,
});
await page.goto("/videos");
await expect(page.getByTestId("video-card")).toHaveCount(1);
await expect(page.getByText("E2E 录制")).toBeVisible();
});
test("recorder blocks local camera when another device owns live analysis", async ({ page }) => {
test("recorder blocks local camera when another device owns live analysis", async ({
page,
}) => {
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
await page.goto("/recorder");
await expect(page.getByText("当前账号已有其他设备正在实时分析")).toBeVisible();
await expect(
page.getByText("当前账号已有其他设备正在实时分析")
).toBeVisible();
await expect(page.getByTestId("recorder-start-camera-button")).toBeDisabled();
});

查看文件

@@ -37,8 +37,10 @@ type MockMediaSession = {
id: string;
userId: string;
title: string;
purpose?: "recording" | "relay";
status: string;
archiveStatus: string;
previewStatus?: string;
format: string;
mimeType: string;
qualityPreset: string;
@@ -48,6 +50,7 @@ type MockMediaSession = {
uploadedSegments: number;
uploadedBytes: number;
durationMs: number;
previewUpdatedAt?: string;
streamConnected: boolean;
viewerCount?: number;
playback: {
@@ -255,42 +258,61 @@ async function readTrpcInput(route: Route, operationIndex: number) {
if (!postData) return null;
const parsed = JSON.parse(postData);
return parsed?.json ?? parsed?.[operationIndex]?.json ?? parsed?.[String(operationIndex)]?.json ?? null;
return (
parsed?.json ??
parsed?.[operationIndex]?.json ??
parsed?.[String(operationIndex)]?.json ??
null
);
}
function buildMediaSession(user: MockUser, title: string): MockMediaSession {
function buildMediaSession(
user: MockUser,
title: string,
purpose: "recording" | "relay" = "recording"
): MockMediaSession {
return {
id: "session-e2e",
userId: String(user.id),
title,
status: "created",
purpose,
status: purpose === "relay" ? "recording" : "created",
archiveStatus: "idle",
previewStatus: purpose === "relay" ? "ready" : "idle",
format: "webm",
mimeType: "video/webm",
qualityPreset: "balanced",
facingMode: "environment",
deviceKind: "mobile",
reconnectCount: 0,
uploadedSegments: 0,
uploadedBytes: 0,
durationMs: 0,
uploadedSegments: purpose === "relay" ? 1 : 0,
uploadedBytes: purpose === "relay" ? 1_280_000 : 0,
durationMs: purpose === "relay" ? 60_000 : 0,
previewUpdatedAt: purpose === "relay" ? nowIso() : undefined,
streamConnected: true,
playback: {
ready: false,
ready: purpose !== "relay",
previewUrl:
purpose === "relay"
? "/media/assets/sessions/session-e2e/preview.webm"
: undefined,
},
markers: [],
};
}
function createTask(state: MockAppState, input: {
type: string;
title: string;
status?: string;
progress?: number;
message?: string;
result?: any;
error?: string | null;
}) {
function createTask(
state: MockAppState,
input: {
type: string;
title: string;
status?: string;
progress?: number;
message?: string;
result?: any;
error?: string | null;
}
) {
const task = {
id: `task-${state.nextTaskId++}`,
userId: state.user.id,
@@ -304,7 +326,8 @@ function createTask(state: MockAppState, input: {
attempts: input.status === "failed" ? 2 : 1,
maxAttempts: input.type === "media_finalize" ? 90 : 3,
startedAt: nowIso(),
completedAt: input.status === "queued" || input.status === "running" ? null : nowIso(),
completedAt:
input.status === "queued" || input.status === "running" ? null : nowIso(),
createdAt: nowIso(),
updatedAt: nowIso(),
};
@@ -323,297 +346,332 @@ 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 = await Promise.all(operations.map(async (operation, operationIndex) => {
switch (operation) {
case "auth.me":
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
state.authMeNullResponsesAfterLogin -= 1;
return trpcResult(null);
const results = await Promise.all(
operations.map(async (operation, operationIndex) => {
switch (operation) {
case "auth.me":
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
state.authMeNullResponsesAfterLogin -= 1;
return trpcResult(null);
}
return trpcResult(state.authenticated ? state.user : null);
case "auth.loginWithUsername":
state.authenticated = true;
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 });
}
return trpcResult(state.authenticated ? state.user : null);
case "auth.loginWithUsername":
state.authenticated = true;
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": {
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,
durationDays,
version: 1,
adjustmentNotes: null,
exercises: [
{
day: 1,
name: "正手影子挥拍",
category: "影子挥拍",
duration: 15,
description: "练习完整引拍和收拍动作。",
tips: "保持重心稳定,击球点在身体前侧。",
sets: 3,
reps: 12,
},
{
day: 1,
name: "交叉步移动",
category: "脚步移动",
duration: 12,
description: "强化启动和回位节奏。",
tips: "每次移动后快速回到准备姿势。",
sets: 4,
reps: 10,
},
],
};
return trpcResult({
taskId: createTask(state, {
type: "training_plan_generate",
title: `${durationDays}天训练计划生成`,
result: {
kind: "training_plan_generate",
planId: state.activePlan.id,
plan: state.activePlan,
},
}).id,
});
}
case "plan.adjust":
return trpcResult({
taskId: createTask(state, {
type: "training_plan_adjust",
title: "训练计划调整",
result: {
kind: "training_plan_adjust",
adjustmentNotes: "已根据最近分析结果调整训练重点。",
},
}).id,
});
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":
return trpcResult([]);
case "analysis.runtimeGet":
return trpcResult(state.liveRuntime);
case "analysis.runtimeAcquire":
if (state.liveRuntime.runtimeSession?.status === "active" && state.liveRuntime.role === "viewer") {
case "plan.active":
return trpcResult(state.activePlan);
case "plan.list":
return trpcResult(state.activePlan ? [state.activePlan] : []);
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,
durationDays,
version: 1,
adjustmentNotes: null,
exercises: [
{
day: 1,
name: "正手影子挥拍",
category: "影子挥拍",
duration: 15,
description: "练习完整引拍和收拍动作。",
tips: "保持重心稳定,击球点在身体前侧。",
sets: 3,
reps: 12,
},
{
day: 1,
name: "交叉步移动",
category: "脚步移动",
duration: 12,
description: "强化启动和回位节奏。",
tips: "每次移动后快速回到准备姿势。",
sets: 4,
reps: 10,
},
],
};
return trpcResult({
taskId: createTask(state, {
type: "training_plan_generate",
title: `${durationDays}天训练计划生成`,
result: {
kind: "training_plan_generate",
planId: state.activePlan.id,
plan: state.activePlan,
},
}).id,
});
}
case "plan.adjust":
return trpcResult({
taskId: createTask(state, {
type: "training_plan_adjust",
title: "训练计划调整",
result: {
kind: "training_plan_adjust",
adjustmentNotes: "已根据最近分析结果调整训练重点。",
},
}).id,
});
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":
return trpcResult([]);
case "analysis.runtimeGet":
return trpcResult(state.liveRuntime);
}
state.liveRuntime = {
role: "owner",
runtimeSession: {
id: 501,
title: "实时分析 正手",
sessionMode: "practice",
mediaSessionId: state.mediaSession?.id || null,
status: "active",
startedAt: nowIso(),
endedAt: null,
lastHeartbeatAt: nowIso(),
snapshot: {
phase: "analyzing",
currentAction: "forehand",
rawAction: "forehand",
visibleSegments: 1,
unknownSegments: 0,
durationMs: 1500,
feedback: ["节奏稳定"],
},
},
};
return trpcResult(state.liveRuntime);
case "analysis.runtimeHeartbeat": {
const input = await readTrpcInput(route, operationIndex);
if (state.liveRuntime.runtimeSession) {
state.liveRuntime.runtimeSession = {
...state.liveRuntime.runtimeSession,
mediaSessionId: input?.mediaSessionId ?? state.liveRuntime.runtimeSession.mediaSessionId,
snapshot: input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
lastHeartbeatAt: nowIso(),
};
}
return trpcResult(state.liveRuntime);
}
case "analysis.runtimeRelease":
state.liveRuntime = { role: "idle", runtimeSession: null };
return trpcResult({ success: true, runtimeSession: null });
case "analysis.liveSessionSave":
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
case "task.list":
return trpcResult(state.tasks);
case "task.get": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
return trpcResult(state.tasks.find((task) => task.id === taskId) || null);
}
case "task.retry": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
const task = state.tasks.find((item) => item.id === taskId);
if (task) {
task.status = "succeeded";
task.progress = 100;
task.error = null;
task.message = "任务执行完成";
}
return trpcResult({ task });
}
case "task.createMediaFinalize": {
if (state.mediaSession) {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url: state.mediaSession.playback.webmUrl,
format: "webm",
fileSize: state.mediaSession.playback.webmSize,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
}
return trpcResult({
taskId: createTask(state, {
type: "media_finalize",
title: "录制归档",
result: {
kind: "media_finalize",
sessionId: state.mediaSession?.id,
videoId: state.videos[0]?.id,
url: state.videos[0]?.url,
},
}).id,
});
}
case "analysis.getCorrections":
return trpcResult({
taskId: createTask(state, {
type: "pose_correction_multimodal",
title: "动作纠正",
result: {
corrections: "## 动作概览\n整体节奏稳定,建议继续优化击球点前置。",
report: {
priorityFixes: [
{
title: "击球点前置",
why: "击球点略靠后会影响挥拍连贯性。",
howToPractice: "每组 8 次影子挥拍,刻意在身体前侧完成触球动作。",
successMetric: "连续 3 组都能稳定在身体前侧完成挥拍。",
},
],
case "analysis.runtimeAcquire":
if (
state.liveRuntime.runtimeSession?.status === "active" &&
state.liveRuntime.role === "viewer"
) {
return trpcResult(state.liveRuntime);
}
state.liveRuntime = {
role: "owner",
runtimeSession: {
id: 501,
title: "实时分析 正手",
sessionMode: "practice",
mediaSessionId: state.mediaSession?.id || null,
status: "active",
startedAt: nowIso(),
endedAt: null,
lastHeartbeatAt: nowIso(),
snapshot: {
phase: "analyzing",
currentAction: "forehand",
rawAction: "forehand",
visibleSegments: 1,
unknownSegments: 0,
durationMs: 1500,
feedback: ["节奏稳定"],
},
},
}).id,
});
case "video.registerExternal":
if (state.mediaSession?.playback.webmUrl || state.mediaSession?.playback.mp4Url) {
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url: state.mediaSession.playback.webmUrl || state.mediaSession.playback.mp4Url,
format: "webm",
fileSize: state.mediaSession.playback.webmSize || 1024 * 1024,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
};
return trpcResult(state.liveRuntime);
case "analysis.runtimeHeartbeat": {
const input = await readTrpcInput(route, operationIndex);
if (state.liveRuntime.runtimeSession) {
state.liveRuntime.runtimeSession = {
...state.liveRuntime.runtimeSession,
mediaSessionId:
input?.mediaSessionId ??
state.liveRuntime.runtimeSession.mediaSessionId,
snapshot:
input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
lastHeartbeatAt: nowIso(),
};
}
return trpcResult(state.liveRuntime);
}
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
case "achievement.list":
return trpcResult(buildStats(state.user).achievements);
case "rating.current":
return trpcResult({
rating: state.user.ntrpRating,
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
});
case "rating.history":
return trpcResult([
{
id: 1,
rating: 2.4,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 72,
strokeConsistency: 70,
footwork: 66,
fluidity: 69,
timing: 68,
matchReadiness: 60,
},
sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
},
{
id: 2,
case "analysis.runtimeRelease":
state.liveRuntime = { role: "idle", runtimeSession: null };
return trpcResult({ success: true, runtimeSession: null });
case "analysis.liveSessionSave":
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
case "task.list":
return trpcResult(state.tasks);
case "task.get": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId =
parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
return trpcResult(
state.tasks.find(task => task.id === taskId) || null
);
}
case "task.retry": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId =
parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
const task = state.tasks.find(item => item.id === taskId);
if (task) {
task.status = "succeeded";
task.progress = 100;
task.error = null;
task.message = "任务执行完成";
}
return trpcResult({ task });
}
case "task.createMediaFinalize": {
if (state.mediaSession) {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url: state.mediaSession.playback.webmUrl,
format: "webm",
fileSize: state.mediaSession.playback.webmSize,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
}
return trpcResult({
taskId: createTask(state, {
type: "media_finalize",
title: "录制归档",
result: {
kind: "media_finalize",
sessionId: state.mediaSession?.id,
videoId: state.videos[0]?.id,
url: state.videos[0]?.url,
},
}).id,
});
}
case "analysis.getCorrections":
return trpcResult({
taskId: createTask(state, {
type: "pose_correction_multimodal",
title: "动作纠正",
result: {
corrections:
"## 动作概览\n整体节奏稳定,建议继续优化击球点前置。",
report: {
priorityFixes: [
{
title: "击球点前置",
why: "击球点略靠后会影响挥拍连贯性。",
howToPractice:
"每组 8 次影子挥拍,刻意在身体前侧完成触球动作。",
successMetric: "连续 3 组都能稳定在身体前侧完成挥拍。",
},
],
},
},
}).id,
});
case "video.registerExternal":
if (
state.mediaSession?.playback.webmUrl ||
state.mediaSession?.playback.mp4Url
) {
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url:
state.mediaSession.playback.webmUrl ||
state.mediaSession.playback.mp4Url,
format: "webm",
fileSize: state.mediaSession.playback.webmSize || 1024 * 1024,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
}
return trpcResult({
videoId: state.nextVideoId,
url: state.mediaSession?.playback.webmUrl,
});
case "achievement.list":
return trpcResult(buildStats(state.user).achievements);
case "rating.current":
return trpcResult({
rating: state.user.ntrpRating,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
},
]);
default:
return trpcResult(null);
}
}));
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
});
case "rating.history":
return trpcResult([
{
id: 1,
rating: 2.4,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 72,
strokeConsistency: 70,
footwork: 66,
fluidity: 69,
timing: 68,
matchReadiness: 60,
},
sourceSummary: {
analyses: 1,
liveSessions: 0,
totalEffectiveActions: 12,
totalPk: 0,
activeDays: 1,
},
},
{
id: 2,
rating: state.user.ntrpRating,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: buildStats(state.user).latestNtrpSnapshot
.dimensionScores,
sourceSummary: {
analyses: 2,
liveSessions: 1,
totalEffectiveActions: 36,
totalPk: 0,
activeDays: 2,
},
},
]);
default:
return trpcResult(null);
}
})
);
await fulfillJson(route, results);
}
@@ -649,7 +707,11 @@ async function handleMedia(route: Route, state: MockAppState) {
return;
}
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
await fulfillJson(route, {
viewerId: `viewer-${state.mediaSession.viewerCount}`,
type: "answer",
sdp: "mock-answer",
});
return;
}
@@ -689,16 +751,27 @@ async function handleMedia(route: Route, state: MockAppState) {
}
if (path === `/media/sessions/${state.mediaSession.id}`) {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
if (state.mediaSession.purpose === "relay") {
state.mediaSession.previewStatus = "ready";
state.mediaSession.previewUpdatedAt = nowIso();
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/preview.webm",
webmSize: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/preview.webm",
};
} else {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
}
await fulfillJson(route, { session: state.mediaSession });
return;
}
@@ -727,7 +800,13 @@ export async function installAppMocks(
viewerSignalConflictOnce?: boolean;
}
) {
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
const seededViewerSession = options?.liveViewerMode
? buildMediaSession(
buildUser(options?.userName),
"其他设备实时分析",
"relay"
)
: null;
const state: MockAppState = {
authenticated: options?.authenticated ?? false,
user: buildUser(options?.userName),
@@ -940,7 +1019,11 @@ export async function installAppMocks(
setOptions() {}
onResults(callback: (results: { poseLandmarks: ReturnType<typeof buildFakeLandmarks> }) => void) {
onResults(
callback: (results: {
poseLandmarks: ReturnType<typeof buildFakeLandmarks>;
}) => void
) {
this.callback = callback;
}
@@ -964,10 +1047,14 @@ export async function installAppMocks(
Object.defineProperty(HTMLMediaElement.prototype, "srcObject", {
configurable: true,
get() {
return (this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject ?? null;
return (
(this as HTMLMediaElement & { __srcObject?: MediaStream })
.__srcObject ?? null
);
},
set(value) {
(this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject = value as MediaStream;
(this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject =
value as MediaStream;
},
});
@@ -997,7 +1084,11 @@ export async function installAppMocks(
if (this.state !== "recording") return;
const event = new Event("dataavailable") as Event & { data?: Blob };
event.data = new Blob(["segment"], { type: this.mimeType });
const handler = (this as unknown as { ondataavailable?: (evt: Event & { data?: Blob }) => void }).ondataavailable;
const handler = (
this as unknown as {
ondataavailable?: (evt: Event & { data?: Blob }) => void;
}
).ondataavailable;
handler?.(event);
this.dispatchEvent(event);
}
@@ -1061,10 +1152,21 @@ export async function installAppMocks(
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async (constraints?: { audio?: unknown }) => createFakeMediaStream(Boolean(constraints?.audio)),
getUserMedia: async (constraints?: { audio?: unknown }) =>
createFakeMediaStream(Boolean(constraints?.audio)),
enumerateDevices: async () => [
{ deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" },
{ deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" },
{
deviceId: "cam-1",
kind: "videoinput",
label: "Front Camera",
groupId: "g1",
},
{
deviceId: "cam-2",
kind: "videoinput",
label: "Back Camera",
groupId: "g1",
},
],
addEventListener: () => undefined,
removeEventListener: () => undefined,
@@ -1072,8 +1174,8 @@ export async function installAppMocks(
});
});
await page.route("**/api/trpc/**", (route) => handleTrpc(route, state));
await page.route("**/media/**", (route) => handleMedia(route, state));
await page.route("**/api/trpc/**", route => handleTrpc(route, state));
await page.route("**/media/**", route => handleMedia(route, state));
return state;
}