import os, json, uuid, gc, traceback, shutil, time from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, TextClip from moviepy.video.VideoClip import ColorClip from .config import OUT_DIR, TMP_DIR, MANIFEST_PATH, W, H from .tts_utils import tts_edge, tts_gtts, normalize_audio_to_wav from .graphics import make_background from .video_utils import prepare_video_presentateur, write_srt, write_video_with_fallback, safe_name capsules = [] # Charger le manifeste existant if os.path.exists(MANIFEST_PATH): try: data = json.load(open(MANIFEST_PATH, "r", encoding="utf-8")) if isinstance(data, dict) and "capsules" in data: capsules.extend(data["capsules"]) except Exception: pass def _save_manifest(): with open(MANIFEST_PATH, "w", encoding="utf-8") as f: json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2) def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme, image_fond=None, logo_path=None, logo_pos="haut-gauche", fond_mode="plein écran", video_presentateur=None, voix_type="Féminine", position_presentateur="bottom-right", plein=False, moteur_voix="Edge-TTS (recommandé)", langue="fr", speaker=None): # 1️⃣ TTS try: audio_mp = tts_edge(texte_voix, voice=speaker or ("fr-FR-DeniseNeural" if langue == "fr" else "nl-NL-MaaikeNeural")) except Exception as e: print(f"[Capsule] Erreur TTS Edge ({e}), fallback gTTS.") audio_mp = tts_gtts(texte_voix, lang=langue) audio_wav = audio_mp if not audio_mp.lower().endswith(".wav"): try: audio_wav = normalize_audio_to_wav(audio_mp) except Exception as e: print(f"[Audio] Normalisation échouée ({e}), on garde {audio_mp}") # 2️⃣ Fond print("[Capsule] Génération du fond...") fond_path = make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, image_fond, fond_mode) if fond_path and not os.path.exists(fond_path): # Correction chemin TMP_DIR alt_path = os.path.join(os.getcwd(), "_tmp_capsules", os.path.basename(fond_path)) if os.path.exists(alt_path): fond_path = alt_path print(f"[Capsule] ⚙️ Correction du chemin fond: {fond_path}") if fond_path is None: print("[Capsule] ❌ fond_path est None, création d'urgence") from PIL import Image try: emergency_bg = Image.new("RGB", (W, H), (0, 82, 147)) fond_path = os.path.join(TMP_DIR, f"emergency_{uuid.uuid4().hex[:6]}.png") emergency_bg.save(fond_path) print(f"[Capsule] ✅ Fond d'urgence créé: {fond_path}") except Exception as e: print(f"[Capsule] ❌ Impossible de créer le fond d'urgence: {e}") fond_path = None # 3️⃣ Composition vidéo audio = AudioFileClip(audio_wav) dur = float(audio.duration or 5.0) target_fps = 25 clips = [] if fond_path and os.path.exists(fond_path): try: bg = ImageClip(fond_path).set_duration(dur) clips.append(bg) print(f"[Capsule] ✅ Fond chargé: {fond_path}") except Exception as e: print(f"[Capsule] ❌ Erreur ImageClip, fallback ColorClip: {e}") bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur) clips.append(bg) else: print("[Capsule] ❌ Aucun fond valide, utilisation ColorClip") bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur) clips.append(bg) # 4️⃣ Présentateur v_presentateur = None has_text = bool(texte_ecran and texte_ecran.strip()) plein_auto = plein if not has_text else False if video_presentateur and os.path.exists(video_presentateur): ext = os.path.splitext(video_presentateur)[1].lower() if ext in [".mp4", ".mov", ".avi", ".mkv"]: print(f"[Capsule] Vidéo présentateur trouvée: {video_presentateur}") v_presentateur = prepare_video_presentateur( video_presentateur, dur, position_presentateur, plein_auto ) elif ext in [".jpg", ".jpeg", ".png", ".bmp", ".webp"]: print(f"[Capsule] Image présentateur trouvée: {video_presentateur}") try: img_clip = ImageClip(video_presentateur).set_duration(dur) if plein_auto: img_clip = img_clip.resize((W, H)).set_position(("center", "center")) else: img_clip = img_clip.resize(width=520) pos_map = { "bottom-right": ("right", "bottom"), "bottom-left": ("left", "bottom"), "top-right": ("right", "top"), "top-left": ("left", "top"), "center": ("center", "center"), } img_clip = img_clip.set_position(pos_map.get(position_presentateur, ("right", "bottom"))) v_presentateur = img_clip except Exception as e: print(f"[Capsule] ❌ Erreur image présentateur : {e}") if v_presentateur: clips.append(v_presentateur) print(f"[Capsule] ✅ Présentateur ajouté") else: print(f"[Capsule] Aucun présentateur: {video_presentateur}") # 5️⃣ Overlay texte (par-dessus tout) if texte_ecran and texte_ecran.strip(): try: txt_clip = TextClip( texte_ecran, fontsize=50, color='white', font='DejaVu-Sans-Bold', method='caption', size=(W - 200, None) ).set_position(('center', 'bottom')).set_duration(dur) clips.append(txt_clip) print("[Capsule] ✅ Overlay texte ajouté (bas centré)") except Exception as e: print(f"[Capsule] ⚠️ Overlay texte échoué : {e}") # 6️⃣ Assemblage final final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100)) name = safe_name(f"{titre}_{langue}") out_base = os.path.join(OUT_DIR, name) out = write_video_with_fallback(final, out_base, fps=target_fps) srt_path = write_srt(texte_voix, dur) capsules.append({ "file": out, "title": titre, "langue": langue, "voice": speaker or voix_type, "theme": theme, "duration": round(dur, 1), "inputs": {"image_fond": image_fond, "logo": logo_path, "video_presentateur": video_presentateur}, "texte_voix": texte_voix, "texte_ecran": texte_ecran }) _save_manifest() # 7️⃣ Nettoyage try: audio.close() final.close() bg.close() if v_presentateur: v_presentateur.close() if os.path.exists(audio_mp): os.remove(audio_mp) if audio_wav != audio_mp and os.path.exists(audio_wav): os.remove(audio_wav) except Exception as e: print(f"[Clean] Erreur nettoyage: {e}") gc.collect() return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {speaker or voix_type})", srt_path # 🧾 Fonctions supplémentaires identiques à ta version originale def table_capsules(): return [[i+1, c["title"], c.get("langue","fr").upper(), f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])] for i, c in enumerate(capsules)] def assemble_final(): if not capsules: return None, "❌ Aucune capsule." from moviepy.editor import VideoFileClip from moviepy.video.compositing.concatenate import concatenate_videoclips clips = [VideoFileClip(c["file"]) for c in capsules] try: out = write_video_with_fallback( concatenate_videoclips(clips, method="compose"), os.path.join(OUT_DIR, "VIDEO_COMPLETE"), fps=25, ) return out, f"🎉 Vidéo finale prête ({len(capsules)} capsules)." finally: for c in clips: try: c.close() except: pass def supprimer_capsule(index): try: idx = int(index) - 1 if 0 <= idx < len(capsules): fichier = capsules[idx]["file"] if os.path.exists(fichier): os.remove(fichier) del capsules[idx] _save_manifest() return f"🗑 Capsule supprimée : {fichier}", table_capsules() else: return "⚠️ Index invalide.", table_capsules() except Exception as e: return f"❌ Erreur lors de la suppression : {e}", table_capsules() def deplacer_capsule(index, direction): try: idx = int(index) - 1 if direction == "up" and idx > 0: capsules[idx - 1], capsules[idx] = capsules[idx], capsules[idx - 1] elif direction == "down" and idx < len(capsules) - 1: capsules[idx + 1], capsules[idx] = capsules[idx], capsules[idx + 1] _save_manifest() return f"🔁 Capsule déplacée {direction}.", table_capsules() except Exception as e: return f"❌ Erreur de déplacement : {e}", table_capsules() def export_project_zip(): """Crée un ZIP complet pour toutes les capsules (vidéos, srt, inputs, params, manifest).""" try: zip_root = os.path.join(TMP_DIR, f"zip_export_{int(time.time())}") os.makedirs(zip_root, exist_ok=True) manifest = {"capsules": capsules} with open(os.path.join(zip_root, "manifest.json"), "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) for i, cap in enumerate(capsules, 1): cap_dir = os.path.join(zip_root, f"capsule_{i}") os.makedirs(os.path.join(cap_dir, "inputs"), exist_ok=True) if cap.get("file") and os.path.exists(cap["file"]): shutil.copy2(cap["file"], os.path.join(cap_dir, os.path.basename(cap["file"]))) for f in os.listdir(OUT_DIR): if f.lower().endswith(".srt"): shutil.copy2(os.path.join(OUT_DIR, f), os.path.join(cap_dir, f)) for k in ["image_fond", "logo", "video_presentateur"]: p = cap.get("inputs", {}).get(k) if cap.get("inputs") else None if p and os.path.exists(p): shutil.copy2(p, os.path.join(cap_dir, "inputs", os.path.basename(p))) with open(os.path.join(cap_dir, "params.json"), "w", encoding="utf-8") as pf: json.dump(cap, pf, ensure_ascii=False, indent=2) zip_path = shutil.make_archive(os.path.join(TMP_DIR, f"capsules_export_{int(time.time())}"), "zip", zip_root) return zip_path except Exception as e: print(f"[Export] ❌ Erreur ZIP : {e}") return None def assemble_final_fast(): """Concaténation instantanée sans réencodage (FFmpeg direct).""" import subprocess if not capsules: return None, "❌ Aucune capsule." list_path = os.path.join(OUT_DIR, "concat_list.txt") with open(list_path, "w", encoding="utf-8") as f: for c in capsules: f.write(f"file '{c['file']}'\n") out_path = os.path.join(OUT_DIR, "VIDEO_COMPLETE_FAST.mp4") cmd = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", out_path] subprocess.run(cmd, check=True) return out_path, "🎉 Vidéo finale assemblée instantanément (sans réencodage)"