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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +144 -447
app.py CHANGED
@@ -1,8 +1,6 @@
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
@@ -11,493 +9,192 @@ 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)
267
  x = torch.tensor(audio, dtype=torch.float32).unsqueeze(0).to(device)
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(
363
- out_path,
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",
372
- remove_temp=True
373
- )
374
-
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)
 
1
  # -*- coding: utf-8 -*-
2
+ """ROBOTSMALI VIDEO CAPTIONING V8 - MINIMALIST BLUE (STABLE VERSION)"""
3
+
 
 
4
  import gradio as gr
5
  import numpy as np
6
  import torch
 
9
  import tempfile
10
  import warnings
11
  from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip
12
+ from typing import List, Tuple
13
+ from huggingface_hub import hf_hub_download, snapshot_download
 
 
 
 
14
 
15
+ # ------------------------------------------------------------
16
+ # Import NeMo
17
+ # ------------------------------------------------------------
18
  try:
19
  from nemo.collections import asr as nemo_asr
 
20
  from ctc_segmentation import ctc_segmentation, CtcSegmentationParameters, prepare_text
21
  NEMO_LOADED = True
22
+ except Exception as e:
23
+ print("❌ ERREUR : NeMo ou ctc-segmentation non installé.")
24
  NEMO_LOADED = False
 
 
 
 
 
 
 
 
25
 
26
+ # ------------------------------------------------------------
27
+ # Modèles RobotsMali
28
+ # ------------------------------------------------------------
29
  MODELS = {
30
  "Soloni V1 (RNnT - Précis)": ("RobotsMali/soloni-114m-tdt-ctc-V1", "soloni-114m-tdt-ctc-V1.nemo", "rnnt"),
31
  "Soloba V1 (CTC - Équilibré)": ("RobotsMali/soloba-ctc-0.6b-V1", None, "ctc"),
32
  "QuartzNet V1 (CTC - Rapide)": ("RobotsMali/stt-bm-quartznet15x5-V1", None, "ctc"),
 
 
 
 
33
  }
 
34
 
35
+ asr_pipeline = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ # ------------------------------------------------------------
38
+ # Chargement modèle robuste
39
+ # ------------------------------------------------------------
40
  def load_ctc_model_safe(repo_id):
 
 
41
  try:
 
42
  return nemo_asr.models.EncDecCTCModelBPE.from_pretrained(model_name=repo_id)
43
+ except:
 
 
44
  with tempfile.TemporaryDirectory() as tmpdir:
45
+ path = snapshot_download(repo_id, cache_dir=tmpdir)
46
+ for f in os.listdir(path):
47
+ if f.endswith(".nemo"):
48
+ return nemo_asr.models.EncDecCTCModelBPE.restore_from(os.path.join(path, f))
49
+ raise RuntimeError("Impossible de charger le modèle CTC.")
50
+
51
+ def load_asr_model(model_name):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  repo_id, nemo_file, mode = MODELS[model_name]
53
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
54
 
55
  if model_name not in asr_pipeline:
 
 
 
56
  if mode == "rnnt":
 
 
57
  nemo_path = hf_hub_download(repo_id, filename=nemo_file)
58
+ model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(nemo_path)
59
  else:
60
+ model = load_ctc_model_safe(repo_id)
61
+
62
+ model.to(device).eval()
63
+ asr_pipeline[model_name] = model
 
 
 
64
 
65
  return asr_pipeline[model_name]
66
 
67
+ # ------------------------------------------------------------
68
+ # Groupage des mots en sous-titres
69
+ # ------------------------------------------------------------
70
+ MAX_WORDS = 4
71
+ MAX_CHARS = 45
72
+ MAX_DURATION = 3.5
73
+
74
+ def group_words(words):
75
+ subs, group = [], []
76
+
77
+ def commit(g):
78
+ if g:
79
+ subs.append((g[0][0], g[-1][1], " ".join([w[2] for w in g])))
80
 
81
+ for w in words:
82
+ test = group + [w]
83
+ text = " ".join([t[2] for t in test])
84
+ duration = test[-1][1] - test[0][0]
85
+
86
+ if len(test) > MAX_WORDS or len(text) > MAX_CHARS or duration > MAX_DURATION:
87
+ commit(group)
88
+ group = [w]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  else:
90
+ group.append(w)
91
+
92
+ commit(group)
93
+ return subs
94
+
95
+ # ------------------------------------------------------------
96
+ # Transcription + Alignement
97
+ # ------------------------------------------------------------
98
+ def transcribe(model, device, wavfile, model_name):
99
+ audio, sr = sf.read(wavfile)
100
+ if audio.ndim == 2: audio = np.mean(audio, axis=1)
 
 
 
 
 
101
  x = torch.tensor(audio, dtype=torch.float32).unsqueeze(0).to(device)
102
  ln = torch.tensor([x.shape[1]]).to(device)
103
  total_s = len(audio) / sr
104
 
105
+ # RNNT direct timestamps
106
  if "Soloni" in model_name:
107
+ hyps = model.decode_and_align(*model.preprocessor(input_signal=x, input_signal_length=ln))
108
+ words = [(w.start_offset_ms/1000, w.end_offset_ms/1000, w.word) for w in hyps[0][0].words]
109
+ return group_words(words)
110
+
111
+ # CTC + segmentation
112
+ text = model.transcribe([wavfile])[0]
113
+ if not text.strip(): return []
114
+ with torch.no_grad(): logits, loglen = model(x, ln)
115
+ words = text.strip().split()
116
+ cfg = CtcSegmentationParameters()
117
+ cfg.char_list = list(model.tokenizer.vocab.keys())
118
+ gt, _ = prepare_text(cfg, words)
119
+ timings, _, _ = ctc_segmentation(cfg, logits.cpu().numpy()[0], gt)
120
+ tps = total_s / loglen.cpu().numpy()[0]
121
+
122
+ aligned = [(timings[i]*tps,
123
+ timings[i+1]*tps if i+1 < len(timings) else total_s,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  words[i]) for i in range(len(words))]
125
+ return group_words(aligned)
126
+
127
+ # ------------------------------------------------------------
128
+ # Extraction audio
129
+ # ------------------------------------------------------------
130
+ def extract_audio(video, wav):
131
+ v = VideoFileClip(video)
132
+ v.audio.write_audiofile(wav, fps=16000, codec="pcm_s16le", logger=None)
133
+ v.close()
134
+
135
+ # ------------------------------------------------------------
136
+ # Burn subtitles
137
+ # ------------------------------------------------------------
 
 
 
 
 
 
 
138
  def burn(video, subs):
139
+ output = "RobotsMali_Subtitled.mp4"
 
 
 
140
  clip = VideoFileClip(video)
141
  W, H = clip.size
142
+ layers = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
+ for start, end, text in subs:
145
+ txt = TextClip(
146
+ text.upper(), fontsize=H//20, color='white', bg_color='rgba(0,0,0,0.7)',
147
+ method='caption', size=(W*0.9, None)
148
+ ).set_pos(("center", H*0.85)).set_duration(end-start).set_start(start)
149
+ layers.append(txt)
150
+
151
+ final = CompositeVideoClip([clip] + layers)
152
+ final.write_videofile(output, codec="libx264", audio_codec="aac", fps=clip.fps, logger=None)
153
+ clip.close(); final.close()
154
+ return output
155
+
156
+ # ------------------------------------------------------------
157
+ # PIPELINE STABLE (PAS DE YIELD)
158
+ # ------------------------------------------------------------
159
  def pipeline(video_file, model_name):
 
 
 
 
160
  if video_file is None:
161
+ return "⚠️ Importez une vidéo.", None
162
 
163
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
164
+ status = f"🧠 Chargement du modèle {model_name}..."
165
+
 
166
  try:
167
  model = load_asr_model(model_name)
168
+ status += "\n🎶 Extraction audio..."
169
+ wav = os.path.join(tempfile.gettempdir(), "audio.wav")
170
+ extract_audio(video_file, wav)
171
+
172
+ status += "\n📝 Transcription..."
173
+ subs = transcribe(model, device, wav, model_name)
174
+ if not subs: return "⚠️ Aucun mot détecté.", None
175
+
176
+ status += "\n🎬 Sous-titrage..."
177
+ out = burn(video_file, subs)
178
+
179
+ if os.path.exists(wav): os.remove(wav)
180
+ status += "\n✅ Terminé !"
181
+
182
+ return status, out
 
 
 
 
183
 
184
  except Exception as e:
185
+ return f" ERREUR : {e}", None
186
+
187
+ # ------------------------------------------------------------
188
+ # Interface
189
+ # ------------------------------------------------------------
190
+ with gr.Blocks() as demo:
191
+ gr.Markdown("# ⚡ ROBOTSMALI V8 — MINIMALIST BLUE")
192
+ video = gr.Video(label="Importer une vidéo")
193
+ model = gr.Dropdown(list(MODELS.keys()), value="Soloni V1 (RNnT - Précis)")
194
+ run = gr.Button("▶️ PRODUIRE")
195
+ status = gr.Markdown()
196
+ out = gr.Video()
197
+
198
+ run.click(pipeline, inputs=[video, model], outputs=[status, out])
199
+
200
+ demo.launch(share=True)