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>
14 KiB
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)
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 emTextContentMCP).
Para adicionar uma ferramenta nova
- Escrever a função no Engine (
fcpxml/…) com testes. É aqui que mora o trabalho de verdade; o resto é encanamento. - Criar
handle_<nome>emserver_tools/<categoria>.py, seguindo o padrão acima. Escolha a categoria pelo assunto, não pelo tamanho do arquivo. - Declarar o schema (
Tool(...)) no mesmo módulo. - Registrar no
TOOL_HANDLERSdeserver.py. - 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.