Maximofn commited on
Commit
f12d1d9
·
verified ·
1 Parent(s): 16914ee

Idioma auto y subtitulos incrustados palabra a palabra con color por hablante

Browse files
Files changed (2) hide show
  1. app.py +61 -3
  2. subtitle_render.py +224 -0
app.py CHANGED
@@ -65,6 +65,7 @@ from audio import get_audio_from_video
65
  from transcribe import transcribe, get_language_dict
66
  from diarize_library import diarize_audio
67
  from youtube_download import download_video, get_video_info, is_valid_youtube_url
 
68
  import traceback
69
 
70
  # Carpetas de trabajo del pipeline. Se crean al arrancar porque el Space pierde
@@ -84,6 +85,9 @@ WORK_FOLDERS = [
84
 
85
  for _folder in WORK_FOLDERS:
86
  os.makedirs(_folder, exist_ok=True)
 
 
 
87
  import json
88
 
89
  NUMBER = 100
@@ -318,14 +322,16 @@ def trascribe_audio(input_audio_path, source_languaje):
318
  gr.update(value=transcription)
319
  ]
320
 
321
- # Get language dict
 
322
  language_dict = get_language_dict()
 
323
 
324
  # Transcribe audio file. Se informa del fallo en la propia interfaz: si la
325
  # excepción sube hasta gradio, el cliente solo recibe el nombre de la clase
326
  # y hay que rebuscar en los logs del Space para saber qué pasó.
327
  try:
328
- transcription_str, transcription_dict = transcribe(input_audio_path, language_dict[source_languaje]["transcriber"], DEVICE, CHUNK_SECONDS, CHUNK_OVERLAP_SECONDS)
329
  except Exception:
330
  error = traceback.format_exc()
331
  print(error)
@@ -390,6 +396,51 @@ def translate_transcription(transcription_path, diarization_path, source_languaj
390
  gr.Textbox(value=translated_transcription)
391
  )
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def add_translated_subtitles_to_video(original_video_path, original_audio_path, original_audio_translated_path):
394
  print('*'*NUMBER)
395
  print("Add subtitles to video")
@@ -717,7 +768,7 @@ def subtify():
717
  config_block = gr.Column(scale=1, visible=visible)
718
  with config_block:
719
  with gr.Row():
720
- source_languaje = gr.Dropdown(visible=True, label="Source languaje", show_label=True, value="English", choices=language_dict, scale=1, interactive=True, info="Language of the video")
721
  target_languaje = gr.Dropdown(visible=True, label="Target languaje", show_label=True, value="Español", choices=language_dict, scale=1, interactive=True, info="Language to translate the subtitles")
722
  with gr.Accordion("Advanced settings", open=True, visible=True) as Advanced_setings:
723
  number_of_speakers = gr.Number(visible=True, label="Number of speakers", show_label=True, value=0, interactive=True, info="Number of speakers in the video, if you don't know, select 0")
@@ -802,6 +853,13 @@ def subtify():
802
  inputs=[source_languaje],
803
  outputs=[merged_transcription_progress_info, merged_transcription]
804
  )
 
 
 
 
 
 
 
805
  # merged_transcription_progress_info.change(
806
  # fn=translate_transcription,
807
  # inputs=[transcription, diarization, source_languaje, target_languaje],
 
65
  from transcribe import transcribe, get_language_dict
66
  from diarize_library import diarize_audio
67
  from youtube_download import download_video, get_video_info, is_valid_youtube_url
68
+ from subtitle_render import render_subtitled_video
69
  import traceback
70
 
71
  # Carpetas de trabajo del pipeline. Se crean al arrancar porque el Space pierde
 
85
 
86
  for _folder in WORK_FOLDERS:
87
  os.makedirs(_folder, exist_ok=True)
88
+
89
+ # Opción del selector de idioma que deja que el modelo lo identifique solo
90
+ AUTO_LANGUAGE = "auto"
91
  import json
92
 
93
  NUMBER = 100
 
322
  gr.update(value=transcription)
323
  ]
324
 
325
+ # Get language dict. Con "auto" no se le pasa idioma a transcribe(), que
326
+ # entonces lo identifica con Whisper sobre los primeros 30 segundos.
327
  language_dict = get_language_dict()
328
+ language = None if source_languaje == AUTO_LANGUAGE else language_dict[source_languaje]["transcriber"]
329
 
330
  # Transcribe audio file. Se informa del fallo en la propia interfaz: si la
331
  # excepción sube hasta gradio, el cliente solo recibe el nombre de la clase
332
  # y hay que rebuscar en los logs del Space para saber qué pasó.
333
  try:
334
+ transcription_str, transcription_dict = transcribe(input_audio_path, language, DEVICE, CHUNK_SECONDS, CHUNK_OVERLAP_SECONDS)
335
  except Exception:
336
  error = traceback.format_exc()
337
  print(error)
 
396
  gr.Textbox(value=translated_transcription)
397
  )
398
 
399
+ def subtitle_video(original_video_path, source_languaje, target_languaje):
400
+ """
401
+ Burn the subtitles into the video, one word at a time and colour-coded per
402
+ speaker.
403
+
404
+ Only runs when source and target languages match: there is nothing to
405
+ translate, so the video just gets its own words on screen. Each word appears
406
+ highlighted at its own timestamp, which makes a timing drift obvious.
407
+ """
408
+ print('*'*NUMBER)
409
+ print("Subtitle video")
410
+
411
+ if source_languaje != target_languaje:
412
+ return [
413
+ gr.update(value=None, visible=False),
414
+ gr.update(value="Translation pipeline not implemented yet")
415
+ ]
416
+
417
+ merged_path = "merged_transcription_diarization/merged.json"
418
+ if not os.path.exists(merged_path):
419
+ return [
420
+ gr.update(value=None, visible=False),
421
+ gr.update(value=f"No existe {merged_path}")
422
+ ]
423
+
424
+ try:
425
+ with open(merged_path, encoding="utf-8") as f:
426
+ merged = json.load(f)
427
+
428
+ output_path = "videos/download_video_with_subtitles.mp4"
429
+ render_subtitled_video(original_video_path, merged.get("chunks", []), output_path)
430
+ except Exception:
431
+ error = traceback.format_exc()
432
+ print(error)
433
+ return [
434
+ gr.update(value=None, visible=False),
435
+ gr.update(value=error)
436
+ ]
437
+
438
+ return [
439
+ gr.update(value=output_path, visible=True),
440
+ gr.update(value="Ok")
441
+ ]
442
+
443
+
444
  def add_translated_subtitles_to_video(original_video_path, original_audio_path, original_audio_translated_path):
445
  print('*'*NUMBER)
446
  print("Add subtitles to video")
 
768
  config_block = gr.Column(scale=1, visible=visible)
769
  with config_block:
770
  with gr.Row():
771
+ source_languaje = gr.Dropdown(visible=True, label="Source languaje", show_label=True, value=AUTO_LANGUAGE, choices=[AUTO_LANGUAGE] + list(language_dict), scale=1, interactive=True, info="Language of the video, or auto to detect it")
772
  target_languaje = gr.Dropdown(visible=True, label="Target languaje", show_label=True, value="Español", choices=language_dict, scale=1, interactive=True, info="Language to translate the subtitles")
773
  with gr.Accordion("Advanced settings", open=True, visible=True) as Advanced_setings:
774
  number_of_speakers = gr.Number(visible=True, label="Number of speakers", show_label=True, value=0, interactive=True, info="Number of speakers in the video, if you don't know, select 0")
 
853
  inputs=[source_languaje],
854
  outputs=[merged_transcription_progress_info, merged_transcription]
855
  )
856
+ # Con el mismo idioma de origen y destino no hay nada que traducir, así
857
+ # que se pasa directo a incrustar los subtítulos en el vídeo
858
+ merged_transcription_progress_info.change(
859
+ fn=subtitle_video,
860
+ inputs=[original_video_path, source_languaje, target_languaje],
861
+ outputs=[subtitled_video, video_subtitled_progress_info]
862
+ )
863
  # merged_transcription_progress_info.change(
864
  # fn=translate_transcription,
865
  # inputs=[transcription, diarization, source_languaje, target_languaje],
subtitle_render.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Genera el vídeo con los subtítulos incrustados, palabra a palabra.
2
+
3
+ Cada hablante tiene su color, tomado del degradado del logo de subtify, y dentro
4
+ de cada frase la palabra que se está pronunciando se resalta en el momento exacto
5
+ que marca su timestamp. Eso hace que un desajuste de tiempos se vea a simple vista.
6
+
7
+ Se usa el formato ASS con etiquetas de karaoke y se incrusta con ffmpeg, que ya
8
+ forma parte del proyecto: renderizar con Remotion exigiría Node y Chromium, que el
9
+ Space no tiene.
10
+ """
11
+
12
+ import os
13
+ import subprocess
14
+
15
+ # Degradado del logo de subtify: púrpura -> magenta -> naranja salmón.
16
+ # El orden alterna tonos para que dos hablantes seguidos no se parezcan.
17
+ SPEAKER_COLORS = [
18
+ "#6C2A8E", # púrpura
19
+ "#E8734A", # naranja salmón
20
+ "#B83C6E", # magenta
21
+ "#4A2A7A", # índigo
22
+ "#F0A05A", # ámbar
23
+ "#D94F8C", # rosa
24
+ ]
25
+
26
+ FONT_NAME = "DejaVu Sans"
27
+ FONT_SIZE = 42
28
+
29
+ # Reagrupación de palabras en frases legibles
30
+ MAX_CHARS = 60
31
+ MAX_DURATION = 5.0
32
+ MAX_GAP = 0.8
33
+
34
+
35
+ def _hex_to_ass(color, alpha=0):
36
+ """Convierte #RRGGBB al formato de color de ASS, que es &HAABBGGRR."""
37
+ color = color.lstrip("#")
38
+ r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
39
+ return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
40
+
41
+
42
+ def _text_color_for(background):
43
+ """Elige texto claro u oscuro según lo luminoso que sea el fondo."""
44
+ color = background.lstrip("#")
45
+ r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
46
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
47
+ return "#101827" if luminance > 0.6 else "#FFFFFF"
48
+
49
+
50
+ def _dim(color, factor=0.45):
51
+ """Versión atenuada de un color, para las palabras aún no pronunciadas."""
52
+ color = color.lstrip("#")
53
+ r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
54
+ return "#%02X%02X%02X" % (int(r * factor), int(g * factor), int(b * factor))
55
+
56
+
57
+ def _timestamp(seconds):
58
+ """Formato de tiempo de ASS: h:mm:ss.cc"""
59
+ if seconds < 0:
60
+ seconds = 0.0
61
+ centis = int(round(seconds * 100))
62
+ hours, centis = divmod(centis, 360000)
63
+ minutes, centis = divmod(centis, 6000)
64
+ secs, centis = divmod(centis, 100)
65
+ return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
66
+
67
+
68
+ def speaker_order(chunks):
69
+ """Asigna un índice estable a cada hablante, por orden de aparición."""
70
+ order = {}
71
+ for chunk in chunks:
72
+ speaker = chunk.get("speaker") or "SPEAKER_00"
73
+ if speaker not in order:
74
+ order[speaker] = len(order)
75
+ return order
76
+
77
+
78
+ def group_into_phrases(chunks):
79
+ """Agrupa las palabras en frases de subtítulo.
80
+
81
+ Una frase se corta cuando cambia el hablante, cuando se hace demasiado larga
82
+ o cuando hay un silencio apreciable.
83
+ """
84
+ phrases = []
85
+ current = None
86
+
87
+ for chunk in chunks:
88
+ text = (chunk.get("text") or "").strip()
89
+ if not text:
90
+ continue
91
+
92
+ start = float(chunk["start"])
93
+ end = float(chunk["end"])
94
+ speaker = chunk.get("speaker") or "SPEAKER_00"
95
+
96
+ if current is None:
97
+ current = {"speaker": speaker, "start": start, "end": end, "words": []}
98
+
99
+ candidate_len = sum(len(w["text"]) + 1 for w in current["words"]) + len(text)
100
+ breaks = (
101
+ speaker != current["speaker"]
102
+ or candidate_len > MAX_CHARS
103
+ or end - current["start"] > MAX_DURATION
104
+ or start - current["end"] > MAX_GAP
105
+ )
106
+
107
+ if breaks and current["words"]:
108
+ phrases.append(current)
109
+ current = {"speaker": speaker, "start": start, "end": end, "words": []}
110
+
111
+ current["words"].append({"text": text, "start": start, "end": end})
112
+ current["end"] = end
113
+
114
+ if current and current["words"]:
115
+ phrases.append(current)
116
+
117
+ return phrases
118
+
119
+
120
+ def build_ass(chunks, width=1920, height=1080):
121
+ """Construye el contenido del fichero ASS con el karaoke por palabra."""
122
+ order = speaker_order(chunks)
123
+ phrases = group_into_phrases(chunks)
124
+
125
+ lines = [
126
+ "[Script Info]",
127
+ "ScriptType: v4.00+",
128
+ f"PlayResX: {width}",
129
+ f"PlayResY: {height}",
130
+ "WrapStyle: 0",
131
+ "ScaledBorderAndShadow: yes",
132
+ "",
133
+ "[V4+ Styles]",
134
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
135
+ "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, "
136
+ "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
137
+ "MarginL, MarginR, MarginV, Encoding",
138
+ ]
139
+
140
+ # Un estilo por hablante. BorderStyle 3 pinta una caja sólida con BackColour,
141
+ # que es lo que da el fondo de color de cada hablante.
142
+ for speaker, index in order.items():
143
+ background = SPEAKER_COLORS[index % len(SPEAKER_COLORS)]
144
+ text_color = _text_color_for(background)
145
+ lines.append(
146
+ f"Style: S{index},{FONT_NAME},{FONT_SIZE},"
147
+ f"{_hex_to_ass(text_color)},{_hex_to_ass(_dim(text_color))},"
148
+ f"{_hex_to_ass(background)},{_hex_to_ass(background)},"
149
+ f"-1,0,0,0,100,100,0,0,3,6,0,2,60,60,60,1"
150
+ )
151
+
152
+ lines += ["", "[Events]",
153
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, "
154
+ "MarginV, Effect, Text"]
155
+
156
+ for phrase in phrases:
157
+ index = order[phrase["speaker"]]
158
+ parts = []
159
+ cursor = phrase["start"]
160
+
161
+ for word in phrase["words"]:
162
+ # Un hueco antes de la palabra se rellena con un karaoke vacío, o el
163
+ # resaltado se adelantaría respecto al audio
164
+ gap = int(round((word["start"] - cursor) * 100))
165
+ if gap > 0:
166
+ parts.append(f"{{\\k{gap}}}")
167
+
168
+ duration = max(1, int(round((word["end"] - word["start"]) * 100)))
169
+ parts.append(f"{{\\k{duration}}}{word['text']} ")
170
+ cursor = word["end"]
171
+
172
+ text = "".join(parts).strip()
173
+ lines.append(
174
+ f"Dialogue: 0,{_timestamp(phrase['start'])},{_timestamp(phrase['end'])},"
175
+ f"S{index},,0,0,0,,{text}"
176
+ )
177
+
178
+ return "\n".join(lines) + "\n"
179
+
180
+
181
+ def get_video_size(video_path):
182
+ """Devuelve (ancho, alto) del vídeo, para que el ASS use su misma resolución."""
183
+ result = subprocess.run(
184
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
185
+ "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", video_path],
186
+ capture_output=True, text=True, check=True,
187
+ )
188
+ width, height = result.stdout.strip().split("x")[:2]
189
+ return int(width), int(height)
190
+
191
+
192
+ def render_subtitled_video(video_path, chunks, output_path, ass_path=None):
193
+ """Incrusta los subtítulos en el vídeo.
194
+
195
+ Args:
196
+ video_path: vídeo original.
197
+ chunks: lista de palabras con start, end, text y speaker.
198
+ output_path: dónde guardar el vídeo resultante.
199
+ ass_path: dónde dejar el ASS generado; junto al vídeo si no se indica.
200
+
201
+ Returns:
202
+ str: ruta del vídeo generado.
203
+ """
204
+ if not chunks:
205
+ raise ValueError("No hay palabras que subtitular")
206
+
207
+ width, height = get_video_size(video_path)
208
+ ass_path = ass_path or os.path.splitext(output_path)[0] + ".ass"
209
+
210
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
211
+ with open(ass_path, "w", encoding="utf-8") as f:
212
+ f.write(build_ass(chunks, width, height))
213
+
214
+ # El filtro subtitles no admite rutas con caracteres sin escapar
215
+ escaped = ass_path.replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'")
216
+
217
+ subprocess.run(
218
+ ["ffmpeg", "-y", "-v", "error", "-i", video_path,
219
+ "-vf", f"subtitles='{escaped}'",
220
+ "-c:a", "copy", output_path],
221
+ check=True,
222
+ )
223
+
224
+ return output_path