Improve live camera relay buffering

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

查看文件

@@ -8,11 +8,34 @@ export type ChangeLogEntry = {
};
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
{
version: "2026.03.17-live-camera-relay-buffer",
releaseDate: "2026-03-17",
repoVersion: "63dbfd2+relay-buffer",
summary:
"实时分析同步观看改为服务端滚动视频缓存,观看端不再轮询单帧图片;media 服务同时新增最近 60 秒缓冲和 30 分钟缓存清理。",
features: [
"live-camera owner 端的 60 秒合成录像分段现在会额外上传到 media relay 会话,观看端改为播放服务端生成的滚动 preview 视频,不再依赖 `live-frame.jpg` 单帧轮询",
"relay 会话只保留最近 60 秒分段,worker 会在新分段到达后按最新窗口重建 `preview.webm`,避免观看端继续看到旧一分钟缓存",
"超过 30 分钟无活动的 relay 会话、分段目录和公开缓存文件会自动清理,避免多端同步长期堆积无用缓存",
"实时分析 viewer 文案和占位提示同步调整为“缓冲最近 60 秒视频 / 加载缓存回放”,更贴近现在的服务端缓存播放行为",
"media preview 非归档阶段跳过 mp4 转码,Chrome 观看直接使用 webm,降低 worker 处理时延和 CPU 消耗",
],
tests: [
"cd media && go test ./...",
"pnpm vitest run client/src/lib/liveCamera.test.ts",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera starts analysis and produces scores|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"',
"pnpm check",
"pnpm build",
"线上 smoke: 部署后确认 `https://te.hao.work/` 已提供新构建而不是旧资源版本,`/live-camera` viewer 端进入“服务端缓存同步”路径并返回正确的 JS/CSS MIME",
],
},
{
version: "2026.03.17-live-camera-preview-recovery",
releaseDate: "2026-03-17",
repoVersion: "06b9701",
summary: "修复实时分析页标题乱码、同步观看残留状态导致的黑屏,以及切回本机摄像头后预览无法恢复的问题。",
summary:
"修复实时分析页标题乱码、同步观看残留状态导致的黑屏,以及切回本机摄像头后预览无法恢复的问题。",
features: [
"runtime 标题恢复逻辑新增更严格的乱码筛除与二次 UTF-8 解码兜底,`服...`、带替换字符的脏标题现在会优先恢复为正常中文,无法恢复时会安全回退到稳定默认标题",
"同步观看退出时会完整重置 viewer 轮询、连接标记和帧版本,不再把旧 viewer 状态残留到 owner 或空闲态,避免页面继续停留在黑屏或“等待同步画面”",
@@ -22,7 +45,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
tests: [
"pnpm check",
"pnpm vitest run client/src/lib/liveCamera.test.ts",
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera\"",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"',
"pnpm build",
"线上 smoke: `curl -I https://te.hao.work/`,并检查页面源码中的 `/assets/index-*.js`、`/assets/index-*.css`、`/assets/pose-*.js` 已切换到新构建且返回正确 MIME",
],
@@ -31,7 +54,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-camera-runtime-refresh",
releaseDate: "2026-03-16",
repoVersion: "8e9e491",
summary: "修复实时分析页偶发残留在同步观看状态、标题乱码,以及摄像头预览绑定波动导致的启动失败。",
summary:
"修复实时分析页偶发残留在同步观看状态、标题乱码,以及摄像头预览绑定波动导致的启动失败。",
features: [
"live-camera 在打开拍摄引导、启用摄像头、开始分析前,都会先向服务端强制刷新 runtime 状态,避免旧的 viewer 锁残留导致本机明明已释放却仍无法启动",
"同步观看标题新增乱码恢复逻辑,可自动把 UTF-8 被误按 Latin-1 显示的标题恢复成正常中文,避免出现 `服...` 一类异常标题",
@@ -39,7 +63,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"e2e mock 的媒体流补齐为带假视频轨道的流对象,并把 viewer 回归改为校验“服务端 relay、无 viewer-signal”行为,减少和旧 P2P 逻辑混淆",
],
tests: [
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active\"",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"',
"pnpm build",
"部署后线上 smoke: `https://te.hao.work/live-camera` 登录 H1 后可见空闲态“启动摄像头”入口,确认不再被残留 viewer 锁卡住;公开站点前端资源为 `assets/index-33wVjC4p.js` 与 `assets/index-tNGuStgv.css`",
],
@@ -48,7 +72,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-viewer-server-relay",
releaseDate: "2026-03-16",
repoVersion: "bb46d26",
summary: "实时分析同步观看改为由 media 服务中转帧图,不再依赖浏览器之间的 P2P 视频连接。",
summary:
"实时分析同步观看改为由 media 服务中转帧图,不再依赖浏览器之间的 P2P 视频连接。",
features: [
"owner 端现在会把带骨架、关键点和虚拟形象叠层的合成画布压缩成 JPEG 并持续上传到 media 服务",
"viewer 端改为直接拉取 media 服务中的最新同步帧图,不再建立 WebRTC viewer peer 连接,因此跨网络和多端观看更稳定",
@@ -65,7 +90,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-camera-startup-fallbacks",
releaseDate: "2026-03-16",
repoVersion: "a211562",
summary: "修复部分设备上摄像头因后置镜头约束、分辨率约束或麦克风不可用而直接启动失败的问题。",
summary:
"修复部分设备上摄像头因后置镜头约束、分辨率约束或麦克风不可用而直接启动失败的问题。",
features: [
"live-camera 与 recorder 改为共用分级降级的摄像头请求流程,会在当前画质失败时自动降分辨率、降约束并回退到兼容镜头",
"当设备不支持默认后置摄像头或当前镜头不可用时,页面会自动切换到实际可用的镜头方向,避免直接报错后卡死在未启动状态",
@@ -81,7 +107,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-analysis-viewer-full-sync",
releaseDate: "2026-03-16",
repoVersion: "922a9fb",
summary: "多端同步观看改为按持有端快照完整渲染,另一设备可同步看到视频状态、模式、画质、虚拟形象和保存阶段信息。",
summary:
"多端同步观看改为按持有端快照完整渲染,另一设备可同步看到视频状态、模式、画质、虚拟形象和保存阶段信息。",
features: [
"viewer 端现在同步显示持有端的会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间",
"同步观看时的分析阶段、保存阶段、已完成状态也会跟随主端刷新,不再只显示本地默认状态",
@@ -89,7 +116,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"新增 viewer 同步信息卡,明确允许 1 秒级延迟,并持续显示最近心跳时间",
],
tests: [
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera switches into viewer mode|viewer stream|recorder blocks\"",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera switches into viewer mode|viewer stream|recorder blocks"',
"pnpm build",
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`;同账号 H1 双端登录后,移动端 owner 可开始实时分析,桌面端 `/live-camera` 自动进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 会同步进入保存阶段",
],
@@ -98,7 +125,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-analysis-lock-hardening",
releaseDate: "2026-03-16",
repoVersion: "f9db6ef",
summary: "修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
summary:
"修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
features: [
"旧用户名登录 token 即使缺少 `sid`,现在也会按 token 本身派生唯一会话标识,不再把不同设备错误识别成同一持有端",
"同步观看模式新增 viewer 自动重试当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会自动重连而不是一直黑屏",
@@ -110,7 +138,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"curl -I https://te.hao.work/",
"pnpm check",
"pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts",
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"viewer mode|viewer stream|recorder blocks\"",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"',
"pnpm build",
"线上 smoke: H1 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放",
],
@@ -119,7 +147,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-analysis-runtime-migration",
releaseDate: "2026-03-16",
repoVersion: "2b72ef9",
summary: "修复实时分析因缺失 `live_analysis_runtime` 表导致的启动失败,并补齐迁移记录避免后续部署再次漏表。",
summary:
"修复实时分析因缺失 `live_analysis_runtime` 表导致的启动失败,并补齐迁移记录避免后续部署再次漏表。",
features: [
"生产库补建 `live_analysis_runtime` 表,并补写 `__drizzle_migrations` 中缺失的 `0011_live_analysis_runtime` 记录",
"仓库内 Drizzle migration journal 补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确感知该迁移",
@@ -139,7 +168,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-camera-multidevice-viewer",
releaseDate: "2026-03-16",
repoVersion: "4e4122d",
summary: "实时分析新增同账号多端互斥和同步观看模式,分析持有端独占摄像头,其它端只能查看同步画面与核心识别结果。",
summary:
"实时分析新增同账号多端互斥和同步观看模式,分析持有端独占摄像头,其它端只能查看同步画面与核心识别结果。",
features: [
"同一账号在 `/live-camera` 进入实时分析后,会写入按用户维度的 runtime 锁,其他设备不能重复启动摄像头或分析",
"其他设备会自动进入“同步观看模式”,可订阅持有端的实时画面,并同步看到动作、评分、反馈、最近片段和归档段数",
@@ -152,8 +182,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"pnpm exec vitest run server/features.test.ts",
"go test ./... && go build ./... (media)",
"pnpm build",
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera\"",
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"recorder flow archives a session and exposes it in videos\"",
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"',
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "recorder flow archives a session and exposes it in videos"',
"curl -I https://te.hao.work/live-camera",
],
},
@@ -161,7 +191,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.16-live-analysis-overlay-archive",
releaseDate: "2026-03-16",
repoVersion: "4fb2d09",
summary: "实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
summary:
"实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
features: [
"实时分析开始后会自动录制合成画布,每 60 秒自动切段归档",
"归档录像会保留原视频、骨架线、关键点和当前虚拟形象覆盖效果",
@@ -181,17 +212,15 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
version: "2026.03.15-live-analysis-leave-hint",
releaseDate: "2026-03-15",
repoVersion: "5c2dcf2",
summary: "实时分析结束后增加离开提示,明确何时必须停留、何时可以安全关闭或切页。",
summary:
"实时分析结束后增加离开提示,明确何时必须停留、何时可以安全关闭或切页。",
features: [
"分析进行中显示“不要关闭或切走页面”提示",
"结束分析后保存阶段显示“请暂时停留当前页面”提示",
"保存成功后明确提示“现在可以关闭浏览器或切换到其他页面”",
"分析中和保存中挂接 beforeunload 提醒,减少误关页面导致的数据丢失",
],
tests: [
"pnpm check",
"pnpm build",
],
tests: ["pnpm check", "pnpm build"],
},
{
version: "2026.03.15-training-generator-collapse",
@@ -204,10 +233,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"移动端继续直接展示完整生成器,避免隐藏关键操作",
"未生成计划时点击“前往生成训练计划”会自动展开并滚动到生成面板",
],
tests: [
"pnpm check",
"pnpm build",
],
tests: ["pnpm check", "pnpm build"],
},
{
version: "2026.03.15-progress-time-actions",
@@ -220,10 +246,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"展开态动作明细统一用中文动作标签展示",
"提醒页通知时间统一切换为 Asia/Shanghai",
],
tests: [
"pnpm check",
"pnpm build",
],
tests: ["pnpm check", "pnpm build"],
},
{
version: "2026.03.15-session-changelog",
@@ -275,7 +298,7 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
],
tests: [
"pnpm check",
"pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
'pnpm exec vitest run server/features.test.ts -t "video\\\\."',
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
],
},
@@ -290,8 +313,6 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"训练提醒通知",
"通知历史管理",
],
tests: [
"教程库、提醒、通知相关测试通过",
],
tests: ["教程库、提醒、通知相关测试通过"],
},
];

查看文件

@@ -14,11 +14,7 @@ export type ArchiveStatus =
| "completed"
| "failed";
export type PreviewStatus =
| "idle"
| "processing"
| "ready"
| "failed";
export type PreviewStatus = "idle" | "processing" | "ready" | "failed";
export type MediaMarker = {
id: string;
@@ -33,6 +29,7 @@ export type MediaSession = {
id: string;
userId: string;
title: string;
purpose?: "recording" | "relay";
status: MediaSessionStatus;
archiveStatus: ArchiveStatus;
previewStatus: PreviewStatus;
@@ -64,11 +61,14 @@ export type MediaSession = {
markers: MediaMarker[];
};
const MEDIA_BASE = (import.meta.env.VITE_MEDIA_BASE_URL || "/media").replace(/\/$/, "");
const MEDIA_BASE = (import.meta.env.VITE_MEDIA_BASE_URL || "/media").replace(
/\/$/,
""
);
const RETRYABLE_STATUS = new Set([502, 503, 504]);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise(resolve => setTimeout(resolve, ms));
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -79,7 +79,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${MEDIA_BASE}${path}`, init);
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const error = new Error(errorBody.error || errorBody.message || `Media service error (${response.status})`);
const error = new Error(
errorBody.error ||
errorBody.message ||
`Media service error (${response.status})`
);
if (RETRYABLE_STATUS.has(response.status) && attempt < 2) {
lastError = error;
await sleep(400 * (attempt + 1));
@@ -89,7 +93,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
return response.json() as Promise<T>;
} catch (error) {
lastError = error instanceof Error ? error : new Error("Media request failed");
lastError =
error instanceof Error ? error : new Error("Media request failed");
if (attempt < 2) {
await sleep(400 * (attempt + 1));
continue;
@@ -109,6 +114,7 @@ export async function createMediaSession(payload: {
qualityPreset: string;
facingMode: string;
deviceKind: string;
purpose?: "recording" | "relay";
}) {
return request<{ session: MediaSession }>("/sessions", {
method: "POST",
@@ -117,28 +123,43 @@ export async function createMediaSession(payload: {
});
}
export async function signalMediaSession(sessionId: string, payload: { sdp: string; type: string }) {
return request<{ sdp: string; type: string }>(`/sessions/${sessionId}/signal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
export async function signalMediaSession(
sessionId: string,
payload: { sdp: string; type: string }
) {
return request<{ sdp: string; type: string }>(
`/sessions/${sessionId}/signal`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
}
export async function signalMediaViewerSession(sessionId: string, payload: { sdp: string; type: string }) {
return request<{ viewerId: string; sdp: string; type: string }>(`/sessions/${sessionId}/viewer-signal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
export async function signalMediaViewerSession(
sessionId: string,
payload: { sdp: string; type: string }
) {
return request<{ viewerId: string; sdp: string; type: string }>(
`/sessions/${sessionId}/viewer-signal`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
}
export async function uploadMediaLiveFrame(sessionId: string, blob: Blob) {
return request<{ session: MediaSession }>(`/sessions/${sessionId}/live-frame`, {
method: "POST",
headers: { "Content-Type": blob.type || "image/jpeg" },
body: blob,
});
return request<{ session: MediaSession }>(
`/sessions/${sessionId}/live-frame`,
{
method: "POST",
headers: { "Content-Type": blob.type || "image/jpeg" },
body: blob,
}
);
}
export async function uploadMediaSegment(
@@ -159,7 +180,12 @@ export async function uploadMediaSegment(
export async function createMediaMarker(
sessionId: string,
payload: { type: string; label: string; timestampMs: number; confidence?: number }
payload: {
type: string;
label: string;
timestampMs: number;
confidence?: number;
}
) {
return request<{ session: MediaSession }>(`/sessions/${sessionId}/markers`, {
method: "POST",
@@ -201,7 +227,11 @@ export function pickRecorderMimeType() {
"video/webm;codecs=h264,opus",
"video/webm",
];
return candidates.find((candidate) => window.MediaRecorder?.isTypeSupported(candidate)) || "video/webm";
return (
candidates.find(candidate =>
window.MediaRecorder?.isTypeSupported(candidate)
) || "video/webm"
);
}
export function pickBitrate(preset: string, isMobile: boolean) {

文件差异内容过多而无法显示 加载差异

查看文件

@@ -1,5 +1,34 @@
# Tennis Training Hub - 变更日志
## 2026.03.17-live-camera-relay-buffer (2026-03-17)
### 功能更新
- `/live-camera` 的同步观看改为播放 media 服务生成的滚动缓存视频,不再轮询 `live-frame.jpg` 单帧图片,因此观看端的画面会按最近 60 秒缓存视频平滑播放
- owner 端每个 60 秒的合成录像分段现在会额外上传到 `relay` 会话,worker 会在收到新分段后自动重建最近窗口的 `preview.webm`
- `relay` 会话只保留最近 60 秒视频分段,旧分段会从会话元数据和磁盘同步清理,避免观看端继续读到旧一分钟之前的缓存
- media worker 会自动清理超过 30 分钟无活动的 relay 会话、分段目录和公开缓存文件,降低磁盘堆积风险
- viewer 页面文案、加载提示和按钮文案已同步更新为“缓存视频 / 缓存回放”语义;预览阶段跳过 mp4 转码,Chrome 直接使用 webm,降低处理时延
### 测试
- `cd media && go test ./...`
- `pnpm vitest run client/src/lib/liveCamera.test.ts`
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera starts analysis and produces scores|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"`
- `pnpm check`
- `pnpm build`
- 线上 smoke部署后确认 `https://te.hao.work/` 已提供新构建而不是旧资源版本,`/live-camera` viewer 端进入“服务端缓存同步”路径,首页与资源文件返回正确 MIME
### 线上 smoke
- 部署完成后已确认 `https://te.hao.work/` 提供的是本次新构建,而不是旧资源版本
- `https://te.hao.work/live-camera` 的 viewer 端会走“服务端缓存同步”路径,不再请求旧的 `live-frame.jpg` 单帧同步
- 首页、主 JS、主 CSS 与 `pose` 模块均返回 `200` 和正确 MIME,未再出现脚本/样式被回退成 `text/html` 的问题
### 仓库版本
- `63dbfd2+relay-buffer`
## 2026.03.17-live-camera-preview-recovery (2026-03-17)
### 功能更新

查看文件

@@ -53,6 +53,18 @@ const (
PreviewFailed PreviewStatus = "failed"
)
type SessionPurpose string
const (
PurposeRecording SessionPurpose = "recording"
PurposeRelay SessionPurpose = "relay"
)
const (
relayPreviewWindow = 60 * time.Second
relayCacheTTL = 30 * time.Minute
)
type PlaybackInfo struct {
WebMURL string `json:"webmUrl,omitempty"`
MP4URL string `json:"mp4Url,omitempty"`
@@ -81,35 +93,36 @@ type Marker struct {
}
type Session struct {
ID string `json:"id"`
UserID string `json:"userId"`
Title string `json:"title"`
Status SessionStatus `json:"status"`
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
PreviewStatus PreviewStatus `json:"previewStatus"`
Format string `json:"format"`
MimeType string `json:"mimeType"`
QualityPreset string `json:"qualityPreset"`
FacingMode string `json:"facingMode"`
DeviceKind string `json:"deviceKind"`
ReconnectCount int `json:"reconnectCount"`
UploadedSegments int `json:"uploadedSegments"`
UploadedBytes int64 `json:"uploadedBytes"`
PreviewSegments int `json:"previewSegments"`
DurationMS int64 `json:"durationMs"`
LastError string `json:"lastError,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FinalizedAt string `json:"finalizedAt,omitempty"`
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
StreamConnected bool `json:"streamConnected"`
LastStreamAt string `json:"lastStreamAt,omitempty"`
ViewerCount int `json:"viewerCount"`
LiveFrameURL string `json:"liveFrameUrl,omitempty"`
LiveFrameUpdated string `json:"liveFrameUpdatedAt,omitempty"`
Playback PlaybackInfo `json:"playback"`
Segments []SegmentMeta `json:"segments"`
Markers []Marker `json:"markers"`
ID string `json:"id"`
UserID string `json:"userId"`
Title string `json:"title"`
Purpose SessionPurpose `json:"purpose"`
Status SessionStatus `json:"status"`
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
PreviewStatus PreviewStatus `json:"previewStatus"`
Format string `json:"format"`
MimeType string `json:"mimeType"`
QualityPreset string `json:"qualityPreset"`
FacingMode string `json:"facingMode"`
DeviceKind string `json:"deviceKind"`
ReconnectCount int `json:"reconnectCount"`
UploadedSegments int `json:"uploadedSegments"`
UploadedBytes int64 `json:"uploadedBytes"`
PreviewSegments int `json:"previewSegments"`
DurationMS int64 `json:"durationMs"`
LastError string `json:"lastError,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FinalizedAt string `json:"finalizedAt,omitempty"`
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
StreamConnected bool `json:"streamConnected"`
LastStreamAt string `json:"lastStreamAt,omitempty"`
ViewerCount int `json:"viewerCount"`
LiveFrameURL string `json:"liveFrameUrl,omitempty"`
LiveFrameUpdated string `json:"liveFrameUpdatedAt,omitempty"`
Playback PlaybackInfo `json:"playback"`
Segments []SegmentMeta `json:"segments"`
Markers []Marker `json:"markers"`
}
func (s *Session) recomputeAggregates() {
@@ -134,6 +147,7 @@ type CreateSessionRequest struct {
QualityPreset string `json:"qualityPreset"`
FacingMode string `json:"facingMode"`
DeviceKind string `json:"deviceKind"`
Purpose string `json:"purpose"`
}
type SignalRequest struct {
@@ -157,10 +171,10 @@ type sessionStore struct {
rootDir string
public string
mu sync.RWMutex
sessions map[string]*Session
peers map[string]*webrtc.PeerConnection
viewerPeers map[string]map[string]*webrtc.PeerConnection
videoTracks map[string]*webrtc.TrackLocalStaticRTP
sessions map[string]*Session
peers map[string]*webrtc.PeerConnection
viewerPeers map[string]map[string]*webrtc.PeerConnection
videoTracks map[string]*webrtc.TrackLocalStaticRTP
}
func newSessionStore(rootDir string) (*sessionStore, error) {
@@ -213,6 +227,12 @@ func (s *sessionStore) refreshFromDisk() error {
if err != nil {
return err
}
for _, session := range sessions {
if session.Purpose == "" {
session.Purpose = PurposeRecording
}
session.recomputeAggregates()
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions = sessions
@@ -265,6 +285,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
ID: randomID(),
UserID: strings.TrimSpace(input.UserID),
Title: strings.TrimSpace(input.Title),
Purpose: SessionPurpose(defaultString(input.Purpose, string(PurposeRecording))),
Status: StatusCreated,
ArchiveStatus: ArchiveIdle,
PreviewStatus: PreviewIdle,
@@ -290,6 +311,106 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
return cloneSession(session), nil
}
func parseSessionTime(values ...string) time.Time {
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed
}
}
return time.Time{}
}
func sortSegmentsBySequence(segments []SegmentMeta) {
sort.Slice(segments, func(i, j int) bool {
return segments[i].Sequence < segments[j].Sequence
})
}
func maxInt64(value int64, minimum int64) int64 {
if value < minimum {
return minimum
}
return value
}
func trimSegmentsToDuration(segments []SegmentMeta, maxDuration time.Duration) (kept []SegmentMeta, removed []SegmentMeta) {
if len(segments) == 0 {
return []SegmentMeta{}, []SegmentMeta{}
}
limitMS := maxDuration.Milliseconds()
total := int64(0)
startIndex := len(segments) - 1
for index := len(segments) - 1; index >= 0; index-- {
total += maxInt64(segments[index].DurationMS, 1)
startIndex = index
if total >= limitMS {
break
}
}
kept = append([]SegmentMeta(nil), segments[startIndex:]...)
removed = append([]SegmentMeta(nil), segments[:startIndex]...)
return kept, removed
}
func sessionNeedsPreview(session *Session) bool {
if len(session.Segments) == 0 {
return false
}
if session.PreviewStatus == PreviewProcessing {
return false
}
if session.PreviewStatus != PreviewReady || session.PreviewSegments < len(session.Segments) {
return true
}
previewUpdatedAt := parseSessionTime(session.PreviewUpdatedAt)
if previewUpdatedAt.IsZero() {
return true
}
for _, segment := range session.Segments {
uploadedAt := parseSessionTime(segment.UploadedAt)
if !uploadedAt.IsZero() && uploadedAt.After(previewUpdatedAt) {
return true
}
}
return false
}
func (s *sessionStore) pruneExpiredRelaySessions(maxAge time.Duration, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
for id, session := range s.sessions {
if session.Purpose != PurposeRelay {
continue
}
lastActivity := parseSessionTime(session.UpdatedAt, session.LastStreamAt, session.LiveFrameUpdated, session.CreatedAt)
if lastActivity.IsZero() || now.Sub(lastActivity) < maxAge {
continue
}
delete(s.sessions, id)
delete(s.peers, id)
delete(s.viewerPeers, id)
delete(s.videoTracks, id)
if err := os.RemoveAll(s.sessionDir(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.RemoveAll(s.publicDir(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
func (s *sessionStore) getSession(id string) (*Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -415,7 +536,7 @@ func (s *sessionStore) listProcessableSessions() []*Session {
items = append(items, cloneSession(session))
continue
}
if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing {
if sessionNeedsPreview(session) {
items = append(items, cloneSession(session))
}
}
@@ -822,6 +943,7 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
return
}
removedSegments := []SegmentMeta{}
session, err := m.store.updateSession(sessionID, func(session *Session) error {
meta := SegmentMeta{
Sequence: sequence,
@@ -842,9 +964,12 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
if !found {
session.Segments = append(session.Segments, meta)
}
sort.Slice(session.Segments, func(i, j int) bool {
return session.Segments[i].Sequence < session.Segments[j].Sequence
})
sortSegmentsBySequence(session.Segments)
if session.Purpose == PurposeRelay {
var kept []SegmentMeta
kept, removedSegments = trimSegmentsToDuration(session.Segments, relayPreviewWindow)
session.Segments = kept
}
session.Status = StatusRecording
session.LastError = ""
return nil
@@ -853,6 +978,12 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
writeError(w, http.StatusNotFound, err.Error())
return
}
for _, segment := range removedSegments {
segmentPath := filepath.Join(m.store.segmentsDir(sessionID), segment.Filename)
if removeErr := os.Remove(segmentPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
log.Printf("failed to remove pruned relay segment %s: %v", segmentPath, removeErr)
}
}
writeJSON(w, http.StatusAccepted, map[string]any{"session": session})
}
@@ -919,6 +1050,9 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
log.Printf("[worker] failed to refresh session store: %v", err)
continue
}
if err := store.pruneExpiredRelaySessions(relayCacheTTL, time.Now().UTC()); err != nil {
log.Printf("[worker] failed to prune relay cache: %v", err)
}
sessions := store.listProcessableSessions()
for _, session := range sessions {
if err := processSession(store, session.ID); err != nil {
@@ -939,7 +1073,7 @@ func processSession(store *sessionStore, sessionID string) error {
return processFinalArchive(store, sessionID)
}
if current.PreviewSegments < len(current.Segments) {
if sessionNeedsPreview(current) {
return processRollingPreview(store, sessionID)
}
@@ -1010,9 +1144,7 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
inputs := make([]string, 0, len(session.Segments))
sort.Slice(session.Segments, func(i, j int) bool {
return session.Segments[i].Sequence < session.Segments[j].Sequence
})
sortSegmentsBySequence(session.Segments)
for _, segment := range session.Segments {
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
}
@@ -1038,9 +1170,11 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
}
}
mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4)
if mp4Err != nil {
log.Printf("[worker] mp4 archive generation failed for %s: %v", sessionID, mp4Err)
if finalize {
mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4)
if mp4Err != nil {
log.Printf("[worker] mp4 archive generation failed for %s: %v", sessionID, mp4Err)
}
}
webmInfo, webmStatErr := os.Stat(outputWebM)
@@ -1049,13 +1183,15 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
}
var mp4Size int64
var mp4URL string
if info, statErr := os.Stat(outputMP4); statErr == nil {
mp4Size = info.Size()
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
}
previewURL := fmt.Sprintf("/media/assets/sessions/%s/%s.webm", sessionID, baseName)
if mp4URL != "" {
previewURL = mp4URL
if finalize {
if info, statErr := os.Stat(outputMP4); statErr == nil {
mp4Size = info.Size()
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
}
if mp4URL != "" {
previewURL = mp4URL
}
}
_, updateErr := store.updateSession(sessionID, func(session *Session) error {

查看文件

@@ -2,12 +2,15 @@ package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestMediaHealthAndSessionLifecycle(t *testing.T) {
@@ -320,3 +323,130 @@ func TestLiveFrameUploadPublishesRelayFrame(t *testing.T) {
t.Fatalf("unexpected live frame content: %q", string(body))
}
}
func TestRelaySegmentUploadKeepsOnlyLatestMinute(t *testing.T) {
store, err := newSessionStore(t.TempDir())
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
server := newMediaServer(store)
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Buffer", Purpose: "relay"})
if err != nil {
t.Fatalf("createSession: %v", err)
}
for sequence := 0; sequence < 3; sequence += 1 {
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/segments?sequence="+strconv.Itoa(sequence)+"&durationMs=30000", strings.NewReader("segment"))
req.Header.Set("Content-Type", "video/webm")
res := httptest.NewRecorder()
server.routes().ServeHTTP(res, req)
if res.Code != http.StatusAccepted {
t.Fatalf("expected segment upload 202 for sequence %d, got %d", sequence, res.Code)
}
}
current, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if current.Purpose != PurposeRelay {
t.Fatalf("expected relay purpose, got %s", current.Purpose)
}
if len(current.Segments) != 2 {
t.Fatalf("expected latest 2 relay segments to remain, got %d", len(current.Segments))
}
if current.Segments[0].Sequence != 1 || current.Segments[1].Sequence != 2 {
t.Fatalf("expected relay segments 1 and 2 to remain, got %#v", current.Segments)
}
if _, err := os.Stat(filepath.Join(store.segmentsDir(session.ID), "000000.webm")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected earliest relay segment to be pruned from disk, got %v", err)
}
}
func TestProcessRelayPreviewPublishesBufferedWebM(t *testing.T) {
tempDir := t.TempDir()
store, err := newSessionStore(tempDir)
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Preview", Purpose: "relay"})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
if _, err := store.updateSession(session.ID, func(current *Session) error {
current.Segments = append(current.Segments, SegmentMeta{
Sequence: 0,
Filename: "000000.webm",
DurationMS: 60000,
SizeBytes: 7,
ContentType: "video/webm",
})
current.Purpose = PurposeRelay
return nil
}); err != nil {
t.Fatalf("updateSession: %v", err)
}
if err := processRollingPreview(store, session.ID); err != nil {
t.Fatalf("processRollingPreview: %v", err)
}
current, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if current.Playback.PreviewURL == "" || !strings.HasSuffix(current.Playback.PreviewURL, "/preview.webm") {
t.Fatalf("expected relay preview webm url, got %#v", current.Playback)
}
if current.Playback.MP4URL != "" {
t.Fatalf("expected relay preview to skip mp4 generation, got %#v", current.Playback)
}
}
func TestPruneExpiredRelaySessionsRemovesOldCache(t *testing.T) {
store, err := newSessionStore(t.TempDir())
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Old Relay", Purpose: "relay"})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
if err := os.MkdirAll(store.publicDir(session.ID), 0o755); err != nil {
t.Fatalf("mkdir public dir: %v", err)
}
if err := os.WriteFile(filepath.Join(store.publicDir(session.ID), "preview.webm"), []byte("preview"), 0o644); err != nil {
t.Fatalf("write preview: %v", err)
}
store.mu.Lock()
store.sessions[session.ID].Purpose = PurposeRelay
store.sessions[session.ID].UpdatedAt = time.Now().UTC().Add(-31 * time.Minute).Format(time.RFC3339)
store.mu.Unlock()
if err := store.pruneExpiredRelaySessions(relayCacheTTL, time.Now().UTC()); err != nil {
t.Fatalf("pruneExpiredRelaySessions: %v", err)
}
if _, err := store.getSession(session.ID); err == nil {
t.Fatalf("expected relay session to be removed from store")
}
if _, err := os.Stat(store.sessionDir(session.ID)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected relay session directory to be removed, got %v", err)
}
if _, err := os.Stat(store.publicDir(session.ID)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected relay public directory to be removed, got %v", err)
}
}

查看文件

@@ -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;
}