Legendas conversacionais para vídeo
Visão geral
As legendas são uma camada própria: elas carregam cada palavra falada e nunca dependem do que está desenhado no quadro. Rótulos de objetos no quadro (nomes de peças, prós/contas, setas) são uma camada diferente com uma linguagem visual diferente (caixas de texto ao lado do objeto); nunca corte uma palavra das legendas porque "ela já está na tela".
Pipeline: words.json + forbid.json (caixas que as legendas não devem cobrir) + config.json + perfil
→ plan → manifest.json (dados canônicos) → burn (prévia com legendas gravadas = verdade visual) / export srt|ass|vtt → validate.
Funções dos formatos (não os misture):
| formato | carrega | NÃO carrega |
|---|---|---|
manifest.json | palavras, tempos, layout, placement, sobreposições aceitas, elevações com motivos, verificações de render: fonte de verdade para dados | pixels |
.srt | texto + tempo | peso, cor, contorno, palavra ativa, posição. Nunca prometa estilo em SRT |
.ass | família + Negrito/\b700, tamanho convertido para métricas libass, contorno, caixa de fundo, palavra ativa por intervalo de tempo, posição | fades; precisa da fonte: subtitles=x.ass:fontsdir=fonts |
.vtt | CSS ::cue (família, peso, contorno via text-shadow, fundo) + classes .active_<spk> / .em_<spk> / .dim / .u por intervalo de tempo | posição exata; a página deve carregar a fonte com @font-face |
preview.mp4 com legendas gravadas | tudo: a única fonte de verdade visual | — |
Quando usar
- Esquetes de diálogo, entrevistas, explicadores com narrador: verticais ou quadrados, no máximo uma ou duas linhas.
- Você tem marcações de tempo por palavra (ou áudio: STT em nuvem opcional via chaves de ambiente) e sabe onde estão rostos/mãos/objetos-chave (caixas; para cenas 3D, renderize um matte de cor sólida e crie caixas).
- Não para vídeos de letra de música (palavras saltitantes e com escala não são suportadas propositalmente).
Algoritmo
Segmentação (chunk no perfil): quebra forçada em pausa > pause_s (0,35 s), fim de frase, mudança de locutor; quebra suave em uma vírgula quando um bloco já tem 2+ palavras; limitada por max_words, largura em pixels (max_width_frac da zona segura) e max_dur_s (~1,2 s de fala é bem legível). Um bloco nunca termina em uma palavra funcional (não, em, a, and, the…): ela passa para o próximo bloco. Uma palavra isolada junta-se ao seu vizinho da mesma frase se couber. As durações das palavras são limitadas a 2,5× a mediana para que uma palavra não se prolongue em uma pausa. Tempo: uma unidade aparece ≤ 50 ms antes de sua primeira palavra, nunca corta a palavra anterior, permanece hold_s após sua última palavra, no mínimo min_show_s.
Posicionamento — placement: {mode: fixed_bottom_center, collision_lift: false} (padrão para diálogo).
- Uma única linha de base para todo o vídeo:
baseline = safe.bottom - baseline_margin_px(ou defina-a explicitamente). Cada unidade é alinhada pela parte inferior nesta linha de base (y + h = baseline) e centralizada no quadro (x + w/2 = L/2). Nada se move: sem elevação, sem deslocamento lateral, sem eixo x por locutor. A largura da linha é limitada para que a linha centralizada permaneça dentro da zona segura. - Uma linha que cruza uma caixa proibida na linha de base permanece no lugar: a unidade recebe
overlap_accepted: [classes]e a folha de QA desenha um quadro amarelo "sobreposição aceita (linha de base fixa)". Informação, não uma falha. Cruzar a interface da plataforma ainda é um erro (diminua a fonte ou divida a frase). - Opção
collision_lift: true(modobaseline_lift): eixo x sob o locutor (align: speaker) ou centralizado com alternativas esquerda/direita; se todo eixo x colidir, eleve em etapas inteiras delift_step_pxaté ficar livre. A unidade recebelift = {px, reason: [classes atingidas na linha de base], t0, t1}, o manifesto lista todas as elevações emcollision_lifts. Sem motivo → sem elevação. Use apenas quando a solicitação pedir para manter os rostos livres a qualquer custo. - Nunca descarte uma palavra.
Aparência. Contorno (outline_px) + sombra suave + um fundo claro cujo alpha é aumentado por unidade apenas o suficiente para manter a cor mais escura acesa ≥ 4,5:1 (WCAG) contra o percentil 90 dos pixels atrás dela. Locutores diferem pela cor da palavra ativa e, no modo híbrido/cartões, uma barra de 4 px na cor do locutor; sem balões de fala, sem placas de nome pesadas. Movimento calmo: apenas fades de alpha, sem pops de escala, sem deslizamentos verticais.
QA (scripts/validate.py, também executado por demo):
- esquema: o manifesto contra
templates/manifest.schema.json; - linhas ≤ 2;
- posicionamento: em
fixed_bottom_centera dispersão de y-baseline e de x-centre em todas as unidades é 0 (tolerância de 0,5 px) e não há elevações; embaseline_liftcada elevação está na grade, tem um motivo e a unidade realmente colide na linha de base; - zero sobreposições com a interface da plataforma em cada quadro; caixas proibidas: 0 em
baseline_lift, relatadas comooverlap_accepted(informação) emfixed_bottom_center; - cada palavra exatamente uma vez, em ordem, visível enquanto falada;
- contraste ≥ 4,5 e sem oscilação, ambos medidos por quadro por
burn; - fontes resolvidas: arquivo presente, família e estilo Pillow, peso OS/2,
fc-scan, e ofontselectdo libass escolhe o arquivo e peso declarados; - pixels: no quadro com legendas gravadas E na renderização libass do ASS, a cor ativa está presente, a parte inferior do glifo está a 3 px do layout, a largura da haste está a 1 px de um raster de referência do peso declarado e o contorno está a 1 px de
outline_px.
Perfis (profiles/*.json)
| perfil | o que é | melhor para |
|---|---|---|
karaoke | 2–4 palavras por vez, palavra falada na cor do locutor, restante esmaecido | mais estável; faixas fixas, explicadores |
reveal | palavras aparecem gradualmente na fala; layout fixo antecipadamente; ≤ 2 linhas | narração calma |
cards | um pensamento por cartão (≤ 8 palavras, 2 linhas), painel denso, palavra-chave colorida | frases lentas e citáveis; lê antes da fala |
kinetic | um token por vez, tipo maior | apenas momentos de destaque; pouco contexto |
hybrid (padrão) | ≤ 3 palavras, palavras aparecem gradualmente, sublinhado sob a palavra falada, cor de ênfase, barra do locutor | diálogo |
Opções dos sistemas de P&D como parâmetros: tempo = chunk.max_dur_s: 1.2 + timing.min_show_s: 0.83 (ativado no híbrido);
ênfase = emphasis: "energy" (z-score de sonoridade da palavra por locutor a partir de --audio), ou "keyword" com uma lista de keywords.
Comandos
python -m subkit demo examples/fixture # 5 perfis + hybrid_lift -> prévias, 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 um commit: sem mídia/chaves/caminhos absolutos
ELEVENLABS_API_KEY=... python -m subkit stt audio.wav -o words.json # opcional; ou --provider groq com GROQ_API_KEY
Armadilhas
- libass ≠ tamanho de fonte Pillow. libass dimensiona por winAscent+winDescent do OS/2 e coloca a linha de base winAscent abaixo do topo da linha; a exportação converte ambos, ou o texto ASS sai ~35% menor, mais fino e mais baixo.
- Fontes variáveis no ASS voltam para a instância padrão (geralmente Regular). Envie uma instância estática do peso que você usa (aqui
Onest-Bold.ttf, 700) e passefontsdir. ffmpeg -ssantes de-iredefine os timestamps: o filtro de legendas então renderiza t=0. Busque após-iao gravar ASS.- O karaokê
\k/\kfestá em centissegundos e colore cada palavra falada permanentemente; para "apenas a palavra atual" emita intervalos de tempo (como a exportação faz). - SRT não tem estilo: se uma entrega precisar de peso ou destaque, envie ASS/VTT ou o vídeo com legendas gravadas.
- Uma linha flutuante (eixo x por locutor, histerese, elevações "mais bonitas") parece instabilidade em diálogo. Mantenha o modo fixo padrão; em
baseline_lifto validador verifica novamente se cada elevação realmente colide. - O contraste é julgado na cor acesa contra os 10% mais brilhantes dos pixels atrás dela, não a média: fogo, neve e realces falham nas médias.
- Não descarte palavras já desenhadas na tela. Em vez disso, mova o rótulo desenhado para uma camada de anotação.
- As zonas seguras em
templates/safe_zones.jsonsão aproximadas; as UIs das plataformas mudam, então verifique-as novamente antes de publicar.
Lista de verificação
- words.json cobre toda a fala (STT em nuvem ou um alinhador forçado); locutores rotulados
- forbid.json: rostos, mãos, fogo/acessórios por cena (+ caixas
speakersparaalign: speaker) - config: tamanho, fps,
safe_zone, cores dos locutores (matizes diferentes, ambos ≥ 4,5:1 no fundo) -
plan→ manifesto; leiaoverlap_accepted(fixo) oucollision_lifts(modo de elevação, cada um com um motivo real) -
burn→ observe a folha de QA (ciano = linha de base, amarelo = sobreposição aceita, laranja = elevação com motivo) e os quadros reais -
validatetodos APROVADOS; envie o vídeo com legendas gravadas (+ ASS/VTT se um reprodutor os renderizar; SRT apenas como texto simples)