fix camera startup fallbacks

这个提交包含在:
cryptocommuniums-afk
2026-03-16 22:23:58 +08:00
父节点 09b1b95e2c
当前提交 a211562860
修改 3 个文件,包含 125 行新增11 行删除

查看文件

@@ -9,6 +9,13 @@ export type CameraZoomState = {
focusMode: string; focusMode: string;
}; };
export type CameraRequestResult = {
stream: MediaStream;
appliedFacingMode: "user" | "environment";
audioEnabled: boolean;
usedFallback: boolean;
};
type NumericRange = { type NumericRange = {
min: number; min: number;
max: 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<string, { video: MediaTrackConstraints | true }>();
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<string, unknown>;
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) { export function getLiveAnalysisBitrate(preset: CameraQualityPreset, isMobile: boolean) {
switch (preset) { switch (preset) {
case "economy": case "economy":

查看文件

@@ -17,7 +17,7 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { formatDateTimeShanghai } from "@/lib/time"; import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner"; 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 { import {
ACTION_WINDOW_FRAMES, ACTION_WINDOW_FRAMES,
AVATAR_PRESETS, AVATAR_PRESETS,
@@ -1221,20 +1221,24 @@ export default function LiveCamera() {
if (streamRef.current) { if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current.getTracks().forEach((track) => track.stop());
} }
const { stream, appliedFacingMode, usedFallback } = await requestCameraStream({
const constraints: MediaStreamConstraints = { facingMode: nextFacing,
video: getCameraVideoConstraints(nextFacing, mobile, preset), isMobile: mobile,
audio: false, preset,
}; });
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream; streamRef.current = stream;
if (appliedFacingMode !== nextFacing) {
setFacing(appliedFacingMode);
}
if (videoRef.current) { if (videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
await videoRef.current.play(); await videoRef.current.play();
} }
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
setCameraActive(true); setCameraActive(true);
if (usedFallback) {
toast.info("当前设备已自动切换到兼容摄像头模式");
}
toast.success("摄像头已启动"); toast.success("摄像头已启动");
} catch (error: any) { } catch (error: any) {
toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`);

查看文件

@@ -31,7 +31,7 @@ import {
recognizeActionFrame, recognizeActionFrame,
stabilizeActionFrame, stabilizeActionFrame,
} from "@/lib/actionRecognition"; } from "@/lib/actionRecognition";
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera"; import { applyTrackZoom, readTrackZoomState, requestCameraStream } from "@/lib/camera";
import { formatDateTimeShanghai } from "@/lib/time"; import { formatDateTimeShanghai } from "@/lib/time";
import { import {
Activity, Activity,
@@ -420,8 +420,10 @@ export default function Recorder() {
streamRef.current = null; streamRef.current = null;
} }
const stream = await navigator.mediaDevices.getUserMedia({ const { stream, appliedFacingMode, audioEnabled, usedFallback } = await requestCameraStream({
video: getCameraVideoConstraints(nextFacingMode, mobile, preset), facingMode: nextFacingMode,
isMobile: mobile,
preset,
audio: { audio: {
echoCancellation: true, echoCancellation: true,
noiseSuppression: true, noiseSuppression: true,
@@ -438,6 +440,9 @@ export default function Recorder() {
suppressTrackEndedRef.current = false; suppressTrackEndedRef.current = false;
streamRef.current = stream; streamRef.current = stream;
if (appliedFacingMode !== nextFacingMode) {
setFacingMode(appliedFacingMode);
}
if (liveVideoRef.current) { if (liveVideoRef.current) {
liveVideoRef.current.srcObject = stream; liveVideoRef.current.srcObject = stream;
await liveVideoRef.current.play(); await liveVideoRef.current.play();
@@ -445,6 +450,12 @@ export default function Recorder() {
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
setCameraError(""); setCameraError("");
setCameraActive(true); setCameraActive(true);
if (usedFallback) {
toast.info("当前设备已自动切换到兼容摄像头模式");
}
if (!audioEnabled) {
toast.warning("麦克风不可用,已切换为仅视频模式");
}
return stream; return stream;
} catch (error: any) { } catch (error: any) {
const message = error?.message || "无法访问摄像头"; const message = error?.message || "无法访问摄像头";