Idioma auto y subtitulos incrustados palabra a palabra con color por hablante
Browse files- app.py +61 -3
- 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,
|
| 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=
|
| 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
|