omarbajouk commited on
Commit
baaf343
·
verified ·
1 Parent(s): a1b5ddd

Upload 7 files

Browse files
Files changed (7) hide show
  1. src/__init__.py +1 -0
  2. src/capsule_builder.py +227 -0
  3. src/config.py +32 -0
  4. src/graphics.py +137 -0
  5. src/tts_utils.py +95 -0
  6. src/ui.py +123 -0
  7. src/video_utils.py +96 -0
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # CapsulesVideoPro package
src/capsule_builder.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json, uuid, gc, traceback
2
+ from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip
3
+ from moviepy.video.VideoClip import ColorClip
4
+
5
+ from .config import OUT_DIR, TMP_DIR, MANIFEST_PATH, W, H
6
+ from .tts_utils import tts_edge, tts_gtts, normalize_audio_to_wav
7
+ from .graphics import make_background
8
+ from .video_utils import prepare_video_presentateur, write_srt, write_video_with_fallback, safe_name
9
+
10
+ capsules = []
11
+
12
+ # Load manifest on import
13
+ if os.path.exists(MANIFEST_PATH):
14
+ try:
15
+ data = json.load(open(MANIFEST_PATH, "r", encoding="utf-8"))
16
+ if isinstance(data, dict) and "capsules" in data:
17
+ capsules.extend(data["capsules"])
18
+ except Exception:
19
+ pass
20
+
21
+ def _save_manifest():
22
+ with open(MANIFEST_PATH, "w", encoding="utf-8") as f:
23
+ json.dump({"capsules": capsules}, f, ensure_ascii=False, indent=2)
24
+
25
+ def build_capsule(titre, sous_titre, texte_voix, texte_ecran, theme,
26
+ image_fond=None, logo_path=None, logo_pos="haut-gauche",
27
+ fond_mode="plein écran",
28
+ video_presentateur=None, voix_type="Féminine",
29
+ position_presentateur="bottom-right", plein=False,
30
+ moteur_voix="Edge-TTS (recommandé)", langue="fr", speaker=None):
31
+
32
+ # 1) TTS
33
+ try:
34
+ audio_mp = tts_edge(texte_voix, voice=speaker or ("fr-FR-DeniseNeural" if langue == "fr" else "nl-NL-MaaikeNeural"))
35
+ except Exception as e:
36
+ print(f"[Capsule] Erreur TTS Edge ({e}), fallback gTTS.")
37
+ audio_mp = tts_gtts(texte_voix, lang=langue)
38
+
39
+ audio_wav = audio_mp
40
+ if not audio_mp.lower().endswith(".wav"):
41
+ try:
42
+ audio_wav = normalize_audio_to_wav(audio_mp)
43
+ except Exception as e:
44
+ print(f"[Audio] Normalisation échouée ({e}), on garde {audio_mp}")
45
+
46
+ # 2) Fond
47
+ print("[Capsule] Génération du fond...")
48
+ fond_path = make_background(titre, sous_titre, texte_ecran, theme,
49
+ logo_path, logo_pos, image_fond, fond_mode)
50
+ if fond_path is None:
51
+ print("[Capsule] ❌ fond_path est None, création d'urgence")
52
+ from PIL import Image
53
+ try:
54
+ emergency_bg = Image.new("RGB", (W, H), (0, 82, 147))
55
+ fond_path = os.path.join(TMP_DIR, f"emergency_{uuid.uuid4().hex[:6]}.png")
56
+ emergency_bg.save(fond_path)
57
+ print(f"[Capsule] ✅ Fond d'urgence créé: {fond_path}")
58
+ except Exception as e:
59
+ print(f"[Capsule] ❌ Impossible de créer le fond d'urgence: {e}")
60
+ fond_path = None
61
+
62
+ # 3) Composition MoviePy
63
+ audio = AudioFileClip(audio_wav)
64
+ dur = float(audio.duration or 5.0)
65
+ target_fps = 25
66
+ clips = []
67
+
68
+ if fond_path and os.path.exists(fond_path):
69
+ try:
70
+ bg = ImageClip(fond_path).set_duration(dur)
71
+ clips.append(bg)
72
+ print(f"[Capsule] ✅ Fond chargé: {fond_path}")
73
+ except Exception as e:
74
+ print(f"[Capsule] ❌ Erreur ImageClip, fallback ColorClip: {e}")
75
+ bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur)
76
+ clips.append(bg)
77
+ else:
78
+ print("[Capsule] ❌ Aucun fond valide, utilisation ColorClip")
79
+ bg = ColorClip(size=(W, H), color=(0, 82, 147)).set_duration(dur)
80
+ clips.append(bg)
81
+
82
+ if video_presentateur and os.path.exists(video_presentateur):
83
+ print(f"[Capsule] Vidéo présentateur trouvée: {video_presentateur}")
84
+ v_presentateur = prepare_video_presentateur(
85
+ video_presentateur, dur, position_presentateur, plein
86
+ )
87
+ if v_presentateur:
88
+ print(f"[Capsule] ✅ Vidéo présentateur ajoutée")
89
+ clips.append(v_presentateur)
90
+ else:
91
+ print(f"[Capsule] ❌ Échec préparation vidéo présentateur")
92
+ else:
93
+ print(f"[Capsule] Aucune vidéo présentateur: {video_presentateur}")
94
+
95
+ final = CompositeVideoClip(clips).set_audio(audio.set_fps(44100))
96
+ name = safe_name(f"{titre}_{langue}")
97
+ out_base = os.path.join(OUT_DIR, name)
98
+ out = write_video_with_fallback(final, out_base, fps=target_fps)
99
+
100
+ srt_path = write_srt(texte_voix, dur)
101
+ capsules.append({
102
+ "file": out,
103
+ "title": titre,
104
+ "langue": langue,
105
+ "voice": speaker or voix_type,
106
+ "theme": theme,
107
+ "duration": round(dur, 1),
108
+ "inputs": {"image_fond": image_fond, "logo": logo_path, "video_presentateur": video_presentateur},
109
+ "texte_voix": texte_voix,
110
+ "texte_ecran": texte_ecran
111
+ })
112
+ _save_manifest()
113
+
114
+ try:
115
+ audio.close()
116
+ final.close()
117
+ bg.close()
118
+ if 'v_presentateur' in locals() and v_presentateur is not None:
119
+ v_presentateur.close()
120
+ if os.path.exists(audio_mp):
121
+ os.remove(audio_mp)
122
+ if audio_wav != audio_mp and os.path.exists(audio_wav):
123
+ os.remove(audio_wav)
124
+ except Exception as e:
125
+ print(f"[Clean] Erreur nettoyage: {e}")
126
+ gc.collect()
127
+
128
+ return out, f"✅ Capsule {langue.upper()} créée ({dur:.1f}s, voix {speaker or voix_type})", srt_path
129
+
130
+ def table_capsules():
131
+ import os
132
+ return [[i+1, c["title"], c.get("langue","fr").upper(),
133
+ f"{c['duration']}s", c["theme"], c["voice"], os.path.basename(c["file"])]
134
+ for i, c in enumerate(capsules)]
135
+
136
+ def assemble_final():
137
+ if not capsules:
138
+ return None, "❌ Aucune capsule."
139
+ from moviepy.editor import VideoFileClip
140
+ from moviepy.video.compositing.concatenate import concatenate_videoclips
141
+ clips = [VideoFileClip(c["file"]) for c in capsules]
142
+ try:
143
+ out = write_video_with_fallback(
144
+ concatenate_videoclips(clips, method="compose"),
145
+ os.path.join(OUT_DIR, "VIDEO_COMPLETE"),
146
+ fps=25,
147
+ )
148
+ return out, f"🎉 Vidéo finale prête ({len(capsules)} capsules)."
149
+ finally:
150
+ for c in clips:
151
+ try: c.close()
152
+ except: pass
153
+
154
+ def supprimer_capsule(index):
155
+ try:
156
+ idx = int(index) - 1
157
+ if 0 <= idx < len(capsules):
158
+ fichier = capsules[idx]["file"]
159
+ if os.path.exists(fichier):
160
+ os.remove(fichier)
161
+ del capsules[idx]
162
+ _save_manifest()
163
+ return f"🗑 Capsule supprimée : {fichier}", table_capsules()
164
+ else:
165
+ return "⚠️ Index invalide.", table_capsules()
166
+ except Exception as e:
167
+ return f"❌ Erreur lors de la suppression : {e}", table_capsules()
168
+
169
+ def deplacer_capsule(index, direction):
170
+ try:
171
+ idx = int(index) - 1
172
+ if direction == "up" and idx > 0:
173
+ capsules[idx - 1], capsules[idx] = capsules[idx], capsules[idx - 1]
174
+ elif direction == "down" and idx < len(capsules) - 1:
175
+ capsules[idx + 1], capsules[idx] = capsules[idx], capsules[idx + 1]
176
+ _save_manifest()
177
+ return f"🔁 Capsule déplacée {direction}.", table_capsules()
178
+ except Exception as e:
179
+ return f"❌ Erreur de déplacement : {e}", table_capsules()
180
+
181
+
182
+ import shutil, time
183
+
184
+ def export_project_zip():
185
+ """Crée un ZIP complet pour toutes les capsules (vidéos, srt, inputs, params, manifest)."""
186
+ try:
187
+ zip_root = os.path.join(TMP_DIR, f"zip_export_{int(time.time())}")
188
+ os.makedirs(zip_root, exist_ok=True)
189
+ # Manifest global
190
+ manifest = {"capsules": capsules}
191
+ with open(os.path.join(zip_root, "manifest.json"), "w", encoding="utf-8") as f:
192
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
193
+
194
+ # Par capsule
195
+ for i, cap in enumerate(capsules, 1):
196
+ cap_dir = os.path.join(zip_root, f"capsule_{i}")
197
+ os.makedirs(os.path.join(cap_dir, "inputs"), exist_ok=True)
198
+
199
+ # copy video + srt if exists
200
+ for key in ["file"]:
201
+ path = cap.get(key)
202
+ if path and os.path.exists(path):
203
+ shutil.copy2(path, os.path.join(cap_dir, os.path.basename(path)))
204
+ # try to find srt next to file if not stored
205
+ if cap.get("file"):
206
+ base = os.path.splitext(os.path.basename(cap["file"]))[0]
207
+ # srt names are not strictly tied; we copy any srt in OUT_DIR too
208
+ for f in os.listdir(OUT_DIR):
209
+ if f.lower().endswith(".srt"):
210
+ shutil.copy2(os.path.join(OUT_DIR, f), os.path.join(cap_dir, f))
211
+
212
+ # inputs
213
+ for k in ["image_fond", "logo", "video_presentateur"]:
214
+ p = cap.get("inputs", {}).get(k) if cap.get("inputs") else None
215
+ if p and os.path.exists(p):
216
+ shutil.copy2(p, os.path.join(cap_dir, "inputs", os.path.basename(p)))
217
+
218
+ # params.json per capsule
219
+ with open(os.path.join(cap_dir, "params.json"), "w", encoding="utf-8") as pf:
220
+ json.dump(cap, pf, ensure_ascii=False, indent=2)
221
+
222
+ # zip
223
+ zip_path = shutil.make_archive(os.path.join(TMP_DIR, f"capsules_export_{int(time.time())}"), "zip", zip_root)
224
+ return zip_path
225
+ except Exception as e:
226
+ print(f"[Export] ❌ Erreur ZIP : {e}")
227
+ return None
src/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json
2
+ import PIL.Image
3
+
4
+ # Pillow ANTIALIAS patch for compatibility with MoviePy
5
+ if not hasattr(PIL.Image, "ANTIALIAS"):
6
+ PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
7
+
8
+ ROOT = os.getcwd()
9
+ OUT_DIR = os.path.join(ROOT, "export")
10
+ TMP_DIR = os.path.join(ROOT, "_tmp_capsules")
11
+ os.makedirs(OUT_DIR, exist_ok=True)
12
+ os.makedirs(TMP_DIR, exist_ok=True)
13
+
14
+ CONFIG_PATH = os.path.join(ROOT, "app_config.json")
15
+ if os.path.exists(CONFIG_PATH):
16
+ cfg = json.load(open(CONFIG_PATH, "r", encoding="utf-8"))
17
+ THEMES = cfg["themes"]
18
+ FONT_REG = cfg["font_paths"]["regular"]
19
+ FONT_BOLD = cfg["font_paths"]["bold"]
20
+ else:
21
+ THEMES = {
22
+ "Bleu Professionnel": {"primary": [0, 82, 147], "secondary": [0, 126, 200]},
23
+ "Vert Gouvernemental": {"primary": [0, 104, 55], "secondary": [0, 155, 119]},
24
+ "Violet Élégant": {"primary": [74, 20, 140], "secondary": [103, 58, 183]},
25
+ }
26
+ FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
27
+ FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
28
+
29
+ W, H = 1920, 1080
30
+ MARGIN_X, SAFE_Y_TOP = 140, 140
31
+
32
+ MANIFEST_PATH = os.path.join(OUT_DIR, "manifest.json")
src/graphics.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, uuid, re, traceback
2
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
3
+ from .config import THEMES, FONT_REG, FONT_BOLD, W, H, MARGIN_X, SAFE_Y_TOP, TMP_DIR
4
+
5
+ def wrap_text(text, font, max_width, draw):
6
+ lines = []
7
+ for para in text.split("\n"):
8
+ current = []
9
+ for word in para.split(" "):
10
+ test = " ".join(current + [word])
11
+ try:
12
+ w = draw.textlength(test, font=font)
13
+ except AttributeError:
14
+ bbox = draw.textbbox((0, 0), test, font=font)
15
+ w = bbox[2] - bbox[0]
16
+ if w <= max_width or not current:
17
+ current.append(word)
18
+ else:
19
+ lines.append(" ".join(current))
20
+ current = [word]
21
+ if current:
22
+ lines.append(" ".join(current))
23
+ return lines
24
+
25
+ def draw_text_shadow(draw, xy, text, font, fill=(255, 255, 255)):
26
+ x, y = xy
27
+ draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0))
28
+ draw.text((x, y), text, font=font, fill=fill)
29
+
30
+ def make_background(titre, sous_titre, texte_ecran, theme, logo_path, logo_pos, img_fond, fond_mode="plein écran"):
31
+ try:
32
+ print(f"[Fond] Création du fond - Thème: {theme}")
33
+ if not titre:
34
+ titre = "Titre CPAS"
35
+ if not theme or theme not in THEMES:
36
+ theme = list(THEMES.keys())[0]
37
+ print(f"[Fond] Thème invalide, utilisation par défaut: {theme}")
38
+
39
+ c = THEMES[theme]
40
+ primary = tuple(c["primary"])
41
+ secondary = tuple(c["secondary"])
42
+
43
+ bg = Image.new("RGB", (W, H), primary)
44
+ draw = ImageDraw.Draw(bg)
45
+
46
+ if img_fond and os.path.exists(img_fond):
47
+ try:
48
+ img = Image.open(img_fond).convert("RGB")
49
+ if fond_mode == "plein écran":
50
+ img = img.resize((W, H))
51
+ img = img.filter(ImageFilter.GaussianBlur(1))
52
+ overlay = Image.new("RGBA", (W, H), (*primary, 90))
53
+ bg = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
54
+ elif fond_mode == "moitié gauche":
55
+ img = img.resize((W//2, H))
56
+ mask = Image.linear_gradient("L").resize((W//2, H))
57
+ color = Image.new("RGB", (W//2, H), primary)
58
+ comp = Image.composite(img, color, ImageOps.invert(mask))
59
+ bg.paste(comp, (0, 0))
60
+ elif fond_mode == "moitié droite":
61
+ img = img.resize((W//2, H))
62
+ mask = Image.linear_gradient("L").resize((W//2, H))
63
+ color = Image.new("RGB", (W//2, H), primary)
64
+ comp = Image.composite(color, img, mask)
65
+ bg.paste(comp, (W//2, 0))
66
+ elif fond_mode == "moitié bas":
67
+ img = img.resize((W, H//2))
68
+ mask = Image.linear_gradient("L").rotate(90).resize((W, H//2))
69
+ color = Image.new("RGB", (W, H//2), primary)
70
+ comp = Image.composite(color, img, mask)
71
+ bg.paste(comp, (0, H//2))
72
+ draw = ImageDraw.Draw(bg)
73
+ print(f"[Fond] Image de fond appliquée: {fond_mode}")
74
+ except Exception as e:
75
+ print(f"[Fond] Erreur image de fond: {e}")
76
+
77
+ try:
78
+ f_title = ImageFont.truetype(FONT_BOLD, 84)
79
+ f_sub = ImageFont.truetype(FONT_REG, 44)
80
+ f_text = ImageFont.truetype(FONT_REG, 40)
81
+ f_small = ImageFont.truetype(FONT_REG, 30)
82
+ except Exception as e:
83
+ print(f"[Fond] Erreur polices, utilisation par défaut: {e}")
84
+ f_title = ImageFont.load_default()
85
+ f_sub = ImageFont.load_default()
86
+ f_text = ImageFont.load_default()
87
+ f_small = ImageFont.load_default()
88
+
89
+ draw.rectangle([(0, 0), (W, 96)], fill=secondary)
90
+ draw.rectangle([(0, H-96), (W, H)], fill=secondary)
91
+
92
+ draw_text_shadow(draw, (MARGIN_X, 30), "CPAS BRUXELLES • SERVICE PUBLIC", f_small)
93
+ draw_text_shadow(draw, (W//2-280, H-72), "📞 0800 35 550 • 🌐 cpasbru.irisnet.be", f_small)
94
+ draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP), titre, f_title)
95
+ draw_text_shadow(draw, (MARGIN_X, SAFE_Y_TOP + 100), sous_titre, f_sub)
96
+
97
+ y = SAFE_Y_TOP + 200
98
+ if texte_ecran:
99
+ for line in texte_ecran.split("\n"):
100
+ wrapped_lines = wrap_text("• " + line.strip("• "), f_text, W - MARGIN_X*2, draw)
101
+ for l in wrapped_lines:
102
+ draw_text_shadow(draw, (MARGIN_X, y), l, f_text)
103
+ y += 55
104
+
105
+ if logo_path and os.path.exists(logo_path):
106
+ try:
107
+ logo = Image.open(logo_path).convert("RGBA")
108
+ logo.thumbnail((260, 260))
109
+ lw, lh = logo.size
110
+ if logo_pos == "haut-gauche":
111
+ pos = (50, 50)
112
+ elif logo_pos == "haut-droite":
113
+ pos = (W - lw - 50, 50)
114
+ else:
115
+ pos = ((W - lw)//2, 50)
116
+ bg.paste(logo, pos, logo)
117
+ print(f"[Fond] Logo appliqué: {logo_pos}")
118
+ except Exception as e:
119
+ print(f"[Fond] Erreur logo: {e}")
120
+
121
+ out_path = os.path.join(TMP_DIR, f"fond_{uuid.uuid4().hex[:6]}.png")
122
+ bg.save(out_path)
123
+ print(f"[Fond] ✅ Fond créé avec succès: {out_path}")
124
+ return out_path
125
+
126
+ except Exception as e:
127
+ print(f"[Fond] ❌ ERREUR CRITIQUE: {e}")
128
+ print(f"[Fond] Traceback: {traceback.format_exc()}")
129
+ try:
130
+ emergency_bg = Image.new("RGB", (W, H), (0, 82, 147))
131
+ out_path = os.path.join(TMP_DIR, f"emergency_fond_{uuid.uuid4().hex[:6]}.png")
132
+ emergency_bg.save(out_path)
133
+ print(f"[Fond] ✅ Fond d'urgence créé: {out_path}")
134
+ return out_path
135
+ except Exception as e2:
136
+ print(f"[Fond] ❌ Même le fallback a échoué: {e2}")
137
+ return None
src/tts_utils.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, uuid, asyncio
2
+ from typing import Dict
3
+ from pydub import AudioSegment
4
+ import edge_tts
5
+
6
+ from .config import TMP_DIR
7
+
8
+ EDGE_VOICES: Dict[str, str] = {}
9
+
10
+ async def fetch_edge_voices_async():
11
+ """Charge dynamiquement toutes les voix FR/NL depuis Edge-TTS."""
12
+ global EDGE_VOICES
13
+ try:
14
+ voices = await edge_tts.list_voices()
15
+ filtered = [v for v in voices if v.get("Locale", "").startswith(("fr", "nl"))]
16
+ filtered.sort(key=lambda v: (v.get("Locale"), v.get("Gender"), v.get("ShortName")))
17
+ EDGE_VOICES = {
18
+ f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v["ShortName"]
19
+ for v in filtered
20
+ }
21
+ print(f"[Edge-TTS] {len(EDGE_VOICES)} voix FR/NL chargées.")
22
+ except Exception as e:
23
+ print(f"[Edge-TTS] Erreur chargement voix : {e}")
24
+ EDGE_VOICES.update({
25
+ "fr-FR-DeniseNeural - fr-FR (Female)": "fr-FR-DeniseNeural",
26
+ "nl-NL-MaaikeNeural - nl-NL (Female)": "nl-NL-MaaikeNeural",
27
+ })
28
+
29
+ def init_edge_voices():
30
+ """Démarre le chargement asynchrone sans bloquer Gradio."""
31
+ try:
32
+ loop = asyncio.get_event_loop()
33
+ if loop.is_running():
34
+ import nest_asyncio
35
+ nest_asyncio.apply()
36
+ loop.create_task(fetch_edge_voices_async())
37
+ else:
38
+ loop.run_until_complete(fetch_edge_voices_async())
39
+ except RuntimeError:
40
+ asyncio.run(fetch_edge_voices_async())
41
+
42
+ def get_edge_voices(lang="fr"):
43
+ """Retourne les voix déjà chargées (selon la langue)."""
44
+ global EDGE_VOICES
45
+ if not EDGE_VOICES:
46
+ init_edge_voices()
47
+ if lang == "fr":
48
+ return [v for k, v in EDGE_VOICES.items() if k.startswith("fr-")]
49
+ elif lang == "nl":
50
+ return [v for k, v in EDGE_VOICES.items() if k.startswith("nl-")]
51
+ return list(EDGE_VOICES.values())
52
+
53
+ async def _edge_tts_async(text, voice, outfile):
54
+ communicate = edge_tts.Communicate(text, voice)
55
+ await communicate.save(outfile)
56
+ return outfile
57
+
58
+ def tts_edge(text: str, voice: str = "fr-FR-DeniseNeural") -> str:
59
+ """Génère un fichier WAV avec Edge-TTS (et fallback gTTS)."""
60
+ out_mp3 = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.mp3")
61
+ try:
62
+ try:
63
+ loop = asyncio.get_event_loop()
64
+ if loop.is_running():
65
+ import nest_asyncio
66
+ nest_asyncio.apply()
67
+ except RuntimeError:
68
+ pass
69
+ asyncio.run(_edge_tts_async(text, voice, out_mp3))
70
+
71
+ out_wav = os.path.join(TMP_DIR, f"edge_{uuid.uuid4().hex}.wav")
72
+ AudioSegment.from_file(out_mp3).export(out_wav, format="wav")
73
+ os.remove(out_mp3)
74
+ return out_wav
75
+ except Exception as e:
76
+ print(f"[Edge-TTS] Erreur : {e} → fallback gTTS")
77
+ return tts_gtts(text, lang="fr" if voice.startswith("fr") else "nl")
78
+
79
+ def tts_gtts(text: str, lang: str = "fr") -> str:
80
+ from gtts import gTTS
81
+ out = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.mp3")
82
+ gTTS(text=text, lang=lang).save(out)
83
+ out_wav = os.path.join(TMP_DIR, f"gtts_{uuid.uuid4().hex}.wav")
84
+ AudioSegment.from_file(out).export(out_wav, format="wav")
85
+ os.remove(out)
86
+ return out_wav
87
+
88
+ def normalize_audio_to_wav(in_path: str) -> str:
89
+ """Convertit n'importe quel format en WAV 44.1kHz stéréo."""
90
+ from pydub import AudioSegment
91
+ wav_path = os.path.join(TMP_DIR, f"norm_{uuid.uuid4().hex}.wav")
92
+ snd = AudioSegment.from_file(in_path)
93
+ snd = snd.set_frame_rate(44100).set_channels(2).set_sample_width(2)
94
+ snd.export(wav_path, format="wav")
95
+ return wav_path
src/ui.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from .config import THEMES
3
+ from .tts_utils import get_edge_voices, init_edge_voices
4
+ from .capsule_builder import (
5
+ build_capsule, table_capsules, assemble_final, supprimer_capsule, deplacer_capsule
6
+ )
7
+
8
+ def build_ui():
9
+ print("[INIT] Lancement de Gradio...")
10
+ init_edge_voices()
11
+
12
+ with gr.Blocks(title="Créateur de Capsules CPAS – Version avec vidéo présentateur",
13
+ theme=gr.themes.Soft()) as demo:
14
+
15
+ gr.Markdown("## 🎬 Créateur de Capsules CPAS – Version avec vidéo présentateur")
16
+ gr.Markdown("**Nouveau** : Utilisez directement une vidéo de présentateur au lieu d'une image.")
17
+
18
+ with gr.Tab("Créer une capsule"):
19
+ with gr.Row():
20
+ with gr.Column():
21
+ image_fond = gr.Image(label="🖼 Image de fond", type="filepath")
22
+ fond_mode = gr.Radio(["plein écran", "moitié gauche", "moitié droite", "moitié bas"],
23
+ label="Mode d'affichage du fond", value="plein écran")
24
+ logo_path = gr.Image(label="🏛 Logo", type="filepath")
25
+ logo_pos = gr.Radio(["haut-gauche","haut-droite","centre"],
26
+ label="Position logo", value="haut-gauche")
27
+ video_presentateur = gr.Video(label="🎬 Vidéo du présentateur")
28
+ position_presentateur = gr.Radio(["bottom-right","bottom-left","top-right","top-left","center"],
29
+ label="Position", value="bottom-right")
30
+ plein = gr.Checkbox(label="Plein écran présentateur", value=False)
31
+ with gr.Column():
32
+ titre = gr.Textbox(label="Titre", value="Aide médicale urgente / Dringende medische hulp")
33
+ sous_titre = gr.Textbox(label="Sous-titre", value="Soins accessibles à tous / Toegankelijke zorg voor iedereen")
34
+ theme = gr.Radio(list(THEMES.keys()), label="Thème", value="Bleu Professionnel")
35
+ langue = gr.Radio(["fr", "nl"], label="Langue de la voix", value="fr")
36
+
37
+ def maj_voix(lang):
38
+ try:
39
+ voices = get_edge_voices(lang)
40
+ return gr.update(choices=voices, value=voices[0] if voices else None)
41
+ except Exception:
42
+ return gr.update(choices=[], value=None)
43
+
44
+ speaker_id = gr.Dropdown(
45
+ label="🎙 Voix Edge-TTS",
46
+ choices=get_edge_voices("fr"),
47
+ value="fr-FR-DeniseNeural",
48
+ info="Liste dynamique des voix Edge-TTS (FR & NL)"
49
+ )
50
+ langue.change(maj_voix, [langue], [speaker_id])
51
+
52
+ voix_type = gr.Radio(["Féminine","Masculine"], label="Voix IA", value="Féminine")
53
+ moteur_voix = gr.Radio(["Edge-TTS (recommandé)", "gTTS (fallback)"],
54
+ label="Moteur voix", value="Edge-TTS (recommandé)")
55
+ texte_voix = gr.Textbox(label="Texte voix off", lines=4,
56
+ value="Bonjour, le CPAS de Bruxelles vous aide pour vos soins de santé.")
57
+ texte_ecran = gr.Textbox(label="Texte à l'écran", lines=4,
58
+ value="💊 Aides médicales\n🏥 Soins urgents\n📋 Formalités simplifiées")
59
+ btn = gr.Button("🎬 Créer Capsule", variant="primary")
60
+
61
+ sortie = gr.Video(label="Capsule générée")
62
+ srt_out = gr.File(label="Sous-titres .srt")
63
+ statut = gr.Markdown()
64
+
65
+ with gr.Tab("Gestion & Assemblage"):
66
+ gr.Markdown("### 🗂 Gestion des capsules")
67
+ liste = gr.Dataframe(
68
+ headers=["N°","Titre","Langue","Durée","Thème","Voix","Fichier"],
69
+ value=table_capsules(),
70
+ interactive=False
71
+ )
72
+ with gr.Row():
73
+ index = gr.Number(label="Index capsule", value=1, precision=0)
74
+ btn_up = gr.Button("⬆️ Monter")
75
+ btn_down = gr.Button("⬇️ Descendre")
76
+ btn_del = gr.Button("🗑 Supprimer")
77
+ message = gr.Markdown()
78
+
79
+ btn_up.click(lambda i: deplacer_capsule(i, "up"), [index], [message, liste])
80
+ btn_down.click(lambda i: deplacer_capsule(i, "down"), [index], [message, liste])
81
+ btn_del.click(supprimer_capsule, [index], [message, liste])
82
+
83
+ gr.Markdown("### 🎬 Assemblage final")
84
+ btn_asm = gr.Button("🎥 Assembler la vidéo complète", variant="primary")
85
+ sortie_finale = gr.Video(label="Vidéo finale")
86
+ btn_asm.click(lambda: assemble_final(), [], [sortie_finale, message])
87
+
88
+ # --- Export ZIP intégré ---
89
+ from .capsule_builder import export_project_zip
90
+ btn_zip = gr.Button("📦 Télécharger projet complet (ZIP)", variant="secondary")
91
+ zip_file = gr.File(label="ZIP exporté")
92
+
93
+ def zip_action():
94
+ path = export_project_zip()
95
+ if path and os.path.exists(path):
96
+ return path
97
+ else:
98
+ raise gr.Error("Erreur : impossible de générer le ZIP.")
99
+
100
+ btn_zip.click(zip_action, [], [zip_file])
101
+
102
+
103
+ def creer_capsule_ui(t, st, tv, te, th, img, fmode, logo, pos_logo, vp, vx, pos_p, plein, motor, lang, speaker):
104
+ try:
105
+ vid, msg, srt = build_capsule(t, st, tv, te, th,
106
+ img, logo, pos_logo, fmode,
107
+ vp, vx, pos_p, plein,
108
+ motor, lang, speaker=speaker)
109
+ return vid, srt, msg, table_capsules()
110
+ except Exception as e:
111
+ import traceback
112
+ return None, None, f"❌ Erreur: {e}\n\n{traceback.format_exc()}", table_capsules()
113
+
114
+ btn.click(
115
+ creer_capsule_ui,
116
+ [titre, sous_titre, texte_voix, texte_ecran, theme,
117
+ image_fond, fond_mode, logo_path, logo_pos,
118
+ video_presentateur, voix_type, position_presentateur,
119
+ plein, moteur_voix, langue, speaker_id],
120
+ [sortie, srt_out, statut, liste]
121
+ )
122
+
123
+ return demo
src/video_utils.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, re, uuid, traceback
2
+ from moviepy.editor import VideoFileClip
3
+ import moviepy.video.fx.all as vfx
4
+ from .config import OUT_DIR, W, H
5
+
6
+ def safe_name(stem, ext=".mp4"):
7
+ stem = re.sub(r"[^\w\-]+", "_", stem)[:40]
8
+ return f"{stem}_{uuid.uuid4().hex[:6]}{ext}"
9
+
10
+ def prepare_video_presentateur(video_path, audio_duration, position, plein_ecran=False):
11
+ """Prépare la vidéo du présentateur avec la bonne durée et position."""
12
+ try:
13
+ print(f"[Video] Chargement: {video_path}")
14
+ if not os.path.exists(video_path):
15
+ print(f"[Video] ❌ Fichier introuvable: {video_path}")
16
+ return None
17
+
18
+ v = VideoFileClip(video_path).without_audio()
19
+ print(f"[Video] Durée vidéo: {v.duration}s, Audio: {audio_duration}s")
20
+
21
+ if v.duration < audio_duration:
22
+ print(f"[Video] Bouclage nécessaire ({v.duration}s -> {audio_duration}s)")
23
+ v = v.fx(vfx.loop, duration=audio_duration)
24
+ elif v.duration > audio_duration:
25
+ print(f"[Video] Découpage nécessaire ({v.duration}s -> {audio_duration}s)")
26
+ v = v.subclip(0, audio_duration)
27
+
28
+ if plein_ecran:
29
+ print(f"[Video] Mode plein écran")
30
+ v = v.resize(newsize=(W, H)).set_position(("center", "center"))
31
+ else:
32
+ print(f"[Video] Mode incrustation, position: {position}")
33
+ v = v.resize(width=520)
34
+ pos_map = {
35
+ "bottom-right": ("right", "bottom"),
36
+ "bottom-left": ("left", "bottom"),
37
+ "top-right": ("right", "top"),
38
+ "top-left": ("left", "top"),
39
+ "center": ("center", "center"),
40
+ }
41
+ v = v.set_position(pos_map.get(position, ("right", "bottom")))
42
+
43
+ print(f"[Video] ✅ Vidéo préparée avec succès")
44
+ return v
45
+
46
+ except Exception as e:
47
+ print(f"[Video] ❌ Erreur préparation: {e}")
48
+ print(f"[Video] Traceback: {traceback.format_exc()}")
49
+ return None
50
+
51
+ def write_srt(text, duration):
52
+ parts = re.split(r'(?<=[\.!?])\s+', text.strip())
53
+ parts = [p for p in parts if p]
54
+ total = len("".join(parts)) or 1
55
+ cur = 0.0
56
+ srt = []
57
+ for i, p in enumerate(parts, 1):
58
+ prop = len(p)/total
59
+ start = cur
60
+ end = min(duration, cur + duration*prop)
61
+ cur = end
62
+ def ts(t):
63
+ m, s = divmod(t, 60)
64
+ h, m = divmod(m, 60)
65
+ return f"{int(h):02}:{int(m):02}:{int(s):02},000"
66
+ srt += [f"{i}", f"{ts(start)} --> {ts(end)}", p, ""]
67
+ path = os.path.join(OUT_DIR, f"srt_{uuid.uuid4().hex[:6]}.srt")
68
+ open(path, "w", encoding="utf-8").write("\n".join(srt))
69
+ return path
70
+
71
+ def write_video_with_fallback(final_clip, out_path_base, fps=25):
72
+ attempts = [
73
+ {"ext": ".mp4", "codec": "libx264", "audio_codec": "aac"},
74
+ {"ext": ".mp4", "codec": "mpeg4", "audio_codec": "aac"},
75
+ {"ext": ".mp4", "codec": "libx264", "audio_codec": "libmp3lame"},
76
+ ]
77
+ ffmpeg_params = ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "-shortest"]
78
+ last_err = None
79
+ for opt in attempts:
80
+ out = out_path_base if out_path_base.endswith(opt["ext"]) else out_path_base + opt["ext"]
81
+ try:
82
+ final_clip.write_videofile(
83
+ out,
84
+ fps=fps,
85
+ codec=opt["codec"],
86
+ audio_codec=opt["audio_codec"],
87
+ audio=True,
88
+ ffmpeg_params=ffmpeg_params,
89
+ logger=None,
90
+ threads=1,
91
+ )
92
+ if os.path.exists(out) and os.path.getsize(out) > 150000:
93
+ return out
94
+ except Exception as e:
95
+ last_err = f"{type(e).__name__}: {e}"
96
+ raise RuntimeError(last_err or "FFmpeg a échoué")