Add camera zoom and data saver controls

这个提交包含在:
cryptocommuniums-afk
2026-03-15 16:17:34 +08:00
父节点 bd8998166b
当前提交 c4ec397ed3
修改 4 个文件,包含 577 行新增39 行删除

查看文件

@@ -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 普通文件
查看文件

@@ -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);
}