文件
tennis-training-hub/client/src/pages/Recorder.tsx
2026-03-16 18:05:58 +08:00

1860 行
74 KiB
TypeScript

import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import {
createMediaMarker,
createMediaSession,
finalizeMediaSession,
formatRecordingTime,
getMediaSession,
pickBitrate,
pickRecorderMimeType,
signalMediaSession,
type MediaMarker,
type MediaSession,
uploadMediaSegment,
} from "@/lib/media";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Slider } from "@/components/ui/slider";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import {
ACTION_LABELS as RECOGNIZED_ACTION_LABELS,
type ActionObservation,
type ActionType,
type TrackingState,
recognizeActionFrame,
stabilizeActionFrame,
} from "@/lib/actionRecognition";
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
import { formatDateTimeShanghai } from "@/lib/time";
import {
Activity,
Camera,
Circle,
CloudUpload,
Download,
FlipHorizontal,
Loader2,
Maximize2,
Minus,
Minimize2,
MonitorUp,
Plus,
Scissors,
ShieldAlert,
Smartphone,
Sparkles,
ListTodo,
Video,
VideoOff,
Wifi,
WifiOff,
Zap,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type RecorderMode =
| "idle"
| "preparing"
| "recording"
| "reconnecting"
| "finalizing"
| "archived";
type PendingSegment = {
sequence: number;
durationMs: number;
blob: Blob;
};
const SEGMENT_LENGTH_MS = 60_000;
const MOTION_SAMPLE_MS = 1_500;
const MOTION_THRESHOLD = 18;
const MOTION_COOLDOWN_MS = 8_000;
const ACTION_SAMPLE_MS = 2_500;
const INVALID_RECORDING_WINDOW_MS = 60_000;
const QUALITY_PRESETS = {
economy: {
label: "节省流量",
subtitle: "1.0 Mbps · 适合移动网络",
description: "优先降低上传流量,保留基础训练画面",
},
balanced: {
label: "均衡模式",
subtitle: "1.4-1.9 Mbps · 推荐",
description: "Chrome 录制与推流的默认平衡配置",
},
clarity: {
label: "清晰优先",
subtitle: "2.5 Mbps · Wi-Fi 推荐",
description: "保留更多动作细节,适合回放和分析",
},
} as const;
function waitForIceGathering(peer: RTCPeerConnection) {
if (peer.iceGatheringState === "complete") {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const handleStateChange = () => {
if (peer.iceGatheringState === "complete") {
peer.removeEventListener("icegatheringstatechange", handleStateChange);
resolve();
}
};
peer.addEventListener("icegatheringstatechange", handleStateChange);
});
}
function isMobileDevice() {
return (
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
window.matchMedia("(max-width: 768px)").matches
);
}
function getArchiveProgress(session: MediaSession | null) {
if (!session) return 0;
switch (session.archiveStatus) {
case "queued":
return 45;
case "processing":
return 78;
case "completed":
return 100;
case "failed":
return 100;
default:
return 20;
}
}
function getArchivePhaseLabel(mode: RecorderMode, session: MediaSession | null, taskProgress?: number | null) {
if (mode === "finalizing" && !taskProgress) {
return "正在提交归档任务";
}
if (session?.archiveStatus === "completed") {
return "回放已生成";
}
if (session?.archiveStatus === "failed") {
return "归档失败";
}
if (session?.archiveStatus === "processing") {
return "正在生成回放";
}
if (session?.archiveStatus === "queued") {
return "正在合并分段";
}
return "等待归档";
}
function formatFileSize(bytes: number) {
if (!bytes) return "0 MB";
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
}
function createActionSummary(): Record<ActionType, number> {
return {
forehand: 0,
backhand: 0,
serve: 0,
volley: 0,
overhead: 0,
slice: 0,
lob: 0,
unknown: 0,
};
}
function summarizeActions(actionSummary: Record<ActionType, number>) {
return Object.entries(actionSummary)
.filter(([actionType, count]) => actionType !== "unknown" && count > 0)
.sort((left, right) => right[1] - left[1])
.map(([actionType, count]) => ({
actionType: actionType as ActionType,
label: RECOGNIZED_ACTION_LABELS[actionType as ActionType] || actionType,
count,
}));
}
export default function Recorder() {
const { user } = useAuth();
const utils = trpc.useUtils();
const runtimeQuery = trpc.analysis.runtimeGet.useQuery(undefined, {
refetchInterval: 1000,
refetchIntervalInBackground: true,
});
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
onSuccess: (data) => {
setArchiveTaskId(data.taskId);
toast.success("录制归档任务已提交");
},
onError: (error) => {
toast.error(`录制归档任务提交失败: ${error.message}`);
},
});
const liveVideoRef = useRef<HTMLVideoElement>(null);
const playbackVideoRef = useRef<HTMLVideoElement>(null);
const motionCanvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const peerRef = useRef<RTCPeerConnection | null>(null);
const actionPoseRef = useRef<any>(null);
const currentSegmentStartedAtRef = useRef<number>(0);
const recordingStartedAtRef = useRef<number>(0);
const segmentSequenceRef = useRef(0);
const motionFrameRef = useRef<Uint8ClampedArray | null>(null);
const lastMotionMarkerAtRef = useRef(0);
const actionTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actionFrameInFlightRef = useRef(false);
const actionTrackingRef = useRef<TrackingState>({});
const actionHistoryRef = useRef<ActionObservation[]>([]);
const actionSummaryRef = useRef<Record<ActionType, number>>(createActionSummary());
const lastRecognizedActionAtRef = useRef<number>(0);
const lastActionMarkerAtRef = useRef<number>(0);
const latestRecognizedActionRef = useRef<ActionType>("unknown");
const validityOverrideRef = useRef<"valid" | "invalid" | null>(null);
const invalidAutoMarkedRef = useRef(false);
const pendingUploadsRef = useRef<PendingSegment[]>([]);
const uploadInFlightRef = useRef(false);
const currentSessionRef = useRef<MediaSession | null>(null);
const segmentTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const motionTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const modeRef = useRef<RecorderMode>("idle");
const reconnectAttemptsRef = useRef(0);
const facingModeRef = useRef<"user" | "environment">("environment");
const suppressTrackEndedRef = useRef(false);
const zoomTargetRef = useRef(1);
const [mode, setMode] = useState<RecorderMode>("idle");
const [qualityPreset, setQualityPreset] = useState<keyof typeof QUALITY_PRESETS>("economy");
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
const [cameraActive, setCameraActive] = useState(false);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
const [durationMs, setDurationMs] = useState(0);
const [sessionMode, setSessionMode] = useState<"practice" | "pk">("practice");
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [queuedSegments, setQueuedSegments] = useState(0);
const [queuedBytes, setQueuedBytes] = useState(0);
const [uploadedSegments, setUploadedSegments] = useState(0);
const [uploadBytes, setUploadBytes] = useState(0);
const [cameraError, setCameraError] = useState("");
const [title, setTitle] = useState(() => `训练录制 ${formatDateTimeShanghai(new Date(), { year: undefined, second: undefined })}`);
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
const [markers, setMarkers] = useState<MediaMarker[]>([]);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
const [actionSummary, setActionSummary] = useState<Record<ActionType, number>>(() => createActionSummary());
const [currentDetectedAction, setCurrentDetectedAction] = useState<ActionType>("unknown");
const [recordingValidity, setRecordingValidity] = useState<"pending" | "valid" | "invalid">("pending");
const [recordingValidityReason, setRecordingValidityReason] = useState("");
const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []);
const runtimeRole = runtimeQuery.data?.role ?? "idle";
const liveAnalysisRuntime = runtimeQuery.data?.runtimeSession;
const liveAnalysisOccupied = runtimeRole === "viewer" && liveAnalysisRuntime?.status === "active";
const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
const archiveTaskQuery = useBackgroundTask(archiveTaskId);
const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession);
const archivePhaseLabel = getArchivePhaseLabel(mode, mediaSession, archiveTaskQuery.data?.progress);
const totalUploadBytes = uploadBytes + queuedBytes;
const uploadStillDraining = mode === "finalizing" && (queuedBytes > 0 || queuedSegments > 0 || finalizeTaskMutation.isPending);
const archiveRunning =
archiveTaskQuery.data?.status === "queued" ||
archiveTaskQuery.data?.status === "running" ||
mediaSession?.archiveStatus === "queued" ||
mediaSession?.archiveStatus === "processing";
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]);
const syncSessionState = useCallback((session: MediaSession | null) => {
currentSessionRef.current = session;
setMediaSession(session);
setMarkers(session?.markers ?? []);
setUploadedSegments(session?.uploadedSegments ?? 0);
setUploadBytes(session?.uploadedBytes ?? 0);
}, []);
const syncQueuedUploadState = useCallback(() => {
setQueuedSegments(pendingUploadsRef.current.length);
setQueuedBytes(pendingUploadsRef.current.reduce((total, item) => total + item.blob.size, 0));
}, []);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
reconnectAttemptsRef.current = reconnectAttempts;
}, [reconnectAttempts]);
useEffect(() => {
facingModeRef.current = facingMode;
}, [facingMode]);
useEffect(() => {
if (archiveTaskQuery.data?.status === "succeeded") {
void (async () => {
if (currentSessionRef.current?.id) {
const response = await getMediaSession(currentSessionRef.current.id);
syncSessionState(response.session);
}
setMode("archived");
utils.video.list.invalidate();
toast.success("回放文件已归档完成");
setArchiveTaskId(null);
})();
} else if (archiveTaskQuery.data?.status === "failed") {
toast.error(`录制归档失败: ${archiveTaskQuery.data.error || "未知错误"}`);
setMode("idle");
setArchiveTaskId(null);
}
}, [archiveTaskQuery.data, syncSessionState, utils.video.list]);
const stopTickers = useCallback(() => {
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
if (actionTickerRef.current) clearInterval(actionTickerRef.current);
segmentTickerRef.current = null;
timerTickerRef.current = null;
motionTickerRef.current = null;
actionTickerRef.current = null;
}, []);
const closePeer = useCallback(() => {
if (peerRef.current) {
peerRef.current.onconnectionstatechange = null;
peerRef.current.close();
peerRef.current = null;
}
setConnectionState("closed");
}, []);
const stopCamera = useCallback(() => {
suppressTrackEndedRef.current = true;
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (liveVideoRef.current) {
liveVideoRef.current.srcObject = null;
}
setZoomState(readTrackZoomState(null));
setCameraActive(false);
}, []);
const syncZoomState = useCallback(async (preferredZoom?: number, providedTrack?: MediaStreamTrack | null) => {
const track = providedTrack || streamRef.current?.getVideoTracks()[0] || null;
if (!track) {
zoomTargetRef.current = 1;
setZoomState(readTrackZoomState(null));
return;
}
let nextState = readTrackZoomState(track);
if (nextState.supported && preferredZoom != null && Math.abs(preferredZoom - nextState.current) > nextState.step / 2) {
try {
nextState = await applyTrackZoom(track, preferredZoom);
} catch {
nextState = readTrackZoomState(track);
}
}
zoomTargetRef.current = nextState.current;
setZoomState(nextState);
}, []);
const updateZoom = useCallback(async (nextZoom: number) => {
const track = streamRef.current?.getVideoTracks()[0] || null;
if (!track) return;
try {
const nextState = await applyTrackZoom(track, nextZoom);
zoomTargetRef.current = nextState.current;
setZoomState(nextState);
} catch (error: any) {
toast.error(`镜头缩放调整失败: ${error?.message || "当前设备不支持"}`);
}
}, []);
const stepZoom = useCallback((direction: -1 | 1) => {
if (!zoomState.supported) return;
const nextZoom = Math.max(
zoomState.min,
Math.min(zoomState.max, zoomState.current + zoomState.step * direction),
);
void updateZoom(nextZoom);
}, [updateZoom, zoomState]);
const startCamera = useCallback((
async (
nextFacingMode = facingMode,
preferredZoom = zoomTargetRef.current,
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
) => {
if (liveAnalysisOccupied) {
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
toast.error(`${title},当前设备不能再开启录制摄像头`);
throw new Error("当前账号已有其他设备正在实时分析");
}
try {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: getCameraVideoConstraints(nextFacingMode, mobile, preset),
audio: {
echoCancellation: true,
noiseSuppression: true,
},
});
stream.getVideoTracks().forEach((track) => {
track.onended = () => {
if (!suppressTrackEndedRef.current && (modeRef.current === "recording" || modeRef.current === "reconnecting")) {
void attemptReconnect();
}
};
});
suppressTrackEndedRef.current = false;
streamRef.current = stream;
if (liveVideoRef.current) {
liveVideoRef.current.srcObject = stream;
await liveVideoRef.current.play();
}
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
setCameraError("");
setCameraActive(true);
return stream;
} catch (error: any) {
const message = error?.message || "无法访问摄像头";
setCameraError(message);
toast.error(`摄像头启动失败: ${message}`);
throw error;
}
}), [facingMode, liveAnalysisOccupied, liveAnalysisRuntime?.title, mobile, qualityPreset, syncZoomState]);
const ensurePreviewStream = useCallback(async () => {
if (streamRef.current) {
if (liveVideoRef.current && liveVideoRef.current.srcObject !== streamRef.current) {
liveVideoRef.current.srcObject = streamRef.current;
await liveVideoRef.current.play().catch(() => {});
}
await syncZoomState(zoomTargetRef.current);
setCameraActive(true);
return streamRef.current;
}
return startCamera(facingModeRef.current, zoomTargetRef.current, qualityPreset);
}, [startCamera, syncZoomState]);
const refreshDevices = useCallback(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
setHasMultipleCameras(devices.filter((device) => device.kind === "videoinput").length > 1);
} catch {
setHasMultipleCameras(false);
}
}, []);
const processUploadQueue = useCallback(async () => {
if (uploadInFlightRef.current || pendingUploadsRef.current.length === 0 || !currentSessionRef.current?.id || !navigator.onLine) {
syncQueuedUploadState();
return;
}
uploadInFlightRef.current = true;
try {
while (pendingUploadsRef.current.length > 0 && currentSessionRef.current?.id && navigator.onLine) {
const nextSegment = pendingUploadsRef.current[0];
const response = await uploadMediaSegment(
currentSessionRef.current.id,
nextSegment.sequence,
nextSegment.durationMs,
nextSegment.blob
);
pendingUploadsRef.current.shift();
syncQueuedUploadState();
syncSessionState(response.session);
}
} catch (error: any) {
toast.error(`分段上传失败: ${error?.message || "未知错误"}`);
} finally {
uploadInFlightRef.current = false;
}
}, [syncQueuedUploadState, syncSessionState]);
const enqueueSegment = useCallback(async (blob: Blob, durationForSegmentMs: number) => {
if (!blob.size) return;
pendingUploadsRef.current.push({
sequence: segmentSequenceRef.current++,
durationMs: Math.max(1, durationForSegmentMs),
blob,
});
syncQueuedUploadState();
await processUploadQueue();
}, [processUploadQueue, syncQueuedUploadState]);
const flushPendingSegments = useCallback(async () => {
while (pendingUploadsRef.current.length > 0 || uploadInFlightRef.current) {
await processUploadQueue();
await new Promise((resolve) => setTimeout(resolve, 200));
}
}, [processUploadQueue]);
const stopRecorder = useCallback(async () => {
stopTickers();
const recorder = recorderRef.current;
recorderRef.current = null;
if (!recorder || recorder.state === "inactive") {
return;
}
const stopped = new Promise<void>((resolve) => {
recorder.addEventListener("stop", () => resolve(), { once: true });
});
try {
recorder.requestData();
} catch {
// ignore
}
recorder.stop();
await stopped;
}, [stopTickers]);
const createManualMarker = useCallback(async (
type: "manual" | "motion" | "action_detected" | "invalid_auto" | "invalid_manual" | "valid_manual",
label: string,
confidence?: number,
) => {
const sessionId = currentSessionRef.current?.id;
if (!sessionId) return;
const timestampMs = Math.max(0, Date.now() - recordingStartedAtRef.current);
const optimisticMarker: MediaMarker = {
id: `${type}-${timestampMs}`,
type,
label,
timestampMs,
confidence,
createdAt: new Date().toISOString(),
};
setMarkers((current) => [...current, optimisticMarker].sort((a, b) => a.timestampMs - b.timestampMs));
try {
const response = await createMediaMarker(sessionId, {
type,
label,
timestampMs,
confidence,
});
syncSessionState(response.session);
} catch (error: any) {
toast.error(`标记失败: ${error?.message || "未知错误"}`);
}
}, [syncSessionState]);
const setValidityState = useCallback((
nextStatus: "pending" | "valid" | "invalid",
reason: string,
override: "valid" | "invalid" | null = validityOverrideRef.current,
) => {
validityOverrideRef.current = override;
setRecordingValidity(nextStatus);
setRecordingValidityReason(reason);
}, []);
const startActionSampling = useCallback(async () => {
if (typeof window === "undefined") return;
const liveVideo = liveVideoRef.current;
if (!liveVideo) return;
if (!actionPoseRef.current) {
const testFactory = (
window as typeof window & {
__TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>;
}
).__TEST_MEDIAPIPE_FACTORY__;
const { Pose } = testFactory ? await testFactory() : await import("@mediapipe/pose");
const pose = new Pose({
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
});
pose.setOptions({
modelComplexity: 0,
smoothLandmarks: true,
enableSegmentation: false,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
pose.onResults((results: { poseLandmarks?: Array<{ x: number; y: number; visibility?: number }> }) => {
if (!results.poseLandmarks) {
return;
}
const analyzed = stabilizeActionFrame(
recognizeActionFrame(results.poseLandmarks, actionTrackingRef.current, performance.now()),
actionHistoryRef.current,
);
setCurrentDetectedAction(analyzed.action);
if (analyzed.action === "unknown" || analyzed.confidence < 0.55) {
return;
}
latestRecognizedActionRef.current = analyzed.action;
lastRecognizedActionAtRef.current = Date.now();
actionSummaryRef.current = {
...actionSummaryRef.current,
[analyzed.action]: (actionSummaryRef.current[analyzed.action] || 0) + 1,
};
setActionSummary({ ...actionSummaryRef.current });
if (validityOverrideRef.current !== "invalid") {
setValidityState("valid", `已识别到 ${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, validityOverrideRef.current);
}
if (Date.now() - lastActionMarkerAtRef.current >= 15_000) {
lastActionMarkerAtRef.current = Date.now();
void createManualMarker("action_detected", `识别到${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, analyzed.confidence);
}
});
actionPoseRef.current = pose;
}
const checkInvalidWindow = () => {
const elapsedMs = Date.now() - recordingStartedAtRef.current;
if (elapsedMs < INVALID_RECORDING_WINDOW_MS || invalidAutoMarkedRef.current) {
return;
}
if (Date.now() - lastRecognizedActionAtRef.current < INVALID_RECORDING_WINDOW_MS) {
return;
}
invalidAutoMarkedRef.current = true;
setValidityState("invalid", "连续 60 秒未识别到有效动作,已自动标记为无效录制", validityOverrideRef.current);
void createManualMarker("invalid_auto", "连续60秒未识别到有效动作,自动标记为无效录制");
};
actionTickerRef.current = setInterval(() => {
const video = liveVideoRef.current;
if (!video || video.readyState < 2 || !actionPoseRef.current || actionFrameInFlightRef.current) {
checkInvalidWindow();
return;
}
actionFrameInFlightRef.current = true;
actionPoseRef.current.send({ image: video })
.catch(() => undefined)
.finally(() => {
actionFrameInFlightRef.current = false;
checkInvalidWindow();
});
}, ACTION_SAMPLE_MS);
}, [createManualMarker, setValidityState]);
const stopActionSampling = useCallback(async () => {
if (actionTickerRef.current) {
clearInterval(actionTickerRef.current);
actionTickerRef.current = null;
}
if (actionPoseRef.current?.close) {
try {
await actionPoseRef.current.close();
} catch {
// ignore pose teardown failures during recorder stop/reset
}
}
actionPoseRef.current = null;
actionFrameInFlightRef.current = false;
}, []);
const updateRecordingValidity = useCallback(async (next: "valid" | "invalid") => {
validityOverrideRef.current = next;
if (next === "valid") {
setValidityState("valid", "已手工恢复为有效录制", "valid");
invalidAutoMarkedRef.current = false;
await createManualMarker("valid_manual", "手工恢复为有效录制");
return;
}
setValidityState("invalid", "已手工标记为无效录制", "invalid");
await createManualMarker("invalid_manual", "手工标记为无效录制");
}, [createManualMarker, setValidityState]);
const sampleMotion = useCallback(() => {
const video = liveVideoRef.current;
const canvas = motionCanvasRef.current;
if (!video || !canvas || video.readyState < 2) {
return;
}
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) {
return;
}
const width = 160;
const height = 90;
canvas.width = width;
canvas.height = height;
ctx.drawImage(video, 0, 0, width, height);
const frame = ctx.getImageData(0, 0, width, height).data;
const lastFrame = motionFrameRef.current;
if (lastFrame) {
let totalDifference = 0;
let samples = 0;
for (let index = 0; index < frame.length; index += 16) {
totalDifference += Math.abs(frame[index] - lastFrame[index]);
samples += 1;
}
const averageDifference = samples ? totalDifference / samples : 0;
const now = Date.now();
if (averageDifference >= MOTION_THRESHOLD && now - lastMotionMarkerAtRef.current >= MOTION_COOLDOWN_MS) {
lastMotionMarkerAtRef.current = now;
void createManualMarker("motion", "自动关键片段", Math.min(0.98, averageDifference / 100));
}
}
motionFrameRef.current = new Uint8ClampedArray(frame);
}, [createManualMarker]);
const startRealtimePush = useCallback(async (stream: MediaStream, sessionId: string) => {
closePeer();
const peer = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
peerRef.current = peer;
setConnectionState("connecting");
stream.getTracks().forEach((track) => {
peer.addTrack(track, stream);
});
peer.onconnectionstatechange = () => {
setConnectionState(peer.connectionState);
if (peer.connectionState === "disconnected" && (modeRef.current === "recording" || modeRef.current === "reconnecting")) {
void attemptReconnect();
}
};
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
await waitForIceGathering(peer);
const answer = await signalMediaSession(sessionId, {
sdp: peer.localDescription?.sdp || "",
type: peer.localDescription?.type || "offer",
});
await peer.setRemoteDescription({
type: answer.type as RTCSdpType,
sdp: answer.sdp,
});
}, [closePeer]);
const startRecorderLoop = useCallback((stream: MediaStream) => {
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: pickBitrate(qualityPreset, mobile),
});
currentSegmentStartedAtRef.current = Date.now();
recorder.ondataavailable = (event) => {
if (!event.data.size) {
return;
}
const now = Date.now();
const segmentDuration = now - currentSegmentStartedAtRef.current;
currentSegmentStartedAtRef.current = now;
void enqueueSegment(event.data, segmentDuration);
};
recorder.onerror = () => {
void attemptReconnect();
};
recorder.start();
recorderRef.current = recorder;
segmentTickerRef.current = setInterval(() => {
if (recorder.state === "recording") {
recorder.requestData();
}
}, SEGMENT_LENGTH_MS);
timerTickerRef.current = setInterval(() => {
setDurationMs(Date.now() - recordingStartedAtRef.current);
}, 250);
motionTickerRef.current = setInterval(() => {
sampleMotion();
}, MOTION_SAMPLE_MS);
}, [enqueueSegment, mimeType, mobile, qualityPreset, sampleMotion]);
const attemptReconnect = useCallback(async () => {
if (!currentSessionRef.current || modeRef.current === "finalizing" || modeRef.current === "archived") {
return;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
setMode("reconnecting");
setReconnectAttempts((count) => {
const nextCount = count + 1;
reconnectAttemptsRef.current = nextCount;
return nextCount;
});
try {
await stopRecorder();
} catch {
// ignore
}
closePeer();
stopCamera();
const nextDelay = Math.min(1_000 * Math.max(1, reconnectAttemptsRef.current), 8_000);
reconnectTimeoutRef.current = setTimeout(async () => {
try {
const stream = await startCamera();
await startRealtimePush(stream, currentSessionRef.current?.id || "");
startRecorderLoop(stream);
setMode("recording");
toast.success("摄像头已重新连接,录制继续");
} catch (error: any) {
toast.error(`自动重连失败: ${error?.message || "未知错误"}`);
if (modeRef.current === "reconnecting") {
void attemptReconnect();
}
}
}, nextDelay);
}, [closePeer, startCamera, startRealtimePush, startRecorderLoop, stopCamera, stopRecorder]);
const beginRecording = useCallback(async () => {
if (!user) {
toast.error("请先登录后再开始录制");
return;
}
if (liveAnalysisOccupied) {
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
toast.error(`${title},当前设备不能同时开始录制`);
return;
}
try {
setMode("preparing");
setMarkers([]);
setDurationMs(0);
setUploadedSegments(0);
setUploadBytes(0);
setQueuedSegments(0);
setQueuedBytes(0);
setReconnectAttempts(0);
setArchiveTaskId(null);
segmentSequenceRef.current = 0;
motionFrameRef.current = null;
pendingUploadsRef.current = [];
actionTrackingRef.current = {};
actionHistoryRef.current = [];
actionSummaryRef.current = createActionSummary();
setActionSummary(createActionSummary());
setCurrentDetectedAction("unknown");
setRecordingValidity("pending");
setRecordingValidityReason("正在抽样动作帧,持续 60 秒未识别到有效动作将自动标记无效。");
validityOverrideRef.current = null;
invalidAutoMarkedRef.current = false;
latestRecognizedActionRef.current = "unknown";
lastActionMarkerAtRef.current = 0;
const stream = await ensurePreviewStream();
const sessionResponse = await createMediaSession({
userId: String(user.id),
title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`,
format: "webm",
mimeType,
qualityPreset,
facingMode,
deviceKind: mobile ? "mobile" : "desktop",
});
syncSessionState(sessionResponse.session);
await startRealtimePush(stream, sessionResponse.session.id);
recordingStartedAtRef.current = Date.now();
lastRecognizedActionAtRef.current = recordingStartedAtRef.current;
startRecorderLoop(stream);
await startActionSampling();
setMode("recording");
toast.success("录制已开始,已同步启动实时推流");
} catch (error: any) {
setMode("idle");
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
}
}, [
ensurePreviewStream,
facingMode,
liveAnalysisOccupied,
liveAnalysisRuntime?.title,
mimeType,
mobile,
qualityPreset,
startActionSampling,
startRealtimePush,
startRecorderLoop,
syncSessionState,
title,
user,
]);
const finishRecording = useCallback(async () => {
const session = currentSessionRef.current;
if (!session) {
return;
}
try {
setMode("finalizing");
await stopActionSampling();
await stopRecorder();
await flushPendingSegments();
closePeer();
stopCamera();
const response = await finalizeMediaSession(session.id, {
title: title.trim() || session.title,
durationMs: Date.now() - recordingStartedAtRef.current,
});
syncSessionState(response.session);
await finalizeTaskMutation.mutateAsync({
sessionId: session.id,
title: title.trim() || session.title,
exerciseType: "recording",
sessionMode,
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
actionCount: Object.entries(actionSummaryRef.current)
.filter(([actionType]) => actionType !== "unknown")
.reduce((sum, [, count]) => sum + count, 0),
actionSummary: actionSummaryRef.current,
dominantAction: latestRecognizedActionRef.current !== "unknown" ? latestRecognizedActionRef.current : undefined,
validityStatus:
recordingValidity === "invalid"
? validityOverrideRef.current === "invalid" ? "invalid_manual" : "invalid_auto"
: recordingValidity === "valid"
? validityOverrideRef.current === "valid" ? "valid_manual" : "valid"
: "pending",
invalidReason: recordingValidity === "invalid" ? recordingValidityReason : undefined,
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
}
}, [closePeer, finalizeTaskMutation, flushPendingSegments, recordingValidity, recordingValidityReason, sessionMode, stopActionSampling, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
await stopRecorder().catch(() => {});
closePeer();
stopCamera();
pendingUploadsRef.current = [];
uploadInFlightRef.current = false;
motionFrameRef.current = null;
await stopActionSampling().catch(() => {});
actionTrackingRef.current = {};
actionHistoryRef.current = [];
actionSummaryRef.current = createActionSummary();
currentSessionRef.current = null;
setArchiveTaskId(null);
setMediaSession(null);
setMarkers([]);
setActionSummary(createActionSummary());
setCurrentDetectedAction("unknown");
setRecordingValidity("pending");
setRecordingValidityReason("");
setDurationMs(0);
setQueuedSegments(0);
setQueuedBytes(0);
setUploadedSegments(0);
setUploadBytes(0);
setReconnectAttempts(0);
setConnectionState("new");
setCameraError("");
setMode("idle");
}, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]);
const flipCamera = useCallback(async () => {
const nextFacingMode = facingMode === "user" ? "environment" : "user";
setFacingMode(nextFacingMode);
if (mode === "idle" && cameraActive) {
stopCamera();
await startCamera(nextFacingMode, zoomTargetRef.current, qualityPreset);
}
}, [cameraActive, facingMode, mode, qualityPreset, startCamera, stopCamera]);
const handleQualityPresetChange = useCallback(async (nextPreset: keyof typeof QUALITY_PRESETS) => {
setQualityPreset(nextPreset);
if (cameraActive && mode === "idle") {
await startCamera(facingModeRef.current, zoomTargetRef.current, nextPreset);
}
}, [cameraActive, mode, startCamera]);
useEffect(() => {
void refreshDevices();
navigator.mediaDevices?.addEventListener?.("devicechange", refreshDevices);
const handleOnline = () => {
setIsOnline(true);
toast.success("网络已恢复,继续上传缓存分段");
void processUploadQueue();
};
const handleOffline = () => {
setIsOnline(false);
toast.warning("网络已断开,录制分段已留在本地队列");
};
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
navigator.mediaDevices?.removeEventListener?.("devicechange", refreshDevices);
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, [processUploadQueue, refreshDevices]);
useEffect(() => {
if (!currentPlaybackUrl || !playbackVideoRef.current || mode !== "archived") {
return;
}
playbackVideoRef.current.load();
}, [currentPlaybackUrl, immersivePreview, mode]);
useEffect(() => {
if (mode === "archived") {
return;
}
const liveVideo = liveVideoRef.current;
if (!liveVideo || !streamRef.current) {
return;
}
if (liveVideo.srcObject !== streamRef.current) {
liveVideo.srcObject = streamRef.current;
}
liveVideo.play().catch(() => {});
}, [cameraActive, immersivePreview, mode]);
useEffect(() => {
if (!mobile) {
setImmersivePreview(false);
return;
}
if (!immersivePreview) {
document.body.style.overflow = "";
return;
}
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [immersivePreview, mobile]);
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
void stopActionSampling();
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
} catch {
// ignore
}
}
closePeer();
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, [closePeer, stopActionSampling, stopTickers]);
const statusBadge = useMemo(() => {
if (mode === "finalizing") {
return { label: "归档处理中", tone: "default" as const, icon: CloudUpload };
}
if (mode === "reconnecting") {
return { label: "摄像头重连中", tone: "secondary" as const, icon: ShieldAlert };
}
if (mode === "recording") {
return { label: "录制中", tone: "destructive" as const, icon: Circle };
}
if (mode === "archived") {
return { label: "回放已就绪", tone: "default" as const, icon: Video };
}
return { label: "待机", tone: "outline" as const, icon: Camera };
}, [mode]);
const previewTitle = mode === "archived" ? "归档回放" : "实时取景";
const StatusIcon = statusBadge.icon;
const previewVideoClassName = immersivePreview && mobile
? "h-full w-full object-contain"
: "h-full w-full object-contain";
const railButtonClassName =
"h-16 w-16 flex-col gap-1 rounded-[20px] px-2 text-[11px] leading-tight shadow-lg shadow-black/20";
const renderPrimaryActions = (layout: "toolbar" | "rail" = "toolbar") => {
const rail = layout === "rail";
const labelFor = (full: string, short: string) => (rail ? short : full);
const buttonClass = (tone: "default" | "outline" | "destructive" | "record" = "default") => {
if (!rail) {
switch (tone) {
case "outline":
return "h-12 rounded-2xl px-4";
case "destructive":
return "h-12 rounded-2xl px-5";
case "record":
return "h-12 rounded-2xl bg-red-600 px-5 hover:bg-red-700";
default:
return "h-12 rounded-2xl px-5";
}
}
switch (tone) {
case "outline":
return `${railButtonClassName} border-white/15 bg-white/8 text-white hover:bg-white/14`;
case "destructive":
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
case "record":
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
default:
return `${railButtonClassName} bg-white text-slate-950 hover:bg-white/90`;
}
};
const iconClass = rail ? "h-5 w-5" : "mr-2 h-4 w-4";
return (
<>
{mode === "idle" && (
<>
{!cameraActive ? (
<Button
data-testid="recorder-start-camera-button"
onClick={() => void startCamera()}
className={buttonClass()}
disabled={liveAnalysisOccupied}
>
<Camera className={iconClass} />
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "启动摄像头", liveAnalysisOccupied ? "占用" : "启动")}
</Button>
) : (
<>
<Button
data-testid="recorder-start-recording-button"
onClick={() => void beginRecording()}
className={buttonClass("record")}
disabled={liveAnalysisOccupied}
>
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "开始录制", liveAnalysisOccupied ? "占用" : "录制")}
</Button>
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
<VideoOff className={iconClass} />
{labelFor("关闭摄像头", "关闭")}
</Button>
</>
)}
{hasMultipleCameras && (
<Button variant="outline" onClick={() => void flipCamera()} className={buttonClass("outline")}>
<FlipHorizontal className={iconClass} />
{labelFor("切换摄像头", "切换")}
</Button>
)}
</>
)}
{(mode === "recording" || mode === "reconnecting") && (
<>
<Button
data-testid="recorder-marker-button"
variant="outline"
onClick={() => void createManualMarker("manual", "手动剪辑点")}
className={buttonClass("outline")}
>
<Scissors className={iconClass} />
{labelFor("标记剪辑点", "标记")}
</Button>
<Button
data-testid="recorder-finish-button"
variant="destructive"
onClick={() => void finishRecording()}
className={buttonClass("destructive")}
>
<CloudUpload className={iconClass} />
{labelFor("结束并归档", "完成")}
</Button>
</>
)}
{mode === "archived" && (
<>
{currentPlaybackUrl && (
<Button asChild variant="outline" className={buttonClass("outline")}>
<a href={currentPlaybackUrl} download target="_blank" rel="noreferrer">
<Download className={iconClass} />
{labelFor("下载回放", "下载")}
</a>
</Button>
)}
<Button
data-testid="recorder-reset-button"
onClick={() => void resetRecorder()}
className={buttonClass()}
>
{rail ? null : null}
{labelFor("重新录制", "重来")}
</Button>
</>
)}
{mode === "finalizing" && (
<Button disabled className={rail ? `${railButtonClassName} bg-white/15 text-white` : "h-12 rounded-2xl px-5"}>
<Loader2 className={rail ? "h-5 w-5 animate-spin" : "mr-2 h-4 w-4 animate-spin"} />
{labelFor("正在生成回放", "处理中")}
</Button>
)}
</>
);
};
const renderPreviewMedia = () => (
<>
{mode === "archived" && currentPlaybackUrl ? (
<video
ref={playbackVideoRef}
className={previewVideoClassName}
src={currentPlaybackUrl}
controls
playsInline
/>
) : (
<video
ref={liveVideoRef}
className={previewVideoClassName}
playsInline
muted
autoPlay
/>
)}
</>
);
const renderZoomOverlay = () => (
<div className="absolute right-3 bottom-3 z-20 flex items-center gap-2 rounded-2xl border border-white/10 bg-black/65 px-2 py-2 text-white shadow-lg">
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => stepZoom(-1)}
disabled={!zoomState.supported}
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</Button>
<div className="min-w-[78px] text-center">
<div className="text-[10px] uppercase tracking-[0.16em] text-white/50"></div>
<div className="mt-1 text-sm font-semibold">{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}</div>
</div>
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => stepZoom(1)}
disabled={!zoomState.supported}
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</Button>
</div>
);
return (
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
<canvas ref={motionCanvasRef} className="hidden" />
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_35%),linear-gradient(135deg,rgba(12,18,24,0.98),rgba(18,38,30,0.95))] p-5 text-white shadow-xl shadow-black/10 md:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusBadge.tone} className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
<StatusIcon className={`h-3.5 w-3.5 ${mode === "recording" ? "fill-current" : ""}`} />
{statusBadge.label}
</Badge>
<Badge variant="outline" className="gap-1.5 border-white/15 bg-white/5 text-white/80">
{isOnline ? <Wifi className="h-3.5 w-3.5" /> : <WifiOff className="h-3.5 w-3.5" />}
{isOnline ? "网络在线" : "离线缓存中"}
</Badge>
<Badge variant="outline" className="gap-1.5 border-white/15 bg-white/5 text-white/80">
<MonitorUp className="h-3.5 w-3.5" />
WebRTC
</Badge>
<Badge variant="outline" className="gap-1.5 border-white/15 bg-white/5 text-white/80">
<Sparkles className="h-3.5 w-3.5" />
{sessionMode === "practice" ? "练习会话" : "训练 PK"}
</Badge>
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight" data-testid="recorder-title">线</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
Chrome WebM 60
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 self-stretch rounded-2xl border border-white/10 bg-white/5 p-2 text-center text-xs text-white/70 sm:w-[360px]">
<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">{formatRecordingTime(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">{uploadedSegments}</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">{formatFileSize(uploadBytes)}</div>
</div>
</div>
</div>
</section>
{(finalizeTaskMutation.isPending || archiveTaskQuery.data?.status === "queued" || archiveTaskQuery.data?.status === "running") ? (
<Alert>
<ListTodo className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{archiveTaskQuery.data?.message || "录制文件正在后台整理、转码并登记到视频库。"}
{formatFileSize(uploadBytes)}
{queuedBytes > 0 ? `,待上传 ${formatFileSize(queuedBytes)}` : ""}
</AlertDescription>
</Alert>
) : null}
{uploadStillDraining ? (
<Alert variant="destructive">
<ShieldAlert className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{queuedSegments} {formatFileSize(queuedBytes)}
0
</AlertDescription>
</Alert>
) : null}
{canLeaveRecorderPage ? (
<Alert>
<Video className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{mode === "archived"
? " 回放文件已生成,你现在可以返回其他页面,稍后也能在视频库继续查看。"
: " 后台会继续合并分段、生成回放并写入视频库,你可以直接离开,任务中心会反馈完成结果。"}
</AlertDescription>
</Alert>
) : null}
{liveAnalysisOccupied ? (
<Alert className="border-amber-300/70 bg-amber-50 text-amber-950">
<ShieldAlert className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{liveAnalysisRuntime?.title || "其他设备正在实时分析"}
{" "}
<a href="/live-camera" className="font-medium underline underline-offset-4">
</a>
{" "}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
<section className="space-y-4">
<Card className="overflow-hidden border-0 shadow-lg">
<CardContent className="p-0">
<div className="relative aspect-[16/10] overflow-hidden bg-black sm:aspect-video">
{!immersivePreview && renderPreviewMedia()}
{!cameraActive && mode === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.75))] px-6 text-center text-white/80">
<VideoOff className="h-14 w-14" />
<div className="space-y-1">
<h2 className="text-xl font-medium"></h2>
<p className="text-sm text-white/60"></p>
</div>
</div>
)}
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
{previewTitle}
</Badge>
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Zap className="h-3.5 w-3.5" />
{QUALITY_PRESETS[qualityPreset].subtitle}
</Badge>
{mobile && (
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Smartphone className="h-3.5 w-3.5" />
</Badge>
)}
</div>
{(mode === "recording" || mode === "reconnecting") && (
<div className="absolute bottom-3 left-3 flex items-center gap-2 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
{formatRecordingTime(durationMs)}
</div>
)}
{mobile && (
<Button
type="button"
size="icon"
variant="secondary"
data-testid="recorder-mobile-focus-button"
onClick={() => setImmersivePreview(true)}
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
>
<Maximize2 className="h-4 w-4" />
</Button>
)}
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
</div>
<div className="border-t border-border/60 bg-card/80 p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_auto]">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="本次训练录制标题"
className="h-12 rounded-2xl border-border/60"
/>
<Select value={sessionMode} onValueChange={(value) => setSessionMode(value as "practice" | "pk")} disabled={mode !== "idle" && mode !== "archived"}>
<SelectTrigger className="h-12 rounded-2xl border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="practice"></SelectItem>
<SelectItem value="pk"> PK</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{renderPrimaryActions()}
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 lg:grid-cols-3">
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => {
const active = qualityPreset === key;
const disabled = mode !== "idle" && mode !== "archived";
return (
<button
key={key}
type="button"
onClick={() => void handleQualityPresetChange(key as keyof typeof QUALITY_PRESETS)}
disabled={disabled}
className={`rounded-2xl border px-4 py-4 text-left transition ${
active
? "border-primary/60 bg-primary/5 shadow-sm"
: "border-border/60 hover:border-primary/30 hover:bg-muted/50"
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
>
<div className="text-sm font-semibold">{preset.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{preset.subtitle}</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{preset.description}</p>
</button>
);
})}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-border/60 bg-muted/30 p-4">
<div className="text-sm font-medium"></div>
<div className="mt-2 text-sm text-muted-foreground">
使 {facingMode === "environment" ? "后置摄像头" : "前置摄像头"}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/30 p-4">
<div className="text-sm font-medium"></div>
<div className="mt-2 text-sm text-muted-foreground">
{mimeType} · {SEGMENT_LENGTH_MS / 1000} WebM/MP4
</div>
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium"> / </div>
<div className="mt-2 text-sm text-muted-foreground">
{zoomState.supported
? `当前 ${zoomState.current.toFixed(1)}x,可在录制过程中直接调整;设备焦点模式为 ${zoomState.focusMode}`
: "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"}
</div>
</div>
<Badge variant="outline" className="w-fit">
{QUALITY_PRESETS.economy.label}
</Badge>
</div>
{zoomState.supported ? (
<div className="mt-4 space-y-3">
<Slider
value={[zoomState.current]}
min={zoomState.min}
max={zoomState.max}
step={zoomState.step}
onValueChange={(value) => {
if (typeof value[0] === "number") {
void updateZoom(value[0]);
}
}}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{zoomState.min.toFixed(1)}x</span>
<span> 1.0x-1.5x</span>
<span>{zoomState.max.toFixed(1)}x</span>
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
</section>
<aside className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-muted/35 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 flex items-center gap-2 text-sm font-medium">
<Activity className="h-4 w-4 text-primary" />
{connectionState}
</div>
</div>
<div className="rounded-2xl bg-muted/35 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-sm font-medium">{reconnectAttempts}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-muted/35 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-sm font-medium">
{recordingValidity === "valid" ? "有效录制" : recordingValidity === "invalid" ? "无效录制" : "待判定"}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{recordingValidityReason || "录制中会自动抽样动作帧并进行判定。"}
</div>
</div>
<div className="rounded-2xl bg-muted/35 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-sm font-medium">
{RECOGNIZED_ACTION_LABELS[currentDetectedAction] || "未知动作"}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{Math.round(ACTION_SAMPLE_MS / 1000)} {Math.round(INVALID_RECORDING_WINDOW_MS / 1000)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void updateRecordingValidity("valid")}
disabled={!currentSessionRef.current}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void updateRecordingValidity("invalid")}
disabled={!currentSessionRef.current}
>
</Button>
{mediaSession?.playback.previewUrl ? (
<Button asChild variant="outline" size="sm">
<a href={mediaSession.playback.previewUrl} target="_blank" rel="noreferrer">
</a>
</Button>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{formatFileSize(uploadBytes)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{queuedSegments} · {formatFileSize(queuedBytes)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{formatFileSize(totalUploadBytes)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{uploadedSegments + queuedSegments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{mediaSession?.status || "idle"}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">
{mediaSession?.previewStatus || "idle"}
{typeof mediaSession?.previewSegments === "number" ? ` · ${mediaSession.previewSegments}` : ""}
</span>
</div>
</div>
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
<div className="space-y-2 rounded-2xl border border-border/60 bg-muted/25 p-4">
<div className="flex items-center justify-between text-sm">
<span>{archivePhaseLabel}</span>
<span className="font-medium">{archiveProgress}%</span>
</div>
<Progress value={archiveProgress} className="h-2" />
<div className="grid gap-2 rounded-2xl bg-background/70 p-3 text-xs text-muted-foreground sm:grid-cols-3">
<div>
<div className="text-[10px] uppercase tracking-[0.14em]"></div>
<div className="mt-1 text-sm font-medium text-foreground">{formatFileSize(uploadBytes)}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-[0.14em]"></div>
<div className="mt-1 text-sm font-medium text-foreground">{formatFileSize(queuedBytes)}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-[0.14em]"></div>
<div className="mt-1 text-sm font-medium text-foreground">{uploadedSegments + queuedSegments} </div>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
{archiveTaskQuery.data?.message
? `${archiveTaskQuery.data.message},当前已上传 ${formatFileSize(uploadBytes)}`
: mediaSession?.archiveStatus === "completed"
? "归档完成,已生成可回放文件并同步到视频库。"
: mediaSession?.archiveStatus === "failed"
? mediaSession.lastError || "归档失败,请检查媒体服务日志。"
: `Worker 正在合并分段并生成归档文件,当前已上传 ${formatFileSize(uploadBytes)}`}
</p>
<div
className={`rounded-2xl px-3 py-2 text-xs leading-5 ${
uploadStillDraining
? "border border-destructive/25 bg-destructive/5 text-destructive"
: canLeaveRecorderPage
? "border border-emerald-200 bg-emerald-50 text-emerald-700"
: "border border-border/60 bg-background/70 text-muted-foreground"
}`}
>
{uploadStillDraining
? `仍需停留:还有 ${queuedSegments} 个分段、约 ${formatFileSize(queuedBytes)} 待上传,待上传清零后再离开当前页面。`
: canLeaveRecorderPage
? "上传已完成:无需继续停留在此页面,后台会继续生成回放并把结果写入视频库。"
: "结束录制后会先清空本地待上传缓存,再转入后台归档。"}
</div>
</div>
)}
{recognizedActionItems.length > 0 ? (
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
<div className="text-sm font-medium"></div>
<div className="mt-3 flex flex-wrap gap-2">
{recognizedActionItems.map((item) => (
<Badge key={item.actionType} variant="secondary">
{item.label} {item.count}
</Badge>
))}
</div>
</div>
) : null}
{cameraError && (
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
{cameraError}
</div>
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-6 text-muted-foreground">
{MOTION_SAMPLE_MS / 1000}
</p>
<div className="space-y-2">
{markers.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 px-4 py-6 text-center text-sm text-muted-foreground">
</div>
) : (
markers.map((marker) => (
<div
key={marker.id}
className={`rounded-2xl border px-4 py-3 ${
marker.type === "motion"
? "border-primary/25 bg-primary/5"
: "border-border/60 bg-muted/35"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{marker.label}</div>
<div className="mt-1 text-xs text-muted-foreground">
{marker.type === "motion" ? "自动标记" : "手动标记"} · {formatRecordingTime(marker.timestampMs)}
</div>
</div>
{typeof marker.confidence === "number" && (
<Badge variant="outline" className="rounded-full">
{Math.round(marker.confidence * 100)}%
</Badge>
)}
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</aside>
</div>
{mobile && immersivePreview && (
<div
className="mobile-safe-top mobile-safe-bottom mobile-safe-inline fixed inset-0 z-[80] bg-black/95 px-3 py-4"
data-testid="recorder-mobile-focus-shell"
>
<div className="grid h-full grid-cols-[minmax(0,1fr)_72px] gap-3">
<div className="relative flex min-h-0 items-center justify-center">
<div className="relative h-full w-full overflow-hidden rounded-[32px] border border-white/10 bg-black shadow-2xl shadow-black/40">
{renderPreviewMedia()}
{!cameraActive && mode === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.8))] px-6 text-center text-white/80">
<VideoOff className="h-14 w-14" />
<div className="space-y-1">
<h2 className="text-xl font-medium"></h2>
<p className="text-sm text-white/60"></p>
</div>
</div>
)}
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
{previewTitle}
</Badge>
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Zap className="h-3.5 w-3.5" />
{QUALITY_PRESETS[qualityPreset].subtitle}
</Badge>
</div>
{(mode === "recording" || mode === "reconnecting") && (
<div className="absolute bottom-3 left-3 flex items-center gap-2 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
{formatRecordingTime(durationMs)}
</div>
)}
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => setImmersivePreview(false)}
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
>
<Minimize2 className="h-4 w-4" />
</Button>
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
</div>
</div>
<div className="flex flex-col items-center justify-center gap-3">
{renderPrimaryActions("rail")}
</div>
</div>
</div>
)}
</div>
);
}