feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão

Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha
alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a
etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado.

- generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro,
  legenda dinâmica só nas frases de ênfase, e a comum é desativada
  (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali.
- validate_subtitle_layout ignora títulos com enabled="0" — corrige falso
  positivo de colisão contra o que está desativado no lugar dele.
- Corrige zoom/marcador sendo descartado quando a borda encosta exatamente
  no início de um corte.
- Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com
  fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia
  entre "ativa" na tela e o que já foi cortado no FCPXML.
- Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json)
  antes da cadeia de remoção de silêncio/legendas — antes, desativar uma
  frase na etapa 5 não tinha efeito nenhum no vídeo final.
- Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder
  aparece assim que termina, sem slide extra.
- Palavra clicável na etapa 5 agora funciona como toggle (clique de novo
  desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte).
- fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento
  fonético via whisperx e roteirização local via Ollama/Gemma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-08-21 18:26:04 -04:00
co-authored by Claude Sonnet 5
parent 711c397dfe
commit 7b5aed79ee
36 changed files with 2922 additions and 624 deletions
+51 -3
View File
@@ -31,7 +31,7 @@ que merece entrada.
| 11 | 2026-08-18 | Preview das legendas dinâmicas desproporcional ao render do FCP (stagger/gap/canvas divergentes) e `inactive_color` exposto sem efeito | `resolvido` |
| 12 | 2026-08-18 | Espaço de coordenadas do modelo "Text": `fontSize`, `kerning` e `Position` no espaço do quadro — converter só o tamanho descolou o espaçamento | `resolvido` |
| 13 | 2026-08-19 | Reanálise de ênfase implementada no Engine mas sem ferramenta MCP — Fase 4 da skill era inexecutável | `resolvido` |
| 14 | 2026-08-19 | Offset sistemático de ~0,4s no timing por palavra (faster-whisper sem alinhamento forçado) — corrigido manualmente no teste, WhisperX pendente | `parcialmente resolvido` |
| 14 | 2026-08-19 | Offset sistemático de ~0,4s no timing por palavra (faster-whisper sem alinhamento forçado) — agora corrigido em pipeline por alinhamento forçado opcional | `resolvido` |
| 15 | 2026-08-19 | `add_zoom` perdia o enquadramento real (voltava a 100%) quando dois zooms caiam no mesmo clipe pós-corte; agora empilha ou substitui conforme as janelas se sobrepõem | `resolvido` |
| 16 | 2026-08-19 | `validate_subtitle_layout` acusava colisão severa em títulos que só se tocam na borda, por não-associatividade de float; 7 de 8 colisões reportadas no teste real eram falso positivo | `resolvido` |
| 17 | 2026-08-19 | Linha de ênfase das legendas dinâmicas sem limite de largura — palavra longa/maiúscula estourava o frame inteiro; auto-fit encolhe até caber, nunca abaixo do corpo | `resolvido` |
@@ -43,6 +43,7 @@ que merece entrada.
| 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` |
| 24 | 2026-08-19 | `admin/test_models_api.py` existia mas estava fora de `testpaths` — 13 testes que nunca rodaram | `resolvido` |
| 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` |
| 26 | 2026-08-21 | `generate_voice_script` (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava `num_ctx`; e `response.json()` de conexão caída escapava como `JSONDecodeError` | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
@@ -118,9 +119,9 @@ Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
- **Por que isso importa mais do que parece:** o erro contamina toda decisão temporal a jusante — zoom disparava ~0,4s antes da palavra-alvo, `gap_before` subestimava pausas reais na mesma medida (o que afeta diretamente a régua de silêncio recém-adotada), e as folgas de corte saíam erradas nas emendas.
- **Decisão tomada:** não rodei `remove_media_silence` bruto sobre o corte. A detecção (ffmpeg, limiar -30dB/0,5s) não distingue "batida entre frases dentro da régua de 1,5s" de "ar morto de emenda" — cortar ambos teria apertado frases fluidas. Corrigi os tempos manualmente medindo o ataque real nos pontos críticos (cabeça, 2 emendas, cauda, 3 zooms) e refiz o corte numa passada só.
- **Solução adotada (paliativa, aplicada manualmente neste teste):** medir o RMS real com `ffmpeg -af astats=metadata=1:reset=1:length=0.05,ametadata=print` em janelas curtas ao redor de cada ponto crítico antes de fixar um corte ou zoom que dependa de precisão de frame. Não é o padrão do sistema — é o que cobre a lacuna até o alinhamento forçado existir.
- **Solução estrutural ainda pendente:** ligar o WhisperX (ou alinhamento forçado equivalente) em `transcribe.py`, o que levaria o erro de ~400ms para ~30ms e corrigiria zoom, corte e `gap_before` de uma vez, sem paliativo por projeto. Não implementado ainda — é mudança de pipeline, exige regerar todos os `_transcript.json`/`_voice_timeline.json` existentes.
- **Solução estrutural implementada:** `transcribe.py` agora roda alinhamento forçado fonético (wav2vec2 via whisperx) como passo opcional pós-transcrição, em `fcpxml/forced_align.py` (classe `ForcedAligner`). O erro cai de ~400ms para ~30ms e corrige zoom, corte e `gap_before` de uma vez. É **dependência opcional** (`[align]` extra / pacote `whisperx` do PyPI) — quando ausente ou em qualquer falha, degrada e devolve os tempos brutos sem quebrar a transcrição. O `transcript` traz `"alignment": true/false` e o `voice_timeline` expõe `layers.alignment`, para quem lê o JSON saber se o offset manual ainda é necessário. Não reaproveitamos código da pasta `WHISPERX/` local (problemas conhecidos) — só a ideia documentada aqui. Exige regerar os `_transcript.json`/`_voice_timeline.json` existentes para aplicar nos caches antigos.
- **Aprendizado:** "não reestime tempos no olho" (critério 01) continua certo para decisão *editorial* — mas não cobre erro sistemático de *medição* na fonte dos tempos. Um offset constante e na mesma direção, em vários pontos do material, é sinal de bug no pipeline de transcrição, não de julgamento errado sobre o material. Vale conferir com uma amostra de áudio real antes de confiar cegamente em timestamp de word-level de qualquer fonte nova.
- **Estado:** `parcialmente resolvido` — paliativo documentado e aplicado neste teste; correção estrutural (WhisperX) pendente de implementação.
- **Estado:** `resolvido` — alinhamento forçado implementado em `transcribe.py`/`fcpxml/forced_align.py`; paliativo de medição manual mantido apenas para transcripts antigos sem `layers.alignment=true`.
---
@@ -1366,3 +1367,50 @@ o outro; percentil entrega um punhado útil nos dois casos.
instaladas por fora do mecanismo sendo testado — ou o teste prova que o
ambiente de teste está bem configurado, não que o código está certo.
- **Estado:** `resolvido`
---
## Entrada #26 — Prompt da IA local estoura o contexto do Ollama (e erro de parse escapa)
- **Sintoma:** botão "Gerar roteiro por IA local" (etapa 4 do assistente)
devolvia "Falha ao gerar roteiro por IA local". Rodando a ponte direto, o
erro real aparecia como *"Server disconnected without sending a response"*
ou *"Connection refused"* do Ollama, e 0 decisões ("Decisões do modelo: 0").
- **Causa raiz (dupla):**
1. `build_edit_messages` embutia o JSON da voice timeline **inteiro** no
prompt. Uma gravação de 3min vira ~188KB / **~47k tokens** (cada palavra
carrega energia, pitch, arousal, valence, `samples`…). Como `num_ctx`
estava em 32768, o prompt estourava a janela e o Ollama **dropava a
conexão** sem resposta.
2. Quando a conexão cai sem resposta, `httpx` entrega um body vazio e
`response.json()` lançava `JSONDecodeError` — que **não** é
`httpx.HTTPError`, então escapava do `try/except` de `ollama_chat` e
virava a exceção genérica que o `cmd_generate_voice_script` transforma
em `ok:false` com a mensagem "Falha ao gerar roteiro por IA local: …".
- **Correção (em `fcpxml/llm_local.py` + `server_tools/voice.py`):**
- `build_edit_messages` agora projeta a timeline (**`_project_timeline`**):
mantém só `text`/`start`/`end`/`speaker`/`emphasis`/`pause_before` das
palavras e `id`/`name` dos locutores; descarta `layers`, `scales`,
`samples` e os floats de áudio. Caiu de ~47k para **~17k tokens** (69KB).
- Salvaguarda `_shrink_to_fit`: se ainda passar de `max_chars` (110k),
remove os `words` dos segmentos de menor `peak_emphasis` até caber.
- `ollama_chat` envolve `post`+`raise_for_status`+`json()` num único
`except Exception` que relança como `RuntimeError` claro — fim do
`JSONDecodeError` escapando.
- `_extract_json` agora desembrulha a lista de 1 elemento `[{source,
actions}]` que alguns modelos devolvem, senão o `parse_actions` tratava o
objeto-wrapper como uma ação sem `kind` e rejeitava tudo (0 decisões).
- `handle_generate_voice_script` levanta `RuntimeError` com a causa quando o
modelo não devolve nenhuma decisão utilizável, então o app mostra a
mensagem real ("O modelo local não devolveu decisões utilizáveis: …")
em vez do genérico.
- **Validação:** `tests/test_llm_local.py` ganhou `test_build_edit_messages_is_compact`
(prompt < raw, sem `samples`/`energy_raw`/`pitch_hz`) e
`test_ollama_chat_wraps_empty_response`. Ponte testada com Ollama mockado
nos dois sentidos (sucesso aplica; falha → `ok:false` com msg clara).
- **Estado:** `resolvido`
> **Aprendizado:** modelo local tem contexto finito — nunca embutir o objeto
> de análise cru no prompt; projetar só o que a decisão usa. E qualquer parse
> de resposta de servidor local deve tratar body vazio/quebrado como erro de
> transporte, não como sucesso mudo.