Harden live camera viewer sync rendering
这个提交包含在:
@@ -19,6 +19,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
"同步观看的 relay 缓存时长改为按会话配置,范围 10 秒到 5 分钟,默认 2 分钟;viewer 文案、徽标和设置面板都会实时显示当前缓存窗口",
|
||||
"owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频",
|
||||
"media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作",
|
||||
"同步端渲染远端 recentSegments 时新增旧快照归一化,`keyFrames`、`issueSummary` 等数组字段缺失时也会自动补默认值,避免再出现 `Cannot read properties of undefined (reading 'length')`",
|
||||
"同步观看界面新增“已累积 / 还需多久才能看到首段回放 / 距离目标缓存还差多少”的提示,观看端不再只显示笼统的等待文案",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision",
|
||||
],
|
||||
tests: [
|
||||
|
||||
@@ -255,6 +255,7 @@ const MERGE_GAP_MS = 900;
|
||||
const MIN_SEGMENT_MS = 1_200;
|
||||
const ANALYSIS_RECORDING_SEGMENT_MS = 60_000;
|
||||
const RELAY_UPLOAD_SEGMENT_MS = 10_000;
|
||||
const VIEWER_MIN_PLAYABLE_BUFFER_SECONDS = RELAY_UPLOAD_SEGMENT_MS / 1000;
|
||||
const RELAY_BUFFER_OPTIONS = [
|
||||
{ value: 10, label: "10 秒缓存" },
|
||||
{ value: 30, label: "30 秒缓存" },
|
||||
@@ -821,6 +822,106 @@ function formatRelayBufferLabel(seconds: number | null | undefined) {
|
||||
return `${minutes} 分 ${remainSeconds} 秒`;
|
||||
}
|
||||
|
||||
function formatElapsedSecondsLabel(seconds: number | null | undefined) {
|
||||
const normalized = Math.max(0, Math.floor(seconds ?? 0));
|
||||
if (normalized < 60) {
|
||||
return `${normalized} 秒`;
|
||||
}
|
||||
if (normalized % 60 === 0) {
|
||||
return `${normalized / 60} 分钟`;
|
||||
}
|
||||
const minutes = Math.floor(normalized / 60);
|
||||
const remainSeconds = normalized % 60;
|
||||
return `${minutes} 分 ${remainSeconds} 秒`;
|
||||
}
|
||||
|
||||
function normalizeActionSegment(input: Partial<ActionSegment> | null | undefined) {
|
||||
const actionType = ACTION_META[(input?.actionType as ActionType) ?? "unknown"]
|
||||
? ((input?.actionType as ActionType) ?? "unknown")
|
||||
: "unknown";
|
||||
const startMs = Math.max(0, Number(input?.startMs ?? 0));
|
||||
const endMs = Math.max(startMs, Number(input?.endMs ?? startMs));
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
Number(input?.durationMs ?? Math.max(0, endMs - startMs))
|
||||
);
|
||||
const keyFrames = Array.isArray(input?.keyFrames)
|
||||
? input!.keyFrames
|
||||
.map(value => Number(value))
|
||||
.filter(value => Number.isFinite(value) && value >= 0)
|
||||
: [];
|
||||
const issueSummary = Array.isArray(input?.issueSummary)
|
||||
? input!.issueSummary.filter(
|
||||
(value): value is string => typeof value === "string" && value.length > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
actionType,
|
||||
isUnknown: Boolean(input?.isUnknown ?? actionType === "unknown"),
|
||||
startMs,
|
||||
endMs,
|
||||
durationMs,
|
||||
confidenceAvg: Number(input?.confidenceAvg ?? 0),
|
||||
score: Number(input?.score ?? 0),
|
||||
peakScore: Number(input?.peakScore ?? input?.score ?? 0),
|
||||
frameCount: Math.max(0, Number(input?.frameCount ?? 0)),
|
||||
issueSummary,
|
||||
keyFrames,
|
||||
clipLabel:
|
||||
typeof input?.clipLabel === "string" && input.clipLabel.length > 0
|
||||
? input.clipLabel
|
||||
: `${ACTION_META[actionType].label} ${formatDuration(startMs)} - ${formatDuration(endMs)}`,
|
||||
} satisfies ActionSegment;
|
||||
}
|
||||
|
||||
function normalizeActionSegments(input: Partial<ActionSegment>[] | null | undefined) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return input.map(segment => normalizeActionSegment(segment));
|
||||
}
|
||||
|
||||
function formatViewerAccumulationHint({
|
||||
bufferedSeconds,
|
||||
relayBufferSeconds,
|
||||
previewReady,
|
||||
}: {
|
||||
bufferedSeconds: number;
|
||||
relayBufferSeconds: number;
|
||||
previewReady: boolean;
|
||||
}) {
|
||||
const normalizedBufferedSeconds = Math.max(0, bufferedSeconds);
|
||||
const remainingToPlayableSeconds = Math.max(
|
||||
0,
|
||||
VIEWER_MIN_PLAYABLE_BUFFER_SECONDS - normalizedBufferedSeconds
|
||||
);
|
||||
const remainingToTargetSeconds = Math.max(
|
||||
0,
|
||||
relayBufferSeconds - normalizedBufferedSeconds
|
||||
);
|
||||
const bufferedLabel = formatElapsedSecondsLabel(normalizedBufferedSeconds);
|
||||
const playableLabel = formatElapsedSecondsLabel(remainingToPlayableSeconds);
|
||||
const targetLabel = formatRelayBufferLabel(relayBufferSeconds);
|
||||
const targetRemainingLabel = formatElapsedSecondsLabel(
|
||||
remainingToTargetSeconds
|
||||
);
|
||||
|
||||
if (previewReady) {
|
||||
if (remainingToTargetSeconds > 0) {
|
||||
return `已累积 ${bufferedLabel},已经可以观看;距离当前 ${targetLabel} 缓存目标还差 ${targetRemainingLabel}。`;
|
||||
}
|
||||
return `已累积 ${bufferedLabel},当前缓存窗口已达到 ${targetLabel}。`;
|
||||
}
|
||||
|
||||
if (remainingToPlayableSeconds > 0) {
|
||||
return `已累积 ${bufferedLabel},预计还需 ${playableLabel} 才会出现首段可观看回放;当前缓存目标为 ${targetLabel}。`;
|
||||
}
|
||||
|
||||
return `首段缓存已到达,正在生成可观看回放;当前缓存目标为 ${targetLabel}。`;
|
||||
}
|
||||
|
||||
export default function LiveCamera() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -1018,12 +1119,14 @@ export default function LiveCamera() {
|
||||
),
|
||||
[segmentFilter, visibleSegments]
|
||||
);
|
||||
const normalizedViewerRecentSegments = useMemo(
|
||||
() => normalizeActionSegments(runtimeSnapshot?.recentSegments),
|
||||
[runtimeSnapshot?.recentSegments]
|
||||
);
|
||||
const viewerRecentSegments = useMemo(
|
||||
() =>
|
||||
(runtimeSnapshot?.recentSegments ?? []).filter(
|
||||
segment => !segment.isUnknown
|
||||
),
|
||||
[runtimeSnapshot?.recentSegments]
|
||||
normalizedViewerRecentSegments.filter(segment => !segment.isUnknown),
|
||||
[normalizedViewerRecentSegments]
|
||||
);
|
||||
const displayVisibleSegments =
|
||||
runtimeRole === "viewer" ? viewerRecentSegments : visibleSegments;
|
||||
@@ -2260,7 +2363,13 @@ export default function LiveCamera() {
|
||||
const displayScore =
|
||||
runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
|
||||
const displayFeedback =
|
||||
runtimeRole === "viewer" ? (runtimeSnapshot?.feedback ?? []) : feedback;
|
||||
runtimeRole === "viewer"
|
||||
? Array.isArray(runtimeSnapshot?.feedback)
|
||||
? runtimeSnapshot.feedback.filter(
|
||||
(item): item is string => typeof item === "string" && item.length > 0
|
||||
)
|
||||
: []
|
||||
: feedback;
|
||||
const displayDurationMs =
|
||||
runtimeRole === "viewer" ? (runtimeSnapshot?.durationMs ?? 0) : durationMs;
|
||||
const displayStabilityMeta =
|
||||
@@ -2331,6 +2440,18 @@ export default function LiveCamera() {
|
||||
const viewerBufferReady =
|
||||
runtimeRole === "viewer" &&
|
||||
Boolean(viewerMediaSession?.playback.previewUrl);
|
||||
const viewerBufferedSeconds = Math.max(
|
||||
0,
|
||||
Math.floor((viewerMediaSession?.durationMs ?? 0) / 1000)
|
||||
);
|
||||
const viewerAccumulationHint =
|
||||
runtimeRole === "viewer"
|
||||
? formatViewerAccumulationHint({
|
||||
bufferedSeconds: viewerBufferedSeconds,
|
||||
relayBufferSeconds: displayRelayBufferSeconds,
|
||||
previewReady: viewerBufferReady,
|
||||
})
|
||||
: "";
|
||||
const hasVideoFeed =
|
||||
runtimeRole === "viewer" ? viewerConnected : cameraActive;
|
||||
const heroAction = ACTION_META[displayAction];
|
||||
@@ -2705,6 +2826,7 @@ export default function LiveCamera() {
|
||||
。当前设备不会占用本地摄像头,也不能再次开启分析;同步画面会通过
|
||||
media 服务中转,并以最近 {displayRelayBufferLabel}
|
||||
缓存视频方式平滑回放,动作、评分与会话信息会按心跳自动同步。
|
||||
{viewerAccumulationHint ? ` ${viewerAccumulationHint}` : ""}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -2860,8 +2982,8 @@ export default function LiveCamera() {
|
||||
<div className="text-sm text-white/60">
|
||||
{runtimeRole === "viewer"
|
||||
? viewerBufferReady
|
||||
? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。`
|
||||
: `${viewerModeLabel},当前设备只能观看同步内容;持有端累计出缓存片段后会自动出现平滑视频。`
|
||||
? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。${viewerAccumulationHint}`
|
||||
: `${viewerModeLabel},当前设备只能观看同步内容;${viewerAccumulationHint}`
|
||||
: "先完成拍摄校准,再开启自动动作识别。"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3023,6 +3145,10 @@ export default function LiveCamera() {
|
||||
}
|
||||
</div>
|
||||
<div>同步缓存:{displayRelayBufferLabel}</div>
|
||||
<div>
|
||||
已累积:
|
||||
{formatElapsedSecondsLabel(viewerBufferedSeconds)}
|
||||
</div>
|
||||
<div>
|
||||
虚拟形象:
|
||||
{displayAvatarEnabled
|
||||
@@ -3045,6 +3171,9 @@ export default function LiveCamera() {
|
||||
)
|
||||
: "等待首个心跳"}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{viewerAccumulationHint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
- 多端同步观看的 relay 缓存窗口改为按会话配置,默认 `2` 分钟,可选最短 `10` 秒、最长 `5` 分钟;viewer 页面、徽标和设置卡都会同步显示当前缓存时长
|
||||
- owner 端分析录制在继续保持“每 `60` 秒自动归档”之外,会额外每 `10` 秒上传一次 relay 分片,因此短缓存模式下其他端不需要等待整整 `60` 秒才看到平滑同步视频
|
||||
- media 服务会按各自 relay 会话的缓存秒数裁剪 preview 分段;从磁盘恢复旧 relay 会话时也会自动归一化到合法范围,避免旧会话继续沿用固定 `60` 秒窗口
|
||||
- 线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,而不是旧资源版本;当前公开站点资源 revision 为 `assets/index-2-BhvFom.js`、`assets/index-BHHHsAWc.css`、`assets/pose-BPcIm7Xa.js`
|
||||
- 同步端渲染远端 `recentSegments` 时新增旧快照归一化;即使历史快照缺少 `keyFrames`、`issueSummary` 等数组字段,也会自动补默认值,不再触发 `Cannot read properties of undefined (reading 'length')`
|
||||
- 同步观看界面新增“已累积多少缓存、预计还需多久才能看到首段回放、距离目标缓存还差多少”的提示,观看端等待阶段会给出更明确的可观察时间说明
|
||||
- 线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,而不是旧资源版本;当前公开站点资源 revision 为 `assets/index-CYpJPG0R.js`、`assets/index-BHHHsAWc.css`、`assets/pose-C93FSit6.js`
|
||||
|
||||
### 测试
|
||||
|
||||
@@ -19,14 +21,14 @@
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts`
|
||||
- `playwright-skill` 线上 smoke:登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”,且无控制台与页面级错误
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `curl -I https://te.hao.work/assets/index-2-BhvFom.js`
|
||||
- `curl -I https://te.hao.work/assets/index-CYpJPG0R.js`
|
||||
- `curl -I https://te.hao.work/assets/index-BHHHsAWc.css`
|
||||
- `curl -I https://te.hao.work/assets/pose-BPcIm7Xa.js`
|
||||
- `curl -I https://te.hao.work/assets/pose-C93FSit6.js`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建,而不是旧资源版本
|
||||
- 当前公开站点前端资源 revision:`assets/index-2-BhvFom.js`、`assets/index-BHHHsAWc.css`、`assets/pose-BPcIm7Xa.js`
|
||||
- 当前公开站点前端资源 revision:`assets/index-CYpJPG0R.js`、`assets/index-BHHHsAWc.css`、`assets/pose-C93FSit6.js`
|
||||
- 已确认首页、主 JS、主 CSS 与 `pose` 模块均返回 `200`,且 MIME 分别为 `text/html`、`application/javascript`、`text/css`、`application/javascript`
|
||||
- 真实浏览器验证已通过:登录 `H1` 后进入 `/live-camera`,能够完成校准、启用摄像头并点击“开始分析”;页面会进入“分析进行中”状态,默认显示“缓存 2 分钟”,且未再出现 `Pose` 模块解构异常
|
||||
|
||||
|
||||
@@ -88,12 +88,54 @@ test("live camera switches into viewer mode when another device already owns ana
|
||||
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 viewer tolerates legacy segments and shows remaining buffer hint", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await installAppMocks(page, {
|
||||
authenticated: true,
|
||||
liveViewerMode: true,
|
||||
});
|
||||
|
||||
if (state.liveRuntime.runtimeSession?.snapshot) {
|
||||
state.liveRuntime.runtimeSession.snapshot.recentSegments = [
|
||||
{
|
||||
actionType: "forehand",
|
||||
isUnknown: false,
|
||||
startMs: 1200,
|
||||
endMs: 3600,
|
||||
durationMs: 2400,
|
||||
confidenceAvg: 0.82,
|
||||
score: 81,
|
||||
peakScore: 86,
|
||||
frameCount: 18,
|
||||
} as any,
|
||||
];
|
||||
}
|
||||
|
||||
if (state.mediaSession) {
|
||||
state.mediaSession.durationMs = 4_000;
|
||||
state.mediaSession.playback.previewUrl = undefined;
|
||||
}
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await expect(page.getByText("同步观看模式")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("live-camera-viewer-sync-card")
|
||||
.getByText(/预计还需 6 秒 才会出现首段可观看回放/)
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("关键帧 0")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera recovers mojibake viewer titles before rendering", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -754,13 +754,17 @@ async function handleMedia(route: Route, state: MockAppState) {
|
||||
|
||||
if (path === `/media/sessions/${state.mediaSession.id}`) {
|
||||
if (state.mediaSession.purpose === "relay") {
|
||||
state.mediaSession.previewStatus = "ready";
|
||||
state.mediaSession.previewStatus = state.mediaSession.playback.previewUrl
|
||||
? "ready"
|
||||
: "processing";
|
||||
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",
|
||||
ready: Boolean(state.mediaSession.playback.previewUrl),
|
||||
webmUrl:
|
||||
state.mediaSession.playback.webmUrl ??
|
||||
"/media/assets/sessions/session-e2e/preview.webm",
|
||||
webmSize: state.mediaSession.playback.webmSize ?? 1_800_000,
|
||||
previewUrl: state.mediaSession.playback.previewUrl,
|
||||
};
|
||||
} else {
|
||||
state.mediaSession.status = "archived";
|
||||
|
||||
在新工单中引用
屏蔽一个用户