diff --git a/client/src/components/camera-recorder.tsx b/client/src/components/camera-recorder.tsx index ebdaad8..d4d64d4 100644 --- a/client/src/components/camera-recorder.tsx +++ b/client/src/components/camera-recorder.tsx @@ -21,9 +21,9 @@ interface QualityConfig { } const QUALITY_CONFIGS: Record = { - "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 }, + "4k": { label: "4K Vertical", width: 2160, height: 3840, bitrate: 40000000 }, + "1080p": { label: "Full HD (Reel)", width: 1080, height: 1920, bitrate: 12000000 }, // Align with 12Mbps max spec + "720p": { label: "HD Vertical", width: 720, height: 1280, bitrate: 8000000 }, }; export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) { diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index 1842b69..54b11e0 100644 --- a/ffmpeg-service/main.py +++ b/ffmpeg-service/main.py @@ -24,39 +24,47 @@ TEMP_DIR.mkdir(parents=True, exist_ok=True) os.environ["HOME"] = "/tmp" os.environ["XDG_CACHE_HOME"] = "/tmp/.cache" + # List available filters and fonts for debugging def run_diagnostics(): print("📋 Checking FFmpeg environment...") try: - filters_out = subprocess.run(["ffmpeg", "-filters"], capture_output=True, text=True).stdout + filters_out = subprocess.run( + ["ffmpeg", "-filters"], capture_output=True, text=True + ).stdout has_subtitles = "subtitles" in filters_out has_drawtext = "drawtext" in filters_out print(f"✅ Filters found: subtitles={has_subtitles}, drawtext={has_drawtext}") - + print("📋 Available fonts (fc-list):") subprocess.run(["fc-list"], check=True) except Exception as e: print(f"⚠️ Failed to check FFmpeg environment: {e}") + run_diagnostics() + @app.get("/debug-ffmpeg") async def debug_ffmpeg(): try: - filters = subprocess.run(["ffmpeg", "-filters"], capture_output=True, text=True).stdout + filters = subprocess.run( + ["ffmpeg", "-filters"], capture_output=True, text=True + ).stdout fonts = subprocess.run(["fc-list"], capture_output=True, text=True).stdout return { "filters_summary": { "subtitles": "subtitles" in filters, - "drawtext": "drawtext" in filters + "drawtext": "drawtext" in filters, }, "env": {k: v for k, v in os.environ.items() if "API" not in k}, - "fonts": fonts.splitlines()[:50], # First 50 - "raw_filters_hint": filters[:500] + "fonts": fonts.splitlines()[:50], # First 50 + "raw_filters_hint": filters[:500], } except Exception as e: return {"error": str(e)} + # Robust font detection def get_font_path(): possible_paths = [ @@ -68,7 +76,7 @@ def get_font_path(): if os.path.exists(path): print(f"✅ Found font at: {path}") return path - + # Fallback search print("⚠️ Specific font not found, searching recursively in /usr/share/fonts...") try: @@ -83,7 +91,8 @@ def get_font_path(): except Exception as e: print(f"⚠️ Error searching for fonts: {e}") - return "Sans" # Generic fallback + return "Sans" # Generic fallback + FONT_PATH = get_font_path() @@ -204,7 +213,7 @@ Style: Default,DejaVu Sans,{font_size},&H00FFFFFF,&H000000FF,&H00000000,&H000000 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ - + events = "" current_time = 0.0 @@ -217,14 +226,18 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text # Escape special characters if any sanitized_chunk = chunk.replace("{", "(").replace("}", ")") - events += f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{sanitized_chunk}\n" + events += ( + f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{sanitized_chunk}\n" + ) current_time += duration with open(ass_path, "w", encoding="utf-8") as f: f.write(header + events) - - print(f"📄 Generated ASS file: {ass_path.stat().st_size} bytes with {len(chunks)} chunks") + + print( + f"📄 Generated ASS file: {ass_path.stat().st_size} bytes with {len(chunks)} chunks" + ) def format_ass_time(seconds: float) -> str: @@ -237,12 +250,15 @@ def format_ass_time(seconds: float) -> str: def generate_simple_srt(text: str, srt_path: Path): - """Format seconds as SRT timestamp (HH:MM:SS,mmm).""" - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = int(seconds % 60) - millis = int((seconds % 1) * 1000) - return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + # This was a stub, but let's fix the internal helper if it were called + def format_srt_time(seconds: float) -> str: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds % 1) * 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + pass def format_vtt_time(seconds: float) -> str: @@ -413,10 +429,20 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): f"vidstabtransform=input={transforms_path}:smoothing=10:crop=black:zoom=1,unsharp=5:5:0.8:3:3:0.4", "-c:v", "libx264", + "-profile:v", + "high", + "-r", + "30", "-preset", "medium", "-crf", - "18", + "17", + "-b:v", + "10M", + "-maxrate", + "12M", + "-bufsize", + "20M", "-c:a", "copy", str(stabilized_path), @@ -455,8 +481,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): has_original_audio = False try: probe_cmd = [ - "ffprobe", "-v", "error", "-select_streams", "a:0", - "-show_entries", "stream=codec_type", "-of", "csv=p=0", + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=codec_type", + "-of", + "csv=p=0", str(input_video_path), ] probe_out = subprocess.check_output(probe_cmd).decode().strip() @@ -469,7 +502,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # 0: Video (already added) # 1: Music (optional) # 2: TTS (optional) - + input_count = 1 music_idx = -1 tts_idx = -1 @@ -478,7 +511,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): cmd.extend(["-i", str(input_audio_path)]) music_idx = input_count input_count += 1 - + if has_tts: cmd.extend(["-i", str(tts_audio_path)]) tts_idx = input_count @@ -486,13 +519,13 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # --- Filter Complex Construction --- fc_parts = [] - + # A. Video Chain # 1. Scale & Crop to Fill 1080x1920 (Vertical Reel) # [0:v] -> [v_processed] # Using 'increase' + 'crop' to ensure full screen coverage without black bars video_filters_str = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,unsharp=5:5:0.8:3:3:0.4" - + # 2. Text Overlay if request.text and request.draw_text: text_filter = "" @@ -503,14 +536,16 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): text_filter = f"subtitles='{ass_path_str}'" else: # Standard Text (without TTS) - print(f"🎬 Overlaying subtitles from standard text: {request.text[:30]}...") + print( + f"🎬 Overlaying subtitles from standard text: {request.text[:30]}..." + ) std_ass_path = job_dir / "std_text.ass" # Use fontsize 40 by default for standard text generate_simple_ass(request.text, std_ass_path, font_size=40) - + ass_path_str = str(std_ass_path).replace("\\", "/").replace(":", "\\:") text_filter = f"subtitles='{ass_path_str}'" - + # Combine formatting + text video_filters_str += f",{text_filter}" @@ -519,57 +554,61 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # B. Audio Chain audio_mapped = False - + inputs_for_mix = 0 audio_mix_str = "" # Strategy: # If no music and no TTS -> Copy original audio (if exists) or silent # If music or TTS -> Mix everything - + if has_music or has_tts: # Prepare inputs if has_original_audio: audio_mix_str += "[0:a]" inputs_for_mix += 1 - + if has_music: # Adjust volume - fc_parts.append(f"[{music_idx}:a]volume={request.music_volume}[a_music]") + fc_parts.append( + f"[{music_idx}:a]volume={request.music_volume}[a_music]" + ) audio_mix_str += "[a_music]" inputs_for_mix += 1 - + if has_tts: # TTS louder fc_parts.append(f"[{tts_idx}:a]volume=1.5[a_tts]") audio_mix_str += "[a_tts]" inputs_for_mix += 1 - + # Mix if inputs_for_mix > 0: - fc_parts.append(f"{audio_mix_str}amix=inputs={inputs_for_mix}:duration=first:dropout_transition=2:normalize=0[aout]") + fc_parts.append( + f"{audio_mix_str}amix=inputs={inputs_for_mix}:duration=first:dropout_transition=2:normalize=0[aout]" + ) audio_mapped = True else: # No external audio added if has_original_audio: # Just pass through original audio # We can map 0:a directly, no filter needed for audio - audio_mapped = False + audio_mapped = False else: # No audio at all audio_mapped = False # Apply Filter Complex cmd.extend(["-filter_complex", ";".join(fc_parts)]) - + # Maps - cmd.extend(["-map", "[vout]"]) # Map processed video - + cmd.extend(["-map", "[vout]"]) # Map processed video + if audio_mapped: - cmd.extend(["-map", "[aout]"]) # Map mixed audio + cmd.extend(["-map", "[aout]"]) # Map mixed audio elif has_original_audio: - cmd.extend(["-map", "0:a"]) # Map original audio directly - + cmd.extend(["-map", "0:a"]) # Map original audio directly + cmd.extend(["-shortest"]) # Quality settings @@ -577,10 +616,20 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): [ "-c:v", "libx264", + "-profile:v", + "high", + "-r", + "30", "-preset", "slow", "-crf", "17", + "-b:v", + "10M", + "-maxrate", + "12M", + "-bufsize", + "20M", "-c:a", "aac", "-b:a", @@ -603,7 +652,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): print(f"❌ FFmpeg failed. Stderr:\n{process.stderr.decode()}") else: # Check stderr for font warnings even on success - stderr_last_lines = '\n'.join(process.stderr.decode().splitlines()[-20:]) + stderr_last_lines = "\n".join(process.stderr.decode().splitlines()[-20:]) print(f"✅ FFmpeg executed. Stderr (last 20 lines):\n{stderr_last_lines}") stats["encoding_duration"] = time.time() - start_step diff --git a/task.md b/task.md index 6b567aa..4ea3b81 100644 --- a/task.md +++ b/task.md @@ -102,13 +102,14 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant - [x] **Fix API 500** (`server/routes/reels.ts`) : Variable `stabilize` manquante corrigée. - [x] **Fix iOS Upload** (`client/src/pages/new-reel.tsx`) : Ajout support `.mov` et augmentation limite taille (500MB). - [ ] **Support Gros Fichiers (4GB)** : Passage à DiskStorage et streaming Cloudinary. -- [ ] **Phase 10: Optimisation 1080p & Qualité Maximale** ⏳ +- [x] **Phase 10: Optimisation 1080p & Qualité Maximale** ✅ - [x] FFmpeg: Forcer scaling 1080x1920 (Vertical HD) - [x] FFmpeg: Augmenter CRF à 17 et preset à `slow` - [x] FFmpeg: Augmenter taille police par défaut à 64 - [x] FFmpeg: Positionner texte plus bas (proportionnel 1080p) + - [x] FFmpeg: Profil H.264 High, 30fps et bitrate 10Mbps - [x] Frontend: Ajouter toggle Stabilisation et Qualité -- [ ] **Phase 11: Clarification UX Mobile** ✅ +- [x] **Phase 11: Clarification UX Mobile** ✅ - [x] Modifier l'alerte iOS pour prévenir que les réglages se font après - [x] Ajouter une flèche ou une animation pour attirer l'œil sur le Switch de stabilisation - [x] S'assurer que le bouton "Suivant" est bien visible sous le Switch @@ -125,6 +126,12 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant - [x] **Final Robust Fix** : Switch complet vers le filtre `subtitles` et format `.srt` pour contourner l'absence de `drawtext` dans l'environnement. - [x] **Style Adjustment** : Réduction de la taille du texte à 30 pour un rendu plus élégant. +### Phase 13: Activation Agent BMad ⏳ + +- [x] Activer l'agent `bmad-master.md` +- [x] Charger la configuration `_bmad/core/config.yaml` +- [x] Afficher le menu de l'agent en français + ## Progress Log - **22 Jan 2026** - Analyse complète et PRD créé