Files
gart/code/Engine/docs/03_SERVER_TOOLS.md
T
João HenriqueandClaude Sonnet 5 c99274895c feat(legendas): liga compound_subphrases por padrão no pipeline
generate_dynamic_subtitles e a metade dinâmica de generate_subtitles_by_emphasis
passam a empacotar cada sub-frase da legenda dinâmica num compound clip por
padrão (compound_subphrases=True), completando o wrap_titles_in_compound
e split_into_subphrases do commit anterior — que ainda não tinham chamador
em produção.

Também torna validate_subtitle_layout ciente de compound clips: media cada
grupo (spine principal + cada <media> de compound) no seu próprio espaço de
tempo, em vez de uma varredura .//title global — sem isso, âncoras de
compounds diferentes liam offset "0s" e acusavam colisão espacial entre
frases que nunca dividem a tela, só porque compartilham o mesmo zero de
tempo local.

Testado ponta a ponta na gravação real (Mastopexia): 12 compounds, 41
títulos todos empacotados, zero soltos, zero IDs duplicados, DTD válida.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 17:05:20 -04:00

253 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03 — Camada MCP (`server.py` + `server_tools/`) — 78 ferramentas
> **Escopo:** As 78 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 77 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`, `remove_speech_gaps`, `apply_voice_actions`,
`generate_voice_script`, `get_voice_analysis_config`,
`save_voice_analysis_config`.
`remove_speech_gaps` corta pelo que a **transcrição** já sabe que não tem
fala — lê `words[].start/end` do `_voice_timeline.json` (função
`speech_gap_cut_actions`, em `fcpxml/voice_actions.py`) em vez de medir
volume. É o complemento correto para o caso que `remove_media_silence`
(silêncio por dB, ver seção "Silêncio e beats") não cobre: um trecho sem
fala mas com som real acima do limiar (respiração, ruído de roupa, batida) —
`remove_media_silence` nunca vai cortar isso porque tecnicamente não é
silêncio. Não corta a lacuna antes da primeiríssima palavra (pode ser quase
o arquivo inteiro, antes da tomada realmente começar) — isso continua
decisão manual na Fase 6 do `apply_voice_actions`
(`.claude/skills/editar-por-voz/criterios/06-texto-corte-marcador.md`).
O fluxo manual é: `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`).
`generate_voice_script` é o fluxo **automático e fechado** (sem wizard, sem
copiar-e-colar): transcreve (cache) → `build_voice_timeline` → entrega a
timeline a um **modelo local Ollama** que dirige a edição → devolve o roteiro
legível (markdown) **e** o JSON de ações, e opcionalmente aplica num FCPXML.
O cliente fica em `fcpxml/llm_local.py`; o modelo é tratado como entrada não
confiável e cada ação é validada por `parse_actions`. Padrão:
`qwen2.5:7b-instruct-q4_K_M` (troca de `gemma3:12b` — não cabia em máquina de
8GB de RAM; Gemma 3 4B foi testado antes e falhou por apagar o roteiro
principal em vez de só cortar bastidor). Passe `model=` para usar outro
servido pelo Ollama.
### Legendas dinâmicas (geração → validação → aplicação)
`generate_dynamic_subtitles`, `generate_plain_subtitles`,
`generate_subtitles_by_emphasis`, `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)
```
**`generate_subtitles_by_emphasis`** gera as duas legendas numa passada só,
dividindo por palavra: a dinâmica cobre as frases marcadas como ênfase na
etapa 5 (zoom aplicado, nível ≥ 1); a comum cobre **todo o resto** — um bloco
comum simplesmente não é criado onde a dinâmica já cobre. A primeira versão
gerava a comum inteira e desativava (`enabled="0"`) o que ficava sob a
dinâmica, mas um título desativado continua aparecendo como clipe riscado na
timeline do Final Cut mesmo sem renderizar — um corte com bastante ênfase
enchia a trilha de clipes mortos. Trocado por não gerar ali: o preço é que,
se a ênfase for desativada à mão depois, a legenda comum daquele trecho
precisa ser regenerada, não só reativada. É a tradução de `10-revisao-humana.md`
(skill `editar-por-voz`): "a frase de ênfase recebe zoom E legenda dinâmica;
as demais recebem legenda comum". A decisão vem de
`<mídia>_phrase_actions.json["emphasis_spans"]`, escrito por
`save_phrase_review` quando o editor termina a etapa 5 — sem esse arquivo (ou
sem `zoom`/`text` marcados na revisão), a tool gera só a comum, tudo ligado,
e avisa no relatório ("Sem revisão de ênfase"). Não expõe overrides de estilo
por chamada — usa a config salva ("Legendas Dinâmicas"/plain); para estilo
pontual, use `generate_dynamic_subtitles`/`generate_plain_subtitles` direto.
**Separação por role (didática na timeline):** `generate_dynamic_subtitles` e
`generate_plain_subtitles` (e a metade dinâmica/comum do `by_emphasis`) aplicam
`role="titles.dinamicas"` e `role="titles.convencionais"` em cada `<title>`
criado — sub-roles de `titles`, **nunca** `subtitles.*` (que esconderia o título
atrás de Code). O parâmetro `role` de cada ferramenta MCP sobrescreve o default
(vindo de `load_dynamic_subtitle_config()["role"]` /
`load_plain_subtitle_config()["role"]`). Ver `02_MODULES.md` (seção "Separação
de role") e `05_EXPERIENCIAS.md` entrada 32 (DTD: `<title>` leva `role`, não
`videoRole`).
**Compound clip por sub-frase (padrão em `generate_dynamic_subtitles` e na
metade dinâmica do `by_emphasis`):** `compound_subphrases=True` divide cada
frase em sub-frases pela vírgula (`transcribe.split_into_subphrases`) e
empacota os `<title>` de cada uma num `<ref-clip>` — a dúzia de títulos
empilhados por lane que uma frase gera vira uma barra só, arrastável/mutável
como unidade. Exceção: um trecho curto depois da vírgula ("né?", "Então...",
< 3 palavras) funde de volta na sub-frase anterior em vez de virar compound
próprio — soa como parte da mesma respiração, não uma frase nova. A estrutura
replica o que o próprio Final Cut gera em "New Compound Clip": o título mais
cedo vira âncora do spine interno em offset 0, os demais penduram nele por
lane. `validate_subtitle_layout` mede cada compound no seu próprio espaço de
tempo — sem isso, âncoras de compounds diferentes leem "0s" e colidem no
papel mesmo estando segundos distantes na timeline real.
**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`.