Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- src/__init__.py +1 -0
- src/capsule_builder.py +227 -0
- src/config.py +32 -0
- src/graphics.py +137 -0
- src/tts_utils.py +95 -0
- src/ui.py +123 -0
- 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é")
|