diff --git a/client/src/components/camera-recorder.tsx b/client/src/components/camera-recorder.tsx index f39c476..ebdaad8 100644 --- a/client/src/components/camera-recorder.tsx +++ b/client/src/components/camera-recorder.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { useToast } from '@/hooks/use-toast'; interface CameraRecorderProps { - onCapture: (file: File) => void; + onCapture: (file: File, options?: { stabilize?: boolean }) => void; onCancel: () => void; } @@ -17,12 +17,13 @@ interface QualityConfig { label: string; width: number; height: number; + bitrate: number; // in bps } const QUALITY_CONFIGS: Record = { - "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 }, + "4k": { label: "4K Ultra HD", width: 3840, height: 2160, bitrate: 50000000 }, + "1080p": { label: "Full HD", width: 1920, height: 1080, bitrate: 25000000 }, + "720p": { label: "HD 720p", width: 1280, height: 720, bitrate: 12000000 }, }; export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { @@ -43,6 +44,7 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const [quality, setQuality] = useState('1080p'); const [showSettings, setShowSettings] = useState(false); const [cameraError, setCameraError] = useState(null); + const [stabilize, setStabilize] = useState(true); // Stabilisation serveur activée par défaut // Fetch devices const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => { @@ -86,11 +88,12 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm']; const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || ''; + const currentConfig = QUALITY_CONFIGS[quality]; try { const mediaRecorder = new MediaRecorder(stream, { mimeType, - videoBitsPerSecond: 8000000 + videoBitsPerSecond: currentConfig.bitrate // Bitrate dynamique selon qualité }); mediaRecorder.ondataavailable = (e) => { @@ -121,7 +124,10 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const handleConfirm = () => { if (recordedBlob) { - onCapture(new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type })); + onCapture( + new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }), + { stabilize } + ); } }; @@ -157,6 +163,7 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { const videoConstraints = { width: { ideal: config.width }, height: { ideal: config.height }, + frameRate: { ideal: 30 }, // 30fps pour une capture fluide deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined, facingMode: selectedDeviceId ? undefined : facingMode }; @@ -246,6 +253,18 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { ))} +
+ +

Corrige les tremblements après capture (traitement serveur)

+
diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index a6729de..b68750f 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -68,6 +68,7 @@ export default function NewReel() { const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural"); const [drawText, setDrawText] = useState(true); + const [shouldStabilize, setShouldStabilize] = useState(false); // État audio preview const [isPlaying, setIsPlaying] = useState(null); @@ -316,6 +317,7 @@ export default function NewReel() { ttsEnabled, ttsVoice, drawText, + stabilize: shouldStabilize, }); }; @@ -335,7 +337,16 @@ export default function NewReel() { {/* Camera Recorder Modal */} {showCamera && ( { + onCapture={(file, options) => { + if (options?.stabilize) { + setShouldStabilize(true); + toast({ + title: "Stabilisation activée", + description: "La vidéo sera stabilisée lors de la création du Reel (ceci peut prendre plus de temps)", + }); + } else { + setShouldStabilize(false); + } uploadMutation.mutate(file); setShowCamera(false); }} @@ -989,5 +1000,115 @@ export default function NewReel() { + {/* Overlay de progression */ } + + + ); +} + +function ProcessingOverlay({ isVisible, stabilize, ttsEnabled }: { isVisible: boolean; stabilize: boolean; ttsEnabled: boolean }) { + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState("Initialisation..."); + + useEffect(() => { + if (!isVisible) { + setProgress(0); + return; + } + + setProgress(0); + setStatus("Préparation des fichiers..."); + + const timeouts: NodeJS.Timeout[] = []; + let interval: NodeJS.Timeout; + + // Sequence de simulation + // 1. 2s: Téléchargement + timeouts.push(setTimeout(() => { + setStatus("Téléchargement des médias..."); + setProgress(10); + }, 1500)); + + // 2. 4s: Audio/TTS + timeouts.push(setTimeout(() => { + setStatus(ttsEnabled ? "Génération de la voix IA..." : "Mixage audio..."); + setProgress(25); + }, 3500)); + + // 3. 7s: Stabilisation ou Encodage + timeouts.push(setTimeout(() => { + if (stabilize) { + setStatus("Stabilisation vidéo (Traitement long)..."); + setProgress(35); + + // Progression lente : +1% toutes les 800ms + // Ça permet de couvrir ~45 secondes avant d'arriver à 90% + let p = 35; + interval = setInterval(() => { + if (p < 90) { + p++; + setProgress(p); + } + }, 800); + } else { + setStatus("Encodage optimisé..."); + setProgress(40); + + // Progression pour encodage standard (~10-15s) + let p = 40; + interval = setInterval(() => { + if (p < 90) { + p += 2; + setProgress(p); + } + }, 500); + } + }, 6500)); + + // Nettoyage + return () => { + timeouts.forEach(clearTimeout); + if (interval) clearInterval(interval); + }; + }, [isVisible, stabilize, ttsEnabled]); + + if (!isVisible) return null; + + return ( +
+
+
+ +
+ {progress}% +
+
+ +
+

Création de votre Reel

+

+ {status} +

+
+ +
+
+
+ + {stabilize && ( +
+ + Stabilisation activée : traitement prolongé +
+ )} +
+
); } diff --git a/ffmpeg-service/Dockerfile b/ffmpeg-service/Dockerfile index fa0e64b..c70f2ed 100644 --- a/ffmpeg-service/Dockerfile +++ b/ffmpeg-service/Dockerfile @@ -1,11 +1,20 @@ FROM python:3.11-slim -# Install system dependencies including FFmpeg and fonts +# Install system dependencies including FFmpeg with vidstab support and fonts +# We need to build/install libvidstab and compile ffmpeg with it, OR use static build RUN apt-get update && apt-get install -y \ - ffmpeg \ + wget \ + xz-utils \ fonts-dejavu \ && rm -rf /var/lib/apt/lists/* +# Download static FFmpeg build with all filters including vidstab +RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ + && tar xf ffmpeg-release-amd64-static.tar.xz \ + && mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \ + && mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \ + && rm -rf ffmpeg-* + WORKDIR /app # Copy requirements first for better caching diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index 0b6df29..fb141c6 100644 --- a/ffmpeg-service/main.py +++ b/ffmpeg-service/main.py @@ -12,6 +12,7 @@ from pathlib import Path import edge_tts import re import emoji +import time app = FastAPI() @@ -35,6 +36,7 @@ class ReelRequest(BaseModel): tts_enabled: bool = False tts_voice: str = "fr-FR-VivienneMultilingualNeural" draw_text: bool = True + stabilize: bool = False # Stabilisation vidéo via vidstab def clean_text_for_tts(text: str) -> str: @@ -160,6 +162,7 @@ class ReelResponse(BaseModel): output_base64: Optional[str] = None duration: Optional[float] = None detail: Optional[str] = None + processing_stats: Optional[dict] = None @app.get("/health") @@ -174,6 +177,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): if x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Invalid API Key") + start_total = time.time() + stats = { + "download_duration": 0, + "tts_duration": 0, + "stabilize_duration": 0, + "encoding_duration": 0, + "total_duration": 0, + } + try: job_id = str(uuid.uuid4()) job_dir = TEMP_DIR / job_id @@ -185,6 +197,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): tts_vtt_path = job_dir / "tts.vtt" output_video_path = job_dir / "output.mp4" + start_step = time.time() # 1. Save Input Video if request.video_base64: with open(input_video_path, "wb") as f: @@ -212,6 +225,9 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): print(f"Failed to download music: {e}") # We continue without music if it fails + stats["download_duration"] = time.time() - start_step + start_step = time.time() + # 3. Generate TTS (if enabled) has_tts = False tts_clean_text = "" @@ -257,6 +273,78 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): print(f"❌ Failed to generate TTS: {e}") traceback.print_exc() + stats["tts_duration"] = time.time() - start_step + start_step = time.time() + + # 3.5 Stabilize video if requested (vidstab two-pass) + if request.stabilize: + print("📐 Starting video stabilization (vidstab)...") + transforms_path = job_dir / "transforms.trf" + stabilized_path = job_dir / "stabilized.mp4" + + try: + # Pass 1: Detect motion/shakiness + print("📐 Pass 1: Detecting motion...") + detect_cmd = [ + "ffmpeg", + "-y", + "-i", + str(input_video_path), + "-vf", + f"vidstabdetect=stepsize=6:shakiness=8:accuracy=9:result={transforms_path}", + "-f", + "null", + "-", + ] + detect_proc = subprocess.run( + detect_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + if detect_proc.returncode != 0: + print( + f"⚠️ Stabilization pass 1 failed: {detect_proc.stderr.decode()[:500]}" + ) + elif transforms_path.exists(): + # Pass 2: Apply stabilization transform + print("📐 Pass 2: Applying stabilization...") + transform_cmd = [ + "ffmpeg", + "-y", + "-i", + str(input_video_path), + "-vf", + f"vidstabtransform=input={transforms_path}:smoothing=10:crop=black:zoom=1,unsharp=5:5:0.8:3:3:0.4", + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "18", + "-c:a", + "copy", + str(stabilized_path), + ] + transform_proc = subprocess.run( + transform_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + if transform_proc.returncode == 0 and stabilized_path.exists(): + # Use stabilized video for further processing + input_video_path = stabilized_path + print("✅ Video stabilization complete!") + else: + print( + f"⚠️ Stabilization pass 2 failed: {transform_proc.stderr.decode()[:500]}" + ) + else: + print("⚠️ Transforms file not created, skipping stabilization") + + except Exception as e: + print(f"⚠️ Stabilization error (continuing without): {e}") + + stats["stabilize_duration"] = time.time() - start_step + start_step = time.time() + # 4. Build FFmpeg Command cmd = ["ffmpeg", "-y", "-i", str(input_video_path)] @@ -383,7 +471,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): "-preset", "medium", "-crf", - "23", # Good balance for quality/size + "18", # High quality output (lower = better, 18 is visually lossless) "-c:a", "aac", "-b:a", @@ -400,6 +488,9 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # execute process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stats["encoding_duration"] = time.time() - start_step + stats["total_duration"] = time.time() - start_total + if process.returncode != 0: print(f"FFmpeg failed: {process.stderr.decode()}") raise Exception(f"FFmpeg encoding failed: {process.stderr.decode()}") @@ -426,7 +517,14 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # Cleanup shutil.rmtree(job_dir) - return {"success": True, "output_base64": out_b64, "duration": duration} + print(f"📊 Processing Stats: {stats}") + + return { + "success": True, + "output_base64": out_b64, + "duration": duration, + "processing_stats": stats, + } except Exception as e: if "job_dir" in locals(): diff --git a/server/routes/reels.ts b/server/routes/reels.ts index 9be4704..c7e64d0 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -188,6 +188,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { fontSize = 16, musicVolume = 0.25, drawText = true, + stabilize = false, } = req.body; // Récupérer le média vidéo @@ -321,6 +322,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => { ttsEnabled: ttsEnabled, ttsVoice: ttsVoice, drawText: drawText, + stabilize: stabilize, pageCount: pageIds.length, }); diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 77195d0..c943c7d 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -17,6 +17,7 @@ interface FFmpegReelRequest { font_size?: number; // Taille police (default: 24) music_volume?: number; // Volume musique (default: 0.25) draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true) + stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false) } interface FFmpegReelResponse { @@ -71,6 +72,7 @@ export class FFmpegService { fontSize?: number; musicVolume?: number; drawText?: boolean; + stabilize?: boolean; } = {} ): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> { const config = this.ensureConfigured(); @@ -86,6 +88,7 @@ export class FFmpegService { font_size: options.fontSize ?? 16, music_volume: options.musicVolume ?? 0.25, draw_text: options.drawText ?? true, + stabilize: options.stabilize ?? false, }; // Remove undefined values @@ -164,6 +167,7 @@ export class FFmpegService { fontSize?: number; musicVolume?: number; drawText?: boolean; + stabilize?: boolean; } = {} ): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> { const config = this.ensureConfigured(); @@ -179,6 +183,7 @@ export class FFmpegService { font_size: options.fontSize ?? 16, music_volume: options.musicVolume ?? 0.25, draw_text: options.drawText ?? true, + stabilize: options.stabilize ?? false, }; // Remove undefined values diff --git a/task.md b/task.md index 07722cb..2152817 100644 --- a/task.md +++ b/task.md @@ -73,6 +73,30 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant - [~] Run `/bmad-bmgd-workflows-workflow-init` (Annulé: Application, pas un Jeu) +### Phase 7: Amélioration Qualité Vidéo & Stabilisation ✅ + +- [x] **Amélioration Capture Caméra** (`camera-recorder.tsx`) + - [x] Augmenter bitrate selon résolution (720p: 12Mbps, 1080p: 25Mbps, 4K: 50Mbps) + - [x] Ajouter contrainte framerate 30fps + - [x] Ajouter toggle "Stabilisation serveur" dans réglages +- [x] **Stabilisation FFmpeg vidstab** (`ffmpeg-service/main.py`) + - [x] Installer vidstab dans le Dockerfile (via static build) + - [x] Ajouter paramètre `stabilize` au modèle ReelRequest + - [x] Implémenter stabilisation 2 passes (detect + transform) + - [x] Améliorer qualité sortie (CRF 18) +- [x] **Backend API** (`ffmpeg.ts`) + - [x] Passer le paramètre `stabilize` à l'API FFmpeg + +### Phase 8: Monitoring & UX ✅ + +- [x] **Monitoring Performance** (`ffmpeg-service/main.py`) + - [x] Mesurer et logger les temps de traitement par étape (Download, Stabilize, TTS, Encode) + - [x] Retourner les métriques dans la réponse API +- [x] **Feedback UI** (`new-reel.tsx`) + - [x] Ajouter un composant de progression "fake" intelligent pendant la création + - [x] Afficher les étapes (Upload, Audio, Stabilisation, Encodage) + - [x] Estimer le temps restant selon les options activées (Stabilisation = +30s) + ## Progress Log - **22 Jan 2026** - Analyse complète et PRD créé