diff --git a/client/src/lib/camera.test.ts b/client/src/lib/camera.test.ts new file mode 100644 index 0000000..3cbc3b3 --- /dev/null +++ b/client/src/lib/camera.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + applyTrackZoom, + getCameraVideoConstraints, + getLiveAnalysisBitrate, + readTrackZoomState, +} from "./camera"; + +describe("camera utilities", () => { + it("builds economy constraints for mobile capture", () => { + expect(getCameraVideoConstraints("environment", true, "economy")).toEqual({ + facingMode: "environment", + width: { ideal: 960 }, + height: { ideal: 540 }, + frameRate: { ideal: 24, max: 24 }, + }); + }); + + it("builds clarity constraints for desktop capture", () => { + expect(getCameraVideoConstraints("user", false, "clarity")).toEqual({ + facingMode: "user", + width: { ideal: 1920 }, + height: { ideal: 1080 }, + frameRate: { ideal: 30, max: 30 }, + }); + }); + + it("selects live analysis bitrates by preset", () => { + expect(getLiveAnalysisBitrate("economy", true)).toBe(900_000); + expect(getLiveAnalysisBitrate("balanced", false)).toBe(1_900_000); + expect(getLiveAnalysisBitrate("clarity", false)).toBe(2_500_000); + }); + + it("reads zoom capability from the active video track", () => { + const track = { + getCapabilities: () => ({ + zoom: { min: 1, max: 4, step: 0.5 }, + focusMode: ["continuous", "manual"], + }), + getSettings: () => ({ + zoom: 2, + focusMode: "continuous", + }), + } as unknown as MediaStreamTrack; + + expect(readTrackZoomState(track)).toEqual({ + supported: true, + min: 1, + max: 4, + step: 0.5, + current: 2, + focusMode: "continuous", + }); + }); + + it("applies zoom using media track constraints", async () => { + let currentZoom = 1; + const track = { + getCapabilities: () => ({ + zoom: { min: 1, max: 3, step: 0.25 }, + }), + getSettings: () => ({ + zoom: currentZoom, + }), + applyConstraints: async (constraints: MediaTrackConstraints & { advanced?: Array<{ zoom?: number }> }) => { + currentZoom = constraints.advanced?.[0]?.zoom ?? (constraints as { zoom?: number }).zoom ?? currentZoom; + }, + } as unknown as MediaStreamTrack; + + const result = await applyTrackZoom(track, 2.5); + + expect(result.current).toBe(2.5); + }); +}); diff --git a/client/src/lib/camera.ts b/client/src/lib/camera.ts new file mode 100644 index 0000000..e324b8f --- /dev/null +++ b/client/src/lib/camera.ts @@ -0,0 +1,151 @@ +export type CameraQualityPreset = "economy" | "balanced" | "clarity"; + +export type CameraZoomState = { + supported: boolean; + min: number; + max: number; + step: number; + current: number; + focusMode: string; +}; + +type NumericRange = { + min: number; + max: number; + step: number; +}; + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function parseNumericRange(value: unknown): NumericRange | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as { min?: unknown; max?: unknown; step?: unknown }; + if (typeof candidate.min !== "number" || typeof candidate.max !== "number") { + return null; + } + + return { + min: candidate.min, + max: candidate.max, + step: typeof candidate.step === "number" && candidate.step > 0 ? candidate.step : 0.1, + }; +} + +export function getCameraVideoConstraints( + facingMode: "user" | "environment", + isMobile: boolean, + preset: CameraQualityPreset, +): MediaTrackConstraints { + switch (preset) { + case "economy": + return { + facingMode, + width: { ideal: isMobile ? 960 : 1280 }, + height: { ideal: isMobile ? 540 : 720 }, + frameRate: { ideal: 24, max: 24 }, + }; + case "clarity": + return { + facingMode, + width: { ideal: isMobile ? 1280 : 1920 }, + height: { ideal: isMobile ? 720 : 1080 }, + frameRate: { ideal: 30, max: 30 }, + }; + default: + return { + facingMode, + width: { ideal: isMobile ? 1280 : 1600 }, + height: { ideal: isMobile ? 720 : 900 }, + frameRate: { ideal: 30, max: 30 }, + }; + } +} + +export function getLiveAnalysisBitrate(preset: CameraQualityPreset, isMobile: boolean) { + switch (preset) { + case "economy": + return isMobile ? 900_000 : 1_100_000; + case "clarity": + return isMobile ? 1_900_000 : 2_500_000; + default: + return isMobile ? 1_300_000 : 1_900_000; + } +} + +export function readTrackZoomState(track: MediaStreamTrack | null): CameraZoomState { + if (!track) { + return { + supported: false, + min: 1, + max: 1, + step: 0.1, + current: 1, + focusMode: "auto", + }; + } + + const capabilities = ( + typeof (track as MediaStreamTrack & { getCapabilities?: () => unknown }).getCapabilities === "function" + ? (track as MediaStreamTrack & { getCapabilities: () => unknown }).getCapabilities() + : {} + ) as Record; + const settings = ( + typeof (track as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function" + ? (track as MediaStreamTrack & { getSettings: () => unknown }).getSettings() + : {} + ) as Record; + + const zoomRange = parseNumericRange(capabilities.zoom); + const focusModes = Array.isArray(capabilities.focusMode) + ? capabilities.focusMode.filter((item: unknown): item is string => typeof item === "string") + : []; + const focusMode = typeof settings.focusMode === "string" + ? settings.focusMode + : focusModes.includes("continuous") + ? "continuous" + : focusModes[0] || "auto"; + + if (!zoomRange || zoomRange.max - zoomRange.min <= 0.001) { + return { + supported: false, + min: 1, + max: 1, + step: 0.1, + current: 1, + focusMode, + }; + } + + const current = typeof settings.zoom === "number" + ? clamp(settings.zoom, zoomRange.min, zoomRange.max) + : zoomRange.min; + + return { + supported: true, + min: zoomRange.min, + max: zoomRange.max, + step: zoomRange.step, + current, + focusMode, + }; +} + +export async function applyTrackZoom(track: MediaStreamTrack | null, rawZoom: number) { + const currentState = readTrackZoomState(track); + if (!track || !currentState.supported) { + return currentState; + } + + const zoom = clamp(rawZoom, currentState.min, currentState.max); + try { + await track.applyConstraints({ advanced: [{ zoom }] } as unknown as MediaTrackConstraints); + } catch { + await track.applyConstraints({ zoom } as unknown as MediaTrackConstraints); + } + return readTrackZoomState(track); +} diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index e9f4404..1546a9c 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -6,7 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; import { toast } from "sonner"; +import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera"; import { Activity, Camera, @@ -14,9 +16,11 @@ import { CheckCircle2, FlipHorizontal, Maximize2, + Minus, Minimize2, Monitor, PlayCircle, + Plus, RotateCcw, Smartphone, Sparkles, @@ -109,6 +113,23 @@ const SETUP_STEPS = [ const SEGMENT_MAX_MS = 10_000; const MERGE_GAP_MS = 500; const MIN_SEGMENT_MS = 250; +const CAMERA_QUALITY_PRESETS: Record = { + economy: { + label: "节省流量", + subtitle: "540p-720p · 低码率", + description: "默认模式,优先减少本地录制文件大小与移动网络流量。", + }, + balanced: { + label: "均衡模式", + subtitle: "720p-900p · 中码率", + description: "兼顾动作识别稳定度与录制体积。", + }, + clarity: { + label: "清晰优先", + subtitle: "720p-1080p · 高码率", + description: "适合 Wi-Fi 和需要保留更多回放细节的场景。", + }, +}; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); @@ -540,6 +561,7 @@ export default function LiveCamera() { const currentSegmentRef = useRef(null); const segmentsRef = useRef([]); const frameSamplesRef = useRef([]); + const zoomTargetRef = useRef(1); const [cameraActive, setCameraActive] = useState(false); const [facing, setFacing] = useState("environment"); @@ -556,6 +578,8 @@ export default function LiveCamera() { const [segments, setSegments] = useState([]); const [durationMs, setDurationMs] = useState(0); const [segmentFilter, setSegmentFilter] = useState("all"); + const [qualityPreset, setQualityPreset] = useState("economy"); + const [zoomState, setZoomState] = useState(() => readTrackZoomState(null)); const uploadMutation = trpc.video.upload.useMutation(); const saveLiveSessionMutation = trpc.analysis.liveSessionSave.useMutation({ @@ -670,6 +694,7 @@ export default function LiveCamera() { if (videoRef.current) { videoRef.current.srcObject = null; } + setZoomState(readTrackZoomState(null)); setCameraActive(false); }, [stopSessionRecorder]); @@ -679,19 +704,58 @@ export default function LiveCamera() { }; }, [stopCamera]); - const startCamera = useCallback(async () => { + 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 = clamp(zoomState.current + zoomState.step * direction, zoomState.min, zoomState.max); + void updateZoom(nextZoom); + }, [updateZoom, zoomState]); + + const startCamera = useCallback(async ( + nextFacing: CameraFacing = facing, + preferredZoom = zoomTargetRef.current, + preset: CameraQualityPreset = qualityPreset, + ) => { try { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } const constraints: MediaStreamConstraints = { - video: { - facingMode: facing, - width: { ideal: mobile ? 1280 : 1920 }, - height: { ideal: mobile ? 720 : 1080 }, - frameRate: { ideal: 30, max: 30 }, - }, + video: getCameraVideoConstraints(nextFacing, mobile, preset), audio: false, }; @@ -701,12 +765,13 @@ export default function LiveCamera() { videoRef.current.srcObject = stream; await videoRef.current.play(); } + await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); setCameraActive(true); toast.success("摄像头已启动"); } catch (error: any) { toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); } - }, [facing, mobile]); + }, [facing, mobile, qualityPreset, syncZoomState]); const switchCamera = useCallback(async () => { const nextFacing: CameraFacing = facing === "user" ? "environment" : "user"; @@ -714,9 +779,16 @@ export default function LiveCamera() { if (!cameraActive) return; stopCamera(); await new Promise((resolve) => setTimeout(resolve, 250)); - await startCamera(); + await startCamera(nextFacing, zoomTargetRef.current); }, [cameraActive, facing, startCamera, stopCamera]); + const handleQualityPresetChange = useCallback(async (nextPreset: CameraQualityPreset) => { + setQualityPreset(nextPreset); + if (cameraActive && !analyzing && !saving) { + await startCamera(facing, zoomTargetRef.current, nextPreset); + } + }, [analyzing, cameraActive, facing, saving, startCamera]); + const flushSegment = useCallback((segment: ActionSegment | null) => { if (!segment || segment.durationMs < MIN_SEGMENT_MS) { return; @@ -770,7 +842,10 @@ export default function LiveCamera() { recorderChunksRef.current = []; const mimeType = pickRecorderMimeType(); recorderMimeTypeRef.current = mimeType; - const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: mobile ? 1_300_000 : 2_300_000 }); + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: getLiveAnalysisBitrate(qualityPreset, mobile), + }); recorderRef.current = recorder; recorder.ondataavailable = (event) => { @@ -788,7 +863,7 @@ export default function LiveCamera() { }); recorder.start(1000); - }, [mobile]); + }, [mobile, qualityPreset]); const persistSession = useCallback(async () => { const endedAt = Date.now(); @@ -1003,8 +1078,8 @@ export default function LiveCamera() { const handleSetupComplete = useCallback(async () => { setShowSetupGuide(false); - await startCamera(); - }, [startCamera]); + await startCamera(facing, zoomTargetRef.current, qualityPreset); + }, [facing, qualityPreset, startCamera]); const heroAction = ACTION_META[currentAction]; const previewTitle = analyzing ? `${heroAction.label} 识别中` : cameraActive ? "准备开始实时分析" : "摄像头待启动"; @@ -1061,6 +1136,35 @@ export default function LiveCamera() { ); }; + const renderZoomOverlay = () => ( +
+ +
+
焦距
+
{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}
+
+ +
+ ); + return (
@@ -1125,6 +1229,10 @@ export default function LiveCamera() { {sessionMode === "practice" ? "练习会话" : "训练 PK"} + +

实时分析中枢

@@ -1205,6 +1313,8 @@ export default function LiveCamera() { ) : null} + {cameraActive && zoomState.supported ? renderZoomOverlay() : null} + {(analyzing || saving) ? (
{saving ? "正在保存会话..." : `识别中 · ${formatDuration(durationMs)}`} @@ -1231,6 +1341,76 @@ export default function LiveCamera() { + + + 拍摄与流量设置 + 默认使用节省流量模式,必要时再切到更高画质。 + + +
+ {Object.entries(CAMERA_QUALITY_PRESETS).map(([key, preset]) => { + const active = qualityPreset === key; + const disabled = analyzing || saving; + return ( + + ); + })} +
+ +
+
+
当前采集规格
+
+ {CAMERA_QUALITY_PRESETS[qualityPreset].subtitle} · 分析录制码率会随模式同步切换,默认优先节省流量。 +
+
+
+
镜头焦距 / 放大缩小
+
+ {zoomState.supported + ? `当前 ${zoomState.current.toFixed(1)}x,可在分析过程中直接微调取景;焦点模式为 ${zoomState.focusMode}。` + : "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"} +
+
+
+ + {zoomState.supported ? ( +
+ { + if (typeof value[0] === "number") { + void updateZoom(value[0]); + } + }} + /> +
+ {zoomState.min.toFixed(1)}x + 建议 1.0x-1.5x 保留完整挥拍 + {zoomState.max.toFixed(1)}x +
+
+ ) : null} +
+
+ 连续动作区间 @@ -1483,6 +1663,8 @@ export default function LiveCamera() { > + + {cameraActive && zoomState.supported ? renderZoomOverlay() : null}
diff --git a/client/src/pages/Recorder.tsx b/client/src/pages/Recorder.tsx index 58e4f3b..370fbc2 100644 --- a/client/src/pages/Recorder.tsx +++ b/client/src/pages/Recorder.tsx @@ -19,9 +19,11 @@ 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 { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera"; import { Activity, Camera, @@ -31,8 +33,10 @@ import { FlipHorizontal, Loader2, Maximize2, + Minus, Minimize2, MonitorUp, + Plus, Scissors, ShieldAlert, Smartphone, @@ -182,9 +186,10 @@ export default function Recorder() { const reconnectAttemptsRef = useRef(0); const facingModeRef = useRef<"user" | "environment">("environment"); const suppressTrackEndedRef = useRef(false); + const zoomTargetRef = useRef(1); const [mode, setMode] = useState("idle"); - const [qualityPreset, setQualityPreset] = useState("balanced"); + const [qualityPreset, setQualityPreset] = useState("economy"); const [facingMode, setFacingMode] = useState<"user" | "environment">("environment"); const [cameraActive, setCameraActive] = useState(false); const [hasMultipleCameras, setHasMultipleCameras] = useState(false); @@ -203,6 +208,7 @@ export default function Recorder() { const [connectionState, setConnectionState] = useState("new"); const [immersivePreview, setImmersivePreview] = useState(false); const [archiveTaskId, setArchiveTaskId] = useState(null); + const [zoomState, setZoomState] = useState(() => readTrackZoomState(null)); const mobile = useMemo(() => isMobileDevice(), []); const mimeType = useMemo(() => pickRecorderMimeType(), []); @@ -290,10 +296,59 @@ export default function Recorder() { if (liveVideoRef.current) { liveVideoRef.current.srcObject = null; } + setZoomState(readTrackZoomState(null)); setCameraActive(false); }, []); - const startCamera = useCallback(async (nextFacingMode = facingMode) => { + 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, + ) => { try { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); @@ -301,12 +356,7 @@ export default function Recorder() { } const stream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: nextFacingMode, - width: { ideal: mobile ? 1280 : 1920 }, - height: { ideal: mobile ? 720 : 1080 }, - frameRate: { ideal: 30, max: 30 }, - }, + video: getCameraVideoConstraints(nextFacingMode, mobile, preset), audio: { echoCancellation: true, noiseSuppression: true, @@ -327,6 +377,7 @@ export default function Recorder() { liveVideoRef.current.srcObject = stream; await liveVideoRef.current.play(); } + await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); setCameraError(""); setCameraActive(true); return stream; @@ -336,7 +387,7 @@ export default function Recorder() { toast.error(`摄像头启动失败: ${message}`); throw error; } - }, [facingMode, mobile]); + }), [facingMode, mobile, qualityPreset, syncZoomState]); const ensurePreviewStream = useCallback(async () => { if (streamRef.current) { @@ -344,12 +395,13 @@ export default function Recorder() { liveVideoRef.current.srcObject = streamRef.current; await liveVideoRef.current.play().catch(() => {}); } + await syncZoomState(zoomTargetRef.current); setCameraActive(true); return streamRef.current; } - return startCamera(facingModeRef.current); - }, [startCamera]); + return startCamera(facingModeRef.current, zoomTargetRef.current, qualityPreset); + }, [startCamera, syncZoomState]); const refreshDevices = useCallback(async () => { try { @@ -713,9 +765,16 @@ export default function Recorder() { setFacingMode(nextFacingMode); if (mode === "idle" && cameraActive) { stopCamera(); - await startCamera(nextFacingMode); + await startCamera(nextFacingMode, zoomTargetRef.current, qualityPreset); } - }, [cameraActive, facingMode, mode, startCamera, stopCamera]); + }, [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(); @@ -968,6 +1027,35 @@ export default function Recorder() { ); + const renderZoomOverlay = () => ( +
+ +
+
焦距
+
{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}
+
+ +
+ ); + return (
@@ -1112,6 +1200,8 @@ export default function Recorder() { )} + + {cameraActive && zoomState.supported ? renderZoomOverlay() : null}
@@ -1145,19 +1235,21 @@ export default function Recorder() {
- {Object.entries(QUALITY_PRESETS).map(([key, preset]) => { - const active = qualityPreset === key; - return ( -
+ +
+
+
+
镜头焦距 / 放大缩小
+
+ {zoomState.supported + ? `当前 ${zoomState.current.toFixed(1)}x,可在录制过程中直接调整;设备焦点模式为 ${zoomState.focusMode}。` + : "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"} +
+
+ + 默认 {QUALITY_PRESETS.economy.label} + +
+ + {zoomState.supported ? ( +
+ { + if (typeof value[0] === "number") { + void updateZoom(value[0]); + } + }} + /> +
+ {zoomState.min.toFixed(1)}x + 默认建议 1.0x-1.5x,用于半场动作取景 + {zoomState.max.toFixed(1)}x +
+
+ ) : null} +
@@ -1379,6 +1508,8 @@ export default function Recorder() { > + + {cameraActive && zoomState.supported ? renderZoomOverlay() : null}