diff --git a/client/src/components/camera-recorder.tsx b/client/src/components/camera-recorder.tsx index 628fe38..abb4c21 100644 --- a/client/src/components/camera-recorder.tsx +++ b/client/src/components/camera-recorder.tsx @@ -1,9 +1,9 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2 } from 'lucide-react'; +import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; import { useToast } from '@/hooks/use-toast'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; interface CameraRecorderProps { onCapture: (file: File) => void; @@ -37,26 +37,64 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const [recordedBlob, setRecordedBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [timer, setTimer] = useState(0); - const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment'); + + // Camera Device State + 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); + // Fetch video devices + 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); + + if (videoDevices.length > 0 && !selectedDeviceId) { + setSelectedDeviceId(videoDevices[0].deviceId); + } + } catch (err) { + console.error("Error enumerating devices:", err); + setPermissionError("Accès caméra refusé ou non disponible."); + } + }; + getDevices(); + }, []); + // Initialisation de la caméra const startCamera = useCallback(async () => { + if (!selectedDeviceId && devices.length > 0) return; // Wait for selection logic + try { + setPermissionError(null); + if (stream) { stream.getTracks().forEach(track => track.stop()); } const config = QUALITY_CONFIGS[quality]; - console.log(`📸 Starting camera: ${config.label}, facing: ${facingMode}`); + console.log(`📸 Starting camera: ${config.label}, deviceId: ${selectedDeviceId}`); - // Simple constraints that work on all devices - const simpleConstraints: MediaStreamConstraints = { - audio: true, - video: { - facingMode: facingMode, + // Constraints with specific deviceId if selected, otherwise facingMode fallback + const constraints: MediaStreamConstraints = { + audio: { + echoCancellation: true, + noiseSuppression: true, + }, + video: selectedDeviceId ? { + deviceId: { exact: selectedDeviceId }, + width: { ideal: config.width }, + height: { ideal: config.height }, + } : { + facingMode: 'user', // Default fallback width: { ideal: config.width }, height: { ideal: config.height }, } @@ -65,15 +103,14 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { let newStream: MediaStream; try { - // Try to get camera with simple constraints - newStream = await navigator.mediaDevices.getUserMedia(simpleConstraints); - console.log('✅ Camera obtained with simple constraints'); + newStream = await navigator.mediaDevices.getUserMedia(constraints); + console.log('✅ Camera obtained'); } catch (err) { - console.warn('⚠️ Simple constraints failed, trying minimal:', err); - // Fallback to minimal constraints + console.warn('⚠️ Constraints failed, trying minimal fallback:', err); + // Fallback to minimal constraints (any video source) newStream = await navigator.mediaDevices.getUserMedia({ audio: true, - video: { facingMode: facingMode } + video: true }); console.log('✅ Camera obtained with minimal constraints'); } @@ -83,8 +120,12 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { if (videoRef.current) { videoRef.current.srcObject = newStream; videoRef.current.volume = 0; - // Ensure video plays on iOS - await videoRef.current.play().catch(() => { }); + // Explicit play try + try { + await videoRef.current.play(); + } catch (playErr) { + console.error("Video play failed:", playErr); + } } // Check actual resolution @@ -98,24 +139,27 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { } catch (error) { console.error('❌ Error accessing camera:', error); + setPermissionError(error instanceof Error ? error.message : "Erreur inconnue"); toast({ title: "Erreur caméra", description: "Impossible d'accéder à la caméra. Vérifiez les permissions.", variant: "destructive" }); - onCancel(); } - }, [facingMode, quality, onCancel, toast]); + }, [selectedDeviceId, quality, toast, devices]); // Removed complex deps + // Restart camera when device or quality changes useEffect(() => { - startCamera(); + if (selectedDeviceId) { + startCamera(); + } return () => { - // Cleanup on unmount if (stream) { stream.getTracks().forEach(track => track.stop()); } }; - }, [startCamera]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDeviceId, quality]); // Timer logic useEffect(() => { @@ -136,22 +180,30 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { chunksRef.current = []; // Essayer les codecs supportés pour la meilleure qualité - // iOS (Safari) supporte bien video/mp4 ou video/webkit;codecs=h264 - let mimeType = 'video/mp4'; - if (!MediaRecorder.isTypeSupported(mimeType)) { - mimeType = 'video/webm;codecs=vp9'; - if (!MediaRecorder.isTypeSupported(mimeType)) { - mimeType = 'video/webm'; - } + const mimeTypes = [ + 'video/mp4', + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm' + ]; + + let mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || ''; + + if (!mimeType) { + console.warn("No supported mimeType found, letting browser decide default."); } console.log('🎥 Recording with mimeType:', mimeType); try { - const mediaRecorder = new MediaRecorder(stream, { - mimeType, + const options: MediaRecorderOptions = { videoBitsPerSecond: 8000000 // 8 Mbps target - }); + }; + if (mimeType) { + options.mimeType = mimeType; + } + + const mediaRecorder = new MediaRecorder(stream, options); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { @@ -160,12 +212,14 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { }; mediaRecorder.onstop = () => { - const blob = new Blob(chunksRef.current, { type: mimeType }); + const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' }); setRecordedBlob(blob); const url = URL.createObjectURL(blob); setPreviewUrl(url); // Stop stream tracks to save battery while reviewing + // stream.getTracks().forEach(track => track.stop()); // Don't stop yet if we want retake to be fast? + // Actually retake restarts camera, so stopping here is fine. stream.getTracks().forEach(track => track.stop()); }; @@ -189,19 +243,15 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { } }; - const switchCamera = () => { - setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); - }; - const handleRetake = () => { setRecordedBlob(null); setPreviewUrl(null); - startCamera(); // Restart stream + // Force restart + startCamera(); }; const handleConfirm = () => { if (recordedBlob) { - // Create a File object const file = new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }); @@ -253,12 +303,15 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { return (
{/* Header */} -
- -
- {/* Recording timer or quality selector */} +
+
+ +
+ +
+ {/* Recording timer or Settings Trigger */} {isRecording ? (
@@ -267,101 +320,126 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { ) : ( )}
- + +
{/* Spacer for alignment */}
- {/* Quality Settings Panel */} + {/* Error Message */} + {permissionError && ( +
+ +

Erreur Caméra

+

{permissionError}

+ +
+ )} + + {/* Settings Panel Overlay */} {showSettings && !isRecording && ( -
-

Qualité Vidéo

-
- {(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => ( - - ))} +
+
+
+

Paramètres Caméra

+ +
+ +
+
+ + +
+ +
+ +
+ {(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => ( + + ))} +
+

+ {QUALITY_CONFIGS[quality].label} - {resolutionInfo} +

+
+ + +
-

- {QUALITY_CONFIGS[quality].label} -

)} {/* Viewfinder */}
-
{/* Controls */} -
- {/* Quality selector - visible before recording */} - {!isRecording && ( -
- {(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => ( - - ))} -
- )} - - {/* Record button */} - {isRecording ? ( - - ) : ( - - )} +
+
+ {isRecording ? ( + + ) : ( + + )} +
); } + +// Ensure TypeScript treats this as a module +export default CameraRecorder; diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index 51d0931..fc71ee4 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -65,9 +65,9 @@ export default function NewReel() { const [selectedPages, setSelectedPages] = useState([]); const [scheduledDate, setScheduledDate] = useState(undefined); const [musicVolume, setMusicVolume] = useState([25]); - const [ttsEnabled, setTtsEnabled] = useState(false); + const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsVoice, setTtsVoice] = useState("female"); - const [drawText, setDrawText] = useState(true); + const [drawText, setDrawText] = useState(false); // État audio preview const [isPlaying, setIsPlaying] = useState(null); diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index ada8088..7832c57 100644 --- a/ffmpeg-service/main.py +++ b/ffmpeg-service/main.py @@ -30,10 +30,10 @@ class ReelRequest(BaseModel): music_id: Optional[str] = None music_url: Optional[str] = None word_duration: float = 0.6 - font_size: int = 60 + font_size: int = 30 music_volume: float = 0.25 tts_enabled: bool = False - tts_voice: str = "fr-FR-VivienneNeural" + tts_voice: str = "fr-FR-VivienneMultilingualNeural" draw_text: bool = True @@ -270,8 +270,8 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): if request.text and request.draw_text: if has_tts: # Use subtitles filter - # Force style to look like TikTok/Reels text (Bottom center, white, black box) - style = f"FontName=Arial,FontSize={request.font_size},PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000,BorderStyle=3,BackColour=&H80000000,Bold=1,Alignment=2,MarginV=150" + # Force style to look like TikTok/Reels text (No Black Box, just Outline) + style = f"FontName=Arial,FontSize={request.font_size},PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=3,Shadow=0,Bold=1,Alignment=2,MarginV=150" # Escape path for FFmpeg filter vtt_path_str = str(tts_vtt_path).replace("\\", "/").replace(":", "\\:") video_filters.append( @@ -280,7 +280,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): else: # Standard Drawtext logic sanitized_text = request.text.replace("'", "").replace(":", "\\:") - drawtext = f"drawtext=fontfile={FONT_PATH}:text='{sanitized_text}':fontcolor=white:fontsize={request.font_size}:box=1:boxcolor=black@0.5:boxborderw=5:x=(w-text_w)/2:y=h-text_h-150" + drawtext = f"drawtext=fontfile={FONT_PATH}:text='{sanitized_text}':fontcolor=white:fontsize={request.font_size}:borderw=3:bordercolor=black:x=(w-text_w)/2:y=h-text_h-150" video_filters.append(drawtext) # Audio Mixing Strategy