Subtitulador de Video de Demo
Problema
Las demos de productos grabadas en pantalla fallan con su audiencia por tres razones que no tienen nada que ver con el producto: el texto de la interfaz está en el idioma equivocado, es demasiado pequeño para leerlo, y el video a menudo no tiene narración (grabaciones silenciosas). El espectador ve clics pero no puede seguir el porqué. Los subtítulos solucionan eso, pero solo si están estilizados para ser legibles sobre una interfaz ocupada, colocados donde no cubran lo que se está demostrando, y sincronizados para que nunca sean cortados por una transición.
Esta habilidad es el pipeline completo, incluyendo los modos de fallo no obvios que cuestan horas cuando se descubren por las malas.
Cuándo usarlo
- "Agregar subtítulos a este video de demo" / "subtitular esta grabación para nuestro equipo coreano"
- Una grabación de pantalla silenciosa (
.mp4, sin pista de audio, o audio que es solo música de fondo) donde la historia debe ser contada por el propio texto de los subtítulos - Una demo de producto solo en inglés que se muestra a una audiencia no angloparlante
- Subtítulos ya existen pero están centrados en la parte inferior y cubren el panel de la demo — necesitan colocación que siga el foco
- Generar un guion de lectura para un presentador que hablará sobre el video (ver "Uso complementario")
No uses esta habilidad para: doblaje con voz sintetizada (eso es un pipeline de TTS), o extraer subtítulos que ya existen en una plataforma.
Instalación (un comando)
scripts/bootstrap.sh --dry-run # solo inspeccionar: imprime exactamente lo que falta
scripts/bootstrap.sh # instala lo que falta (pregunta una vez)
scripts/bootstrap.sh --yes --lang ja # desatendido; también prepara fuentes japonesas
bootstrap.sh es idempotente — no hace nada en una máquina que ya está lista. Detecta la plataforma y elige el instalador correcto (Homebrew / apt / dnf / pacman / winget / scoop), y como respaldo coloca un ffmpeg estático en ~/.local/bin cuando no hay un gestor de paquetes disponible. Ejecútalo como la primera acción de cualquier sesión que vaya a incrustar subtítulos; las habilidades no tienen un hook de instalación, por lo que este script es el instalador.
Requisitos previos
Ejecuta la verificación integrada primero — inspecciona la máquina y te dice exactamente lo que falta:
scripts/doctor.sh "Hiragino Sans GB" # pasa la familia CJK que planeas usar
Lo que verifica, y el comando de instalación cuando algo falta:
| Requisito | Por qué | Instalación |
|---|---|---|
ffmpeg compilado con libass | quema los subtítulos .ass (-vf ass=...) | macOS brew install ffmpeg; Windows: compilación completa de gyan.dev; Debian apt install ffmpeg |
--enable-fontconfig en la compilación de ffmpeg | resuelve fuentes por nombre de familia; sin ello obtienes una cara de respaldo silenciosa | las mismas compilaciones de arriba lo incluyen |
| una familia de fuente CJK que el sistema pueda resolver | el texto del subtítulo en sí | macOS: Hiragino Sans GB (preinstalada) · Windows: Microsoft YaHei (preinstalada) · Linux: apt install fonts-noto-cjk |
python3 (opcional) | solo export_cues.py; biblioteca estándar, sin instalaciones pip | macOS: xcode-select --install; Linux: apt install python3 |
bash, zip (opcional) | scripts y empaquetado para distribución | preinstalados en todas partes |
Sin Node, sin ImageMagick, sin paquetes pip, sin claves API. Un modelo con capacidad de visión (el propio agente) lee los fotogramas — eso no es algo que instales.
Dos notas de entorno que pican:
- Pon
ffmpegen elPATH, o exporta su ubicación para los scripts:FFMPEG=/ruta/a/ffmpeg scripts/burn.sh .... - Windows: libass resuelve fuentes a través de fontconfig/DirectWrite dependiendo de la compilación. Si los subtítulos se renderizan con el tipo de letra incorrecto, instala fontconfig (
scoop install fontconfigo MSYS2) y vuelve a verificar confc-match "Microsoft YaHei:bold".
Pipeline
Trabaja de arriba a abajo. No te saltes la Fase 2 — cada fase posterior depende de conocer la geometría de la interfaz.
Fase 1 — Inventario (2 minutos)
scripts/probe.sh INPUT.mp4
Registrar: duración, resolución, fps, tasa de bits, si existe una pista de audio. Ejecuta la verificación de audio dentro del script (volumen medio + mapa de silencio).
Decisión de la verificación de audio:
| Hallazgo de audio | Tipo de subtítulo |
|---|---|
| Sin pista de audio en absoluto | Subtítulos de narración — tú escribes la historia ("qué está pasando / por qué es importante") |
| Audio presente pero sin habla (solo música) | Igual que arriba. Confirma transcribiendo una porción de 10s: una herramienta ASR que devuelve vacío o casi vacío en audio fuerte significa música, no voz |
| Habla presente | Subtítulos de diálogo — transcribe primero con marcas de tiempo, luego limpia; no inventes el texto |
Fase 2 — Análisis visual y geometría (la fase que la gente se salta)
Extrae fotogramas a 2 fps (un clip de 60s → ~120 fotogramas; revisa primero cada 4º fotograma para el arco, luego acerca el texto):
scripts/split_frames.sh INPUT.mp4 frames/ 2
Construye tres artefactos antes de escribir un solo subtítulo:
-
Hoja de ritmo —
| tiempo | qué cambió en pantalla | texto en pantalla (textual) |. Un "ritmo" es cualquier cambio: aparece una notificación, se desliza un panel, se hace clic en un botón, ocurre un desplazamiento, un cambio de página, una transición. Anota los tipos de transición (flash blanco, corte, negro) — los subtítulos no deben cruzarlas. -
Texto de interfaz, textual, leído de la resolución original. Los fotogramas de vista previa reducidos difuminan el texto pequeño de la interfaz y lo transcribirás incorrectamente. Recorta del archivo original:
ffmpeg -i IN.mp4 -ss 12.5 -frames:v 1 -vf "crop=W:H:X:Y,scale=2*iw:-1" out.jpg(-ssdespués de-ipara extracción precisa; ver Trampas). -
Mapa de geometría — los límites en píxeles del panel "en vivo" (la cosa que se está demostrando) en píxeles de origen. Mídelo, no lo calcules a ojo:
scripts/find_edge.sh INPUT.mp4 30 2880 300 # t=30s, tira desde x=2880, 300px de anchoLuego lee la imagen de la tira devuelta y anota dónde cae el borde izquierdo del panel dentro de la tira; suma ese desplazamiento a la x de la tira para obtener el borde absoluto. Repite para otras escenas si el diseño cambia (por ejemplo, se abre un panel de chat más tarde).
Regla que se deriva del mapa de geometría: el subtítulo vive en la región libre adyacente al panel de la demo, nunca encima de él. Ver Fase 5.
Fase 3 — Redactar el texto del subtítulo
Dos reglas gobiernan todo:
- Una idea por señal. Máximo 2–3 líneas cortas. Una señal es una oración, no un párrafo.
- Presupuesto de velocidad de lectura. CJK: 3.5–5.5 caracteres por segundo es cómodo; más de 6 es un apuro; menos de 3 se siente muerto. Latino: 12–18 caracteres por segundo. Calcúlalo, no adivines.
scripts/export_cues.pyimprime la densidad por señal una vez que tienes un archivo.ass— itera contra ese número.
Reglas de sincronización de señales:
- Inicia la señal cuando el ritmo visual aterriza, no antes. Si una notificación se desliza en 1.0s, la señal comienza en 1.0s.
- Termina las señales antes de una transición (flash blanco / corte), dejando al menos 0.8s de silencio a través de ella. Un subtítulo que cruza una transición se lee como un fallo.
- Deja espacios reales. De tres a cinco segundos en blanco por minuto mantiene el video respirando; subtítulos de pared a pared agotan al espectador.
- Para subtítulos de narración, pon la razón en el subtítulo, no la mecánica: "el agente contactó por su cuenta" vence a "apareció una notificación en la esquina superior derecha".
Detente aquí y consigue que el texto sea aprobado. Texto aprobado → estilizado. Texto rechazado después del estilizado cuesta un segundo renderizado para nada.
Fase 3b — Elegir el idioma del subtítulo
El pipeline es independiente del idioma, pero tres cosas deben decidirse por idioma antes del estilizado.
1. Familia de fuente — elige una que la plataforma pueda resolver (verifica con scripts/doctor.sh <idioma>):
| Idioma del subtítulo | macOS | Windows | Linux |
|---|---|---|---|
| Chino (zh) | Hiragino Sans GB / PingFang SC | Microsoft YaHei | Noto Sans CJK SC |
| Japonés (ja) | Hiragino Sans | Yu Gothic / Meiryo | Noto Sans CJK JP |
| Coreano (ko) | Apple SD Gothic Neo | Malgun Gothic | Noto Sans CJK KR |
| Latino (en/fr/de/es…) | Helvetica Neue | Segoe UI | Inter / DejaVu Sans |
2. Velocidad de lectura — el presupuesto depende del sistema de escritura; scripts/export_cues.py selecciona el correcto automáticamente (--lang anula) y marca las señales que lo exceden:
| Sistema de escritura | Cómodo | Marcar como demasiado rápido |
|---|---|---|
| Han / Kana (zh, ja) | 3.5–5.5 caracteres/s | > 6.0 |
| Hangul (ko) | 4.0–6.0 caracteres/s | > 6.5 |
| Latino | 12–18 caracteres/s | > 19 |
3. Salto de línea — El chino y el japonés no tienen espacios entre palabras: corta por puntuación (U+FF0C, U+3001, U+3002, U+FF1A) y nunca dividas un nombre de producto. El coreano y el latino cortan por espacios; mantén una partícula coreana unida a su sustantivo. Si una línea no cabe, reajusta antes de encoger la fuente — un tipo de letra más pequeño pierde más legibilidad que una línea extra.
4. Ancho de línea por sistema de escritura — el ancho del glifo no es el mismo en todos los sistemas de escritura, por lo que el mismo Fontsize da diferentes longitudes de línea. Presupuesta líneas como piso(px_disponible / (fontsize * k)), donde k es aproximadamente 1.0 para Han y 1.05–1.1 para Hangul y Kana (se ajustan más anchos), y 0.5 para el latino en promedio (varía con la palabra).
Medido a 4K con Fontsize 165: 13 caracteres Hangul ≈ 2,150 px (seguro en un espacio de 2,450 px), mientras que 18 caracteres ≈ 2,970 px — eso se desborda y empuja la línea fuera del borde izquierdo del fotograma. Cuando una línea no cabe, reajústala; no encojas la fuente para que quepa.
Subtítulos bilingües (original + traducción): solo cuando ambas líneas son cortas. Dos líneas de idioma duplican la carga de lectura y, en una demo, compiten con la interfaz. Prefiere un idioma por versión del archivo a menos que el cliente pida lo contrario; cuando debas, apila el original arriba y la traducción abajo, y presupuesta todo el bloque contra la velocidad de lectura de la traducción.
Scripts de derecha a izquierda (árabe, hebreo): libass soporta RTL, pero la calidad del moldeado depende del harfbuzz de la compilación. Renderiza un fotograma de vista previa antes de comprometerte — verifica que las letras se unan correctamente y que el ancla de alineación todavía tenga sentido.
El peso de la fuente difiere según el sistema de escritura. Las familias japonesas y coreanas generalmente se renderizan visualmente más grandes que una familia china con el mismo Fontsize; verifica con un fotograma de vista previa y ajusta a ojo, no por regla.
Fase 4 — Estilo (el resplandor de dos capas)
Un único estilo ASS no puede producir "texto blanco con un contorno grueso de color y un halo suave de color" — Outline solo dibuja un anillo. Usa dos eventos por señal en dos capas:
| Capa | Relleno | Contorno | Difuminado | Propósito |
|---|---|---|---|---|
| 0 | transparente (&HFF000000) | ancho, semitransparente púrpura | \blur 20–30 | el halo |
| 1 | blanco | más estrecho, púrpura sólido | ninguno / \blur1 | el texto legible |
Punto de partida (ver assets/caption-template.ass). Los colores son &HAABBGGRR — alfa 00 es opaco, FF es completamente transparente, y el orden de bytes es BGR:
púrpura RGB(124,46,240) → &H00F02E7C
halo, 35% opaco → &H55F02E7C
Escala por PlayRes, no por gusto: un renderizado a 4K usó fontsize 180 / outline 16 / halo 28 / blur 24; el mismo aspecto a 1080p es 80 / 7 / 12 / 12. Establece PlayResX/Y a la resolución de salida y mantén ScaledBorderAndShadow: yes.
Fase 5 — Colocación que sigue el foco
El centro inferior está mal para las demos: la parte inferior suele ser la interfaz, y el panel de la demo es lo único que debe permanecer visible. Ancla cada señal a la región libre adyacente a lo que se está mostrando:
| Enfoque de esta señal | Ancla | Por qué |
|---|---|---|
| Un panel lateral / panel de chat está abierto | \an6\pos(<panel_left - 40>, <panel_vertical_middle>) | el borde derecho se pega al panel, el bloque crece hacia la izquierda, los cambios de ancho no necesitan recálculo |
| Un toast/notificación en la parte superior derecha | \an3\pos(<toast_right>, <toast_bottom + 80>) | el ancla inferior derecha coloca el bloque justo debajo del toast |
| Página de ancho completo, sin panel | \an5\pos(<visual_center_x>, <y entre dos bloques de contenido>) | centrado, pero colocado en un espacio medido — no en el centro geométrico |
| Tarjeta de título con tipo grande incorporado | \an5\pos(center_x, DEBAJO del título) | el centro geométrico choca con el propio titular del video |
Calcula el ancho máximo de línea antes de escribir: max_line_px ≈ fontsize × caracteres. Si una línea excede la región libre, reajústala (nunca encojas la fuente solo para una señal) — corta por puntuación y mantén los nombres de producto sin dividir.
Para bloques anclados a la derecha, un derrame de unas decenas de píxeles sobre un fondo plano está bien y se ve intencional; el derrame sobre el panel de la demo no lo está.
Mantén el ancla fuera del borde del fotograma. El contorno y el halo se extienden más allá de la caja del glifo en aproximadamente outline + blur píxeles (a 4K con outline 15 / blur 22, eso es ~40 px, más el suavizado). Un ancla \an3 estacionada a 50 px del borde derecho, por lo tanto, hace que su halo se recorte y se lea como una palabra rota. Estaciona anclas al menos a 150 px dentro del fotograma y verifica el resultado en un fotograma de vista previa — el recorte es invisible en el archivo .ass y obvio en la pantalla.
Fase 6 — Incrustar
scripts/burn.sh INPUT.mp4 subs.ass OUT.mp4 [crf]
Esto es -i IN -vf "ass=subs.ass" -c:v libx264 -crf 17~18 -pix_fmt yuv420p -movflags +faststart, manteniendo la pista de audio original. Los subtítulos duros son la opción predeterminada correcta para las demos — sobreviven a cualquier reproductor, carga y uso compartido de pantalla; entrega un .ass/.srt junto solo si se solicita.
Fase 7 — Verificar
Renderiza imágenes fijas baratas primero, luego verifica el archivo codificado:
scripts/preview_frame.sh INPUT.mp4 subs.ass 16.0 /tmp/check1.jpg
Verifica cada fotograma para: texto sobre el panel de la demo, texto fuera del fotograma, salto de línea inesperado, una señal que debería haber terminado, halo que se ha convertido en papilla sobre un fondo brillante. Luego vuelve a extraer 3 fotogramas del archivo terminado (los subtítulos están horneados, la calidad es final) y confirma de nuevo.
Trampas (cada una costó tiempo real encontrarla)
-ssantes de-isilencia el filtroass. La búsqueda de entrada restablece las marcas de tiempo a cero, por lo que el filtro renderiza el estado del subtítulo en t≈0 para cada fotograma. Resultado: el video se quema sin subtítulos en absoluto, y ffmpeg no imprime ningún error. Siempre pon-ssdespués de-i(búsqueda de salida), o añade-copyts. Los scripts incluidos ya hacen esto.- Las coordenadas del fotograma de vista previa mienten. Un fotograma escalado a 1280×720 pero mostrado a ti...