omarbajouk commited on
Commit
8056606
·
verified ·
1 Parent(s): 3554094

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +275 -394
app.py CHANGED
@@ -1,71 +1,60 @@
1
-
2
-
3
  # ============================================================
4
- # IMPORTS & CONFIG
 
5
  # ============================================================
6
- import os, io, json, re, uuid, time, shutil, traceback, gc, asyncio
7
- from moviepy.editor import *
8
- import moviepy.video.fx.all as vfx
9
- from moviepy.video.compositing.concatenate import concatenate_videoclips
10
- from gtts import gTTS
11
- from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
12
  import gradio as gr
13
- from pydub import AudioSegment
14
- from rich import print as rprint
15
- import edge_tts
16
 
 
17
  ROOT = os.getcwd()
18
  OUT_DIR = os.path.join(ROOT, "export")
19
  TMP_DIR = os.path.join(ROOT, "_tmp_capsules")
20
  os.makedirs(OUT_DIR, exist_ok=True)
21
  os.makedirs(TMP_DIR, exist_ok=True)
22
 
23
- FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
24
- FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  W, H = 1920, 1080
27
  MARGIN_X, SAFE_Y_TOP = 140, 140
28
 
 
29
  capsules = []
30
  manifest_path = os.path.join(OUT_DIR, "manifest.json")
 
 
 
 
 
 
 
31
 
32
- themes = {
33
- "Bleu Professionnel": {"primary": (0, 82, 147), "secondary": (0, 126, 200)},
34
- "Vert Gouvernemental": {"primary": (0, 104, 55), "secondary": (0, 155, 119)},
35
- "Violet Élégant": {"primary": (74, 20, 140), "secondary": (103, 58, 183)},
36
- }
37
-
38
- # ============================================================
39
- # 🚀 Téléchargement automatique de SadTalker (si absent)
40
- # ============================================================
41
- import os, subprocess
42
-
43
- if not os.path.exists("SadTalker"):
44
- print("📦 Téléchargement de SadTalker depuis GitHub...")
45
- subprocess.run(["git", "clone", "--depth", "1", "https://github.com/OpenTalker/SadTalker.git"], check=True)
46
-
47
- # Télécharger les checkpoints nécessaires
48
- os.makedirs("SadTalker/checkpoints", exist_ok=True)
49
- checkpoints = {
50
- "audio2exp.pt": "https://huggingface.co/OpenTalker/SadTalker/resolve/main/checkpoints/audio2exp.pt",
51
- "GFPGANv1.4.pth": "https://huggingface.co/OpenTalker/SadTalker/resolve/main/checkpoints/GFPGANv1.4.pth",
52
- "epoch_20.pth": "https://huggingface.co/OpenTalker/SadTalker/resolve/main/checkpoints/epoch_20.pth",
53
- "mapping_00229-model.pth.tar": "https://huggingface.co/OpenTalker/SadTalker/resolve/main/checkpoints/mapping_00229-model.pth.tar",
54
- "shape_predictor_68_face_landmarks.dat": "https://huggingface.co/OpenTalker/SadTalker/resolve/main/checkpoints/shape_predictor_68_face_landmarks.dat",
55
- }
56
-
57
- for name, url in checkpoints.items():
58
- dest = f"SadTalker/checkpoints/{name}"
59
- if not os.path.exists(dest):
60
- print(f"⬇️ Téléchargement {name}...")
61
- subprocess.run(["wget", "-q", "-O", dest, url], check=True)
62
-
63
- print("✅ SadTalker prêt à l’emploi.")
64
 
65
  # ============================================================
66
- # OUTILS GÉNÉRAUX
67
  # ============================================================
68
- def wrap_text(text, font, max_width, draw):
69
  lines = []
70
  for para in text.split("\n"):
71
  current = []
@@ -85,122 +74,49 @@ def wrap_text(text, font, max_width, draw):
85
  lines.append(" ".join(current))
86
  return lines
87
 
88
- def draw_text_shadow(draw, xy, text, font, fill=(255,255,255)):
89
  x, y = xy
90
- draw.text((x+2, y+2), text, font=font, fill=(0,0,0))
91
  draw.text((x, y), text, font=font, fill=fill)
92
 
93
- def safe_name(stem, ext=".mp4"):
94
  stem = re.sub(r"[^\w\-]+", "_", stem)[:40]
95
  return f"{stem}_{uuid.uuid4().hex[:6]}{ext}"
96
 
97
- def save_manifest():
98
- with open(manifest_path, "w", encoding="utf-8") as f:
99
- json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2)
100
-
101
- if os.path.exists(manifest_path):
102
- try:
103
- data = json.load(open(manifest_path, "r", encoding="utf-8"))
104
- if isinstance(data, dict) and "capsules" in data:
105
- capsules = data["capsules"]
106
- except Exception as e:
107
- rprint(f"[yellow]Impossible de charger le manifest: {e}[/yellow]")
108
-
109
  # ============================================================
110
- # 🧠 SYNTHÈSE VOCALE gTTS / Edge / espeak / Kokoro (Hugging Face)
111
  # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- from transformers import pipeline
114
- import soundfile as sf
115
- import asyncio
116
- from pydub import AudioSegment
117
-
118
- def tts_gtts(text, lang="fr"):
119
- """🔹 Génère avec Google TTS (rapide, en ligne)"""
120
- out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
121
  from gtts import gTTS
 
122
  gTTS(text=text, lang=lang).save(out)
123
  return out
124
 
125
- def tts_espeak(text, voice="fr+f3"):
126
- """🔹 Génère avec eSpeak (offline, basique)"""
127
- out = os.path.join(TMP_DIR, f"espeak_{uuid.uuid4().hex}.wav")
128
- os.system(f'espeak-ng -v {voice} -s 165 -p 50 --stdout "{text}" > "{out}"')
129
- return out
130
-
131
- async def _tts_edge_async(text, voice="fr-FR-DeniseNeural"):
132
- """🔹 Edge-TTS (Azure Cloud)"""
133
- import edge_tts
134
- out = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.mp3")
135
- communicate = edge_tts.Communicate(text, voice)
136
- await communicate.save(out)
137
- return out
138
-
139
- def tts_edge(text, voice="fr-FR-DeniseNeural"):
140
- """Génère via Edge-TTS puis convertit en WAV"""
141
- try:
142
- mp3_path = asyncio.run(_tts_edge_async(text, voice))
143
- wav_path = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.wav")
144
- AudioSegment.from_file(mp3_path).export(wav_path, format="wav")
145
- os.remove(mp3_path)
146
- return wav_path
147
- except Exception as e:
148
- rprint(f"[red]Erreur Edge-TTS: {e}, fallback espeak[/red]")
149
- return tts_espeak(text)
150
-
151
- def tts_kokoro(text, langue="fr"):
152
- """🔹 Synthèse Kokoro Hugging Face (offline, voix très naturelle)"""
153
- try:
154
- kokoro = pipeline("text-to-speech", model="onnx-community/Kokoro-82M-v1.0-ONNX")
155
- output = kokoro(text)
156
- wav_path = os.path.join(TMP_DIR, f"kokoro_{uuid.uuid4().hex}.wav")
157
- sf.write(wav_path, output["audio"], output["sampling_rate"])
158
- return wav_path
159
- except Exception as e:
160
- rprint(f"[red]Erreur Kokoro: {e}, fallback espeak[/red]")
161
- return tts_espeak(text)
162
-
163
- def synth_voice(text, engine="Kokoro", gender_hint="Féminine", langue="fr"):
164
- """
165
- Sélectionne automatiquement le moteur TTS selon le choix utilisateur.
166
- """
167
- try:
168
- if engine == "Kokoro":
169
- return tts_kokoro(text, langue=langue)
170
-
171
- elif engine == "gTTS":
172
- path = tts_gtts(text, lang=langue)
173
- # Ajustement léger masculin
174
- if gender_hint.lower().startswith("m"):
175
- snd = AudioSegment.from_file(path)
176
- snd = snd._spawn(snd.raw_data, overrides={"frame_rate": int(snd.frame_rate * 0.94)}).set_frame_rate(snd.frame_rate)
177
- snd.export(path, format="mp3")
178
- return path
179
-
180
- elif engine == "edge-tts":
181
- voices = {
182
- "fr": {"f": "fr-FR-DeniseNeural", "m": "fr-FR-AlainNeural"},
183
- "nl": {"f": "nl-NL-ColetteNeural", "m": "nl-BE-ArnaudNeural"},
184
- "en": {"f": "en-GB-LibbyNeural", "m": "en-GB-RyanNeural"},
185
- }
186
- v = voices.get(langue, voices["fr"])["f" if gender_hint.lower().startswith("f") else "m"]
187
- return tts_edge(text, voice=v)
188
-
189
- else: # espeak fallback
190
- voice = f"{langue}+f3" if gender_hint.lower().startswith("f") else f"{langue}+m3"
191
- return tts_espeak(text, voice=voice)
192
-
193
- except Exception as e:
194
- rprint(f"[red]Erreur synthèse {engine}: {e}, fallback espeak[/red]")
195
- voice = f"{langue}+f3" if gender_hint.lower().startswith("f") else f"{langue}+m3"
196
- return tts_espeak(text, voice=voice)
197
-
198
-
199
- # ============================================================
200
- # AUDIO NORMALISATION WAV
201
- # ============================================================
202
  def _normalize_audio_to_wav(in_path: str) -> str:
203
- # Convertit n'importe quel format (mp3/wav) en WAV standard
 
204
  wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
205
  snd = AudioSegment.from_file(in_path)
206
  snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
@@ -208,311 +124,277 @@ def _normalize_audio_to_wav(in_path: str) -> str:
208
  return wav_path
209
 
210
  # ============================================================
211
- # SOUS-TITRES
212
- # ============================================================
213
- def write_srt(text, duration):
214
- parts = re.split(r'(?<=[\.!?])\s+', text.strip())
215
- parts = [p for p in parts if p]
216
- total = len("".join(parts)) or 1
217
- cur = 0.0
218
- srt = []
219
- for i, p in enumerate(parts, 1):
220
- prop = len(p)/total
221
- start = cur
222
- end = min(duration, cur + duration*prop)
223
- cur = end
224
- def ts(t):
225
- m, s = divmod(t, 60)
226
- h, m = divmod(m, 60)
227
- return f"{int(h):02}:{int(m):02}:{int(s):02},000"
228
- srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""]
229
- path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
230
- open(path, "w", encoding="utf-8").write("\n".join(srt))
231
- return path
232
-
233
- # ============================================================
234
- # CRÉATION FOND
235
  # ============================================================
236
  def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
237
- c = themes[theme]
238
- bg = Image.new("RGB", (W, H), c["primary"])
 
239
  if img_fond and os.path.exists(img_fond):
240
  img = Image.open(img_fond).convert("RGB")
241
  if fond_mode == "plein écran":
242
  img = img.resize((W, H))
243
  img = img.filter(ImageFilter.GaussianBlur(1))
244
- overlay = Image.new("RGBA", (W, H), (*c["primary"], 90))
245
  bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
246
  elif fond_mode == "moitié gauche":
247
  img = img.resize((W//2, H))
248
  mask = Image.linear_gradient("L").resize((W//2, H))
249
- color = Image.new("RGB", (W//2, H), c["primary"])
250
  comp = Image.composite(img, color, ImageOps.invert(mask))
251
  bg.paste(comp, (0, 0))
252
  elif fond_mode == "moitié droite":
253
  img = img.resize((W//2, H))
254
  mask = Image.linear_gradient("L").resize((W//2, H))
255
- color = Image.new("RGB", (W//2, H), c["primary"])
256
  comp = Image.composite(color, img, mask)
257
  bg.paste(comp, (W//2, 0))
258
  elif fond_mode == "moitié bas":
259
  img = img.resize((W, H//2))
260
  mask = Image.linear_gradient("L").rotate(90).resize((W, H//2))
261
- color = Image.new("RGB", (W, H//2), c["primary"])
262
  comp = Image.composite(color, img, mask)
263
  bg.paste(comp, (0, H//2))
264
 
265
  draw = ImageDraw.Draw(bg)
266
  f_title = ImageFont.truetype(FONT_BOLD, 84)
267
- f_sub = ImageFont.truetype(FONT_REG, 44)
268
- f_text = ImageFont.truetype(FONT_REG, 40)
269
  f_small = ImageFont.truetype(FONT_REG, 30)
270
- draw.rectangle([(0, 0), (W, 96)], fill=c["secondary"])
271
- draw.rectangle([(0, H-96), (W, H)], fill=c["secondary"])
272
- draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
273
- draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small)
274
- draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
275
- draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP+100), sous_titre, f_sub)
 
 
 
276
  y = SAFE_Y_TOP + 200
277
  for line in texte_ecran.split("\n"):
278
- for l in wrap_text("• "+line.strip("• "), f_text, W-MARGIN_X*2, draw):
279
- draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
280
  y += 55
 
281
  if logo_path and os.path.exists(logo_path):
282
  logo = Image.open(logo_path).convert("RGBA")
283
- logo.thumbnail((260,260))
284
  lw, lh = logo.size
285
- pos = (50,50) if logo_pos=="haut-gauche" else (W-lw-50,50) if logo_pos=="haut-droite" else ((W-lw)//2,50)
 
 
 
 
 
286
  bg.paste(logo, pos, logo)
 
287
  out = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
288
  bg.save(out)
289
  return out
290
 
291
  # ============================================================
292
- # FFMPEG FALLBACK (sans temp_audiofile)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  # ============================================================
294
- def _write_video_with_fallback(final_clip, out_path_base, fps=24):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  attempts = [
296
  {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"},
297
- {"ext": ".mp4", "codec": "mpeg4", "audio_codec": "aac"},
298
- {"ext": ".mp4", "codec": "libx264", "audio_codec": "libmp3lame"},
299
- ]
300
- ffmpeg_common = [
301
- "-pix_fmt", "yuv420p",
302
- "-movflags", "+faststart",
303
- "-threads", "1",
304
- "-max_muxing_queue_size", "1024",
305
- "-shortest"
306
  ]
 
307
  last_err = None
308
  for i, opt in enumerate(attempts, 1):
309
  out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"]
310
  try:
311
- rprint(f"[cyan]FFmpeg try #{i}: codec={opt['codec']} audio={opt['audio_codec']} -> {out}[/cyan]")
312
  final_clip.write_videofile(
313
  out,
314
  fps=fps,
315
  codec=opt["codec"],
316
  audio_codec=opt["audio_codec"],
317
  audio=True,
318
- ffmpeg_params=ffmpeg_common,
319
  logger=None,
320
  threads=1,
321
  )
322
  if os.path.exists(out) and os.path.getsize(out) > 150000:
323
  return out
324
  except Exception as e:
325
- last_err = f"{type(e).__name__}: {e}\n" + traceback.format_exc()
326
- rprint(f"[yellow]Échec essai #{i}: {last_err}[/yellow]")
327
- raise RuntimeError(last_err or "Échec inconnu de l'encodage FFmpeg")
328
-
329
- def generate_sadtalker_video(image_path, audio_path, output_dir=TMP_DIR, fps=25):
330
- """
331
- Génère une vidéo animée du visage à partir d'une image et d'un fichier audio,
332
- en utilisant SadTalker.
333
- """
334
- try:
335
- os.makedirs(output_dir, exist_ok=True)
336
- out_path = os.path.join(output_dir, f"sadtalker_{uuid.uuid4().hex[:6]}.mp4")
337
- cmd = (
338
- f'cd SadTalker && python inference.py '
339
- f'--driven_audio "{audio_path}" '
340
- f'--source_image "{image_path}" '
341
- f'--result_dir "{output_dir}" '
342
- f'--still --enhancer gfpgan --fps {fps}'
343
- )
344
- os.system(cmd)
345
 
346
- # Cherche le dernier fichier mp4 généré
347
- candidates = [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".mp4")]
348
- if not candidates:
349
- rprint("[red]❌ Aucune sortie SadTalker générée[/red]")
350
- return None
351
- latest = max(candidates, key=os.path.getctime)
352
- os.rename(latest, out_path)
353
- if os.path.exists(out_path):
354
- return out_path
355
- return None
356
- except Exception as e:
357
- rprint(f"[red]Erreur SadTalker: {e}[/red]")
358
- return None
359
-
360
- #=============================================================
361
  # ============================================================
362
- # BUILD CAPSULE Version SadTalker (qualité type Sora 2)
363
  # ============================================================
364
  def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
365
  image_fond=None, logo_path=None, logo_pos="haut-gauche",
366
  fond_mode="plein écran",
367
- video_presentateur=None, voix_type="Féminine",
368
  position_presentateur="bottom-right", plein=False,
369
- moteur_voix="Edge-TTS (voix naturelle)", langue="fr"):
370
- """
371
- Construit une capsule vidéo complète avec :
372
- - TTS Edge/gTTS/espeak
373
- - Génération visuelle (fond + texte)
374
- - Présentateur animé via SadTalker
375
- """
376
- try:
377
- # ====================================================
378
- # 1️⃣ Synthèse vocale
379
- # ====================================================
380
- # Sélection du moteur vocal selon le choix utilisateur
381
- if moteur_voix.startswith("Kokoro"):
382
- engine = "Kokoro"
383
- elif moteur_voix.startswith("Edge"):
384
- engine = "edge-tts"
385
- elif moteur_voix.startswith("gTTS"):
386
- engine = "gTTS"
387
- else:
388
- engine = "espeak"
389
-
390
- audio_path_mp = synth_voice(texte_voix, engine=engine, gender_hint=voix_type, langue=langue)
391
- audio_path = _normalize_audio_to_wav(audio_path_mp)
392
-
393
- if not os.path.exists(audio_path) or os.path.getsize(audio_path) < 1000:
394
- raise RuntimeError(f"Audio non valide : {audio_path}")
395
-
396
- audio = AudioFileClip(audio_path)
397
- dur = float(audio.duration or 5.0)
398
- target_fps = 25 # ✅ FPS fixe haute qualité
399
-
400
- # ====================================================
401
- # 2️⃣ Génération du fond graphique
402
- # ====================================================
403
- fond_path = make_background(
404
- titre, sous_titre, texte_ecran, theme,
405
- logo_path, logo_pos, image_fond, fond_mode
406
- )
407
- bg = ImageClip(fond_path).set_duration(dur)
408
-
409
- # ====================================================
410
- # 3️⃣ Génération présentateur (SadTalker)
411
- # ====================================================
412
- clips = [bg]
413
- if video_presentateur and os.path.exists(video_presentateur):
414
- ext = os.path.splitext(video_presentateur)[1].lower()
415
- video_path = None
416
-
417
- if ext in [".jpg", ".jpeg", ".png"]:
418
- rprint("[cyan]🎭 Génération visage animé avec SadTalker...[/cyan]")
419
- synced = generate_sadtalker_video(video_presentateur, audio_path, fps=target_fps)
420
- if synced:
421
- video_path = synced
422
- rprint("[green]✅ SadTalker : visage animé généré[/green]")
423
- else:
424
- rprint("[red]⚠️ SadTalker n’a pas pu produire de vidéo[/red]")
425
  else:
426
- rprint("[yellow]⚠️ SadTalker attend une image (portrait), pas une vidéo.[/yellow]")
427
- video_path = video_presentateur
428
-
429
- if video_path and os.path.exists(video_path):
430
- v = VideoFileClip(video_path).without_audio()
431
- v = v.fx(vfx.loop, duration=dur)
432
-
433
- if plein:
434
- v = v.resize((W, H))
435
- else:
436
- v = v.resize(width=480)
437
- pos_map = {
438
- "bottom-right": ("right", "bottom"),
439
- "bottom-left": ("left", "bottom"),
440
- "top-right": ("right", "top"),
441
- "top-left": ("left", "top"),
442
- "center": ("center", "center"),
443
- }
444
- v = v.set_position(pos_map.get(position_presentateur, ("right", "bottom")))
445
-
446
- clips.append(v)
447
-
448
- # ====================================================
449
- # 4️⃣ Composition finale et export
450
- # ====================================================
451
- final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
452
- name = safe_name(f"{titre}_{langue}")
453
- out_base = os.path.join(OUT_DIR, name)
454
- out = _write_video_with_fallback(final, out_base, fps=target_fps)
455
-
456
- # ====================================================
457
- # 5️⃣ Sous-titres + Manifest
458
- # ====================================================
459
- srt_path = write_srt(texte_voix, dur)
460
- capsules.append({
461
- "file": out,
462
- "title": titre,
463
- "langue": langue,
464
- "voice": voix_type,
465
- "theme": theme,
466
- "duration": round(dur, 1)
467
- })
468
- save_manifest()
469
-
470
- # ====================================================
471
- # 6️⃣ Nettoyage
472
- # ====================================================
473
- audio.close()
474
- final.close()
475
- bg.close()
476
- try:
477
- if os.path.exists(audio_path_mp): os.remove(audio_path_mp)
478
- if os.path.exists(audio_path): os.remove(audio_path)
479
- except:
480
- pass
481
- gc.collect()
482
-
483
- return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {voix_type})", srt_path
484
-
485
- except Exception as e:
486
- err_msg = f"❌ Erreur: {e}\n\nTraceback:\n{traceback.format_exc()}"
487
- rprint(f"[red]{err_msg}[/red]")
488
- return None, err_msg, None
489
 
 
490
 
491
  # ============================================================
492
- # TABLEAU, ASSEMBLAGE ET GESTION DES CAPSULES
493
  # ============================================================
494
  def table_capsules():
 
495
  return [[i+1, c["title"], c.get("langue","fr").upper(),
496
- f"{c['duration']}s", c["theme"], c["voice"],
497
- os.path.basename(c["file"])]
498
  for i, c in enumerate(capsules)]
499
 
500
-
501
  def assemble_final():
502
  if not capsules:
503
  return None, "❌ Aucune capsule."
 
 
504
  clips = [VideoFileClip(c["file"]) for c in capsules]
505
- final = concatenate_videoclips(clips, method="compose")
506
  try:
507
- out = _write_video_with_fallback(final, os.path.join(OUT_DIR, safe_name("VIDEO_COMPLETE")), fps=25)
 
508
  return out, f"🎉 Vidéo finale prête ({len(capsules)} capsules)."
509
  finally:
510
  for c in clips:
511
  try: c.close()
512
  except: pass
513
- try: final.close()
514
- except: pass
515
-
516
 
517
  def supprimer_capsule(index):
518
  try:
@@ -522,14 +404,13 @@ def supprimer_capsule(index):
522
  if os.path.exists(fichier):
523
  os.remove(fichier)
524
  del capsules[idx]
525
- save_manifest()
526
  return f"🗑 Capsule supprimée : {fichier}", table_capsules()
527
  else:
528
  return "⚠️ Index invalide.", table_capsules()
529
  except Exception as e:
530
  return f"❌ Erreur lors de la suppression : {e}", table_capsules()
531
 
532
-
533
  def deplacer_capsule(index, direction):
534
  try:
535
  idx = int(index) - 1
@@ -537,17 +418,19 @@ def deplacer_capsule(index, direction):
537
  capsules[idx - 1], capsules[idx] = capsules[idx], capsules[idx - 1]
538
  elif direction == "down" and idx < len(capsules) - 1:
539
  capsules[idx + 1], capsules[idx] = capsules[idx], capsules[idx + 1]
540
- save_manifest()
541
  return f"🔁 Capsule déplacée {direction}.", table_capsules()
542
  except Exception as e:
543
  return f"❌ Erreur de déplacement : {e}", table_capsules()
544
 
545
-
546
  # ============================================================
547
- # INTERFACE GRADIO (adaptée pour SadTalker)
548
  # ============================================================
549
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
550
- gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version SadTalker (réaliste)")
 
 
 
551
 
552
  with gr.Tab("Créer une capsule"):
553
  with gr.Row():
@@ -558,24 +441,21 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
558
  logo_path = gr.Image(label="🏛 Logo", type="filepath")
559
  logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"],
560
  label="Position logo", value="haut-gauche")
561
- # SadTalker prend une IMAGE, pas une vidéo
562
- video_presentateur = gr.Image(label="🧑‍🎨 Image du présentateur (SadTalker)", type="filepath")
563
  position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
564
  label="Position", value="bottom-right")
565
  plein = gr.Checkbox(label="Plein écran présentateur", value=False)
566
  with gr.Column():
567
  titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp")
568
  sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen")
569
- theme = gr.Radio(list(themes.keys()), label="Thème", value="Bleu Professionnel")
570
  langue = gr.Radio(["fr","nl"], label="Langue de la voix", value="fr")
571
  voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
572
  moteur_voix = gr.Radio(
573
- ["Kokoro (HuggingFace, offline)", "Edge-TTS (voix naturelle)", "gTTS (en ligne)", "espeak-ng (offline)"],
574
  label="Moteur voix",
575
  value="Kokoro (HuggingFace, offline)"
576
  )
577
-
578
-
579
  texte_voix = gr.Textbox(label="Texte voix off", lines=4,
580
  value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
581
  texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
@@ -586,7 +466,6 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
586
  srt_out = gr.File(label="Sous-titres .srt")
587
  statut = gr.Markdown()
588
 
589
- # Onglet gestion
590
  with gr.Tab("Gestion & Assemblage"):
591
  gr.Markdown("### 🗂 Gestion des capsules")
592
  liste = gr.Dataframe(
@@ -610,22 +489,24 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
610
  sortie_finale = gr.Video(label="Vidéo finale")
611
  btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])
612
 
613
- # Fonction principale pour Gradio
614
- def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, vp, vx, pos_p, plein, motor, langue):
615
- vid, msg, srt = build_capsule(t, st, tv, te, th,
616
- img, logo, pos_logo, fmode,
617
- vp, vx, pos_p, plein,
618
- motor, langue)
619
- return vid, srt, msg, table_capsules()
 
 
620
 
621
  btn.click(
622
  creer_capsule_ui,
623
  [titre, sous_titre, texte_voix, texte_ecran, theme,
624
  image_fond, fond_mode, logo_path, logo_pos,
625
- video_presentateur, voix_type, position_presentateur,
626
  plein, moteur_voix, langue],
627
  [sortie, srt_out, statut, liste]
628
  )
629
 
630
- print("🚀 Lancement de l'interface SadTalker FR/NL…")
631
- demo.launch(share=True, debug=True)
 
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"))
23
+ THEMES = cfg["themes"]
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]},
31
+ "Violet Élégant": {"primary": [74, 20, 140], "secondary": [103, 58, 183]},
32
+ }
33
+ FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
34
+ FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
35
 
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):
43
+ try:
44
+ data = json.load(open(manifest_path, "r", encoding="utf-8"))
45
+ if isinstance(data, dict) and "capsules" in data:
46
+ capsules = data["capsules"]
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 = []
59
  for para in text.split("\n"):
60
  current = []
 
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 (Kokoro par défaut, gTTS en secours)
88
  # ============================================================
89
+ kokoro_pipeline = None # lazy load
90
+
91
+ def _get_kokoro():
92
+ global kokoro_pipeline
93
+ if kokoro_pipeline is None:
94
+ from transformers import pipeline
95
+ # nécessite transformers récent + onnxruntime + soundfile
96
+ kokoro_pipeline = pipeline("text-to-speech", model="onnx-community/Kokoro-82M-v1.0-ONNX")
97
+ return kokoro_pipeline
98
+
99
+ def tts_kokoro(text: str, langue: str = "fr") -> str:
100
+ import soundfile as sf
101
+ out = os.path.join(TMP_DIR, f"kokoro_{uuid.uuid4().hex}.wav")
102
+ try:
103
+ kokoro = _get_kokoro()
104
+ result = kokoro(text)
105
+ sf.write(out, result["audio"], result["sampling_rate"])
106
+ return out
107
+ except Exception as e:
108
+ # Fallback gTTS si problème
109
+ return tts_gtts(text, lang=langue)
110
 
111
+ def tts_gtts(text: str, lang: str = "fr") -> str:
 
 
 
 
 
 
 
112
  from gtts import gTTS
113
+ out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
114
  gTTS(text=text, lang=lang).save(out)
115
  return out
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def _normalize_audio_to_wav(in_path: str) -> str:
118
+ # Convertit n'importe quel format (mp3/wav) en WAV standard (44.1kHz stéréo)
119
+ from pydub import AudioSegment
120
  wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
121
  snd = AudioSegment.from_file(in_path)
122
  snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
 
124
  return wav_path
125
 
126
  # ============================================================
127
+ # FOND / GRAPHISME (PIL rapide)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # ============================================================
129
  def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
130
+ c = THEMES[theme]
131
+ primary = tuple(c["primary"]); secondary = tuple(c["secondary"])
132
+ bg = Image.new("RGB", (W, H), primary)
133
  if img_fond and os.path.exists(img_fond):
134
  img = Image.open(img_fond).convert("RGB")
135
  if fond_mode == "plein écran":
136
  img = img.resize((W, H))
137
  img = img.filter(ImageFilter.GaussianBlur(1))
138
+ overlay = Image.new("RGBA", (W, H), (*primary, 90))
139
  bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
140
  elif fond_mode == "moitié gauche":
141
  img = img.resize((W//2, H))
142
  mask = Image.linear_gradient("L").resize((W//2, H))
143
+ color = Image.new("RGB", (W//2, H), primary)
144
  comp = Image.composite(img, color, ImageOps.invert(mask))
145
  bg.paste(comp, (0, 0))
146
  elif fond_mode == "moitié droite":
147
  img = img.resize((W//2, H))
148
  mask = Image.linear_gradient("L").resize((W//2, H))
149
+ color = Image.new("RGB", (W//2, H), primary)
150
  comp = Image.composite(color, img, mask)
151
  bg.paste(comp, (W//2, 0))
152
  elif fond_mode == "moitié bas":
153
  img = img.resize((W, H//2))
154
  mask = Image.linear_gradient("L").rotate(90).resize((W, H//2))
155
+ color = Image.new("RGB", (W, H//2), primary)
156
  comp = Image.composite(color, img, mask)
157
  bg.paste(comp, (0, H//2))
158
 
159
  draw = ImageDraw.Draw(bg)
160
  f_title = ImageFont.truetype(FONT_BOLD, 84)
161
+ f_sub = ImageFont.truetype(FONT_REG, 44)
162
+ f_text = ImageFont.truetype(FONT_REG, 40)
163
  f_small = ImageFont.truetype(FONT_REG, 30)
164
+
165
+ draw.rectangle([(0, 0), (W, 96)], fill=secondary)
166
+ draw.rectangle([(0, H-96), (W, H)], fill=secondary)
167
+
168
+ _draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
169
+ _draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small)
170
+ _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
171
+ _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub)
172
+
173
  y = SAFE_Y_TOP + 200
174
  for line in texte_ecran.split("\n"):
175
+ for l in _wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw):
176
+ _draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
177
  y += 55
178
+
179
  if logo_path and os.path.exists(logo_path):
180
  logo = Image.open(logo_path).convert("RGBA")
181
+ logo.thumbnail((260, 260))
182
  lw, lh = logo.size
183
+ if logo_pos == "haut-gauche":
184
+ pos = (50, 50)
185
+ elif logo_pos == "haut-droite":
186
+ pos = (W - lw - 50, 50)
187
+ else:
188
+ pos = ((W - lw)//2, 50)
189
  bg.paste(logo, pos, logo)
190
+
191
  out = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
192
  bg.save(out)
193
  return out
194
 
195
  # ============================================================
196
+ # SadTalker appel subprocess (image -> visage animé)
197
+ # ============================================================
198
+ def _check_sadtalker_ready() -> Optional[str]:
199
+ base = os.path.join(ROOT, "SadTalker")
200
+ if not os.path.isdir(base):
201
+ return "Dossier SadTalker manquant. Ajoutez 'SadTalker/' à la racine du Space (voir README)."
202
+ ck = os.path.join(base, "checkpoints")
203
+ needed = [
204
+ "audio2exp.pt",
205
+ "GFPGANv1.4.pth",
206
+ "epoch_20.pth",
207
+ "mapping_00229-model.pth.tar",
208
+ "shape_predictor_68_face_landmarks.dat",
209
+ ]
210
+ missing = [f for f in needed if not os.path.exists(os.path.join(ck, f))]
211
+ if missing:
212
+ return "Checkpoints SadTalker manquants: " + ", ".join(missing)
213
+ return None
214
+
215
+ def generate_sadtalker_video(image_path, audio_path, output_dir=TMP_DIR, fps=25) -> Optional[str]:
216
+ err = _check_sadtalker_ready()
217
+ if err:
218
+ # Pas d’échec brutal : on renvoie None (le fond seul sera utilisé)
219
+ print(f"[SadTalker] {err}")
220
+ return None
221
+ try:
222
+ os.makedirs(output_dir, exist_ok=True)
223
+ out_path = os.path.join(output_dir, f"sadtalker_{uuid.uuid4().hex[:6]}.mp4")
224
+ cmd = [
225
+ "python", "inference.py",
226
+ "--driven_audio", audio_path,
227
+ "--source_image", image_path,
228
+ "--result_dir", output_dir,
229
+ "--still", "--enhancer", "gfpgan",
230
+ "--fps", str(fps),
231
+ ]
232
+ subprocess.run(cmd, cwd=os.path.join(ROOT, "SadTalker"), check=True)
233
+ # Récupérer le dernier mp4 créé
234
+ candidates = [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".mp4")]
235
+ latest = max(candidates, key=os.path.getctime) if candidates else None
236
+ if latest:
237
+ # Harmoniser le nom
238
+ shutil.move(latest, out_path)
239
+ return out_path
240
+ return None
241
+ except Exception as e:
242
+ print("[SadTalker] Erreur:", e)
243
+ return None
244
+
245
+ # ============================================================
246
+ # SOUS-TITRES .SRT
247
  # ============================================================
248
+ def write_srt(text, duration):
249
+ parts = re.split(r'(?<=[\.!?])\s+', text.strip())
250
+ parts = [p for p in parts if p]
251
+ total = len("".join(parts)) or 1
252
+ cur = 0.0
253
+ srt = []
254
+ for i, p in enumerate(parts, 1):
255
+ prop = len(p)/total
256
+ start = cur
257
+ end = min(duration, cur + duration*prop)
258
+ cur = end
259
+ def ts(t):
260
+ m, s = divmod(t, 60)
261
+ h, m = divmod(m, 60)
262
+ return f"{int(h):02}:{int(m):02}:{int(s):02},000"
263
+ srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""]
264
+ path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
265
+ open(path, "w", encoding="utf-8").write("\n".join(srt))
266
+ return path
267
+
268
+ # ============================================================
269
+ # EXPORT VIDÉO (MoviePy — imports différés)
270
+ # ============================================================
271
+ def _write_video_with_fallback(final_clip, out_path_base, fps=25):
272
  attempts = [
273
  {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"},
274
+ {"ext": ".mp4", "codec": "mpeg4", "audio_codec": "aac"},
275
+ {"ext": ".mp4", "codec": "libx264","audio_codec": "libmp3lame"},
 
 
 
 
 
 
 
276
  ]
277
+ ffmpeg_params = ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "-shortest"]
278
  last_err = None
279
  for i, opt in enumerate(attempts, 1):
280
  out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"]
281
  try:
 
282
  final_clip.write_videofile(
283
  out,
284
  fps=fps,
285
  codec=opt["codec"],
286
  audio_codec=opt["audio_codec"],
287
  audio=True,
288
+ ffmpeg_params=ffmpeg_params,
289
  logger=None,
290
  threads=1,
291
  )
292
  if os.path.exists(out) and os.path.getsize(out) > 150000:
293
  return out
294
  except Exception as e:
295
+ last_err = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
296
+ raise RuntimeError(last_err or "FFmpeg a échoué")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  # ============================================================
299
+ # BUILD CAPSULE Pipeline complet
300
  # ============================================================
301
  def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
302
  image_fond=None, logo_path=None, logo_pos="haut-gauche",
303
  fond_mode="plein écran",
304
+ image_presentateur=None, voix_type="Féminine",
305
  position_presentateur="bottom-right", plein=False,
306
+ moteur_voix="Kokoro (HuggingFace, offline)", langue="fr"):
307
+
308
+ # 1) TTS
309
+ engine = "Kokoro" if moteur_voix.startswith("Kokoro") else ("gTTS" if moteur_voix.startswith("gTTS") else "Kokoro")
310
+ audio_mp = tts_kokoro(texte_voix, langue=langue) if engine == "Kokoro" else tts_gtts(texte_voix, lang=langue)
311
+ audio_wav = _normalize_audio_to_wav(audio_mp)
312
+
313
+ # 2) Fond (PIL)
314
+ fond_path = make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, image_fond, fond_mode)
315
+
316
+ # 3) MoviePy (imports lents ici seulement)
317
+ from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, VideoFileClip
318
+ import moviepy.video.fx.all as vfx
319
+
320
+ audio = AudioFileClip(audio_wav)
321
+ dur = float(audio.duration or 5.0)
322
+ target_fps = 25
323
+ bg = ImageClip(fond_path).set_duration(dur)
324
+
325
+ # 4) SadTalker (optionnel)
326
+ clips = [bg]
327
+ if image_presentateur and os.path.exists(image_presentateur):
328
+ vpath = generate_sadtalker_video(image_presentateur, audio_wav, fps=target_fps)
329
+ if vpath and os.path.exists(vpath):
330
+ v = VideoFileClip(vpath).without_audio().fx(vfx.loop, duration=dur)
331
+ if plein:
332
+ v = v.resize((W, H))
333
+ v = v.set_position(("center", "center"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  else:
335
+ v = v.resize(width=520)
336
+ pos_map = {
337
+ "bottom-right": ("right", "bottom"),
338
+ "bottom-left": ("left", "bottom"),
339
+ "top-right": ("right", "top"),
340
+ "top-left": ("left", "top"),
341
+ "center": ("center", "center"),
342
+ }
343
+ v = v.set_position(pos_map.get(position_presentateur, ("right", "bottom")))
344
+ clips.append(v)
345
+
346
+ # 5) Composition + export
347
+ final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
348
+ name = _safe_name(f"{titre}_{langue}")
349
+ out_base = os.path.join(OUT_DIR, name)
350
+ out = _write_video_with_fallback(final, out_base, fps=target_fps)
351
+
352
+ # 6) SRT + manifest
353
+ srt_path = write_srt(texte_voix, dur)
354
+ capsules.append({
355
+ "file": out,
356
+ "title": titre,
357
+ "langue": langue,
358
+ "voice": voix_type,
359
+ "theme": theme,
360
+ "duration": round(dur, 1)
361
+ })
362
+ _save_manifest()
363
+
364
+ # Nettoyage
365
+ try:
366
+ audio.close(); final.close(); bg.close()
367
+ if os.path.exists(audio_mp): os.remove(audio_mp)
368
+ if os.path.exists(audio_wav): os.remove(audio_wav)
369
+ except Exception:
370
+ pass
371
+ gc.collect()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
+ return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {voix_type})", srt_path
374
 
375
  # ============================================================
376
+ # GESTION / ASSEMBLAGE
377
  # ============================================================
378
  def table_capsules():
379
+ import os
380
  return [[i+1, c["title"], c.get("langue","fr").upper(),
381
+ f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])]
 
382
  for i, c in enumerate(capsules)]
383
 
 
384
  def assemble_final():
385
  if not capsules:
386
  return None, "❌ Aucune capsule."
387
+ from moviepy.editor import VideoFileClip
388
+ from moviepy.video.compositing.concatenate import concatenate_videoclips
389
  clips = [VideoFileClip(c["file"]) for c in capsules]
 
390
  try:
391
+ out = _write_video_with_fallback(concatenate_videoclips(clips, method="compose"),
392
+ os.path.join(OUT_DIR, _safe_name("VIDEO_COMPLETE")), fps=25)
393
  return out, f"🎉 Vidéo finale prête ({len(capsules)} capsules)."
394
  finally:
395
  for c in clips:
396
  try: c.close()
397
  except: pass
 
 
 
398
 
399
  def supprimer_capsule(index):
400
  try:
 
404
  if os.path.exists(fichier):
405
  os.remove(fichier)
406
  del capsules[idx]
407
+ _save_manifest()
408
  return f"🗑 Capsule supprimée : {fichier}", table_capsules()
409
  else:
410
  return "⚠️ Index invalide.", table_capsules()
411
  except Exception as e:
412
  return f"❌ Erreur lors de la suppression : {e}", table_capsules()
413
 
 
414
  def deplacer_capsule(index, direction):
415
  try:
416
  idx = int(index) - 1
 
418
  capsules[idx - 1], capsules[idx] = capsules[idx], capsules[idx - 1]
419
  elif direction == "down" and idx < len(capsules) - 1:
420
  capsules[idx + 1], capsules[idx] = capsules[idx], capsules[idx + 1]
421
+ _save_manifest()
422
  return f"🔁 Capsule déplacée {direction}.", table_capsules()
423
  except Exception as e:
424
  return f"❌ Erreur de déplacement : {e}", table_capsules()
425
 
 
426
  # ============================================================
427
+ # UI GRADIO
428
  # ============================================================
429
+ with gr.Blocks(title="Créateur de Capsules CPAS – SadTalker + Kokoro",
430
+ theme=gr.themes.Soft()) as demo:
431
+
432
+ gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version complète (SadTalker + Kokoro)")
433
+ gr.Markdown("**Astuce** : pour un démarrage instantané, chargez le dossier `SadTalker/checkpoints/` dans le Space (voir README).")
434
 
435
  with gr.Tab("Créer une capsule"):
436
  with gr.Row():
 
441
  logo_path = gr.Image(label="🏛 Logo", type="filepath")
442
  logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"],
443
  label="Position logo", value="haut-gauche")
444
+ image_presentateur = gr.Image(label="🧑‍🎨 Image du présentateur (portrait pour SadTalker)", type="filepath")
 
445
  position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
446
  label="Position", value="bottom-right")
447
  plein = gr.Checkbox(label="Plein écran présentateur", value=False)
448
  with gr.Column():
449
  titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp")
450
  sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen")
451
+ theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel")
452
  langue = gr.Radio(["fr","nl"], label="Langue de la voix", value="fr")
453
  voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
454
  moteur_voix = gr.Radio(
455
+ ["Kokoro (HuggingFace, offline)", "gTTS (en ligne)"],
456
  label="Moteur voix",
457
  value="Kokoro (HuggingFace, offline)"
458
  )
 
 
459
  texte_voix = gr.Textbox(label="Texte voix off", lines=4,
460
  value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
461
  texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
 
466
  srt_out = gr.File(label="Sous-titres .srt")
467
  statut = gr.Markdown()
468
 
 
469
  with gr.Tab("Gestion & Assemblage"):
470
  gr.Markdown("### 🗂 Gestion des capsules")
471
  liste = gr.Dataframe(
 
489
  sortie_finale = gr.Video(label="Vidéo finale")
490
  btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])
491
 
492
+ def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, ip, vx, pos_p, plein, motor, lang):
493
+ try:
494
+ vid, msg, srt = build_capsule(t, st, tv, te, th,
495
+ img, logo, pos_logo, fmode,
496
+ ip, vx, pos_p, plein,
497
+ motor, lang)
498
+ return vid, srt, msg, table_capsules()
499
+ except Exception as e:
500
+ return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules()
501
 
502
  btn.click(
503
  creer_capsule_ui,
504
  [titre, sous_titre, texte_voix, texte_ecran, theme,
505
  image_fond, fond_mode, logo_path, logo_pos,
506
+ image_presentateur, voix_type, position_presentateur,
507
  plein, moteur_voix, langue],
508
  [sortie, srt_out, statut, liste]
509
  )
510
 
511
+ if __name__ == "__main__":
512
+ demo.launch()