Incrustar subtítulos en un vídeo de 1080×1920 para TikTok o Reels suele empezar con un plan razonable: el feed tapa más o menos la quinta parte inferior del encuadre, así que el subtítulo tiene que quedar por encima. Con el filtro subtitles de ffmpeg recurres a force_style y ajustas MarginV. Renderizas, y el subtítulo aparece en otro sitio completamente distinto, y bastante más grande de lo esperado.
El motivo es que un archivo .srt no sabe nada del tamaño de tu vídeo. ffmpeg lo convierte en un script ASS con un lienzo fijo y diminuto, y cada número de force_style se interpreta en las unidades de ese lienzo. Abajo hay mediciones sobre un render real de 1080×1920 y un force_style que coloca el subtítulo justo donde dicen las cuentas.
El lienzo de 384×288 detrás de cada .srt
Convierte cualquier .srt con ffmpeg y mira la cabecera:
ffmpeg -i one.srt one.ass && head -12 one.ass
[Script Info]
; Script generated by FFmpeg/Lavc61.19.100
ScriptType: v4.00+
PlayResX: 384
PlayResY: 288
ScaledBorderAndShadow: yes
...
Style: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1
PlayResX: 384 y PlayResY: 288 son el sistema de coordenadas del script, un lienzo pequeño de 4:3. libass lo escala al fotograma sobre el que dibuja: los valores verticales por 1920 / 288 = 6.67 y los horizontales por 1080 / 384 = 2.81. MarginV, Fontsize, Outline, MarginL y MarginR en force_style están todos en esos píxeles del script.
Así que MarginV=28 no son "28 píxeles" ni "el 28% de la altura". En un fotograma de 1920 píxeles son unos 187 píxeles. Fontsize=22 son unos 147 píxeles. Lo segundo lo comprobé directamente: una H mayúscula con Fontsize=22 y con PlayResY=1920,Fontsize=146.67 ocupa exactamente los mismos píxeles (filas 1258 a 1351).
Medir dónde cae el texto
Para tener números en lugar de impresiones, renderiza un subtítulo sobre un fotograma gris liso de 1080×1920, lee el fotograma como bytes grises en bruto y busca los píxeles blancos. Basta con la biblioteca estándar de Python:
import subprocess, sys
FF = sys.argv[1] # path to an ffmpeg built with libass
W, H = 1080, 1920
def text_box(style, srt="one.srt"):
"""Burn one caption onto a grey 1080x1920 frame and return where the white text landed."""
vf = f"subtitles={srt}:force_style='{style}',format=gray"
raw = subprocess.run(
[FF, "-v", "error", "-f", "lavfi", "-i", f"color=c=0x303030:s={W}x{H}:d=2",
"-vf", vf, "-ss", "1", "-frames:v", "1", "-f", "rawvideo", "-"],
capture_output=True, check=True).stdout
pts = [(i // W, i % W) for i, v in enumerate(raw) if v > 200]
rows = sorted({y for y, _ in pts})
lines = 1 + sum(1 for a, b in zip(rows, rows[1:]) if b - a > 20)
xs = [x for _, x in pts]
return rows[0], rows[-1], min(xs), max(xs), lines
El subtítulo de one.srt es una línea real de 38 caracteres sacada de una transcripción, "the Saharan dust cloud that we've seen", visible de 0 a 2 segundos. El umbral de 200 captura el relleno blanco e ignora el contorno negro; un hueco de más de 20 filas vacías cuenta como una nueva línea de texto.
Los resultados
El objetivo es el que usa el editor de vídeo de osFoundry para 9:16: el feed tapa el 20% inferior del encuadre y los subtítulos se sitúan al 72% de la altura, lo que en 1920 píxeles significa que el texto debería terminar hacia la fila 1382.
| force_style | Borde superior del texto | Borde inferior del texto | Líneas |
|---|---|---|---|
MarginV=10,Fontsize=22 |
1437 (74.8%) | 1826 (95.1%) | 3 |
MarginV=28,Fontsize=22 |
1317 (68.6%) | 1706 (88.9%) | 3 |
MarginV=81,Fontsize=22 |
964 (50.2%) | 1352 (70.4%) | 3 |
PlayResX=1080,PlayResY=1920,MarginV=538,Fontsize=80 |
1235 (64.3%) | 1366 (71.1%) | 2 |
Se ven tres cosas.
MarginV=28, el número que sale si tratas el margen como un porcentaje (100 − 72), deja la última línea al 89% de la altura, muy dentro de la franja tapada. MarginV=81 es ese mismo 28% expresado en píxeles del script (0.28 × 288), y ahí el borde inferior sí cae en el 70%.
Pero corregir solo el margen no basta. Con Fontsize=22, la línea de 38 caracteres se convierte en tres líneas de texto de 147 píxeles, y como el subtítulo está anclado por abajo, las líneas de más crecen hacia arriba. La parte superior del subtítulo llega al 50% del encuadre, que en un clip de alguien hablando a cámara es el pecho o la cara.
Los laterales tienen el mismo problema en la otra dirección. Los valores por defecto MarginL=10 y MarginR=10 se quedan en unos 28 píxeles en un fotograma de 1080 de ancho (pasar MarginL de 10 a 23 desplazó el texto 37 píxeles, 2.85 por unidad), así que el texto partido puede meterse en las franjas del 6% donde están los botones de me gusta y compartir.
Trabaja en píxeles reales
La solución más sencilla es decirle a libass que el lienzo del script es el propio vídeo. PlayResX y PlayResY son campos de la sección de información del script, pero libass también los acepta en force_style, y entonces cada margen y cada tamaño van en píxeles del fotograma:
def safe_style(top=0.08, bottom=0.20, sides=0.06, baseline=0.72, size=80):
"""force_style that works in real pixels, with the caption sitting on `baseline`."""
return ",".join([
f"PlayResX={W}", f"PlayResY={H}",
"Alignment=2",
f"MarginV={round((1 - baseline) * H)}",
f"MarginL={round(sides * W) + 15}", f"MarginR={round(sides * W) + 15}",
f"Fontsize={size}", "Outline=5",
])
Eso genera PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5, la última fila de la tabla: dos líneas, borde inferior en el 71.1% y el texto entre x = 200 y 877, bien lejos de las dos franjas laterales de 65 píxeles. El ajuste de línea inteligente que libass usa por defecto parte la línea en dos de anchura parecida en lugar de una larga y otra corta.
En la línea de comandos:
ffmpeg -i in.mp4 -vf "subtitles=captions.srt:force_style='PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5'" -c:a copy out.mp4
Un detalle al hacer las cuentas: MarginV mide hasta el borde inferior de la caja de la línea, que incluye espacio para los descendentes, no hasta la línea base. Con MarginV=540, la parte inferior de una H mayúscula quedó en la fila 1351, unos 29 píxeles por encima de 1920 − 540 = 1380. Si te importan unos pocos píxeles, mide tu propia fuente en lugar de fiarte del número.
Si prefieres no depender de force_style, escribir tú mismo el archivo .ass con PlayResX: 1080 y PlayResY: 1920 en la cabecera da el mismo resultado y te permite dar estilo a cada línea por separado.
Cuánto cuesta incrustarlos
En un clip de 1080×1920 de 32 segundos con 13 subtítulos, la incrustación con el estilo de arriba tardó 13.2 segundos en un Mac M1 con ffmpeg 7.1 y los ajustes por defecto de libx264. Recodificar el mismo clip sin filtro de subtítulos tardó 13.3 segundos. Dibujar el texto se pierde en el ruido; todo el coste es la codificación.
A qué franja apuntar
Las cifras de zona segura no son un estándar único. Las de arriba son las del propio editor de osFoundry (SAFE_AREAS en su código del lienzo: 9:16 es 8% arriba, 20% abajo, 6% a los lados) y describen lo que un feed dibuja encima de una publicación normal. Las plataformas publican indicaciones sobre todo para anuncios:
| Ubicación | Dejar libre | Fuente |
|---|---|---|
| Anuncios de Instagram Reels | 14% arriba, 35% abajo, 6% a los lados | Guía de anuncios de Meta, especificaciones de vídeo de Reels |
| Anuncios in-feed de TikTok | depende de la longitud del texto del anuncio; archivos de zona segura descargables, con versiones aparte para regiones de derecha a izquierda y para anuncios con anchors | Centro de ayuda de TikTok Ads |
| Anuncios de YouTube Shorts | una plantilla de zona segura de 1080×1920; los elementos superpuestos varían según el formato | Ayuda de Google Ads |
| Editor de osFoundry, 9:16 | 8% arriba, 20% abajo, 6% a los lados | SAFE_AREAS |
Para un anuncio en Reels, la misma función con bottom=0.35 y una línea base de 0.62 más o menos mantiene el subtítulo por encima de la franja de Meta. La gracia de calcular el estilo en lugar de escribirlo a mano es que cambiar de destino es cuestión de un argumento.
La app deriva la altura del subtítulo a partir de la zona segura en una función pequeña, que explica el porqué en su comentario:
/// Where captions sit, as a fraction of frame height from the top.
///
/// 🔴 Derived from the platform safe area, not from a fixed margin. On a vertical feed the
/// bottom fifth of the frame is under the platform's own caption and buttons, so a caption
/// placed at the conventional 90% is guaranteed to be covered — which is the whole reason C1
/// shipped the safe-area overlay.
pub fn caption_baseline(aspect: &str) -> f64 {
match aspect {
"9:16" => 0.72,
"1:1" => 0.84,
_ => 0.88,
}
}
La app de escritorio con ese editor es gratuita en la página de descarga de osFoundry, y en su página local-first se explica más sobre cómo osFoundry mantiene este tipo de trabajo en tu propia máquina.

Top comments (0)