Fix live camera gorilla avatar preset

这个提交包含在:
cryptocommuniums-afk
2026-03-15 21:03:06 +08:00
父节点 264d49475b
当前提交 139dc61b61
修改 5 个文件,包含 907 行新增117 行删除

查看文件

@@ -5,12 +5,28 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
import {
ACTION_WINDOW_FRAMES,
AVATAR_PRESETS,
createEmptyStabilizedActionMeta,
createStableActionState,
drawLiveCameraOverlay,
resolveAvatarKeyFromPrompt,
stabilizeActionStream,
type AvatarKey,
type AvatarRenderState,
type FrameActionSample,
type LiveActionType,
type StabilizedActionMeta,
} from "@/lib/liveCamera";
import {
Activity,
Camera,
@@ -34,7 +50,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type CameraFacing = "user" | "environment";
type SessionMode = "practice" | "pk";
type ActionType = "forehand" | "backhand" | "serve" | "volley" | "overhead" | "slice" | "lob" | "unknown";
type ActionType = LiveActionType;
type PoseScore = {
overall: number;
@@ -82,11 +98,6 @@ type AnalyzedFrame = {
feedback: string[];
};
type ActionObservation = {
action: ActionType;
confidence: number;
};
const ACTION_META: Record<ActionType, { label: string; tone: string; accent: string }> = {
forehand: { label: "正手挥拍", tone: "bg-emerald-500/10 text-emerald-700", accent: "bg-emerald-500" },
backhand: { label: "反手挥拍", tone: "bg-sky-500/10 text-sky-700", accent: "bg-sky-500" },
@@ -98,23 +109,16 @@ const ACTION_META: Record<ActionType, { label: string; tone: string; accent: str
unknown: { label: "未知动作", tone: "bg-slate-500/10 text-slate-700", accent: "bg-slate-500" },
};
const POSE_CONNECTIONS: Array<[number, number]> = [
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
[16, 20], [17, 19], [18, 20],
];
const SETUP_STEPS = [
{ title: "固定设备", desc: "手机或平板保持稳定,避免分析阶段发生晃动", icon: <Smartphone className="h-5 w-5" /> },
{ title: "保留全身", desc: "画面尽量覆盖从头到脚,便于识别重心和脚步", icon: <Monitor className="h-5 w-5" /> },
{ title: "确认视角", desc: "后置摄像头优先,横屏更适合完整挥拍追踪", icon: <Camera className="h-5 w-5" /> },
{ title: "开始分析", desc: "动作会按连续区间自动聚合,最长单段不超过 10 秒", icon: <Target className="h-5 w-5" /> },
{ title: "开始分析", desc: "动作会先经过 24 帧稳定窗口确认,再按连续区间聚合保存", icon: <Target className="h-5 w-5" /> },
];
const SEGMENT_MAX_MS = 10_000;
const MERGE_GAP_MS = 500;
const MIN_SEGMENT_MS = 250;
const MERGE_GAP_MS = 900;
const MIN_SEGMENT_MS = 1_200;
const CAMERA_QUALITY_PRESETS: Record<CameraQualityPreset, { label: string; subtitle: string; description: string }> = {
economy: {
label: "节省流量",
@@ -212,55 +216,6 @@ function createSegment(action: ActionType, elapsedMs: number, frame: AnalyzedFra
};
}
function stabilizeAnalyzedFrame(frame: AnalyzedFrame, history: ActionObservation[]): AnalyzedFrame {
const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
history.splice(0, history.length, ...nextHistory);
const weights = nextHistory.map((_, index) => index + 1);
const actionScores = nextHistory.reduce<Record<ActionType, number>>((acc, sample, index) => {
const weighted = sample.confidence * weights[index];
acc[sample.action] = (acc[sample.action] || 0) + weighted;
return acc;
}, {
forehand: 0,
backhand: 0,
serve: 0,
volley: 0,
overhead: 0,
slice: 0,
lob: 0,
unknown: 0,
});
const ranked = Object.entries(actionScores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
const [, runnerScore = 0] = ranked[1] || [];
const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
const averageConfidence = winnerSamples.length > 0
? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
: frame.confidence;
const stableAction =
winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
? frame.action
: winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
? frame.action
: winner;
const stableConfidence = stableAction === frame.action
? Math.max(frame.confidence, averageConfidence)
: averageConfidence;
return {
...frame,
action: stableAction,
confidence: clamp(stableConfidence, 0, 1),
feedback: stableAction === "unknown"
? ["系统正在继续观察,当前窗口内未形成稳定动作特征。", ...frame.feedback].slice(0, 3)
: frame.feedback,
};
}
function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): AnalyzedFrame {
const nose = landmarks[0];
const leftShoulder = landmarks[11];
@@ -488,33 +443,6 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
};
}
function drawOverlay(canvas: HTMLCanvasElement | null, landmarks: Point[] | undefined) {
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!landmarks) return;
ctx.strokeStyle = "rgba(25, 211, 155, 0.9)";
ctx.lineWidth = 3;
for (const [from, to] of POSE_CONNECTIONS) {
const a = landmarks[from];
const b = landmarks[to];
if (!a || !b || (a.visibility ?? 1) < 0.25 || (b.visibility ?? 1) < 0.25) continue;
ctx.beginPath();
ctx.moveTo(a.x * canvas.width, a.y * canvas.height);
ctx.lineTo(b.x * canvas.width, b.y * canvas.height);
ctx.stroke();
}
landmarks.forEach((point, index) => {
if ((point.visibility ?? 1) < 0.25) return;
ctx.fillStyle = index >= 11 && index <= 16 ? "rgba(253, 224, 71, 0.95)" : "rgba(255,255,255,0.88)";
ctx.beginPath();
ctx.arc(point.x * canvas.width, point.y * canvas.height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
ctx.fill();
});
}
function ScoreBar({ label, value, accent }: { label: string; value: number; accent?: string }) {
return (
<div className="space-y-1">
@@ -559,11 +487,17 @@ export default function LiveCamera() {
const animationRef = useRef<number>(0);
const sessionStartedAtRef = useRef<number>(0);
const trackingRef = useRef<TrackingState>({});
const actionHistoryRef = useRef<ActionObservation[]>([]);
const actionHistoryRef = useRef<FrameActionSample[]>([]);
const stableActionStateRef = useRef(createStableActionState());
const currentSegmentRef = useRef<ActionSegment | null>(null);
const segmentsRef = useRef<ActionSegment[]>([]);
const frameSamplesRef = useRef<PoseScore[]>([]);
const volatilitySamplesRef = useRef<number[]>([]);
const zoomTargetRef = useRef(1);
const avatarRenderRef = useRef<AvatarRenderState>({
enabled: false,
avatarKey: "gorilla",
});
const [cameraActive, setCameraActive] = useState(false);
const [facing, setFacing] = useState<CameraFacing>("environment");
@@ -577,12 +511,22 @@ export default function LiveCamera() {
const [immersivePreview, setImmersivePreview] = useState(false);
const [liveScore, setLiveScore] = useState<PoseScore | null>(null);
const [currentAction, setCurrentAction] = useState<ActionType>("unknown");
const [rawAction, setRawAction] = useState<ActionType>("unknown");
const [feedback, setFeedback] = useState<string[]>([]);
const [segments, setSegments] = useState<ActionSegment[]>([]);
const [durationMs, setDurationMs] = useState(0);
const [segmentFilter, setSegmentFilter] = useState<ActionType | "all">("all");
const [qualityPreset, setQualityPreset] = useState<CameraQualityPreset>("economy");
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
const [stabilityMeta, setStabilityMeta] = useState<StabilizedActionMeta>(() => createEmptyStabilizedActionMeta());
const [avatarEnabled, setAvatarEnabled] = useState(false);
const [avatarKey, setAvatarKey] = useState<AvatarKey>("gorilla");
const [avatarPrompt, setAvatarPrompt] = useState("");
const resolvedAvatarKey = useMemo(
() => resolveAvatarKeyFromPrompt(avatarPrompt, avatarKey),
[avatarKey, avatarPrompt],
);
const uploadMutation = trpc.video.upload.useMutation();
const saveLiveSessionMutation = trpc.analysis.liveSessionSave.useMutation({
@@ -597,6 +541,14 @@ export default function LiveCamera() {
});
const liveSessionsQuery = trpc.analysis.liveSessionList.useQuery({ limit: 8 });
useEffect(() => {
avatarRenderRef.current = {
enabled: avatarEnabled,
avatarKey: resolvedAvatarKey,
customLabel: avatarPrompt.trim() || undefined,
};
}, [avatarEnabled, avatarPrompt, resolvedAvatarKey]);
const visibleSegments = useMemo(
() => segments.filter((segment) => !segment.isUnknown).sort((a, b) => b.startMs - a.startMs),
[segments],
@@ -697,6 +649,12 @@ export default function LiveCamera() {
if (videoRef.current) {
videoRef.current.srcObject = null;
}
actionHistoryRef.current = [];
stableActionStateRef.current = createStableActionState();
volatilitySamplesRef.current = [];
setCurrentAction("unknown");
setRawAction("unknown");
setStabilityMeta(createEmptyStabilizedActionMeta());
setZoomState(readTrackZoomState(null));
setCameraActive(false);
}, [stopSessionRecorder]);
@@ -906,6 +864,10 @@ export default function LiveCamera() {
const averageFootwork = scoreSamples.length > 0 ? scoreSamples.reduce((sum, item) => sum + item.footwork, 0) / scoreSamples.length : liveScore?.footwork || 0;
const averageConsistency = scoreSamples.length > 0 ? scoreSamples.reduce((sum, item) => sum + item.consistency, 0) / scoreSamples.length : liveScore?.consistency || 0;
const sessionFeedback = Array.from(new Set(finalSegments.flatMap((segment) => segment.issueSummary))).slice(0, 5);
const averageRawVolatility = volatilitySamplesRef.current.length > 0
? volatilitySamplesRef.current.reduce((sum, value) => sum + value, 0) / volatilitySamplesRef.current.length
: 0;
const avatarState = avatarRenderRef.current;
let uploadedVideo: { videoId: number; url: string } | null = null;
const recordedBlob = await stopSessionRecorder();
@@ -948,8 +910,14 @@ export default function LiveCamera() {
feedback: sessionFeedback,
metrics: {
actionDurations: segmentDurations,
stabilizedActionDurations: segmentDurations,
averageConfidence: Math.round((scoreSamples.reduce((sum, item) => sum + item.confidence, 0) / Math.max(1, scoreSamples.length)) * 10) / 10,
sampleCount: scoreSamples.length,
stableWindowFrames: ACTION_WINDOW_FRAMES,
actionSwitchCount: stableActionStateRef.current.switchCount,
rawActionVolatility: Number(averageRawVolatility.toFixed(4)),
avatarEnabled: avatarState.enabled,
avatarKey: avatarState.enabled ? avatarState.avatarKey : null,
mobile,
},
segments: finalSegments.map((segment) => ({
@@ -987,8 +955,15 @@ export default function LiveCamera() {
currentSegmentRef.current = null;
trackingRef.current = {};
actionHistoryRef.current = [];
stableActionStateRef.current = createStableActionState();
frameSamplesRef.current = [];
volatilitySamplesRef.current = [];
sessionStartedAtRef.current = Date.now();
setCurrentAction("unknown");
setRawAction("unknown");
setLiveScore(null);
setFeedback([]);
setStabilityMeta(createEmptyStabilizedActionMeta());
setDurationMs(0);
startSessionRecorder(streamRef.current);
@@ -1023,19 +998,48 @@ export default function LiveCamera() {
canvas.height = video.videoHeight;
}
drawOverlay(canvas, results.poseLandmarks);
drawLiveCameraOverlay(canvas, results.poseLandmarks, avatarRenderRef.current);
if (!results.poseLandmarks) return;
const analyzed = stabilizeAnalyzedFrame(
analyzePoseFrame(results.poseLandmarks, trackingRef.current, performance.now()),
const frameTimestamp = performance.now();
const analyzed = analyzePoseFrame(results.poseLandmarks, trackingRef.current, frameTimestamp);
const nextStabilityMeta = stabilizeActionStream(
{
action: analyzed.action,
confidence: analyzed.confidence,
timestamp: frameTimestamp,
},
actionHistoryRef.current,
stableActionStateRef.current,
);
const elapsedMs = Date.now() - sessionStartedAtRef.current;
appendFrameToSegment(analyzed, elapsedMs);
frameSamplesRef.current.push(analyzed.score);
setLiveScore(analyzed.score);
setCurrentAction(analyzed.action);
setFeedback(analyzed.feedback);
const stabilityLabel = nextStabilityMeta.pendingAction ?? nextStabilityMeta.windowAction;
const stabilityFeedback = nextStabilityMeta.pending && stabilityLabel !== "unknown"
? [`正在确认 ${ACTION_META[stabilityLabel].label},需要持续约 0.7 秒后再切换。`, ...analyzed.feedback]
: nextStabilityMeta.stableAction === "unknown"
? ["系统正在积累 24 帧动作窗口,当前先作为观察片段处理。", ...analyzed.feedback]
: analyzed.action !== nextStabilityMeta.stableAction
? [`原始候选为 ${ACTION_META[analyzed.action].label},当前保持 ${ACTION_META[nextStabilityMeta.stableAction].label}`, ...analyzed.feedback]
: analyzed.feedback;
const displayedScore: PoseScore = {
...analyzed.score,
confidence: Math.round(nextStabilityMeta.stableConfidence * 100),
};
const stabilizedFrame: AnalyzedFrame = {
...analyzed,
action: nextStabilityMeta.stableAction,
confidence: nextStabilityMeta.stableConfidence,
score: displayedScore,
feedback: stabilityFeedback.slice(0, 3),
};
appendFrameToSegment(stabilizedFrame, elapsedMs);
frameSamplesRef.current.push(displayedScore);
volatilitySamplesRef.current.push(nextStabilityMeta.rawVolatility);
setLiveScore(displayedScore);
setCurrentAction(nextStabilityMeta.stableAction);
setRawAction(analyzed.action);
setStabilityMeta(nextStabilityMeta);
setFeedback(stabilizedFrame.feedback);
setDurationMs(elapsedMs);
});
@@ -1108,7 +1112,16 @@ export default function LiveCamera() {
}, [facing, qualityPreset, startCamera]);
const heroAction = ACTION_META[currentAction];
const previewTitle = analyzing ? `${heroAction.label} 识别中` : cameraActive ? "准备开始实时分析" : "摄像头待启动";
const rawActionMeta = ACTION_META[rawAction];
const pendingActionMeta = stabilityMeta.pendingAction ? ACTION_META[stabilityMeta.pendingAction] : null;
const resolvedAvatarLabel = AVATAR_PRESETS.find((preset) => preset.key === resolvedAvatarKey)?.label || "猩猩";
const previewTitle = analyzing
? stabilityMeta.pending && pendingActionMeta
? `${pendingActionMeta.label} 切换确认中`
: `${heroAction.label} 识别中`
: cameraActive
? "准备开始实时分析"
: "摄像头待启动";
const renderPrimaryActions = (rail = false) => {
const buttonClass = rail
@@ -1285,12 +1298,16 @@ export default function LiveCamera() {
<div className="flex flex-wrap items-center gap-2">
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
<Sparkles className="h-3.5 w-3.5" />
24
</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" />
+
</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}` : "骨架叠加"}
</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" />
{sessionMode === "practice" ? "练习会话" : "训练 PK"}
@@ -1303,23 +1320,27 @@ export default function LiveCamera() {
<div>
<h1 className="text-3xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
24
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-white/5 p-2 text-center text-xs text-white/75 sm:w-[360px]">
<div className="grid grid-cols-2 gap-2 rounded-2xl border border-white/10 bg-white/5 p-2 text-center text-xs text-white/75 sm:w-[420px]">
<div className="rounded-xl bg-black/15 px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-sm font-semibold text-white">{heroAction.label}</div>
</div>
<div className="rounded-xl bg-black/15 px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-sm font-semibold text-white">{rawActionMeta.label}</div>
</div>
<div className="rounded-xl bg-black/15 px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold text-white">{formatDuration(durationMs)}</div>
</div>
<div className="rounded-xl bg-black/15 px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold text-white">{segments.length}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold text-white">{stabilityMeta.windowFrames}/{ACTION_WINDOW_FRAMES}</div>
</div>
</div>
</div>
@@ -1359,12 +1380,18 @@ export default function LiveCamera() {
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Activity className="h-3.5 w-3.5" />
{previewTitle}
{previewTitle}
</Badge>
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Target className="h-3.5 w-3.5" />
{visibleSegments.length}
</Badge>
{avatarEnabled ? (
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
{resolvedAvatarLabel}
</Badge>
) : null}
</div>
{mobile ? (
@@ -1381,9 +1408,28 @@ export default function LiveCamera() {
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
{(analyzing || saving) ? (
<div className="absolute bottom-3 left-3 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
{saving ? "正在保存会话..." : `识别中 · ${formatDuration(durationMs)}`}
{(cameraActive || saving) ? (
<div className="absolute bottom-3 left-3 right-20 rounded-[24px] border border-white/10 bg-black/65 px-3 py-3 text-white shadow-lg backdrop-blur-sm sm:right-[112px]">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1 text-sm font-semibold">{heroAction.label}</div>
<div className="mt-1 text-xs text-white/60"> {rawActionMeta.label}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1 text-sm font-semibold">
{stabilityMeta.windowFrames}/{ACTION_WINDOW_FRAMES} · {Math.round(stabilityMeta.windowShare * 100)}%
</div>
<div className="mt-1 text-xs text-white/60">
{saving
? "正在保存会话..."
: stabilityMeta.pending && pendingActionMeta
? `切换确认中 · ${pendingActionMeta.label} · ${Math.max(0, stabilityMeta.candidateMs / 1000).toFixed(1)}s`
: `已稳定 ${Math.max(0, stabilityMeta.stableMs / 1000).toFixed(1)}s · 波动 ${Math.round(stabilityMeta.rawVolatility * 100)}%`}
</div>
</div>
</div>
</div>
) : null}
</div>
@@ -1403,6 +1449,50 @@ export default function LiveCamera() {
{renderPrimaryActions()}
</div>
</div>
<div className="mt-4 grid gap-3 rounded-[24px] border border-border/60 bg-muted/20 p-4 lg:grid-cols-[minmax(0,1.1fr)_180px_220px]">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-muted-foreground">
</div>
</div>
<Switch
checked={avatarEnabled}
onCheckedChange={setAvatarEnabled}
disabled={!cameraActive && !analyzing}
data-testid="live-camera-avatar-switch"
/>
</div>
<div className="text-xs text-muted-foreground">
{resolvedAvatarLabel}
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入别名自动映射到内置形象"}
</div>
</div>
<div>
<div className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground"></div>
<Select value={avatarKey} onValueChange={(value) => setAvatarKey(value as AvatarKey)}>
<SelectTrigger className="h-12 rounded-2xl border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVATAR_PRESETS.map((preset) => (
<SelectItem key={preset.key} value={preset.key}>{preset.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<div className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground"></div>
<Input
value={avatarPrompt}
onChange={(event) => setAvatarPrompt(event.target.value)}
placeholder="例如 猴子 / dog mascot"
className="h-12 rounded-2xl border-border/60"
/>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1410,7 +1500,7 @@ export default function LiveCamera() {
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>使</CardDescription>
<CardDescription>使 24 </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
@@ -1481,7 +1571,7 @@ export default function LiveCamera() {
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
10 便
10 便
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -1618,6 +1708,25 @@ export default function LiveCamera() {
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between text-sm">
<span></span>
<Badge className={heroAction.tone}>{heroAction.label}</Badge>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div> {rawActionMeta.label}</div>
<div> {stabilityMeta.windowFrames}/{ACTION_WINDOW_FRAMES}</div>
<div> {Math.round(stabilityMeta.windowShare * 100)}%</div>
<div> {stabilityMeta.switchCount} </div>
</div>
<Progress value={stabilityMeta.windowProgress * 100} className="mt-3 h-2" />
<div className="mt-2 text-xs text-muted-foreground">
{stabilityMeta.pending && pendingActionMeta
? `当前正在确认 ${pendingActionMeta.label},确认后才会切段入库。`
: "当前区间只会按稳定动作聚合,短时抖动不会直接切换动作。"}
</div>
</div>
{feedback.length > 0 ? feedback.map((item) => (
<div key={item} className="rounded-2xl border border-border/60 bg-muted/25 px-4 py-3 text-sm">
{item}
@@ -1714,12 +1823,41 @@ export default function LiveCamera() {
<Sparkles className="h-3.5 w-3.5" />
{heroAction.label}
</Badge>
{avatarEnabled ? (
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Camera className="h-3.5 w-3.5" />
{resolvedAvatarLabel}
</Badge>
) : null}
<Badge className="gap-1.5 bg-black/60 text-white shadow-sm">
<Target className="h-3.5 w-3.5" />
</Badge>
</div>
<div className="absolute bottom-3 left-3 right-3 rounded-[24px] border border-white/10 bg-black/65 px-3 py-3 text-white shadow-lg backdrop-blur-sm">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1 text-sm font-semibold">{heroAction.label}</div>
</div>
<div>
<div className="uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1 text-sm font-semibold">{rawActionMeta.label}</div>
</div>
<div>
<div className="uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1">{stabilityMeta.windowFrames}/{ACTION_WINDOW_FRAMES}</div>
</div>
<div>
<div className="uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-1">
{stabilityMeta.pending && pendingActionMeta ? `确认 ${pendingActionMeta.label}` : "稳定跟踪中"}
</div>
</div>
</div>
</div>
<Button
type="button"
size="icon"