From a2115628601155fe656392d512e6afb952021584 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Mar 2026 22:23:58 +0800 Subject: [PATCH] fix camera startup fallbacks --- client/src/lib/camera.ts | 99 +++++++++++++++++++++++++++++++++ client/src/pages/LiveCamera.tsx | 20 ++++--- client/src/pages/Recorder.tsx | 17 +++++- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/client/src/lib/camera.ts b/client/src/lib/camera.ts index e324b8f..5ffeb8f 100644 --- a/client/src/lib/camera.ts +++ b/client/src/lib/camera.ts @@ -9,6 +9,13 @@ export type CameraZoomState = { focusMode: string; }; +export type CameraRequestResult = { + stream: MediaStream; + appliedFacingMode: "user" | "environment"; + audioEnabled: boolean; + usedFallback: boolean; +}; + type NumericRange = { min: number; max: number; @@ -66,6 +73,98 @@ export function getCameraVideoConstraints( } } +function normalizeVideoConstraintCandidate(candidate: MediaTrackConstraints | true) { + if (candidate === true) { + return { label: "camera-any", video: true as const }; + } + + return { + label: JSON.stringify(candidate), + video: candidate, + }; +} + +function createFallbackVideoCandidates( + facingMode: "user" | "environment", + isMobile: boolean, + preset: CameraQualityPreset, +) { + const base = getCameraVideoConstraints(facingMode, isMobile, preset); + const alternateFacing = facingMode === "environment" ? "user" : "environment"; + const lowRes = { + facingMode, + width: { ideal: isMobile ? 640 : 960 }, + height: { ideal: isMobile ? 360 : 540 }, + } satisfies MediaTrackConstraints; + const lowResAlternate = { + facingMode: alternateFacing, + width: { ideal: isMobile ? 640 : 960 }, + height: { ideal: isMobile ? 360 : 540 }, + } satisfies MediaTrackConstraints; + const anyCamera = { + width: { ideal: isMobile ? 640 : 960 }, + height: { ideal: isMobile ? 360 : 540 }, + } satisfies MediaTrackConstraints; + + const candidates = [ + normalizeVideoConstraintCandidate(base), + normalizeVideoConstraintCandidate({ + ...base, + frameRate: undefined, + }), + normalizeVideoConstraintCandidate(lowRes), + normalizeVideoConstraintCandidate(lowResAlternate), + normalizeVideoConstraintCandidate(anyCamera), + normalizeVideoConstraintCandidate(true), + ]; + + const deduped = new Map(); + candidates.forEach((candidate) => { + if (!deduped.has(candidate.label)) { + deduped.set(candidate.label, { video: candidate.video }); + } + }); + return Array.from(deduped.values()); +} + +export async function requestCameraStream(options: { + facingMode: "user" | "environment"; + isMobile: boolean; + preset: CameraQualityPreset; + audio?: false | MediaTrackConstraints; +}) { + const videoCandidates = createFallbackVideoCandidates(options.facingMode, options.isMobile, options.preset); + const audioCandidates = options.audio ? [options.audio, false] : [false]; + let lastError: unknown = null; + + for (const audio of audioCandidates) { + for (let index = 0; index < videoCandidates.length; index += 1) { + const video = videoCandidates[index]?.video ?? true; + try { + const stream = await navigator.mediaDevices.getUserMedia({ video, audio }); + const videoTrack = stream.getVideoTracks()[0] || null; + const settings = ( + videoTrack && typeof (videoTrack as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function" + ? (videoTrack as MediaStreamTrack & { getSettings: () => unknown }).getSettings() + : {} + ) as Record; + const appliedFacingMode = settings.facingMode === "user" ? "user" : settings.facingMode === "environment" ? "environment" : options.facingMode; + + return { + stream, + appliedFacingMode, + audioEnabled: stream.getAudioTracks().length > 0, + usedFallback: index > 0 || audio === false && Boolean(options.audio), + } satisfies CameraRequestResult; + } catch (error) { + lastError = error; + } + } + } + + throw lastError instanceof Error ? lastError : new Error("无法访问摄像头"); +} + export function getLiveAnalysisBitrate(preset: CameraQualityPreset, isMobile: boolean) { switch (preset) { case "economy": diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index ca5256c..6e175d8 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -17,7 +17,7 @@ import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; -import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera"; +import { applyTrackZoom, type CameraQualityPreset, getLiveAnalysisBitrate, readTrackZoomState, requestCameraStream } from "@/lib/camera"; import { ACTION_WINDOW_FRAMES, AVATAR_PRESETS, @@ -1221,20 +1221,24 @@ export default function LiveCamera() { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } - - const constraints: MediaStreamConstraints = { - video: getCameraVideoConstraints(nextFacing, mobile, preset), - audio: false, - }; - - const stream = await navigator.mediaDevices.getUserMedia(constraints); + const { stream, appliedFacingMode, usedFallback } = await requestCameraStream({ + facingMode: nextFacing, + isMobile: mobile, + preset, + }); streamRef.current = stream; + if (appliedFacingMode !== nextFacing) { + setFacing(appliedFacingMode); + } if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); setCameraActive(true); + if (usedFallback) { + toast.info("当前设备已自动切换到兼容摄像头模式"); + } toast.success("摄像头已启动"); } catch (error: any) { toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); diff --git a/client/src/pages/Recorder.tsx b/client/src/pages/Recorder.tsx index bfb0cd2..e9253a4 100644 --- a/client/src/pages/Recorder.tsx +++ b/client/src/pages/Recorder.tsx @@ -31,7 +31,7 @@ import { recognizeActionFrame, stabilizeActionFrame, } from "@/lib/actionRecognition"; -import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera"; +import { applyTrackZoom, readTrackZoomState, requestCameraStream } from "@/lib/camera"; import { formatDateTimeShanghai } from "@/lib/time"; import { Activity, @@ -420,8 +420,10 @@ export default function Recorder() { streamRef.current = null; } - const stream = await navigator.mediaDevices.getUserMedia({ - video: getCameraVideoConstraints(nextFacingMode, mobile, preset), + const { stream, appliedFacingMode, audioEnabled, usedFallback } = await requestCameraStream({ + facingMode: nextFacingMode, + isMobile: mobile, + preset, audio: { echoCancellation: true, noiseSuppression: true, @@ -438,6 +440,9 @@ export default function Recorder() { suppressTrackEndedRef.current = false; streamRef.current = stream; + if (appliedFacingMode !== nextFacingMode) { + setFacingMode(appliedFacingMode); + } if (liveVideoRef.current) { liveVideoRef.current.srcObject = stream; await liveVideoRef.current.play(); @@ -445,6 +450,12 @@ export default function Recorder() { await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); setCameraError(""); setCameraActive(true); + if (usedFallback) { + toast.info("当前设备已自动切换到兼容摄像头模式"); + } + if (!audioEnabled) { + toast.warning("麦克风不可用,已切换为仅视频模式"); + } return stream; } catch (error: any) { const message = error?.message || "无法访问摄像头";