Add camera zoom and data saver controls

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

查看文件

@@ -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">