omarbajouk commited on
Commit
260dbde
·
verified ·
1 Parent(s): d4b545e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +139 -301
app.py CHANGED
@@ -1,22 +1,21 @@
1
- # app.py
2
  # ============================================================
3
  # CPAS Bruxelles — Créateur de Capsules (Gradio + Kokoro + SadTalker)
4
- # Version "Space HF" optimisée (chargement rapide, imports différés)
5
  # ============================================================
6
 
7
- import os, json, re, uuid, shutil, traceback, gc, subprocess
8
  from typing import Optional
9
  import gradio as gr
10
  from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
 
11
 
12
- # ---------- Config statique ----------
13
  ROOT = os.getcwd()
14
  OUT_DIR = os.path.join(ROOT, "export")
15
  TMP_DIR = os.path.join(ROOT, "_tmp_capsules")
16
  os.makedirs(OUT_DIR, exist_ok=True)
17
  os.makedirs(TMP_DIR, exist_ok=True)
18
 
19
- # Charger config externe
20
  CONFIG_PATH = os.path.join(ROOT, "app_config.json")
21
  if os.path.exists(CONFIG_PATH):
22
  cfg = json.load(open(CONFIG_PATH, "r", encoding="utf-8"))
@@ -24,7 +23,6 @@ if os.path.exists(CONFIG_PATH):
24
  FONT_REG = cfg["font_paths"]["regular"]
25
  FONT_BOLD = cfg["font_paths"]["bold"]
26
  else:
27
- # Valeurs de secours
28
  THEMES = {
29
  "Bleu Professionnel": {"primary": [0, 82, 147], "secondary": [0, 126, 200]},
30
  "Vert Gouvernemental": {"primary": [0, 104, 55], "secondary": [0, 155, 119]},
@@ -36,7 +34,6 @@ else:
36
  W, H = 1920, 1080
37
  MARGIN_X, SAFE_Y_TOP = 140, 140
38
 
39
- # ---------- État runtime ----------
40
  capsules = []
41
  manifest_path = os.path.join(OUT_DIR, "manifest.json")
42
  if os.path.exists(manifest_path):
@@ -47,12 +44,14 @@ if os.path.exists(manifest_path):
47
  except Exception:
48
  pass
49
 
 
50
  def _save_manifest():
51
  with open(manifest_path, "w", encoding="utf-8") as f:
52
  json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2)
53
 
 
54
  # ============================================================
55
- # OUTILS GÉNÉRAUX (rapides)
56
  # ============================================================
57
  def _wrap_text(text, font, max_width, draw):
58
  lines = []
@@ -60,11 +59,7 @@ def _wrap_text(text, font, max_width, draw):
60
  current = []
61
  for word in para.split(" "):
62
  test = " ".join(current + [word])
63
- try:
64
- w = draw.textlength(test, font=font)
65
- except AttributeError:
66
- bbox = draw.textbbox((0, 0), test, font=font)
67
- w = bbox[2] - bbox[0]
68
  if w <= max_width or not current:
69
  current.append(word)
70
  else:
@@ -74,33 +69,27 @@ def _wrap_text(text, font, max_width, draw):
74
  lines.append(" ".join(current))
75
  return lines
76
 
 
77
  def _draw_text_shadow(draw, xy, text, font, fill=(255, 255, 255)):
78
  x, y = xy
79
  draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0))
80
  draw.text((x, y), text, font=font, fill=fill)
81
 
 
82
  def _safe_name(stem, ext=".mp4"):
83
  stem = re.sub(r"[^\w\-]+", "_", stem)[:40]
84
  return f"{stem}_{uuid.uuid4().hex[:6]}{ext}"
85
 
86
- # ============================================================
87
- # SYNTHÈSE VOCALE — Edge-TTS multivoix (FR/NL) + gTTS fallback
88
- # ============================================================
89
- import asyncio
90
- import edge_tts
91
- from pydub import AudioSegment
92
- import soundfile as sf
93
 
94
  # ============================================================
95
- # 🔊 CHARGEMENT DYNAMIQUE DES VOIX EDGE-TTS (FR/NL)
96
  # ============================================================
97
-
98
-
99
 
100
  EDGE_VOICES = {}
101
 
 
102
  async def fetch_edge_voices_async():
103
- """Charge dynamiquement toutes les voix FR/NL depuis Edge-TTS."""
104
  global EDGE_VOICES
105
  try:
106
  voices = await edge_tts.list_voices()
@@ -110,7 +99,6 @@ async def fetch_edge_voices_async():
110
  f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v["ShortName"]
111
  for v in filtered
112
  }
113
- print(f"[Edge-TTS] {len(EDGE_VOICES)} voix FR/NL chargées.")
114
  except Exception as e:
115
  print(f"[Edge-TTS] Erreur chargement voix : {e}")
116
  EDGE_VOICES.update({
@@ -118,17 +106,16 @@ async def fetch_edge_voices_async():
118
  "nl-NL-MaaikeNeural - nl-NL (Female)": "nl-NL-MaaikeNeural",
119
  })
120
 
 
121
  def init_edge_voices():
122
- """Démarre le chargement asynchrone sans bloquer Gradio."""
123
  try:
124
  loop = asyncio.get_event_loop()
125
  loop.create_task(fetch_edge_voices_async())
126
  except RuntimeError:
127
  asyncio.run(fetch_edge_voices_async())
128
 
 
129
  def get_edge_voices(lang="fr"):
130
- """Retourne les voix déjà chargées (selon la langue)."""
131
- global EDGE_VOICES
132
  if not EDGE_VOICES:
133
  init_edge_voices()
134
  if lang == "fr":
@@ -138,296 +125,178 @@ def get_edge_voices(lang="fr"):
138
  return list(EDGE_VOICES.values())
139
 
140
 
141
-
142
  async def _edge_tts_async(text, voice, outfile):
143
  communicate = edge_tts.Communicate(text, voice)
144
  await communicate.save(outfile)
145
  return outfile
146
 
147
- def tts_edge(text: str, voice: str = "fr-FR-DeniseNeural") -> str:
148
- """Génère un fichier WAV avec Edge-TTS (et fallback gTTS)."""
149
- out_mp3 = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.mp3")
150
- try:
151
- # Correction boucle asyncio (HF/Gradio)
152
- try:
153
- loop = asyncio.get_event_loop()
154
- if loop.is_running():
155
- import nest_asyncio
156
- nest_asyncio.apply()
157
- except RuntimeError:
158
- pass
159
 
160
- asyncio.run(_edge_tts_async(text, voice, out_mp3))
 
 
 
 
 
 
161
 
162
- # Conversion WAV pour compatibilité MoviePy
163
- out_wav = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.wav")
164
- AudioSegment.from_file(out_mp3).export(out_wav, format="wav")
165
- os.remove(out_mp3)
166
- return out_wav
167
 
168
- except Exception as e:
169
- print(f"[Edge-TTS] Erreur : {e} → fallback gTTS")
170
- return tts_gtts(text, lang="fr" if voice.startswith("fr") else "nl")
171
-
172
- def tts_gtts(text: str, lang: str = "fr") -> str:
173
- """Fallback via Google Text-to-Speech (gTTS)."""
174
  from gtts import gTTS
175
- out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
176
- gTTS(text=text, lang=lang).save(out)
177
- # Conversion en WAV pour compatibilité
178
- out_wav = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.wav")
179
- AudioSegment.from_file(out).export(out_wav, format="wav")
180
- os.remove(out)
181
- return out_wav
182
 
183
  def _normalize_audio_to_wav(in_path: str) -> str:
184
- # Convertit n'importe quel format (mp3/wav) en WAV standard (44.1kHz stéréo)
185
- from pydub import AudioSegment
186
  wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
187
  snd = AudioSegment.from_file(in_path)
188
  snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
189
  snd.export(wav_path, format="wav")
190
  return wav_path
191
 
 
192
  # ============================================================
193
- # FOND / GRAPHISME (PIL rapide)
194
  # ============================================================
195
  def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
196
  c = THEMES[theme]
197
  primary = tuple(c["primary"]); secondary = tuple(c["secondary"])
198
  bg = Image.new("RGB", (W, H), primary)
199
  if img_fond and os.path.exists(img_fond):
200
- img = Image.open(img_fond).convert("RGB")
201
- if fond_mode == "plein écran":
202
- img = img.resize((W, H))
203
- img = img.filter(ImageFilter.GaussianBlur(1))
204
- overlay = Image.new("RGBA", (W, H), (*primary, 90))
205
- bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
206
- elif fond_mode == "moitié gauche":
207
- img = img.resize((W//2, H))
208
- mask = Image.linear_gradient("L").resize((W//2, H))
209
- color = Image.new("RGB", (W//2, H), primary)
210
- comp = Image.composite(img, color, ImageOps.invert(mask))
211
- bg.paste(comp, (0, 0))
212
- elif fond_mode == "moitié droite":
213
- img = img.resize((W//2, H))
214
- mask = Image.linear_gradient("L").resize((W//2, H))
215
- color = Image.new("RGB", (W//2, H), primary)
216
- comp = Image.composite(color, img, mask)
217
- bg.paste(comp, (W//2, 0))
218
- elif fond_mode == "moitié bas":
219
- img = img.resize((W, H//2))
220
- mask = Image.linear_gradient("L").rotate(90).resize((W, H//2))
221
- color = Image.new("RGB", (W, H//2), primary)
222
- comp = Image.composite(color, img, mask)
223
- bg.paste(comp, (0, H//2))
224
-
225
  draw = ImageDraw.Draw(bg)
226
  f_title = ImageFont.truetype(FONT_BOLD, 84)
227
- f_sub = ImageFont.truetype(FONT_REG, 44)
228
- f_text = ImageFont.truetype(FONT_REG, 40)
229
  f_small = ImageFont.truetype(FONT_REG, 30)
230
-
231
  draw.rectangle([(0, 0), (W, 96)], fill=secondary)
232
  draw.rectangle([(0, H-96), (W, H)], fill=secondary)
233
-
234
  _draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
235
- _draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small)
236
  _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
237
  _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub)
238
-
239
  y = SAFE_Y_TOP + 200
240
  for line in texte_ecran.split("\n"):
241
  for l in _wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw):
242
  _draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
243
  y += 55
244
-
245
  if logo_path and os.path.exists(logo_path):
246
  logo = Image.open(logo_path).convert("RGBA")
247
  logo.thumbnail((260, 260))
248
  lw, lh = logo.size
249
- if logo_pos == "haut-gauche":
250
- pos = (50, 50)
251
- elif logo_pos == "haut-droite":
252
- pos = (W - lw - 50, 50)
253
- else:
254
- pos = ((W - lw)//2, 50)
255
  bg.paste(logo, pos, logo)
256
-
257
  out = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
258
  bg.save(out)
259
  return out
260
 
261
- # ============================================================
262
- # SadTalker — appel subprocess (image -> visage animé)
263
- # ============================================================
264
- def _check_sadtalker_ready() -> Optional[str]:
265
- base = os.path.join(ROOT, "SadTalker")
266
- if not os.path.isdir(base):
267
- return "Dossier SadTalker manquant. Ajoutez 'SadTalker/' à la racine du Space (voir README)."
268
- ck = os.path.join(base, "checkpoints")
269
- needed = [
270
- "audio2exp.pt",
271
- "GFPGANv1.4.pth",
272
- "epoch_20.pth",
273
- "mapping_00229-model.pth.tar",
274
- "shape_predictor_68_face_landmarks.dat",
275
- ]
276
- missing = [f for f in needed if not os.path.exists(os.path.join(ck, f))]
277
- if missing:
278
- return "Checkpoints SadTalker manquants: " + ", ".join(missing)
279
- return None
280
-
281
- def generate_sadtalker_video(image_path, audio_path, output_dir=TMP_DIR, fps=25) -> Optional[str]:
282
- err = _check_sadtalker_ready()
283
- if err:
284
- # Pas d’échec brutal : on renvoie None (le fond seul sera utilisé)
285
- print(f"[SadTalker] {err}")
286
- return None
287
- try:
288
- os.makedirs(output_dir, exist_ok=True)
289
- out_path = os.path.join(output_dir, f"sadtalker_{uuid.uuid4().hex[:6]}.mp4")
290
- cmd = [
291
- "python", "inference.py",
292
- "--driven_audio", audio_path,
293
- "--source_image", image_path,
294
- "--result_dir", output_dir,
295
- "--still", "--enhancer", "gfpgan",
296
- "--fps", str(fps),
297
- ]
298
- subprocess.run(cmd, cwd=os.path.join(ROOT, "SadTalker"), check=True)
299
- # Récupérer le dernier mp4 créé
300
- candidates = [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".mp4")]
301
- latest = max(candidates, key=os.path.getctime) if candidates else None
302
- if latest:
303
- # Harmoniser le nom
304
- shutil.move(latest, out_path)
305
- return out_path
306
- return None
307
- except Exception as e:
308
- print("[SadTalker] Erreur:", e)
309
- return None
310
 
311
  # ============================================================
312
- # SOUS-TITRES .SRT
313
  # ============================================================
314
  def write_srt(text, duration):
315
- parts = re.split(r'(?<=[\.!?])\s+', text.strip())
316
- parts = [p for p in parts if p]
317
- total = len("".join(parts)) or 1
318
  cur = 0.0
319
  srt = []
320
- for i, p in enumerate(parts, 1):
321
- prop = len(p)/total
322
- start = cur
323
- end = min(duration, cur + duration*prop)
324
  cur = end
325
- def ts(t):
326
- m, s = divmod(t, 60)
327
- h, m = divmod(m, 60)
328
- return f"{int(h):02}:{int(m):02}:{int(s):02},000"
329
- srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""]
330
  path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
331
  open(path, "w", encoding="utf-8").write("\n".join(srt))
332
  return path
333
 
 
334
  # ============================================================
335
- # EXPORT VIDÉO (MoviePy — imports différés)
336
  # ============================================================
337
  def _write_video_with_fallback(final_clip, out_path_base, fps=25):
338
  attempts = [
339
- {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"},
340
- {"ext": ".mp4", "codec": "mpeg4", "audio_codec": "aac"},
341
- {"ext": ".mp4", "codec": "libx264","audio_codec": "libmp3lame"},
342
  ]
343
- ffmpeg_params = ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "-shortest"]
344
- last_err = None
345
- for i, opt in enumerate(attempts, 1):
346
- out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"]
347
  try:
348
- final_clip.write_videofile(
349
- out,
350
- fps=fps,
351
- codec=opt["codec"],
352
- audio_codec=opt["audio_codec"],
353
- audio=True,
354
- ffmpeg_params=ffmpeg_params,
355
- logger=None,
356
- threads=1,
357
- )
358
- if os.path.exists(out) and os.path.getsize(out) > 150000:
359
- return out
360
  except Exception as e:
361
- last_err = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
362
- raise RuntimeError(last_err or "FFmpeg a échoué")
 
363
 
364
  # ============================================================
365
- # BUILD CAPSULE — Pipeline complet (corrigé)
366
  # ============================================================
367
  def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
368
  image_fond=None, logo_path=None, logo_pos="haut-gauche",
369
- fond_mode="plein écran",
370
- image_presentateur=None, voix_type="Féminine",
371
- position_presentateur="bottom-right", plein=False,
372
- moteur_voix="Parler-TTS (offline)", langue="fr", speaker=None):
373
-
374
- # 1) TTS (Edge multivoix ou fallback)
375
- try:
376
- audio_mp = tts_edge(texte_voix, voice=speaker or ("fr-FR-DeniseNeural" if langue == "fr" else "nl-NL-MaaikeNeural"))
377
- except Exception as e:
378
- print(f"[Capsule] Erreur TTS Edge ({e}), fallback gTTS.")
379
- audio_mp = tts_gtts(texte_voix, lang=langue)
380
-
381
- # S'assurer qu'on a un WAV
382
- audio_wav = audio_mp
383
- if not audio_mp.lower().endswith(".wav"):
384
- try:
385
- audio_wav = _normalize_audio_to_wav(audio_mp)
386
- except Exception as e:
387
- print(f"[Audio] Normalisation échouée ({e}), on garde {audio_mp}")
388
 
389
-
390
- # 2) Fond (PIL)
391
- fond_path = make_background(titre, sous_titre, texte_ecran, theme,
392
- logo_path, logo_pos, image_fond, fond_mode)
393
-
394
- # 3) MoviePy (imports lents ici seulement)
395
- from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, VideoFileClip
396
  import moviepy.video.fx.all as vfx
397
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  audio = AudioFileClip(audio_wav)
399
  dur = float(audio.duration or 5.0)
400
- target_fps = 25
401
  bg = ImageClip(fond_path).set_duration(dur)
402
-
403
- # 4) SadTalker (optionnel)
404
  clips = [bg]
405
- if image_presentateur and os.path.exists(image_presentateur):
406
- vpath = generate_sadtalker_video(image_presentateur, audio_wav, fps=target_fps)
407
- if vpath and os.path.exists(vpath):
408
- v = VideoFileClip(vpath).without_audio().fx(vfx.loop, duration=dur)
409
- if plein:
410
- v = v.resize((W, H)).set_position(("center", "center"))
411
- else:
412
- v = v.resize(width=520)
413
- pos_map = {
414
- "bottom-right": ("right", "bottom"),
415
- "bottom-left": ("left", "bottom"),
416
- "top-right": ("right", "top"),
417
- "top-left": ("left", "top"),
418
- "center": ("center", "center"),
419
- }
420
- v = v.set_position(pos_map.get(position_presentateur, ("right", "bottom")))
421
- clips.append(v)
422
-
423
- # 5) Composition + export
 
 
 
 
 
424
  final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
425
  name = _safe_name(f"{titre}_{langue}")
426
- out_base = os.path.join(OUT_DIR, name)
427
- out = _write_video_with_fallback(final, out_base, fps=target_fps)
428
 
429
- # 6) Sous-titres + manifest
430
  srt_path = write_srt(texte_voix, dur)
 
431
  capsules.append({
432
  "file": out,
433
  "title": titre,
@@ -438,18 +307,7 @@ def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
438
  })
439
  _save_manifest()
440
 
441
- # 7) Nettoyage
442
- try:
443
- audio.close()
444
- final.close()
445
- bg.close()
446
- if os.path.exists(audio_mp): os.remove(audio_mp)
447
- if audio_wav != audio_mp and os.path.exists(audio_wav): os.remove(audio_wav)
448
- except Exception as e:
449
- print(f"[Clean] Erreur nettoyage : {e}")
450
- gc.collect()
451
-
452
- return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {speaker or voix_type})", srt_path
453
 
454
 
455
  # ============================================================
@@ -461,6 +319,7 @@ def table_capsules():
461
  f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])]
462
  for i, c in enumerate(capsules)]
463
 
 
464
  def assemble_final():
465
  if not capsules:
466
  return None, "❌ Aucune capsule."
@@ -476,6 +335,7 @@ def assemble_final():
476
  try: c.close()
477
  except: pass
478
 
 
479
  def supprimer_capsule(index):
480
  try:
481
  idx = int(index) - 1
@@ -489,7 +349,8 @@ def supprimer_capsule(index):
489
  else:
490
  return "⚠️ Index invalide.", table_capsules()
491
  except Exception as e:
492
- return f"❌ Erreur lors de la suppression : {e}", table_capsules()
 
493
 
494
  def deplacer_capsule(index, direction):
495
  try:
@@ -501,62 +362,44 @@ def deplacer_capsule(index, direction):
501
  _save_manifest()
502
  return f"🔁 Capsule déplacée {direction}.", table_capsules()
503
  except Exception as e:
504
- return f"❌ Erreur de déplacement : {e}", table_capsules()
 
505
 
506
  # ============================================================
507
  # UI GRADIO
508
  # ============================================================
509
  print("[INIT] Lancement de Gradio...")
510
  init_edge_voices()
511
- with gr.Blocks(title="Créateur de Capsules CPAS – SadTalker + Kokoro",
512
- theme=gr.themes.Soft()) as demo:
513
-
514
- gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version complète (SadTalker + Kokoro)")
515
- gr.Markdown("**Astuce** : pour un démarrage instantané, chargez le dossier `SadTalker/checkpoints/` dans le Space (voir README).")
516
 
517
  with gr.Tab("Créer une capsule"):
518
  with gr.Row():
519
  with gr.Column():
520
  image_fond = gr.Image(label="🖼 Image de fond", type="filepath")
521
  fond_mode = gr.Radio(["plein écran", "moitié gauche", "moitié droite", "moitié bas"],
522
- label="Mode d'affichage du fond", value="plein écran")
523
  logo_path = gr.Image(label="🏛 Logo", type="filepath")
524
- logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"],
525
- label="Position logo", value="haut-gauche")
526
- image_presentateur = gr.Image(label="🧑‍🎨 Image du présentateur (portrait pour SadTalker)", type="filepath")
 
 
527
  position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
528
  label="Position", value="bottom-right")
529
  plein = gr.Checkbox(label="Plein écran présentateur", value=False)
 
530
  with gr.Column():
531
- titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp")
532
- sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen")
533
  theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel")
534
  langue = gr.Radio(["fr", "nl"], label="Langue de la voix", value="fr")
535
-
536
- def maj_voix(lang):
537
- try:
538
- voices = get_edge_voices(lang)
539
- return gr.update(choices=voices, value=voices[0] if voices else None)
540
- except Exception as e:
541
- return gr.update(choices=[], value=None)
542
-
543
-
544
-
545
  speaker_id = gr.Dropdown(
546
  label="🎙 Voix Edge-TTS",
547
  choices=get_edge_voices("fr"),
548
- value="fr-FR-DeniseNeural",
549
- info="Liste dynamique des voix Edge-TTS (FR & NL)"
550
  )
551
-
552
- langue.change(maj_voix, [langue], [speaker_id])
553
-
554
  voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
555
- moteur_voix = gr.Radio(
556
- ["Kokoro (HuggingFace, offline)", "gTTS (en ligne)"],
557
- label="Moteur voix",
558
- value="Kokoro (HuggingFace, offline)"
559
- )
560
  texte_voix = gr.Textbox(label="Texte voix off", lines=4,
561
  value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
562
  texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
@@ -580,36 +423,31 @@ with gr.Blocks(title="Créateur de Capsules CPAS – SadTalker + Kokoro",
580
  btn_down = gr.Button("⬇️ Descendre")
581
  btn_del = gr.Button("🗑 Supprimer")
582
  message = gr.Markdown()
583
-
584
  btn_up.click(lambda i: deplacer_capsule(i, "up"), [index], [message, liste])
585
  btn_down.click(lambda i: deplacer_capsule(i, "down"), [index], [message, liste])
586
  btn_del.click(supprimer_capsule, [index], [message, liste])
587
-
588
  gr.Markdown("### 🎬 Assemblage final")
589
  btn_asm = gr.Button("🎥 Assembler la vidéo complète", variant="primary")
590
  sortie_finale = gr.Video(label="Vidéo finale")
591
  btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])
592
 
593
- def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, ip, vx, pos_p, plein, motor, lang, speaker):
 
594
  try:
595
- vid, msg, srt = build_capsule(t, st, tv, te, th,
596
- img, logo, pos_logo, fmode,
597
- ip, vx, pos_p, plein,
598
- motor, lang, speaker=speaker)
599
- return vid, srt, msg, table_capsules()
600
  except Exception as e:
601
  return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules()
602
 
603
- btn.click(
604
- creer_capsule_ui,
605
- [titre, sous_titre, texte_voix, texte_ecran, theme,
606
- image_fond, fond_mode, logo_path, logo_pos,
607
- image_presentateur, voix_type, position_presentateur,
608
- plein, moteur_voix, langue, speaker_id],
609
- [sortie, srt_out, statut, liste]
610
- )
611
-
612
-
613
 
614
  if __name__ == "__main__":
615
  demo.launch()
 
 
1
  # ============================================================
2
  # CPAS Bruxelles — Créateur de Capsules (Gradio + Kokoro + SadTalker)
3
+ # Version complète (vidéo directe + SadTalker + sous-titres)
4
  # ============================================================
5
 
6
+ import os, json, re, uuid, shutil, traceback, gc, subprocess, asyncio
7
  from typing import Optional
8
  import gradio as gr
9
  from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
10
+ from pydub import AudioSegment
11
 
12
+ # ---------- CONFIGURATION ----------
13
  ROOT = os.getcwd()
14
  OUT_DIR = os.path.join(ROOT, "export")
15
  TMP_DIR = os.path.join(ROOT, "_tmp_capsules")
16
  os.makedirs(OUT_DIR, exist_ok=True)
17
  os.makedirs(TMP_DIR, exist_ok=True)
18
 
 
19
  CONFIG_PATH = os.path.join(ROOT, "app_config.json")
20
  if os.path.exists(CONFIG_PATH):
21
  cfg = json.load(open(CONFIG_PATH, "r", encoding="utf-8"))
 
23
  FONT_REG = cfg["font_paths"]["regular"]
24
  FONT_BOLD = cfg["font_paths"]["bold"]
25
  else:
 
26
  THEMES = {
27
  "Bleu Professionnel": {"primary": [0, 82, 147], "secondary": [0, 126, 200]},
28
  "Vert Gouvernemental": {"primary": [0, 104, 55], "secondary": [0, 155, 119]},
 
34
  W, H = 1920, 1080
35
  MARGIN_X, SAFE_Y_TOP = 140, 140
36
 
 
37
  capsules = []
38
  manifest_path = os.path.join(OUT_DIR, "manifest.json")
39
  if os.path.exists(manifest_path):
 
44
  except Exception:
45
  pass
46
 
47
+
48
  def _save_manifest():
49
  with open(manifest_path, "w", encoding="utf-8") as f:
50
  json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2)
51
 
52
+
53
  # ============================================================
54
+ # OUTILS GÉNÉRAUX
55
  # ============================================================
56
  def _wrap_text(text, font, max_width, draw):
57
  lines = []
 
59
  current = []
60
  for word in para.split(" "):
61
  test = " ".join(current + [word])
62
+ w = draw.textlength(test, font=font)
 
 
 
 
63
  if w <= max_width or not current:
64
  current.append(word)
65
  else:
 
69
  lines.append(" ".join(current))
70
  return lines
71
 
72
+
73
  def _draw_text_shadow(draw, xy, text, font, fill=(255, 255, 255)):
74
  x, y = xy
75
  draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0))
76
  draw.text((x, y), text, font=font, fill=fill)
77
 
78
+
79
  def _safe_name(stem, ext=".mp4"):
80
  stem = re.sub(r"[^\w\-]+", "_", stem)[:40]
81
  return f"{stem}_{uuid.uuid4().hex[:6]}{ext}"
82
 
 
 
 
 
 
 
 
83
 
84
  # ============================================================
85
+ # SYNTHÈSE VOCALE Edge-TTS
86
  # ============================================================
87
+ import edge_tts
 
88
 
89
  EDGE_VOICES = {}
90
 
91
+
92
  async def fetch_edge_voices_async():
 
93
  global EDGE_VOICES
94
  try:
95
  voices = await edge_tts.list_voices()
 
99
  f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v["ShortName"]
100
  for v in filtered
101
  }
 
102
  except Exception as e:
103
  print(f"[Edge-TTS] Erreur chargement voix : {e}")
104
  EDGE_VOICES.update({
 
106
  "nl-NL-MaaikeNeural - nl-NL (Female)": "nl-NL-MaaikeNeural",
107
  })
108
 
109
+
110
  def init_edge_voices():
 
111
  try:
112
  loop = asyncio.get_event_loop()
113
  loop.create_task(fetch_edge_voices_async())
114
  except RuntimeError:
115
  asyncio.run(fetch_edge_voices_async())
116
 
117
+
118
  def get_edge_voices(lang="fr"):
 
 
119
  if not EDGE_VOICES:
120
  init_edge_voices()
121
  if lang == "fr":
 
125
  return list(EDGE_VOICES.values())
126
 
127
 
 
128
  async def _edge_tts_async(text, voice, outfile):
129
  communicate = edge_tts.Communicate(text, voice)
130
  await communicate.save(outfile)
131
  return outfile
132
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ def tts_edge(text: str, voice: str) -> str:
135
+ out_mp3 = os.path.join(TMP_DIR, f"tts_{uuid.uuid4().hex}.mp3")
136
+ asyncio.run(_edge_tts_async(text, voice, out_mp3))
137
+ out_wav = os.path.join(TMP_DIR, f"tts_{uuid.uuid4().hex}.wav")
138
+ AudioSegment.from_file(out_mp3).export(out_wav, format="wav")
139
+ os.remove(out_mp3)
140
+ return out_wav
141
 
 
 
 
 
 
142
 
143
+ def tts_gtts(text: str, lang="fr") -> str:
 
 
 
 
 
144
  from gtts import gTTS
145
+ mp3 = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
146
+ gTTS(text=text, lang=lang).save(mp3)
147
+ wav = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.wav")
148
+ AudioSegment.from_file(mp3).export(wav, format="wav")
149
+ os.remove(mp3)
150
+ return wav
151
+
152
 
153
  def _normalize_audio_to_wav(in_path: str) -> str:
 
 
154
  wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
155
  snd = AudioSegment.from_file(in_path)
156
  snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
157
  snd.export(wav_path, format="wav")
158
  return wav_path
159
 
160
+
161
  # ============================================================
162
+ # FOND / GRAPHISME
163
  # ============================================================
164
  def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
165
  c = THEMES[theme]
166
  primary = tuple(c["primary"]); secondary = tuple(c["secondary"])
167
  bg = Image.new("RGB", (W, H), primary)
168
  if img_fond and os.path.exists(img_fond):
169
+ img = Image.open(img_fond).convert("RGB").resize((W, H))
170
+ img = img.filter(ImageFilter.GaussianBlur(1))
171
+ overlay = Image.new("RGBA", (W, H), (*primary, 90))
172
+ bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  draw = ImageDraw.Draw(bg)
174
  f_title = ImageFont.truetype(FONT_BOLD, 84)
175
+ f_sub = ImageFont.truetype(FONT_REG, 44)
176
+ f_text = ImageFont.truetype(FONT_REG, 40)
177
  f_small = ImageFont.truetype(FONT_REG, 30)
 
178
  draw.rectangle([(0, 0), (W, 96)], fill=secondary)
179
  draw.rectangle([(0, H-96), (W, H)], fill=secondary)
 
180
  _draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
 
181
  _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
182
  _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub)
 
183
  y = SAFE_Y_TOP + 200
184
  for line in texte_ecran.split("\n"):
185
  for l in _wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw):
186
  _draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
187
  y += 55
 
188
  if logo_path and os.path.exists(logo_path):
189
  logo = Image.open(logo_path).convert("RGBA")
190
  logo.thumbnail((260, 260))
191
  lw, lh = logo.size
192
+ pos = (50, 50) if logo_pos == "haut-gauche" else (W - lw - 50, 50)
 
 
 
 
 
193
  bg.paste(logo, pos, logo)
 
194
  out = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
195
  bg.save(out)
196
  return out
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  # ============================================================
200
+ # SOUS-TITRES
201
  # ============================================================
202
  def write_srt(text, duration):
203
+ parts = re.split(r'(?<=[.!?])\s+', text.strip())
204
+ total_chars = len("".join(parts)) or 1
 
205
  cur = 0.0
206
  srt = []
207
+ for i, part in enumerate(parts, 1):
208
+ prop = len(part) / total_chars
209
+ start, end = cur, cur + duration * prop
 
210
  cur = end
211
+ ts = lambda t: f"{int(t//3600):02}:{int((t%3600)//60):02}:{int(t%60):02},000"
212
+ srt += [f"{i}", f"{ts(start)} --> {ts(end)}", part, ""]
 
 
 
213
  path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
214
  open(path, "w", encoding="utf-8").write("\n".join(srt))
215
  return path
216
 
217
+
218
  # ============================================================
219
+ # EXPORT / COMPOSITION VIDÉO
220
  # ============================================================
221
  def _write_video_with_fallback(final_clip, out_path_base, fps=25):
222
  attempts = [
223
+ {"codec": "libx264", "audio_codec": "aac"},
224
+ {"codec": "mpeg4", "audio_codec": "aac"},
 
225
  ]
226
+ for opt in attempts:
227
+ out = out_path_base if out_path_base.endswith(".mp4") else out_path_base + ".mp4"
 
 
228
  try:
229
+ final_clip.write_videofile(out, fps=fps, codec=opt["codec"],
230
+ audio_codec=opt["audio_codec"],
231
+ ffmpeg_params=["-pix_fmt", "yuv420p", "-movflags", "+faststart"],
232
+ logger=None)
233
+ if os.path.exists(out): return out
 
 
 
 
 
 
 
234
  except Exception as e:
235
+ print("[FFmpeg] Erreur:", e)
236
+ raise RuntimeError("FFmpeg a échoué")
237
+
238
 
239
  # ============================================================
240
+ # PIPELINE DE CRÉATION
241
  # ============================================================
242
  def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
243
  image_fond=None, logo_path=None, logo_pos="haut-gauche",
244
+ fond_mode="plein écran", image_presentateur=None,
245
+ voix_type="Féminine", position_presentateur="bottom-right",
246
+ plein=False, langue="fr", speaker=None,
247
+ video_presentateur=None,
248
+ source_audio_option="Garder la voix originale de la vidéo"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
+ from moviepy.editor import AudioFileClip, ImageClip, VideoFileClip, CompositeVideoClip
 
 
 
 
 
 
251
  import moviepy.video.fx.all as vfx
252
 
253
+ # --- AUDIO ---
254
+ if video_presentateur and source_audio_option == "Garder la voix originale de la vidéo":
255
+ vclip = VideoFileClip(video_presentateur)
256
+ audio_wav = os.path.join(TMP_DIR, f"orig_{uuid.uuid4().hex}.wav")
257
+ vclip.audio.write_audiofile(audio_wav, fps=44100, logger=None)
258
+ vclip.close()
259
+ else:
260
+ try:
261
+ audio_wav = tts_edge(texte_voix, voice=speaker or ("fr-FR-DeniseNeural" if langue == "fr" else "nl-NL-MaaikeNeural"))
262
+ except Exception:
263
+ audio_wav = tts_gtts(texte_voix, lang=langue)
264
+
265
  audio = AudioFileClip(audio_wav)
266
  dur = float(audio.duration or 5.0)
267
+ fond_path = make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, image_fond, fond_mode)
268
  bg = ImageClip(fond_path).set_duration(dur)
 
 
269
  clips = [bg]
270
+
271
+ # --- PRÉSENTATEUR ---
272
+ if video_presentateur and os.path.exists(video_presentateur):
273
+ v = VideoFileClip(video_presentateur)
274
+ if source_audio_option == "Remplacer par voix IA":
275
+ v = v.without_audio()
276
+ if not plein:
277
+ v = v.resize(width=520)
278
+ pos_map = {
279
+ "bottom-right": ("right", "bottom"),
280
+ "bottom-left": ("left", "bottom"),
281
+ "top-right": ("right", "top"),
282
+ "top-left": ("left", "top"),
283
+ "center": ("center", "center"),
284
+ }
285
+ v = v.set_position(pos_map.get(position_presentateur, ("right", "bottom")))
286
+ clips.append(v)
287
+ elif image_presentateur and os.path.exists(image_presentateur):
288
+ from moviepy.editor import ImageClip
289
+ img_clip = ImageClip(image_presentateur).set_duration(dur).resize(width=520)
290
+ img_clip = img_clip.set_position(("right", "bottom"))
291
+ clips.append(img_clip)
292
+
293
+ # --- COMPOSITION ---
294
  final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
295
  name = _safe_name(f"{titre}_{langue}")
296
+ out = _write_video_with_fallback(final, os.path.join(OUT_DIR, name))
 
297
 
 
298
  srt_path = write_srt(texte_voix, dur)
299
+
300
  capsules.append({
301
  "file": out,
302
  "title": titre,
 
307
  })
308
  _save_manifest()
309
 
310
+ return out, f"✅ Capsule créée ({dur:.1f}s)", srt_path
 
 
 
 
 
 
 
 
 
 
 
311
 
312
 
313
  # ============================================================
 
319
  f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])]
320
  for i, c in enumerate(capsules)]
321
 
322
+
323
  def assemble_final():
324
  if not capsules:
325
  return None, "❌ Aucune capsule."
 
335
  try: c.close()
336
  except: pass
337
 
338
+
339
  def supprimer_capsule(index):
340
  try:
341
  idx = int(index) - 1
 
349
  else:
350
  return "⚠️ Index invalide.", table_capsules()
351
  except Exception as e:
352
+ return f"❌ Erreur : {e}", table_capsules()
353
+
354
 
355
  def deplacer_capsule(index, direction):
356
  try:
 
362
  _save_manifest()
363
  return f"🔁 Capsule déplacée {direction}.", table_capsules()
364
  except Exception as e:
365
+ return f"❌ Erreur : {e}", table_capsules()
366
+
367
 
368
  # ============================================================
369
  # UI GRADIO
370
  # ============================================================
371
  print("[INIT] Lancement de Gradio...")
372
  init_edge_voices()
373
+ with gr.Blocks(title="Créateur de Capsules CPAS – Complet") as demo:
374
+ gr.Markdown("## 🎬 Créateur de Capsules CPAS — version complète")
 
 
 
375
 
376
  with gr.Tab("Créer une capsule"):
377
  with gr.Row():
378
  with gr.Column():
379
  image_fond = gr.Image(label="🖼 Image de fond", type="filepath")
380
  fond_mode = gr.Radio(["plein écran", "moitié gauche", "moitié droite", "moitié bas"],
381
+ label="Mode du fond", value="plein écran")
382
  logo_path = gr.Image(label="🏛 Logo", type="filepath")
383
+ logo_pos = gr.Radio(["haut-gauche","haut-droite"], label="Position logo", value="haut-gauche")
384
+ video_presentateur = gr.Video(label="🎥 Vidéo du présentateur", type="filepath")
385
+ image_presentateur = gr.Image(label="🧑‍🎨 Image présentateur (SadTalker)", type="filepath")
386
+ source_audio_option = gr.Radio(["Garder la voix originale de la vidéo", "Remplacer par voix IA"],
387
+ label="🎧 Option audio", value="Garder la voix originale de la vidéo")
388
  position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
389
  label="Position", value="bottom-right")
390
  plein = gr.Checkbox(label="Plein écran présentateur", value=False)
391
+
392
  with gr.Column():
393
+ titre = gr.Textbox(label="Titre", value="Aide médicale urgente")
394
+ sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous")
395
  theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel")
396
  langue = gr.Radio(["fr", "nl"], label="Langue de la voix", value="fr")
 
 
 
 
 
 
 
 
 
 
397
  speaker_id = gr.Dropdown(
398
  label="🎙 Voix Edge-TTS",
399
  choices=get_edge_voices("fr"),
400
+ value="fr-FR-DeniseNeural"
 
401
  )
 
 
 
402
  voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
 
 
 
 
 
403
  texte_voix = gr.Textbox(label="Texte voix off", lines=4,
404
  value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
405
  texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
 
423
  btn_down = gr.Button("⬇️ Descendre")
424
  btn_del = gr.Button("🗑 Supprimer")
425
  message = gr.Markdown()
 
426
  btn_up.click(lambda i: deplacer_capsule(i, "up"), [index], [message, liste])
427
  btn_down.click(lambda i: deplacer_capsule(i, "down"), [index], [message, liste])
428
  btn_del.click(supprimer_capsule, [index], [message, liste])
 
429
  gr.Markdown("### 🎬 Assemblage final")
430
  btn_asm = gr.Button("🎥 Assembler la vidéo complète", variant="primary")
431
  sortie_finale = gr.Video(label="Vidéo finale")
432
  btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])
433
 
434
+ def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo,
435
+ ip, vx, pos_p, plein, lang, speaker, vid, src_opt):
436
  try:
437
+ vid_path, msg, srt = build_capsule(
438
+ t, st, tv, te, th, img, logo, pos_logo, fmode,
439
+ ip, vx, pos_p, plein, lang, speaker,
440
+ video_presentateur=vid, source_audio_option=src_opt)
441
+ return vid_path, srt, msg, table_capsules()
442
  except Exception as e:
443
  return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules()
444
 
445
+ btn.click(creer_capsule_ui,
446
+ [titre, sous_titre, texte_voix, texte_ecran, theme,
447
+ image_fond, fond_mode, logo_path, logo_pos,
448
+ image_presentateur, voix_type, position_presentateur,
449
+ plein, langue, speaker_id, video_presentateur, source_audio_option],
450
+ [sortie, srt_out, statut, liste])
 
 
 
 
451
 
452
  if __name__ == "__main__":
453
  demo.launch()