# 03 — Camada MCP (`server.py` + `server_tools/`) — 77 ferramentas > **Escopo:** As 77 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`, `apply_voice_actions`, `generate_voice_script`, `get_voice_analysis_config`, `save_voice_analysis_config`. 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 `_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. **Antes de atribuir uma colisão ao gerador, confirme que é o gerador.** Um `` 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`.