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
+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` |
| 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` |
| 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.
@@ -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
> 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.
---
### 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.