|
|
|
@@ -121,6 +121,15 @@ type RuntimeSnapshot = {
|
|
|
|
phase?: "idle" | "analyzing" | "saving" | "safe" | "failed";
|
|
|
|
phase?: "idle" | "analyzing" | "saving" | "safe" | "failed";
|
|
|
|
startedAt?: number;
|
|
|
|
startedAt?: number;
|
|
|
|
durationMs?: number;
|
|
|
|
durationMs?: number;
|
|
|
|
|
|
|
|
title?: string;
|
|
|
|
|
|
|
|
sessionMode?: SessionMode;
|
|
|
|
|
|
|
|
qualityPreset?: CameraQualityPreset;
|
|
|
|
|
|
|
|
facingMode?: CameraFacing;
|
|
|
|
|
|
|
|
deviceKind?: "mobile" | "desktop";
|
|
|
|
|
|
|
|
avatarEnabled?: boolean;
|
|
|
|
|
|
|
|
avatarKey?: AvatarKey;
|
|
|
|
|
|
|
|
avatarLabel?: string;
|
|
|
|
|
|
|
|
updatedAt?: number;
|
|
|
|
currentAction?: ActionType;
|
|
|
|
currentAction?: ActionType;
|
|
|
|
rawAction?: ActionType;
|
|
|
|
rawAction?: ActionType;
|
|
|
|
feedback?: string[];
|
|
|
|
feedback?: string[];
|
|
|
|
@@ -534,6 +543,20 @@ function getSessionBand(input: { overallScore: number; knownRatio: number; effec
|
|
|
|
return { label: "待加强", tone: "bg-amber-500/10 text-amber-700" };
|
|
|
|
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() {
|
|
|
|
export default function LiveCamera() {
|
|
|
|
const { user } = useAuth();
|
|
|
|
const { user } = useAuth();
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
@@ -675,6 +698,13 @@ export default function LiveCamera() {
|
|
|
|
leaveStatusRef.current = leaveStatus;
|
|
|
|
leaveStatusRef.current = leaveStatus;
|
|
|
|
}, [leaveStatus]);
|
|
|
|
}, [leaveStatus]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (runtimeRole === "viewer") {
|
|
|
|
|
|
|
|
setShowSetupGuide(false);
|
|
|
|
|
|
|
|
setSetupStep(0);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, [runtimeRole]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
sessionModeRef.current = sessionMode;
|
|
|
|
sessionModeRef.current = sessionMode;
|
|
|
|
}, [sessionMode]);
|
|
|
|
}, [sessionMode]);
|
|
|
|
@@ -859,6 +889,15 @@ export default function LiveCamera() {
|
|
|
|
phase: phase ?? leaveStatusRef.current,
|
|
|
|
phase: phase ?? leaveStatusRef.current,
|
|
|
|
startedAt: sessionStartedAtRef.current || undefined,
|
|
|
|
startedAt: sessionStartedAtRef.current || undefined,
|
|
|
|
durationMs: durationMsRef.current,
|
|
|
|
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,
|
|
|
|
currentAction: currentActionRef.current,
|
|
|
|
rawAction: rawActionRef.current,
|
|
|
|
rawAction: rawActionRef.current,
|
|
|
|
feedback: feedbackRef.current,
|
|
|
|
feedback: feedbackRef.current,
|
|
|
|
@@ -868,7 +907,7 @@ export default function LiveCamera() {
|
|
|
|
unknownSegments: segmentsRef.current.filter((segment) => segment.isUnknown).length,
|
|
|
|
unknownSegments: segmentsRef.current.filter((segment) => segment.isUnknown).length,
|
|
|
|
archivedVideoCount: archivedVideosRef.current.length,
|
|
|
|
archivedVideoCount: archivedVideosRef.current.length,
|
|
|
|
recentSegments: segmentsRef.current.slice(-5),
|
|
|
|
recentSegments: segmentsRef.current.slice(-5),
|
|
|
|
}), []);
|
|
|
|
}), [facing, mobile, qualityPreset, runtimeSession?.title]);
|
|
|
|
|
|
|
|
|
|
|
|
const closeBroadcastPeer = useCallback(() => {
|
|
|
|
const closeBroadcastPeer = useCallback(() => {
|
|
|
|
broadcastSessionIdRef.current = null;
|
|
|
|
broadcastSessionIdRef.current = null;
|
|
|
|
@@ -1644,6 +1683,7 @@ export default function LiveCamera() {
|
|
|
|
await startCamera(facing, zoomTargetRef.current, qualityPreset);
|
|
|
|
await startCamera(facing, zoomTargetRef.current, qualityPreset);
|
|
|
|
}, [facing, qualityPreset, startCamera]);
|
|
|
|
}, [facing, qualityPreset, startCamera]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const displayLeaveStatus = runtimeRole === "viewer" ? (runtimeSnapshot?.phase ?? "idle") : leaveStatus;
|
|
|
|
const displayAction = runtimeRole === "viewer" ? (runtimeSnapshot?.currentAction ?? "unknown") : currentAction;
|
|
|
|
const displayAction = runtimeRole === "viewer" ? (runtimeSnapshot?.currentAction ?? "unknown") : currentAction;
|
|
|
|
const displayRawAction = runtimeRole === "viewer" ? (runtimeSnapshot?.rawAction ?? "unknown") : rawAction;
|
|
|
|
const displayRawAction = runtimeRole === "viewer" ? (runtimeSnapshot?.rawAction ?? "unknown") : rawAction;
|
|
|
|
const displayScore = runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
|
|
|
|
const displayScore = runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
|
|
|
|
@@ -1655,6 +1695,33 @@ export default function LiveCamera() {
|
|
|
|
...runtimeSnapshot?.stabilityMeta,
|
|
|
|
...runtimeSnapshot?.stabilityMeta,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
: 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 hasVideoFeed = cameraActive || viewerConnected;
|
|
|
|
const heroAction = ACTION_META[displayAction];
|
|
|
|
const heroAction = ACTION_META[displayAction];
|
|
|
|
const rawActionMeta = ACTION_META[displayRawAction];
|
|
|
|
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 fullBodyAvatarPresets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
|
|
|
|
const previewTitle = runtimeRole === "viewer"
|
|
|
|
const previewTitle = runtimeRole === "viewer"
|
|
|
|
? viewerConnected
|
|
|
|
? viewerConnected
|
|
|
|
? "同步观看中"
|
|
|
|
? `${runtimeSyncLabel} · 同步观看中`
|
|
|
|
: "正在连接同步画面"
|
|
|
|
: "正在连接同步画面"
|
|
|
|
: analyzing
|
|
|
|
: analyzing
|
|
|
|
? displayStabilityMeta.pending && pendingActionMeta
|
|
|
|
? displayStabilityMeta.pending && pendingActionMeta
|
|
|
|
@@ -1906,42 +1973,50 @@ export default function LiveCamera() {
|
|
|
|
</DialogContent>
|
|
|
|
</DialogContent>
|
|
|
|
</Dialog>
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{leaveStatus === "analyzing" ? (
|
|
|
|
{displayLeaveStatus === "analyzing" ? (
|
|
|
|
<Alert>
|
|
|
|
<Alert>
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<AlertTitle>分析进行中</AlertTitle>
|
|
|
|
<AlertTitle>分析进行中</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
|
|
<AlertDescription>
|
|
|
|
当前仍在采集和识别动作数据,请先不要关闭浏览器或切走页面。
|
|
|
|
{runtimeRole === "viewer"
|
|
|
|
|
|
|
|
? "持有端仍在采集和识别动作数据,本页会按会话心跳持续同步视频与动作信息。"
|
|
|
|
|
|
|
|
: "当前仍在采集和识别动作数据,请先不要关闭浏览器或切走页面。"}
|
|
|
|
</AlertDescription>
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
</Alert>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{leaveStatus === "saving" ? (
|
|
|
|
{displayLeaveStatus === "saving" ? (
|
|
|
|
<Alert>
|
|
|
|
<Alert>
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<AlertTitle>正在保存分析结果</AlertTitle>
|
|
|
|
<AlertTitle>正在保存分析结果</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
|
|
<AlertDescription>
|
|
|
|
实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。
|
|
|
|
{runtimeRole === "viewer"
|
|
|
|
|
|
|
|
? "持有端正在提交录像、动作区间和训练记录;本页会同步保存状态,可以稍后再刷新查看。"
|
|
|
|
|
|
|
|
: "实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。"}
|
|
|
|
</AlertDescription>
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
</Alert>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{leaveStatus === "safe" ? (
|
|
|
|
{displayLeaveStatus === "safe" ? (
|
|
|
|
<Alert>
|
|
|
|
<Alert>
|
|
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
|
|
<AlertTitle>分析结果已保存</AlertTitle>
|
|
|
|
<AlertTitle>分析结果已保存</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
|
|
<AlertDescription>
|
|
|
|
当前分析数据已经提交完成。现在可以关闭浏览器、返回上一页,或切换到其他页面,不会影响已保存的数据。
|
|
|
|
{runtimeRole === "viewer"
|
|
|
|
|
|
|
|
? "持有端分析数据已经提交完成;本页显示的是同步结果,你现在可以离开,不会影响已保存的数据。"
|
|
|
|
|
|
|
|
: "当前分析数据已经提交完成。现在可以关闭浏览器、返回上一页,或切换到其他页面,不会影响已保存的数据。"}
|
|
|
|
</AlertDescription>
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
</Alert>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{leaveStatus === "failed" ? (
|
|
|
|
{displayLeaveStatus === "failed" ? (
|
|
|
|
<Alert>
|
|
|
|
<Alert>
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<Activity className="h-4 w-4" />
|
|
|
|
<AlertTitle>分析保存失败</AlertTitle>
|
|
|
|
<AlertTitle>分析保存失败</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
|
|
<AlertDescription>
|
|
|
|
当前会话还没有完整写入,请先留在本页并重新尝试结束分析或检查网络状态。
|
|
|
|
{runtimeRole === "viewer"
|
|
|
|
|
|
|
|
? "持有端当前会话还没有完整写入,本页会继续显示最后一次同步状态。"
|
|
|
|
|
|
|
|
: "当前会话还没有完整写入,请先留在本页并重新尝试结束分析或检查网络状态。"}
|
|
|
|
</AlertDescription>
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
</Alert>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
@@ -1951,7 +2026,7 @@ export default function LiveCamera() {
|
|
|
|
<Monitor className="h-4 w-4" />
|
|
|
|
<Monitor className="h-4 w-4" />
|
|
|
|
<AlertTitle>同步观看模式</AlertTitle>
|
|
|
|
<AlertTitle>同步观看模式</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
|
|
<AlertDescription>
|
|
|
|
{viewerModeLabel}。当前设备不会占用本地摄像头,也不能再次开启分析;如需查看同步画面,可直接点击“同步观看”。
|
|
|
|
{viewerModeLabel}。当前设备不会占用本地摄像头,也不能再次开启分析;视频、动作、评分与会话信息会按心跳自动同步,允许 1 秒级延迟。
|
|
|
|
</AlertDescription>
|
|
|
|
</AlertDescription>
|
|
|
|
</Alert>
|
|
|
|
</Alert>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
@@ -1982,21 +2057,29 @@ export default function LiveCamera() {
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
|
|
|
|
<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" />
|
|
|
|
<Camera className="h-3.5 w-3.5" />
|
|
|
|
{avatarEnabled ? `虚拟形象 ${resolvedAvatarLabel}` : "骨架叠加"}
|
|
|
|
{displayAvatarEnabled ? `虚拟形象 ${displayAvatarLabel}` : "骨架叠加"}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
|
|
|
|
<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" />
|
|
|
|
<PlayCircle className="h-3.5 w-3.5" />
|
|
|
|
{(runtimeRole === "viewer" ? runtimeSession?.sessionMode : sessionMode) === "practice" ? "练习会话" : "训练 PK"}
|
|
|
|
{displaySessionMode === "practice" ? "练习会话" : "训练 PK"}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
|
|
|
|
<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" />
|
|
|
|
<Video className="h-3.5 w-3.5" />
|
|
|
|
默认 {CAMERA_QUALITY_PRESETS[qualityPreset].label}
|
|
|
|
默认 {CAMERA_QUALITY_PRESETS[displayQualityPreset].label}
|
|
|
|
</Badge>
|
|
|
|
</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>
|
|
|
|
<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">
|
|
|
|
<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>
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -2082,10 +2165,10 @@ export default function LiveCamera() {
|
|
|
|
<Target className="h-3.5 w-3.5" />
|
|
|
|
<Target className="h-3.5 w-3.5" />
|
|
|
|
非未知片段 {displayVisibleSegments.length}
|
|
|
|
非未知片段 {displayVisibleSegments.length}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
{avatarEnabled ? (
|
|
|
|
{displayAvatarEnabled ? (
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
虚拟形象 {resolvedAvatarLabel}
|
|
|
|
虚拟形象 {displayAvatarLabel}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -2133,7 +2216,7 @@ export default function LiveCamera() {
|
|
|
|
<div className="border-t border-border/60 bg-card/80 p-4">
|
|
|
|
<div className="border-t border-border/60 bg-card/80 p-4">
|
|
|
|
<div className="grid gap-3 md:grid-cols-[180px_minmax(0,1fr)]">
|
|
|
|
<div className="grid gap-3 md:grid-cols-[180px_minmax(0,1fr)]">
|
|
|
|
<Select
|
|
|
|
<Select
|
|
|
|
value={runtimeRole === "viewer" ? (runtimeSession?.sessionMode ?? sessionMode) : sessionMode}
|
|
|
|
value={displaySessionMode}
|
|
|
|
onValueChange={(value) => setSessionMode(value as SessionMode)}
|
|
|
|
onValueChange={(value) => setSessionMode(value as SessionMode)}
|
|
|
|
disabled={analyzing || saving || runtimeRole === "viewer"}
|
|
|
|
disabled={analyzing || saving || runtimeRole === "viewer"}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
@@ -2150,6 +2233,29 @@ export default function LiveCamera() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 rounded-[24px] border border-border/60 bg-muted/15 p-4 md:grid-cols-3">
|
|
|
|
<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="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="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">自动分析录像</div>
|
|
|
|
<div className="mt-2 text-lg font-semibold">每 60 秒自动切段</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">
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
<span>未知动作占比</span>
|
|
|
|
<span>未知动作占比</span>
|
|
|
|
<span className="font-medium">
|
|
|
|
<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>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<Progress
|
|
|
|
<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"
|
|
|
|
className="mt-3 h-2"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -2564,10 +2670,10 @@ export default function LiveCamera() {
|
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
{heroAction.label}
|
|
|
|
{heroAction.label}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
{avatarEnabled ? (
|
|
|
|
{displayAvatarEnabled ? (
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
<Camera className="h-3.5 w-3.5" />
|
|
|
|
<Camera className="h-3.5 w-3.5" />
|
|
|
|
{resolvedAvatarLabel}
|
|
|
|
{displayAvatarLabel}
|
|
|
|
</Badge>
|
|
|
|
</Badge>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
|
|
|
|
|