fix(legendas): impede legenda comum sob composição dinâmica e duplicação ao regerar

Marca cada título gerado (dynamic/plain) em metadata para que regenerar
substitua a saída anterior em vez de empilhar, e usa os spans de ênfase
revisados (não os segmentos brutos do Whisper) como janela da composição
dinâmica, evitando que ela invada o trecho de legenda comum seguinte.
suppress_plain_under_dynamic corta qualquer sobra visível como rede de
segurança.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-09-22 21:51:02 -04:00
co-authored by Claude Sonnet 5
parent 32d78d0f8d
commit 0fdfe33613
5 changed files with 479 additions and 10 deletions
+60 -5
View File
@@ -28,6 +28,7 @@ Versão: `0.6.35` · Última varredura: 2026-08-19
| `model_manager.py` | 748 | Modelos Whisper: catálogo, download, config |
| `voice_timeline.py` | 600 | O JSON de voz que a IA lê |
| `phrase_review.py` | 547 | Revisão de frases (etapa 5 do assistente) |
| `speaker_review.py` | 207 | Revisão de falantes (etapa 3 do assistente) |
| `collision.py` | 472 | Colisão entre títulos na tela |
| `font_metrics.py` | 445 | Largura real de glifos por fonte |
| `templates.py` | 387 | Templates de timeline |
@@ -36,7 +37,7 @@ Versão: `0.6.35` · Última varredura: 2026-08-19
| `forced_align.py` | 181 | Alinhamento forçado opcional (whisperx/wav2vec2) que corrige o viés de ~0,4s no início das palavras |
| `live.py` | 273 | Modo Live (push_to_fcp) |
| `diff.py` | 269 | Comparação de timelines |
| `voice_actions.py` | 263 | Decisões de edição (cut/zoom/text/marker) |
| `voice_actions.py` | 319 | Decisões de edição (cut/zoom/text/marker) |
| `export.py` | 226 | Export Resolve v1.9 + FCP7 XMEML v5 |
| `voice_features.py` | 220 | Pitch, energia, ritmo, pausas |
| `diarize.py` | 180 | Quem falou (pyannote) |
@@ -55,9 +56,10 @@ editorial, todos operando sobre o mesmo documento e os mesmos índices.
| Módulo | Linhas | Conteúdo |
|--------|-------:|----------|
| `core.py` | 723 | `ModifierCore`: carga, índices, navegação na spine, `save` |
| `titles.py` | 600 | Títulos de texto e legendas dinâmicas |
| `titles.py` | 867 | Títulos de texto e legendas dinâmicas |
| `cut.py` | 333 | Dividir, cortar faixas, apagar |
| `speed.py` | 297 | Velocidade e zoom (punch-in) |
| `speed.py` | 94 | Velocidade de reprodução |
| `zoom.py` | 204 | Zoom (punch-in) via clipe de ajuste conectado |
| `helpers.py` | 279 | Sanitização, escalas, construtores de elemento |
| `rapid.py` | 240 | Flash frames, rapid trim, preencher buracos |
| `validation.py` | 232 | Verificações estruturais antes de salvar |
@@ -66,7 +68,8 @@ editorial, todos operando sobre o mesmo documento e os mesmos índices.
| `document.py` | 170 | Assets de vídeo, timebases, `write_fcpxml` |
| `markers.py` | 165 | Marcadores: um, por timecode, em lote |
| `audio.py` | 162 | Clipes de áudio e cama musical |
| `generator.py` | 147 | `FCPXMLWriter` — cria documento do zero |
| `generator.py` | 90 | `FCPXMLWriter` — orquestra a criação do zero (estado + delegação) |
| `builders.py` | — | Um builder por tipo de elemento: `FormatBuilder`, `AssetBuilder`, `MarkerBuilder`, `KeywordBuilder`, `ClipBuilder`, `SequenceBuilder`, `LibraryBuilder` |
| `reorder.py` | 126 | Reordenar e recalcular offsets |
| `trim.py` | 125 | Aparar e propagar o ripple |
| `transitions.py` | 94 | Transições entre vizinhos |
@@ -121,7 +124,10 @@ voice_features.py pitch, energia, ritmo, pausas
▼
emphasis.py combina tudo num índice 0–1 por palavra
▼
voice_timeline.py monta o _voice_timeline.json ◄── é isto que a IA lê
voice_timeline.py monta o _voice_timeline.json ◄── a análise crua
▼
speaker_review.py (opcional) filtra falante mutado + linha riscada
→ _voice_timeline_clean.json ◄── é isto que a IA prefere
▼
[decisão: skill "editar-por-voz", ou a mão do usuário]
▼
@@ -155,6 +161,36 @@ Saída em camadas, para um modelo raciocinar do topo e descer só onde importa:
`layers` existe para separar *"a fala é monótona"* de *"a análise acústica nunca
carregou"* — os dois deixam os mesmos zeros nos dados.
### `speaker_review.py` — a triagem antes da IA
Roda logo após `analyze_voice` (etapa 3 do assistente, tela `SpeakerReviewView`
no app): lista quem foi detectado (`speaker_profiles`, com % de fala e falas
de amostra) e a transcrição segmento a segmento, para o usuário nomear cada
falante, mutar quem não interessa (ex.: o entrevistador) e riscar linhas soltas
antes de qualquer IA ver o arquivo. `build_speaker_review` nunca toca a
timeline crua; `apply_speaker_review`/`write_clean_voice_timeline` produzem
uma cópia separada, `_voice_timeline_clean.json`, reaproveitando
`enrich_words`/`_segment_rows`/`_summary` de `voice_timeline.py` para
recalcular a ênfase só sobre quem sobrou — mesma lógica de `restrict_to_kept`,
por falante/segmento em vez de por intervalo de tempo. O merge de decisões
salvas segue o padrão de `phrase_review.merge_saved_decisions`: sempre
reconstrói da análise atual, só as escolhas humanas persistem.
A skill "editar-por-voz", `generate_voice_script` e `cmd_build_phrase_review`
(etapa 4/5, `admin/api/review.py`) preferem o `_clean` quando ele existe; sem
revisão salva, seguem lendo o `_voice_timeline.json` normal — a etapa 3 é
sempre opcional. Os três pontos de leitura precisam concordar nessa
preferência: se um deles voltar a ler o arquivo cru direto, a revisão de
falantes vira letra morta sem nenhum erro visível (ver `05_EXPERIENCIAS.md`).
Cada linha em `build_speaker_review` carrega suas `words` originais (ênfase
por palavra), para a tela desenhar os mesmos chips da etapa 5 sem esperar um
recálculo. `apply_speaker_review` é reaproveitada por dois caminhos: gravar
(`write_clean_voice_timeline`, via `save_speaker_review`) e só **prever**
(`cmd_recalc_speaker_review`, sem tocar disco) — o botão "Recalcular" da tela
usa o segundo caminho para atualizar a ênfase só sobre quem sobreviveu ao
corte, sem reprocessar áudio.
### `phrase_review.py` — a revisão humana
Junta o timeline de voz com as ações da IA numa lista de frases editáveis, e
@@ -175,6 +211,25 @@ converte de volta. Frase inativa vira `cut`; ênfase ≥ 1 vira `zoom` mais um
Estes três não estão divididos porque **cada um já é um assunto só**. O
`text_layout.py` tem 901 linhas de um problema coeso: diagramação.
### Separação de role entre legendas dinâmicas e convencionais
As duas categorias são ambas `<title>` conectados, mas recebem **roles
diferentes** para ficarem didáticas na timeline do FCP (cada role ganha cor
própria no índice). O atributo usado em `<title>` é `role` (CDATA) — **nunca**
`videoRole`, que é DTD-inválido para títulos (ver `05_EXPERIENCIAS.md`,
entrada 32).
| Categoria | `role` | De onde vem |
|-----------|--------|-------------|
| Legendas dinâmicas | `titles.dinamicas` | `DynamicSubtitleConfig.role` / `load_dynamic_subtitle_config()["role"]` |
| Legendas convencionais | `titles.convencionais` | `load_plain_subtitle_config()["role"]` |
A cor do texto em si continua nos configs de fonte (abas do app), não no
role. Os geradores `generate_dynamic_subtitles` (writer/titles.py),
`handle_generate_plain_subtitles` e `handle_generate_subtitles_by_emphasis`
(server_tools/subtitles.py) aplicam o role em cada `<title>` criado; o
parâmetro `role` das ferramentas MCP sobrescreve o default.
---
## Armadilhas do FCPXML (custaram sessões de depuração)