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);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { toast } from "sonner";
|
||||
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
|
||||
import {
|
||||
Activity,
|
||||
Camera,
|
||||
@@ -14,9 +16,11 @@ import {
|
||||
CheckCircle2,
|
||||
FlipHorizontal,
|
||||
Maximize2,
|
||||
Minus,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
@@ -109,6 +113,23 @@ const SETUP_STEPS = [
|
||||
const SEGMENT_MAX_MS = 10_000;
|
||||
const MERGE_GAP_MS = 500;
|
||||
const MIN_SEGMENT_MS = 250;
|
||||
const CAMERA_QUALITY_PRESETS: Record<CameraQualityPreset, { label: string; subtitle: string; description: string }> = {
|
||||
economy: {
|
||||
label: "节省流量",
|
||||
subtitle: "540p-720p · 低码率",
|
||||
description: "默认模式,优先减少本地录制文件大小与移动网络流量。",
|
||||
},
|
||||
balanced: {
|
||||
label: "均衡模式",
|
||||
subtitle: "720p-900p · 中码率",
|
||||
description: "兼顾动作识别稳定度与录制体积。",
|
||||
},
|
||||
clarity: {
|
||||
label: "清晰优先",
|
||||
subtitle: "720p-1080p · 高码率",
|
||||
description: "适合 Wi-Fi 和需要保留更多回放细节的场景。",
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
@@ -540,6 +561,7 @@ export default function LiveCamera() {
|
||||
const currentSegmentRef = useRef<ActionSegment | null>(null);
|
||||
const segmentsRef = useRef<ActionSegment[]>([]);
|
||||
const frameSamplesRef = useRef<PoseScore[]>([]);
|
||||
const zoomTargetRef = useRef(1);
|
||||
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [facing, setFacing] = useState<CameraFacing>("environment");
|
||||
@@ -556,6 +578,8 @@ export default function LiveCamera() {
|
||||
const [segments, setSegments] = useState<ActionSegment[]>([]);
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const [segmentFilter, setSegmentFilter] = useState<ActionType | "all">("all");
|
||||
const [qualityPreset, setQualityPreset] = useState<CameraQualityPreset>("economy");
|
||||
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
|
||||
|
||||
const uploadMutation = trpc.video.upload.useMutation();
|
||||
const saveLiveSessionMutation = trpc.analysis.liveSessionSave.useMutation({
|
||||
@@ -670,6 +694,7 @@ export default function LiveCamera() {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setZoomState(readTrackZoomState(null));
|
||||
setCameraActive(false);
|
||||
}, [stopSessionRecorder]);
|
||||
|
||||
@@ -679,19 +704,58 @@ export default function LiveCamera() {
|
||||
};
|
||||
}, [stopCamera]);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
const syncZoomState = useCallback(async (preferredZoom?: number, providedTrack?: MediaStreamTrack | null) => {
|
||||
const track = providedTrack || streamRef.current?.getVideoTracks()[0] || null;
|
||||
if (!track) {
|
||||
zoomTargetRef.current = 1;
|
||||
setZoomState(readTrackZoomState(null));
|
||||
return;
|
||||
}
|
||||
|
||||
let nextState = readTrackZoomState(track);
|
||||
if (nextState.supported && preferredZoom != null && Math.abs(preferredZoom - nextState.current) > nextState.step / 2) {
|
||||
try {
|
||||
nextState = await applyTrackZoom(track, preferredZoom);
|
||||
} catch {
|
||||
nextState = readTrackZoomState(track);
|
||||
}
|
||||
}
|
||||
|
||||
zoomTargetRef.current = nextState.current;
|
||||
setZoomState(nextState);
|
||||
}, []);
|
||||
|
||||
const updateZoom = useCallback(async (nextZoom: number) => {
|
||||
const track = streamRef.current?.getVideoTracks()[0] || null;
|
||||
if (!track) return;
|
||||
|
||||
try {
|
||||
const nextState = await applyTrackZoom(track, nextZoom);
|
||||
zoomTargetRef.current = nextState.current;
|
||||
setZoomState(nextState);
|
||||
} catch (error: any) {
|
||||
toast.error(`镜头缩放调整失败: ${error?.message || "当前设备不支持"}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stepZoom = useCallback((direction: -1 | 1) => {
|
||||
if (!zoomState.supported) return;
|
||||
const nextZoom = clamp(zoomState.current + zoomState.step * direction, zoomState.min, zoomState.max);
|
||||
void updateZoom(nextZoom);
|
||||
}, [updateZoom, zoomState]);
|
||||
|
||||
const startCamera = useCallback(async (
|
||||
nextFacing: CameraFacing = facing,
|
||||
preferredZoom = zoomTargetRef.current,
|
||||
preset: CameraQualityPreset = qualityPreset,
|
||||
) => {
|
||||
try {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: facing,
|
||||
width: { ideal: mobile ? 1280 : 1920 },
|
||||
height: { ideal: mobile ? 720 : 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
},
|
||||
video: getCameraVideoConstraints(nextFacing, mobile, preset),
|
||||
audio: false,
|
||||
};
|
||||
|
||||
@@ -701,12 +765,13 @@ export default function LiveCamera() {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
|
||||
setCameraActive(true);
|
||||
toast.success("摄像头已启动");
|
||||
} catch (error: any) {
|
||||
toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`);
|
||||
}
|
||||
}, [facing, mobile]);
|
||||
}, [facing, mobile, qualityPreset, syncZoomState]);
|
||||
|
||||
const switchCamera = useCallback(async () => {
|
||||
const nextFacing: CameraFacing = facing === "user" ? "environment" : "user";
|
||||
@@ -714,9 +779,16 @@ export default function LiveCamera() {
|
||||
if (!cameraActive) return;
|
||||
stopCamera();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await startCamera();
|
||||
await startCamera(nextFacing, zoomTargetRef.current);
|
||||
}, [cameraActive, facing, startCamera, stopCamera]);
|
||||
|
||||
const handleQualityPresetChange = useCallback(async (nextPreset: CameraQualityPreset) => {
|
||||
setQualityPreset(nextPreset);
|
||||
if (cameraActive && !analyzing && !saving) {
|
||||
await startCamera(facing, zoomTargetRef.current, nextPreset);
|
||||
}
|
||||
}, [analyzing, cameraActive, facing, saving, startCamera]);
|
||||
|
||||
const flushSegment = useCallback((segment: ActionSegment | null) => {
|
||||
if (!segment || segment.durationMs < MIN_SEGMENT_MS) {
|
||||
return;
|
||||
@@ -770,7 +842,10 @@ export default function LiveCamera() {
|
||||
recorderChunksRef.current = [];
|
||||
const mimeType = pickRecorderMimeType();
|
||||
recorderMimeTypeRef.current = mimeType;
|
||||
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: mobile ? 1_300_000 : 2_300_000 });
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: getLiveAnalysisBitrate(qualityPreset, mobile),
|
||||
});
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -788,7 +863,7 @@ export default function LiveCamera() {
|
||||
});
|
||||
|
||||
recorder.start(1000);
|
||||
}, [mobile]);
|
||||
}, [mobile, qualityPreset]);
|
||||
|
||||
const persistSession = useCallback(async () => {
|
||||
const endedAt = Date.now();
|
||||
@@ -1003,8 +1078,8 @@ export default function LiveCamera() {
|
||||
|
||||
const handleSetupComplete = useCallback(async () => {
|
||||
setShowSetupGuide(false);
|
||||
await startCamera();
|
||||
}, [startCamera]);
|
||||
await startCamera(facing, zoomTargetRef.current, qualityPreset);
|
||||
}, [facing, qualityPreset, startCamera]);
|
||||
|
||||
const heroAction = ACTION_META[currentAction];
|
||||
const previewTitle = analyzing ? `${heroAction.label} 识别中` : cameraActive ? "准备开始实时分析" : "摄像头待启动";
|
||||
@@ -1061,6 +1136,35 @@ export default function LiveCamera() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderZoomOverlay = () => (
|
||||
<div className="absolute right-3 bottom-3 z-20 flex items-center gap-2 rounded-2xl border border-white/10 bg-black/65 px-2 py-2 text-white shadow-lg">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => stepZoom(-1)}
|
||||
disabled={!zoomState.supported}
|
||||
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-[78px] text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-white/50">焦距</div>
|
||||
<div className="mt-1 text-sm font-semibold">{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => stepZoom(1)}
|
||||
disabled={!zoomState.supported}
|
||||
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mobile-safe-bottom">
|
||||
<Dialog open={showSetupGuide} onOpenChange={setShowSetupGuide}>
|
||||
@@ -1125,6 +1229,10 @@ export default function LiveCamera() {
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
{sessionMode === "practice" ? "练习会话" : "训练 PK"}
|
||||
</Badge>
|
||||
<Badge className="gap-1.5 border-white/10 bg-white/10 text-white hover:bg-white/10">
|
||||
<Video className="h-3.5 w-3.5" />
|
||||
默认 {CAMERA_QUALITY_PRESETS[qualityPreset].label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">实时分析中枢</h1>
|
||||
@@ -1205,6 +1313,8 @@ export default function LiveCamera() {
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
|
||||
|
||||
{(analyzing || saving) ? (
|
||||
<div className="absolute bottom-3 left-3 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
|
||||
{saving ? "正在保存会话..." : `识别中 · ${formatDuration(durationMs)}`}
|
||||
@@ -1231,6 +1341,76 @@ export default function LiveCamera() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">拍摄与流量设置</CardTitle>
|
||||
<CardDescription>默认使用节省流量模式,必要时再切到更高画质。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{Object.entries(CAMERA_QUALITY_PRESETS).map(([key, preset]) => {
|
||||
const active = qualityPreset === key;
|
||||
const disabled = analyzing || saving;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => void handleQualityPresetChange(key as CameraQualityPreset)}
|
||||
disabled={disabled}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition ${
|
||||
active
|
||||
? "border-primary/60 bg-primary/5 shadow-sm"
|
||||
: "border-border/60 hover:border-primary/30 hover:bg-muted/50"
|
||||
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{preset.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{preset.subtitle}</div>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{preset.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||
<div className="text-sm font-medium">当前采集规格</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{CAMERA_QUALITY_PRESETS[qualityPreset].subtitle} · 分析录制码率会随模式同步切换,默认优先节省流量。
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||
<div className="text-sm font-medium">镜头焦距 / 放大缩小</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{zoomState.supported
|
||||
? `当前 ${zoomState.current.toFixed(1)}x,可在分析过程中直接微调取景;焦点模式为 ${zoomState.focusMode}。`
|
||||
: "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zoomState.supported ? (
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<Slider
|
||||
value={[zoomState.current]}
|
||||
min={zoomState.min}
|
||||
max={zoomState.max}
|
||||
step={zoomState.step}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value[0] === "number") {
|
||||
void updateZoom(value[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{zoomState.min.toFixed(1)}x</span>
|
||||
<span>建议 1.0x-1.5x 保留完整挥拍</span>
|
||||
<span>{zoomState.max.toFixed(1)}x</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">连续动作区间</CardTitle>
|
||||
@@ -1483,6 +1663,8 @@ export default function LiveCamera() {
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
|
||||
@@ -19,9 +19,11 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { toast } from "sonner";
|
||||
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
|
||||
import {
|
||||
Activity,
|
||||
Camera,
|
||||
@@ -31,8 +33,10 @@ import {
|
||||
FlipHorizontal,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minus,
|
||||
Minimize2,
|
||||
MonitorUp,
|
||||
Plus,
|
||||
Scissors,
|
||||
ShieldAlert,
|
||||
Smartphone,
|
||||
@@ -182,9 +186,10 @@ export default function Recorder() {
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const facingModeRef = useRef<"user" | "environment">("environment");
|
||||
const suppressTrackEndedRef = useRef(false);
|
||||
const zoomTargetRef = useRef(1);
|
||||
|
||||
const [mode, setMode] = useState<RecorderMode>("idle");
|
||||
const [qualityPreset, setQualityPreset] = useState<keyof typeof QUALITY_PRESETS>("balanced");
|
||||
const [qualityPreset, setQualityPreset] = useState<keyof typeof QUALITY_PRESETS>("economy");
|
||||
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
|
||||
@@ -203,6 +208,7 @@ export default function Recorder() {
|
||||
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
||||
const [immersivePreview, setImmersivePreview] = useState(false);
|
||||
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
|
||||
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
|
||||
|
||||
const mobile = useMemo(() => isMobileDevice(), []);
|
||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||
@@ -290,10 +296,59 @@ export default function Recorder() {
|
||||
if (liveVideoRef.current) {
|
||||
liveVideoRef.current.srcObject = null;
|
||||
}
|
||||
setZoomState(readTrackZoomState(null));
|
||||
setCameraActive(false);
|
||||
}, []);
|
||||
|
||||
const startCamera = useCallback(async (nextFacingMode = facingMode) => {
|
||||
const syncZoomState = useCallback(async (preferredZoom?: number, providedTrack?: MediaStreamTrack | null) => {
|
||||
const track = providedTrack || streamRef.current?.getVideoTracks()[0] || null;
|
||||
if (!track) {
|
||||
zoomTargetRef.current = 1;
|
||||
setZoomState(readTrackZoomState(null));
|
||||
return;
|
||||
}
|
||||
|
||||
let nextState = readTrackZoomState(track);
|
||||
if (nextState.supported && preferredZoom != null && Math.abs(preferredZoom - nextState.current) > nextState.step / 2) {
|
||||
try {
|
||||
nextState = await applyTrackZoom(track, preferredZoom);
|
||||
} catch {
|
||||
nextState = readTrackZoomState(track);
|
||||
}
|
||||
}
|
||||
|
||||
zoomTargetRef.current = nextState.current;
|
||||
setZoomState(nextState);
|
||||
}, []);
|
||||
|
||||
const updateZoom = useCallback(async (nextZoom: number) => {
|
||||
const track = streamRef.current?.getVideoTracks()[0] || null;
|
||||
if (!track) return;
|
||||
|
||||
try {
|
||||
const nextState = await applyTrackZoom(track, nextZoom);
|
||||
zoomTargetRef.current = nextState.current;
|
||||
setZoomState(nextState);
|
||||
} catch (error: any) {
|
||||
toast.error(`镜头缩放调整失败: ${error?.message || "当前设备不支持"}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stepZoom = useCallback((direction: -1 | 1) => {
|
||||
if (!zoomState.supported) return;
|
||||
const nextZoom = Math.max(
|
||||
zoomState.min,
|
||||
Math.min(zoomState.max, zoomState.current + zoomState.step * direction),
|
||||
);
|
||||
void updateZoom(nextZoom);
|
||||
}, [updateZoom, zoomState]);
|
||||
|
||||
const startCamera = useCallback((
|
||||
async (
|
||||
nextFacingMode = facingMode,
|
||||
preferredZoom = zoomTargetRef.current,
|
||||
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
|
||||
) => {
|
||||
try {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -301,12 +356,7 @@ export default function Recorder() {
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: nextFacingMode,
|
||||
width: { ideal: mobile ? 1280 : 1920 },
|
||||
height: { ideal: mobile ? 720 : 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
},
|
||||
video: getCameraVideoConstraints(nextFacingMode, mobile, preset),
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
@@ -327,6 +377,7 @@ export default function Recorder() {
|
||||
liveVideoRef.current.srcObject = stream;
|
||||
await liveVideoRef.current.play();
|
||||
}
|
||||
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
|
||||
setCameraError("");
|
||||
setCameraActive(true);
|
||||
return stream;
|
||||
@@ -336,7 +387,7 @@ export default function Recorder() {
|
||||
toast.error(`摄像头启动失败: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}, [facingMode, mobile]);
|
||||
}), [facingMode, mobile, qualityPreset, syncZoomState]);
|
||||
|
||||
const ensurePreviewStream = useCallback(async () => {
|
||||
if (streamRef.current) {
|
||||
@@ -344,12 +395,13 @@ export default function Recorder() {
|
||||
liveVideoRef.current.srcObject = streamRef.current;
|
||||
await liveVideoRef.current.play().catch(() => {});
|
||||
}
|
||||
await syncZoomState(zoomTargetRef.current);
|
||||
setCameraActive(true);
|
||||
return streamRef.current;
|
||||
}
|
||||
|
||||
return startCamera(facingModeRef.current);
|
||||
}, [startCamera]);
|
||||
return startCamera(facingModeRef.current, zoomTargetRef.current, qualityPreset);
|
||||
}, [startCamera, syncZoomState]);
|
||||
|
||||
const refreshDevices = useCallback(async () => {
|
||||
try {
|
||||
@@ -713,9 +765,16 @@ export default function Recorder() {
|
||||
setFacingMode(nextFacingMode);
|
||||
if (mode === "idle" && cameraActive) {
|
||||
stopCamera();
|
||||
await startCamera(nextFacingMode);
|
||||
await startCamera(nextFacingMode, zoomTargetRef.current, qualityPreset);
|
||||
}
|
||||
}, [cameraActive, facingMode, mode, startCamera, stopCamera]);
|
||||
}, [cameraActive, facingMode, mode, qualityPreset, startCamera, stopCamera]);
|
||||
|
||||
const handleQualityPresetChange = useCallback(async (nextPreset: keyof typeof QUALITY_PRESETS) => {
|
||||
setQualityPreset(nextPreset);
|
||||
if (cameraActive && mode === "idle") {
|
||||
await startCamera(facingModeRef.current, zoomTargetRef.current, nextPreset);
|
||||
}
|
||||
}, [cameraActive, mode, startCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDevices();
|
||||
@@ -968,6 +1027,35 @@ export default function Recorder() {
|
||||
</>
|
||||
);
|
||||
|
||||
const renderZoomOverlay = () => (
|
||||
<div className="absolute right-3 bottom-3 z-20 flex items-center gap-2 rounded-2xl border border-white/10 bg-black/65 px-2 py-2 text-white shadow-lg">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => stepZoom(-1)}
|
||||
disabled={!zoomState.supported}
|
||||
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-[78px] text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-white/50">焦距</div>
|
||||
<div className="mt-1 text-sm font-semibold">{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => stepZoom(1)}
|
||||
disabled={!zoomState.supported}
|
||||
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
|
||||
<canvas ref={motionCanvasRef} className="hidden" />
|
||||
@@ -1112,6 +1200,8 @@ export default function Recorder() {
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 bg-card/80 p-4">
|
||||
@@ -1147,16 +1237,18 @@ export default function Recorder() {
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => {
|
||||
const active = qualityPreset === key;
|
||||
const disabled = mode !== "idle" && mode !== "archived";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setQualityPreset(key as keyof typeof QUALITY_PRESETS)}
|
||||
onClick={() => void handleQualityPresetChange(key as keyof typeof QUALITY_PRESETS)}
|
||||
disabled={disabled}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition ${
|
||||
active
|
||||
? "border-primary/60 bg-primary/5 shadow-sm"
|
||||
: "border-border/60 hover:border-primary/30 hover:bg-muted/50"
|
||||
}`}
|
||||
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{preset.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{preset.subtitle}</div>
|
||||
@@ -1180,6 +1272,43 @@ export default function Recorder() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">镜头焦距 / 放大缩小</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{zoomState.supported
|
||||
? `当前 ${zoomState.current.toFixed(1)}x,可在录制过程中直接调整;设备焦点模式为 ${zoomState.focusMode}。`
|
||||
: "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="w-fit">
|
||||
默认 {QUALITY_PRESETS.economy.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{zoomState.supported ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Slider
|
||||
value={[zoomState.current]}
|
||||
min={zoomState.min}
|
||||
max={zoomState.max}
|
||||
step={zoomState.step}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value[0] === "number") {
|
||||
void updateZoom(value[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{zoomState.min.toFixed(1)}x</span>
|
||||
<span>默认建议 1.0x-1.5x,用于半场动作取景</span>
|
||||
<span>{zoomState.max.toFixed(1)}x</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -1379,6 +1508,8 @@ export default function Recorder() {
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户