Add camera zoom and data saver controls
这个提交包含在:
74
client/src/lib/camera.test.ts
普通文件
74
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);
|
||||
});
|
||||
});
|
||||
151
client/src/lib/camera.ts
普通文件
151
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<string, unknown>;
|
||||
const settings = (
|
||||
typeof (track as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function"
|
||||
? (track as MediaStreamTrack & { getSettings: () => unknown }).getSettings()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户