omarbajouk commited on
Commit
30038ad
·
verified ·
1 Parent(s): b264ed7

Create app.py

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