Spaces:
Sleeping
Sleeping
| # app.py | |
| # ============================================================ | |
| # CPAS Bruxelles — Créateur de Capsules (Gradio + Kokoro + SadTalker) | |
| # Version modifiée pour utiliser une vidéo de présentateur au lieu d'une image | |
| # ============================================================ | |
| import os, json, re, uuid, shutil, traceback, gc, subprocess | |
| from typing import Optional | |
| import gradio as gr | |
| from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps | |
| # ============================================================ | |
| # PATCH MOVIEPY - Correction ANTIALIAS | |
| # ============================================================ | |
| import PIL.Image | |
| # Remplacer ANTIALIAS par LANCZOS (nouveau nom dans Pillow >= 10.0.0) | |
| if not hasattr(PIL.Image, 'ANTIALIAS'): | |
| PIL.Image.ANTIALIAS = PIL.Image.LANCZOS | |
| # ---------- Config statique ---------- | |
| ROOT = os.getcwd() | |
| OUT_DIR = os.path.join(ROOT, "export") | |
| TMP_DIR = os.path.join(ROOT, "_tmp_capsules") | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| os.makedirs(TMP_DIR, exist_ok=True) | |
| # Charger config externe | |
| CONFIG_PATH = os.path.join(ROOT, "app_config.json") | |
| if os.path.exists(CONFIG_PATH): | |
| cfg = json.load(open(CONFIG_PATH, "r", encoding="utf-8")) | |
| THEMES = cfg["themes"] | |
| FONT_REG = cfg["font_paths"]["regular"] | |
| FONT_BOLD = cfg["font_paths"]["bold"] | |
| else: | |
| # Valeurs de secours | |
| THEMES = { | |
| "Bleu Professionnel": {"primary": [0, 82, 147], "secondary": [0, 126, 200]}, | |
| "Vert Gouvernemental": {"primary": [0, 104, 55], "secondary": [0, 155, 119]}, | |
| "Violet Élégant": {"primary": [74, 20, 140], "secondary": [103, 58, 183]}, | |
| } | |
| FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" | |
| FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" | |
| W, H = 1920, 1080 | |
| MARGIN_X, SAFE_Y_TOP = 140, 140 | |
| # ---------- État runtime ---------- | |
| capsules = [] | |
| manifest_path = os.path.join(OUT_DIR, "manifest.json") | |
| 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 = 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) | |
| # ============================================================ | |
| # OUTILS GÉNÉRAUX (rapides) | |
| # ============================================================ | |
| def _wrap_text(text, font, max_width, draw): | |
| lines = [] | |
| for para in text.split("\n"): | |
| current = [] | |
| for word in para.split(" "): | |
| test = " ".join(current + [word]) | |
| try: | |
| w = draw.textlength(test, font=font) | |
| except AttributeError: | |
| bbox = draw.textbbox((0, 0), test, font=font) | |
| w = bbox[2] - bbox[0] | |
| if w <= max_width or not current: | |
| current.append(word) | |
| else: | |
| lines.append(" ".join(current)) | |
| current = [word] | |
| if current: | |
| lines.append(" ".join(current)) | |
| return lines | |
| def _draw_text_shadow(draw, xy, text, font, fill=(255, 255, 255)): | |
| x, y = xy | |
| draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0)) | |
| draw.text((x, y), text, font=font, fill=fill) | |
| def _safe_name(stem, ext=".mp4"): | |
| stem = re.sub(r"[^\w\-]+", "_", stem)[:40] | |
| return f"{stem}_{uuid.uuid4().hex[:6]}{ext}" | |
| # ============================================================ | |
| # SYNTHÈSE VOCALE — Edge-TTS multivoix (FR/NL) + gTTS fallback | |
| # ============================================================ | |
| import asyncio | |
| import edge_tts | |
| from pydub import AudioSegment | |
| import soundfile as sf | |
| # ============================================================ | |
| # 🔊 CHARGEMENT DYNAMIQUE DES VOIX EDGE-TTS (FR/NL) | |
| # ============================================================ | |
| EDGE_VOICES = {} | |
| async def fetch_edge_voices_async(): | |
| """Charge dynamiquement toutes les voix FR/NL depuis Edge-TTS.""" | |
| global EDGE_VOICES | |
| try: | |
| voices = await edge_tts.list_voices() | |
| filtered = [v for v in voices if v["Locale"].startswith(("fr", "nl"))] | |
| filtered.sort(key=lambda v: (v["Locale"], v["Gender"], v["ShortName"])) | |
| EDGE_VOICES = { | |
| f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v["ShortName"] | |
| for v in filtered | |
| } | |
| print(f"[Edge-TTS] {len(EDGE_VOICES)} voix FR/NL chargées.") | |
| except Exception as e: | |
| print(f"[Edge-TTS] Erreur chargement voix : {e}") | |
| EDGE_VOICES.update({ | |
| "fr-FR-DeniseNeural - fr-FR (Female)": "fr-FR-DeniseNeural", | |
| "nl-NL-MaaikeNeural - nl-NL (Female)": "nl-NL-MaaikeNeural", | |
| }) | |
| def init_edge_voices(): | |
| """Démarre le chargement asynchrone sans bloquer Gradio.""" | |
| try: | |
| loop = asyncio.get_event_loop() | |
| loop.create_task(fetch_edge_voices_async()) | |
| except RuntimeError: | |
| asyncio.run(fetch_edge_voices_async()) | |
| def get_edge_voices(lang="fr"): | |
| """Retourne les voix déjà chargées (selon la langue).""" | |
| global EDGE_VOICES | |
| if not EDGE_VOICES: | |
| init_edge_voices() | |
| if lang == "fr": | |
| return [v for k, v in EDGE_VOICES.items() if k.startswith("fr-")] | |
| elif lang == "nl": | |
| return [v for k, v in EDGE_VOICES.items() if k.startswith("nl-")] | |
| return list(EDGE_VOICES.values()) | |
| async def _edge_tts_async(text, voice, outfile): | |
| communicate = edge_tts.Communicate(text, voice) | |
| await communicate.save(outfile) | |
| return outfile | |
| def tts_edge(text: str, voice: str = "fr-FR-DeniseNeural") -> str: | |
| """Génère un fichier WAV avec Edge-TTS (et fallback gTTS).""" | |
| out_mp3 = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.mp3") | |
| try: | |
| # Correction boucle asyncio (HF/Gradio) | |
| try: | |
| loop = asyncio.get_event_loop() | |
| if loop.is_running(): | |
| import nest_asyncio | |
| nest_asyncio.apply() | |
| except RuntimeError: | |
| pass | |
| asyncio.run(_edge_tts_async(text, voice, out_mp3)) | |
| # Conversion WAV pour compatibilité MoviePy | |
| out_wav = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.wav") | |
| AudioSegment.from_file(out_mp3).export(out_wav, format="wav") | |
| os.remove(out_mp3) | |
| return out_wav | |
| except Exception as e: | |
| print(f"[Edge-TTS] Erreur : {e} → fallback gTTS") | |
| return tts_gtts(text, lang="fr" if voice.startswith("fr") else "nl") | |
| def tts_gtts(text: str, lang: str = "fr") -> str: | |
| """Fallback via Google Text-to-Speech (gTTS).""" | |
| from gtts import gTTS | |
| out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3") | |
| gTTS(text=text, lang=lang).save(out) | |
| # Conversion en WAV pour compatibilité | |
| out_wav = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.wav") | |
| AudioSegment.from_file(out).export(out_wav, format="wav") | |
| os.remove(out) | |
| return out_wav | |
| def _normalize_audio_to_wav(in_path: str) -> str: | |
| # Convertit n'importe quel format (mp3/wav) en WAV standard (44.1kHz stéréo) | |
| from pydub import AudioSegment | |
| wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav") | |
| snd = AudioSegment.from_file(in_path) | |
| snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2) | |
| snd.export(wav_path, format="wav") | |
| return wav_path | |
| # ============================================================ | |
| # FOND / GRAPHISME (PIL rapide) | |
| # ============================================================ | |
| def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"): | |
| try: | |
| print(f"[Fond] Création du fond - Thème: {theme}") | |
| # Validation des entrées | |
| if not titre: | |
| titre = "Titre CPAS" | |
| if not theme or theme not in THEMES: | |
| theme = list(THEMES.keys())[0] | |
| print(f"[Fond] Thème invalide, utilisation par défaut: {theme}") | |
| c = THEMES[theme] | |
| primary = tuple(c["primary"]) | |
| secondary = tuple(c["secondary"]) | |
| # Créer le fond de base | |
| bg = Image.new("RGB", (W, H), primary) | |
| draw = ImageDraw.Draw(bg) | |
| # Application de l'image de fond si fournie | |
| if img_fond and os.path.exists(img_fond): | |
| try: | |
| img = Image.open(img_fond).convert("RGB") | |
| if fond_mode == "plein écran": | |
| img = img.resize((W, H)) | |
| img = img.filter(ImageFilter.GaussianBlur(1)) | |
| overlay = Image.new("RGBA", (W, H), (*primary, 90)) | |
| bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB") | |
| elif fond_mode == "moitié gauche": | |
| img = img.resize((W//2, H)) | |
| mask = Image.linear_gradient("L").resize((W//2, H)) | |
| color = Image.new("RGB", (W//2, H), primary) | |
| comp = Image.composite(img, color, ImageOps.invert(mask)) | |
| bg.paste(comp, (0, 0)) | |
| elif fond_mode == "moitié droite": | |
| img = img.resize((W//2, H)) | |
| mask = Image.linear_gradient("L").resize((W//2, H)) | |
| color = Image.new("RGB", (W//2, H), primary) | |
| comp = Image.composite(color, img, mask) | |
| bg.paste(comp, (W//2, 0)) | |
| elif fond_mode == "moitié bas": | |
| img = img.resize((W, H//2)) | |
| mask = Image.linear_gradient("L").rotate(90).resize((W, H//2)) | |
| color = Image.new("RGB", (W, H//2), primary) | |
| comp = Image.composite(color, img, mask) | |
| bg.paste(comp, (0, H//2)) | |
| draw = ImageDraw.Draw(bg) # Recréer le draw après modification | |
| print(f"[Fond] Image de fond appliquée: {fond_mode}") | |
| except Exception as e: | |
| print(f"[Fond] Erreur image de fond: {e}") | |
| # Chargement des polices avec fallback | |
| try: | |
| f_title = ImageFont.truetype(FONT_BOLD, 84) | |
| f_sub = ImageFont.truetype(FONT_REG, 44) | |
| f_text = ImageFont.truetype(FONT_REG, 40) | |
| f_small = ImageFont.truetype(FONT_REG, 30) | |
| except Exception as e: | |
| print(f"[Fond] Erreur polices, utilisation par défaut: {e}") | |
| # Polices par défaut | |
| f_title = ImageFont.load_default() | |
| f_sub = ImageFont.load_default() | |
| f_text = ImageFont.load_default() | |
| f_small = ImageFont.load_default() | |
| # Bandes colorées | |
| draw.rectangle([(0, 0), (W, 96)], fill=secondary) | |
| draw.rectangle([(0, H-96), (W, H)], fill=secondary) | |
| # Textes | |
| _draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small) | |
| _draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small) | |
| _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title) | |
| _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub) | |
| # Texte écran avec wrap | |
| y = SAFE_Y_TOP + 200 | |
| if texte_ecran: | |
| for line in texte_ecran.split("\n"): | |
| wrapped_lines = _wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw) | |
| for l in wrapped_lines: | |
| _draw_text_shadow(draw, (MARGIN_X, y), l, f_text) | |
| y += 55 | |
| # Logo | |
| if logo_path and os.path.exists(logo_path): | |
| try: | |
| logo = Image.open(logo_path).convert("RGBA") | |
| logo.thumbnail((260, 260)) | |
| lw, lh = logo.size | |
| if logo_pos == "haut-gauche": | |
| pos = (50, 50) | |
| elif logo_pos == "haut-droite": | |
| pos = (W - lw - 50, 50) | |
| else: # centre | |
| pos = ((W - lw)//2, 50) | |
| bg.paste(logo, pos, logo) | |
| print(f"[Fond] Logo appliqué: {logo_pos}") | |
| except Exception as e: | |
| print(f"[Fond] Erreur logo: {e}") | |
| # Sauvegarde garantie | |
| out_path = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png") | |
| bg.save(out_path) | |
| print(f"[Fond] ✅ Fond créé avec succès: {out_path}") | |
| return out_path | |
| except Exception as e: | |
| print(f"[Fond] ❌ ERREUR CRITIQUE: {e}") | |
| print(f"[Fond] Traceback: {traceback.format_exc()}") | |
| # Fallback absolu | |
| try: | |
| emergency_bg = Image.new("RGB", (W, H), (0, 82, 147)) | |
| out_path = os.path.join(TMP_DIR, f"emergency_fond_{uuid.uuid4().hex[:6]}.png") | |
| emergency_bg.save(out_path) | |
| print(f"[Fond] ✅ Fond d'urgence créé: {out_path}") | |
| return out_path | |
| except Exception as e2: | |
| print(f"[Fond] ❌ Même le fallback a échoué: {e2}") | |
| return None | |
| # ============================================================ | |
| # SUPPRESSION DE LA PARTIE SADTALKER (plus nécessaire) | |
| # ============================================================ | |
| def _prepare_video_presentateur(video_path, audio_duration, position, plein_ecran=False): | |
| """Prépare la vidéo du présentateur avec la bonne durée et position.""" | |
| from moviepy.editor import VideoFileClip | |
| import moviepy.video.fx.all as vfx | |
| try: | |
| print(f"[Video] Chargement: {video_path}") | |
| if not os.path.exists(video_path): | |
| print(f"[Video] ❌ Fichier introuvable: {video_path}") | |
| return None | |
| v = VideoFileClip(video_path).without_audio() | |
| print(f"[Video] Durée vidéo: {v.duration}s, Audio: {audio_duration}s") | |
| # Ajuster la durée à celle de l'audio | |
| if v.duration < audio_duration: | |
| print(f"[Video] Bouclage nécessaire ({v.duration}s -> {audio_duration}s)") | |
| v = v.fx(vfx.loop, duration=audio_duration) | |
| elif v.duration > audio_duration: | |
| print(f"[Video] Découpage nécessaire ({v.duration}s -> {audio_duration}s)") | |
| v = v.subclip(0, audio_duration) | |
| # Ajuster la taille et la position - AVEC RESIZE SÉCURISÉ | |
| if plein_ecran: | |
| print(f"[Video] Mode plein écran") | |
| # Utiliser resize avec méthode moderne | |
| v = v.resize(newsize=(W, H)) | |
| v = v.set_position(("center", "center")) | |
| else: | |
| print(f"[Video] Mode incrustation, position: {position}") | |
| # Redimensionner avec méthode moderne | |
| v = v.resize(width=520) | |
| pos_map = { | |
| "bottom-right": ("right", "bottom"), | |
| "bottom-left": ("left", "bottom"), | |
| "top-right": ("right", "top"), | |
| "top-left": ("left", "top"), | |
| "center": ("center", "center"), | |
| } | |
| v = v.set_position(pos_map.get(position, ("right", "bottom"))) | |
| print(f"[Video] ✅ Vidéo préparée avec succès") | |
| return v | |
| except Exception as e: | |
| print(f"[Video] ❌ Erreur préparation: {e}") | |
| print(f"[Video] Traceback: {traceback.format_exc()}") | |
| return None | |
| # ============================================================ | |
| # SOUS-TITRES .SRT | |
| # ============================================================ | |
| def write_srt(text, duration): | |
| parts = re.split(r'(?<=[\.!?])\s+', text.strip()) | |
| parts = [p for p in parts if p] | |
| total = len("".join(parts)) or 1 | |
| cur = 0.0 | |
| srt = [] | |
| for i, p in enumerate(parts, 1): | |
| prop = len(p)/total | |
| start = cur | |
| end = min(duration, cur + duration*prop) | |
| cur = end | |
| def ts(t): | |
| m, s = divmod(t, 60) | |
| h, m = divmod(m, 60) | |
| return f"{int(h):02}:{int(m):02}:{int(s):02},000" | |
| srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""] | |
| path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt") | |
| open(path, "w", encoding="utf-8").write("\n".join(srt)) | |
| return path | |
| # ============================================================ | |
| # EXPORT VIDÉO (MoviePy — imports différés) | |
| # ============================================================ | |
| def _write_video_with_fallback(final_clip, out_path_base, fps=25): | |
| attempts = [ | |
| {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"}, | |
| {"ext": ".mp4", "codec": "mpeg4", "audio_codec": "aac"}, | |
| {"ext": ".mp4", "codec": "libx264","audio_codec": "libmp3lame"}, | |
| ] | |
| ffmpeg_params = ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "-shortest"] | |
| last_err = None | |
| for i, opt in enumerate(attempts, 1): | |
| out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"] | |
| try: | |
| final_clip.write_videofile( | |
| out, | |
| fps=fps, | |
| codec=opt["codec"], | |
| audio_codec=opt["audio_codec"], | |
| audio=True, | |
| ffmpeg_params=ffmpeg_params, | |
| logger=None, | |
| threads=1, | |
| ) | |
| if os.path.exists(out) and os.path.getsize(out) > 150000: | |
| return out | |
| except Exception as e: | |
| last_err = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" | |
| raise RuntimeError(last_err or "FFmpeg a échoué") | |
| # ============================================================ | |
| # BUILD CAPSULE — Pipeline complet (modifié pour vidéo présentateur) | |
| # ============================================================ | |
| 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 (Edge multivoix ou fallback) | |
| 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) | |
| # S'assurer qu'on a un WAV | |
| 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 (PIL) - SECTION CORRIGÉE | |
| print(f"[Capsule] Génération du fond...") | |
| fond_path = make_background(titre, sous_titre, texte_ecran, theme, | |
| logo_path, logo_pos, image_fond, fond_mode) | |
| # VÉRIFICATION ROBUSTE DU FOND | |
| if fond_path is None: | |
| print(f"[Capsule] ❌ fond_path est None, création d'urgence") | |
| 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) MoviePy (imports lents ici seulement) | |
| from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, VideoFileClip | |
| from moviepy.video.VideoClip import ColorClip | |
| import moviepy.video.fx.all as vfx | |
| audio = AudioFileClip(audio_wav) | |
| dur = float(audio.duration or 5.0) | |
| target_fps = 25 | |
| # CHARGEMENT ROBUSTE DU FOND | |
| 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(f"[Capsule] ❌ Aucun fond valide, utilisation ColorClip") | |
| bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur) | |
| clips.append(bg) | |
| # 4) Vidéo présentateur (au lieu de SadTalker) | |
| if video_presentateur and os.path.exists(video_presentateur): | |
| print(f"[Capsule] Vidéo présentateur trouvée: {video_presentateur}") | |
| v_presentateur = _prepare_video_presentateur( | |
| video_presentateur, | |
| dur, | |
| position_presentateur, | |
| plein | |
| ) | |
| if v_presentateur: | |
| print(f"[Capsule] ✅ Vidéo présentateur ajoutée") | |
| clips.append(v_presentateur) | |
| else: | |
| print(f"[Capsule] ❌ Échec préparation vidéo présentateur") | |
| else: | |
| print(f"[Capsule] Aucune vidéo présentateur: {video_presentateur}") | |
| # 5) Composition + export | |
| 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) | |
| # 6) Sous-titres + manifest | |
| 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) | |
| }) | |
| _save_manifest() | |
| # 7) Nettoyage CORRIGÉ | |
| try: | |
| audio.close() | |
| final.close() | |
| bg.close() | |
| if 'v_presentateur' in locals() and v_presentateur is not None: | |
| 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 | |
| # ============================================================ | |
| # GESTION / ASSEMBLAGE | |
| # ============================================================ | |
| def table_capsules(): | |
| import os | |
| 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, _safe_name("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() | |
| # ============================================================ | |
| # UI GRADIO | |
| # ============================================================ | |
| print("[INIT] Lancement de Gradio...") | |
| init_edge_voices() | |
| with gr.Blocks(title="Créateur de Capsules CPAS – Version avec vidéo présentateur", | |
| theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version avec vidéo présentateur") | |
| gr.Markdown("**Nouveau** : Utilisez directement une vidéo de présentateur au lieu d'une image.") | |
| with gr.Tab("Créer une capsule"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| image_fond = gr.Image(label="🖼 Image de fond", type="filepath") | |
| fond_mode = gr.Radio(["plein écran", "moitié gauche", "moitié droite", "moitié bas"], | |
| label="Mode d'affichage du fond", value="plein écran") | |
| logo_path = gr.Image(label="🏛 Logo", type="filepath") | |
| logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"], | |
| label="Position logo", value="haut-gauche") | |
| # REMPLACEMENT : Image → Video (sans le paramètre type) | |
| video_presentateur = gr.Video(label="🎬 Vidéo du présentateur") | |
| position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"], | |
| label="Position", value="bottom-right") | |
| plein = gr.Checkbox(label="Plein écran présentateur", value=False) | |
| with gr.Column(): | |
| titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp") | |
| sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen") | |
| theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel") | |
| langue = gr.Radio(["fr", "nl"], label="Langue de la voix", value="fr") | |
| def maj_voix(lang): | |
| try: | |
| voices = get_edge_voices(lang) | |
| return gr.update(choices=voices, value=voices[0] if voices else None) | |
| except Exception as e: | |
| return gr.update(choices=[], value=None) | |
| speaker_id = gr.Dropdown( | |
| label="🎙 Voix Edge-TTS", | |
| choices=get_edge_voices("fr"), | |
| value="fr-FR-DeniseNeural", | |
| info="Liste dynamique des voix Edge-TTS (FR & NL)" | |
| ) | |
| langue.change(maj_voix, [langue], [speaker_id]) | |
| voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine") | |
| moteur_voix = gr.Radio( | |
| ["Edge-TTS (recommandé)", "gTTS (fallback)"], | |
| label="Moteur voix", | |
| value="Edge-TTS (recommandé)" | |
| ) | |
| texte_voix = gr.Textbox(label="Texte voix off", lines=4, | |
| value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.") | |
| texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4, | |
| value="💊 Aides médicales\n🏥 Soins urgents\n📋 Formalités simplifiées") | |
| btn = gr.Button("🎬 Créer Capsule", variant="primary") | |
| sortie = gr.Video(label="Capsule générée") | |
| srt_out = gr.File(label="Sous-titres .srt") | |
| statut = gr.Markdown() | |
| with gr.Tab("Gestion & Assemblage"): | |
| gr.Markdown("### 🗂 Gestion des capsules") | |
| liste = gr.Dataframe( | |
| headers=["N°","Titre","Langue","Durée","Thème","Voix","Fichier"], | |
| value=table_capsules(), | |
| interactive=False | |
| ) | |
| with gr.Row(): | |
| index = gr.Number(label="Index capsule", value=1, precision=0) | |
| btn_up = gr.Button("⬆️ Monter") | |
| btn_down = gr.Button("⬇️ Descendre") | |
| btn_del = gr.Button("🗑 Supprimer") | |
| message = gr.Markdown() | |
| btn_up.click(lambda i: deplacer_capsule(i, "up"), [index], [message, liste]) | |
| btn_down.click(lambda i: deplacer_capsule(i, "down"), [index], [message, liste]) | |
| btn_del.click(supprimer_capsule, [index], [message, liste]) | |
| gr.Markdown("### 🎬 Assemblage final") | |
| btn_asm = gr.Button("🎥 Assembler la vidéo complète", variant="primary") | |
| sortie_finale = gr.Video(label="Vidéo finale") | |
| btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message]) | |
| def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, vp, vx, pos_p, plein, motor, lang, speaker): | |
| try: | |
| vid, msg, srt = build_capsule(t, st, tv, te, th, | |
| img, logo, pos_logo, fmode, | |
| vp, vx, pos_p, plein, | |
| motor, lang, speaker=speaker) | |
| return vid, srt, msg, table_capsules() | |
| except Exception as e: | |
| return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules() | |
| btn.click( | |
| creer_capsule_ui, | |
| [titre, sous_titre, texte_voix, texte_ecran, theme, | |
| image_fond, fond_mode, logo_path, logo_pos, | |
| video_presentateur, voix_type, position_presentateur, | |
| plein, moteur_voix, langue, speaker_id], | |
| [sortie, srt_out, statut, liste] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |