From 922a9fb63fb6b5bf94e8b2b6799268f58cf085aa Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Mar 2026 19:19:46 +0800 Subject: [PATCH] feat sync live analysis viewer state --- client/src/pages/LiveCamera.tsx | 152 +++++++++++++++++++++++++++----- tests/e2e/app.spec.ts | 4 + tests/e2e/helpers/mockApp.ts | 9 ++ 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index f88daaf..ca5256c 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -121,6 +121,15 @@ type RuntimeSnapshot = { phase?: "idle" | "analyzing" | "saving" | "safe" | "failed"; startedAt?: number; durationMs?: number; + title?: string; + sessionMode?: SessionMode; + qualityPreset?: CameraQualityPreset; + facingMode?: CameraFacing; + deviceKind?: "mobile" | "desktop"; + avatarEnabled?: boolean; + avatarKey?: AvatarKey; + avatarLabel?: string; + updatedAt?: number; currentAction?: ActionType; rawAction?: ActionType; feedback?: string[]; @@ -534,6 +543,20 @@ function getSessionBand(input: { overallScore: number; knownRatio: number; effec return { label: "待加强", tone: "bg-amber-500/10 text-amber-700" }; } +function getRuntimeSyncDelayMs(lastHeartbeatAt?: string | null) { + if (!lastHeartbeatAt) return null; + const heartbeatMs = new Date(lastHeartbeatAt).getTime(); + if (Number.isNaN(heartbeatMs)) return null; + return Math.max(0, Date.now() - heartbeatMs); +} + +function formatRuntimeSyncDelay(delayMs: number | null) { + if (delayMs == null) return "等待同步"; + if (delayMs < 1500) return "同步中"; + if (delayMs < 10_000) return `${(delayMs / 1000).toFixed(1)}s 延迟`; + return "同步较慢"; +} + export default function LiveCamera() { const { user } = useAuth(); const utils = trpc.useUtils(); @@ -675,6 +698,13 @@ export default function LiveCamera() { leaveStatusRef.current = leaveStatus; }, [leaveStatus]); + useEffect(() => { + if (runtimeRole === "viewer") { + setShowSetupGuide(false); + setSetupStep(0); + } + }, [runtimeRole]); + useEffect(() => { sessionModeRef.current = sessionMode; }, [sessionMode]); @@ -859,6 +889,15 @@ export default function LiveCamera() { phase: phase ?? leaveStatusRef.current, startedAt: sessionStartedAtRef.current || undefined, durationMs: durationMsRef.current, + title: runtimeSession?.title ?? `实时分析 ${ACTION_META[currentActionRef.current].label}`, + sessionMode: sessionModeRef.current, + qualityPreset, + facingMode: facing, + deviceKind: mobile ? "mobile" : "desktop", + avatarEnabled: avatarRenderRef.current.enabled, + avatarKey: avatarRenderRef.current.avatarKey, + avatarLabel: getAvatarPreset(avatarRenderRef.current.avatarKey)?.label || "猩猩", + updatedAt: Date.now(), currentAction: currentActionRef.current, rawAction: rawActionRef.current, feedback: feedbackRef.current, @@ -868,7 +907,7 @@ export default function LiveCamera() { unknownSegments: segmentsRef.current.filter((segment) => segment.isUnknown).length, archivedVideoCount: archivedVideosRef.current.length, recentSegments: segmentsRef.current.slice(-5), - }), []); + }), [facing, mobile, qualityPreset, runtimeSession?.title]); const closeBroadcastPeer = useCallback(() => { broadcastSessionIdRef.current = null; @@ -1644,6 +1683,7 @@ export default function LiveCamera() { await startCamera(facing, zoomTargetRef.current, qualityPreset); }, [facing, qualityPreset, startCamera]); + const displayLeaveStatus = runtimeRole === "viewer" ? (runtimeSnapshot?.phase ?? "idle") : leaveStatus; const displayAction = runtimeRole === "viewer" ? (runtimeSnapshot?.currentAction ?? "unknown") : currentAction; const displayRawAction = runtimeRole === "viewer" ? (runtimeSnapshot?.rawAction ?? "unknown") : rawAction; const displayScore = runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore; @@ -1655,6 +1695,33 @@ export default function LiveCamera() { ...runtimeSnapshot?.stabilityMeta, } : stabilityMeta; + const displaySessionMode = runtimeRole === "viewer" + ? (runtimeSnapshot?.sessionMode ?? runtimeSession?.sessionMode ?? sessionMode) + : sessionMode; + const displayQualityPreset = runtimeRole === "viewer" + ? (runtimeSnapshot?.qualityPreset ?? qualityPreset) + : qualityPreset; + const displayFacing = runtimeRole === "viewer" + ? (runtimeSnapshot?.facingMode ?? facing) + : facing; + const displayDeviceKind = runtimeRole === "viewer" + ? (runtimeSnapshot?.deviceKind ?? (mobile ? "mobile" : "desktop")) + : (mobile ? "mobile" : "desktop"); + const displayAvatarEnabled = runtimeRole === "viewer" + ? Boolean(runtimeSnapshot?.avatarEnabled) + : avatarEnabled; + const displayAvatarKey = runtimeRole === "viewer" + ? ((runtimeSnapshot?.avatarKey as AvatarKey | undefined) ?? resolvedAvatarKey) + : resolvedAvatarKey; + const displayAvatarPreset = getAvatarPreset(displayAvatarKey); + const displayAvatarLabel = runtimeRole === "viewer" + ? (runtimeSnapshot?.avatarLabel ?? displayAvatarPreset?.label ?? "猩猩") + : (displayAvatarPreset?.label || "猩猩"); + const runtimeSyncDelayMs = runtimeRole === "viewer" ? getRuntimeSyncDelayMs(runtimeSession?.lastHeartbeatAt) : null; + const runtimeSyncLabel = runtimeRole === "viewer" ? formatRuntimeSyncDelay(runtimeSyncDelayMs) : ""; + const displayRuntimeTitle = runtimeRole === "viewer" + ? (runtimeSnapshot?.title ?? runtimeSession?.title ?? "其他设备实时分析") + : (runtimeSession?.title ?? `实时分析 ${ACTION_META[currentAction].label}`); const hasVideoFeed = cameraActive || viewerConnected; const heroAction = ACTION_META[displayAction]; const rawActionMeta = ACTION_META[displayRawAction]; @@ -1665,7 +1732,7 @@ export default function LiveCamera() { const fullBodyAvatarPresets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d"); const previewTitle = runtimeRole === "viewer" ? viewerConnected - ? "同步观看中" + ? `${runtimeSyncLabel} · 同步观看中` : "正在连接同步画面" : analyzing ? displayStabilityMeta.pending && pendingActionMeta @@ -1906,42 +1973,50 @@ export default function LiveCamera() { - {leaveStatus === "analyzing" ? ( + {displayLeaveStatus === "analyzing" ? ( 分析进行中 - 当前仍在采集和识别动作数据,请先不要关闭浏览器或切走页面。 + {runtimeRole === "viewer" + ? "持有端仍在采集和识别动作数据,本页会按会话心跳持续同步视频与动作信息。" + : "当前仍在采集和识别动作数据,请先不要关闭浏览器或切走页面。"} ) : null} - {leaveStatus === "saving" ? ( + {displayLeaveStatus === "saving" ? ( 正在保存分析结果 - 实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。 + {runtimeRole === "viewer" + ? "持有端正在提交录像、动作区间和训练记录;本页会同步保存状态,可以稍后再刷新查看。" + : "实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。"} ) : null} - {leaveStatus === "safe" ? ( + {displayLeaveStatus === "safe" ? ( 分析结果已保存 - 当前分析数据已经提交完成。现在可以关闭浏览器、返回上一页,或切换到其他页面,不会影响已保存的数据。 + {runtimeRole === "viewer" + ? "持有端分析数据已经提交完成;本页显示的是同步结果,你现在可以离开,不会影响已保存的数据。" + : "当前分析数据已经提交完成。现在可以关闭浏览器、返回上一页,或切换到其他页面,不会影响已保存的数据。"} ) : null} - {leaveStatus === "failed" ? ( + {displayLeaveStatus === "failed" ? ( 分析保存失败 - 当前会话还没有完整写入,请先留在本页并重新尝试结束分析或检查网络状态。 + {runtimeRole === "viewer" + ? "持有端当前会话还没有完整写入,本页会继续显示最后一次同步状态。" + : "当前会话还没有完整写入,请先留在本页并重新尝试结束分析或检查网络状态。"} ) : null} @@ -1951,7 +2026,7 @@ export default function LiveCamera() { 同步观看模式 - {viewerModeLabel}。当前设备不会占用本地摄像头,也不能再次开启分析;如需查看同步画面,可直接点击“同步观看”。 + {viewerModeLabel}。当前设备不会占用本地摄像头,也不能再次开启分析;视频、动作、评分与会话信息会按心跳自动同步,允许 1 秒级延迟。 ) : null} @@ -1982,21 +2057,29 @@ export default function LiveCamera() { - {avatarEnabled ? `虚拟形象 ${resolvedAvatarLabel}` : "骨架叠加"} + {displayAvatarEnabled ? `虚拟形象 ${displayAvatarLabel}` : "骨架叠加"} - {(runtimeRole === "viewer" ? runtimeSession?.sessionMode : sessionMode) === "practice" ? "练习会话" : "训练 PK"} + {displaySessionMode === "practice" ? "练习会话" : "训练 PK"} + {runtimeRole === "viewer" ? ( + + + {runtimeSyncLabel} + + ) : null}
-

实时分析中枢

+

{displayRuntimeTitle}

- 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。 + {runtimeRole === "viewer" + ? `当前正在同步 ${displayDeviceKind === "mobile" ? "移动端" : "桌面端"} ${displayFacing === "environment" ? "后置/主摄视角" : "前置视角"} 画面。视频、动作、评分、最近区间、虚拟形象和会话状态会自动跟随持有端刷新,允许少量网络延迟。` + : "摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。"}

@@ -2082,10 +2165,10 @@ export default function LiveCamera() { 非未知片段 {displayVisibleSegments.length} - {avatarEnabled ? ( + {displayAvatarEnabled ? ( - 虚拟形象 {resolvedAvatarLabel} + 虚拟形象 {displayAvatarLabel} ) : null} @@ -2133,7 +2216,7 @@ export default function LiveCamera() {