Harden live camera viewer sync rendering

这个提交包含在:
cryptocommuniums-afk
2026-03-17 14:15:14 +08:00
父节点 597f16d0b9
当前提交 902bd783c9
修改 5 个文件,包含 195 行新增16 行删除

查看文件

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