chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# 01 — Arquitetura do Sistema (G-ART / fcp-mcp-server)
> Referência canônica de como o sistema está dividido e implementado. Leia este
> documento antes de qualquer mudança de código.
## 1. Visão de cima (camadas)
O sistema é um **servidor MCP em Python** que lê/analisa/reescreve arquivos
**FCPXML** do Final Cut Pro. Há **três camadas** bem separadas:
```
┌─────────────────────────────────────────────────────────────┐
│ admin/ — Aplicações complementares (fora do MCP) │
│ models_api.py API (FastAPI) p/ gerenciar modelos │
│ models_gui.py UI desktop (Flet) p/ gerenciar modelos │
│ graphify.sh/.md Pipeline de graphify do código │
├─────────────────────────────────────────────────────────────┤
│ server.py — CAMADA MCP / TRANSPORTE (NÃO tem lógica) │
│ 62 tools, handlers, prompts, resources, dispatch │
│ Só valida entrada/saída e traduz JSON-RPC → chamadas │
├─────────────────────────────────────────────────────────────┤
│ fcpxml/ — "ENGINE" = NÚCLEO PURO Python (desacoplado) │
│ Não conhece MCP nem argumentos de tool. │
│ Trabalha com objetos Python e XML. │
│ É o foco / onde quase tudo mora. │
└─────────────────────────────────────────────────────────────┘
```
**Regra de arquitetura:** `server.py` NUNCA implementa lógica de timeline —
ele delega ao `fcpxml/`. Tudo em `fcpxml/` é testável isoladamente (1032 testes).
## 2. Regras transversais (convenções em todo o código)
| Conceito | Regra |
|----------|-------|
| **Tempo** | `TimeValue` fração racional `"600/2400s"`. Nunca use float p/ tempo. |
| **I/O paths** | Sempre via helpers `_validate_filepath` / `_validate_output_path` (sandbox). |
| **Nome de saída** | Nunca sobrescrever original: `output_<suffix>.fcpxml`. |
| **Segurança XML** | Sempre `defusedxml` (via `safe_xml.py`). Nunca `xml.etree` direto. |
| **Deps opcionais** | `librosa`/`ffmpeg`/`huggingface_hub` importados **lazy**, degradam com `None`. |
| **Lint** | `ruff check . --exclude docs/` — zero erros. |
| **Validação pós-correção** | `./Engine/run_after_fix.sh` SEMPRE após cada correção. |
## 3. Fluxo de um request (round-trip)
```
Cliente MCP (Claude)
│ JSON-RPC (stdio)
▼
server.py ── dispatcher (TOOL_HANDLERS)
│ valida path, parseia projeto, chama engine
▼
fcpxml/parser.py XML → objetos
fcpxml/writer.py edita / grava
fcpxml/rough_cut.py gera novas timelines
fcpxml/export.py cross-NLE
▼
output_<suffix>.fcpxml (original intocado)
▼
Final Cut Pro: File → Import → XML (ou push_to_fcp, sem cliques)
```
## 4. Dual-mode: XML + Live
O sistema opera em **dois modos complementares**:
- **Modo XML (principal):** exporta FCPXML, processa como dados, reimporta.
Roda fora do FCP. Nenhuma API privada.
- **Modo Live (`fcpxml/live.py`):** *push* do FCPXML direto p/ o FCP em
execução via Apple events oficiais (`Open Document`), com `import-options`.
Leitura de bibliotecas via AppleScript read-only.
**Assimetria estrutural:** import é scriptable, mas a Apple não oferece export
programático — round-trips voltam pelas ferramentas XML.
## 5. Onde está cada responsabilidade
| Responsabilidade | Fica em |
|------------------|---------|
| Modelos de dados (tempo, clips, markers) | `fcpxml/models.py` |
| Parse FCPXML → objetos | `fcpxml/parser.py` |
| Editing/escrita (modifier + writer) | `fcpxml/writer.py` |
| Geração de timeline nova | `fcpxml/rough_cut.py` |
| Comparação de timelines | `fcpxml/diff.py` |
| Export cross-NLE (Resolve, FCP7) | `fcpxml/export.py` |
| Inteligência de mídia (silêncio/beats) | `fcpxml/media_intel.py` |
| Transcrição Whisper local | `fcpxml/transcribe.py` |
| Gestão de modelos Whisper | `fcpxml/model_manager.py` |
| Templates de timeline | `fcpxml/templates.py` |
| Controle Live do FCP | `fcpxml/live.py` |
| Segurança XML (`defusedxml`, `serialize_xml`) | `fcpxml/safe_xml.py` |
| Validação contra DTDs da Apple | `fcpxml/dtd.py` |
| Transporte MCP (62 tools) | `server.py` |
## 6. Mapa de dependências (você está aqui se for mexer no X → quem tocar)
```
server.py ──► fcpxml/parser, writer, rough_cut, export, diff,
media_intel, transcribe, templates, live, dtd
admin/models_gui.py ──► fcpxml/media_intel, model_manager,
parser, transcribe
admin/models_api.py ──► fcpxml/model_manager
fcpxml/writer.py ──► fcpxml/models, safe_xml, dtd
fcpxml/__init__.py ──► reexporta a API pública
```
> Se você cria uma **nova ferramenta MCP**, o trabalho principal é em `fcpxml/`
> (função pura + testes). O handler em `server.py` fica fino: validação de
> caminho → `_parse_project` → chama a função → `_text_result`.
+114
View File
@@ -0,0 +1,114 @@
# 02 — Módulos do Engine (`fcpxml/`)
Guia módulo a módulo do núcleo Python. Tamanho em linhas, responsabilidade e as
funções/classes públicas de cada um. APIs públicas são reexportadas em
`fcpxml/__init__.py` (fonte da verdade para o `__all__`).
## Versão atual
`__version__ = "0.6.35"` — ver `fcpxml/__init__.py`.
---
| Módulo | Linhas | Papel |
|--------|-------:|-------|
| `models.py` | 930 | Data classes e enums (tempo, clips, markers, QC) |
| `parser.py` | 367 | FCPXML → objetos Python |
| `writer.py` | 3154 | Edição e escrita de FCPXML (o maior) |
| `rough_cut.py` | 798 | Geração de timelines novas |
| `dtd.py` | 112 | Validação contra DTDs oficiais |
| `safe_xml.py` | 113 | Wrappers `defusedxml` + `serialize_xml()` |
| `media_intel.py` | 173 | Silêncio (ffmpeg) e beats (librosa) |
| `transcribe.py` | 184 | Transcrição Whisper + edição por transcrição |
| `model_manager.py` | 298 | Gestão de modelos Whisper (cache/catálogo) |
| `export.py` | 226 | Export DaVinci Resolve v1.9 + FCP7 XMEML v5 |
| `diff.py` | 269 | Comparação de timelines |
| `live.py` | 273 | Modo Live — push_to_fcp / list_fcp_libraries |
| `templates.py` | 387 | Templates de timeline |
| `__init__.py` | 139 | Reexporta API pública |
---
## `models.py` — modelos e enums
Single source of truth para estrutura de dados. NUNCA mexa aqui sem rodar
`test_models.py`.
- **Tempo:** `TimeValue` (fração racional), `Timecode`.
- **Clips:** `Clip`, `VideoClip`, `AudioClip`, `ConnectedClip` (lane),
`CompoundClip`, `Transition`.
- **Contêineres:** `Timeline`, `Project`, `Keyword`.
- **Markers:** `Marker`, `MarkerType`, `MarkerColor`, `MARKER_XML_TAGS`.
`MarkerType` é o dono da serialização (`from_string`/`from_xml_element`/`xml_attrs`).
Match estrito do atributo `completed` (`'0'`/`'1'`, sem padding).
- **QC:** `SilenceCandidate`, `FlashFrame`, `GapInfo`, `DuplicateGroup`,
`ValidationIssue`, `ValidationResult`.
- **Geração:** `SegmentSpec`, `PacingConfig`, `PacingStyle`, `RoughCutResult`.
## `parser.py` — leitura
- `parse_fcpxml(path)` → `Project`.
- `FCPXMLParser` — lê spine, connected clips (lanes), secondary storylines, roles.
## `writer.py` — o coração (3154 linhas)
Duas classes principais:
- **`FCPXMLModifier`** — edita documento existente de forma index-based
(dicts de `clips`/`resources`/`formats`), imune a ambiguidade de nomes duplicados.
Métodos: `insert_clip`, `add_marker`, `trim_clip`, `delete_clip`, `split_clip`,
`change_speed`, `cut_clip_ranges` (usado pela remoção de silêncio), etc.
- **`FCPXMLWriter`** — gera FCPXML novo a partir de objetos Python.
Helpers de nível de arquivo: `modify_fcpxml`, `add_marker_to_file`,
`trim_clip_in_file`, `build_marker_element`, `write_fcpxml`, `validate_fcpxml`,
`list_effects`, `FCP_EFFECTS`.
## `rough_cut.py` — geração
- `RoughCutGenerator`, `generate_rough_cut`, `generate_segmented_rough_cut`.
## `media_intel.py` — inteligência de mídia (v0.10)
- Silêncio via `ffmpeg silencedetect` (subprocess limitado), `remove_silence_candidates`,
mapeamento source→timeline.
- Beats via `librosa` (import lazy, extra `[intelligence]`).
- Degrada para `None` quando `ffmpeg` ausente.
## `transcribe.py` — Whisper local
- `transcribe(media_path, model_size, language)` → dict com `words` (spans).
- `ALLOWED_MODELS` — allowlist de nomes de modelo (também usado por `model_manager`).
- Edição por transcrição: remove filler words, aparar por transcrição.
## `model_manager.py` — gestão de modelos
Catálogo `models.json` + cache no HF hub. Config em `~/.fcp-mcp-server/config.json`.
Funções: `get/save_models_dir`, `list_installed_models`, `download_model`,
`delete_model`, `get/load_selected_model`, `save_selected_model`, `load_catalog`.
Permite cancelamento de download via `threading.Event`. Segue convenções:
allowlist, lazy imports, degradação graciosa.
## `export.py` — cross-NLE
- `DaVinciExporter` — FCPXML v1.9 p/ DaVinci Resolve.
- Export FCP7 XMEML v5.
## `diff.py` — comparação
- `compare_timelines`, `TimelineDiff`, `ClipDiff`, `MarkerDiff`.
- Detecta added/removed/moved/trimmed clips & markers.
## `live.py` — FCP ao vivo (macOS)
- `push_to_fcp(path, library, options)` — Apple event *Open Document* + `<import-options>`.
Requer `.fcpbundle` p/ zero-click real.
- `list_fcp_libraries()` — AppleScript read-only.
## `templates.py`
- `Template`, `TemplateSlot`, `ClipSpec`, `BUILTIN_TEMPLATES`, `apply_template`,
`list_templates`. Estruturas prontas: intro/outro, lower thirds, music video.
## `safe_xml.py`
Wrappers `defusedxml` centralizados + `serialize_xml()`. Todo parse/escrita passa aqui.
## `dtd.py`
Valida output contra DTDs oficiais no bundle do FCP (via `xmllint`; exige o caminho
do DTD percent-encoded por causa dos espaços em "Final Cut Pro.app").
---
## Como adicionar um módulo novo
1. Criar `fcpxml/<seu_modulo>.py` — função pura, sem conhecer MCP.
2. Reexportar classes/funções em `fcpxml/__init__.py` (`__all__`).
3. Cobrir em `tests/test_<seu_modulo>.py`.
4. Rodar `./Engine/run_after_fix.sh`.
+84
View File
@@ -0,0 +1,84 @@
# 03 — Camada MCP (`server.py`) — 62 ferramentas
`server.py` (3824 linhas) é a camada de transporte. Não tem lógica de timeline —
mapeia nome → handler e delega ao Engine. O dispatch é um dicionário
`TOOL_HANDLERS` (padrão de despacho, sem cadeias gigantes de if/elif).
## Helpers centrais (use-os, não reinvente)
| Helper | Linha | Função |
|--------|------:|--------|
| `_check_json_depth()` | 83 | Rejeita payloads além de 50 níveis |
| `_validate_filepath()` | 103 | Sandbox de entrada |
| `_validate_output_path()` | 149 | Sandbox de saída |
| `_format_clip_table()` | 245 | Renderização de tabela |
| `_markdown_table()` | 259 | Renderização de tabela markdown |
| `_parse_project()` | 319 | Parseia FCPXML → `(tree, timeline, project)`; quase todos os handlers começam aqui |
| `_resolve_io_paths()` | 357 | Validação de caminho de entrada/saída |
| `_setup_modifier()` | 390 | Prepara modifier com validação |
| `_setup_generator()` | 414 | Prepara generator com validação |
| `_parse_timestamp_parts()` | 433 | Parse de timestamps (min:seg, H:MM:SS, SMPTE) |
| `_detect_flash_frames/gaps/duplicate_groups()` | 1667+ | Detectores de QC |
## As 62 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`.
### 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 módulo do Engine (`fcpxml/…`) + testes.
2. Criar `handle_<nome>` em `server.py` seguindo o padrão acima.
3. Registrar no dicionário `TOOL_HANDLERS`.
4. Rodar `./Engine/run_after_fix.sh`.
+84
View File
@@ -0,0 +1,84 @@
# 04 — Testes, Fluxo de Trabalho e Estado Atual
## 1. Suíte de testes
**1032 testes em 24 arquivos** em `tests/`. Rode com `uv run pytest tests/ -v`.
| Arquivo | Cobre |
|---------|-------|
| `test_models.py` | `TimeValue` (aritmética), `Timecode`, propriedades de Clip, modelos de validação, helpers de Timeline |
| `test_writer.py` | insert_clip, add_marker (todos os tipos), trim_clip, delete_clip, split_clip, change_speed |
| `test_server.py` | handlers MCP, parser, dispatch |
| `test_rough_cut.py` | `RoughCutGenerator` |
| `test_features_v05.py` | connected clips, roles, timeline diff, reformat, silêncio, export, compatibilidade |
| `test_features_v06.py` | features da v0.6 |
| `test_marker_pipeline.py` | `build_marker_element`, batch auto-modes, índices de clip duplicados, `write_fcpxml` |
| `test_refactored_helpers.py` | `_index_elements`, `_iter_spine_clips`, `_find_spine_clip_at_seconds`, `_resolve_clip_duration`, `_make_asset_clip`, `_format_batch_result`, `serialize_xml` |
| `test_transcribe.py` | spans de frase/filler, álgebra de merge/invert de intervalos, degradação do Whisper, handlers por transcrição |
| `test_media_intel.py` | parse de `silencedetect`, mapeamento source→timeline, bounds de parâmetros, integração WAV real (skips sem ffmpeg) |
| `test_diff.py` | comparação de timelines |
| `test_export.py` | export Resolve / FCP7 |
| `test_live.py` | modo live |
| `test_security.py` | segurança de path / XML |
| `test_edge_cases.py`, `test_diversity.py`, `test_parser.py`, `test_validation.py`, `test_bundles.py`, `test_dtd_validation.py`, `test_relink.py`, `test_speed_cutting.py`, `test_targeted_gaps.py`, `test_fcpxml_writer.py` | demais suítes |
**Fixtures:** `examples/sample.fcpxml` + fixtures XML inline. NOTA: `sample.fcpxml`
NÃO é DTD-conformante (assets pré-`media-rep`, markers de capítulo no sequence) —
não o use como fixture de validade DTD. Testes criam arquivos temporários e limpam.
**Obs. transcribe/media:** testes exigem deps opcionais (`ffmpeg`, whisper).
Sem eles, os testes relevantes fazem `skip` — o CI instala.
## 2. Fluxo de trabalho padrão (obrigatório)
> **Regra do sistema:** sempre após concluir UMA correção de código, o sistema é
> automaticamente executado/validado.
```bash
./Engine/run_after_fix.sh
```
O que ele faz (e falha via `set -e` se qualquer um não passar):
1. `uv run ruff check . --exclude docs/` → **zero erros de lint**.
2. `uv run pytest tests/ -v` → **toda a suíte passa**.
### Equivalente manual (pre-commit)
```bash
ruff check . --exclude docs/ # lint — zero erros
pytest tests/ -v # testes — todos passam
```
O CI roda ambos em todo push para `main`. Se um falhar, o commit ganha X no GitHub.
Corrija o lint **antes** de commitar.
## 3. Como rodar a aplicação
```bash
uv run server.py # Inicia o servidor MCP (stdio)
uv run python admin/models_gui.py # UI desktop de gestão de modelos (Flet)
uv run --extra dev pytest tests/ -v # Testes com extra de dev
```
## 4. Estado atual do sistema (resumo "até agora")
- **v0.6.35** — núcleo FCPXML completo em Python (`fcpxml/`).
- **62 ferramentas MCP** em `server.py`, organizadas por dispatch `TOOL_HANDLERS`.
- **Suporte FCPXML 1.8–1.14** (`.fcpxml` e bundles `.fcpxmld` com sidecars),
escrita padrão 1.13.
- **Dual-mode:** XML (principal) + Live (push_to_fcp / list_fcp_libraries via Apple events).
- **Inteligência de mídia (v0.10):** silêncio via ffmpeg + beats via librosa (lazy).
- **Transcrição local Whisper** + gestão de modelos (`model_manager.py`, catálogo
`models.json`, cache HF, cancelamento de download).
- **UI desktop (Flet):** `admin/models_gui.py` — aba Modelos (download/selecionar/
remover/config pasta de modelos) e aba Transcrição (projeto FCPXML → transcrição).
- **API complementar:** `admin/models_api.py`.
- **Validação DTD:** contra DTDs oficiais do bundle do FCP (v0.10+).
- **Export cross-NLE:** DaVinci Resolve v1.9 + FCP7 XMEML v5.
## 5. Evolução prevista (roadmaps)
- `docs/CAPABILITY-AUDIT-2026-06.md` — auditoria do ecossistema + roadmap dual-mode.
- `docs/specs/06_IMPLEMENTATION_ROADMAP.md` — roadmap de implementação.
- `docs/TRANSCRIPTION-MODELS.md` — fases da gestão de modelos (MCP handlers e
wiring em `transcribe()` vêm em fase posterior; hoje `model_manager.py` é o
esqueleto com catálogo e primitivas de cache reais).
+708
View File
@@ -0,0 +1,708 @@
# 05 — Experiências: Registro de Problemas, Erros e Decisões
> **Propósito:** registrar, de forma cumulativa, todos os problemas estruturais,
> erros que se repetiram em várias tentativas e decisões difíceis enfrentadas
> durante o desenvolvimento do G-ART. Serve de memória de trabalho para que
> futuras implementações **não repitam os mesmos erros** e para que decisões já
> tomadas não sejam redescobertas do zero.
**Regra:** sempre que um problema for detectado (estrutural ou funcional) e
houver uma correção ou trabalho em torno dele, **adicione um registro aqui**
antes de prosseguir. Um problema que se repete em várias tentativas é sinal de
que merece entrada.
---
## Como registrar (template de entrada)
Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
```markdown
### [DATA] Título curto do problema
- **Sintoma:** o que acontecia / o erro observado.
- **Causa raiz:** o que realmente causava o problema (após investigação).
- **Onde:** arquivo(s) e, se útil, função/linha.
- **Tentativas que falharam:** o que já foi tentado e não funcionou.
- **Solução adotada:** a correção que resolveu.
- **Aprendizado:** regra/comportamento a lembrar nas próximas implementações.
- **Estado:** `aberto` | `resolvido` | `mitigado` | `evitado por design`
```
---
## Registro de Experiências
### 2026-08-17 — Garantir que dois blocos nunca se sobreponham: empilhar pela TINTA real, não pela cap-height
- **Sintoma:** na composição progressiva, a cedilha de "começar" (Playfair
Display Medium Italic, 230pt) invadia a linha de apoio logo abaixo. As
caixas "lógicas" não se cruzavam — as renderizadas, sim.
- **Causa raiz:** o empilhamento usava altura nominal `font_size * 0.75`
(cap-height). Numa serifada de display itálica os acentos sobem a 1,007em e
os descendentes descem a -0,241em: a tinta real ocupa quase o dobro da
cap-height, e a folga nominal some.
- **Onde:** `fcpxml/font_metrics.py` (`VERTICAL_METRICS`),
`fcpxml/text_layout.py` (`ink_extent`, `compose_sentence`, `PlacedBlock`).
- **Tentativas que falharam:** aumentar `line_gap` — afasta as linhas em todos
os casos e perde o bloco compacto da referência, sem garantir nada: basta
uma fonte com acentos mais altos para colidir de novo.
- **Solução adotada:** métricas verticais reais extraídas das fontes
(`ascent`/`descent` da caixa de linha que o FCP centra na Position, mais os
extremos de tinta por classe de glifo: caixa alta, ascendente, x-height,
acento maiúsculo/minúsculo, descendente). `ink_extent()` calcula o topo e a
base da tinta DO TEXTO em questão; `compose_sentence` empilha essas caixas
borda a borda com folga fixa. Não-sobreposição vira propriedade da
aritmética, não de um fator de segurança. Fonte sem métricas medidas usa um
fallback com 8% de folga extra.
- **Aprendizado:** medir largura resolve colisão lado a lado; colisão entre
linhas exige medir altura de tinta — e ela depende dos caracteres da linha,
não só do corpo da fonte.
- **Estado:** `resolvido`
---
### 2026-08-17 — Legendas saíam palavra a palavra centradas, e não como a composição progressiva diagramada da referência
- **Sintoma:** o usuário mandou o reel de referência (@fernandoluz.d) e disse
"elas devem aparecer assim": `[que vão] / [melhorar] / [sua legenda]` — um
bloco por trecho, palavra-chave grande em serifada itálica, complementares
pequenas em grotesca, linhas escalonadas. O gerador entregava um `<title>`
por PALAVRA, todos na mesma família, cada linha centrada.
- **Causa raiz:** `layout_sentence` empacota palavra a palavra e centra cada
linha; o `rhythm` variava tamanho/cor por índice (indigo/amarelo/cinza), não
por papel semântico da palavra. Nenhum dos dois produz a diagramação.
- **Onde:** `fcpxml/text_layout.py` (`compose_sentence`, `pick_emphasis_index`,
`PlacedBlock`), `fcpxml/models.py` (looks editoriais, `granularity`),
`fcpxml/writer.py` (emissão por unidade), `fcpxml/font_metrics.py`,
`server.py` (parâmetros da tool).
- **Tentativas que falharam:** tentar aproximar o visual só trocando os
tamanhos do `rhythm` — sem agrupar as palavras de apoio num único título, o
resultado continua sendo legenda corrida.
- **Solução adotada:** modo `granularity="phrase"` (padrão): a frase vira
linhas — apoio antes, palavra-chave sozinha, apoio depois —, uma linha por
`<title>`, entrando no instante da sua primeira palavra e todas limpando
juntas. Destaque em Playfair Display Medium Italic (métricas reais extraídas
da fonte instalada e embutidas em `font_metrics`), apoio em Helvetica Neue
Bold, tudo branco, linhas escalonadas por `REFERENCE_STAGGER_RATIO`. O modo
antigo continua disponível em `granularity="word"`.
- **Aprendizado:** o destaque é semântico, não posicional — escolher a palavra
por índice num ciclo nunca reproduz uma diagramação. E toda fonte nova exige
métricas reais antes de entrar no layout: sem elas, a medição de largura
erra e duas linhas colidem.
- **Estado:** `resolvido`
---
### 2026-08-17 — Importação recusada: `id` do `<text-style-def>` derivado do texto da legenda não é um XML Name válido
- **Sintoma:** ao gerar títulos/legendas, a validação de DTD falhava com
`Syntax of value for attribute ref of text-style is not valid` +
`Syntax of value for attribute id of text-style-def is not valid`, e o Final
Cut recusava o arquivo na importação.
- **Causa raiz:** `_make_text_title_clip` montava o id como
`f"{name}_ts0"`, e `name` vem do texto da legenda
(`"3 coisas que você precisa saber - Text"`). No DTD, `id` é do tipo `ID` e
`ref` do tipo `IDREF`: o valor precisa ser um **XML Name** — sem espaços,
sem acentos, nunca começando por dígito. Os três casos apareciam de uma vez
em texto português.
- **Onde:** `fcpxml/writer.py` (`_make_text_title_clip`, novo
`_unique_text_style_id`).
- **Tentativas que falharam:** confiar em `_sanitize_xml_value`, que protege
*conteúdo* de atributo (CDATA) mas não impõe as regras de XML Name.
- **Solução adotada:** `_unique_text_style_id()` — dobra o texto para ASCII
(NFKD), troca tudo que não seja `[A-Za-z0-9_.-]` por `_`, prefixa com `ts_`
(garante início por letra, inclusive quando o texto é só CJK/emoji e o slug
fica vazio) e sufixa um contador conferido contra um cache de ids do
documento, mantendo unicidade document-wide sem varrer a árvore por título.
- **Aprendizado:** todo atributo do tipo `ID`/`IDREF` no FCPXML precisa ser
gerado, nunca derivado de texto do usuário. Sanitizar valor de atributo e
sanitizar identificador são problemas diferentes.
- **Estado:** `resolvido`
---
### 2026-08-17 — Títulos ("Essencial - Título"/"Título Básico") nunca apareciam no FCP; o template que renderiza é o "Text" (Basic Text)
- **Sintoma:** título gerado no início do vídeo ficava invisível ou "sumia da
timeline" (mas continuava na lista de clipes), mesmo com posição/offset
aparentemente corretos. Com o template animado, o texto não desenhava; com o
estático, o título caía antes do in-point do clipe e o FCP o descartava.
- **Causa raiz (duas, no mesmo ciclo):**
1. Os dois templates que usávamos — "Essencial - Título"
(`Essential Title.moti`) e "Título Básico" (`Bumper:Opener/Basic
Title.moti`) — **não resolvem para um template desenhável** no FCP. A
importação é silenciosa: nada aparece, sem erro. É o MESMO modo de falha
silenciosa já registrado duas vezes antes nesta sessão (uid fabricado).
2. No caminho `animated=False`, o offset era gravado como **relativo**
(`0s`) em vez de coordenadas de mídia-fonte (`start` do clipe-pai +
relativo). O FCP lê `0s` como "0s da mídia", antes do in-point do clipe
(`start="220062843/24000s"`), então o título nunca cai sobre o vídeo.
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_BASIC_TITLE_UID`,
`_TEXTO_TITLE_PARAMS`, `_make_texto_title_clip`, `_make_basic_title_clip`,
`generate_dynamic_subtitles`.
- **Como o usuário resolveu:** criou dois títulos à mão no FCP e exportou
(`teste.fcpxmld` e `posição.fcpxmld`, FCP 1.14 em inglês). Ambos usam o
template **"Text"** (`uid=".../Titles.localized/Basic
Text.localized/Text.localized/Text.moti"`, `name="Text"`), `start` fixo
`86486400/24000s`, e um bloco de `<param>` com margens/alinhamento/`Custom
Speed` (com `<keyframeAnimation>` de tempos nominais constantes). A posição
é o param `Position` (chave `.../13260/3296672360/1/100/101`) com valor
estático `"x y"` — **sem** `<adjust-transform>`.
- **Solução adotada:** substituir os dois templates por um único "Text"
(Basic Text), copiado verbatim dos exports reais. Novo
`_make_text_title_clip` + `_ensure_text_title_effect` + `add_text_title`.
`generate_dynamic_subtitles` agora usa sempre o "Text" e grava offset em
coordenadas de mídia-fonte (`start` do pai + relativo) para **todos** os
títulos; posição via param `Position`, não `adjust-transform`. Removidos os
templates/código morto "Essencial - Título"/"Título Básico".
- **Aprendizado:** o único teste que vale para template de título é um
roundtrip de importação REAL no FCP — e o padrão-ouro é o export que o
próprio FCP produz quando o usuário adiciona o título à mão. Quando isso
existir, copiar **verbatim** (uid, params, `start`) e não "simplificar"
nada. Título conectado SEMPRE usa `start` do clipe-pai como origem do
offset, nunca `0s`.
- **Estado:** `resolvido` no XML — pendente de confirmação de importação real
no FCP pelo usuário.
---
### 2026-08-17 — Legendas dinâmicas sobrepondo entre clipes: título conectado NÃO é aparado pelo out-point do clipe-pai
- **Sintoma:** ao gerar, blocos de legenda de um clipe continuavam na tela por
cima das legendas do clipe seguinte — duas frases desenhadas ao mesmo tempo.
- **Causa raiz:** um `<title>` conectado a um `asset-clip` **não** é cortado
pelo fim do clipe-pai; o FCP segue desenhando sobre o que vier depois. O
último bloco de cada clipe terminava no `end` da última palavra do Whisper —
que frequentemente ultrapassa o corte — e palavras cujo `start` já caía
depois do corte também eram emitidas.
- **Onde:** `fcpxml/writer.py`, `generate_dynamic_subtitles()`.
- **Tentativas que falharam:** comprimir a palavra tardia para o último frame
do clipe (empilhava vários títulos no mesmo frame e na mesma lane).
- **Solução adotada:** descartar palavras que começam depois da duração do
clipe-pai e limitar (`clamp`) o fim de cada bloco a essa duração. Testes em
`tests/test_dynamic_subtitles.py::TestClipBoundaryClamping`.
- **Aprendizado:** nada anexado a um clipe pode sobreviver ao próprio clipe;
tempos vindos do Whisper precisam sempre ser recortados pela janela do clipe,
não só filtrados pelo `start`.
- **Estado:** `resolvido`
---
### 2026-08-17 — Palavras sobrepostas na tela: largura de texto ESTIMADA subestimava; solução foi embutir as métricas reais das fontes
- **Sintoma:** o usuário reportou que as palavras apareciam **todas sobrepostas** no Final
Cut. A verificação automática do XML dizia "0 sobreposições" — porque conferia contra a
minha própria estimativa de largura, não contra o que o FCP realmente desenha. Verificar
um cálculo com o mesmo cálculo não verifica nada.
- **Duas causas, uma de processo e uma técnica:**
1. **Processo:** o arquivo que o usuário importou era a versão anterior, gerada antes do
posicionamento existir (todas as palavras em `0 -45.4935`, literalmente no mesmo
ponto). Como o caminho de saída é sempre o mesmo (`_dynamic_subtitles.fcpxmld`), é
fácil reabrir a versão velha sem perceber.
2. **Técnica, e real:** `measure_text` estimava a largura por uma tabela AFM genérica de
Helvetica. Comparada com as fontes reais do macOS, o erro ia de **-1,4% a +7,0%** — e
o caso negativo é fatal: uma largura menor que a real faz duas palavras encostarem.
"dificuldade" a 170pt media 867,8 contra 880,1 reais.
- **Investigação:** as variantes reais (`Helvetica.ttc`, `HelveticaNeue.ttc`) diferem entre
si em até **21,5% do em** em alguns glifos — Light, Regular, Neue e Light Italic têm
avanços distintos. Nenhuma tabela única serve para todas.
- **Solução adotada:** extrair os avanços reais das fontes do sistema com `fontTools` e
**embutir como tabela** em `fcpxml/font_metrics.py` (9 variantes × 143 glifos). O
`fontTools` foi usado só na geração, via `uv run --with` — **não** virou dependência do
projeto, e o layout não lê fonte em runtime, então o resultado é idêntico em qualquer
máquina. Erro medido depois: **+2,0% constante** (só a margem de segurança), nunca
abaixo. Margem de segurança reduzida de 1,03 para 1,02, já que a medida agora é exata.
Espaço entre palavras passou de 5 pontos fixos para 14% do corpo da fonte maior.
- **Ferramenta que destravou o problema:** gerar um **preview HTML** que desenha as
palavras nas posições calculadas, com as fontes e tamanhos reais. Permite ver o layout
sem reimportar no FCP a cada tentativa. Nota: o painel de preview bloqueia JavaScript
(CSP), então o HTML precisa ser estático, com as posições já escritas no `style` de cada
elemento — nada de calcular no navegador.
- **Aprendizado:** **nunca validar uma saída com a mesma estimativa que a produziu.** Se o
código estima larguras, a verificação tem de medir contra a fonte real, senão ela apenas
confirma o próprio erro. E quando existe uma fonte de verdade acessível (o arquivo de
fonte no disco), extrair os dados dela e embutir sai mais barato e mais exato do que
qualquer aproximação — sem custo de dependência.
- **Estado:** `resolvido` (layout aprovado pelo usuário no preview HTML; confirmação de
importação no FCP pendente)
### 2026-08-17 — Calibrar coordenadas de título pedindo um export ao usuário, em vez de adivinhar a escala
- **Sintoma:** para posicionar cada palavra na tela era preciso escrever o param
`Posição` do template Essential Title, mas não havia como saber a unidade nem a escala.
O único valor existente no código era `0 -45.4935`, idêntico em todos os títulos —
variância zero, portanto nada a inferir.
- **Risco reconhecido antes de agir:** este mesmo arquivo já registra (entrada de
2026-08-14) que um `<param name="Position">` **fabricado** foi removido justamente por
ser inadivinhável, e que nem o DTD nem os testes unitários pegam `key`/valor inválido.
Chutar aqui reproduziria a falha silenciosa pela terceira vez.
- **Solução adotada:** em vez de estimar, pedir ao usuário um export do Final Cut com
palavras posicionadas à mão. Ele enviou `Exemplo Letra.fcpxmld` (projeto 2160x3840) com
a frase "Toda a minha vida, assim," — cinco palavras posicionadas no Inspetor, o resto
no default. Três fatos saíram dos números:
1. **Posição usa a mesma unidade que `fontSize`.** As distâncias centro a centro na
linha 1 (254,06 e 265,08) batem com a soma das meias-larguras calculadas pelas
métricas Helvetica nos tamanhos 170/128/151 (245,1 e 265,0). Uma unidade diferente
apareceria como razão constante; não há nenhuma.
2. **O canvas é 1080x1920 pontos** — metade do quadro, porque o FCP posiciona em pontos
sobre mídia 2x. A linha 1 vai de -460,3 a +495,7, preenchendo essa largura com
margens pequenas, exatamente como o quadro de referência aparenta.
3. **y cresce para cima**: "vida," (linha 2) em -233,65 contra a linha 1 em ~-101.
Também desambiguou **qual** param responde ao Inspetor: as cinco palavras carregam
valores distintos em `9999/10085/10086/1/100/101`, enquanto `.../2/358` permanece
`0 69` em todos os títulos do arquivo.
- **Validação:** o layout recalculado reproduz o do usuário — espaçamento entre linhas
131,8 contra 132,7 (erro de 0,7%) e o y das duas linhas coincidindo na casa decimal.
Os valores viraram testes (`tests/test_text_layout.py`,
`TestCalibrationAgainstRealExport`), então qualquer regressão de escala falha.
- **Descoberta colateral:** o espaçamento entre linhas do usuário (132,65) é menor que o
corpo da maior fonte da linha (170), o que só fecha porque o texto ocupa a altura de
caixa-alta (~0,75 do corpo), não o em-box inteiro. Usar o em-box afastaria as linhas
~40% a mais do que ele fez.
- **Aprendizado:** quando um valor não é derivável dos dados em mãos, **pedir um artefato
de calibração ao usuário custa minutos e elimina a adivinhação**. Cinco palavras
arrastadas à mão renderam escala, unidade, orientação do eixo, espaçamento e a paleta —
tudo o que três rodadas anteriores de chute não conseguiram. E vale desconfiar de
qualquer constante que apareça idêntica em todas as instâncias de um arquivo: variância
zero significa que ela nunca foi exercitada, não que esteja certa.
- **Estado:** `resolvido`
### 2026-08-17 — Legendas dinâmicas invisíveis porque eram geradas como CAPTION, não como TÍTULO — e a "referência verificada" do código nunca tinha funcionado
- **Sintoma:** legendas dinâmicas **nunca** apareceram no Final Cut. Importava sem
nenhum erro, DTD passava, e nada era desenhado sobre o vídeo. Sintoma idêntico ao das
entradas anteriores (uid inválido → descarte silencioso), o que levou várias rodadas de
correção a atacarem o alvo errado (uid, offset, dispatch de builder).
- **Causa raiz:** o programa emitia **caption**, não **título animado**. Duas coisas
acopladas, ambas erradas:
1. `_TEXTO_TITLE_UID` apontava para `.../Subtitles.localized/Subtitle.localized/Subtitle.moti`
— o template de **legenda/caption** do FCP, não um template de título.
2. Cada `<title>` recebia `role="subtitles.subtitles-1"`. Esse role faz o Final Cut
tratar o elemento como legenda e roteá-lo para a **pista de captions**, que não é
desenhada sobre o vídeo a menos que a exibição de legendas esteja ligada.
- **Como foi descoberto:** o usuário montou títulos à mão dentro do FCP e exportou
(`Teste de Texto.fcpxmld`, `exemplo de arquivos.fcpxmld`). Comparando os títulos dele
(que aparecem) com os gerados (que somem): os dele usam `Essential Title.moti` /
`Essential Fade.moti` / `Text.moti` e **não têm atributo `role` nenhum`**; os gerados
usavam `Subtitle.moti` + `role="subtitles.*"`. Prova adicional: no re-export, o FCP
devolveu os `caption_*` com offsets negativos e fora do clipe (−2,13s num clipe de
1,835s), porque os realocou como captions noutro sistema de coordenadas, enquanto os
títulos manuais voltaram coerentes dentro do clipe (0,58s e 1,38s).
- **Onde:** `fcpxml/writer.py` (`_TEXTO_TITLE_UID`, `_TEXTO_TITLE_ROLE`,
`_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_make_texto_title_clip`),
`fcpxml/models.py` (`DynamicSubtitleConfig`), `server.py`, `MacApp/Sources/*.swift`.
- **Premissa falsa que ancorou os erros anteriores:** um comentário no próprio
`fcpxml/writer.py` afirmava que `WHISPERX/code/"Teste do dia.fcpxmld"` era um export
real do FCP *"actually imported and played back"*. O usuário confirmou que **nunca
funcionou** — aquele arquivo é output do próprio programa. Como o comentário foi tratado
como fonte de verdade, cada correção seguinte se apoiava nele e reproduzia a estrutura
errada (inclusive o `role` de caption). A entrada anterior desta lista herdou o mesmo erro.
- **Solução adotada:**
1. `_TEXTO_TITLE_UID` → `.../Titles.localized/Essential Titles.localized/Essential Title.localized/Essential Title.moti`
e nome do efeito → `"Essencial - Título"`.
2. `_TEXTO_TITLE_ROLE` **removido** e `elem.set('role', ...)` eliminado de
`_make_texto_title_clip`. Nenhum título gerado carrega `role`.
3. `_TEXTO_TITLE_START` → `86486400/24000s` (valor que o FCP escreve para o Essential Title).
4. `_TEXTO_TITLE_PARAMS` → só os 5 params de layout do Essential Title. Os ~75 params de
animação foram deixados de fora de propósito: carregam `<keyframeAnimation>` com tempos
**absolutos** calibrados à duração de uma instância específica, e replicá-los em títulos
de outra duração produz animação truncada/congelada. Sem eles o template Motion anima
pelos próprios defaults.
5. Granularidade: `max_words_per_line` 4 → **1** e `lane_count` 3 → **9** (defaults
alinhados em `models.py`, `server.py` e no MacApp), gerando um `<title>` por palavra.
6. Comentários falsos reescritos apontando para os exports reais do usuário.
7. Novo teste de regressão `test_titles_carry_no_caption_role`.
- **Aprendizado:** **legenda dinâmica = título animado, não caption.** Um
`role="subtitles.*"` num `<title>` o esconde atrás do toggle de legendas — importa limpo
e nunca aparece, exatamente o mesmo sintoma de um uid inválido, o que torna os dois fáceis
de confundir. E, mais importante: **um comentário dizendo "verificado" não é verificação.**
Só vale como referência um arquivo que o usuário confirmou ter saído do Final Cut. Antes de
tratar qualquer arquivo como ground truth, checar se ele é output do próprio programa —
se os `name=` seguem o padrão que o código gera (`caption_<hex>`), ele é.
- **Estado:** `resolvido` no XML (verificado na saída: efeito Essential Title, zero roles,
1 título por palavra, offsets dentro da janela do clipe) — **pendente de confirmação de
importação real no FCP pelo usuário**, que é o único teste que conta neste histórico.
### 2026-08-17 — Legendas dinâmicas não apareciam no FCP: dispatch misturava Título Básico com bloco/role do Subtitle + `offset` em coordenada errada (timeline em vez de mídia)
> **Nota (revisão posterior):** esta entrada trata `WHISPERX/code/"Teste do dia.fcpxmld"`
> como export real verificado do FCP. **Isso está errado** — aquele arquivo é output do
> próprio programa e nunca funcionou. Ver a entrada acima. A parte de `offset` em
> coordenada de mídia continua correta (reconfirmada contra `exemplo de arquivos.fcpxmld`),
> mas o `role="subtitles.*"` e o template Subtitle.moti descritos aqui eram a causa real
> das legendas invisíveis.
- **Sintoma:** ao gerar legendas dinâmicas num projeto real (`Depoimento da Erika - Original.fcpxmld`, clipe único com `start="226220995/24000s"` ≈ 256.59s na mídia de origem) e abrir o resultado no Final Cut, as legendas simplesmente não apareciam — nem na timeline, nem na lista de roles. O app chamava o handler sem `animated`, então caía no default e produzia um `<title>` com `ref="r_title_basic"` (Título Básico) mas com `role="subtitles.subtitles-1"`, `start="86400314/24000s"` e os 19 params do template "Legenda" — um híbrido impossível de resolver no FCP (descarte silencioso, padrão documentado nas entradas de 2026-08-15/2026-08-17).
- **Causa raiz (dois bugs num ciclo):**
1. `generate_dynamic_subtitles` (`fcpxml/writer.py`) escolhia o efeito certinho por `config.animated` (linhas 2893-2896), mas SEMPRE construía o clip com `_make_texto_title_clip` (linha 2944) — ignorando o `animated`. `_make_basic_title_clip` (que monta o Título Básico sem role/start e só os 2 params `Compactar`/`Alinhamento`) existia mas **nunca era chamado** (código morto). Resultado default: ref do básico + corpo do subtitle → o FCP descarta silenciosamente.
2. Mesmo no caminho animado, o `offset` era escrito como valor **relativo à timeline** (ex.: `1/4800s`). Mas o export real verificado (`WHISPERX/code/"Teste do dia.fcpxmld"`) mostra que o template "Legenda"/Subtitle posiciona os captions em **coordenadas da mídia de origem**: `offset = start_do_clipe + relativo` (ex.: `240822582/24000s` = start `240817577/24000s` + 0.2085s), enquanto o "Título Básico" (r3) usa offset relativo (ex.: `1001/4800s`). Escrever offset relativo pequeno num clip cujo start é ~256s/9000s faz o FCP ler ~0s da mídia — antes do in-point do clipe — e a legenda cai "fora" (caso `Legendas fora.fcpxmld`).
- **Onde:** `fcpxml/writer.py` (`generate_dynamic_subtitles`, linhas ~2893-2956), `fcpxml/models.py` (`DynamicSubtitleConfig.animated`, default errado `False`), `tests/test_dynamic_subtitles.py`.
- **Solução adotada:**
1. `generate_dynamic_subtitles` agora despacha pelo `config.animated`: `True` → `_make_texto_title_clip` (template "Legenda"), `False` → `_make_basic_title_clip` (Título Básico). O híbrido impossível não existe mais.
2. No caminho animado, `offset = media_origin + snap(relativo)` com `media_origin = _parse_time(parent.get('start'))` (coordenada da mídia, igual ao export real); no estático, offset permanece relativo (como r3).
3. `DynamicSubtitleConfig.animated` agora é `True` por padrão (decisão do usuário: a feature é a legenda animada/ediável; `False` só para texto queimado no frame).
4. Adicionados testes de regressão (`test_animated_offset_uses_source_media_coordinates`, `test_animated_effect_is_legenda_subtitle`, `test_static_mode_uses_basic_title_no_role`, `test_animated_and_static_use_separate_effects`).
- **Aprendizado:** dois templates diferentes num mesmo método exigem dispatch por builder, e cada um tem seu próprio sistema de coordenadas de `offset` — o template de caption/legenda usa a posição na mídia de origem (nunca um offset relativo pequeno), o título estático usa offset relativo ao clipe. Comparar sempre com o export real (coordenadas + role + params) antes de fechar uma estrutura, e nunca ignorar o branch `else` de um `if config.X` que decide o template — builder único = mistura de corpo de um template com ref de outro = descarte silencioso no FCP.
- **Estado:** `resolvido`
---
### 2026-08-17 — Clipe-fantasma de 1 frame no início e no fim após remoção de silêncio (raiz real no gerador, diferente da entrada de 2026-08-14)
- **Sintoma:** usuário testou `remove_silences` num projeto real (`Depoimento da Erika_silence_removed.fcpxmld`) e reportou dois clipinhos minúsculos: o primeiro e o último clipe da spine gerada tinham `duration="1001/24000s"` — exatamente 1 frame a 23.976fps.
- **Causa raiz:** diferente da entrada de 2026-08-14 ("Micro-clips... são criados pelo FCP, não pelo corte") — aqui os slivers já vinham no `Info.fcpxml` bruto gerado pelo programa, confirmado lendo o XML direto, sem passar pelo FCP. `handle_remove_media_silence`/`cut_clip_ranges` (`fcpxml/writer.py`) aplica um `padding` (respiro, padrão 0.05s) antes/depois de cada trecho de silêncio cortado. Quando o silêncio detectado toca a própria borda do clipe (começo ou fim), não sobra fala nenhuma daquele lado para o padding "respirar perto de" — o padding vira, sozinho, o segmento "kept" (mantido) daquela ponta, e após o snap para o grid de frames (`snap_seconds_to_frame`) esse segmento de ~0.05s vira exatamente 1 frame, virando clipe próprio em vez de ser absorvido.
- **Onde:** `fcpxml/writer.py::FCPXMLModifier.cut_clip_ranges`, construção da lista `keeps` (complemento dos `cut_ranges` mesclados).
- **Tentativas que falharam:** n/a — diagnóstico direto lendo o XML bruto e cruzando com a lógica de `cut_clip_ranges`; os números batem exatamente (0.05s de padding ≈ 1.2 frames a 23.976fps → arredonda para 1 frame).
- **Solução adotada:** a pedido do usuário ("em vez de criar esse [micro-clipe] novo, ele pode pegar o próprio segundo clipe e aumentar a duração dele para começar antes") — depois de montar `keeps`, se o primeiro segmento tiver menos que ~2 frames de duração, ele é fundido no segmento seguinte (que passa a começar mais cedo); simétrico no fim (o penúltimo segmento passa a terminar mais tarde, absorvendo o último). Isso também restaura o pequeno trecho de silêncio adjacente que teria sido cortado ali — troca aceitável por não deixar clipe-fantasma na timeline.
- **Aprendizado:** ao gerar clipes a partir de um algoritmo de corte com padding, sempre checar segmentos residuais nas BORDAS da mídia/clipe (não só entre dois cortes no meio) — o padding não tem "vizinho de fala" do lado de fora do clipe, então o caso de borda precisa de tratamento explícito (fundir em vez de emitir). Existe um padrão irmão já usado alhures no código (`_absorb_into_neighbor`, `fcpxml/writer.py:1112`) para a mesma ideia geral.
- **Estado:** `resolvido`
---
### 2026-08-17 — As 481 legendas do usuário ficaram todas presas num único clipe errado: `generate_dynamic_subtitles` resolvia o clipe-pai por `name`, ambíguo depois de corte de silêncio
- **Sintoma:** mesmo com o `uid` e os `offset`/`duration` corrigidos (entradas abaixo), o usuário mandou o `.fcpxmld` real depois de importar no FCP ("como ficou depois de importar") e as 481 legendas apareciam todas grudadas em ~8-17 segundos de um único clipe da timeline, com frases completamente sem relação entre si ("vontade.", "Sou erica Fernanda, tenho", "sou casada.") — claramente vindas de pontos bem distantes de uma entrevista de ~17 minutos, não de um trecho de 8 segundos.
- **Causa raiz:** `server.py::handle_generate_dynamic_subtitles` itera cada clipe da spine (`for el in spine_clips`), calcula a janela de palavras correta *relativa àquele clipe* (`el`), mas chamava `modifier.generate_dynamic_subtitles(name, ...)` passando `name = el.get("name", "")` — uma STRING — em vez do elemento. Depois de qualquer `remove_silences`/corte com ripple, TODOS os fragmentos resultantes de um clipe original mantêm o mesmo `name` herdado (aqui, ~482 clipes, todos `name="0E6A8829"`, o nome do asset de origem). `_require_clip()` resolve por `self.clips[key]`, um dict indexado por `id` ou, na falta dele, por `name` (`fcpxml/writer.py:_index_elements`) — com nomes duplicados, cada novo clipe indexado SOBRESCREVE o anterior, então `self.clips["0E6A8829"]` acaba apontando para só UM clipe (o último indexado). Toda chamada do loop, para qualquer um dos 482 clipes reais, resolvia para esse mesmo clipe errado — empilhando ali as legendas de quase o vídeo inteiro.
- **Onde:** `server.py` (`handle_generate_dynamic_subtitles`, a chamada a `modifier.generate_dynamic_subtitles`), `fcpxml/writer.py` (`generate_dynamic_subtitles`, `_require_clip`, `_index_elements`).
- **Solução adotada:** `generate_dynamic_subtitles` agora aceita `parent_clip` como `str | ET.Element` — se receber o elemento diretamente, usa-o sem passar pelo lookup por nome; só cai em `_require_clip(name)` (mantido para compatibilidade com chamadas antigas/testes) quando recebe uma string. `server.py` foi atualizado para passar `el` (o elemento já em mãos no loop) em vez de `name`. Adicionado teste de regressão (`test_element_param_bypasses_ambiguous_duplicate_name_lookup`) que simula dois clipes com o mesmo `name` e confirma que passar o elemento anexa cada legenda ao clipe certo.
- **Aprendizado:** **nunca identificar um clipe específico por `name` num handler que itera múltiplos clipes** — qualquer operação de corte/ripple/remoção de silêncio no FCPXML preserva o `name` original em todos os fragmentos resultantes, então `name` deixa de ser único assim que o timeline é editado. Sempre que o chamador já tem o `ET.Element` em mãos (por ter vindo de uma iteração como `_iter_spine_clips()`), passe o elemento adiante em vez de re-resolvê-lo por um identificador que pode colidir.
- **Estado:** `resolvido`
---
### 2026-08-17 — Legendas dinâmicas sumiam silenciosamente do Final Cut por `uid` de efeito inválido (mistura de dois templates); dois bugs num só ciclo
- **Sintoma:** depois de corrigir os erros de frame-boundary do `offset`/`duration` (entrada abaixo, mesma sessão), o Final Cut não reportava mais nenhum erro de importação — mas os 481 `<title>` de legenda simplesmente não apareciam em lugar nenhum: nem na timeline, nem na lista de roles do projeto.
- **Causa raiz:**
1. O `uid` fixado em `_TEXTO_TITLE_UID` (`.../Titles.localized/Basic Text.localized/Text.localized/Text.moti`) mistura os nomes de dois templates diferentes ("Basic Text" e "Text") e não corresponde a nenhum Motion template real instalado no FCP. Quando o `uid` de um `<effect>` referenciado por um clipe conectado não resolve para um template existente, o Final Cut **descarta silenciosamente** os clipes conectados que dependem dele durante a importação — sem erro, sem aviso na UI. É a segunda vez que um `uid` fabricado aqui é a causa raiz (ver entrada de 2026-08-15 abaixo — da primeira vez foi o "Basic Title", desta vez foi um "Texto" que só passou pelos testes internos porque nunca foi de fato importado de novo no FCP depois de escrito).
2. Um dos `<param>` copiados junto (`Opacidade` = `"0"`) fixava a opacidade do texto em zero — mesmo se o `uid` estivesse certo, o texto ficaria invisível.
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_ensure_texto_title_effect`, `_make_texto_title_clip`.
- **Solução adotada:** o usuário identificou um export real, já importado e reproduzido com sucesso no FCP, presente no próprio repositório em `WHISPERX/code/"Teste do dia.fcpxmld"/Info.fcpxml` — efeito `r4` nomeado **"Legenda"**, `uid=".../Titles.localized/Subtitles.localized/Subtitle.localized/Subtitle.moti"`, usado em cinco `<title>` conectados por palavra/linha com `role="subtitles.subtitles-1"`. Copiado o `uid`, o `name` ("Legenda"), o `start` fixo (`86400314/24000s`, diferente do valor anterior), o atributo `role`, e o bloco de 19 `<param>` inteiro verbatim — que não inclui nenhum param de opacidade nem `<keyframeAnimation>` manual (a revelação palavra-a-palavra é nativa do template via os params `Animar`/`Intervalo`, não uma curva de velocidade fabricada como no template anterior).
- **Aprendizado:** um `uid`/bloco de params "plausível" que passa nos testes internos (`fcpxml/dtd.py`, pytest) **não é prova de que é real** — só um roundtrip de importação de verdade no FCP prova isso, e mesmo assim a falha pode ser silenciosa (sem erro) em vez de uma rejeição explícita. Regra geral reforçada: nunca fabricar/adivinhar `uid` de efeito nativo do FCP, mesmo que o formato pareça consistente com outros exports reais — sempre copiar de um `.fcpxmld`/`Info.fcpxml` que o usuário confirma ter sido importado e reproduzido com sucesso.
- **Estado:** `resolvido`
---
### 2026-08-17 — Palavras das legendas dinâmicas empilhadas: o template "Essencial - Título" ANIMA por padrão e a animação ignora a posição estática — solução foi desligar `Animar`
- **Sintoma:** o usuário reportou, repetidamente, **todas as palavras uma em cima da outra** no Final Cut. O XML gerado tinha posições estáticas e distintas (verificado), mas o FCP **ignorava** a posição e empilhava tudo — o sintoma persistiu mesmo com `value="x y"` correto em cada palavra.
- **Causa raiz (a definitiva):** o template "Essencial - Título" (Essential Title, Motion) tem um parâmetro **`Animar`** (Animate). No export de calibração (`Exemplo Letra.fcpxmld`, as 5 palavras que o usuário arrastou à mão e funcionaram), cada título carrega **~111 params**, incluindo `Animar = "4 (Tudo)"` mais dezenas de params de animação por caractere (`X/Y/Z deslocamento`, `Objeto Original`, `Deslocamento Inicial/Final`, `Direção`, `Velocidade Personalizada` com keyframes). Nós só escrevíamos 5 params de layout e **omitíamos o `Animar`**. Sem ele, o FCP usa a **animação default** do template — a animação de "Tudo" (fly-in 3D por caractere) — e **é essa animação que posiciona as letras**, não o nosso `Posição`. Resultado: cada caractere cai na posição default e tudo empilha. As palavras da calibração só ficaram no lugar porque o FCP escreveu o bloco de animação completo junto.
- **Por que NÃO copiar o bloco de animação:** os params por caractere (`X deslocamento`, `Y deslocamento`, `Z deslocamento`, `Objeto Original`) têm valores **diferentes por palavra** (ex.: `X deslocamento = -960.047` em "Tod" vs `-243` em "a") — são dados 3D por caractere que o FCP calcula e que não dá para reproduzir. Já os `time` dos keyframes são **idênticos em todas as palavras** (`0s`, `1567433324/1000000000s`, `19915648/3840000s`, `6686008967/1000000000s`), confirmando que são a curva default do template, não calibrados por instância.
- **Onde:** `fcpxml/writer.py::_make_texto_title_clip`, novo `_TEXTO_ANIMAR_KEYS` (10 chaves `.../201/203` de `Animar`), `tests/test_dynamic_subtitles.py`.
- **Tentativas que falharam:** (1) embrulhar a posição em `keyframeAnimation` — piorou, pois o param é estático e o FCP descartou tudo para o default `0 -45.4935`; (2) reverter só para o valor estático — ainda empilhava, porque a animação default continuava ignorando a posição.
- **Solução adotada:** escrever `Animar = "0 (Nenhum)"` nas 10 chaves de animação do template, desligando a animação. O título fica **estático** e respeita o `Posição` gravado; a revelação palavra-por-palavra continua vindo do `offset`/`duration` de cada palavra (não da animação). Teste atualizado: 5 params de layout + 10 `Animar = "0 (Nenhum)"`, sem `keyframeAnimation`.
- **Aprendizado:** num template Motion de título, a **posição visual pode ser controlada pela animação, não pelo param de layout** — se um título "arrastado à mão" funciona e o gerado empilha, comparar o bloco de params **inteiro** (não só a posição) entre os dois arquivos. O `Animar` é o interruptor-mestre: `4 (Tudo)` anima (e aí só os dados 3D por caractere — irreproduzíveis — colocam as letras no lugar); `0 (Nenhum)` desliga e devolve o controle ao param `Posição`. E: a mesma conclusão errada foi registrada e corrigida duas vezes nesta sessão — a cada iteração, reler o export de calibração **inteiro** antes de decidir o mecanismo.
- **Estado:** `resolvido` no XML (posições estáticas distintas + 10× `Animar = "0 (Nenhum)"` verificados no output real; 1124 testes verdes) — **pendente de confirmação de importação real no FCP pelo usuário**, único teste que conta neste histórico.
---
### 2026-08-17 — `offset`/`duration` de legendas dinâmicas fora do grid de frames (denominador `/23s` em vez de `/24000s`)
- **Sintoma:** importação real no Final Cut rejeitada com 457 de 481 erros "O item não está em um limite de quadro de edição", todos apontando para `offset`/`duration` de `<title>` com denominador `/23s` (ex.: `offset="2/23s"`, `duration="67/23s"`).
- **Causa raiz:** `generate_dynamic_subtitles` (`fcpxml/writer.py`) construía cada offset/duration com `TimeValue.from_seconds(seconds, self.fps)`. Esse classmethod (`fcpxml/models.py`) faz `int(fps)` — para um projeto NTSC a 23.976fps (`frameDuration="1001/24000s"`, `self.fps ≈ 23.976`), `int(fps)` trunca para `23`, uma base de tempo inválida para o FCPXML. Todo o resto do XML (asset-clips, cortes de silêncio, sequence) já usava a base exata `1001/24000s`.
- **Onde:** `fcpxml/writer.py:2841-2850` (chamada) e `fcpxml/models.py:314-318` (`TimeValue.from_seconds`, bug latente — outros call-sites como marcadores em `fcpxml/writer.py:1442,1511` usam o mesmo padrão e podem ter o mesmo problema em taxas NTSC, não corrigido nesta rodada por estar fora do escopo do bug relatado).
- **Solução adotada:** trocado `TimeValue.from_seconds(start, self.fps)` / `TimeValue.from_seconds(end - start, self.fps)` por `self.snap_seconds_to_frame(...)` — helper já existente (`fcpxml/writer.py:746`) que usa a fração exata de `frameDuration` (via `frame_duration_fraction()`, `fcpxml/writer.py:727`) em vez do float truncado, já usado em outro lugar do writer para snap da spine. Também trocado `min_dur_seconds = 1.0 / self.fps` por `float(self.frame_duration_fraction())`.
- **Aprendizado:** qualquer conversão de segundos-float para `TimeValue` num projeto FCPXML deve usar a fração exata do `frameDuration` do `<format>` da sequência (via `frame_duration_fraction()`/`snap_seconds_to_frame()`), nunca `int(fps)` — taxas NTSC (23.976/29.97/59.94) sempre truncam errado com um fps float.
- **Estado:** `resolvido`
---
### 2026-08-15 — Abandonado o Compound Clip em `generate_dynamic_subtitles`; voltado a títulos soltos em lanes cicladas, com estrutura copiada de um export real
- **Sintoma/decisão:** mesmo depois de corrigir os 3 erros de importação do Compound Clip (entrada abaixo), o usuário decidiu recuar da abordagem por completo — "muito problema e muito erro" — e pediu para voltar ao básico: títulos soltos, direto na timeline, em várias lanes, sem Compound Clip.
- **O que mudou:** `generate_dynamic_subtitles` não cria mais `<media>`/`<sequence>`/`<gap>`/`<ref-clip>` nenhum. Cada linha (chunk de palavras) vira um `<title>` autônomo, anexado direto no clipe pai via `_dtd_insert`, ciclando por `config.lane_count` lanes (round-robin). A duração de cada linha se estende até sua própria lane ser reaproveitada `lane_count` linhas depois (ou até seu próprio fim, se for uma das últimas) — isso empilha visualmente várias linhas ao mesmo tempo (efeito cascata) sem nunca sobrepor duas linhas na MESMA lane.
- **Fonte da estrutura XML:** o usuário mandou um FCPXML real (`com exemplo de título.fcpxmld/Info.fcpxml`) com 3 títulos criados manualmente no FCP usando o template **"Texto"** (`uid=".../Titles.localized/Basic Text.localized/Text.localized/Text.moti"` — diferente do "Basic Title" usado antes). Copiei o bloco de `<param>` inteiro (margens, alinhamento, quebra automática, o par Opacidade/Velocidade Personalizada com `<keyframeAnimation>` que parece ser a animação de revelação nativa do template) e o atributo `start` fixo (`86486400/24000s`, idêntico nos três títulos do export) como constantes fixas em `_TEXTO_TITLE_PARAMS`/`_TEXTO_TITLE_START` — não tentei entender/simplificar esses valores, só copiei verbatim, já que "simplificar" um bloco de params reais foi exatamente o que causou os erros anteriores.
- **Importante (o usuário corrigiu isso no meio da conversa):** os offsets/timings do arquivo de exemplo eram só ilustrativos — não estavam sincronizados com nenhuma fala real. O timing de verdade continua vindo 100% da transcrição Whisper (`words` com `start`/`end` reais), usando a mesma lógica de `TimeValue`/mapeamento fonte→timeline já estabelecida no projeto. Só a ESTRUTURA XML (uid, params, `start` fixo) foi copiada do exemplo, nunca os números de tempo.
- **Onde:** `fcpxml/writer.py` (`FCPXMLModifier.generate_dynamic_subtitles`, `_make_texto_title_clip`, `_ensure_texto_title_effect`), `fcpxml/models.py` (`DynamicSubtitleConfig.lane_count` substituindo `lane`), `server.py`, `admin/models_api.py`, `MacApp/Sources/CaptionsView.swift`.
- **Aprendizado:** quando o usuário oferece um export real do FCP como referência, tratar isso como fonte de verdade para a ESTRUTURA (uid, ordem de elementos, bloco de params), mas nunca para os NÚMEROS de tempo específicos de um exemplo ilustrativo — a menos que ele diga explicitamente que os números também são reais. E: depois de duas rodadas de erro de importação real, a abordagem mais simples e mais próxima de um export real validado sempre vale mais que uma abstração mais "elegante" (Compound Clip) que ninguém verificou contra o importador de verdade do FCP.
- **Estado:** `resolvido`
---
### 2026-08-15 — Três rejeições de importação real no Final Cut Pro em `generate_dynamic_subtitles` (uid fabricado, `<title>` ancorado em `<gap>`, duração 0)
- **Sintoma:** ao importar de verdade no Final Cut Pro (não só validar internamente), o app recusou o `Info.fcpxml` com três classes de erro: (1) `uid=".../Titles.localized/Basic Text.localized/..." — O item não pôde ser lido`; (2) `Edição inválida sem nenhuma mídia respectiva` apontando para `.../gap[1]/title[1]`; (3) `Um valor inesperado foi encontrado (duration="0/1s")` em vários `<gap>` dentro dos `<media>` de legenda.
- **Causa raiz:**
1. O `uid` do efeito "Basic Title" foi **inventado** (nunca verificado contra um export real) — o caminho correto tem `Bumper:Opener.localized`, não `Basic Text.localized`.
2. Os `<title>` por palavra estavam sendo anexados como *connected clip* (via `lane`) dentro de um `<gap>` usado só para "segurar" a duração da linha — mas um `<gap>` não é mídia, e FCP rejeita qualquer clipe conectado a um `<gap>` como âncora.
3. Palavras com `start`/`end` muito próximos (ou vindas de um transcript com timestamps imprecisos) geravam durações que arredondavam para 0 frames no fps da sequência.
- **Onde:** `fcpxml/writer.py`, `FCPXMLModifier.generate_dynamic_subtitles` / `_make_title_clip` / `_ensure_basic_title_effect`.
- **Tentativas que falharam:** validar apenas com `fcpxml/dtd.py` e com os testes unitários — nenhum dos dois pega uid/params inválidos (o DTD da Apple não estava disponível neste ambiente) nem a regra "conectado precisa de mídia real por trás", que só o importador real do FCP aplica.
- **Solução adotada:**
1. Encontrado um `uid` **real e correto** dentro do próprio repositório, em `WHISPERX/code/*.fcpxmld/Info.fcpxml` (um projeto de verdade exportado pelo usuário) — usado esse valor em vez de inventar um novo. Os únicos dois `<param>` que esse template realmente usa (`Compactar` e `Alinhamento`, com `key` fixo) também foram copiados de lá; o `<param name="Position">` fabricado foi removido.
2. Reestruturado o compound clip: os `<title>` por palavra agora são conteúdo **primário** da spine interna (como o próprio `<title>` já suporta ser primário), com `<gap>` só preenchendo silêncio real entre eles — nunca mais como pai/âncora de um clipe conectado.
3. Adicionado um piso de duração mínima de 1 frame (`1.0 / fps`) tanto por palavra quanto pela linha inteira, em vez do padding fixo de `0.01s` que arredondava para 0 em fps altos.
- **Aprendizado:** **nunca fabricar `uid`/`key` de efeitos nativos do FCP** — eles não são adivinháveis e a validação interna (`fcpxml/dtd.py`) só pega isso se o Final Cut Pro estiver instalado localmente; sempre que possível, procurar/pedir um export real como referência antes de inventar. Além disso, "conectado" (`lane`) sempre precisa de um clipe com mídia de verdade por trás — um `<gap>` nunca serve de âncora, mesmo que pareça funcionar nos testes internos (que só checam a árvore XML, não as regras semânticas do importador do FCP). E qualquer duração calculada a partir de subtração de floats de transcrição deve ter um piso de `1/fps`, nunca uma constante fixa pequena.
- **Estado:** `resolvido`
---
### 2026-08-15 — Corpo de `cmd_add_zoom` colado por engano dentro de `cmd_remove_silences` em `admin/models_api.py`
- **Sintoma:** lint (`ruff`) falhando com `F821 Undefined name 'clip_id'` e `F841 Local variable 'clip_id' is assigned to but never used`, em duas funções diferentes do bridge Python↔Swift.
- **Causa raiz:** durante uma edição manual (introduzindo `_derived_output()` para suportar `output_dir` configurável), o corpo inteiro de `cmd_add_zoom` (checagem de `clip_id` + chamada a `handle_add_zoom`) foi colado dentro de `cmd_remove_silences`, antes do bloco correto que já chamava `handle_remove_media_silence` — deixando `cmd_remove_silences` com código morto/quebrado (chamava o handler errado e checava uma variável inexistente) e `cmd_add_zoom` truncado (só validava `path`/`clip_id` e não fazia mais nada).
- **Onde:** `admin/models_api.py`, funções `cmd_remove_silences` e `cmd_add_zoom`.
- **Tentativas que falharam:** n/a — identificado direto pelo lint e por leitura do código antes de qualquer tentativa de correção.
- **Solução adotada:** movido o fragmento (checagem de `clip_id` + chamada a `handle_add_zoom`) de volta para dentro de `cmd_add_zoom`, removendo-o de `cmd_remove_silences`, que voltou a conter só a chamada correta a `handle_remove_media_silence`.
- **Aprendizado:** depois de qualquer edição manual em `admin/models_api.py` (ou qualquer arquivo com várias funções `cmd_*` de shape parecido), rodar o lint imediatamente pega colagens cruzadas de função — `ruff` acusa tanto a variável usada-mas-nunca-definida (função que perdeu o trecho) quanto a definida-mas-nunca-usada (função que ganhou o trecho de outra) no mesmo commit, o que é um sinal forte de bloco trocado de lugar, não dois bugs independentes.
- **Estado:** `resolvido`
---
### 2026-08-15 — IDs duplicados de `text-style-def` rejeitados pelo Final Cut Pro em legendas dinâmicas
- **Sintoma:** ao importar o FCPXML gerado por `generate_dynamic_subtitles`, o Final Cut Pro recusava o arquivo com "A validação DTD falhou" e uma lista de IDs como `caption_L0W0_ts0 already defined`.
- **Causa raiz:** os IDs de `<title>`/`<text-style-def>` eram montados como `caption_L{line_idx}W{word_idx}_ts{i}`, com `line_idx`/`word_idx` reiniciando em 0 a cada chamada de `generate_dynamic_subtitles`. Como o handler (`handle_generate_dynamic_subtitles` em `server.py`) chama esse método uma vez por clipe da spine na mesma instância de `FCPXMLModifier`, múltiplos clipes geravam exatamente os mesmos IDs — o DTD exige unicidade de ID no documento inteiro, não por clipe.
- **Onde:** `fcpxml/writer.py`, método `FCPXMLModifier.generate_dynamic_subtitles`.
- **Tentativas que falharam:** nenhuma alternativa testada — o padrão (índices posicionais que resetam por chamada) era o bug desde a primeira implementação; só foi pego ao testar a importação real no Final Cut Pro.
- **Solução adotada:** trocar o índice posicional por um prefixo derivado de `uuid.uuid4().hex[:8]` por linha (`caption_{line_uid}_W{word_idx}`), garantindo unicidade mesmo entre chamadas repetidas na mesma instância do modifier. De quebra, corrigido também: o `<ref-clip>` (compound clip) estava sendo anexado via `ET.SubElement` direto no clipe pai, o que o colocava depois de marcadores (`keyword`, `chapter-marker`) já existentes — violando a ordem de filhos exigida pelo DTD (itens-âncora como `ref-clip` devem vir antes de itens de marcador). Trocado para `_dtd_insert(parent, ref_clip)`, que já resolve essa ordenação.
- **Aprendizado:** qualquer ID gerado dentro de um método chamado em loop (uma vez por clipe/iteração) sobre a MESMA árvore XML não pode depender de um índice que reinicia a cada chamada — precisa ser único por invocação (UUID, contador persistido na instância, ou verificação contra os IDs já existentes no documento). Além disso, qualquer elemento anexado a um clipe existente via `SubElement` direto (em vez de `_dtd_insert`) só é seguro se o clipe nunca tiver marcadores/filhos de prioridade menor já presentes — na dúvida, sempre usar `_dtd_insert`.
- **Estado:** `resolvido`
---
### 2026-08-14 — Tolerância e ações consolidadas no processamento em lote
- **Sintoma:** a tolerância do corte ficava separada dos checkboxes e havia controles individuais repetindo as ações do lote.
- **Causa raiz:** o layout foi evoluído incrementalmente, mantendo os fluxos antigos abaixo do novo processamento em lote.
- **Solução adotada:** slider dentro do grupo "Remover silêncios", desabilitado quando a opção é desmarcada; campo de frases condicionado ao respectivo checkbox; removidos botões individuais e mantidas apenas as ações finais de abrir pasta e abrir no Final Cut.
- **Aprendizado:** quando existe processamento em lote, os parâmetros devem ficar junto da operação e os comandos individuais não devem duplicar o fluxo principal.
- **Estado:** `resolvido`
---
### 2026-08-14 — Pasta única e processamento em lote no app
- **Sintoma:** cada operação salvava o resultado em locais diferentes e exigia abrir o Finder ou localizar manualmente cada arquivo.
- **Causa raiz:** os comandos do bridge usavam apenas `generate_output_path` ao lado do projeto e a UI oferecia ações independentes, sem uma pasta de trabalho comum.
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` e `server.py`.
- **Solução adotada:** nova pasta de saída configurável no topo, cinco checkboxes de processamento e botão único; as etapas são encadeadas e todos os XML/SRT recebem `output_dir` explícito.
- **Aprendizado:** operações relacionadas devem compartilhar uma pasta de saída e uma entrada encadeada, evitando artefatos espalhados pelo sistema.
- **Estado:** `resolvido`
---
### 2026-08-14 — Mapeamento de legenda por intervalo, sem duração inventada
- **Sintoma:** a tentativa de impor duração mínima gerou legendas deslocadas, duplicadas e piores; havia também cues de duração zero que o FCP rejeitava.
- **Causa raiz:** o mapeamento usava apenas o início do segmento e depois estendia artificialmente o fim, ignorando segmentos que atravessavam cortes.
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
- **Tentativas que falharam:** descartar todo cue menor que 0.5s e preencher cada cue até 1s.
- **Solução adotada:** intersectar o intervalo completo da fala com cada janela de clipe mantida, mapear apenas a interseção, mesclar somente partes contíguas do mesmo segmento e omitir apenas spans que viram zero milissegundos.
- **Aprendizado:** sincronização deve transformar intervalos fonte→timeline; nunca inventar duração para corrigir legibilidade.
- **Estado:** `resolvido`
---
### 2026-08-14 — Exportar legenda a partir da versão cortada, não do projeto original
- **Sintoma:** a legenda gerada cobria o projeto original (326s) em vez do vídeo cortado (255s), desalinhada com os frames finais.
- **Causa raiz:** o botão "Exportar Legendas (SRT)" usava `projectPath` (projeto aberto na tela) em vez do resultado da remoção de silêncio (`processedPath`).
- **Onde:** `MacApp/Sources/TranscriptionView.swift` (`exportSubtitles`).
- **Tentativas que falharam:** gerar sempre do `projectPath`.
- **Solução adotada:** `exportSubtitles` passa a usar `processedPath` (a cópia `_silence_removed`) quando existe, caindo para o `projectPath` caso contrário — a legenda sempre acompanha o corte mais recente.
- **Aprendizado:** artefatos derivados do corte (SRT, marcadores) devem ser gerados da mesma versão editada que o usuário está usando, não do arquivo-fonte original.
- **Estado:** `resolvido`
---
### 2026-08-14 — Legenda SRT ultrapassava a duração do projeto (aviso do FCP)
- **Sintoma:** ao importar o SRT, o Final Cut avisava "as legendas se estendem além da duração do projeto" e sugeria conectá-las a um clipe vazio no final.
- **Causa raiz:** o último bloco de legenda mapeado terminava após o fim da timeline (ex.: SRT até 257.4s num projeto de 255.6s), porque o timestamp final arredondava (`round`) para cima e nenhum teto impedia o overrun.
- **Onde:** `admin/models_api.py` (`cmd_export_srt`, `srt_stamp`).
- **Tentativas que falharam:** mapear o fim ao fim do clipe apenas; o último cue ainda podia exceder a sequência real.
- **Solução adotada:** calcular `timeline_total = _timeline_duration().to_seconds()` e clampar `tl_start`/`tl_end` de cada cue a esse teto; usar `floor` (em vez de `round`) no `srt_stamp` para nunca subir acima de um limite de frame.
- **Aprendizado:** SRT que termina após o último frame do projeto é rejeitado pelo FCP; sempre clampar o último cue ao total da timeline e truncar (não arredondar) timestamps.
- **Estado:** `resolvido`
---
### 2026-08-14 — Remoção de gap vazio final como padrão na remoção de silêncio
- **Sintoma:** a timeline do resultado de remoção de silêncio podia terminar com um gap "Espaço" vazio após o último clipe (introduzido no round-trip com o Final Cut), deixando um "objeto preto" no final.
- **Causa raiz:** nenhum passo garantia a remoção de gaps ao final da spine; o FCP re-adicionava o espaço ao importar.
- **Onde:** `fcpxml/writer.py` (novo `FCPXMLModifier.remove_trailing_gaps`) e `server.py` (`handle_remove_media_silence`).
- **Tentativas que falharam:** depender do usuário apagar o gap manualmente no FCP.
- **Solução adotada:** `remove_trailing_gaps()` remove apenas o `<gap>` final da spine (gaps no meio são preservados) e re-sincroniza a duração da sequência; chamado antes de salvar na remoção de silêncio. `cmd_remove_silences` (app) já herda via delegação ao handler.
- **Aprendizado:** operações que encurtam a timeline devem remover gaps finais para o arquivo exportado terminar onde o conteúdo termina.
- **Estado:** `resolvido`
---
### 2026-08-14 — Micro-clips de 1 frame e gap "Espaço" no final são criados pelo FCP, não pelo corte
- **Sintoma:** o resultado da remoção de silêncio mostrava, após ~4:11, dezenas de micro-clips de 1 frame (0.042s) e um objeto preto/gap "Espaço" de 755s no final.
- **Causa raiz:** o algoritmo gera um arquivo LIMPO (82 clipes, source `start` monotônico, sem micro-clips). O arquivo exportado pelo Final Cut tinha 162 clipes, 80 regressões de `start` e o gap "Espaço" — o FCP re-quebrou os clipes e inseriu o gap ao abrir/salvar/exportar, não o programa.
- **Onde:** comparação entre `_out_test.fcpxml` (saída do `handle_remove_media_silence`) e `Legendas fora.fcpxmld` (exportado do FCP).
- **Tentativas que falharam:** suspeitar do `cut_clip_ranges`/`_filter_children_for_segment`; o arquivo gerado pelo programa não tem esses micro-clips.
- **Solução adotada:** confirmado que o bug não está no código de corte; é artefato da re-exportação pelo FCP. Ajustar a detecção de silêncio (`min_duration`/padding) não resolve porque o arquivo gerado já está correto.
- **Aprendizado:** antes de assumir bug no gerador, reproduzir a saída crua e comparar com o artefato final — a re-importação no NLE pode reintroduzir clipes/gaps.
- **Estado:** `resolvido` (diagnóstico)
---
### 2026-08-14 — Legenda SRT fora de sincronia após corte de silêncio
- **Sintoma:** ao exportar legenda depois de remover silêncios, o SRT cobria o vídeo inteiro em vez de apenas os trechos que ficaram — legendas apareciam em partes já cortadas.
- **Causa raiz:** `cmd_export_srt` gerava o SRT direto do transcript da mídia original (`segments_to_srt`), com timestamps da fonte bruta, ignorando os cortes da timeline editada.
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
- **Tentativas que falharam:** exportar os segmentos como vinham do Whisper.
- **Solução adotada:** mapear cada segmento da fonte para a posição real na timeline com `clip_offset + (seg_start - clip_source_start)` (mesma lógica do `transcript_markers`), por clipe da spine editada, descartando falas fora da janela usada e ordenando por tempo.
- **Aprendizado:** qualquer artefato derivado da transcrição (SRT, cortes) deve ser mapeado fonte→timeline, nunca usar o transcript bruto; reutilizar o mapa já existente em `handle_transcript_markers`.
- **Estado:** `resolvido`
---
### 2026-08-14 — Terceiro ponto do bug de `int(fps)`: `TimeValue.from_timecode()` corrompia qualquer segundo decimal em taxa NTSC
- **Sintoma:** ao implementar a nova tool `transcript_markers` (marca no timeline
cada frase transcrita), `add_marker_at_timeline("317.9857s", ...)` lançava
`ValueError: No spine clip at position 331.478s` — uma posição **fora** da
timeline, mesmo com o timestamp de entrada correto e dentro dos limites.
- **Causa raiz:** `TimeValue.from_timecode()` (`fcpxml/models.py`), no ramo que
parseia segundos decimais simples (`"12.5s"`, sem `/`), calculava
`frames = round(seconds * fps)` com o `fps` real (float), mas construía o
`TimeValue` como `TimeValue(frames, int(fps))` — numerador calculado com o
fps certo, denominador truncado. A 23.976fps isso infla o valor em ~1.04x
(317.99s virou 331.48s). Terceiro local com essa mesma classe de bug (os
outros dois: `to_frame_timevalue`/`_cut_transcript_spans` em `server.py`,
já corrigidos na entrada anterior) — `from_timecode` é usado por
`_parse_time()`, chamado por quase todo o `writer.py`, então qualquer
handler que passe um timecode decimal (não fração) nessa taxa era afetado.
- **Onde:** `fcpxml/models.py` (`TimeValue.from_timecode`).
- **Tentativas que falharam:** nenhuma — bug novo, achado testando o handler
novo contra a transcrição real em cache antes de expor na UI.
- **Solução adotada:** reconstruir a fração exata do fps via
`Fraction(fps).limit_denominator(100_000)` (recupera `24000/1001` a partir
do float com precisão total) e usar `frames * fps_frac.denominator` /
`fps_frac.numerator` como numerador/denominador — mantém os dois em
unidades consistentes. Teste de regressão em
`tests/test_models.py::test_from_seconds_string_ntsc_rate_exact`.
- **Validado:** reproduzido o erro exato reportado, corrigido, e o handler
novo (`transcript_markers`) rodou de ponta a ponta contra a transcrição
real (54 marcadores, sem erro) depois da correção.
- **Aprendizado:** qualquer função que aceite `fps: float` e construa um
`TimeValue` diretamente (em vez de delegar pra uma fração exata) é suspeita
de ter esse bug em taxas NTSC. Ao corrigir uma instância, procurar outras
chamadas de `int(fps)` / `round(2400/fps)` no arquivo inteiro — não parar
na primeira encontrada.
- **Estado:** `resolvido`
---
### 2026-08-14 — Legendas visíveis exigem SRT/título, não marcadores
- **Sintoma:** pedido de "legenda na timeline que apareça no vídeo" — os marcadores de navegação não mostram texto sobre o vídeo.
- **Causa raiz:** marcadores (`transcript_markers`) são apenas navegação; legenda visível no FCP exige SRT importado como idioma de legenda ou um `<title>` conectado (fragilmente dependente da versão do FCP).
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` (`cmd_export_srt`), reuso de `segments_to_srt`.
- **Tentativas que falharam:** usar marcadores para legenda; gerar `<title>` de texto no FCPXML é frágil entre versões.
- **Solução adotada:** novo comando `export_srt` que gera um `.srt` por mídia transcrita (via transcript cacheado + `segments_to_srt`), exposto como botão "Exportar Legendas (SRT)". O FCP importa o SRT como legenda nativa e desenha sobre o vídeo.
- **Aprendizado:** "legenda" no FCP = SRT/caption, não marcador; sempre distinguir navegação (marker) de texto sobreposto (caption).
- **Estado:** `resolvido`
---
### 2026-08-14 — Remoção de silêncio/transcrição gerava XML fora da grade de frame em projetos NTSC (23.976/29.97fps), confirmado por importação real no FCP
- **Sintoma:** ao importar no Final Cut Pro o XML gerado por `remove_media_silence`,
dezenas de avisos "O item não está em um limite de quadro de edição" em quase
todo `asset-clip` da spine (projeto real de 82 clipes, `Depimento Erika`).
- **Causa raiz:** `handle_remove_media_silence` e `_cut_transcript_spans`
(`server.py`) calculavam os limites de corte com
`TimeValue(round(seconds*fps) * round(2400/fps), 2400)` — uma base fixa de
2400 ticks/segundo. Para 24/25/30/48/50/60fps isso é exato, mas para
23.976fps (`frameDuration="1001/24000s"`) `round(2400/23.976)` arredonda para
100, tratando cada frame como `1/24s` exato em vez do `1001/24000s` real —
uma correção de `Engine/docs/05_EXPERIENCIAS.md` (entrada anterior) existia
como `FCPXMLModifier.snap_spine_times_to_frames()` mas só era chamada por
`handle_add_marker`; nenhum handler de corte/ripple a usava.
- **Onde:** `server.py` (`handle_remove_media_silence`, `_cut_transcript_spans`)
e `fcpxml/writer.py` (`FCPXMLModifier.save()`).
- **Tentativas que falharam:** nenhuma — a correção certa (`Fraction` exato)
já existia no código, só não estava conectada aos caminhos que realmente
cortam a spine.
- **Solução adotada:** (1) `save()` agora chama `snap_spine_times_to_frames()`
incondicionalmente antes de serializar — todo handler que escreve passa por
ali, então a proteção é universal e não depende de cada handler lembrar de
chamar. (2) Os dois pontos de corte por segundos (`to_frame_timevalue` /
`to_frame`) agora usam o novo `FCPXMLModifier.snap_seconds_to_frame()`, que
arredonda para o frame mais próximo usando a fração exata de `frameDuration`
em vez da base fixa de 2400. Teste de regressão em
`tests/test_media_intel.py::test_ntsc_rate_output_stays_frame_aligned`
reproduz `duration="41100/2400s"` com a lógica antiga (não-inteiro em frames)
e confirma alinhamento exato com a nova.
- **Validado:** reimportação real no Final Cut Pro pelo usuário, sem os avisos.
- **Aprendizado:** qualquer cálculo de tempo que assuma uma base fixa de ticks
(2400, 600, etc.) quebra silenciosamente em taxas NTSC fracionárias
(23.976/29.97/59.94fps) — usar sempre `Fraction` a partir do `frameDuration`
real da sequência, nunca `fps` arredondado. E quando existir uma correção
"canônica" pronta no código, verificar que TODOS os caminhos relevantes a
chamam, não só um.
- **Estado:** `resolvido`
---
### 2026-08-14 — Fluxo de transcrição dependia de clique redundante e ocultava falhas
- **Sintoma:** após selecionar um projeto, era necessário clicar novamente para abrir a transcrição; erros do processo Python podiam não aparecer na interface.
- **Causa raiz:** a tela filha era condicionada a um botão intermediário, e o bridge não preservava fragmentos incompletos do JSONL nem convertia saída diferente de zero em erro.
- **Onde:** `MacApp/Sources/ProjectView.swift` e `MacApp/Sources/PythonBridge.swift`.
- **Tentativas que falharam:** depender apenas do callback de linhas completas e deixar a conclusão ignorar o código de saída.
- **Solução adotada:** abrir a tela automaticamente após `inspect`, processar a última linha parcial e propagar falhas do subprocesso.
- **Aprendizado:** bridges JSONL precisam tratar chunks arbitrários de stdout e sempre validar o status de saída.
- **Estado:** `resolvido`
---
### 2026-08-14 — Limites de quadro no XML exportado após remoção de silêncio
- **Sintoma:** o Final Cut Pro rejeitava `Info.fcpxml` com avisos de que
`offset` e `duration` não estavam em limites de quadro.
- **Causa raiz:** a edição ripple produzia frações de tempo válidas
matematicamente, mas desalinhadas do `frameDuration` exato da sequência.
- **Onde:** `fcpxml/writer.py` e `server.py` no fluxo de remoção de silêncio.
- **Tentativas que falharam:** usar FPS convertido para float e assumir uma
base inteira, o que não funciona para 23.976/29.97.
- **Solução adotada:** normalizar `offset` e `duration` da spine usando a
fração exata de `frameDuration` antes de salvar a cópia modificada.
- **Aprendizado:** limites de edição do FCPXML devem ser calculados com
`Fraction`, nunca com FPS arredondado ou floats.
- **Estado:** `resolvido`
---
<!-- NOVAS ENTRADAS DEVEM SER ADICIONADAS ACIMA DESTA LINHA, SEMPRE NO TOPO
DA LISTA, PARA QUE A MAIS RECENTE FIQUE EM PRIMEIRO LUGAR. -->
### 2026-08-14 — Início do registro de experiências
- **Sintoma:** não havia um local centralizado para registrar erros/estruturas
problemáticas; cada correção era tratada isoladamente.
- **Causa raiz:** ausência de um artefato de memória de projeto; o contexto de
bugs já resolvidos se perdia entre sessões.
- **Onde:** `Engine/docs/05_EXPERIENCIAS.md` (este arquivo, recém-criado).
- **Tentativas que falharam:** n/a (primeira entrada).
- **Solução adotada:** criação deste arquivo com template padronizado, integrado
ao fluxo de validação pós-correção (`Engine/run_after_fix.sh`).
- **Aprendizado:** registrar problemas continuamente reduz o retrabalho; uma
entrada clara evita reabrir bugs já entendidos.
- **Estado:** `resolvido`
---
## Resumo rápido (índice)
| # | Data | Problema | Estado |
|---|------|----------|--------|
| 1 | 2026-08-14 | Início do registro de experiências | `resolvido` |
| 4 | 2026-08-14 | XML fora da grade de frame em NTSC (23.976/29.97fps), confirmado no FCP | `resolvido` |
| 5 | 2026-08-14 | `TimeValue.from_timecode` corrompia segundos decimais em NTSC (3º ponto do bug) | `resolvido` |
| 6 | 2026-08-17 | Clipe-fantasma de 1 frame no início/fim após remoção de silêncio (padding sem vizinho na borda) | `resolvido` |
| 7 | 2026-08-17 | Legendas dinâmicas sobrepondo entre clipes (título conectado não é aparado pelo out-point do pai) | `resolvido` |
| 8 | 2026-08-17 | Importação recusada: `id` de `<text-style-def>` derivado do texto (acentos/espaços/dígito inicial) não é XML Name válido | `resolvido` |
| 9 | 2026-08-17 | Legendas palavra a palavra centradas em vez da composição progressiva diagramada (bloco por trecho, palavra-chave em display italic) | `resolvido` |
| 10 | 2026-08-17 | Cedilha/acentos da display italic invadindo a linha vizinha: empilhamento passou a usar a tinta real por classe de glifo | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
+77
View File
@@ -0,0 +1,77 @@
# 06 — Boas Práticas de Programação (G-ART)
> **Propósito:** registrar as melhores práticas de programação a serem aplicadas
> **sempre** que qualquer alteração ou correção for feita neste programa.
> Servem de checklist obrigatório antes de concluir qualquer mudança.
**Regra:** antes de considerar uma alteração concluída, confira os itens abaixo.
Eles são o mesmo espírito do fluxo de validação pós-correção
(`Engine/run_after_fix.sh`), mas cobrem também **qualidade de código** e
**convenções do projeto**.
---
## 1. Correções de código
1. **Sempre valide após corrigir** — rode `./Engine/run_after_fix.sh` (lint +
testes). Nunca declare uma correção pronta sem que lint e a suíte passem.
2. **Toda correção já corrigida vira registro** — registre o problema em
`Engine/docs/05_EXPERIENCIAS.md` para que não se repita.
3. **Mude o mínimo necessário** — altere apenas o que resolve o problema; evite
refatorar código não relacionado na mesma mudança.
## 2. Tempo e FCPXML
4. **Nunca use `float` para tempo** — toda duração/offset é `TimeValue`
(fração racional `"600/2400s"`). Float introduz erro de arredondamento.
5. **`offset` é a posição na timeline; `start` é o in-point na origem** — não
confundir nas edições de clip.
6. **Markers são filhos dos clips, não irmãos** — e `<spine>` é a storyline
primária; connected clips penduram-se com atributo `lane`.
7. **Preserve sidecars em bundles `.fcpxmld`** — ao gravar um bundle, copie os
arquivos de dados; caso contrário destrói object-tracking/Cinematic.
## 3. Estrutura e arquitetura
8. **Mantenha o núcleo desacoplado** — `fcpxml/` não conhece o protocolo MCP;
`server.py` é a camada de transporte. Não vazem lógica MCP para o núcleo.
9. **Use o padrão de dispatch** — sem cadeias gigantes de `if/elif`; use
`TOOL_HANDLERS` (dicionário nome → handler assíncrono).
10. **Reaproveite os helpers centrais** — `_parse_project()`, `_resolve_io_paths()`,
`_setup_modifier()`, etc. Não duplique parse/validação de caminho.
11. **Nunca sobrescreva o original** — use `generate_output_path()` e crie
`_modified`, `_chapters`, etc.
12. **Mantenha o `MarkerType` como single source of truth** — a serialização
(parse/escrita) vive no enum, não espalhada por handlers.
## 4. Segurança
13. **Sempre use `safe_xml.py` (defusedxml)** — todos os entry points de parse;
jamais `xml.etree` cru com input não confiável.
14. **Valide caminhos com `_validate_filepath` / `_validate_output_path`** — o
sandbox de I/O existe para impedir escrita fora do permitido.
15. **Rejeite payloads excessivamente aninhados** — `_check_json_depth` protege
contra payloads além de 50 níveis.
16. **Nunca registre/commite segredos ou chaves** — nem em logs, nem em código.
## 5. Qualidade e clareza
17. **Sem comentários desnecessários** — código deve ser autoexplicativo;
comente o *porquê*, não o *o quê*.
18. **Mimice as convenções do projeto** — mesma estrutura de imports, nomes,
padrões e bibliotecas já usadas nas vizinhas.
19. **Lazy import de dependências opcionais** — `media_intel` (librosa) e
`transcribe` (Whisper) importam sob demanda e degradam com graça (`None`).
20. **Convenções de teste** — use `examples/sample.fcpxml` + fixtures XML inline;
`sample.fcpxml` NÃO é DTD-conformante, não o use como fixture de validade DTD.
## 6. Checklist final antes de concluir uma alteração
- [ ] `./Engine/run_after_fix.sh` passou (lint zero erros + todos os testes).
- [ ] Problema registrado em `Engine/docs/05_EXPERIENCIAS.md` (se aplicável).
- [ ] Nenhum `float` usado em matemática de tempo.
- [ ] Nenhum caminho original sobrescrito.
- [ ] `safe_xml.py` usado em todo parse de input não confiável.
- [ ] Nenhum segredo registrado ou commitado.
- [ ] Mudança mínima, sem refatoração não relacionada.
- [ ] Boa prática nova aprendida adicionada a esta lista.
@@ -0,0 +1,121 @@
# 07 — Estudo (SUSPENSO): Detectar o Projeto Ativo no Final Cut Pro
> **Status: SUSPENSO** (2026-08-14). Estudo retomável. Ver seção
> ["Onde parámos e próximos passos"](#onde-paramos-e-proximos-passos) ao fundo.
## Objetivo
Descobrir se é possível detectar, de forma programática, **qual projeto/timeline
está aberto e em edição no Final Cut Pro** — não apenas enumerar os projetos —
para que as ferramentas do MCP possam operar sobre o projeto ativo.
Contexto atual do repositório: `fcpxml/live.py` já implementa `list_fcp_libraries`
(enumerar bibliotecas → eventos → projetos) e `push_to_fcp` (import via Open
Document). O que **falta** é saber *qual* projeto o usuário está vendo/editando.
## Fatos verificados na máquina (2026-08-14)
- **FCP está em execução**, mas o executável **não está em `/Applications`**:
localizado em `/Volumes/Merongo/Applications/Final Cut Pro Creator Studio.app`.
Aplicativo aparece como "Final Cut Pro Creator Studio" mas o bundle id é
`com.apple.FinalCut` (processo `Final Cut Pro`).
- Ambiente: `osascript` funciona (permissão de automação concedida para o host).
## Superfície oficial: AppleScript (`ProEditor.sdef`)
O dicionário oficial fica em:
`/Volumes/Merongo/Applications/Final Cut Pro Creator Studio.app/Contents/Resources/ProEditor.sdef`
Observações estruturais do `.sdef`:
- **Top-level = somente `libraries`** → `events` → `projects`/`sequences`.
- **NÃO existe** propriedade `active project`, `front project` ou `active sequence`
no dicionário. A única leitura possível é `get` (100% read-only).
- O `sdef` inclui a suite padrão (`CocoaStandard.sdef`), o que traz `document`.
### Testes de terminal (resultados reais)
| Comando (via `osascript`) | Resultado |
|---|---|
| `get name of front document` | `Biblioteca Padrão` — retorna a **biblioteca** em primeiro plano (class `document` que corresponde a um `library`; o id casa com um `library`). |
| `get class of front document` | `document` |
| `get name of front window` | `Final Cut Pro` (apenas o nome da janela do app, **não** o projeto) |
| `front project` | erro (`Não é possível obter project 1`) |
| `active document` | erro de sintaxe (`active` não é palavra-chave) |
| `events of front document` | erro `-1728` (eventos ficam em `library`, não em `document`) |
| `get name of every project` | erro `-1728` (projetos não são top-level) |
### Conclusão da superfície oficial
- ✅ Dá para detectar a **biblioteca em primeiro plano** via `front document`
(equivalente a `libraries` de maior prioridade na lista).
- ❌ **NÃO dá** para detectar o **projeto ativo** (a timeline em edição) apenas
pelo AppleScript oficial.
## Superfície não-oficial: CommandPost
CommandPost instalado: **v1.4.13** em `/Applications/CommandPost.app` (versão 1,
gratuita; **NÃO** é a v2 que requer LateNite). LateNite **não está** instalado.
- Na v1.4.13 **não estava rodando** e a porta WebSocket `27480` **estava fechada**
(essa porta é do servidor WebSocket da v2). Logo, a integração via WS na porta
27480 **não se aplica** a esta instalação.
- O app é baseado em **Hammerspoon** e tem um comando AppleScript próprio:
**`execute lua code`** (declarado em
`/Applications/CommandPost.app/Contents/Resources/CommandPost.sdef`).
Isso permite rodar Lua dentro do ambiente do CommandPost (que tem acesso à
árvore Accessibility/AX do FCP e à API `cp.*`).
- O CommandPost usa **Accessibility (AX) scripting** para ler o FCP. A API interna
(`cp.apple.finalcutpro`) é extensa e profunda; os caminhos para "documento ativo"
não são uma propriedade direta e limpa — são derivados da árvore AX (ex.: título
da janela da timeline).
## Superfície não-oficial: AX direto (System Events)
Testado: ler títulos das janelas do processo "Final Cut Pro" via `System Events`:
```
tell application "System Events" to tell process "Final Cut Pro" to get title of every window
```
- Resultado: **erro `-25211` — "osascript é um acesso assistivo não permitido"**
(o host não tem permissão de Accessibility/Assistive Access).
- Isso é uma permissão de sistema (System Settings → Privacy & Security →
Accessibility) que precisa ser concedida ao processo host.
- A princípio o título da janela da timeline do FCP **contém o nome do projeto**,
o que tornaria essa a via mais simples — desde que a permissão AX exista.
## Mapa de decisão (resumo)
| Via | Detecta projeto ativo? | Permissão extra | Robustez |
|---|---|---|---|
| AppleScript oficial (`front document`) | Só a biblioteca, não o projeto | Automação (já ok) | Alta (sanctioned) |
| CommandPost `execute lua code` | Sim (via AX/`cp.*`) | CommandPost rodando + AX do CommandPost | Média (frágil a updates do FCP) |
| AX direto (`System Events` título da janela) | Sim (título da timeline tem o projeto) | Accessibility no host | Média-alta |
| CommandPost v2 WS porta 27480 | Sim | CommandPost v2 + LateNite (~US$10) | — (não aplicável aqui, é v1) |
## Onde paramos e próximos passos
**Estado:** investigação inicial feita; **nenhum código foi escrito/alterado**.
Nenhum arquivo do repositório foi modificado; nada foi commitado.
Próximos passos sugeridos ao retomar:
1. **Decidir a via** — provavelmente a mais promissora e de menor custo é a
**AX direta via `System Events`** (ler o título da janela da timeline), pois
dispensa CommandPost. Requer apenas conceder Accessibility ao host.
2. **Conceder permissão** de Accessibility ao terminal/host em System Settings →
Privacy & Security → Accessibility, e re-testar:
```
tell application "System Events" to tell process "Final Cut Pro" to get title of every window
```
Confirmar que o título da timeline contém o nome do projeto ativo.
3. Se AX direto for inviável, **testar CommandPost `execute lua code`**:
iniciar o CommandPost, e via osascript executar Lua que consulta a árvore AX
do FCP (ex.: a janela `PrimaryWindow` / a `timeline`) para extrair o título.
A API interna relevante vive em `extensions/cp/apple/finalcutpro/`.
4. **Projetar o tool MCP** (ex.: `get_fcp_active_project` / `detect_fcp_project`),
seguindo o padrão de `handle_*` em `server.py` e a documentação de boas
práticas (`Engine/docs/06_BOAS_PRATICAS.md`).
5. **Registrar** qualquer correção/erro recorrente em
`Engine/docs/05_EXPERIENCIAS.md` e rodar `./Engine/run_after_fix.sh`.