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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user