Add auto archived overlay recordings for live analysis
这个提交包含在:
@@ -8,6 +8,26 @@ export type ChangeLogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
{
|
||||
version: "2026.03.16-live-analysis-overlay-archive",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "e3fe9a8 + local changes",
|
||||
summary: "实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
|
||||
features: [
|
||||
"实时分析开始后会自动录制合成画布,每 60 秒自动切段归档",
|
||||
"归档录像会保留原视频、骨架线、关键点和当前虚拟形象覆盖效果",
|
||||
"归档片段会自动写入视频库,标签显示为“实时分析”",
|
||||
"删除视频库中的实时分析录像时,不会删除已写入的实时分析数据和训练记录",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已切换到本次新构建,`/live-camera`、`/videos`、`/changelog` 页面均可正常访问",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm build",
|
||||
"pnpm test:e2e",
|
||||
"Playwright smoke: 真实站点登录 H1,完成 /live-camera 引导、开始/结束分析,并确认 /videos 可见实时分析条目",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-live-analysis-leave-hint",
|
||||
releaseDate: "2026-03-15",
|
||||
|
||||
@@ -662,18 +662,22 @@ function drawFullFigureAvatar(
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
export function renderLiveCameraOverlayToContext(
|
||||
ctx: CanvasRenderingContext2D | null,
|
||||
width: number,
|
||||
height: number,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
options?: { clear?: boolean },
|
||||
) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!ctx) return;
|
||||
if (options?.clear !== false) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
if (!landmarks) return;
|
||||
|
||||
if (avatarState?.enabled) {
|
||||
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
|
||||
const anchors = getAvatarAnchors(landmarks, width, height);
|
||||
if (anchors) {
|
||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||
const visual = AVATAR_VISUALS[avatarState.avatarKey];
|
||||
@@ -715,8 +719,8 @@ export function drawLiveCameraOverlay(
|
||||
const end = landmarks[to];
|
||||
if (!start || !end || (start.visibility ?? 1) < 0.25 || (end.visibility ?? 1) < 0.25) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x * canvas.width, start.y * canvas.height);
|
||||
ctx.lineTo(end.x * canvas.width, end.y * canvas.height);
|
||||
ctx.moveTo(start.x * width, start.y * height);
|
||||
ctx.lineTo(end.x * width, end.y * height);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
@@ -724,7 +728,17 @@ export function drawLiveCameraOverlay(
|
||||
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.arc(point.x * width, point.y * height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
renderLiveCameraOverlayToContext(ctx, canvas.width, canvas.height, landmarks, avatarState, { clear: true });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createStableActionState,
|
||||
drawLiveCameraOverlay,
|
||||
getAvatarPreset,
|
||||
renderLiveCameraOverlayToContext,
|
||||
resolveAvatarKeyFromPrompt,
|
||||
stabilizeActionStream,
|
||||
type AvatarKey,
|
||||
@@ -80,6 +81,14 @@ type ActionSegment = {
|
||||
clipLabel: string;
|
||||
};
|
||||
|
||||
type ArchivedAnalysisVideo = {
|
||||
videoId: number;
|
||||
url: string;
|
||||
sequence: number;
|
||||
durationMs: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -122,6 +131,7 @@ const SETUP_STEPS = [
|
||||
const SEGMENT_MAX_MS = 10_000;
|
||||
const MERGE_GAP_MS = 900;
|
||||
const MIN_SEGMENT_MS = 1_200;
|
||||
const ANALYSIS_RECORDING_SEGMENT_MS = 60_000;
|
||||
const CAMERA_QUALITY_PRESETS: Record<CameraQualityPreset, { label: string; subtitle: string; description: string }> = {
|
||||
economy: {
|
||||
label: "节省流量",
|
||||
@@ -482,10 +492,17 @@ export default function LiveCamera() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const poseRef = useRef<any>(null);
|
||||
const compositeCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const recorderStreamRef = useRef<MediaStream | null>(null);
|
||||
const recorderMimeTypeRef = useRef("video/webm");
|
||||
const recorderChunksRef = useRef<Blob[]>([]);
|
||||
const recorderStopPromiseRef = useRef<Promise<Blob | null> | null>(null);
|
||||
const recorderStopPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const recorderSegmentStartedAtRef = useRef<number>(0);
|
||||
const recorderSequenceRef = useRef(0);
|
||||
const recorderRotateTimerRef = useRef<number>(0);
|
||||
const recorderUploadQueueRef = useRef(Promise.resolve());
|
||||
const archivedVideosRef = useRef<ArchivedAnalysisVideo[]>([]);
|
||||
const analyzingRef = useRef(false);
|
||||
const animationRef = useRef<number>(0);
|
||||
const sessionStartedAtRef = useRef<number>(0);
|
||||
@@ -525,6 +542,7 @@ export default function LiveCamera() {
|
||||
const [avatarEnabled, setAvatarEnabled] = useState(false);
|
||||
const [avatarKey, setAvatarKey] = useState<AvatarKey>("gorilla");
|
||||
const [avatarPrompt, setAvatarPrompt] = useState("");
|
||||
const [archivedVideoCount, setArchivedVideoCount] = useState(0);
|
||||
|
||||
const resolvedAvatarKey = useMemo(
|
||||
() => resolveAvatarKeyFromPrompt(avatarPrompt, avatarKey),
|
||||
@@ -536,6 +554,7 @@ export default function LiveCamera() {
|
||||
onSuccess: () => {
|
||||
utils.profile.stats.invalidate();
|
||||
utils.analysis.liveSessionList.invalidate();
|
||||
utils.video.list.invalidate();
|
||||
utils.record.list.invalidate();
|
||||
utils.achievement.list.invalidate();
|
||||
utils.rating.current.invalidate();
|
||||
@@ -621,16 +640,94 @@ export default function LiveCamera() {
|
||||
}
|
||||
}, [cameraActive, immersivePreview]);
|
||||
|
||||
const ensureCompositeCanvas = useCallback(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (!compositeCanvasRef.current) {
|
||||
compositeCanvasRef.current = document.createElement("canvas");
|
||||
}
|
||||
return compositeCanvasRef.current;
|
||||
}, []);
|
||||
|
||||
const renderCompositeFrame = useCallback((landmarks?: Point[]) => {
|
||||
const video = videoRef.current;
|
||||
const compositeCanvas = ensureCompositeCanvas();
|
||||
if (!video || !compositeCanvas || video.videoWidth <= 0 || video.videoHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (compositeCanvas.width !== video.videoWidth || compositeCanvas.height !== video.videoHeight) {
|
||||
compositeCanvas.width = video.videoWidth;
|
||||
compositeCanvas.height = video.videoHeight;
|
||||
}
|
||||
|
||||
const ctx = compositeCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, compositeCanvas.width, compositeCanvas.height);
|
||||
ctx.drawImage(video, 0, 0, compositeCanvas.width, compositeCanvas.height);
|
||||
renderLiveCameraOverlayToContext(
|
||||
ctx,
|
||||
compositeCanvas.width,
|
||||
compositeCanvas.height,
|
||||
landmarks,
|
||||
avatarRenderRef.current,
|
||||
{ clear: false },
|
||||
);
|
||||
}, [ensureCompositeCanvas]);
|
||||
|
||||
const queueArchivedVideoUpload = useCallback(async (blob: Blob, sequence: number, durationMs: number) => {
|
||||
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
|
||||
const title = `实时分析录像 ${formatDateTimeShanghai(new Date(), {
|
||||
year: undefined,
|
||||
second: undefined,
|
||||
})} · 第 ${sequence} 段`;
|
||||
|
||||
recorderUploadQueueRef.current = recorderUploadQueueRef.current
|
||||
.then(async () => {
|
||||
const fileBase64 = await blobToBase64(blob);
|
||||
const uploaded = await uploadMutation.mutateAsync({
|
||||
title,
|
||||
format,
|
||||
fileSize: blob.size,
|
||||
duration: Math.max(1, Math.round(durationMs / 1000)),
|
||||
exerciseType: "live_analysis",
|
||||
fileBase64,
|
||||
});
|
||||
const nextVideo: ArchivedAnalysisVideo = {
|
||||
videoId: uploaded.videoId,
|
||||
url: uploaded.url,
|
||||
sequence,
|
||||
durationMs,
|
||||
title,
|
||||
};
|
||||
archivedVideosRef.current = [...archivedVideosRef.current, nextVideo].sort((a, b) => a.sequence - b.sequence);
|
||||
setArchivedVideoCount(archivedVideosRef.current.length);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(`分析录像第 ${sequence} 段归档失败: ${error?.message || "未知错误"}`);
|
||||
});
|
||||
|
||||
return recorderUploadQueueRef.current;
|
||||
}, [uploadMutation]);
|
||||
|
||||
const stopSessionRecorder = useCallback(async () => {
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) return null;
|
||||
if (recorderRotateTimerRef.current) {
|
||||
window.clearTimeout(recorderRotateTimerRef.current);
|
||||
recorderRotateTimerRef.current = 0;
|
||||
}
|
||||
if (!recorder) {
|
||||
await recorderUploadQueueRef.current;
|
||||
return;
|
||||
}
|
||||
const stopPromise = recorderStopPromiseRef.current;
|
||||
if (recorder.state !== "inactive") {
|
||||
recorder.stop();
|
||||
}
|
||||
recorderRef.current = null;
|
||||
recorderStopPromiseRef.current = null;
|
||||
return stopPromise ?? null;
|
||||
await (stopPromise ?? Promise.resolve());
|
||||
await recorderUploadQueueRef.current;
|
||||
}, []);
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
@@ -659,6 +756,9 @@ export default function LiveCamera() {
|
||||
setRawAction("unknown");
|
||||
setStabilityMeta(createEmptyStabilizedActionMeta());
|
||||
setZoomState(readTrackZoomState(null));
|
||||
archivedVideosRef.current = [];
|
||||
recorderSequenceRef.current = 0;
|
||||
setArchivedVideoCount(0);
|
||||
setCameraActive(false);
|
||||
}, [stopSessionRecorder]);
|
||||
|
||||
@@ -796,21 +896,35 @@ export default function LiveCamera() {
|
||||
currentSegmentRef.current = createSegment(frame.action, elapsedMs, frame);
|
||||
}, [flushSegment]);
|
||||
|
||||
const startSessionRecorder = useCallback((stream: MediaStream) => {
|
||||
const startSessionRecorder = useCallback(function startSessionRecorderInternal() {
|
||||
if (typeof MediaRecorder === "undefined") {
|
||||
recorderRef.current = null;
|
||||
recorderStopPromiseRef.current = Promise.resolve(null);
|
||||
recorderStopPromiseRef.current = Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const compositeCanvas = ensureCompositeCanvas();
|
||||
if (!compositeCanvas || typeof compositeCanvas.captureStream !== "function") {
|
||||
recorderRef.current = null;
|
||||
recorderStopPromiseRef.current = Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
renderCompositeFrame();
|
||||
recorderChunksRef.current = [];
|
||||
const mimeType = pickRecorderMimeType();
|
||||
recorderMimeTypeRef.current = mimeType;
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
if (!recorderStreamRef.current) {
|
||||
recorderStreamRef.current = compositeCanvas.captureStream(mobile ? 24 : 30);
|
||||
}
|
||||
const recorder = new MediaRecorder(recorderStreamRef.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: getLiveAnalysisBitrate(qualityPreset, mobile),
|
||||
});
|
||||
recorderRef.current = recorder;
|
||||
const sequence = recorderSequenceRef.current + 1;
|
||||
recorderSequenceRef.current = sequence;
|
||||
recorderSegmentStartedAtRef.current = Date.now();
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
@@ -820,14 +934,32 @@ export default function LiveCamera() {
|
||||
|
||||
recorderStopPromiseRef.current = new Promise((resolve) => {
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Math.max(0, Date.now() - recorderSegmentStartedAtRef.current);
|
||||
const type = recorderMimeTypeRef.current.includes("mp4") ? "video/mp4" : "video/webm";
|
||||
const blob = recorderChunksRef.current.length > 0 ? new Blob(recorderChunksRef.current, { type }) : null;
|
||||
resolve(blob);
|
||||
recorderChunksRef.current = [];
|
||||
recorderRef.current = null;
|
||||
recorderStopPromiseRef.current = null;
|
||||
if (blob && blob.size > 0 && durationMs > 0) {
|
||||
void queueArchivedVideoUpload(blob, sequence, durationMs);
|
||||
}
|
||||
if (analyzingRef.current) {
|
||||
startSessionRecorderInternal();
|
||||
} else if (recorderStreamRef.current) {
|
||||
recorderStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
recorderStreamRef.current = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
recorder.start(1000);
|
||||
}, [mobile, qualityPreset]);
|
||||
recorder.start();
|
||||
recorderRotateTimerRef.current = window.setTimeout(() => {
|
||||
if (recorder.state === "recording") {
|
||||
recorder.stop();
|
||||
}
|
||||
}, ANALYSIS_RECORDING_SEGMENT_MS);
|
||||
}, [ensureCompositeCanvas, mobile, qualityPreset, queueArchivedVideoUpload, renderCompositeFrame]);
|
||||
|
||||
const persistSession = useCallback(async () => {
|
||||
const endedAt = Date.now();
|
||||
@@ -871,27 +1003,9 @@ export default function LiveCamera() {
|
||||
? 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();
|
||||
if (recordedBlob && recordedBlob.size > 0) {
|
||||
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
|
||||
const fileBase64 = await blobToBase64(recordedBlob);
|
||||
uploadedVideo = await uploadMutation.mutateAsync({
|
||||
title: `实时分析 ${formatDateTimeShanghai(new Date(), {
|
||||
year: undefined,
|
||||
second: undefined,
|
||||
})}`,
|
||||
format,
|
||||
fileSize: recordedBlob.size,
|
||||
exerciseType: dominantAction,
|
||||
fileBase64,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalSegments.length === 0) {
|
||||
return;
|
||||
}
|
||||
await stopSessionRecorder();
|
||||
const archivedVideos = [...archivedVideosRef.current].sort((a, b) => a.sequence - b.sequence);
|
||||
const primaryArchivedVideo = archivedVideos[0] ?? null;
|
||||
|
||||
await saveLiveSessionMutation.mutateAsync({
|
||||
title: `实时分析 ${ACTION_META[dominantAction].label}`,
|
||||
@@ -921,6 +1035,9 @@ export default function LiveCamera() {
|
||||
rawActionVolatility: Number(averageRawVolatility.toFixed(4)),
|
||||
avatarEnabled: avatarState.enabled,
|
||||
avatarKey: avatarState.enabled ? avatarState.avatarKey : null,
|
||||
autoRecordingEnabled: true,
|
||||
autoRecordingSegmentMs: ANALYSIS_RECORDING_SEGMENT_MS,
|
||||
archivedVideos,
|
||||
mobile,
|
||||
},
|
||||
segments: finalSegments.map((segment) => ({
|
||||
@@ -937,10 +1054,10 @@ export default function LiveCamera() {
|
||||
keyFrames: segment.keyFrames,
|
||||
clipLabel: segment.clipLabel,
|
||||
})),
|
||||
videoId: uploadedVideo?.videoId,
|
||||
videoUrl: uploadedVideo?.url,
|
||||
videoId: primaryArchivedVideo?.videoId,
|
||||
videoUrl: primaryArchivedVideo?.url,
|
||||
});
|
||||
}, [flushSegment, liveScore, mobile, saveLiveSessionMutation, sessionMode, stopSessionRecorder, uploadMutation]);
|
||||
}, [flushSegment, liveScore, mobile, saveLiveSessionMutation, sessionMode, stopSessionRecorder]);
|
||||
|
||||
const startAnalysis = useCallback(async () => {
|
||||
if (!cameraActive || !videoRef.current || !streamRef.current) {
|
||||
@@ -961,6 +1078,9 @@ export default function LiveCamera() {
|
||||
stableActionStateRef.current = createStableActionState();
|
||||
frameSamplesRef.current = [];
|
||||
volatilitySamplesRef.current = [];
|
||||
archivedVideosRef.current = [];
|
||||
recorderSequenceRef.current = 0;
|
||||
setArchivedVideoCount(0);
|
||||
sessionStartedAtRef.current = Date.now();
|
||||
setCurrentAction("unknown");
|
||||
setRawAction("unknown");
|
||||
@@ -968,7 +1088,7 @@ export default function LiveCamera() {
|
||||
setFeedback([]);
|
||||
setStabilityMeta(createEmptyStabilizedActionMeta());
|
||||
setDurationMs(0);
|
||||
startSessionRecorder(streamRef.current);
|
||||
startSessionRecorder();
|
||||
|
||||
try {
|
||||
const testFactory = (
|
||||
@@ -1002,6 +1122,7 @@ export default function LiveCamera() {
|
||||
}
|
||||
|
||||
drawLiveCameraOverlay(canvas, results.poseLandmarks, avatarRenderRef.current);
|
||||
renderCompositeFrame(results.poseLandmarks);
|
||||
if (!results.poseLandmarks) return;
|
||||
|
||||
const frameTimestamp = performance.now();
|
||||
@@ -1063,7 +1184,7 @@ export default function LiveCamera() {
|
||||
await stopSessionRecorder();
|
||||
toast.error(`实时分析启动失败: ${error?.message || "未知错误"}`);
|
||||
}
|
||||
}, [appendFrameToSegment, cameraActive, saving, startSessionRecorder, stopSessionRecorder]);
|
||||
}, [appendFrameToSegment, cameraActive, renderCompositeFrame, saving, startSessionRecorder, stopSessionRecorder]);
|
||||
|
||||
const stopAnalysis = useCallback(async () => {
|
||||
if (!analyzingRef.current) return;
|
||||
@@ -1084,7 +1205,7 @@ export default function LiveCamera() {
|
||||
}
|
||||
await persistSession();
|
||||
setLeaveStatus("safe");
|
||||
toast.success("实时分析已保存,并同步写入训练记录");
|
||||
toast.success(`实时分析已保存,并同步写入训练记录${archivedVideosRef.current.length > 0 ? `;已归档 ${archivedVideosRef.current.length} 段分析录像` : ""}`);
|
||||
await liveSessionsQuery.refetch();
|
||||
} catch (error: any) {
|
||||
setLeaveStatus("failed");
|
||||
@@ -1345,7 +1466,7 @@ export default function LiveCamera() {
|
||||
<Activity className="h-4 w-4" />
|
||||
<AlertTitle>正在保存分析结果</AlertTitle>
|
||||
<AlertDescription>
|
||||
视频、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。
|
||||
实时分析录像、动作区间和训练记录正在提交,请暂时停留当前页面;保存完成后会提示你可以离开。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -1382,6 +1503,10 @@ export default function LiveCamera() {
|
||||
<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">
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
60 秒自动归档
|
||||
</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}` : "骨架叠加"}
|
||||
@@ -1398,7 +1523,7 @@ 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 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。
|
||||
摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1513,7 +1638,7 @@ export default function LiveCamera() {
|
||||
</div>
|
||||
|
||||
<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 value={sessionMode} onValueChange={(value) => setSessionMode(value as SessionMode)} disabled={analyzing || saving}>
|
||||
<SelectTrigger className="h-12 rounded-2xl border-border/60">
|
||||
<SelectValue />
|
||||
@@ -1527,13 +1652,36 @@ export default function LiveCamera() {
|
||||
{renderPrimaryActions()}
|
||||
</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="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>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
录到的是合成画布,包含原视频、骨架线、关键点和当前虚拟形象覆盖效果。
|
||||
</div>
|
||||
</div>
|
||||
<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">{archivedVideoCount}</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
归档完成后会自动进入视频库,标签为“实时分析”,后续可单独删除,不影响分析数据。
|
||||
</div>
|
||||
</div>
|
||||
<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">视频与数据解耦</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
即使用户在视频库删除录像,实时分析片段、评分和训练记录仍会继续保留。
|
||||
</div>
|
||||
</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">
|
||||
开启后实时画面可使用 10 个免费动物替身,或 4 个免费的全身 3D Avatar 示例覆盖主体。当前只影响前端叠加显示,不改变动作识别与原视频归档。
|
||||
开启后实时画面可使用 10 个免费动物替身,或 4 个免费的全身 3D Avatar 示例覆盖主体。该设置不会改变动作识别结果,但归档录像会保留当前叠加效果。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
在新工单中引用
屏蔽一个用户