CapsulesVideo / app.py
omarbajouk's picture
Update app.py
f0ab94e verified
# 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()