Subtítulos de video conversacional
Resumen
Los subtítulos son una capa independiente: llevan cada palabra hablada y nunca dependen de lo que se muestra en el encuadre. Las etiquetas de objetos en el encuadre (nombres de partes, pros/contra, flechas) son una capa diferente con un lenguaje visual distinto (llamadas junto al objeto); nunca cortes una palabra de los subtítulos porque "ya está en pantalla".
Pipeline: words.json + forbid.json (cajas que los subtítulos no deben cubrir) + config.json + perfil
→ plan → manifest.json (datos canónicos) → burn (vista previa horneada = verdad visual) / export srt|ass|vtt → validate.
Roles de los formatos (no los mezcles):
| formato | contiene | NO contiene |
|---|---|---|
manifest.json | palabras, tiempos, diseño, placement, superposiciones aceptadas, elevaciones con motivos, verificaciones de renderizado: fuente de verdad para los datos | píxeles |
.srt | texto + tiempo | peso, color, contorno, palabra activa, posición. Nunca prometas estilo en SRT |
.ass | familia + Negrita/\b700, tamaño convertido a métricas libass, contorno, caja de fondo, palabra activa por segmento de tiempo, posición | fundidos; necesita la fuente: subtitles=x.ass:fontsdir=fonts |
.vtt | ::cue CSS (familia, peso, contorno mediante text-shadow, fondo) + clases .active_<spk> / .em_<spk> / .dim / .u por segmento de tiempo | posición exacta; la página debe cargar la fuente con @font-face |
preview.mp4 horneado | todo: la única fuente de verdad visual | — |
Cuándo usarlo
- Sketchs de diálogo, entrevistas, explicadores con narrador: vertical o cuadrado, máximo una o dos líneas.
- Tienes tiempos de palabras (o audio: STT en la nube opcional mediante claves de entorno) y sabes dónde están los rostros / manos / objetos clave (cajas; para escenas 3D renderiza una máscara de color plano y delimítala con cajas).
- No para videos de letras (rebote, palabras con escala no se admiten deliberadamente).
Algoritmo
Segmentación (chunk en el perfil): corte forzado en pausa > pause_s (0,35 s), final de oración, cambio de hablante; corte suave en coma una vez que un fragmento tiene 2+ palabras; limitado por max_words, ancho en píxeles (max_width_frac de la zona segura) y max_dur_s (~1,2 s de habla se lee bien). Un fragmento nunca termina en una palabra funcional (не, в, а, and, the…): pasa al siguiente fragmento. Una palabra solitaria se une a su vecina de la misma oración si cabe. Las duraciones de las palabras se recortan a 2,5× la mediana para que una palabra no se prolongue en una pausa. Tiempo: una unidad aparece ≤ 50 ms antes de su primera palabra, nunca corta la palabra anterior, permanece hold_s después de su última palabra, al menos min_show_s.
Colocación — placement: {mode: fixed_bottom_center, collision_lift: false} (por defecto para diálogo).
- Una línea base para todo el video:
baseline = safe.bottom - baseline_margin_px(o establécela explícitamente). Cada unidad se alinea por abajo en ella (y + h = baseline) y se centra en el encuadre (x + w/2 = W/2). Nada se mueve: sin elevación, sin desplazamiento lateral, sin x por hablante. El ancho de línea está limitado para que la línea centrada se mantenga dentro de la zona segura. - Una línea que cruza una caja prohibida en la línea base se mantiene: la unidad recibe
overlap_accepted: [classes]y la hoja de QA dibuja un marco amarillo "superposición aceptada (línea base fija)". Es información, no un fallo. Cruzar la interfaz de plataforma sigue siendo un error (reduce la fuente o divide la frase). - Opción
collision_lift: true(modobaseline_lift): x bajo el hablante (align: speaker) o centrado con alternativas izquierda/derecha; si cada x colisiona, eleva en pasos completos delift_step_pxhasta que quede libre. La unidad recibelift = {px, reason: [clases impactadas en la línea base], t0, t1}, el manifiesto lista todas las elevaciones encollision_lifts. Sin motivo → sin elevación. Úsalo solo cuando el pedido indique mantener los rostros despejados a toda costa. - Nunca elimines una palabra.
Aspecto. Contorno (outline_px) + sombra suave + un fondo claro cuyo alfa se incrementa por unidad solo lo necesario para mantener el color iluminado más oscuro ≥ 4,5:1 (WCAG) contra el percentil 90 de los píxeles detrás. Los hablantes se diferencian por el color de la palabra activa y, en híbrido/tarjetas, una barra de 4 px en el color del hablante; sin burbujas de diálogo, sin placas de nombre pesadas. Movimiento calmado: solo fundidos alfa, sin saltos de escala, sin deslizamientos verticales.
QA (scripts/validate.py, también se ejecuta con demo):
- esquema: el manifiesto contra
templates/manifest.schema.json; - líneas ≤ 2;
- colocación: en
fixed_bottom_centerla dispersión de y-línea base y de x-centro sobre todas las unidades es 0 (tolerancia 0,5 px) y no hay elevaciones; enbaseline_liftcada elevación está en la cuadrícula, tiene un motivo y la unidad realmente colisiona en la línea base; - cero superposiciones con la interfaz de plataforma en cada fotograma; cajas prohibidas: 0 en
baseline_lift, reportadas comooverlap_accepted(información) enfixed_bottom_center; - cada palabra exactamente una vez, en orden, visible mientras se habla;
- contraste ≥ 4,5 y sin rebote, ambos medidos por fotograma mediante
burn; - fuentes resueltas: archivo presente, familia y estilo Pillow, peso OS/2,
fc-scan, y la selección de fuente libass elige el archivo y peso declarados; - píxeles: en el fotograma horneado Y en el renderizado libass del ASS, el color activo está presente, la parte inferior del glifo está dentro de 3 px del diseño, el ancho del trazo está dentro de 1 px de un ráster de referencia del peso declarado, y el contorno está dentro de 1 px de
outline_px.
Perfiles (profiles/*.json)
| perfil | qué | mejor para |
|---|---|---|
karaoke | 2–4 palabras a la vez, palabra hablada en color del hablante, resto atenuado | más estable; carriles fijos, explicadores |
reveal | palabras aparecen al hablar; diseño fijo de antemano; ≤ 2 líneas | narración calmada |
cards | un pensamiento por tarjeta (≤ 8 palabras, 2 líneas), panel denso, palabra clave coloreada | líneas lentas y citables; se lee antes del habla |
kinetic | un token a la vez, tipo más grande | solo remates; poco contexto |
hybrid (por defecto) | ≤ 3 palabras, palabras aparecen, subrayado bajo la palabra hablada, color de énfasis, barra de hablante | diálogo |
Opciones de los sistemas de I+D como parámetros: tempo = chunk.max_dur_s: 1.2 + timing.min_show_s: 0.83 (activado en híbrido);
stress = emphasis: "energy" (puntuación z de sonoridad de palabra por hablante desde --audio), o "keyword" con una lista keywords.
Comandos
python -m subkit demo examples/fixture # 5 perfiles + hybrid_lift -> vistas previas, srt/ass/vtt, validation_report.{md,json}
python -m subkit plan --words W --forbid F --config C [--profile hybrid] [--audio A] -o manifest.json
python -m subkit burn manifest.json --forbid F --video in.mp4 [--audio A] -o preview.mp4 --qa qa.jpg
python -m subkit export manifest.json --format ass -o captions.ass # srt | ass | vtt
python scripts/check_clean.py # antes de un commit: sin media/keys/rutas absolutas
ELEVENLABS_API_KEY=... python -m subkit stt audio.wav -o words.json # opcional; o --provider groq con GROQ_API_KEY
Fallos comunes
- libass ≠ tamaño de fuente Pillow. libass dimensiona por OS/2 winAscent+winDescent y coloca la línea base winAscent debajo de la parte superior de la línea; export convierte ambas, o el texto ASS sale ~35% más pequeño, más delgado y más bajo.
- Las fuentes variables en ASS caen en la instancia predeterminada (generalmente Regular). Entrega una instancia estática del peso que uses (aquí
Onest-Bold.ttf, 700) y pasafontsdir. ffmpeg -ssantes de-ireinicia las marcas de tiempo: el filtro de subtítulos renderiza entonces t=0. Busca después de-ial quemar ASS.- El karaoke
\k/\kfestá en centisegundos y colorea cada palabra hablada permanentemente; para "solo la palabra actual" emite segmentos de tiempo (como hace export). - SRT no tiene estilo: si un entregable necesita peso o resaltado, entrega ASS/VTT o el video horneado.
- Una línea flotante (x por hablante, histéresis, elevaciones "que se ven mejor") se percibe como vibración en diálogo. Mantén el modo fijo predeterminado; en
baseline_liftel validador vuelve a verificar que cada elevación realmente colisiona. - El contraste se evalúa sobre el color iluminado contra el 10% más brillante de los píxeles detrás, no el promedio: el fuego, la nieve y los reflejos fallan los promedios.
- No elimines palabras ya dibujadas en pantalla. Mueve la etiqueta dibujada a una capa de anotaciones en su lugar.
- Las zonas seguras en
templates/safe_zones.jsonson aproximadas; las interfaces de plataforma cambian, así que verifícalas de nuevo antes de publicar.
Lista de verificación
- words.json cubre todo el discurso (STT en la nube o un alineador forzado); hablantes etiquetados
- forbid.json: rostros, manos, fuego/objetos por toma (+ cajas
speakersparaalign: speaker) - config: tamaño, fps,
safe_zone, colores de hablante (diferentes matices, ambos ≥ 4,5:1 sobre el fondo) -
plan→ manifiesto; leeoverlap_accepted(fijo) ocollision_lifts(modo elevación, cada uno con un motivo real) -
burn→ mira la hoja de QA (cian = línea base, amarillo = superposición aceptada, naranja = elevación con motivo) y los fotogramas reales -
validatetodo OK; entrega el video horneado (+ ASS/VTT si un reproductor los renderiza; SRT solo como texto plano)