diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts index 75ae0db..8201ebb 100644 --- a/client/src/lib/changelog.ts +++ b/client/src/lib/changelog.ts @@ -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: [ diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 6ae645c..76b3305 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -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 | 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[] | 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}` : ""} ) : null} @@ -2860,8 +2982,8 @@ export default function LiveCamera() {
{runtimeRole === "viewer" ? viewerBufferReady - ? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。` - : `${viewerModeLabel},当前设备只能观看同步内容;持有端累计出缓存片段后会自动出现平滑视频。` + ? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。${viewerAccumulationHint}` + : `${viewerModeLabel},当前设备只能观看同步内容;${viewerAccumulationHint}` : "先完成拍摄校准,再开启自动动作识别。"}
@@ -3023,6 +3145,10 @@ export default function LiveCamera() { }
同步缓存:{displayRelayBufferLabel}
+
+ 已累积: + {formatElapsedSecondsLabel(viewerBufferedSeconds)} +
虚拟形象: {displayAvatarEnabled @@ -3045,6 +3171,9 @@ export default function LiveCamera() { ) : "等待首个心跳"}
+
+ {viewerAccumulationHint} +
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e274119..d6d8376 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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` 模块解构异常 diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 32abca1..d75b85c 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -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, }) => { diff --git a/tests/e2e/helpers/mockApp.ts b/tests/e2e/helpers/mockApp.ts index 4beb957..af4a9a3 100644 --- a/tests/e2e/helpers/mockApp.ts +++ b/tests/e2e/helpers/mockApp.ts @@ -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";