1860 行
74 KiB
TypeScript
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>
|
|
);
|
|
}
|