Improve live camera relay buffering
这个提交包含在:
@@ -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;
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户