feat sync live analysis viewer state

这个提交包含在:
cryptocommuniums-afk
2026-03-16 19:19:46 +08:00
父节点 31bead3452
当前提交 922a9fb63f
修改 3 个文件,包含 142 行新增23 行删除

查看文件

@@ -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() {
</DialogContent>
</Dialog>
{leaveStatus === "analyzing" ? (
{displayLeaveStatus === "analyzing" ? (
<Alert>
<Activity className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{runtimeRole === "viewer"
? "持有端仍在采集和识别动作数据,本页会按会话心跳持续同步视频与动作信息。"
: "当前仍在采集和识别动作数据,请先不要关闭浏览器或切走页面。"}
</AlertDescription>
</Alert>
) : null}
{leaveStatus === "saving" ? (
{displayLeaveStatus === "saving" ? (
<Alert>
<Activity className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{runtimeRole === "viewer"
? "持有端正在提交录像、动作区间和训练记录;本页会同步保存状态,可以稍后再刷新查看。"
: "实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。"}
</AlertDescription>
</Alert>
) : null}
{leaveStatus === "safe" ? (
{displayLeaveStatus === "safe" ? (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{runtimeRole === "viewer"
? "持有端分析数据已经提交完成;本页显示的是同步结果,你现在可以离开,不会影响已保存的数据。"
: "当前分析数据已经提交完成。现在可以关闭浏览器、返回上一页,或切换到其他页面,不会影响已保存的数据。"}
</AlertDescription>
</Alert>
) : null}
{leaveStatus === "failed" ? (
{displayLeaveStatus === "failed" ? (
<Alert>
<Activity className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{runtimeRole === "viewer"
? "持有端当前会话还没有完整写入,本页会继续显示最后一次同步状态。"
: "当前会话还没有完整写入,请先留在本页并重新尝试结束分析或检查网络状态。"}
</AlertDescription>
</Alert>
) : null}
@@ -1951,7 +2026,7 @@ export default function LiveCamera() {
<Monitor className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{viewerModeLabel}
{viewerModeLabel} 1
</AlertDescription>
</Alert>
) : null}
@@ -1982,21 +2057,29 @@ export default function LiveCamera() {
</Badge>
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
<Camera className="h-3.5 w-3.5" />
{avatarEnabled ? `虚拟形象 ${resolvedAvatarLabel}` : "骨架叠加"}
{displayAvatarEnabled ? `虚拟形象 ${displayAvatarLabel}` : "骨架叠加"}
</Badge>
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
<PlayCircle className="h-3.5 w-3.5" />
{(runtimeRole === "viewer" ? runtimeSession?.sessionMode : sessionMode) === "practice" ? "练习会话" : "训练 PK"}
{displaySessionMode === "practice" ? "练习会话" : "训练 PK"}
</Badge>
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
<Video className="h-3.5 w-3.5" />
{CAMERA_QUALITY_PRESETS[qualityPreset].label}
{CAMERA_QUALITY_PRESETS[displayQualityPreset].label}
</Badge>
{runtimeRole === "viewer" ? (
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10" data-testid="live-camera-viewer-delay-badge">
<Monitor className="h-3.5 w-3.5" />
{runtimeSyncLabel}
</Badge>
) : null}
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight"></h1>
<h1 className="text-3xl font-semibold tracking-tight">{displayRuntimeTitle}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
24 + / 60 10 4 3D Avatar
{runtimeRole === "viewer"
? `当前正在同步 ${displayDeviceKind === "mobile" ? "移动端" : "桌面端"} ${displayFacing === "environment" ? "后置/主摄视角" : "前置视角"} 画面。视频、动作、评分、最近区间、虚拟形象和会话状态会自动跟随持有端刷新,允许少量网络延迟。`
: "摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。"}
</p>
</div>
</div>
@@ -2082,10 +2165,10 @@ export default function LiveCamera() {
<Target className="h-3.5 w-3.5" />
{displayVisibleSegments.length}
</Badge>
{avatarEnabled ? (
{displayAvatarEnabled ? (
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
{resolvedAvatarLabel}
{displayAvatarLabel}
</Badge>
) : null}
</div>
@@ -2133,7 +2216,7 @@ export default function LiveCamera() {
<div className="border-t border-border/60 bg-card/80 p-4">
<div className="grid gap-3 md:grid-cols-[180px_minmax(0,1fr)]">
<Select
value={runtimeRole === "viewer" ? (runtimeSession?.sessionMode ?? sessionMode) : sessionMode}
value={displaySessionMode}
onValueChange={(value) => setSessionMode(value as SessionMode)}
disabled={analyzing || saving || runtimeRole === "viewer"}
>
@@ -2150,6 +2233,29 @@ export default function LiveCamera() {
</div>
</div>
<div className="mt-4 grid gap-3 rounded-[24px] border border-border/60 bg-muted/15 p-4 md:grid-cols-3">
{runtimeRole === "viewer" ? (
<div className="rounded-2xl border border-border/60 bg-background/90 p-4 md:col-span-3" data-testid="live-camera-viewer-sync-card">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{displayRuntimeTitle}</div>
<div className="mt-2 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>{displayDeviceKind === "mobile" ? "移动端" : "桌面端"}</div>
<div>{displayFacing === "environment" ? "后置 / 主摄" : "前置"}</div>
<div>{CAMERA_QUALITY_PRESETS[displayQualityPreset].label}</div>
<div>{displayAvatarEnabled ? displayAvatarLabel : "未开启"}</div>
</div>
</div>
<div className="min-w-[150px] rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 text-sm">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 font-semibold">{runtimeSyncLabel}</div>
<div className="mt-1 text-xs text-muted-foreground">
{runtimeSession?.lastHeartbeatAt ? formatDateTimeShanghai(runtimeSession.lastHeartbeatAt) : "等待首个心跳"}
</div>
</div>
</div>
</div>
) : null}
<div className="rounded-2xl border border-border/60 bg-background/90 p-4">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold"> 60 </div>
@@ -2482,11 +2588,11 @@ export default function LiveCamera() {
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">
{segments.length > 0 ? `${Math.round((unknownSegments.length / segments.length) * 100)}%` : "0%"}
{totalDisplaySegments > 0 ? `${Math.round(((runtimeRole === "viewer" ? (runtimeSnapshot?.unknownSegments ?? 0) : unknownSegments.length) / totalDisplaySegments) * 100)}%` : "0%"}
</span>
</div>
<Progress
value={segments.length > 0 ? (unknownSegments.length / segments.length) * 100 : 0}
value={totalDisplaySegments > 0 ? (((runtimeRole === "viewer" ? (runtimeSnapshot?.unknownSegments ?? 0) : unknownSegments.length) / totalDisplaySegments) * 100) : 0}
className="mt-3 h-2"
/>
</div>
@@ -2564,10 +2670,10 @@ export default function LiveCamera() {
<Sparkles className="h-3.5 w-3.5" />
{heroAction.label}
</Badge>
{avatarEnabled ? (
{displayAvatarEnabled ? (
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Camera className="h-3.5 w-3.5" />
{resolvedAvatarLabel}
{displayAvatarLabel}
</Badge>
) : null}
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">