# 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()