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 | | `model_manager.py` | 748 | Modelos Whisper: catálogo, download, config |
| `voice_timeline.py` | 600 | O JSON de voz que a IA lê | | `voice_timeline.py` | 600 | O JSON de voz que a IA lê |
| `phrase_review.py` | 547 | Revisão de frases (etapa 5 do assistente) | | `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 | | `collision.py` | 472 | Colisão entre títulos na tela |
| `font_metrics.py` | 445 | Largura real de glifos por fonte | | `font_metrics.py` | 445 | Largura real de glifos por fonte |
| `templates.py` | 387 | Templates de timeline | | `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 | | `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) | | `live.py` | 273 | Modo Live (push_to_fcp) |
| `diff.py` | 269 | Comparação de timelines | | `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 | | `export.py` | 226 | Export Resolve v1.9 + FCP7 XMEML v5 |
| `voice_features.py` | 220 | Pitch, energia, ritmo, pausas | | `voice_features.py` | 220 | Pitch, energia, ritmo, pausas |
| `diarize.py` | 180 | Quem falou (pyannote) | | `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 | | Módulo | Linhas | Conteúdo |
|--------|-------:|----------| |--------|-------:|----------|
| `core.py` | 723 | `ModifierCore`: carga, índices, navegação na spine, `save` | | `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 | | `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 | | `helpers.py` | 279 | Sanitização, escalas, construtores de elemento |
| `rapid.py` | 240 | Flash frames, rapid trim, preencher buracos | | `rapid.py` | 240 | Flash frames, rapid trim, preencher buracos |
| `validation.py` | 232 | Verificações estruturais antes de salvar | | `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` | | `document.py` | 170 | Assets de vídeo, timebases, `write_fcpxml` |
| `markers.py` | 165 | Marcadores: um, por timecode, em lote | | `markers.py` | 165 | Marcadores: um, por timecode, em lote |
| `audio.py` | 162 | Clipes de áudio e cama musical | | `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 | | `reorder.py` | 126 | Reordenar e recalcular offsets |
| `trim.py` | 125 | Aparar e propagar o ripple | | `trim.py` | 125 | Aparar e propagar o ripple |
| `transitions.py` | 94 | Transições entre vizinhos | | `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 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] [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 `layers` existe para separar *"a fala é monótona"* de *"a análise acústica nunca
carregou"* — os dois deixam os mesmos zeros nos dados. 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 ### `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 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 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. `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) ## Armadilhas do FCPXML (custaram sessões de depuração)
+238
View File
@@ -45,6 +45,12 @@ que merece entrada.
| 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` | | 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` | | 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` |
| 27 | 2026-08-21 | Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda | `resolvido` | | 27 | 2026-08-21 | Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda | `resolvido` |
| 28 | 2026-08-21 | Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando entre clipes | `resolvido` |
| 29 | 2026-08-21 | `remove_media_silence` (dB) não corta lacuna sem fala mas com som real — trecho sobrevivia intacto na timeline final | `resolvido` |
| 30 | 2026-08-24 | `add_zoom` animava `<adjust-transform>` direto no clipe, diferente de como o FCP realmente exporta zoom (clipe de ajuste conectado) | `resolvido` |
| 31 | 2026-08-24 | Revisão de falantes ("Quem fica na edição") salvava certo, mas etapa 4 (Revisão de frases) lia a timeline crua, ignorando falantes mutados/linhas riscadas | `resolvido` |
| 32 | 2026-08-24 | Separar legendas dinâmicas de convencionais por role: `<title>` aceita `role` (CDATA), NÃO `videoRole` — este último é DTD-inválido para títulos e quebra a validação | `resolvido` |
| 33 | 2026-09-22 | Legenda comum sobreposta à composição dinâmica em `generate_subtitles_by_emphasis`; regenerar acumulava títulos em vez de substituir | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes. > Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
@@ -1494,3 +1500,235 @@ o outro; percentil entrega um punhado útil nos dois casos.
> "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido > "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido
> entre elas — a pausa entre duas coisas descartadas também precisa ser > entre elas — a pausa entre duas coisas descartadas também precisa ser
> descartada, e ninguém a cobre por definição se o corte for por frase. > descartada, e ninguém a cobre por definição se o corte for por frase.
---
### 2026-08-21 — `remove_media_silence` (dB) não pega lacuna sem fala com som real
- **Sintoma:** usuário viu, no projeto Mastopexia real, um trecho de ~1,9s
sem fala (imagem parada antes da tomada começar) que sobreviveu intacto
na timeline final — depois de `apply_voice_actions`, `remove_media_silence`
e `generate_dynamic_subtitles` já terem rodado. Achou que era bug de ordem
no encadeamento das etapas ("corta e depois volta").
- **Investigação:** não era ordem. Extraído o áudio real do trecho
(`ffmpeg -af volumedetect`): `mean_volume -21.4dB`, `max_volume 0.0dB` —
longe do limiar padrão de silêncio (-30dB). Rodado `detect_silence` nos
mesmos limiares do sistema (-30/-25/-20/-16dB): nenhum sinaliza o trecho.
O trecho tem som real (roupa, respiração, ambiente) mas nenhuma palavra —
exatamente o caso que `06-texto-corte-marcador.md` já descrevia
("ausência de fala não é ausência de som"), só que sem ferramenta para
agir sobre ele: `remove_media_silence` só enxerga volume, nunca vai
cortar algo que soa alto mas não tem fala.
- **Correção:** nova função pura `speech_gap_cut_actions()` em
`fcpxml/voice_actions.py` — gera `cut`s a partir dos gaps entre
`words[].start/end` do `_voice_timeline.json` (tempo de fonte, como todo
`VoiceAction`), com a mesma folga por dentro (`padding`) que
`speaker_cut_actions()` já usava. Nova tool MCP `remove_speech_gaps`
(`server_tools/voice.py`, mesmo molde de `remove_speakers`): resolve o
`media_path`, lê a timeline, gera as ações e reaplica via
`handle_apply_voice_actions` — não duplica a lógica de corte no FCPXML.
Deliberadamente não corta a lacuna antes da primeiríssima palavra (pode
ser quase o arquivo inteiro, antes da tomada começar de verdade).
- **Ordem revista:** `apply_voice_actions → remove_speech_gaps →
remove_media_silence → generate_dynamic_subtitles` — a lacuna "sem fala"
some primeiro (cobertura ampla, por transcrição), o que sobra de silêncio
técnico *dentro* da fala é apertado depois.
- **Validação:** `tests/test_voice_actions.py::TestSpeechGapCutActions`
(gap acima/abaixo do limiar, lacuna antes da 1ª palavra nunca cortada,
segmentos com palavras sobrepostas não quebram, timeline vazia). Suíte
completa (1508 testes) roda limpa.
- **Estado:** `resolvido`
> **Aprendizado:** um detector de silêncio por dB nunca vai cobrir "sem fala
> com som" — são categorias diferentes, não uma questão de calibrar o
> limiar. Quando já existe transcrição confiável, ela é a fonte melhor para
> "onde não tem fala": não depende de threshold nenhum, só da própria
> palavra existir ou não naquele instante.
---
### 2026-08-24 — Zoom era `<adjust-transform>` no próprio clipe; FCP exporta como clipe de ajuste
- **Sintoma:** usuário pediu para o zoom parar de mexer diretamente no
clipe da timeline e passar a usar um "adjustment clip" com crop
animado — o jeito como ele já fazia zoom manualmente no FCP.
- **Investigação:** não havia amostra real no projeto para confirmar a
forma exata do XML (`adjust-crop`? um `<clip>` com `<adjustment>` como
`fcpxml/writer/adjustment.py` já fazia para filtros?). O usuário enviou
um `.fcpxmld` exportado pelo próprio FCP com um zoom manual
(`exemplo zoom.fcpxmld`), que revelou a forma real: um `<video ref="...">`
referenciando o efeito nativo `FFAdjustmentEffect` ("Clipe de Ajuste"),
anexado numa lane acima do clipe, com seu **próprio** `<adjust-transform>`
animando `scale` de `1 1` até o pico — não `adjust-crop`, e não o wrapper
`<adjustment>` que `adjustment.py` usa (que, conferido contra o DTD real
da Apple, **não existe** — aquele módulo gera XML inválido; ver dívida
em `09_MANUTENCAO.md`). Cruzado com o DTD oficial (`FCPXMLv1_13.dtd`, uma
cópia local encontrada fora do projeto): `<video>` é `%anchor_item;`
válido sem precisar de asset, e `adjust-transform` é filho direto seu.
- **Correção:** `add_zoom` (extraído para `fcpxml/writer/zoom.py`, deixou
de compartilhar módulo com `change_speed`) agora cria um `<video>`
conectado em vez de animar o clipe base. Isso **simplificou** a lógica
antiga: como o clipe de ajuste composita por cima da imagem já
reenquadrada, não precisa mais ler/preservar rotação, posição ou escala
do clipe original (a classe de teste inteira sobre "preservar
enquadramento" — e o bug histórico #15 que ela cobria — deixou de fazer
sentido); e dois zooms disjuntos no mesmo clipe agora são dois `<video>`
irmãos, não um merge de keyframes num `<adjust-transform>` só.
- **Validação:** os 22 testes de zoom em `test_writer.py` reescritos contra
a nova forma (`clip.find('video').find('adjust-transform')...`), mais
`test_voice_actions_tool.py`. Offset/duration da timeline gerada
conferidos byte a byte contra os números reais do `.fcpxmld` de exemplo
(bateram exatamente). Suíte completa roda limpa.
- **Estado:** `resolvido`
> **Aprendizado:** para decisões de forma exata de XML, um exemplo real
> exportado pelo próprio FCP vale mais que qualquer inferência — a diferença
> entre `adjust-crop`, o wrapper inválido de `adjustment.py` e a forma real
> (`<video ref="FFAdjustmentEffect">`) não dava para cravar sem um dos dois
> (amostra real ou o DTD oficial da Apple, que também foi cruzado aqui).
> Peça o exemplo antes de implementar às cegas.
---
### 2026-08-24 — Revisão de falantes salvava certo, mas a etapa 4 nunca lia o resultado
- **Sintoma:** usuário desmarcou falas de bastidor na tela "Quem fica na edição"
(etapa 3, `SpeakerReviewView`) e clicou "Salvar seleção", mas as falas
desmarcadas continuavam voltando na revisão de frases (etapa 4) e no roteiro
final gerado a partir dela.
- **Causa raiz:** `save_speaker_review` (`fcpxml/speaker_review.py`) e o
`_voice_timeline_clean.json` que ela grava estavam **corretos** — conferido
num projeto real: 37 segmentos na timeline crua, 15 marcados `excluded` na
revisão salva, 22 sobrando no `_clean.json` (37-15=22, bate exato). O bug
estava um passo adiante: `cmd_build_phrase_review`
(`admin/api/review.py`), que monta a etapa 4, abria
`args.get("voice_timeline")` — o arquivo **cru** — direto, sem nunca checar
se existia um `_voice_timeline_clean.json` ao lado. `generate_voice_script`
(`server_tools/voice.py`) e `copyForChat` (`WizardView.swift`) já faziam
essa checagem corretamente; só a etapa 4 ficou de fora.
- **Onde:** `admin/api/review.py::cmd_build_phrase_review`.
- **Por que passou despercebido:** a tela de revisão de falantes em si
funcionava e mostrava "Salvo" — o problema só aparecia num passo seguinte
e sem nenhum erro, então parecia que "a seleção não estava sendo salva"
quando na verdade ela salvava certo e era ignorada mais adiante.
- **Solução adotada:** `cmd_build_phrase_review` agora resolve
`speaker_review.clean_voice_timeline_path(timeline_path)` primeiro e lê
esse arquivo quando ele existe, caindo para o cru só na ausência dele —
mesma checagem que os outros dois pontos já faziam.
- **Aprendizado:** quando existem **múltiplos pontos de leitura** de um
mesmo artefato derivado (aqui: três lugares que podem preferir
`_voice_timeline_clean.json` sobre o cru), adicionar a checagem em um novo
ponto de leitura não é opcional — ela precisa ser replicada em todos, ou o
comportamento diverge silenciosamente conforme o caminho que o app tomar.
Vale grepar por todo lugar que abre o arquivo "canônico" sempre que um
arquivo "_clean"/derivado for introduzido.
- **Estado:** `resolvido` — corrigido em `admin/api/review.py`, suíte
completa (1506 de 1508 testes; as 2 falhas restantes são de ambiente —
WhisperX/torchcodec sem libs de sistema, sem relação com a mudança) e
lint do arquivo alterado limpos.
---
### Entrada 32 — 2026-08-24: `<title>` leva `role`, nunca `videoRole`
**Sintoma:** ao atribuir role de vídeo a legendas geradas (para separar
legendas dinâmicas de convencionais na timeline), a validação contra o DTD
FCPXML v1.13 quebrou com `No declaration for attribute videoRole of element
title`.
**Causa:** no DTD da Apple, `<title>` (`<!ATTLIST title %clip_attrs;>` +
`<!ATTLIST title role CDATA #IMPLIED>`) **não** declara `videoRole`. Esse
atributo existe em `<video>`, `<asset-clip>`, `<clip>` etc., mas não em
títulos. `<title>` usa o atributo genérico `role` (CDATA). Confirmado no
`FCPXMLv1_13.dtd` linhas 566–569.
**Decisão:** legendas dinâmicas e convencionais recebem `role="titles.dinamicas"`
e `role="titles.convencionais"` (sub-roles de `titles`, NUNCA `subtitles.*` —
ver entrada sobre roteamento de captions). O campo de config e o parâmetro dos
geradores chama-se `role` (não `video_role`). `assign_role` (mixin `RolesMixin`)
continua correto para clips/vídeos, pois seta `videoRole` neles — não confundir
os dois caminhos.
**Lição:** antes de setar `videoRole` num elemento qualquer, conferir o DTD:
títulos usam `role`. Teste de regressão em `tests/test_dynamic_subtitles.py`
(`test_titles_carry_title_subrole`) garante `titles.*` e bloqueia `subtitles.*`.
> **Nota de reconciliação:** entradas antigas deste arquivo (2026-08-17)
> afirmavam "nenhum título gerado carrega `role`" e tinham o teste
> `test_titles_carry_no_caption_role`. Aquilo referia-se **especificamente**
> a `role="subtitles.*"` (que roteia o título para a pista de captions e o
> esconde). A regra continua válida: proibido `subtitles.*`. O que mudou é que
> agora aplicamos `role="titles.*"` (sub-role de título, válido no DTD e útil
> para separar dinâmicas de convencionais na timeline). O teste foi renomeado
> para `test_titles_carry_title_subrole` e passa a exigir `titles.*` + bloquear
> `subtitles.*`.
---
### Entrada 33 — 2026-09-22: legenda comum sob a composição dinâmica; regenerar acumulava títulos
**Sintoma:** num corte real (Mastopexia), aos 11s a legenda comum "mamas
também mudam. É" aparecia simultaneamente com a composição dinâmica de
ênfase, poluindo o quadro com texto duplicado. Gerar novamente as legendas
(dinâmica ou convencional) sobre um clipe já legendado empilhava um segundo
conjunto de títulos por cima do anterior em vez de substituí-lo.
**Causa raiz — duas falhas distintas:**
1. **Sem marcação de autoria.** Os três handlers de legenda
(`handle_generate_dynamic_subtitles`, `handle_generate_plain_subtitles`,
`handle_generate_subtitles_by_emphasis`) só *adicionavam* títulos —
nenhum removia o que uma chamada anterior tinha gerado. Sem uma forma de
distinguir "título que este programa gerou" de "título que o editor
inseriu manualmente no FCP", uma regeneração não tinha como saber o que é
seguro apagar.
2. **Janela da legenda de ênfase maior que a fala.** Em
`handle_generate_subtitles_by_emphasis`, o cálculo de fim de bloco usava
os segmentos brutos do Whisper (`data["segments"]`) para decidir até onde
a composição dinâmica se estende — não os spans de ênfase revisados
(`spans`). Um segmento do Whisper cobre a frase inteira; a ênfase cobre só
o trecho grifado. A dinâmica então ficava "seguindo" além do próprio
áudio que a originou, invadindo o intervalo onde a legenda comum já
deveria estar sozinha.
- **Onde:** `code/fcpxml/writer/titles.py` (`TitlesMixin`) e
`code/server_tools/subtitles.py` (os três handlers de geração).
- **Solução adotada:**
- Todo título/composição gerado por este programa carrega uma marca em
`<metadata><md key="com.gart.subtitle.kind" value="dynamic|plain">`
(`mark_generated_subtitle`). Um heurístico de compatibilidade
(`_generated_subtitle_kind`) reconhece a assinatura exata de exports
antigos sem a marca (efeito/uid/start de texto do G-ART + padrão de nome),
para não tratar título manual do editor como "nosso" por engano.
- Cada handler chama `remove_generated_subtitles(el, kinds)` no início,
apagando só os títulos com a marca do próprio tipo que está sendo
regerado — títulos manuais e do outro tipo ficam intactos.
- `generate_dynamic_subtitles` ganhou o parâmetro `hold_between_sentences`
(default `True`, preserva o comportamento anterior nas chamadas normais).
`handle_generate_subtitles_by_emphasis` passa `hold_between_sentences=False`
e usa os `spans` de ênfase revisados como `emphasis_segments` (em vez dos
segmentos brutos do Whisper) — a composição dinâmica agora encerra no fim
real da palavra falada quando o próximo bloco pertence a outra frase, e
nunca ultrapassa a janela de ênfase que a gerou.
- `suppress_plain_under_dynamic` recorta (fatiando o clipe do título, sem
duplicar `text-style`) qualquer legenda comum gerada cujo intervalo caia
dentro de uma composição dinâmica ainda ativa — mesmo que o cálculo de
janela de algum outro caminho volte a divergir no futuro, isso funciona
como rede de segurança contra sobreposição visível.
- **Aprendizado:** um gerador que pode ser chamado de novo sobre a mesma
timeline **precisa** de uma forma de reconhecer sua própria saída anterior
antes de decidir "substituir" — sem isso, "regerar" e "empilhar" são
indistinguíveis. E ao derivar o fim de uma janela temporal a partir de uma
fonte (segmentos do Whisper, spans de ênfase, etc.), confirme que a fonte
escolhida tem a granularidade do fenômeno que está sendo delimitado — usar
a fonte "mais larga disponível" por conveniência cria sobra sistemática.
- **Teste de regressão:**
`code/tests/test_subtitle_overlap_regression.py` — roda o handler real
(`handle_generate_subtitles_by_emphasis`) contra um intervalo de ênfase
seguido de uma lacuna de fala comum, e confere que nenhuma composição
dinâmica sobrepõe uma legenda comum; e que chamar o mesmo handler duas
vezes não duplica títulos gerados nem remove um título manual inserido
entre as duas chamadas.
- **Estado:** `resolvido` — 224 testes das suítes de legenda/writer
passando (incl. o novo regressivo); suíte completa 1540 passando, 8
skipped, 1 falha e 1 erro de ambiente sem relação com a mudança (WhisperX/
`extract_pitch` ausente, torchcodec sem libs de sistema); lint dos arquivos
alterados limpo.
+93 -1
View File
@@ -28,6 +28,92 @@ from .helpers import _dtd_insert, _sanitize_xml_value
class TitlesMixin: class TitlesMixin:
"""Títulos de texto e legendas dinâmicas.""" """Títulos de texto e legendas dinâmicas."""
_SUBTITLE_METADATA_KEY = 'com.gart.subtitle.kind'
def mark_generated_subtitle(self, element: ET.Element, kind: str) -> None:
metadata = element.find('metadata')
if metadata is None:
metadata = ET.Element('metadata')
_dtd_insert(element, metadata)
ET.SubElement(metadata, 'md', key=self._SUBTITLE_METADATA_KEY, value=kind)
def _generated_subtitle_kind(self, element: ET.Element) -> Optional[str]:
marker = element.find(f"metadata/md[@key='{self._SUBTITLE_METADATA_KEY}']")
if marker is not None:
return marker.get('value')
# Recognize the exact signature of older G-ART exports. A role alone
# is not ownership: users also assign these roles to manual titles.
if element.tag == 'title':
if element.get('start') != self._TEXT_TITLE_START:
return None
effect = self.root.find(f".//resources/effect[@id='{element.get('ref')}']")
if effect is None or effect.get('uid') != self._TEXT_TITLE_UID:
return None
if re.fullmatch(r'caption_[0-9a-f]{8}', element.get('name', '')):
return 'dynamic'
text = ''.join(element.findtext('text/text-style', ''))
if (element.get('role') == 'titles.convencionais'
and element.get('lane') == '20'
and element.get('name') == f'{text} - Text'):
return 'plain'
elif element.tag == 'ref-clip':
media = self.root.find(f".//resources/media[@id='{element.get('ref')}']")
if media is not None:
titles = media.findall('.//title')
if titles and all(self._generated_subtitle_kind(t) == 'dynamic' for t in titles):
return 'dynamic'
return None
def remove_generated_subtitles(self, parent: ET.Element, kinds: tuple) -> None:
"""Replace only our own captions, preserving unrelated graphics."""
resources = self.root.find('.//resources')
for child in list(parent):
if self._generated_subtitle_kind(child) not in kinds:
continue
parent.remove(child)
if child.tag == 'ref-clip' and resources is not None:
ref = child.get('ref')
if not self.root.findall(f".//ref-clip[@ref='{ref}']"):
media = resources.find(f"media[@id='{ref}']")
if media is not None:
resources.remove(media)
def suppress_plain_under_dynamic(self, parent: ET.Element) -> None:
"""Keep generated plain titles only on frames without dynamic text."""
import copy
windows = []
for child in parent:
if self._generated_subtitle_kind(child) == 'dynamic':
start = self._parse_time(child.get('offset', '0s'))
windows.append((start, start + self._parse_time(child.get('duration', '0s'))))
for title in list(parent):
if self._generated_subtitle_kind(title) != 'plain':
continue
start = self._parse_time(title.get('offset', '0s'))
end = start + self._parse_time(title.get('duration', '0s'))
remaining = [(start, end)]
for lo, hi in windows:
parts = []
for a, b in remaining:
if a < hi and lo < b:
if a < lo:
parts.append((a, lo))
if hi < b:
parts.append((hi, b))
else:
parts.append((a, b))
remaining = parts
if remaining == [(start, end)]:
continue
parent.remove(title)
for a, b in remaining:
part = copy.deepcopy(title)
self._reassign_text_style_ids(part)
part.set('offset', a.to_fcpxml())
part.set('duration', (b - a).to_fcpxml())
_dtd_insert(parent, part)
# DYNAMIC (KARAOKE-STYLE) SUBTITLES # DYNAMIC (KARAOKE-STYLE) SUBTITLES
# ======================================================================== # ========================================================================
@@ -385,6 +471,7 @@ class TitlesMixin:
configs: Optional[List['DynamicSubtitleConfig']] = None, configs: Optional[List['DynamicSubtitleConfig']] = None,
compound_subphrases: bool = False, compound_subphrases: bool = False,
subphrase_min_words: int = 3, subphrase_min_words: int = 3,
hold_between_sentences: bool = True,
) -> List[ET.Element]: ) -> List[ET.Element]:
"""Generate progressive-reveal subtitle titles, one per word. """Generate progressive-reveal subtitle titles, one per word.
@@ -538,6 +625,9 @@ class TitlesMixin:
for i, units in enumerate(blocks): for i, units in enumerate(blocks):
if i + 1 < len(blocks): if i + 1 < len(blocks):
end = block_starts[i + 1] end = block_starts[i + 1]
if not hold_between_sentences and block_sentences[i] != block_sentences[i + 1]:
spoken_end = self.snap_seconds_to_frame(max(unit.end for unit in units))
end = min(end, spoken_end)
else: else:
end = self.snap_seconds_to_frame( end = self.snap_seconds_to_frame(
max(unit.end for unit in units) max(unit.end for unit in units)
@@ -599,6 +689,7 @@ class TitlesMixin:
role=block_role, role=block_role,
) )
_dtd_insert(parent, title) _dtd_insert(parent, title)
self.mark_generated_subtitle(title, 'dynamic')
created.append(title) created.append(title)
by_sentence.setdefault(sentence_index, []).append(title) by_sentence.setdefault(sentence_index, []).append(title)
@@ -611,9 +702,10 @@ class TitlesMixin:
str(w.get('word') or w.get('text') or '') str(w.get('word') or w.get('text') or '')
for w in sentences[sentence_index] for w in sentences[sentence_index]
).strip() ).strip()
self.wrap_titles_in_compound( compound = self.wrap_titles_in_compound(
parent, group, name=label[:60] or "Legenda" parent, group, name=label[:60] or "Legenda"
) )
self.mark_generated_subtitle(compound, 'dynamic')
if any(getattr(cfg, 'validate', False) for cfg in layout_configs): if any(getattr(cfg, 'validate', False) for cfg in layout_configs):
report = self.validate_subtitle_layout() report = self.validate_subtitle_layout()
+16 -4
View File
@@ -470,11 +470,13 @@ async def handle_generate_dynamic_subtitles(arguments: dict) -> Sequence[TextCon
# loop to whichever one `self.clips` last indexed, stacking every # loop to whichever one `self.clips` last indexed, stacking every
# clip's captions onto a single wrong spine element instead of each # clip's captions onto a single wrong spine element instead of each
# clip's own. See Engine/docs/05_EXPERIENCIAS.md, entry 2026-08-17. # clip's own. See Engine/docs/05_EXPERIENCIAS.md, entry 2026-08-17.
modifier.remove_generated_subtitles(el, ('dynamic',))
lines = modifier.generate_dynamic_subtitles( lines = modifier.generate_dynamic_subtitles(
el, clip_words, configs=configs, segments=clip_segments, el, clip_words, configs=configs, segments=clip_segments,
role=single_role_override, role=single_role_override,
compound_subphrases=True, compound_subphrases=True,
) )
modifier.suppress_plain_under_dynamic(el)
added.append((name, len(lines), len(clip_words))) added.append((name, len(lines), len(clip_words)))
if not added: if not added:
@@ -554,6 +556,7 @@ async def handle_generate_plain_subtitles(arguments: dict) -> Sequence[TextConte
skipped.append((name, "no words in clip's source range")) skipped.append((name, "no words in clip's source range"))
continue continue
modifier.remove_generated_subtitles(el, ('plain',))
blocks = _plain_subtitle_blocks(clip_words, max_words) blocks = _plain_subtitle_blocks(clip_words, max_words)
created = 0 created = 0
for block in blocks: for block in blocks:
@@ -567,7 +570,7 @@ async def handle_generate_plain_subtitles(arguments: dict) -> Sequence[TextConte
start = max(0.0, min(float(w.get("start", 0.0)) for w in block)) start = max(0.0, min(float(w.get("start", 0.0)) for w in block))
end = max(float(w.get("end", start)) for w in block) end = max(float(w.get("end", start)) for w in block)
duration = max(end - start, modifier.frame_duration_fraction()) duration = max(end - start, modifier.frame_duration_fraction())
modifier.add_text_title( title = modifier.add_text_title(
el, el,
text, text,
offset=f"{start:.6f}s", offset=f"{start:.6f}s",
@@ -583,7 +586,9 @@ async def handle_generate_plain_subtitles(arguments: dict) -> Sequence[TextConte
size_param=font_size, size_param=font_size,
role=saved["role"], role=saved["role"],
) )
modifier.mark_generated_subtitle(title, 'plain')
created += 1 created += 1
modifier.suppress_plain_under_dynamic(el)
if created: if created:
added.append((name, created, len(clip_words))) added.append((name, created, len(clip_words)))
@@ -711,14 +716,17 @@ async def handle_generate_subtitles_by_emphasis(arguments: dict) -> Sequence[Tex
clip_hide_plain_spans = clip_spans + _clip_relative(exclude_spans) clip_hide_plain_spans = clip_spans + _clip_relative(exclude_spans)
all_words = data.get("words", []) all_words = data.get("words", [])
modifier.remove_generated_subtitles(el, ('dynamic', 'plain'))
dynamic_lines = 0 dynamic_lines = 0
dynamic_word_count = 0 dynamic_word_count = 0
emphasis_words = _words_in_spans(all_words, spans) emphasis_words = _words_in_spans(all_words, spans)
clip_emphasis_words = _words_overlapping_clip(emphasis_words, clip_source_start, window_end) clip_emphasis_words = _words_overlapping_clip(emphasis_words, clip_source_start, window_end)
if clip_emphasis_words: if clip_emphasis_words:
all_segments = data.get("segments", []) # Reviewed phrases, not broader Whisper segments, define where
emphasis_segments = _segments_in_spans(all_segments, spans) # a dynamic composition may live. Separate emphasis windows must
# never hold text over the plain speech between them.
emphasis_segments = spans
clip_segments = [ clip_segments = [
{ {
"start": float(s.get("start", 0.0)) - clip_source_start, "start": float(s.get("start", 0.0)) - clip_source_start,
@@ -736,6 +744,7 @@ async def handle_generate_subtitles_by_emphasis(arguments: dict) -> Sequence[Tex
el, clip_emphasis_words, configs=dynamic_configs, segments=clip_segments, el, clip_emphasis_words, configs=dynamic_configs, segments=clip_segments,
role=single_dynamic_role, role=single_dynamic_role,
compound_subphrases=True, compound_subphrases=True,
hold_between_sentences=False,
) )
) )
dynamic_word_count = len(clip_emphasis_words) dynamic_word_count = len(clip_emphasis_words)
@@ -766,7 +775,7 @@ async def handle_generate_subtitles_by_emphasis(arguments: dict) -> Sequence[Tex
plain_hidden += 1 plain_hidden += 1
continue continue
duration = max(end - start, modifier.frame_duration_fraction()) duration = max(end - start, modifier.frame_duration_fraction())
modifier.add_text_title( title = modifier.add_text_title(
el, el,
text, text,
offset=f"{start:.6f}s", offset=f"{start:.6f}s",
@@ -782,8 +791,11 @@ async def handle_generate_subtitles_by_emphasis(arguments: dict) -> Sequence[Tex
size_param=plain_font_size, size_param=plain_font_size,
role=saved_plain["role"], role=saved_plain["role"],
) )
modifier.mark_generated_subtitle(title, 'plain')
plain_created += 1 plain_created += 1
modifier.suppress_plain_under_dynamic(el)
if dynamic_lines or plain_created: if dynamic_lines or plain_created:
added.append( added.append(
(name, dynamic_lines, plain_created, plain_hidden, dynamic_word_count + len(clip_all_words)) (name, dynamic_lines, plain_created, plain_hidden, dynamic_word_count + len(clip_all_words))
@@ -0,0 +1,72 @@
"""Exercise the real subtitle handler across emphasis gaps and regeneration."""
import asyncio
from pathlib import Path
import pytest
from fcpxml.models import DynamicSubtitleConfig
from fcpxml.writer import FCPXMLModifier
from server_tools import subtitles
@pytest.fixture
def caption_job(tmp_path, monkeypatch):
modifier = FCPXMLModifier(Path(__file__).parents[1] / "examples/sample.fcpxml")
parent = modifier._require_clip("Interview_A")
parent.set("start", "0s")
parent.set("duration", "10s")
media = tmp_path / "speech.mov"
media.touch()
modifier.resources[parent.get("ref")]["src"] = media.as_uri()
monkeypatch.setattr(modifier, "_iter_spine_clips", lambda: iter([(0, parent)]))
monkeypatch.setattr(subtitles, "_setup_modifier", lambda *args: (
"input.fcpxml", str(tmp_path / "output.fcpxml"), modifier,
))
monkeypatch.setattr(subtitles, "get_active_dynamic_subtitle_layouts", lambda: [
{"role": "titles.dinamicas"},
])
monkeypatch.setattr(subtitles, "_build_dynamic_subtitle_config", lambda *args: DynamicSubtitleConfig())
monkeypatch.setattr(subtitles, "load_plain_subtitle_config", lambda: {
"role": "titles.convencionais", "font": "Helvetica Neue", "font_size": 48,
"font_color": "1 1 1 1", "max_words": 1, "position_y": -100,
"uppercase": False, "keep_punctuation": True,
})
monkeypatch.setattr(subtitles, "_load_plain_exclude_spans", lambda _: [])
monkeypatch.setattr(subtitles, "_load_emphasis_spans", lambda _: [
{"start": 0, "end": 1}, {"start": 4, "end": 5},
])
monkeypatch.setattr(subtitles, "_load_or_transcribe", lambda *args: ({
"words": [
{"word": "corpo", "start": 0, "end": 1},
{"word": "muda", "start": 2, "end": 3},
{"word": "também", "start": 4, "end": 5},
],
"segments": [{"start": 0, "end": 1}, {"start": 4, "end": 5}],
}, None))
return modifier, parent
def test_dynamic_composition_clears_before_plain_words_in_emphasis_gap(caption_job):
modifier, parent = caption_job
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
dynamic = parent.findall("ref-clip")
plain = parent.findall("title")
assert dynamic and plain
for composition in dynamic:
start = modifier._parse_time(composition.get("offset"))
end = start + modifier._parse_time(composition.get("duration"))
for title in plain:
plain_start = modifier._parse_time(title.get("offset"))
plain_end = plain_start + modifier._parse_time(title.get("duration"))
assert not (start < plain_end and plain_start < end), "dynamic and plain overlap"
def test_regeneration_replaces_generated_subtitles_and_preserves_manual_titles(caption_job):
modifier, parent = caption_job
manual = modifier.add_text_title(parent, "Nome da médica", role="titles.manual")
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
initial = len(parent.findall("ref-clip")) + len(parent.findall("title"))
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
assert len(parent.findall("ref-clip")) + len(parent.findall("title")) == initial
assert manual in list(parent)