From c9b7493c9c1e26c23bbff04082c2d1b9f678896a Mon Sep 17 00:00:00 2001 From: Jacques Date: Thu, 22 Jan 2026 21:05:17 +0100 Subject: [PATCH] feat: refactor camera to react-webcam, add test route, and fix docker volume for ffmpeg --- client/src/App.tsx | 2 + client/src/components/camera-recorder.tsx | 291 +++++++--------------- client/src/pages/test-camera.tsx | 40 +++ docker-compose.yml | 2 + package-lock.json | 11 + package.json | 1 + 6 files changed, 142 insertions(+), 205 deletions(-) create mode 100644 client/src/pages/test-camera.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 29e63ca..061c14e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { createResponsiveRoute } from "@/components/responsive-route"; import NotFound from "@/pages/not-found"; import Login from "@/pages/login"; +import TestCamera from "@/pages/test-camera"; // Create responsive routes with lazy loading for optimal performance @@ -135,6 +136,7 @@ function Router() { {() => } {() => } {() => } + ); diff --git a/client/src/components/camera-recorder.tsx b/client/src/components/camera-recorder.tsx index 8d35df1..d3e81dd 100644 --- a/client/src/components/camera-recorder.tsx +++ b/client/src/components/camera-recorder.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Bug } from 'lucide-react'; +import Webcam from 'react-webcam'; +import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Video } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useToast } from '@/hooks/use-toast'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -17,163 +18,72 @@ interface QualityConfig { label: string; width: number; height: number; - bitrate: number; } const QUALITY_CONFIGS: Record = { - "4k": { label: "4K Ultra HD", width: 3840, height: 2160, bitrate: 25000000 }, - "1080p": { label: "Full HD", width: 1920, height: 1080, bitrate: 8000000 }, - "720p": { label: "HD 720p", width: 1280, height: 720, bitrate: 5000000 }, + "4k": { label: "4K Ultra HD", width: 3840, height: 2160 }, + "1080p": { label: "Full HD", width: 1920, height: 1080 }, + "720p": { label: "HD 720p", width: 1280, height: 720 }, }; export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { - const videoRef = useRef(null); + const webcamRef = useRef(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const { toast } = useToast(); - const [stream, setStream] = useState(null); const [isRecording, setIsRecording] = useState(false); const [recordedBlob, setRecordedBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [timer, setTimer] = useState(0); - // Camera Device State + // Devices & Settings const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState(''); - const [permissionError, setPermissionError] = useState(null); - - const [resolutionInfo, setResolutionInfo] = useState(''); const [quality, setQuality] = useState('1080p'); const [showSettings, setShowSettings] = useState(false); - const [showDebug, setShowDebug] = useState(false); + const [cameraError, setCameraError] = useState(null); - // Initial Device Fetch - useEffect(() => { - const getDevices = async () => { - try { - // Request permission first to get device labels - await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); - - const allDevices = await navigator.mediaDevices.enumerateDevices(); - const videoDevices = allDevices.filter(device => device.kind === 'videoinput'); - setDevices(videoDevices); - console.log("Devices found:", videoDevices); - - if (videoDevices.length > 0 && !selectedDeviceId) { - setSelectedDeviceId(videoDevices[0].deviceId); - } - } catch (err) { - console.error("Error enumerating devices:", err); - setPermissionError("Accès caméra refusé. Vérifiez les permissions du navigateur."); - } - }; - getDevices(); - }, []); - - // Camera Start Logic - const startCamera = useCallback(async () => { - try { - setPermissionError(null); - - if (stream) { - stream.getTracks().forEach(track => track.stop()); - } - - const config = QUALITY_CONFIGS[quality]; - console.log(`📸 Starting camera: ${config.label}, deviceId: ${selectedDeviceId}`); - - // Constraints - const constraints: MediaStreamConstraints = { - audio: { - echoCancellation: true, - noiseSuppression: true, - }, - video: selectedDeviceId ? { - deviceId: { exact: selectedDeviceId }, - width: { ideal: config.width }, - height: { ideal: config.height }, - } : { - facingMode: 'user', - width: { ideal: config.width }, - height: { ideal: config.height }, - } - }; - - let newStream: MediaStream; - - try { - newStream = await navigator.mediaDevices.getUserMedia(constraints); - } catch (err) { - console.warn('⚠️ Standard constraints failed:', err); - setPermissionError(`Erreur: ${err instanceof Error ? err.message : String(err)}`); - // Fallback - newStream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: true - }); - } - - setStream(newStream); - - if (videoRef.current) { - videoRef.current.srcObject = newStream; - videoRef.current.volume = 0; - await videoRef.current.play(); - } - - // Resolution check - const videoTrack = newStream.getVideoTracks()[0]; - if (videoTrack) { - const settings = videoTrack.getSettings(); - setResolutionInfo(`${settings.width}x${settings.height}`); - } - - } catch (error) { - console.error('❌ Error accessing camera:', error); - const msg = error instanceof Error ? error.message : "Erreur inconnue"; - setPermissionError(msg); - toast({ - title: "Erreur caméra", - description: msg, - variant: "destructive" - }); + // Fetch devices + const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => { + const videoDevices = mediaDevices.filter(({ kind }) => kind === "videoinput"); + setDevices(videoDevices); + if (videoDevices.length > 0 && !selectedDeviceId) { + setSelectedDeviceId(videoDevices[0].deviceId); } - }, [selectedDeviceId, quality, toast]); + }, [selectedDeviceId]); - // Restart on quality/device change useEffect(() => { - if (selectedDeviceId || devices.length === 0) { - // Attempt start even if no devices found (might work with default) - startCamera(); - } - return () => { - if (stream) { - stream.getTracks().forEach(track => track.stop()); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDeviceId, quality]); + navigator.mediaDevices.enumerateDevices().then(handleDevices).catch(err => { + console.error("Error enumerating devices", err); + setCameraError("Erreur liste périphériques: " + err.message); + }); + }, [handleDevices]); - // Timer logic + // Timer useEffect(() => { let interval: NodeJS.Timeout; if (isRecording) { - interval = setInterval(() => { - setTimer(prev => prev + 1); - }, 1000); + interval = setInterval(() => setTimer(t => t + 1), 1000); } else { setTimer(0); } return () => clearInterval(interval); }, [isRecording]); - // Recording Functions - const startRecording = () => { - if (!stream) return; + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Recording Logic + const startRecording = useCallback(() => { + if (!webcamRef.current || !webcamRef.current.stream) return; + chunksRef.current = []; + const stream = webcamRef.current.stream; - // Codec selection const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm']; const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || ''; @@ -191,24 +101,23 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' }); setRecordedBlob(blob); setPreviewUrl(URL.createObjectURL(blob)); - stream.getTracks().forEach(track => track.stop()); }; - mediaRecorder.start(1000); + mediaRecorder.start(); mediaRecorderRef.current = mediaRecorder; setIsRecording(true); - } catch (e) { - console.error('Recording failed:', e); - toast({ title: "Erreur enregistrement", description: "Format non supporté.", variant: "destructive" }); + } catch (e: any) { + console.error("Recording error", e); + toast({ title: "Erreur", description: "Impossible de lancer l'enregistrement: " + e.message, variant: "destructive" }); } - }; + }, [webcamRef, toast]); - const stopRecording = () => { + const stopRecording = useCallback(() => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); } - }; + }, [isRecording]); const handleConfirm = () => { if (recordedBlob) { @@ -216,19 +125,19 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { } }; - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; + const handleUserMediaError = useCallback((error: string | DOMException) => { + console.error("Webcam Error:", error); + const msg = typeof error === 'string' ? error : error.message; + setCameraError(msg); + }, []); - // Render Review Mode + // Review Mode if (recordedBlob && previewUrl) { return (