From 0fdfe33613123ffd7d4f7d664bfe6408b3899cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Tue, 22 Sep 2026 21:51:02 -0400 Subject: [PATCH] =?UTF-8?q?fix(legendas):=20impede=20legenda=20comum=20sob?= =?UTF-8?q?=20composi=C3=A7=C3=A3o=20din=C3=A2mica=20e=20duplica=C3=A7?= =?UTF-8?q?=C3=A3o=20ao=20regerar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- code/Engine/docs/02_MODULES.md | 65 ++++- code/Engine/docs/05_EXPERIENCIAS.md | 238 ++++++++++++++++++ code/fcpxml/writer/titles.py | 94 ++++++- code/server_tools/subtitles.py | 20 +- .../tests/test_subtitle_overlap_regression.py | 72 ++++++ 5 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 code/tests/test_subtitle_overlap_regression.py diff --git a/code/Engine/docs/02_MODULES.md b/code/Engine/docs/02_MODULES.md index a4ec4d2..861686c 100644 --- a/code/Engine/docs/02_MODULES.md +++ b/code/Engine/docs/02_MODULES.md @@ -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 `` 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) diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md index 758eb47..a737092 100644 --- a/code/Engine/docs/05_EXPERIENCIAS.md +++ b/code/Engine/docs/05_EXPERIENCIAS.md @@ -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. diff --git a/code/fcpxml/writer/titles.py b/code/fcpxml/writer/titles.py index 975680f..f06fc7d 100644 --- a/code/fcpxml/writer/titles.py +++ b/code/fcpxml/writer/titles.py @@ -28,6 +28,92 @@ from .helpers import _dtd_insert, _sanitize_xml_value class TitlesMixin: """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 # ======================================================================== @@ -385,6 +471,7 @@ class TitlesMixin: configs: Optional[List['DynamicSubtitleConfig']] = None, compound_subphrases: bool = False, subphrase_min_words: int = 3, + hold_between_sentences: bool = True, ) -> List[ET.Element]: """Generate progressive-reveal subtitle titles, one per word. @@ -538,6 +625,9 @@ class TitlesMixin: for i, units in enumerate(blocks): if i + 1 < len(blocks): 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: end = self.snap_seconds_to_frame( max(unit.end for unit in units) @@ -599,6 +689,7 @@ class TitlesMixin: role=block_role, ) _dtd_insert(parent, title) + self.mark_generated_subtitle(title, 'dynamic') created.append(title) by_sentence.setdefault(sentence_index, []).append(title) @@ -611,9 +702,10 @@ class TitlesMixin: str(w.get('word') or w.get('text') or '') for w in sentences[sentence_index] ).strip() - self.wrap_titles_in_compound( + compound = self.wrap_titles_in_compound( parent, group, name=label[:60] or "Legenda" ) + self.mark_generated_subtitle(compound, 'dynamic') if any(getattr(cfg, 'validate', False) for cfg in layout_configs): report = self.validate_subtitle_layout() diff --git a/code/server_tools/subtitles.py b/code/server_tools/subtitles.py index da01852..4d1cb85 100644 --- a/code/server_tools/subtitles.py +++ b/code/server_tools/subtitles.py @@ -470,11 +470,13 @@ async def handle_generate_dynamic_subtitles(arguments: dict) -> Sequence[TextCon # loop to whichever one `self.clips` last indexed, stacking every # 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. + modifier.remove_generated_subtitles(el, ('dynamic',)) lines = modifier.generate_dynamic_subtitles( el, clip_words, configs=configs, segments=clip_segments, role=single_role_override, compound_subphrases=True, ) + modifier.suppress_plain_under_dynamic(el) added.append((name, len(lines), len(clip_words))) 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")) continue + modifier.remove_generated_subtitles(el, ('plain',)) blocks = _plain_subtitle_blocks(clip_words, max_words) created = 0 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)) end = max(float(w.get("end", start)) for w in block) duration = max(end - start, modifier.frame_duration_fraction()) - modifier.add_text_title( + title = modifier.add_text_title( el, text, offset=f"{start:.6f}s", @@ -583,7 +586,9 @@ async def handle_generate_plain_subtitles(arguments: dict) -> Sequence[TextConte size_param=font_size, role=saved["role"], ) + modifier.mark_generated_subtitle(title, 'plain') created += 1 + modifier.suppress_plain_under_dynamic(el) if created: 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) all_words = data.get("words", []) + modifier.remove_generated_subtitles(el, ('dynamic', 'plain')) dynamic_lines = 0 dynamic_word_count = 0 emphasis_words = _words_in_spans(all_words, spans) clip_emphasis_words = _words_overlapping_clip(emphasis_words, clip_source_start, window_end) if clip_emphasis_words: - all_segments = data.get("segments", []) - emphasis_segments = _segments_in_spans(all_segments, spans) + # Reviewed phrases, not broader Whisper segments, define where + # a dynamic composition may live. Separate emphasis windows must + # never hold text over the plain speech between them. + emphasis_segments = spans clip_segments = [ { "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, role=single_dynamic_role, compound_subphrases=True, + hold_between_sentences=False, ) ) 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 continue duration = max(end - start, modifier.frame_duration_fraction()) - modifier.add_text_title( + title = modifier.add_text_title( el, text, 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, role=saved_plain["role"], ) + modifier.mark_generated_subtitle(title, 'plain') plain_created += 1 + modifier.suppress_plain_under_dynamic(el) + if dynamic_lines or plain_created: added.append( (name, dynamic_lines, plain_created, plain_hidden, dynamic_word_count + len(clip_all_words)) diff --git a/code/tests/test_subtitle_overlap_regression.py b/code/tests/test_subtitle_overlap_regression.py new file mode 100644 index 0000000..38d80bf --- /dev/null +++ b/code/tests/test_subtitle_overlap_regression.py @@ -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)