fix camera startup fallbacks
这个提交包含在:
@@ -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 || "无法访问摄像头";
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户