File size: 7,709 Bytes
f12d1d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Genera el vídeo con los subtítulos incrustados, palabra a palabra.
Cada hablante tiene su color, tomado del degradado del logo de subtify, y dentro
de cada frase la palabra que se está pronunciando se resalta en el momento exacto
que marca su timestamp. Eso hace que un desajuste de tiempos se vea a simple vista.
Se usa el formato ASS con etiquetas de karaoke y se incrusta con ffmpeg, que ya
forma parte del proyecto: renderizar con Remotion exigiría Node y Chromium, que el
Space no tiene.
"""
import os
import subprocess
# Degradado del logo de subtify: púrpura -> magenta -> naranja salmón.
# El orden alterna tonos para que dos hablantes seguidos no se parezcan.
SPEAKER_COLORS = [
"#6C2A8E", # púrpura
"#E8734A", # naranja salmón
"#B83C6E", # magenta
"#4A2A7A", # índigo
"#F0A05A", # ámbar
"#D94F8C", # rosa
]
FONT_NAME = "DejaVu Sans"
FONT_SIZE = 42
# Reagrupación de palabras en frases legibles
MAX_CHARS = 60
MAX_DURATION = 5.0
MAX_GAP = 0.8
def _hex_to_ass(color, alpha=0):
"""Convierte #RRGGBB al formato de color de ASS, que es &HAABBGGRR."""
color = color.lstrip("#")
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
def _text_color_for(background):
"""Elige texto claro u oscuro según lo luminoso que sea el fondo."""
color = background.lstrip("#")
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return "#101827" if luminance > 0.6 else "#FFFFFF"
def _dim(color, factor=0.45):
"""Versión atenuada de un color, para las palabras aún no pronunciadas."""
color = color.lstrip("#")
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
return "#%02X%02X%02X" % (int(r * factor), int(g * factor), int(b * factor))
def _timestamp(seconds):
"""Formato de tiempo de ASS: h:mm:ss.cc"""
if seconds < 0:
seconds = 0.0
centis = int(round(seconds * 100))
hours, centis = divmod(centis, 360000)
minutes, centis = divmod(centis, 6000)
secs, centis = divmod(centis, 100)
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
def speaker_order(chunks):
"""Asigna un índice estable a cada hablante, por orden de aparición."""
order = {}
for chunk in chunks:
speaker = chunk.get("speaker") or "SPEAKER_00"
if speaker not in order:
order[speaker] = len(order)
return order
def group_into_phrases(chunks):
"""Agrupa las palabras en frases de subtítulo.
Una frase se corta cuando cambia el hablante, cuando se hace demasiado larga
o cuando hay un silencio apreciable.
"""
phrases = []
current = None
for chunk in chunks:
text = (chunk.get("text") or "").strip()
if not text:
continue
start = float(chunk["start"])
end = float(chunk["end"])
speaker = chunk.get("speaker") or "SPEAKER_00"
if current is None:
current = {"speaker": speaker, "start": start, "end": end, "words": []}
candidate_len = sum(len(w["text"]) + 1 for w in current["words"]) + len(text)
breaks = (
speaker != current["speaker"]
or candidate_len > MAX_CHARS
or end - current["start"] > MAX_DURATION
or start - current["end"] > MAX_GAP
)
if breaks and current["words"]:
phrases.append(current)
current = {"speaker": speaker, "start": start, "end": end, "words": []}
current["words"].append({"text": text, "start": start, "end": end})
current["end"] = end
if current and current["words"]:
phrases.append(current)
return phrases
def build_ass(chunks, width=1920, height=1080):
"""Construye el contenido del fichero ASS con el karaoke por palabra."""
order = speaker_order(chunks)
phrases = group_into_phrases(chunks)
lines = [
"[Script Info]",
"ScriptType: v4.00+",
f"PlayResX: {width}",
f"PlayResY: {height}",
"WrapStyle: 0",
"ScaledBorderAndShadow: yes",
"",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, "
"ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
"MarginL, MarginR, MarginV, Encoding",
]
# Un estilo por hablante. BorderStyle 3 pinta una caja sólida con BackColour,
# que es lo que da el fondo de color de cada hablante.
for speaker, index in order.items():
background = SPEAKER_COLORS[index % len(SPEAKER_COLORS)]
text_color = _text_color_for(background)
lines.append(
f"Style: S{index},{FONT_NAME},{FONT_SIZE},"
f"{_hex_to_ass(text_color)},{_hex_to_ass(_dim(text_color))},"
f"{_hex_to_ass(background)},{_hex_to_ass(background)},"
f"-1,0,0,0,100,100,0,0,3,6,0,2,60,60,60,1"
)
lines += ["", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, "
"MarginV, Effect, Text"]
for phrase in phrases:
index = order[phrase["speaker"]]
parts = []
cursor = phrase["start"]
for word in phrase["words"]:
# Un hueco antes de la palabra se rellena con un karaoke vacío, o el
# resaltado se adelantaría respecto al audio
gap = int(round((word["start"] - cursor) * 100))
if gap > 0:
parts.append(f"{{\\k{gap}}}")
duration = max(1, int(round((word["end"] - word["start"]) * 100)))
parts.append(f"{{\\k{duration}}}{word['text']} ")
cursor = word["end"]
text = "".join(parts).strip()
lines.append(
f"Dialogue: 0,{_timestamp(phrase['start'])},{_timestamp(phrase['end'])},"
f"S{index},,0,0,0,,{text}"
)
return "\n".join(lines) + "\n"
def get_video_size(video_path):
"""Devuelve (ancho, alto) del vídeo, para que el ASS use su misma resolución."""
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", video_path],
capture_output=True, text=True, check=True,
)
width, height = result.stdout.strip().split("x")[:2]
return int(width), int(height)
def render_subtitled_video(video_path, chunks, output_path, ass_path=None):
"""Incrusta los subtítulos en el vídeo.
Args:
video_path: vídeo original.
chunks: lista de palabras con start, end, text y speaker.
output_path: dónde guardar el vídeo resultante.
ass_path: dónde dejar el ASS generado; junto al vídeo si no se indica.
Returns:
str: ruta del vídeo generado.
"""
if not chunks:
raise ValueError("No hay palabras que subtitular")
width, height = get_video_size(video_path)
ass_path = ass_path or os.path.splitext(output_path)[0] + ".ass"
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(ass_path, "w", encoding="utf-8") as f:
f.write(build_ass(chunks, width, height))
# El filtro subtitles no admite rutas con caracteres sin escapar
escaped = ass_path.replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'")
subprocess.run(
["ffmpeg", "-y", "-v", "error", "-i", video_path,
"-vf", f"subtitles='{escaped}'",
"-c:a", "copy", output_path],
check=True,
)
return output_path
|