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>
248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# 02 — Módulos do Engine (`fcpxml/`)
|
||
|
||
> **Escopo:** Mapa do engine `fcpxml/`: qual módulo faz o quê e onde mexer.
|
||
> **Não cobre:** Camadas e regras gerais (→ 01) · handlers MCP (→ 03) · o que está aberto (→ 09)
|
||
|
||
Mapa módulo a módulo do núcleo Python: onde cada coisa mora e o que ela faz.
|
||
A API pública é reexportada em `fcpxml/__init__.py` — essa é a fonte da verdade
|
||
do `__all__`.
|
||
|
||
Versão: `0.6.35` · Última varredura: 2026-08-19
|
||
|
||
> **Por que existem pacotes aqui.** `writer.py` tinha 4.199 linhas e `models.py`
|
||
> 1.091, cada um com muitos assuntos dentro. Viraram pacotes com um módulo por
|
||
> assunto. Do lado de fora **nada mudou**: `from .writer import FCPXMLModifier`
|
||
> e `from .models import TimeValue` seguem valendo, porque os `__init__.py`
|
||
> reexportam tudo — inclusive os nomes com underscore que a suíte usa.
|
||
|
||
---
|
||
|
||
## Visão geral
|
||
|
||
| Módulo / pacote | Linhas | Papel |
|
||
|-----------------|-------:|-------|
|
||
| `writer/` | 4.687 | **Edição e escrita de FCPXML** — o coração |
|
||
| `models/` | 1.195 | Data classes e enums |
|
||
| `text_layout.py` | 901 | Diagramação das legendas dinâmicas |
|
||
| `rough_cut.py` | 798 | Geração de timelines novas |
|
||
| `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 |
|
||
| `parser.py` | 367 | FCPXML → objetos Python |
|
||
| `transcribe.py` | 332 | Transcrição Whisper e corte por texto |
|
||
| `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` | 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) |
|
||
| `media_intel.py` | 177 | Silêncio (ffmpeg) e beats (librosa) |
|
||
| `emphasis.py` | 133 | Índice de ênfase por palavra |
|
||
| `safe_xml.py` | 113 | `defusedxml` + `serialize_xml()` |
|
||
| `dtd.py` | 112 | Validação contra os DTDs da Apple |
|
||
|
||
---
|
||
|
||
## `writer/` — edição e escrita
|
||
|
||
O `FCPXMLModifier` é montado por **composição de mixins**: um mixin por assunto
|
||
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` | 867 | Títulos de texto e legendas dinâmicas |
|
||
| `cut.py` | 333 | Dividir, cortar faixas, apagar |
|
||
| `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 |
|
||
| `compound.py` | 196 | Compound clips: criar e achatar |
|
||
| `silence.py` | 185 | Detectar e remover silêncio |
|
||
| `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` | 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 |
|
||
| `relink.py` | 94 | Repontar mídia |
|
||
| `insert.py` | 78 | Inserir clipes na spine |
|
||
| `modifier.py` | 64 | Monta a classe a partir dos mixins |
|
||
| `selection.py` | 57 | Selecionar por palavra-chave |
|
||
| `api.py` | 55 | Atalhos de uma linha |
|
||
| `connected.py` | 49 | Clipes conectados (lanes) |
|
||
| `roles.py` | 43 | Atribuir roles |
|
||
| `reformat.py` | 43 | Reenquadrar resolução |
|
||
|
||
**Onde mexer:** ache o assunto na tabela e abra só aquele arquivo. Se a sua
|
||
mudança precisa de dois mixins ao mesmo tempo, provavelmente o que você quer
|
||
é um método novo no `core.py` que os dois chamem.
|
||
|
||
**Cuidado:** os mixins compartilham `self`. Um método novo que colida de nome
|
||
com outro mixin sobrescreve em silêncio — a ordem em `modifier.py` decide quem
|
||
ganha. Hoje nenhum colide; mantenha assim.
|
||
|
||
---
|
||
|
||
## `models/` — dados e enums
|
||
|
||
Fonte única da estrutura de dados. **Nunca mexa aqui sem rodar `test_models.py`.**
|
||
|
||
| Módulo | Linhas | Conteúdo |
|
||
|--------|-------:|----------|
|
||
| `timing.py` | 304 | `TimeValue` (fração racional), `Timecode` |
|
||
| `timeline.py` | 217 | `Clip`, `ConnectedClip`, `CompoundClip`, `Timeline`, `Project`, `Marker` |
|
||
| `enums.py` | 183 | `MarkerType`, `MarkerColor`, `TransitionType`, `PacingStyle`… |
|
||
| `subtitles.py` | 157 | `WordLook`, `WordStyle`, `DynamicSubtitleConfig`, paleta |
|
||
| `qc.py` | 121 | `FlashFrame`, `GapInfo`, `DuplicateGroup`, `ValidationIssue` |
|
||
| `planning.py` | 93 | `SegmentSpec`, `PacingConfig`, `RoughCutResult`, `MontageConfig` |
|
||
|
||
`MarkerType` é o dono da serialização de marcador (`from_string`,
|
||
`from_xml_element`, `xml_attrs`) — não reimplemente isso em outro lugar.
|
||
|
||
---
|
||
|
||
## O caminho da voz (do áudio à decisão)
|
||
|
||
Estes seis módulos formam um pipeline. É o fluxo mais novo e o menos óbvio do
|
||
projeto, então vale ler nesta ordem:
|
||
|
||
```
|
||
transcribe.py áudio → palavras com tempo
|
||
+
|
||
diarize.py quem falou cada trecho
|
||
+
|
||
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 ◄── 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]
|
||
▼
|
||
voice_actions.py valida a lista de cut/zoom/text/marker
|
||
▼
|
||
phrase_review.py funde tudo em frases revisáveis (etapa 5 do app)
|
||
▼
|
||
writer/ aplica no FCPXML
|
||
```
|
||
|
||
**Regra de ouro do pipeline:** toda ação carrega tempo da **mídia original**,
|
||
nunca pós-corte. Cortes deslocam tudo depois deles; resolver o deslocamento só
|
||
na hora de aplicar (`shift_after_cuts`) elimina uma classe inteira de bug.
|
||
|
||
### `voice_timeline.py` — o contrato com a IA
|
||
|
||
Saída em camadas, para um modelo raciocinar do topo e descer só onde importa:
|
||
|
||
```
|
||
{version, source, language,
|
||
layers: {transcript, acoustics, speakers, emotion} ← o que rodou de verdade
|
||
scales: {…} ← como ler cada número
|
||
summary: {…}
|
||
speakers: [...]
|
||
segments: [{start, end, speaker, text, gap_before, take_boundary,
|
||
avg_energy, peak_emphasis, emotion, emotion_confidence,
|
||
words: [{text, start, end, energy, pitch_delta, rate_delta,
|
||
pause_before, emphasis}]}]}
|
||
```
|
||
|
||
`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
|
||
converte de volta. Frase inativa vira `cut`; ênfase ≥ 1 vira `zoom` mais um
|
||
`emphasis_spans` que a etapa de legendas usa. O trim de cada frase anda em
|
||
**fronteira de palavra** — cortar é apontar para uma palavra, nunca caçar frame.
|
||
|
||
---
|
||
|
||
## Legendas dinâmicas (três módulos que andam juntos)
|
||
|
||
| Módulo | Papel |
|
||
|--------|-------|
|
||
| `text_layout.py` | Quebra a frase em linhas e posiciona cada palavra |
|
||
| `font_metrics.py` | Largura real de cada glifo na fonte escolhida |
|
||
| `collision.py` | Detecta título saindo do quadro ou colidindo com outro |
|
||
|
||
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 `<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)
|
||
|
||
- Tempo é fração: `"3600/2400s"` = 1,5 s.
|
||
- `offset` é posição na timeline; `start` é o in-point da mídia.
|
||
- `<asset-clip>` (biblioteca) é diferente de `<clip>` (timeline).
|
||
- Marcadores são **filhos** do clipe, não irmãos.
|
||
- `.fcpxmld` é um **diretório** — sidecars precisam ser copiados no save, ou
|
||
dados de object tracking e Cinematic são destruídos.
|
||
- Negrito no FCP é `bold="1"` (atributo); itálico é `fontFace` + `italic="1"`.
|
||
- `id` de `<text-style-def>` precisa ser XML Name válido — acento, espaço ou
|
||
dígito inicial fazem o FCP recusar o arquivo inteiro.
|
||
- `code/examples/sample.fcpxml` **não** é DTD-conformante. Não use como fixture
|
||
de validade.
|