A documentação descrevia um sistema que não existe mais: 62/73 ferramentas
(são 74), writer.py e models.py como arquivos (viraram pacotes), 1032 testes
(são 1454), models_api.py descrito como "API FastAPI" (é ponte JSON) e o app
SwiftUI ausente por completo — 5.500 linhas que o usuário opera todo dia sem
uma linha de documentação.
Cada arquivo passa a ter uma função específica, com cabeçalho de escopo
dizendo o que cobre e o que NÃO cobre (com a seta para quem cobre). O objetivo
é ler só o necessário: doc fora do assunto custa tempo e processamento sem
entregar nada.
01 arquitetura camadas, duas portas de entrada, regras transversais
02 módulos mapa do engine, incluindo o pipeline de voz
03 server/tools as 74 tools, helpers e como criar uma nova
08 app macOS NOVO — build por swiftc, telas, ponte, etapa 5
09 manutenção NOVO — por onde começar, o que está aberto, sintoma→arquivo
CLAUDE.md ganha a seção "Documentação (MANDATORY)": tabela de roteamento
(qual arquivo abrir para cada tarefa) e a regra de que toda alteração de
código atualiza a doc no mesmo commit, com o mapa de o-que-mexeu → o-que-
atualizar. Doc velha engana mais que doc ausente.
O índice do 05_EXPERIENCIAS subiu para o topo: consultar "isso já quebrou
antes?" custava carregar 1.281 linhas antes de chegar na tabela.
Dívidas levantadas na varredura e registradas em 09 §2: etapa 6 ainda ignora
o phrase_review.json, offset de ~400ms do Whisper, MacApp sem teste, admin/
fora do lint, confirmações visuais pendentes no FCP, submódulo WHISPERX sujo.
Também corrigidos dois links quebrados no Engine/README que apontavam um
nível acima do certo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
9.7 KiB
Markdown
184 lines
9.7 KiB
Markdown
# 03 — Camada MCP (`server.py` + `server_tools/`) — 74 ferramentas
|
||
|
||
> **Escopo:** As 74 ferramentas MCP: helpers, categorias e como criar uma nova.
|
||
> **Não cobre:** Lógica de edição, que mora no engine (→ 02) · comandos do app (→ 08)
|
||
|
||
`server.py` (592 linhas) é só o transporte: dispatch por dicionário
|
||
`TOOL_HANDLERS`, sem cadeia de if/elif e **sem lógica de timeline**. Os handlers
|
||
moram em `server_tools/`, um módulo por categoria, e os helpers que todos usam
|
||
em `server_tools/_shared/`.
|
||
|
||
```
|
||
server_tools/
|
||
editing.py (649) qc.py (696) voice.py (754) timeline.py (400)
|
||
subtitles.py markers_import generation.py transcript.py
|
||
export.py roles.py live.py
|
||
_shared/ ← helpers compartilhados, ver abaixo
|
||
```
|
||
|
||
## Helpers centrais (use-os, não reinvente)
|
||
|
||
Todos reexportados por `server_tools/_shared`, então `from ._shared import X`
|
||
continua funcionando. A coluna diz o módulo real, para quando você precisar
|
||
**editar** o helper — ou apontar um `monkeypatch` para ele.
|
||
|
||
| Helper | Mora em | Função |
|
||
|--------|---------|--------|
|
||
| `_validate_filepath()` | `_shared/paths.py` | Sandbox de entrada |
|
||
| `_validate_output_path()` | `_shared/paths.py` | Sandbox de saída |
|
||
| `_check_json_depth()` | `_shared/paths.py` | Rejeita payloads além de 50 níveis |
|
||
| `generate_output_path()` | `_shared/paths.py` | Nome derivado, sem tocar no original |
|
||
| `_resolve_io_paths()` | `_shared/paths.py` | Entrada + saída de uma vez |
|
||
| `_parse_project()` | `_shared/project.py` | FCPXML → `(tree, timeline, project)`; quase todo handler começa aqui |
|
||
| `_setup_modifier()` | `_shared/project.py` | Prepara modifier já validado |
|
||
| `_setup_generator()` | `_shared/project.py` | Prepara generator já validado |
|
||
| `_text_result()` | `_shared/project.py` | Envolve o texto em `TextContent` MCP |
|
||
| `_markdown_table()` | `_shared/formatting.py` | Tabela markdown |
|
||
| `_format_clip_table()` | `_shared/formatting.py` | Tabela de clipes |
|
||
| `_format_batch_result()` | `_shared/formatting.py` | Relatório de operação em lote |
|
||
| `_parse_timestamp_parts()` | `_shared/captions.py` | min:seg, H:MM:SS, SMPTE |
|
||
| `parse_srt()` / `parse_vtt()` | `_shared/captions.py` | Legendas coladas |
|
||
| `_detect_flash_frames/gaps/duplicate_groups()` | `_shared/detection.py` | Detectores de QC |
|
||
| `_load_or_transcribe()` | `_shared/media.py` | Transcrição com cache em disco |
|
||
| `_cut_transcript_spans()` | `_shared/media.py` | Corte por trecho falado |
|
||
| `_apply_placed_action()` | `_shared/media.py` | Aplica zoom/text/marker já posicionado |
|
||
|
||
## As 74 ferramentas por categoria
|
||
|
||
### Timeline & análise (Projeto)
|
||
`list_projects`, `analyze_timeline`, `list_clips`, `list_markers`, `list_connected_clips`,
|
||
`list_compound_clips`, `list_library_clips`, `list_roles`, `list_keywords`, `list_effects`.
|
||
|
||
### QC e detecção
|
||
`find_short_cuts`, `find_long_clips`, `analyze_pacing`, `detect_flash_frames`,
|
||
`detect_duplicates`, `detect_gaps`, `validate_timeline`, `detect_silence_candidates`,
|
||
`detect_media_silence`, `remove_silence_candidates`, `remove_media_silence`, `detect_beats`.
|
||
|
||
### Edição
|
||
`add_marker`, `batch_add_markers`, `trim_clip`, `reorder_clips`, `add_transition`,
|
||
`change_speed`, `delete_clips`, `split_clip`, `insert_clip`, `fix_flash_frames`,
|
||
`rapid_trim`, `fill_gaps`, `add_audio`, `create_compound_clip`, `flatten_compound_clip`.
|
||
|
||
### Geração
|
||
`auto_rough_cut`, `generate_montage`, `generate_ab_roll`, `list_templates`, `apply_template`.
|
||
|
||
### Beats / markers importados
|
||
`import_beat_markers`, `snap_to_beats`, `import_srt_markers`, `import_transcript_markers`.
|
||
|
||
### Roles
|
||
`assign_role`, `filter_by_role`, `export_role_stems`.
|
||
|
||
### Transcrição & edição por transcrição
|
||
`transcribe_media`, `edit_by_transcript`, `remove_filler_words`.
|
||
|
||
### Diferenciação
|
||
`diff_timelines`.
|
||
|
||
### Export / relink
|
||
`export_edl`, `export_csv`, `export_resolve_xml`, `export_fcp7_xml`, `relink_media`.
|
||
|
||
### Reformat
|
||
`reformat_timeline`.
|
||
|
||
### Voz (análise → decisão → aplicação)
|
||
`analyze_voice_features`, `build_voice_timeline`, `refine_voice_timeline`,
|
||
`remove_speakers`, `apply_voice_actions`, `get_voice_analysis_config`,
|
||
`save_voice_analysis_config`.
|
||
|
||
O fluxo é sempre o mesmo: `build_voice_timeline` mede (caro, roda uma vez) →
|
||
o modelo decide os cortes → **`refine_voice_timeline` renormaliza sobre o que
|
||
sobrou** (barato, sem reabrir áudio) e propõe as janelas de zoom → o modelo
|
||
corta a lista pelo ritmo → `apply_voice_actions` aplica. Pular a renormalização
|
||
faz o ranking de ênfase apontar para as palavras erradas (ver
|
||
`05_EXPERIENCIAS.md`).
|
||
|
||
### Legendas dinâmicas (geração → validação → aplicação)
|
||
`generate_dynamic_subtitles`, `validate_subtitle_layout`, `transcript_markers`.
|
||
|
||
**Sempre gere e depois valide — nunca dê a geração como pronta sem
|
||
`validate_subtitle_layout`.** A composição garante "sem sobreposição" só
|
||
*por construção* dentro do que ela mesma sabe medir; um título editado à
|
||
mão, uma palavra fora do alcance do que foi calibrado, ou conteúdo antigo
|
||
no mesmo arquivo escapam dessa garantia. Fluxo:
|
||
|
||
```
|
||
generate_dynamic_subtitles(filepath)
|
||
↓
|
||
validate_subtitle_layout(output_path) ← sempre, mesmo quando "parece certo"
|
||
↓
|
||
severidade none/warning → entregar
|
||
severidade probable/severe → investigar CADA colisão pela fração exata do
|
||
XML antes de mudar código (ver checklist abaixo)
|
||
```
|
||
|
||
**Antes de atribuir uma colisão ao gerador, confirme que é o gerador.**
|
||
Um `<title>` de nome estranho (`ref` diferente, params tipo `Auto-Shrink`/
|
||
`Left Margin` que `_make_text_title_clip` nunca escreve) é conteúdo humano
|
||
ou de outra ferramenta, não um bug — comparar contra o arquivo original
|
||
(`grep` pelo texto) resolve em segundos. Caso real: uma colisão "severa"
|
||
era um título manual feito no FCP que sobrou no arquivo reaproveitado como
|
||
base de teste (`05_EXPERIENCIAS.md`, 2026-08-19).
|
||
|
||
**Antes de atribuir uma colisão a uma sobreposição real, confirme pela
|
||
fração exata do FCPXML, não pelo float arredondado.** Dois títulos que só
|
||
se tocam na borda (um bloco some exatamente quando o próximo começa, por
|
||
design) podem imprimir tempos "iguais" e ainda assim colidir no relatório
|
||
por ruído de ponto flutuante — `float(a+b) != float(c)` mesmo quando as
|
||
frações `a+b` e `c` são idênticas. `temporal_overlap()` já tem uma
|
||
tolerância (`_BOUNDARY_EPSILON = 1e-6`, muitas ordens abaixo de um frame)
|
||
para absorver isso; se uma colisão nova parecer nascer do nada, comparar
|
||
`m._parse_time(...)` dos dois títulos por igualdade exata antes de
|
||
suspeitar de sobreposição de verdade.
|
||
|
||
**Constantes que resolvem os três bugs já encontrados nesta área** (todas
|
||
em `fcpxml/text_layout.py`, exceto a última):
|
||
|
||
| Constante | O que resolve | Por quê |
|
||
|---|---|---|
|
||
| `TEXT_TEMPLATE_FONT_SCALE = 2.0` | Posição e tamanho de fonte dessincronizados | O template "Text" do FCP posiciona no espaço do **frame** (2160×3840), mas o layout mede em pontos de meia-escala (1080×1920). Escalar só o tamanho da fonte e não a posição espalha o texto errado — os dois têm que ser convertidos pelo mesmo fator na saída. |
|
||
| `_EMPHASIS_ITALIC_CUSHION_RATIO = 0.06` | Linha de corpo lendo apertada sob a linha de ênfase | O itálico da Playfair inclina as hastes além da caixa de tinta que a métrica mede; ~14pt de respiro extra só nessa fronteira corrige sem tocar no `line_gap` do resto. |
|
||
| `fit_emphasis()` (função, não constante) | Palavra de ênfase estourando o frame inteiro | Só linhas de corpo faziam wrap contra `box.width`; a linha de ênfase (sempre uma palavra só) nunca foi checada. Uma palavra longa ou toda maiúscula podia medir mais que o frame inteiro sozinha. Encolhe `font_size`+`kerning` pelo mesmo fator até caber — nunca abaixo do tamanho do corpo, senão ênfase deixa de ser ênfase. |
|
||
| `_BOUNDARY_EPSILON = 1e-6` (`fcpxml/collision.py`) | Falso positivo de colisão em títulos que só se tocam | Ver parágrafo acima. |
|
||
|
||
Detalhe de implementação e efeito medido de cada um: `05_EXPERIENCIAS.md`,
|
||
entradas de 2026-08-19 (#15 zoom, #16 colisão por float, #17 auto-fit da
|
||
ênfase — a #15 é do módulo de voz, não de legendas, mas mesma causa-raiz
|
||
de fundo: um mecanismo que lê a própria saída anterior precisa continuar
|
||
sendo fonte de verdade legível, não só efeito colateral write-only).
|
||
|
||
### Live (macOS)
|
||
`push_to_fcp`, `list_fcp_libraries`.
|
||
|
||
---
|
||
|
||
## Padrão de handler (a forma de fazer)
|
||
|
||
```python
|
||
async def handle_<nome>(arguments: dict):
|
||
tree, timeline, project = _parse_project(arguments) # 1. parseia
|
||
# ...opera com o Engine (parser/writer/rough_cut/export)...
|
||
return _text_result(text) # 2. devolve
|
||
```
|
||
|
||
Regras:
|
||
- Todo handler valida caminho com `_validate_filepath`/`_validate_output_path`.
|
||
- Saídas sempre com sufixo `_modified`, `_chapters`, etc. — original nunca é tocado.
|
||
- Cada handler tem o seu `async def handle_<name>(arguments: dict)`.
|
||
- Sempre retornam via `_text_result(text)` (envolve o texto em `TextContent` MCP).
|
||
|
||
## Para adicionar uma ferramenta nova
|
||
|
||
1. **Escrever a função no Engine** (`fcpxml/…`) com testes. É aqui que mora o
|
||
trabalho de verdade; o resto é encanamento.
|
||
2. **Criar `handle_<nome>`** em `server_tools/<categoria>.py`, seguindo o padrão
|
||
acima. Escolha a categoria pelo assunto, não pelo tamanho do arquivo.
|
||
3. **Declarar o schema** (`Tool(...)`) no mesmo módulo.
|
||
4. **Registrar** no `TOOL_HANDLERS` de `server.py`.
|
||
5. Rodar `./Engine/run_after_fix.sh`.
|
||
|
||
Se a ferramenta também deve aparecer no app, exponha um comando equivalente em
|
||
`admin/api/<assunto>.py` e registre na tabela de `admin/models_api.py` — ver
|
||
`08_APP_MACOS.md`. Uma capacidade que só existe como tool MCP **não existe para
|
||
quem usa o app** (foi exatamente o que aconteceu com `apply_voice_actions`,
|
||
`05_EXPERIENCIAS.md` #20).
|
||
4. Rodar `./Engine/run_after_fix.sh`. |