Harden async task flows and enhance analysis tooling

这个提交包含在:
cryptocommuniums-afk
2026-03-15 08:05:37 +08:00
父节点 585fd5773d
当前提交 cb643ac154
修改 14 个文件,包含 566 行新增33 行删除

查看文件

@@ -271,10 +271,40 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
const visibility =
landmarks.reduce((sum, point) => sum + (point.visibility ?? 0.95), 0) /
Math.max(1, landmarks.length);
if (visibility < 0.42 || shoulderSpan < 0.08) {
tracking.prevTimestamp = timestamp;
tracking.prevRightWrist = rightWrist;
tracking.prevLeftWrist = leftWrist;
tracking.prevHipCenter = hipCenter;
tracking.lastAction = "unknown";
return {
action: "unknown",
confidence: 0.2,
score: {
overall: 48,
posture: 50,
balance: 48,
technique: 45,
footwork: 42,
consistency: 40,
confidence: 20,
},
feedback: ["当前画面人体可见度不足,请尽量让头肩和双脚都留在画面内。"],
};
}
const posture = clamp(100 - shoulderTilt * 780 - headOffset * 640, 0, 100);
const balance = clamp(100 - hipTilt * 900 - Math.max(0, 0.16 - footSpread) * 260, 0, 100);
const footwork = clamp(45 + Math.min(36, hipSpeed * 120) + Math.max(0, 165 - kneeBend) * 0.35, 0, 100);
@@ -286,6 +316,8 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
confidence: clamp(
(rightWrist && nose && rightWrist.y < nose.y ? 0.45 : 0.1) +
(rightElbow && rightShoulder && rightElbow.y < rightShoulder.y ? 0.18 : 0.04) +
clamp(contactHeight * 1.4, 0, 0.14) +
clamp((0.24 - footSpread) * 1.2, 0, 0.08) +
clamp((rightElbowAngle - 135) / 55, 0, 0.22) +
clamp(rightVerticalMotion * 4.5, 0, 0.15),
0,
@@ -305,8 +337,11 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
{
action: "forehand",
confidence: clamp(
(rightWrist && nose && rightWrist.x > nose.x ? 0.28 : 0.08) +
clamp(rightSpeed * 0.12, 0, 0.36) +
(rightWrist && nose && rightWrist.x > nose.x ? 0.24 : 0.08) +
(rightForward > 0.11 ? 0.16 : 0.04) +
clamp((wristSpread - 0.2) * 0.8, 0, 0.16) +
clamp((0.08 - torsoOffset) * 1.8, 0, 0.08) +
clamp(rightSpeed * 0.12, 0, 0.28) +
clamp((rightElbowAngle - 85) / 70, 0, 0.2),
0,
0.94,
@@ -315,8 +350,11 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
{
action: "backhand",
confidence: clamp(
((leftWrist && nose && leftWrist.x < nose.x) || (rightWrist && nose && rightWrist.x < nose.x) ? 0.28 : 0.08) +
clamp(Math.max(leftSpeed, rightSpeed) * 0.1, 0, 0.34) +
((leftWrist && nose && leftWrist.x < nose.x) || (rightWrist && nose && rightWrist.x < nose.x) ? 0.2 : 0.06) +
(leftForward > 0.1 ? 0.16 : 0.04) +
(rightWrist && hipCenter && rightWrist.x < hipCenter.x ? 0.12 : 0.02) +
clamp((wristSpread - 0.22) * 0.75, 0, 0.14) +
clamp(Math.max(leftSpeed, rightSpeed) * 0.1, 0, 0.22) +
clamp((leftElbowAngle - 85) / 70, 0, 0.18),
0,
0.92,
@@ -326,6 +364,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
action: "volley",
confidence: clamp(
(rightWrist && rightShoulder && Math.abs(rightWrist.y - rightShoulder.y) < 0.12 ? 0.3 : 0.08) +
clamp((0.16 - Math.abs(contactHeight - 0.08)) * 1.2, 0, 0.1) +
clamp((0.22 - Math.abs((rightWrist?.x ?? 0.5) - hipCenter.x)) * 1.5, 0, 0.18) +
clamp((1.8 - rightSpeed) * 0.14, 0, 0.18),
0,
@@ -336,6 +375,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
action: "slice",
confidence: clamp(
(rightWrist && rightShoulder && rightWrist.y > rightShoulder.y ? 0.18 : 0.06) +
clamp((contactHeight + 0.06) * 0.7, 0, 0.08) +
clamp((tracking.prevRightWrist && rightWrist && rightWrist.y > tracking.prevRightWrist.y ? 0.18 : 0.04), 0, 0.18) +
clamp(rightSpeed * 0.08, 0, 0.24),
0,
@@ -346,6 +386,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
action: "lob",
confidence: clamp(
(rightWrist && nose && rightWrist.y < nose.y + 0.1 ? 0.22 : 0.08) +
clamp((0.18 - Math.abs(rightForward)) * 1.2, 0, 0.08) +
clamp(rightVerticalMotion * 4.2, 0, 0.28) +
clamp((0.18 - Math.abs((rightWrist?.x ?? 0.5) - hipCenter.x)) * 1.4, 0, 0.18),
0,
@@ -356,7 +397,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
candidates.sort((a, b) => b.confidence - a.confidence);
const topCandidate = candidates[0] ?? { action: "unknown" as ActionType, confidence: 0.2 };
const action = topCandidate.confidence >= 0.5 ? topCandidate.action : "unknown";
const action = topCandidate.confidence >= 0.52 ? topCandidate.action : "unknown";
const techniqueBase =
action === "serve" || action === "overhead"
@@ -380,6 +421,9 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
if (action === "unknown") {
feedback.push("当前片段缺少完整挥拍特征,系统已归为未知动作。");
}
if (visibility < 0.65) {
feedback.push("人体关键点可见度偏低,建议调整机位让双臂和双脚完全入镜。");
}
if (posture < 72) {
feedback.push("上体轴线偏移较明显,击球准备时保持头肩稳定。");
}
@@ -465,6 +509,16 @@ function ScoreBar({ label, value, accent }: { label: string; value: number; acce
);
}
function getSessionBand(input: { overallScore: number; knownRatio: number; effectiveSegments: number }) {
if (input.overallScore >= 85 && input.knownRatio >= 0.72 && input.effectiveSegments >= 4) {
return { label: "高质量", tone: "bg-emerald-500/10 text-emerald-700" };
}
if (input.overallScore >= 72 && input.knownRatio >= 0.55 && input.effectiveSegments >= 2) {
return { label: "稳定", tone: "bg-sky-500/10 text-sky-700" };
}
return { label: "待加强", tone: "bg-amber-500/10 text-amber-700" };
}
export default function LiveCamera() {
useAuth();
const utils = trpc.useUtils();
@@ -501,6 +555,7 @@ export default function LiveCamera() {
const [feedback, setFeedback] = useState<string[]>([]);
const [segments, setSegments] = useState<ActionSegment[]>([]);
const [durationMs, setDurationMs] = useState(0);
const [segmentFilter, setSegmentFilter] = useState<ActionType | "all">("all");
const uploadMutation = trpc.video.upload.useMutation();
const saveLiveSessionMutation = trpc.analysis.liveSessionSave.useMutation({
@@ -520,6 +575,54 @@ export default function LiveCamera() {
[segments],
);
const unknownSegments = useMemo(() => segments.filter((segment) => segment.isUnknown), [segments]);
const filteredVisibleSegments = useMemo(
() => segmentFilter === "all" ? visibleSegments : visibleSegments.filter((segment) => segment.actionType === segmentFilter),
[segmentFilter, visibleSegments],
);
const actionStats = useMemo(() => {
const totals = new Map<ActionType, { count: number; durationMs: number; averageScore: number; averageConfidence: number }>();
visibleSegments.forEach((segment) => {
const current = totals.get(segment.actionType) ?? {
count: 0,
durationMs: 0,
averageScore: 0,
averageConfidence: 0,
};
const nextCount = current.count + 1;
totals.set(segment.actionType, {
count: nextCount,
durationMs: current.durationMs + segment.durationMs,
averageScore: ((current.averageScore * current.count) + segment.score) / nextCount,
averageConfidence: ((current.averageConfidence * current.count) + segment.confidenceAvg) / nextCount,
});
});
const totalDuration = Math.max(1, visibleSegments.reduce((sum, segment) => sum + segment.durationMs, 0));
return Array.from(totals.entries())
.map(([actionType, value]) => ({
actionType,
...value,
sharePct: Math.round((value.durationMs / totalDuration) * 100),
}))
.sort((a, b) => b.durationMs - a.durationMs);
}, [visibleSegments]);
const bestSegment = useMemo(
() => visibleSegments.reduce<ActionSegment | null>((best, segment) => {
if (!best) return segment;
return segment.score > best.score ? segment : best;
}, null),
[visibleSegments],
);
const knownRatio = segments.length > 0 ? visibleSegments.length / segments.length : 0;
const sessionBand = useMemo(
() => getSessionBand({
overallScore: liveScore?.overall || 0,
knownRatio,
effectiveSegments: visibleSegments.length,
}),
[knownRatio, liveScore?.overall, visibleSegments.length],
);
useEffect(() => {
navigator.mediaDevices?.enumerateDevices().then((devices) => {
@@ -1082,7 +1185,7 @@ 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" />
@@ -1136,12 +1239,34 @@ export default function LiveCamera() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{visibleSegments.length === 0 ? (
{actionStats.length > 0 ? (
<div className="flex flex-wrap gap-2">
<Button
variant={segmentFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setSegmentFilter("all")}
>
</Button>
{actionStats.map((item) => (
<Button
key={item.actionType}
variant={segmentFilter === item.actionType ? "default" : "outline"}
size="sm"
onClick={() => setSegmentFilter(item.actionType)}
>
{ACTION_META[item.actionType].label} · {item.count}
</Button>
))}
</div>
) : null}
{filteredVisibleSegments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
visibleSegments.map((segment) => {
filteredVisibleSegments.map((segment) => {
const meta = ACTION_META[segment.actionType];
return (
<div key={`${segment.actionType}-${segment.startMs}`} className="rounded-2xl border border-border/60 bg-muted/25 p-4">
@@ -1151,6 +1276,7 @@ export default function LiveCamera() {
<Badge className={meta.tone}>{meta.label}</Badge>
<Badge variant="outline">{formatDuration(segment.startMs)} - {formatDuration(segment.endMs)}</Badge>
<Badge variant="outline"> {formatDuration(segment.durationMs)}</Badge>
<Badge variant="outline"> {segment.keyFrames.length}</Badge>
</div>
<div className="text-sm text-muted-foreground">{segment.issueSummary.join(" · ") || "当前片段节奏稳定"}</div>
</div>
@@ -1189,6 +1315,7 @@ export default function LiveCamera() {
<div className="mt-3 flex items-center justify-center gap-2">
<Badge className={heroAction.tone}>{heroAction.label}</Badge>
<Badge variant="outline"> {liveScore.confidence}%</Badge>
<Badge className={sessionBand.tone}>{sessionBand.label}</Badge>
</div>
</div>
<div className="space-y-3">
@@ -1207,6 +1334,39 @@ export default function LiveCamera() {
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{actionStats.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
actionStats.map((item) => (
<div key={item.actionType} className="space-y-2 rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge className={ACTION_META[item.actionType].tone}>{ACTION_META[item.actionType].label}</Badge>
<span className="text-xs text-muted-foreground">{item.count} </span>
</div>
<div className="text-xs text-muted-foreground">
{Math.round(item.averageScore)} · {Math.round(item.averageConfidence * 100)}%
</div>
</div>
<Progress value={item.sharePct} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span> {formatDuration(item.durationMs)}</span>
<span> {item.sharePct}%</span>
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
@@ -1234,6 +1394,18 @@ export default function LiveCamera() {
className="mt-3 h-2"
/>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{Math.round(knownRatio * 100)}%</span>
</div>
<Progress value={knownRatio * 100} className="mt-3 h-2" />
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div> {bestSegment ? `${Math.round(bestSegment.score)}` : "暂无"}</div>
<div> {actionStats[0] ? ACTION_META[actionStats[0].actionType].label : "未知"}</div>
</div>
</div>
</CardContent>
</Card>
@@ -1265,6 +1437,17 @@ export default function LiveCamera() {
<div> {session.effectiveSegments || 0}</div>
<div> {formatDuration(session.durationMs || 0)}</div>
</div>
{session.videoUrl ? (
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => window.open(session.videoUrl, "_blank", "noopener,noreferrer")}
>
</Button>
</div>
) : null}
</div>
))
)}