binaryMao commited on
Commit
5738fbf
·
verified ·
1 Parent(s): 60b8ac2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +402 -143
app.py CHANGED
@@ -1,68 +1,266 @@
 
 
 
 
 
1
  import gradio as gr
2
  import numpy as np
3
  import torch
4
  import soundfile as sf
5
  import os
6
  import tempfile
7
- from moviepy.editor import VideoFileClip, CompositeVideoClip, ImageClip
8
- from PIL import Image, ImageDraw, ImageFont
9
- from nemo.collections import asr as nemo_asr
10
- from huggingface_hub import hf_hub_download, snapshot_download
11
- from ctc_segmentation import ctc_segmentation, CtcSegmentationParameters, prepare_text
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  MODELS = {
14
- "Soloni V0": ("RobotsMali/soloni-114m-tdt-ctc-V0", "soloni-114m-tdt-ctc-V0.nemo", "rnnt"),
15
- "Soloni V1": ("RobotsMali/soloni-114m-tdt-ctc-V1", "soloni-114m-tdt-ctc-V1.nemo", "rnnt"),
16
- "Soloba V0": ("RobotsMali/soloba-ctc-0.6b-V0", None, "ctc"),
17
- "Soloba V1": ("RobotsMali/soloba-ctc-0.6b-V1", None, "ctc"),
18
- "QuartzNet V0": ("RobotsMali/stt-bm-quartznet15x5-V0", None, "ctc"),
19
- "QuartzNet V1": ("RobotsMali/stt-bm-quartznet15x5-V1", None, "ctc"),
 
20
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  def load_ctc_model_safe(repo_id):
23
- """Charge les modèles CTC de manière robuste"""
 
24
  try:
25
  # Essai 1: Chargement standard
26
  return nemo_asr.models.EncDecCTCModelBPE.from_pretrained(model_name=repo_id)
27
  except Exception as e:
28
- print(f"Erreur lors du chargement standard: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- # Essai 2: Téléchargement manuel via snapshot
31
- try:
32
- print("Tentative de téléchargement manuel...")
33
- model_path = snapshot_download(
34
- repo_id=repo_id,
35
- cache_dir=tempfile.mkdtemp(),
36
- local_dir_use_symlinks=False
37
- )
38
-
39
- # Chercher le fichier .nemo
40
- nemo_file = None
41
- for file in os.listdir(model_path):
42
- if file.endswith('.nemo'):
43
- nemo_file = os.path.join(model_path, file)
44
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- if nemo_file and os.path.exists(nemo_file):
47
- print(f"Chargement depuis: {nemo_file}")
48
- return nemo_asr.models.EncDecCTCModelBPE.restore_from(nemo_file)
49
- else:
50
- raise FileNotFoundError("Fichier .nemo non trouvé dans le repo")
51
-
52
- except Exception as e2:
53
- print(f"Échec du téléchargement manuel: {e2}")
54
- raise
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- def extract_audio(video_path, wav_path):
57
- """Extrait l'audio de la vidéo"""
58
- video = VideoFileClip(video_path)
59
- video.audio.write_audiofile(
60
- wav_path, fps=16000, codec="pcm_s16le", verbose=False, logger=None
61
- )
62
- video.close()
63
 
64
  def transcribe(model, device, wav, model_name):
65
- """Transcrit l'audio avec alignement temporel"""
 
 
66
  audio, sr = sf.read(wav)
67
  if audio.ndim == 2:
68
  audio = np.mean(audio, axis=1)
@@ -70,87 +268,95 @@ def transcribe(model, device, wav, model_name):
70
  ln = torch.tensor([x.shape[1]]).to(device)
71
  total_s = len(audio) / sr
72
 
73
- # Modèles RNNT (Soloni)
74
  if "Soloni" in model_name:
75
  with torch.no_grad():
76
  proc, plen = model.preprocessor(input_signal=x, input_signal_length=ln)
 
77
  hyps = model.decode_and_align(encoder_output=proc, encoded_lengths=plen)
 
 
 
78
  hyp = hyps[0][0] if isinstance(hyps[0], list) else hyps[0]
79
- return [(w.start_offset_ms/1000, w.end_offset_ms/1000, w.word) for w in hyp.words]
 
 
 
80
 
81
- # Modèles CTC (Soloba, QuartzNet)
82
  text = model.transcribe([wav])[0].strip()
83
- if not text:
84
- return []
85
 
86
  with torch.no_grad():
87
  logits, logit_len = model.forward(input_signal=x, input_signal_length=ln)
88
 
89
  words = text.split()
90
- if not words:
91
- return []
92
 
 
93
  config = CtcSegmentationParameters()
94
  config.char_list = list(model.tokenizer.vocab.keys())
95
  gt, _ = prepare_text(config, words)
96
-
97
- timings, _, _ = ctc_segmentation(config, logits.cpu().numpy()[0], gt)
 
 
 
 
98
  tps = total_s / logit_len.cpu().numpy()[0]
99
 
 
100
  aligned = [(timings[i] * tps,
101
  timings[i+1] * tps if i+1 < len(timings) else total_s,
102
  words[i]) for i in range(len(words))]
103
 
104
- # Regroupement des mots
105
- grouped, temp = [], []
106
- for w in aligned:
107
- temp.append(w)
108
- if len(temp) >= 4: # Groupe de 4 mots
109
- grouped.append(temp)
110
- temp = []
111
- if temp:
112
- grouped.append(temp)
 
 
 
 
 
 
 
113
 
114
- return [(g[0][0], g[-1][1], " ".join([w[2] for w in g])) for g in grouped]
115
 
116
  def burn(video, subs):
117
- """Ajoute les sous-titres à la vidéo"""
 
 
 
118
  clip = VideoFileClip(video)
119
  W, H = clip.size
120
 
121
- # Tentative de chargement de police
122
- try:
123
- font_size = max(int(H/20), 20) # Taille minimale
124
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
125
- except:
126
- try:
127
- font = ImageFont.load_default()
128
- except:
129
- font = None
130
-
131
- layers = []
132
  for start, end, text in subs:
133
- # Création de l'image de sous-titre
134
- img_height = int(H * 0.12)
135
- img = Image.new("RGBA", (W, img_height), (0, 0, 0, 140))
136
- draw = ImageDraw.Draw(img)
137
-
138
- if font:
139
- bbox = draw.textbbox((0, 0), text, font=font)
140
- tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
141
- draw.text(((W - tw) // 2, (img_height - th) // 2), text, font=font, fill="white")
142
- else:
143
- # Fallback si police non disponible
144
- draw.text((W//2, img_height//2), text, fill="white", anchor="mm")
145
 
146
- # Création du clip de sous-titre
147
- subtitle_clip = ImageClip(np.array(img)).set_start(start).set_duration(end - start)
148
- subtitle_clip = subtitle_clip.set_position(("center", int(H * 0.85)))
149
- layers.append(subtitle_clip)
150
 
151
  # Composition finale
152
- final = CompositeVideoClip([clip] + layers)
153
- out_path = "RobotsMali_Subtitled.mp4"
154
 
155
  # Écriture de la vidéo finale
156
  final.write_videofile(
@@ -158,6 +364,8 @@ def burn(video, subs):
158
  codec="libx264",
159
  audio_codec="aac",
160
  fps=clip.fps,
 
 
161
  verbose=False,
162
  logger=None,
163
  temp_audiofile="temp-audio.m4a",
@@ -167,78 +375,129 @@ def burn(video, subs):
167
  # Nettoyage
168
  clip.close()
169
  final.close()
170
- for layer in layers:
171
  layer.close()
172
 
173
  return out_path
174
 
 
 
175
  def pipeline(video_file, model_name):
176
  """Pipeline principal de traitement"""
 
 
 
177
  if video_file is None:
178
  return "Veuillez importer une vidéo.", None
179
 
180
- repo, nemo_file, mode = MODELS[model_name]
181
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
182
-
 
 
183
  try:
184
- # Chargement du modèle
185
- if mode == "rnnt":
186
- nemo_path = hf_hub_download(repo, filename=nemo_file)
187
- model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(nemo_path)
188
- else:
189
- model = load_ctc_model_safe(repo) # Utilisation de la fonction sécurisée
190
-
191
- model = model.to(device)
192
- model.eval()
193
-
194
- # Traitement
195
- wav_path = "audio.wav"
196
  extract_audio(video_file, wav_path)
 
 
197
  subs = transcribe(model, device, wav_path, model_name)
 
 
 
 
 
198
  final_video = burn(video_file, subs)
199
 
200
  # Nettoyage des fichiers temporaires
201
  if os.path.exists(wav_path):
202
  os.remove(wav_path)
203
 
204
- return "✅ Sous-titres générés avec succès!", final_video
205
 
206
  except Exception as e:
207
  print(f"Erreur dans le pipeline: {e}")
208
- return f"❌ Erreur: {str(e)}", None
209
-
210
- # Interface Gradio
211
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
212
- gr.Markdown("""
213
- # 🎙️ **RobotsMali — Sous-titrage automatique Bambara**
214
- *Générez automatiquement des sous-titres en Bambara pour vos vidéos*
215
- """)
216
-
217
- with gr.Row():
218
- with gr.Column():
219
- video = gr.Video(label="Vidéo d'entrée", height=300)
220
- model = gr.Dropdown(
221
- list(MODELS.keys()),
222
- value="Soloni V1",
223
- label="Modèle de reconnaissance vocale",
224
- info="Soloni: plus précis Soloba/QuartzNet: plus rapide"
225
- )
226
- btn = gr.Button("⚡ Générer les sous-titres", variant="primary")
227
-
228
- with gr.Column():
229
- status = gr.Markdown("Prêt à traiter...")
230
- out = gr.Video(label="Vidéo sous-titrée", height=300)
231
-
232
- # Exemples
233
- gr.Examples(
234
- examples=[],
235
- inputs=[video, model],
236
- outputs=[status, out],
237
- fn=pipeline,
238
- cache_examples=False,
239
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- btn.click(pipeline, inputs=[video, model], outputs=[status, out])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
  if __name__ == "__main__":
244
- demo.launch(share=True, server_port=7860)
 
1
+ # -*- coding: utf-8 -*-
2
+ """Video_Captioning_Space_V8_0_MINIMALIST_BLUE.ipynb
3
+ Architecture NeMo + ctc-segmentation pour l'alignement sur tous les modèles.
4
+ Design Minimalist Blue.
5
+ """
6
  import gradio as gr
7
  import numpy as np
8
  import torch
9
  import soundfile as sf
10
  import os
11
  import tempfile
12
+ import warnings
13
+ from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip
14
+ from typing import List, Tuple, Union
 
 
15
 
16
+ # --- Installation des dépendances pour Google Colab (À exécuter avant ce script) ---
17
+ # !pip install gradio moviepy numpy torch soundfile
18
+ # !pip install nemo_toolkit['asr']
19
+ # !pip install ctc-segmentation huggingface-hub
20
+
21
+ try:
22
+ from nemo.collections import asr as nemo_asr
23
+ from huggingface_hub import hf_hub_download, snapshot_download
24
+ from ctc_segmentation import ctc_segmentation, CtcSegmentationParameters, prepare_text
25
+ NEMO_LOADED = True
26
+ except ImportError as e:
27
+ NEMO_LOADED = False
28
+ print(f"Erreur d'importation des dépendances NeMo/CTC : {e}")
29
+ # Classes/Fonctions de substitution pour éviter le crash au lancement
30
+ class DummyASRModel:
31
+ def from_pretrained(self, *args, **kwargs):
32
+ raise RuntimeError("Dépendances ASR manquantes. Veuillez exécuter la cellule d'installation.")
33
+ nemo_asr = type('nemo_asr', (object,), {'models': type('models', (object,), {'EncDecHybridRNNTCTCBPEModel': DummyASRModel, 'EncDecCTCModelBPE': DummyASRModel})})
34
+ hf_hub_download = lambda *args, **kwargs: None
35
+ snapshot_download = lambda *args, **kwargs: None
36
+
37
+
38
+ # --- CONFIGURATION DES MODÈLES (Utilisation de votre liste complète) ---
39
  MODELS = {
40
+ "Soloni V1 (RNnT - Précis)": ("RobotsMali/soloni-114m-tdt-ctc-V1", "soloni-114m-tdt-ctc-V1.nemo", "rnnt"),
41
+ "Soloba V1 (CTC - Équilibré)": ("RobotsMali/soloba-ctc-0.6b-V1", None, "ctc"),
42
+ "QuartzNet V1 (CTC - Rapide)": ("RobotsMali/stt-bm-quartznet15x5-V1", None, "ctc"),
43
+ # Anciennes versions (Gardées pour la compatibilité, mais V1 recommandées)
44
+ "Soloni V0 (RNnT)": ("RobotsMali/soloni-114m-tdt-ctc-V0", "soloni-114m-tdt-ctc-V0.nemo", "rnnt"),
45
+ "Soloba V0 (CTC)": ("RobotsMali/soloba-ctc-0.6b-V0", None, "ctc"),
46
+ "QuartzNet V0 (CTC)": ("RobotsMali/stt-bm-quartznet15x5-V0", None, "ctc"),
47
  }
48
+ asr_pipeline = {}
49
+
50
+ # --- CSS : ROBOTSMALI MINIMALIST BLUE ---
51
+ CUSTOM_CSS = """
52
+ @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap');
53
+
54
+ /* Couleurs */
55
+ :root {
56
+ --primary-color: #007bff; /* Bleu de base */
57
+ --accent-color: #00BFFF; /* Bleu Cyan Électrique */
58
+ --background-light: #F8F9FA; /* Gris très clair */
59
+ --surface-color: #FFFFFF; /* Blanc */
60
+ --text-color: #212529; /* Gris très foncé */
61
+ --border-color: #E9ECEF;
62
+ }
63
+
64
+ body {
65
+ background-color: var(--background-light) !important;
66
+ font-family: 'Roboto', sans-serif !important;
67
+ color: var(--text-color) !important;
68
+ }
69
+ .gradio-container {
70
+ max-width: 1200px;
71
+ margin: 0 auto;
72
+ padding: 20px 10px;
73
+ background-color: var(--background-light) !important;
74
+ border-radius: 0 !important;
75
+ }
76
+
77
+ /* Conteneurs et cartes (Blocs) */
78
+ .block {
79
+ border: 1px solid var(--border-color);
80
+ border-radius: 8px;
81
+ background-color: var(--surface-color);
82
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
83
+ padding: 20px;
84
+ }
85
+
86
+ /* Titres */
87
+ h1 {
88
+ color: var(--accent-color) !important;
89
+ text-align: center;
90
+ margin-bottom: 5px;
91
+ font-weight: 700;
92
+ }
93
+ h3 {
94
+ color: var(--primary-color) !important;
95
+ font-weight: 500;
96
+ border-bottom: 1px solid var(--border-color);
97
+ padding-bottom: 5px;
98
+ margin-bottom: 15px;
99
+ }
100
+
101
+ /* Boutons d'action : Bleu Primair */
102
+ .primary {
103
+ background-color: var(--primary-color) !important;
104
+ border: none !important;
105
+ color: white !important;
106
+ font-weight: 700;
107
+ text-transform: uppercase;
108
+ transition: background-color 0.2s;
109
+ }
110
+ .primary:hover {
111
+ background-color: #0056b3 !important; /* Bleu foncé au survol */
112
+ box-shadow: 0 0 8px rgba(0, 123, 255, 0.4);
113
+ }
114
+
115
+ /* Inputs et Dropdowns */
116
+ .gr-input, .gr-dropdown {
117
+ background-color: #FFFFFF !important;
118
+ border: 1px solid #CED4DA !important;
119
+ color: var(--text-color) !important;
120
+ border-radius: 4px;
121
+ }
122
+ .gr-file-input {
123
+ border: 2px dashed var(--primary-color) !important;
124
+ background-color: #F0F5FF !important;
125
+ }
126
+
127
+ /* Statut d'exécution */
128
+ .gr-status {
129
+ background-color: #E6F0FF !important;
130
+ border-left: 5px solid var(--primary-color);
131
+ color: var(--text-color) !important;
132
+ padding: 10px;
133
+ }
134
+ """
135
+
136
+ # ----------------------------------------------------------------------
137
+ # FONCTIONS DE CHARGEMENT ET D'ALIGEMENT
138
+ # ----------------------------------------------------------------------
139
 
140
  def load_ctc_model_safe(repo_id):
141
+ """Charge les modèles CTC de manière robuste (votre fonction)"""
142
+ # Votre logique de chargement stable est conservée
143
  try:
144
  # Essai 1: Chargement standard
145
  return nemo_asr.models.EncDecCTCModelBPE.from_pretrained(model_name=repo_id)
146
  except Exception as e:
147
+ # Essai 2: Téléchargement manuel via snapshot si l'essai 1 échoue
148
+ print(f"Erreur lors du chargement standard du CTC: {e}. Tentative de téléchargement manuel...")
149
+ with tempfile.TemporaryDirectory() as tmpdir:
150
+ try:
151
+ model_path = snapshot_download(
152
+ repo_id=repo_id,
153
+ cache_dir=tmpdir,
154
+ local_dir_use_symlinks=False
155
+ )
156
+
157
+ # Chercher le fichier .nemo
158
+ nemo_file = None
159
+ for file in os.listdir(model_path):
160
+ if file.endswith('.nemo'):
161
+ nemo_file = os.path.join(model_path, file)
162
+ break
163
+
164
+ if nemo_file and os.path.exists(nemo_file):
165
+ print(f"Chargement réussi depuis: {nemo_file}")
166
+ return nemo_asr.models.EncDecCTCModelBPE.restore_from(nemo_file)
167
+ else:
168
+ raise FileNotFoundError("Fichier .nemo non trouvé dans le repo téléchargé.")
169
+
170
+ except Exception as e2:
171
+ raise Exception(f"Échec du téléchargement/chargement manuel du modèle CTC: {e2}")
172
+
173
+ def load_asr_model(model_name: str):
174
+ """Gestion centralisée du chargement de modèles (RNNT et CTC)"""
175
+ global asr_pipeline
176
+ repo_id, nemo_file, mode = MODELS[model_name]
177
+
178
+ if model_name not in asr_pipeline:
179
+ print(f"-> Chargement initial du modèle : {model_name} (Mode: {mode})")
180
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
181
+
182
+ if mode == "rnnt":
183
+ # RNNT (Soloni) : Téléchargement du fichier .nemo spécifique
184
+ if not nemo_file: raise ValueError("Nom de fichier .nemo manquant pour le modèle RNNT.")
185
+ nemo_path = hf_hub_download(repo_id, filename=nemo_file)
186
+ model_instance = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(nemo_path)
187
+ else:
188
+ # CTC (Soloba, QuartzNet) : Utilisation de la fonction sécurisée
189
+ model_instance = load_ctc_model_safe(repo_id)
190
 
191
+ model_instance = model_instance.to(device)
192
+ model_instance.eval()
193
+ asr_pipeline[model_name] = model_instance
194
+ print(f"-> Modèle {model_name} chargé sur {device}.")
195
+
196
+ return asr_pipeline[model_name]
197
+
198
+
199
+ # --- Logique de Segmentation et d'Optimisation des Lignes ---
200
+
201
+ MAX_SUBTITLE_WORDS = 4
202
+ MAX_SUBTITLE_CHARS = 45
203
+ MAX_SUBTITLE_DURATION = 3.5 # Durée maximale en secondes pour une ligne de sous-titre
204
+
205
+ def group_words_to_subtitles(words_with_timestamps: List[Tuple[float, float, str]]) -> List[Tuple[float, float, str]]:
206
+ """
207
+ Formate la liste de mots horodatés en lignes de sous-titres optimisées
208
+ selon les règles de mots, caractères et durée maximum.
209
+ Cette fonction assure l'optimisation pour les 6 modèles.
210
+ """
211
+ subtitles = []
212
+ if not words_with_timestamps: return []
213
+
214
+ current_group = []
215
+
216
+ def finalize_group(group):
217
+ if not group: return
218
+ start_time = group[0][0]
219
+ end_time = group[-1][1]
220
+ line_text = " ".join([w[2] for w in group])
221
+ subtitles.append((start_time, end_time, line_text))
222
+
223
+ for word_data in words_with_timestamps:
224
+ # Tentative d'ajouter le mot au groupe actuel
225
+ test_group = current_group + [word_data]
226
+ test_text = " ".join([w[2] for w in test_group])
227
+
228
+ # Calcul de la durée du groupe test
229
+ test_duration = test_group[-1][1] - test_group[0][0] if test_group else 0
230
+
231
+ should_cut = False
232
+
233
+ # Règle 1: Dépasser la limite de mots
234
+ if len(current_group) >= MAX_SUBTITLE_WORDS:
235
+ should_cut = True
236
 
237
+ # Règle 2: Dépasser la limite de caractères (avant l'ajout)
238
+ elif len(test_text) > MAX_SUBTITLE_CHARS and current_group:
239
+ should_cut = True
240
+
241
+ # Règle 3: Dépasser la durée maximum (avant l'ajout)
242
+ # On coupe si la durée est trop longue, mais seulement si le groupe a
243
+ # déjà une taille raisonnable (>= 2 mots) pour éviter des coupures trop courtes.
244
+ elif len(current_group) >= 2 and test_duration > MAX_SUBTITLE_DURATION:
245
+ should_cut = True
246
+
247
+ if should_cut:
248
+ finalize_group(current_group)
249
+ current_group = [word_data]
250
+ else:
251
+ # Si aucune règle de coupure n'est déclenchée, on ajoute le mot au groupe
252
+ current_group.append(word_data)
253
+
254
+ # Finalisation du dernier groupe
255
+ finalize_group(current_group)
256
+
257
+ return subtitles
258
 
 
 
 
 
 
 
 
259
 
260
  def transcribe(model, device, wav, model_name):
261
+ """Transcrit l'audio et génère des horodatages de LIGNES (start, end, text)"""
262
+
263
+ # Lecture de l'audio
264
  audio, sr = sf.read(wav)
265
  if audio.ndim == 2:
266
  audio = np.mean(audio, axis=1)
 
268
  ln = torch.tensor([x.shape[1]]).to(device)
269
  total_s = len(audio) / sr
270
 
271
+ # --- Mode RNNT (Soloni) : Utilisation de l'alignement natif ---
272
  if "Soloni" in model_name:
273
  with torch.no_grad():
274
  proc, plen = model.preprocessor(input_signal=x, input_signal_length=ln)
275
+ # Utilisation du decode_and_align natif pour les word-timestamps
276
  hyps = model.decode_and_align(encoder_output=proc, encoded_lengths=plen)
277
+
278
+ if not hyps or not hyps[0]: return []
279
+
280
  hyp = hyps[0][0] if isinstance(hyps[0], list) else hyps[0]
281
+ word_timestamps = [(w.start_offset_ms/1000, w.end_offset_ms/1000, w.word) for w in hyp.words]
282
+
283
+ # Application de la logique d'optimisation
284
+ return group_words_to_subtitles(word_timestamps)
285
 
286
+ # --- Mode CTC (Soloba, QuartzNet) : Utilisation de ctc-segmentation ---
287
  text = model.transcribe([wav])[0].strip()
288
+ if not text: return []
 
289
 
290
  with torch.no_grad():
291
  logits, logit_len = model.forward(input_signal=x, input_signal_length=ln)
292
 
293
  words = text.split()
294
+ if not words: return []
 
295
 
296
+ # CTC Segmentation
297
  config = CtcSegmentationParameters()
298
  config.char_list = list(model.tokenizer.vocab.keys())
299
  gt, _ = prepare_text(config, words)
300
+
301
+ # Suppression des avertissements de ctc_segmentation
302
+ with warnings.catch_warnings():
303
+ warnings.simplefilter("ignore")
304
+ timings, _, _ = ctc_segmentation(config, logits.cpu().numpy()[0], gt)
305
+
306
  tps = total_s / logit_len.cpu().numpy()[0]
307
 
308
+ # Alignement des mots
309
  aligned = [(timings[i] * tps,
310
  timings[i+1] * tps if i+1 < len(timings) else total_s,
311
  words[i]) for i in range(len(words))]
312
 
313
+ # Application de la logique d'optimisation
314
+ return group_words_to_subtitles(aligned)
315
+
316
+
317
+ # --- Fonction d'Extraction Audio (Optimisée) ---
318
+
319
+ def extract_audio(video_path, wav_path):
320
+ """Extrait l'audio de la vidéo avec gestion des ressources"""
321
+ try:
322
+ video = VideoFileClip(video_path)
323
+ video.audio.write_audiofile(
324
+ wav_path, fps=16000, codec="pcm_s16le", verbose=False, logger=None
325
+ )
326
+ video.close()
327
+ except Exception as e:
328
+ raise Exception(f"Erreur lors de l'extraction audio: {e}")
329
 
330
+ # --- Fonction d'Incrustation Vidéo (Simplifiée et Stabilisée) ---
331
 
332
  def burn(video, subs):
333
+ """Ajoute les sous-titres à la vidéo en utilisant TextClip (plus stable)"""
334
+ out_path = "RobotsMali_Subtitled.mp4"
335
+ if os.path.exists(out_path): os.remove(out_path)
336
+
337
  clip = VideoFileClip(video)
338
  W, H = clip.size
339
 
340
+ subtitle_clips = []
 
 
 
 
 
 
 
 
 
 
341
  for start, end, text in subs:
342
+ # Utilisation de TextClip pour la stabilité, le style et l'alignement
343
+ # Fond sombre semi-transparent pour la lisibilité sur TOUS fonds vidéo
344
+ txt_clip = TextClip(
345
+ text.upper(),
346
+ fontsize=H // 20,
347
+ color='white',
348
+ font='Roboto-Bold', # Utilisation d'une police web standard pour éviter les erreurs Colab
349
+ bg_color='rgba(0, 0, 0, 0.7)',
350
+ method='caption',
351
+ size=(W * 0.9, None) # 90% de la largeur pour le wrap
352
+ )
 
353
 
354
+ duration = max(0.1, end - start) # Durée minimale de 0.1s
355
+ txt_clip = txt_clip.set_pos(('center', H * 0.85)).set_duration(duration).set_start(start)
356
+ subtitle_clips.append(txt_clip)
 
357
 
358
  # Composition finale
359
+ final = CompositeVideoClip([clip] + subtitle_clips)
 
360
 
361
  # Écriture de la vidéo finale
362
  final.write_videofile(
 
364
  codec="libx264",
365
  audio_codec="aac",
366
  fps=clip.fps,
367
+ bitrate="4000k", # Bitrate fixé à 4000k pour une qualité HD standard
368
+ preset="medium",
369
  verbose=False,
370
  logger=None,
371
  temp_audiofile="temp-audio.m4a",
 
375
  # Nettoyage
376
  clip.close()
377
  final.close()
378
+ for layer in subtitle_clips:
379
  layer.close()
380
 
381
  return out_path
382
 
383
+ # --- Pipeline Principal ---
384
+
385
  def pipeline(video_file, model_name):
386
  """Pipeline principal de traitement"""
387
+ if not NEMO_LOADED:
388
+ return "❌ ERREUR FATALE : NeMo/CTC Segmentation n'a pas été importé. Exécutez la cellule d'installation.", None
389
+
390
  if video_file is None:
391
  return "Veuillez importer une vidéo.", None
392
 
 
393
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
394
+
395
+ yield f"🧠 Chargement du modèle {model_name} sur {device}..."
396
+
397
  try:
398
+ model = load_asr_model(model_name)
399
+
400
+ yield "🎶 Extraction audio en cours..."
401
+ wav_path = os.path.join(tempfile.gettempdir(), "audio.wav")
 
 
 
 
 
 
 
 
402
  extract_audio(video_file, wav_path)
403
+
404
+ yield "📝 Transcription et alignement des mots en cours..."
405
  subs = transcribe(model, device, wav_path, model_name)
406
+
407
+ if not subs:
408
+ return "⚠️ ALERTE : Aucune parole détectée ou alignement échoué. Vérifiez la qualité audio.", None
409
+
410
+ yield "🎬 Incrustation des sous-titres sur la vidéo..."
411
  final_video = burn(video_file, subs)
412
 
413
  # Nettoyage des fichiers temporaires
414
  if os.path.exists(wav_path):
415
  os.remove(wav_path)
416
 
417
+ return "✅ PRODUCTION TERMINÉE avec succès!", final_video
418
 
419
  except Exception as e:
420
  print(f"Erreur dans le pipeline: {e}")
421
+ # Nettoyage en cas d'erreur
422
+ if 'wav_path' in locals() and os.path.exists(wav_path): os.remove(wav_path)
423
+ return f"❌ ERREUR FATALE : {str(e)}", None
424
+
425
+
426
+ # ----------------------------------------------------------------------
427
+ # INTERFACE GRADIO - "ROBOTSMALI V8.0 : MINIMALIST BLUE"
428
+ # ----------------------------------------------------------------------
429
+
430
+ # Statut de l'application
431
+ if NEMO_LOADED:
432
+ APP_STATUS = " SYSTÈME PRÊT : Toutes les dépendances (NeMo/CTC) sont chargées."
433
+ else:
434
+ APP_STATUS = "❌ DÉPENDANCES MANQUANTES : Veuillez exécuter la commande d'installation."
435
+
436
+
437
+ with gr.Blocks(theme=gr.themes.Default(), title="RobotsMali V8.0", css=CUSTOM_CSS) as demo:
438
+ gr.Markdown(
439
+ f"""
440
+ # ⚡ **ROBOTSMALI V8.0 : MINIMALIST BLUE** ⚡
441
+ ### Sous-titrage et alignement de haute précision (RNNT & CTC).
442
+ *Statut : {APP_STATUS}*
443
+ ---
444
+ """
 
 
 
 
 
 
 
445
  )
446
+
447
+ with gr.Row(equal_height=True):
448
+ with gr.Column(scale=1):
449
+ with gr.Group(elem_classes=["block"]):
450
+
451
+ gr.Markdown("### 1. Source & Configuration")
452
+
453
+ video = gr.Video(
454
+ label="Vidéo d'entrée (MP4, MOV, AVI)",
455
+ height=300,
456
+ elem_classes=["gr-file-input"]
457
+ )
458
+
459
+ model = gr.Dropdown(
460
+ list(MODELS.keys()),
461
+ value="Soloni V1 (RNnT - Précis)",
462
+ label="Modèle de Reconnaissance Vocale",
463
+ info="RNnT (Soloni): meilleur alignement. CTC (Soloba/QuartzNet): plus rapide.",
464
+ interactive=NEMO_LOADED,
465
+ )
466
+
467
+ btn = gr.Button("▶️ **INITIER LA PRODUCTION**", variant="primary", interactive=NEMO_LOADED)
468
+
469
+ with gr.Column(scale=2):
470
+ with gr.Group(elem_classes=["block"]):
471
+ gr.Markdown("### 2. Flux de Production & Résultat")
472
+
473
+ status = gr.Markdown(
474
+ value="En attente du fichier source...",
475
+ label="Journal de Bord",
476
+ elem_classes=["gr-status"]
477
+ )
478
+
479
+ out = gr.Video(
480
+ label="Vidéo sous-titrée",
481
+ height=300,
482
+ interactive=False
483
+ )
484
 
485
+ # Explication de la correction :
486
+ gr.Markdown(
487
+ """
488
+ ---
489
+ **Note de l'Expert :** La logique d'alignement a été optimisée et unifiée pour les 6 modèles:
490
+ - **Optimisation:** Chaque ligne de sous-titre respecte désormais simultanément les limites de **4 mots**, **45 caractères** et une durée maximale de **3.5 secondes**, assurant un rythme de lecture optimal.
491
+ - **Unification:** La même fonction d'optimisation est appliquée à la sortie de tous les modèles (RNNT et CTC).
492
+ """
493
+ )
494
+
495
+ # L'utilisation de 'fn' dans gr.Examples est dépréciée. Le clic est le standard.
496
+ btn.click(
497
+ fn=pipeline,
498
+ inputs=[video, model],
499
+ outputs=[status, out]
500
+ )
501
 
502
  if __name__ == "__main__":
503
+ demo.launch(share=True)