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 文案、徽标和设置面板都会实时显示当前缓存窗口", "同步观看的 relay 缓存时长改为按会话配置,范围 10 秒到 5 分钟,默认 2 分钟;viewer 文案、徽标和设置面板都会实时显示当前缓存窗口",
"owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频", "owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频",
"media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作", "media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作",
"同步端渲染远端 recentSegments 时新增旧快照归一化,`keyFrames`、`issueSummary` 等数组字段缺失时也会自动补默认值,避免再出现 `Cannot read properties of undefined (reading 'length')`",
"同步观看界面新增“已累积 / 还需多久才能看到首段回放 / 距离目标缓存还差多少”的提示,观看端不再只显示笼统的等待文案",
"线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision", "线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision",
], ],
tests: [ tests: [

查看文件

@@ -255,6 +255,7 @@ const MERGE_GAP_MS = 900;
const MIN_SEGMENT_MS = 1_200; const MIN_SEGMENT_MS = 1_200;
const ANALYSIS_RECORDING_SEGMENT_MS = 60_000; const ANALYSIS_RECORDING_SEGMENT_MS = 60_000;
const RELAY_UPLOAD_SEGMENT_MS = 10_000; const RELAY_UPLOAD_SEGMENT_MS = 10_000;
const VIEWER_MIN_PLAYABLE_BUFFER_SECONDS = RELAY_UPLOAD_SEGMENT_MS / 1000;
const RELAY_BUFFER_OPTIONS = [ const RELAY_BUFFER_OPTIONS = [
{ value: 10, label: "10 秒缓存" }, { value: 10, label: "10 秒缓存" },
{ value: 30, label: "30 秒缓存" }, { value: 30, label: "30 秒缓存" },
@@ -821,6 +822,106 @@ function formatRelayBufferLabel(seconds: number | null | undefined) {
return `${minutes}${remainSeconds}`; 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() { export default function LiveCamera() {
const { user } = useAuth(); const { user } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -1018,12 +1119,14 @@ export default function LiveCamera() {
), ),
[segmentFilter, visibleSegments] [segmentFilter, visibleSegments]
); );
const normalizedViewerRecentSegments = useMemo(
() => normalizeActionSegments(runtimeSnapshot?.recentSegments),
[runtimeSnapshot?.recentSegments]
);
const viewerRecentSegments = useMemo( const viewerRecentSegments = useMemo(
() => () =>
(runtimeSnapshot?.recentSegments ?? []).filter( normalizedViewerRecentSegments.filter(segment => !segment.isUnknown),
segment => !segment.isUnknown [normalizedViewerRecentSegments]
),
[runtimeSnapshot?.recentSegments]
); );
const displayVisibleSegments = const displayVisibleSegments =
runtimeRole === "viewer" ? viewerRecentSegments : visibleSegments; runtimeRole === "viewer" ? viewerRecentSegments : visibleSegments;
@@ -2260,7 +2363,13 @@ export default function LiveCamera() {
const displayScore = const displayScore =
runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore; runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
const displayFeedback = 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 = const displayDurationMs =
runtimeRole === "viewer" ? (runtimeSnapshot?.durationMs ?? 0) : durationMs; runtimeRole === "viewer" ? (runtimeSnapshot?.durationMs ?? 0) : durationMs;
const displayStabilityMeta = const displayStabilityMeta =
@@ -2331,6 +2440,18 @@ export default function LiveCamera() {
const viewerBufferReady = const viewerBufferReady =
runtimeRole === "viewer" && runtimeRole === "viewer" &&
Boolean(viewerMediaSession?.playback.previewUrl); 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 = const hasVideoFeed =
runtimeRole === "viewer" ? viewerConnected : cameraActive; runtimeRole === "viewer" ? viewerConnected : cameraActive;
const heroAction = ACTION_META[displayAction]; const heroAction = ACTION_META[displayAction];
@@ -2705,6 +2826,7 @@ export default function LiveCamera() {
media {displayRelayBufferLabel} media {displayRelayBufferLabel}
{viewerAccumulationHint ? ` ${viewerAccumulationHint}` : ""}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@@ -2860,8 +2982,8 @@ export default function LiveCamera() {
<div className="text-sm text-white/60"> <div className="text-sm text-white/60">
{runtimeRole === "viewer" {runtimeRole === "viewer"
? viewerBufferReady ? viewerBufferReady
? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。` ? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。${viewerAccumulationHint}`
: `${viewerModeLabel},当前设备只能观看同步内容;持有端累计出缓存片段后会自动出现平滑视频。` : `${viewerModeLabel},当前设备只能观看同步内容;${viewerAccumulationHint}`
: "先完成拍摄校准,再开启自动动作识别。"} : "先完成拍摄校准,再开启自动动作识别。"}
</div> </div>
</div> </div>
@@ -3023,6 +3145,10 @@ export default function LiveCamera() {
} }
</div> </div>
<div>{displayRelayBufferLabel}</div> <div>{displayRelayBufferLabel}</div>
<div>
{formatElapsedSecondsLabel(viewerBufferedSeconds)}
</div>
<div> <div>
{displayAvatarEnabled {displayAvatarEnabled
@@ -3045,6 +3171,9 @@ export default function LiveCamera() {
) )
: "等待首个心跳"} : "等待首个心跳"}
</div> </div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{viewerAccumulationHint}
</div>
</div> </div>
</div> </div>
</div> </div>

查看文件

@@ -8,7 +8,9 @@
- 多端同步观看的 relay 缓存窗口改为按会话配置,默认 `2` 分钟,可选最短 `10` 秒、最长 `5` 分钟;viewer 页面、徽标和设置卡都会同步显示当前缓存时长 - 多端同步观看的 relay 缓存窗口改为按会话配置,默认 `2` 分钟,可选最短 `10` 秒、最长 `5` 分钟;viewer 页面、徽标和设置卡都会同步显示当前缓存时长
- owner 端分析录制在继续保持“每 `60` 秒自动归档”之外,会额外每 `10` 秒上传一次 relay 分片,因此短缓存模式下其他端不需要等待整整 `60` 秒才看到平滑同步视频 - owner 端分析录制在继续保持“每 `60` 秒自动归档”之外,会额外每 `10` 秒上传一次 relay 分片,因此短缓存模式下其他端不需要等待整整 `60` 秒才看到平滑同步视频
- media 服务会按各自 relay 会话的缓存秒数裁剪 preview 分段;从磁盘恢复旧 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` - `pnpm exec playwright test tests/e2e/app.spec.ts`
- `playwright-skill` 线上 smoke登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”,且无控制台与页面级错误 - `playwright-skill` 线上 smoke登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”,且无控制台与页面级错误
- `curl -I https://te.hao.work/` - `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/index-BHHHsAWc.css`
- `curl -I https://te.hao.work/assets/pose-BPcIm7Xa.js` - `curl -I https://te.hao.work/assets/pose-C93FSit6.js`
### 线上 smoke ### 线上 smoke
- `https://te.hao.work/` 已切换到本次新构建,而不是旧资源版本 - `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` - 已确认首页、主 JS、主 CSS 与 `pose` 模块均返回 `200`,且 MIME 分别为 `text/html``application/javascript``text/css``application/javascript`
- 真实浏览器验证已通过:登录 `H1` 后进入 `/live-camera`,能够完成校准、启用摄像头并点击“开始分析”;页面会进入“分析进行中”状态,默认显示“缓存 2 分钟”,且未再出现 `Pose` 模块解构异常 - 真实浏览器验证已通过:登录 `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-viewer-sync-card")).toContainText( await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText(
"猩猩" "猩猩"
); );
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible(); 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 ({ test("live camera recovers mojibake viewer titles before rendering", async ({
page, page,
}) => { }) => {

查看文件

@@ -754,13 +754,17 @@ async function handleMedia(route: Route, state: MockAppState) {
if (path === `/media/sessions/${state.mediaSession.id}`) { if (path === `/media/sessions/${state.mediaSession.id}`) {
if (state.mediaSession.purpose === "relay") { if (state.mediaSession.purpose === "relay") {
state.mediaSession.previewStatus = "ready"; state.mediaSession.previewStatus = state.mediaSession.playback.previewUrl
? "ready"
: "processing";
state.mediaSession.previewUpdatedAt = nowIso(); state.mediaSession.previewUpdatedAt = nowIso();
state.mediaSession.playback = { state.mediaSession.playback = {
ready: true, ready: Boolean(state.mediaSession.playback.previewUrl),
webmUrl: "/media/assets/sessions/session-e2e/preview.webm", webmUrl:
webmSize: 1_800_000, state.mediaSession.playback.webmUrl ??
previewUrl: "/media/assets/sessions/session-e2e/preview.webm", "/media/assets/sessions/session-e2e/preview.webm",
webmSize: state.mediaSession.playback.webmSize ?? 1_800_000,
previewUrl: state.mediaSession.playback.previewUrl,
}; };
} else { } else {
state.mediaSession.status = "archived"; state.mediaSession.status = "archived";