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:
co-authored by
Claude Sonnet 5
parent
32d78d0f8d
commit
0fdfe33613
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user