File size: 30,072 Bytes
ab9c83b
30038ad
ab9c83b
84f0004
30038ad
8056606
ab9c83b
8056606
30038ad
8056606
30038ad
f0ab94e
 
 
 
 
 
 
 
 
1540745
30038ad
 
 
 
 
 
1540745
8056606
 
 
 
 
 
 
1540745
8056606
 
 
 
 
 
 
30038ad
 
 
 
1540745
30038ad
 
8056606
 
 
 
 
 
 
30038ad
8056606
 
 
782bbb0
30038ad
1540745
30038ad
8056606
30038ad
 
 
 
 
1540745
 
 
 
 
30038ad
 
 
 
 
 
 
 
 
8056606
30038ad
8056606
30038ad
 
8056606
30038ad
 
 
69d26fa
1540745
69d26fa
1540745
260dbde
1540745
ab9c83b
 
 
 
 
 
1540745
 
643d6ec
1540745
69d26fa
 
 
643d6ec
69d26fa
 
643d6ec
69d26fa
 
1540745
69d26fa
643d6ec
69d26fa
643d6ec
 
69d26fa
 
 
1540745
69d26fa
 
643d6ec
 
 
8056606
c57fa68
1540745
 
69d26fa
 
7c81ff4
643d6ec
7c81ff4
643d6ec
c57fa68
f58942a
c57fa68
 
 
 
f1a17b9
1540745
 
 
 
 
 
 
 
 
 
 
 
f0b91d5
1540745
f0b91d5
1540745
 
 
 
 
f0b91d5
1540745
 
 
260dbde
1540745
 
 
 
 
 
 
 
 
 
30038ad
 
1540745
ab9c83b
30038ad
 
 
 
 
 
 
1540745
30038ad
 
ca0e393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30038ad
ab9c83b
84f0004
ab9c83b
84f0004
 
 
 
 
ab9c83b
ca0e393
 
 
 
 
84f0004
ca0e393
84f0004
 
 
ca0e393
84f0004
 
ca0e393
84f0004
 
f0ab94e
84f0004
ca0e393
f0ab94e
 
 
84f0004
ca0e393
f0ab94e
ca0e393
84f0004
 
 
 
 
 
 
 
 
ca0e393
84f0004
ca0e393
ab9c83b
ca0e393
 
ab9c83b
 
8056606
1540745
30038ad
8056606
1540745
 
 
8056606
 
1540745
 
 
 
8056606
1540745
 
 
 
 
8056606
 
 
 
 
1540745
8056606
 
30038ad
1540745
 
 
30038ad
1540745
 
 
 
30038ad
1540745
 
 
 
 
 
 
 
 
 
 
 
30038ad
1540745
 
30038ad
 
84f0004
30038ad
 
 
1540745
84f0004
1540745
eded3db
8056606
ab9c83b
 
 
 
 
 
 
 
 
 
260dbde
ab9c83b
1540745
ab9c83b
1540745
ca0e393
 
1540745
 
ca0e393
 
 
 
 
 
 
 
 
 
 
 
1540745
ab9c83b
 
ca0e393
ab9c83b
6f8b96c
ab9c83b
 
 
392d3ee
ca0e393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f8b96c
84f0004
 
9c460aa
84f0004
 
 
 
 
 
 
ca0e393
84f0004
9c460aa
ca0e393
9c460aa
ca0e393
ab9c83b
 
8056606
 
1540745
 
8056606
ab9c83b
 
8056606
 
 
 
ab9c83b
8056606
 
 
 
 
ca0e393
1540745
 
 
 
9c460aa
84f0004
9c460aa
 
 
 
1540745
ca0e393
1540745
 
ca0e393
30038ad
8056606
30038ad
 
8056606
30038ad
8056606
30038ad
 
 
 
 
8056606
 
30038ad
 
8056606
 
30038ad
 
 
 
 
 
 
 
 
 
 
 
 
 
8056606
30038ad
 
 
 
1540745
30038ad
 
 
 
 
 
 
 
8056606
30038ad
 
1540745
30038ad
 
8056606
30038ad
643d6ec
 
84f0004
1540745
 
84f0004
 
30038ad
 
 
 
 
 
1540745
30038ad
1540745
 
eded3db
 
30038ad
ab9c83b
30038ad
 
1540745
 
8056606
ab9c83b
 
1540745
 
 
ab9c83b
 
1540745
ab9c83b
f58942a
c57fa68
 
1540745
 
f58942a
ab9c83b
1540745
 
ab9c83b
 
84f0004
ab9c83b
84f0004
1540745
ab9c83b
 
30038ad
 
 
 
 
ab9c83b
30038ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1540745
30038ad
 
 
1540745
30038ad
 
 
 
 
84f0004
8056606
ab9c83b
 
84f0004
ab9c83b
1540745
8056606
 
30038ad
1540745
 
 
 
eded3db
ab9c83b
1540745
 
 
8056606
84f0004
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
# app.py
# ============================================================
# CPAS Bruxelles — Créateur de Capsules (Gradio + Kokoro + SadTalker)
# Version modifiée pour utiliser une vidéo de présentateur au lieu d'une image
# ============================================================

import os, json, re, uuid, shutil, traceback, gc, subprocess
from typing import Optional
import gradio as gr
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps

# ============================================================
# PATCH MOVIEPY - Correction ANTIALIAS
# ============================================================

import PIL.Image
# Remplacer ANTIALIAS par LANCZOS (nouveau nom dans Pillow >= 10.0.0)
if not hasattr(PIL.Image, 'ANTIALIAS'):
    PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
    
# ---------- Config statique ----------
ROOT = os.getcwd()
OUT_DIR = os.path.join(ROOT, "export")
TMP_DIR = os.path.join(ROOT, "_tmp_capsules")
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(TMP_DIR, exist_ok=True)

# Charger config externe
CONFIG_PATH = os.path.join(ROOT, "app_config.json")
if os.path.exists(CONFIG_PATH):
    cfg = json.load(open(CONFIG_PATH, "r", encoding="utf-8"))
    THEMES = cfg["themes"]
    FONT_REG = cfg["font_paths"]["regular"]
    FONT_BOLD = cfg["font_paths"]["bold"]
else:
    # Valeurs de secours
    THEMES = {
        "Bleu Professionnel": {"primary": [0, 82, 147], "secondary": [0, 126, 200]},
        "Vert Gouvernemental": {"primary": [0, 104, 55], "secondary": [0, 155, 119]},
        "Violet Élégant": {"primary": [74, 20, 140], "secondary": [103, 58, 183]},
    }
    FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
    FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"

W, H = 1920, 1080
MARGIN_X, SAFE_Y_TOP = 140, 140

# ---------- État runtime ----------
capsules = []
manifest_path = os.path.join(OUT_DIR, "manifest.json")
if os.path.exists(manifest_path):
    try:
        data = json.load(open(manifest_path, "r", encoding="utf-8"))
        if isinstance(data, dict) and "capsules" in data:
            capsules = data["capsules"]
    except Exception:
        pass

def _save_manifest():
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2)

# ============================================================
# OUTILS GÉNÉRAUX (rapides)
# ============================================================
def _wrap_text(text, font, max_width, draw):
    lines = []
    for para in text.split("\n"):
        current = []
        for word in para.split(" "):
            test = " ".join(current + [word])
            try:
                w = draw.textlength(test, font=font)
            except AttributeError:
                bbox = draw.textbbox((0, 0), test, font=font)
                w = bbox[2] - bbox[0]
            if w <= max_width or not current:
                current.append(word)
            else:
                lines.append(" ".join(current))
                current = [word]
        if current:
            lines.append(" ".join(current))
    return lines

def _draw_text_shadow(draw, xy, text, font, fill=(255, 255, 255)):
    x, y = xy
    draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0))
    draw.text((x, y), text, font=font, fill=fill)

def _safe_name(stem, ext=".mp4"):
    stem = re.sub(r"[^\w\-]+", "_", stem)[:40]
    return f"{stem}_{uuid.uuid4().hex[:6]}{ext}"

# ============================================================
# SYNTHÈSE VOCALE — Edge-TTS multivoix (FR/NL) + gTTS fallback
# ============================================================
import asyncio
import edge_tts
from pydub import AudioSegment
import soundfile as sf

# ============================================================
# 🔊 CHARGEMENT DYNAMIQUE DES VOIX EDGE-TTS (FR/NL)
# ============================================================

EDGE_VOICES = {}

async def fetch_edge_voices_async():
    """Charge dynamiquement toutes les voix FR/NL depuis Edge-TTS."""
    global EDGE_VOICES
    try:
        voices = await edge_tts.list_voices()
        filtered = [v for v in voices if v["Locale"].startswith(("fr", "nl"))]
        filtered.sort(key=lambda v: (v["Locale"], v["Gender"], v["ShortName"]))
        EDGE_VOICES = {
            f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v["ShortName"]
            for v in filtered
        }
        print(f"[Edge-TTS] {len(EDGE_VOICES)} voix FR/NL chargées.")
    except Exception as e:
        print(f"[Edge-TTS] Erreur chargement voix : {e}")
        EDGE_VOICES.update({
            "fr-FR-DeniseNeural - fr-FR (Female)": "fr-FR-DeniseNeural",
            "nl-NL-MaaikeNeural - nl-NL (Female)": "nl-NL-MaaikeNeural",
        })

def init_edge_voices():
    """Démarre le chargement asynchrone sans bloquer Gradio."""
    try:
        loop = asyncio.get_event_loop()
        loop.create_task(fetch_edge_voices_async())
    except RuntimeError:
        asyncio.run(fetch_edge_voices_async())

def get_edge_voices(lang="fr"):
    """Retourne les voix déjà chargées (selon la langue)."""
    global EDGE_VOICES
    if not EDGE_VOICES:
        init_edge_voices()
    if lang == "fr":
        return [v for k, v in EDGE_VOICES.items() if k.startswith("fr-")]
    elif lang == "nl":
        return [v for k, v in EDGE_VOICES.items() if k.startswith("nl-")]
    return list(EDGE_VOICES.values())

async def _edge_tts_async(text, voice, outfile):
    communicate = edge_tts.Communicate(text, voice)
    await communicate.save(outfile)
    return outfile

def tts_edge(text: str, voice: str = "fr-FR-DeniseNeural") -> str:
    """Génère un fichier WAV avec Edge-TTS (et fallback gTTS)."""
    out_mp3 = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.mp3")
    try:
        # Correction boucle asyncio (HF/Gradio)
        try:
            loop = asyncio.get_event_loop()
            if loop.is_running():
                import nest_asyncio
                nest_asyncio.apply()
        except RuntimeError:
            pass

        asyncio.run(_edge_tts_async(text, voice, out_mp3))

        # Conversion WAV pour compatibilité MoviePy
        out_wav = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.wav")
        AudioSegment.from_file(out_mp3).export(out_wav, format="wav")
        os.remove(out_mp3)
        return out_wav

    except Exception as e:
        print(f"[Edge-TTS] Erreur : {e} → fallback gTTS")
        return tts_gtts(text, lang="fr" if voice.startswith("fr") else "nl")

def tts_gtts(text: str, lang: str = "fr") -> str:
    """Fallback via Google Text-to-Speech (gTTS)."""
    from gtts import gTTS
    out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
    gTTS(text=text, lang=lang).save(out)
    # Conversion en WAV pour compatibilité
    out_wav = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.wav")
    AudioSegment.from_file(out).export(out_wav, format="wav")
    os.remove(out)
    return out_wav

def _normalize_audio_to_wav(in_path: str) -> str:
    # Convertit n'importe quel format (mp3/wav) en WAV standard (44.1kHz stéréo)
    from pydub import AudioSegment
    wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
    snd = AudioSegment.from_file(in_path)
    snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
    snd.export(wav_path, format="wav")
    return wav_path

# ============================================================
# FOND / GRAPHISME (PIL rapide)
# ============================================================
def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
    try:
        print(f"[Fond] Création du fond - Thème: {theme}")
        
        # Validation des entrées
        if not titre:
            titre = "Titre CPAS"
        if not theme or theme not in THEMES:
            theme = list(THEMES.keys())[0]
            print(f"[Fond] Thème invalide, utilisation par défaut: {theme}")
        
        c = THEMES[theme]
        primary = tuple(c["primary"])
        secondary = tuple(c["secondary"])
        
        # Créer le fond de base
        bg = Image.new("RGB", (W, H), primary)
        draw = ImageDraw.Draw(bg)
        
        # Application de l'image de fond si fournie
        if img_fond and os.path.exists(img_fond):
            try:
                img = Image.open(img_fond).convert("RGB")
                if fond_mode == "plein écran":
                    img = img.resize((W, H))
                    img = img.filter(ImageFilter.GaussianBlur(1))
                    overlay = Image.new("RGBA", (W, H), (*primary, 90))
                    bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
                elif fond_mode == "moitié gauche":
                    img = img.resize((W//2, H))
                    mask = Image.linear_gradient("L").resize((W//2, H))
                    color = Image.new("RGB", (W//2, H), primary)
                    comp = Image.composite(img, color, ImageOps.invert(mask))
                    bg.paste(comp, (0, 0))
                elif fond_mode == "moitié droite":
                    img = img.resize((W//2, H))
                    mask = Image.linear_gradient("L").resize((W//2, H))
                    color = Image.new("RGB", (W//2, H), primary)
                    comp = Image.composite(color, img, mask)
                    bg.paste(comp, (W//2, 0))
                elif fond_mode == "moitié bas":
                    img = img.resize((W, H//2))
                    mask = Image.linear_gradient("L").rotate(90).resize((W, H//2))
                    color = Image.new("RGB", (W, H//2), primary)
                    comp = Image.composite(color, img, mask)
                    bg.paste(comp, (0, H//2))
                draw = ImageDraw.Draw(bg)  # Recréer le draw après modification
                print(f"[Fond] Image de fond appliquée: {fond_mode}")
            except Exception as e:
                print(f"[Fond] Erreur image de fond: {e}")
        
        # Chargement des polices avec fallback
        try:
            f_title = ImageFont.truetype(FONT_BOLD, 84)
            f_sub = ImageFont.truetype(FONT_REG, 44)
            f_text = ImageFont.truetype(FONT_REG, 40)
            f_small = ImageFont.truetype(FONT_REG, 30)
        except Exception as e:
            print(f"[Fond] Erreur polices, utilisation par défaut: {e}")
            # Polices par défaut
            f_title = ImageFont.load_default()
            f_sub = ImageFont.load_default()
            f_text = ImageFont.load_default()
            f_small = ImageFont.load_default()
        
        # Bandes colorées
        draw.rectangle([(0, 0), (W, 96)], fill=secondary)
        draw.rectangle([(0, H-96), (W, H)], fill=secondary)
        
        # Textes
        _draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
        _draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small)
        _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
        _draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub)
        
        # Texte écran avec wrap
        y = SAFE_Y_TOP + 200
        if texte_ecran:
            for line in texte_ecran.split("\n"):
                wrapped_lines = _wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw)
                for l in wrapped_lines:
                    _draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
                    y += 55
        
        # Logo
        if logo_path and os.path.exists(logo_path):
            try:
                logo = Image.open(logo_path).convert("RGBA")
                logo.thumbnail((260, 260))
                lw, lh = logo.size
                if logo_pos == "haut-gauche":
                    pos = (50, 50)
                elif logo_pos == "haut-droite":
                    pos = (W - lw - 50, 50)
                else:  # centre
                    pos = ((W - lw)//2, 50)
                bg.paste(logo, pos, logo)
                print(f"[Fond] Logo appliqué: {logo_pos}")
            except Exception as e:
                print(f"[Fond] Erreur logo: {e}")
        
        # Sauvegarde garantie
        out_path = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
        bg.save(out_path)
        print(f"[Fond] ✅ Fond créé avec succès: {out_path}")
        return out_path
        
    except Exception as e:
        print(f"[Fond] ❌ ERREUR CRITIQUE: {e}")
        print(f"[Fond] Traceback: {traceback.format_exc()}")
        # Fallback absolu
        try:
            emergency_bg = Image.new("RGB", (W, H), (0, 82, 147))
            out_path = os.path.join(TMP_DIR, f"emergency_fond_{uuid.uuid4().hex[:6]}.png")
            emergency_bg.save(out_path)
            print(f"[Fond] ✅ Fond d'urgence créé: {out_path}")
            return out_path
        except Exception as e2:
            print(f"[Fond] ❌ Même le fallback a échoué: {e2}")
            return None

# ============================================================
# SUPPRESSION DE LA PARTIE SADTALKER (plus nécessaire)
# ============================================================
def _prepare_video_presentateur(video_path, audio_duration, position, plein_ecran=False):
    """Prépare la vidéo du présentateur avec la bonne durée et position."""
    from moviepy.editor import VideoFileClip
    import moviepy.video.fx.all as vfx
    
    try:
        print(f"[Video] Chargement: {video_path}")
        if not os.path.exists(video_path):
            print(f"[Video] ❌ Fichier introuvable: {video_path}")
            return None
            
        v = VideoFileClip(video_path).without_audio()
        print(f"[Video] Durée vidéo: {v.duration}s, Audio: {audio_duration}s")
        
        # Ajuster la durée à celle de l'audio
        if v.duration < audio_duration:
            print(f"[Video] Bouclage nécessaire ({v.duration}s -> {audio_duration}s)")
            v = v.fx(vfx.loop, duration=audio_duration)
        elif v.duration > audio_duration:
            print(f"[Video] Découpage nécessaire ({v.duration}s -> {audio_duration}s)")
            v = v.subclip(0, audio_duration)
        
        # Ajuster la taille et la position - AVEC RESIZE SÉCURISÉ
        if plein_ecran:
            print(f"[Video] Mode plein écran")
            # Utiliser resize avec méthode moderne
            v = v.resize(newsize=(W, H))
            v = v.set_position(("center", "center"))
        else:
            print(f"[Video] Mode incrustation, position: {position}")
            # Redimensionner avec méthode moderne
            v = v.resize(width=520)
            pos_map = {
                "bottom-right": ("right", "bottom"),
                "bottom-left": ("left", "bottom"),
                "top-right": ("right", "top"),
                "top-left": ("left", "top"),
                "center": ("center", "center"),
            }
            v = v.set_position(pos_map.get(position, ("right", "bottom")))
        
        print(f"[Video] ✅ Vidéo préparée avec succès")
        return v
        
    except Exception as e:
        print(f"[Video] ❌ Erreur préparation: {e}")
        print(f"[Video] Traceback: {traceback.format_exc()}")
        return None

# ============================================================
# SOUS-TITRES .SRT
# ============================================================
def write_srt(text, duration):
    parts = re.split(r'(?<=[\.!?])\s+', text.strip())
    parts = [p for p in parts if p]
    total = len("".join(parts)) or 1
    cur = 0.0
    srt = []
    for i, p in enumerate(parts, 1):
        prop = len(p)/total
        start = cur
        end = min(duration, cur + duration*prop)
        cur = end
        def ts(t):
            m, s = divmod(t, 60)
            h, m = divmod(m, 60)
            return f"{int(h):02}:{int(m):02}:{int(s):02},000"
        srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""]
    path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
    open(path, "w", encoding="utf-8").write("\n".join(srt))
    return path

# ============================================================
# EXPORT VIDÉO (MoviePy — imports différés)
# ============================================================
def _write_video_with_fallback(final_clip, out_path_base, fps=25):
    attempts = [
        {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"},
        {"ext": ".mp4", "codec": "mpeg4",  "audio_codec": "aac"},
        {"ext": ".mp4", "codec": "libx264","audio_codec": "libmp3lame"},
    ]
    ffmpeg_params = ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "-shortest"]
    last_err = None
    for i, opt in enumerate(attempts, 1):
        out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"]
        try:
            final_clip.write_videofile(
                out,
                fps=fps,
                codec=opt["codec"],
                audio_codec=opt["audio_codec"],
                audio=True,
                ffmpeg_params=ffmpeg_params,
                logger=None,
                threads=1,
            )
            if os.path.exists(out) and os.path.getsize(out) > 150000:
                return out
        except Exception as e:
            last_err = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
    raise RuntimeError(last_err or "FFmpeg a échoué")

# ============================================================
# BUILD CAPSULE — Pipeline complet (modifié pour vidéo présentateur)
# ============================================================
def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
                  image_fond=None, logo_path=None, logo_pos="haut-gauche",
                  fond_mode="plein écran",
                  video_presentateur=None, voix_type="Féminine",
                  position_presentateur="bottom-right", plein=False,
                  moteur_voix="Edge-TTS (recommandé)", langue="fr", speaker=None):

    # 1) TTS (Edge multivoix ou fallback)
    try:
        audio_mp = tts_edge(texte_voix, voice=speaker or ("fr-FR-DeniseNeural" if langue == "fr" else "nl-NL-MaaikeNeural"))
    except Exception as e:
        print(f"[Capsule] Erreur TTS Edge ({e}), fallback gTTS.")
        audio_mp = tts_gtts(texte_voix, lang=langue)
    
    # S'assurer qu'on a un WAV
    audio_wav = audio_mp
    if not audio_mp.lower().endswith(".wav"):
        try:
            audio_wav = _normalize_audio_to_wav(audio_mp)
        except Exception as e:
            print(f"[Audio] Normalisation échouée ({e}), on garde {audio_mp}")

    # 2) Fond (PIL) - SECTION CORRIGÉE
    print(f"[Capsule] Génération du fond...")
    fond_path = make_background(titre, sous_titre, texte_ecran, theme,
                                logo_path, logo_pos, image_fond, fond_mode)
    
    # VÉRIFICATION ROBUSTE DU FOND
    if fond_path is None:
        print(f"[Capsule] ❌ fond_path est None, création d'urgence")
        try:
            emergency_bg = Image.new("RGB", (W, H), (0, 82, 147))
            fond_path = os.path.join(TMP_DIR, f"emergency_{uuid.uuid4().hex[:6]}.png")
            emergency_bg.save(fond_path)
            print(f"[Capsule] ✅ Fond d'urgence créé: {fond_path}")
        except Exception as e:
            print(f"[Capsule] ❌ Impossible de créer le fond d'urgence: {e}")
            fond_path = None

    # 3) MoviePy (imports lents ici seulement)
    from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, VideoFileClip
    from moviepy.video.VideoClip import ColorClip
    import moviepy.video.fx.all as vfx

    audio = AudioFileClip(audio_wav)
    dur = float(audio.duration or 5.0)
    target_fps = 25
    
    # CHARGEMENT ROBUSTE DU FOND
    clips = []
    if fond_path and os.path.exists(fond_path):
        try:
            bg = ImageClip(fond_path).set_duration(dur)
            clips.append(bg)
            print(f"[Capsule] ✅ Fond chargé: {fond_path}")
        except Exception as e:
            print(f"[Capsule] ❌ Erreur ImageClip, fallback ColorClip: {e}")
            bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur)
            clips.append(bg)
    else:
        print(f"[Capsule] ❌ Aucun fond valide, utilisation ColorClip")
        bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur)
        clips.append(bg)

    # 4) Vidéo présentateur (au lieu de SadTalker)
    if video_presentateur and os.path.exists(video_presentateur):
        print(f"[Capsule] Vidéo présentateur trouvée: {video_presentateur}")
        v_presentateur = _prepare_video_presentateur(
            video_presentateur, 
            dur, 
            position_presentateur, 
            plein
        )
        if v_presentateur:
            print(f"[Capsule] ✅ Vidéo présentateur ajoutée")
            clips.append(v_presentateur)
        else:
            print(f"[Capsule] ❌ Échec préparation vidéo présentateur")
    else:
        print(f"[Capsule] Aucune vidéo présentateur: {video_presentateur}")

    # 5) Composition + export
    final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
    name = _safe_name(f"{titre}_{langue}")
    out_base = os.path.join(OUT_DIR, name)
    out = _write_video_with_fallback(final, out_base, fps=target_fps)

    # 6) Sous-titres + manifest
    srt_path = write_srt(texte_voix, dur)
    capsules.append({
        "file": out,
        "title": titre,
        "langue": langue,
        "voice": speaker or voix_type,
        "theme": theme,
        "duration": round(dur, 1)
    })
    _save_manifest()

    # 7) Nettoyage CORRIGÉ
    try:
        audio.close()
        final.close()
        bg.close()
        if 'v_presentateur' in locals() and v_presentateur is not None:
            v_presentateur.close()
        if os.path.exists(audio_mp): 
            os.remove(audio_mp)
        if audio_wav != audio_mp and os.path.exists(audio_wav): 
            os.remove(audio_wav)
    except Exception as e:
        print(f"[Clean] Erreur nettoyage: {e}")
    gc.collect()

    return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {speaker or voix_type})", srt_path
# ============================================================
# GESTION / ASSEMBLAGE
# ============================================================
def table_capsules():
    import os
    return [[i+1, c["title"], c.get("langue","fr").upper(),
             f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])]
            for i, c in enumerate(capsules)]

def assemble_final():
    if not capsules:
        return None, "❌ Aucune capsule."
    from moviepy.editor import VideoFileClip
    from moviepy.video.compositing.concatenate import concatenate_videoclips
    clips = [VideoFileClip(c["file"]) for c in capsules]
    try:
        out = _write_video_with_fallback(concatenate_videoclips(clips, method="compose"),
                                         os.path.join(OUT_DIR, _safe_name("VIDEO_COMPLETE")), fps=25)
        return out, f"🎉 Vidéo finale prête ({len(capsules)} capsules)."
    finally:
        for c in clips:
            try: c.close()
            except: pass

def supprimer_capsule(index):
    try:
        idx = int(index) - 1
        if 0 <= idx < len(capsules):
            fichier = capsules[idx]["file"]
            if os.path.exists(fichier):
                os.remove(fichier)
            del capsules[idx]
            _save_manifest()
            return f"🗑 Capsule supprimée : {fichier}", table_capsules()
        else:
            return "⚠️ Index invalide.", table_capsules()
    except Exception as e:
        return f"❌ Erreur lors de la suppression : {e}", table_capsules()

def deplacer_capsule(index, direction):
    try:
        idx = int(index) - 1
        if direction == "up" and idx > 0:
            capsules[idx - 1], capsules[idx] = capsules[idx], capsules[idx - 1]
        elif direction == "down" and idx < len(capsules) - 1:
            capsules[idx + 1], capsules[idx] = capsules[idx], capsules[idx + 1]
        _save_manifest()
        return f"🔁 Capsule déplacée {direction}.", table_capsules()
    except Exception as e:
        return f"❌ Erreur de déplacement : {e}", table_capsules()

# ============================================================
# UI GRADIO
# ============================================================
print("[INIT] Lancement de Gradio...")
init_edge_voices()
with gr.Blocks(title="Créateur de Capsules CPAS – Version avec vidéo présentateur",
               theme=gr.themes.Soft()) as demo:

    gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version avec vidéo présentateur")
    gr.Markdown("**Nouveau** : Utilisez directement une vidéo de présentateur au lieu d'une image.")

    with gr.Tab("Créer une capsule"):
        with gr.Row():
            with gr.Column():
                image_fond = gr.Image(label="🖼 Image de fond", type="filepath")
                fond_mode = gr.Radio(["plein écran", "moitié gauche", "moitié droite", "moitié bas"],
                                     label="Mode d'affichage du fond", value="plein écran")
                logo_path = gr.Image(label="🏛 Logo", type="filepath")
                logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"],
                                    label="Position logo", value="haut-gauche")
                # REMPLACEMENT : Image → Video (sans le paramètre type)
                video_presentateur = gr.Video(label="🎬 Vidéo du présentateur")
                position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
                                                 label="Position", value="bottom-right")
                plein = gr.Checkbox(label="Plein écran présentateur", value=False)
            with gr.Column():
                titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp")
                sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen")
                theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel")
                langue = gr.Radio(["fr", "nl"], label="Langue de la voix", value="fr")
                
                def maj_voix(lang):
                    try:
                        voices = get_edge_voices(lang)
                        return gr.update(choices=voices, value=voices[0] if voices else None)
                    except Exception as e:
                        return gr.update(choices=[], value=None)
                
                speaker_id = gr.Dropdown(
                    label="🎙 Voix Edge-TTS",
                    choices=get_edge_voices("fr"),
                    value="fr-FR-DeniseNeural",
                    info="Liste dynamique des voix Edge-TTS (FR & NL)"
                )

                langue.change(maj_voix, [langue], [speaker_id])

                voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
                moteur_voix = gr.Radio(
                    ["Edge-TTS (recommandé)", "gTTS (fallback)"],
                    label="Moteur voix",
                    value="Edge-TTS (recommandé)"
                )
                texte_voix = gr.Textbox(label="Texte voix off", lines=4,
                                        value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
                texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
                                         value="💊 Aides médicales\n🏥 Soins urgents\n📋 Formalités simplifiées")
                btn = gr.Button("🎬 Créer Capsule", variant="primary")

        sortie = gr.Video(label="Capsule générée")
        srt_out = gr.File(label="Sous-titres .srt")
        statut = gr.Markdown()

    with gr.Tab("Gestion & Assemblage"):
        gr.Markdown("### 🗂 Gestion des capsules")
        liste = gr.Dataframe(
            headers=["N°","Titre","Langue","Durée","Thème","Voix","Fichier"],
            value=table_capsules(),
            interactive=False
        )
        with gr.Row():
            index = gr.Number(label="Index capsule", value=1, precision=0)
            btn_up = gr.Button("⬆️ Monter")
            btn_down = gr.Button("⬇️ Descendre")
            btn_del = gr.Button("🗑 Supprimer")
        message = gr.Markdown()

        btn_up.click(lambda i: deplacer_capsule(i, "up"), [index], [message, liste])
        btn_down.click(lambda i: deplacer_capsule(i, "down"), [index], [message, liste])
        btn_del.click(supprimer_capsule, [index], [message, liste])

        gr.Markdown("### 🎬 Assemblage final")
        btn_asm = gr.Button("🎥 Assembler la vidéo complète", variant="primary")
        sortie_finale = gr.Video(label="Vidéo finale")
        btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])

    def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, vp, vx, pos_p, plein, motor, lang, speaker):
        try:
            vid, msg, srt = build_capsule(t, st, tv, te, th,
                                          img, logo, pos_logo, fmode,
                                          vp, vx, pos_p, plein,
                                          motor, lang, speaker=speaker)
            return vid, srt, msg, table_capsules()
        except Exception as e:
            return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules()

    btn.click(
        creer_capsule_ui,
        [titre, sous_titre, texte_voix, texte_ecran, theme,
         image_fond, fond_mode, logo_path, logo_pos,
         video_presentateur, voix_type, position_presentateur,
         plein, moteur_voix, langue, speaker_id],
        [sortie, srt_out, statut, liste]
    )

if __name__ == "__main__":
    demo.launch()