Files
gart/code/Engine/docs/05_EXPERIENCIAS.md
T
João HenriqueandClaude Opus 5 4f5cf94443 refactor: writer.py vira pacote, um módulo por assunto
O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.

Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.

Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.

    core      723   carga, índices, navegação na spine, save
    titles    600   títulos e legendas dinâmicas
    cut       333   dividir, cortar faixas, apagar
    speed     297   velocidade e zoom
    (+ 20 módulos menores)

Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).

Lint zerado, 1441 testes passando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:38:49 -04:00

1285 lines
134 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-19 — Linha de ênfase das legendas dinâmicas sem limite de largura: auto-fit implementado
- **Contexto:** validando `generate_dynamic_subtitles` sobre o corte real do Mastopexia (ver entrada anterior sobre `validate_subtitle_layout`), sobraram 15 títulos `outside_frame` mesmo depois de eliminados os falsos positivos de colisão.
- **Causa raiz:** `compose_sentence()` (`fcpxml/text_layout.py`) faz wrap das linhas de corpo contra `box.width`, mas a linha de ênfase — sempre uma palavra só, a "key word" em itálico grande — nunca era checada contra largura nenhuma, porque uma palavra sozinha não tem como quebrar em duas linhas. Uma palavra longa (`estruturado,`, `sustentação`, `proporcional`) ou toda maiúscula media mais que o frame inteiro sozinha: `estruturado,` a 460pt (ponto emitido, após `TEXT_TEMPLATE_FONT_SCALE`) mediu **2538px de largura contra 2160px de frame**, estourando os dois lados centrada.
- **Onde:** `fcpxml/text_layout.py::compose_sentence`.
- **Solução adotada:** `fit_emphasis()` mede a linha de ênfase e, se ultrapassar `box.width`, encolhe `font_size` e `kerning` pelo mesmo fator — a largura é linear nesses dois parâmetros juntos, então o fator exato é `box.width / width_medida`, sem iteração. Nunca encolhe abaixo do tamanho do corpo (`body_look.font_size`): ênfase do mesmo tamanho que o texto normal deixa de ser ênfase. Como a extensão vertical de tinta (`ink_extent`) também é linear em `font_size`, encolher a largura encolhe a altura usada no empilhamento junto — restaurando de brinde a garantia "sem sobreposição por construção" que o resto da função já tinha, sem precisar de lógica extra para isso.
- **Efeito medido:** revalidando o mesmo corte, `outside_frame` caiu de 15 para 0, severidade de `severe` para `warning` (restam avisos de área segura, não erros de frame).
- **Achado colateral:** a única colisão que sobrou depois da correção (`MASTOPEXIA` × `é a cirurgia que`) não era bug nenhum — era um título manual (`Auto-Shrink`, template "Text" nativo do FCP, sem relação com `_make_text_title_clip`) presente no arquivo base reutilizado para o teste, provavelmente de uma edição manual no FCP, não gerado por nenhuma chamada da sessão. Revalidando sobre uma base limpa, sem esse título estranho: **0 colisões**. Vale sempre revalidar sobre uma base conhecida antes de atribuir um achado ao código.
- **Estado:** `resolvido` — corrigido em `text_layout.py`, suíte completa e lint passando, revalidado sobre o corte real.
---
### 2026-08-19 — `validate_subtitle_layout` acusava colisão em 7 de 8 casos por ruído de ponto flutuante, não por sobreposição real
- **Contexto:** primeiro uso real de `validate_subtitle_layout` (ferramenta nova) sobre o corte do Mastopexia com legendas dinâmicas geradas. Relatou severidade `severe`: 8 colisões, 15 títulos fora do frame, 10 fora da área segura.
- **Sintoma:** ao rastrear cada colisão reportada pelas frações exatas do FCPXML, 7 dos 8 pares eram títulos **consecutivos que terminam exatamente quando o próximo começa** — o design pretendido ("cada bloco some quando o próximo aparece", `writer.py`'s `block_ends[i] = block_starts[i+1]`) funcionando corretamente. O oitavo (`MASTOPEXIA` × `é a cirurgia que`) era uma sobreposição real de ~0,46s.
- **Causa raiz:** `temporal_overlap()` em `fcpxml/collision.py` compara `end = start.to_seconds() + duration.to_seconds()` (soma de dois floats já arredondados) contra `start.to_seconds()` de outro título (uma única divisão) — mesmo quando a fração exata subjacente é bit-idêntica nos dois casos, a soma de dois floats arredondados não bate com uma única divisão da soma exata dos numeradores (não-associatividade de ponto flutuante). Medido: diferença de ~4,5×10⁻¹³s — treze ordens de grandeza menor que um frame (~0,04s) — suficiente para inverter `start_b < end_a` de `False` para `True` e disparar uma colisão `severe` fantasma. Contradizia o próprio comentário do código ("a title ending exactly as the next begins is never flagged").
- **Onde:** `fcpxml/collision.py::temporal_overlap`.
- **Solução adotada:** tolerância `_BOUNDARY_EPSILON = 1e-6` subtraída de ambos os lados da comparação — muitas ordens de grandeza abaixo de qualquer fronteira de frame real, então não mascara nenhuma sobreposição genuína, só absorve o ruído de arredondamento entre dois caminhos de cálculo do mesmo instante.
- **Aprendizado:** comparar dois floats derivados do MESMO valor exato por caminhos aritméticos diferentes (soma vs. divisão direta) nunca deve usar igualdade/desigualdade estrita — vale para qualquer checagem "toca a borda mas não deveria contar", não só tempo de título. O sintoma (severidade `severe` sem nenhuma sobreposição visível no material) é o sinal de alerta: sempre rastrear a colisão até as frações exatas do XML antes de aceitar o relatório da ferramenta de validação como verdade.
- **Estado:** `resolvido` — corrigido em `collision.py`, teste de regressão com os números reais do caso (`test_boundary_survives_float_noise_from_the_writer`), suíte completa (1369 testes) e lint passando, revalidado sobre o corte real: 8 colisões → 1.
---
### 2026-08-19 — `add_zoom` perdia o enquadramento real quando dois zooms caíam no mesmo clipe pós-corte
- **Sintoma:** no mesmo teste real (Mastopexia), o clipe de abertura do corte apareceu "achatado" no FCP — Scale 100% em vez do enquadramento real (~177%) que o projeto original já tinha, enquanto os clipes seguintes apareciam corretos. Rotação e posição estavam certas; só a escala quebrava, e só no primeiro trecho.
- **Causa raiz:** `add_zoom()` (`fcpxml/writer.py`) lê o enquadramento-base de um clipe só de um jeito: o atributo estático `scale="X Y"` em `<adjust-transform>`. Isso funciona na primeira chamada. Mas quando dois zooms editoriais caem dentro do **mesmo** clipe sobrevivente (dois picos de ênfase que o corte não separou em clipes distintos), a segunda chamada de `add_zoom` encontra não mais um atributo estático, e sim um `<param name="scale">` já **animado** pela primeira — e o código só sabia ler atributo. `stale.get('scale')` voltava `None`, o base virava `1.0` por padrão, e a linha seguinte (`clip.remove(stale)`) **apagava a animação da primeira chamada inteira**, substituindo por uma segunda com base errada.
- **Onde:** `fcpxml/writer.py::add_zoom` (base scale + merge de keyframes); reproduzido isolando `cut_clip_ranges` + duas chamadas de `add_zoom` no mesmo elemento.
- **Por que passou despercebido:** o teste existente (`test_only_one_transform_remains`) já chamava `add_zoom` duas vezes no mesmo clipe, mas só checava que sobrava **um** `<adjust-transform>` na árvore — nunca verificou se a base da segunda chamada estava certa. A suíte cobria a estrutura, não o valor.
- **Solução adotada (duas partes):**
1. Quando não há atributo `scale` estático, `add_zoom` agora lê o `<param name="scale">` existente e recupera a base como o **menor** valor entre as keyframes — válido porque `MIN_ZOOM_SCALE == 1.0` garante que todo pico é `>= base`, então o menor valor keyframeado é sempre o resting scale, seja ele o de abertura, o de fecho ou qualquer um no meio.
2. Duas janelas de zoom no mesmo clipe agora só se **substituem** quando as janelas de tempo se sobrepõem (é o mesmo evento sendo reajustado); quando são **disjuntas** (dois picos editoriais distintos que um corte não separou), as keyframes são **empilhadas** no mesmo `keyframeAnimation` em vez de uma apagar a outra — FCPXML aceita quantas keyframes forem necessárias num único `<param>`.
- **Aprendizado:** "preservar o enquadramento existente" precisa valer em **toda** leitura subsequente do mesmo clipe, não só na primeira. Um mecanismo que lê corretamente da fonte original mas degrada ao reler sua própria saída anterior é o mesmo bug de fundo da entrada #13 (renormalização) por outro ângulo: qualquer estado que o sistema regrava precisa continuar sendo uma fonte de verdade legível, não só um efeito colateral write-only. Vale desconfiar de qualquer `findall()`/leitura de atributo que tenha um "senão assume 1.0/padrão" — é aí que a segunda chamada perde o que a primeira escreveu.
- **Estado:** `resolvido` — corrigido em `writer.py`, dois testes de regressão adicionados (`test_second_zoom_on_same_clip_keeps_the_real_base_scale`, `test_overlapping_zoom_on_same_clip_replaces_instead_of_stacking`), suíte completa (1344 testes) e lint passando, corte real do Mastopexia regravado e conferido.
---
### 2026-08-19 — Teste real fechou o ciclo, e revelou um offset sistemático de ~0,4s no timing por palavra
- **Contexto:** primeiro teste ponta a ponta de `editar-por-voz` num projeto real (Mastopexia, 196,6s de gravação de roteiro com 6 tomadas). Fluxo completo: `build_voice_timeline` → triagem manual (tomada/bastidor/frase abandonada) → `refine_voice_timeline` sobre os sobreviventes → escolha de zoom por função narrativa → `apply_voice_actions`. Corte final: 196,6s → ~50s, 3 clipes.
- **Sintoma:** antes de rodar `remove_media_silence`, medi manualmente o RMS do áudio real nas emendas propostas pelo corte e achei folgas de ~0,4-0,6s onde o JSON dizia que a fala começava/terminava. Comparando timestamp da transcrição contra o ataque real medido em 6 pontos do vídeo (ffmpeg `astats`), o erro era **sistemático, sempre no início da palavra**, entre +0,35s e +0,51s — os finais de palavra batiam certo (+0,01 a +0,15s).
- **Causa raiz:** `transcribe.py` usa `word_timestamps=True` do faster-whisper, que deriva os tempos por atenção cruzada — aproximado por natureza, sem alinhamento forçado. O submódulo WHISPERX existe no repositório mas **não é usado** em nenhum ponto do código; não há etapa de alinhamento fonético.
- **Por que isso importa mais do que parece:** o erro contamina toda decisão temporal a jusante — zoom disparava ~0,4s antes da palavra-alvo, `gap_before` subestimava pausas reais na mesma medida (o que afeta diretamente a régua de silêncio recém-adotada), e as folgas de corte saíam erradas nas emendas.
- **Decisão tomada:** não rodei `remove_media_silence` bruto sobre o corte. A detecção (ffmpeg, limiar -30dB/0,5s) não distingue "batida entre frases dentro da régua de 1,5s" de "ar morto de emenda" — cortar ambos teria apertado frases fluidas. Corrigi os tempos manualmente medindo o ataque real nos pontos críticos (cabeça, 2 emendas, cauda, 3 zooms) e refiz o corte numa passada só.
- **Solução adotada (paliativa, aplicada manualmente neste teste):** medir o RMS real com `ffmpeg -af astats=metadata=1:reset=1:length=0.05,ametadata=print` em janelas curtas ao redor de cada ponto crítico antes de fixar um corte ou zoom que dependa de precisão de frame. Não é o padrão do sistema — é o que cobre a lacuna até o alinhamento forçado existir.
- **Solução estrutural ainda pendente:** ligar o WhisperX (ou alinhamento forçado equivalente) em `transcribe.py`, o que levaria o erro de ~400ms para ~30ms e corrigiria zoom, corte e `gap_before` de uma vez, sem paliativo por projeto. Não implementado ainda — é mudança de pipeline, exige regerar todos os `_transcript.json`/`_voice_timeline.json` existentes.
- **Aprendizado:** "não reestime tempos no olho" (critério 01) continua certo para decisão *editorial* — mas não cobre erro sistemático de *medição* na fonte dos tempos. Um offset constante e na mesma direção, em vários pontos do material, é sinal de bug no pipeline de transcrição, não de julgamento errado sobre o material. Vale conferir com uma amostra de áudio real antes de confiar cegamente em timestamp de word-level de qualquer fonte nova.
- **Estado:** `parcialmente resolvido` — paliativo documentado e aplicado neste teste; correção estrutural (WhisperX) pendente de implementação.
---
### 2026-08-19 — Capacidade existente sem porta de entrada: a Fase 4 da skill era letra morta
- **Sintoma:** a skill `editar-por-voz` manda, como fase obrigatória, reanalisar o material sobrevivente antes de escolher zooms — e o modelo não tinha como cumprir isso. `restrict_to_kept()` e `suggest_zoom_windows()` existiam, estavam testadas e documentadas, mas **nenhuma ferramenta MCP as expunha**. Na prática, todo zoom continuava sendo escolhido com o ranking bruto, exatamente o erro que a entrada anterior descreve.
- **Causa raiz:** a implementação parou na camada Engine. O critério foi escrito descrevendo chamadas Python, que só os testes conseguiam fazer — a distância entre "existe no `fcpxml/`" e "o modelo consegue chamar" passou despercebida porque a suíte cobria a função, não o caminho.
- **Onde:** `server.py` (nova tool `refine_voice_timeline` + handler + dispatch), `tests/test_refine_voice_timeline_tool.py`, `.claude/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md`.
- **Solução adotada:** ferramenta `refine_voice_timeline(media_path, cuts, min_gap, max_zooms, save)`, que numa chamada devolve a comparação bruto × sobreviventes, os picos re-ranqueados e os candidatos a zoom. Handler fino: nada de lógica nova, só o caminho até o que já existia. O critério 04 passou a citar a ferramenta em vez das funções Python.
- **Aprendizado:** função coberta por teste unitário **não** é capacidade entregue. Toda vez que um critério de skill mandar "rode X", verifique que X é chamável pelo modelo — senão o critério vira instrução impossível, e o modelo segue em frente sem erro visível. Vale um teste de registro (`a tool está em list_tools` + `está no TOOL_HANDLERS`) para cada ferramenta nova.
- **Estado:** `resolvido`
---
### 2026-08-19 — Ênfase é relativa: analisar o bruto e editar o corte final são perguntas diferentes
- **Problema:** os zooms estavam sendo escolhidos a partir da análise do material **bruto**. Mas energia é normalizada contra o momento mais alto da gravação — que era `"Amor, eu tô intacto!"` (energia 1,00), justamente uma das falas **cortadas**. Todo o material que sobrou estava pontuado contra uma referência que o espectador nunca veria, comprimindo artificialmente as notas do corte final.
- **Solução:** `restrict_to_kept()` filtra a timeline pelos ranges removidos e **re-normaliza sobre os sobreviventes**. Efeito medido: ênfase média subiu de 0,179 (bruto) para 0,197 (só o que ficou) — o material restante passou a usar a escala inteira. E o ranking mudou de figura: `"Aquela"` 0,39→0,42, `"mastopexia"` 0,26→0,34, `"devolver"` entrando com 0,35.
- **Pré-requisito que virou bug na hora:** re-normalizar exige os valores **brutos**, e o JSON só guardava os normalizados. Adicionados `energy_raw` e `pitch_hz` em cada palavra. A primeira execução saiu com todas as notas caindo — sintoma de estar lendo campo ausente num JSON gerado antes da mudança. Regerar o JSON resolveu; vale lembrar que mudança de esquema exige regerar os caches antes de interpretar qualquer resultado.
- **Armadilha de API evitada:** `enrich_words` chamava `word_pitch_energy`, que **sobrescreve** `energy`/`pitch_hz` com `None` quando não há frame tracks — então re-analisar um subconjunto zerava tudo. Em vez de remendar restaurando os valores depois (que foi a primeira tentativa, e ficou ilegível), entrou o parâmetro `already_measured`.
- **`suggest_zoom_windows()`:** propõe uma janela por frase, a partir da palavra de **conteúdo** mais enfática (artigos e conectivos filtrados por `_FUNCTION_WORDS` — um "a" falado alto continua sendo um artigo), indo até o fim da frase; `min_gap` mantém os zooms afastados.
- **Aprendizado:** "qual o momento mais forte da gravação?" e "qual o momento mais forte do vídeo final?" são perguntas distintas sempre que a métrica for relativa. Toda métrica normalizada precisa ser recalculada quando o conjunto muda — caso contrário ela responde a pergunta errada, silenciosamente.
- **Estado:** `resolvido` (1325 testes verdes; 3 zooms escolhidos pela re-análise aplicados em clipes distintos, DTD 1.14 válido).
---
### 2026-08-19 — Forma do punch-in é assimétrica: entrada de 0,5s, saída de 1 frame
- **Regra editorial (do usuário):** na palavra de ênfase, zoom in **rápido** (~meio segundo); segura durante a frase de impacto; e no fim **volta de um quadro para o outro, sem transição nenhuma** — o vídeo simplesmente retoma o enquadramento e segue o fluxo.
- **O que havia:** `add_zoom` tinha um único `ease` (padrão 0,3s) aplicado **simetricamente** na entrada e na saída, produzindo um retorno lento que chama atenção para si.
- **Solução:** `ease` passou a valer só para a entrada (padrão **0,5s**) e a saída virou **um frame**, calculado do `frameDuration` real da sequência (`ease_out` opcional para quem quiser retorno gradual). Medido no material: entrada 0,501s, hold 3,378s, saída 0,042s = 1 frame a 23,976fps.
- **Detalhe que quase passou:** o handler em `server.py` forçava `ease=float(action.params.get("ease", 0.3))`, então o padrão novo do writer nunca chegava a valer — o zoom saía com 0,33s de entrada. Um default duplicado em duas camadas é sempre o errado das duas; o handler passou a repassar `ease` **só quando explicitamente informado**, deixando o writer ser o dono do padrão.
- **Aprendizado:** ao mudar um default, procurar quem já o repassa. Um `params.get("x", <default>)` numa camada acima anula silenciosamente o default da camada que de fato conhece o assunto.
- **Estado:** `resolvido` (1311 testes verdes, `TestZoomShapeIsAsymmetric` fixa a forma; DTD 1.14 válido) — pendente de conferência visual no FCP.
---
### 2026-08-19 — Export de calibração do FCP fecha o zoom: keyframe só com `time` e `value`
- **Como veio:** depois de o zoom continuar não aparecendo, o usuário fez o zoom **à mão no FCP** sobre o mesmo material e exportou o FCPXML (`Mastopexia - exemplo de zoom.fcpxmld`) — o padrão de calibração já registrado em 2026-08-15 e 2026-08-18, agora aplicado a keyframes.
- **O que o export real mostrou:**
```xml
<adjust-transform position="0.160319 0.663249" rotation="90.1008">
<param name="scale">
<keyframeAnimation>
<keyframe time="2329601280/720000s" value="1.77311 1.77311"/>
```
1. `position`/`rotation` mantidos como atributos e o atributo `scale` **removido** quando a escala é animada — confirmou a correção de preservação de enquadramento;
2. o primeiro keyframe cai exatamente no `start` do clipe (3235,557s) — **confirmou** a correção de timebase de origem;
3. **`<keyframe>` carrega apenas `time` e `value`** — sem `interp` e **sem `curve`**.
- **Correção final:** removido o `curve="smooth"` que eu havia adicionado ao trocar o `interp`. O DTD permite `curve` (default `smooth`), mas como o importador já havia rejeitado `interp` neste mesmo param vetorial, não há razão para apostar que `curve` sobrevive — o export real do FCP não escreve nenhum dos dois, então passamos a escrever nenhum dos dois. Estrutura agora idêntica à do FCP.
- **Aprendizado:** ao corrigir um atributo rejeitado pelo importador, **não basta trocar por outro plausível do DTD** — foi o que fiz (`interp` → `curve`) e ficou uma segunda aposta não verificada em cima da primeira. A resposta certa era pedir um export de calibração e copiar. Vale a regra: diante de qualquer incerteza sobre o que o FCP aceita, o caminho mais curto é um export real, não uma segunda leitura do DTD.
- **Estado:** `resolvido` (1306 testes verdes, estrutura conferida atributo a atributo contra o export do usuário, DTD 1.14 válido) — pendente de confirmação de importação.
---
### 2026-08-19 — Zoom importava como nada: keyframe em tempo relativo, não no timebase de origem
- **Sintoma:** usuário importou o FCPXML no FCP e relatou "não tem zoom, não tem corte, não tem nada".
- **Causa 1 (real) — o zoom:** os `<keyframe>` do `adjust-transform` eram escritos em segundos **relativos ao clipe** (0,08s a 4,80s), mas o clipe tem `start="74637363/24000s"` = **3109,9s** (timecode de origem). O FCP procura a animação no timebase do próprio clipe, não encontra keyframe nenhum na janela dele, e importa o zoom como **nada** — sem erro, sem aviso. Corrigido somando o `start` do clipe (`media_origin + tempo relativo`), exatamente o que `add_text_title` já fazia e **documentava**: *"Anchored in SOURCE media coordinates… so the title lands on screen instead of at ~0s of the media (which FCP silently drops)"*. O `add_zoom` nunca recebeu o mesmo tratamento.
- **Por que escapou:** todo fixture sintético e o projeto da Erika têm clipe com `start="0s"`, onde relativo e absoluto coincidem. Pior: o teste `test_add_zoom_creates_keyframed_transform` usava uma fixture com `start="10s"` — tinha tudo para pegar o bug — mas afirmava `times == [1.0, 1.5, 2.5, 3.0]`, ou seja, **fixava o comportamento errado**. Reescrito para ancorar em `origin + relativo`, com `assert origin > 0` garantindo que a fixture continue exercitando o caso.
- **Causa 2 (percepção) — o corte:** os cortes **estavam** no arquivo (3 clipes, 47,6s contra 196,7s do bruto). Mas o XML mantinha `<event name="17-08-2026">` e `<project name="Mastopexia">` idênticos ao original, então a importação criava um projeto homônimo no mesmo evento e o usuário abriu o antigo. Passou-se a renomear o projeto para `<nome> — corte por voz`.
- **Aprendizado 1:** "não fez nada" pode ser duas coisas muito diferentes — não gerou, ou gerou e o usuário não achou. Vale sempre inspecionar o arquivo antes de concluir, e nunca deixar a saída indistinguível da entrada dentro do app de destino.
- **Aprendizado 2 (repetição do padrão de 2026-08-19/`interp`):** quando um módulo já resolve um problema de coordenadas e **documenta** a solução no docstring, procurar os irmãos que fazem operação equivalente. `add_text_title` sabia ancorar em coordenadas de origem; `add_zoom` e qualquer outro futuro escritor de keyframes precisam da mesma regra.
- **Estado:** `resolvido` (1306 testes verdes, keyframes conferidos dentro da janela do clipe: 3297,74–3302,46s para um clipe de 3297,7–3302,6s; DTD 1.14 válido) — pendente de nova importação no FCP.
---
### 2026-08-19 — Primeira edição real ponta a ponta: três bugs de ordem/identidade que a suíte não pegava
Triagem editorial completa de um vídeo institucional (196,7s → 47,6s, 76% de redução),
feita a partir do `_voice_timeline.json`. A geração do FCPXML expôs três bugs, todos
invisíveis em fixture sintética porque dependem de um projeto **já editado**.
**1. `add_zoom` destruía o enquadramento do editor.** O clipe original trazia
`<adjust-transform position="0.160319 0.663249" rotation="90.1008" scale="1.77311 1.77311"/>`
— material gravado de lado e reenquadrado à mão. `add_zoom` removia qualquer
`adjust-transform` existente ("Replace rather than stack a prior zoom") e criava o seu do
zero, então o trecho com zoom voltava **girado 90°**. Corrigido: os atributos estáticos
(position/rotation/anchor) são preservados e a escala passa a ser animada **relativa** à
base (1,77311 → 1,77311 × 1,18 → 1,77311). Comentário "replace rather than stack" estava
certo na intenção e errado no alcance — nem todo `adjust-transform` é um zoom anterior.
**2. Posicionar antes de cortar espalhava o zoom e apagava marcadores.**
`cut_clip_ranges` divide o clipe e reescreve a spine; o `adjust-transform` era **copiado
para os 3 pedaços** e os `<marker>` sumiam. Invertida a ordem: **cortes primeiro**,
posicionamentos depois — `resolve_actions` já converte os tempos para a timeline
pós-corte, então continuam apontando para o mesmo instante.
**3. Depois de cortar, todos os pedaços têm o MESMO nome.** `add_zoom`/`add_marker`
resolviam o clipe por nome (`_require_clip`), então toda edição caía no **primeiro**
pedaço. `_require_clip` passou a aceitar um `Element` direto (como `add_text_title` já
fazia), e o handler passa o elemento exato — mapeando pelo **offset na timeline**, não
mais pela janela de origem.
**Bônus — marcador é ponto, não trecho.** `resolve_actions` exigia que início *e* fim
sobrevivessem ao corte, descartando justamente os marcadores úteis: os que sinalizam uma
emenda e por definição encostam na borda do corte. Marcadores passaram a resolver só pelo
início.
- **Aprendizado:** os três bugs são a mesma família — **ordem de operações e identidade de
elemento** depois de uma operação que reestrutura a árvore. Fixture sintética tem um
clipe limpo, sem transformações prévias e sem nomes duplicados, então nada disso
aparece. Testar contra um projeto **real já editado** é categoricamente diferente de
testar contra XML gerado por nós.
- **Regressões adicionadas:** `TestZoomPreservesExistingFraming` (5),
`TestPlacementsLandOnTheRightPieceAfterCuts` (3), `TestMarkersSurviveCutEdges` (4).
- **Estado:** `resolvido` (1301 testes verdes, DTD 1.14 válido, enquadramento conferido no
XML) — pendente de importação real no FCP pelo usuário.
---
### 2026-08-19 — Diarização quebrada pelo torchcodec + descoberta: separar tomada de conversa NÃO é diarização
**Parte 1 — a falha técnica.** Com token e termos válidos, `diarize()` retornava `None` e o log dizia só "diarization failed" (o `except Exception:` amplo engolia a causa). Rodando o pyannote direto, a causa apareceu: `pyannote.audio` 4.x decodifica áudio via **torchcodec**, que linka contra uma versão específica do FFmpeg — `dlopen(libtorchcodec_core4.dylib): Library not loaded: @rpath/libavutil.56.dylib`. O FFmpeg instalado é outro major, e a diarização caía inteira numa máquina em que tudo o mais funcionava.
- **Solução:** `_load_waveform()` em `diarize.py` decodifica o áudio por conta própria (reusando `decodable_audio()` do `voice_features.py`, que já extrai WAV mono 16 kHz via ffmpeg) e passa a `{"waveform": tensor, "sample_rate": sr}` que o pyannote aceita — pulando o torchcodec por completo. Bônus: containers de vídeo passam a funcionar direto. Resultado: 33 turnos, 2 participantes, ~1min40s para 3min17s de áudio.
- **Aprendizado:** `except Exception` sem registrar a exceção transforma falha diagnosticável em mistério. O log deveria carregar a causa; sem isso, foi preciso reexecutar a biblioteca à mão para ver o erro real.
**Parte 2 — a descoberta de produto, mais importante.** Com a diarização funcionando, o `remove_speakers` encontrou só **uma** fala do SPEAKER_01 — e ainda por cima uma atribuição errada. Cruzando os turnos com a transcrição, a explicação apareceu: o pyannote detecta a segunda voz em 44,1–46,0s e 51,4–54,8s, mas a transcrição **não tem nada** nesses intervalos (vãos de 43,8→47,5 e 48,8→55,4). A pessoa da equipe está **fora do microfone**: a diarização a ouve, o Whisper não a transcreve.
- **Consequência:** as falas que o usuário quer descartar (*"Amor, eu tô intacto!"*, *"Só clica aí agora na tela."*, *"Posso começar da mastopexia?"*) são **da própria protagonista** — mesma voz, contexto diferente. Cortar por participante não resolve esse caso.
- **Aprendizado:** "quem fala" e "isso é tomada válida?" são perguntas **diferentes**, e é tentador confundi-las porque ambas soam como "separar as partes do vídeo". Diarização resolve a primeira; só a linguagem resolve a segunda. `remove_speakers` continua válido para o caso em que o entrevistador está microfonado (ex. depoimento da Erika), mas não é a ferramenta para triar tomada de conversa.
- **Estado:** `resolvido` (diarização funcional, 1294 testes verdes); triagem tomada/conversa fica na camada de linguagem, critérios em `.claude/skills/editar-por-voz/SKILL.md`.
---
### 2026-08-19 — Pausa longa: ruído para ênfase, sinal para estrutura (o mesmo dado, dois usos opostos)
- **Sintoma:** no material real, palavras de energia baixíssima lideravam o ranking de ênfase. No Mastopexia, 4 dos 7 picos eram assim: `"mastopexia"` (energia 0,25, pausa 6,2s), `"Aquela"` (0,32, 8,7s), `"Aumenta."` (0,25, 5,7s). O mesmo padrão aparecia no depoimento da Erika.
- **Causa raiz:** `compute_emphasis` normalizava a pausa contra `max_pause=1,5s` **saturando** — ou seja, uma pausa de 8,7s e uma de 1,5s recebiam nota idêntica (1,0). Mas gap de 6–9s não é ênfase dramática: é troca de tomada, inserção de B-roll ou a outra pessoa falando. O índice estava premiando corte de cena como se fosse entrega enfática.
- **Solução adotada:** `pause_weight()` — a contribuição sobe até `max_pause` e **cai a zero** acima de `pause_ignore_above` (3s), em vez de saturar. Resultado imediato no mesmo vídeo: o topo passou a ser `"eu"` (energia 1,00), `"o"` (0,87), `"intacto!"` (0,70), `"tô"` (0,74) — todas da mesma frase, que é de fato a fala de impacto.
- **A virada:** o mesmo dado que era ruído virou o sinal mais útil do documento. Os gaps descartados marcam **onde a tomada recomeçou**. Adicionados `gap_before` e `take_boundary` (>= 3s) em cada segmento; num material de 3min17s isso detectou 6 fronteiras, exatamente onde a médica recomeçava o roteiro.
- **Descoberta de produto:** o bruto de consultório não é uma tomada — é o mesmo roteiro gravado 3–4 vezes, entremeado de conversa com a equipe (*"Amor, eu tô intacto!"*, *"Posso começar da mastopexia?"*, *"Só clica aí agora na tela."*). Separar tomada válida de conversa é **tarefa de linguagem, não de acústica**: no áudio a conversa é mais solta e mais alta que o texto decorado, então qualquer limiar acústico erra o alvo por construção. Critérios registrados em `.claude/skills/editar-por-voz/SKILL.md`.
- **Aprendizado:** antes de descartar um sinal por estar poluindo uma métrica, perguntar **para que outra pergunta ele é a resposta**. Aqui a mesma pausa respondia mal "isso foi enfático?" e otimamente "a tomada recomeçou aqui?".
- **Bug pego pelo próprio teste:** ao adicionar `gap_before`, o teste `test_scales_document_every_word_metric` quebrou — `scales` documentava tudo como métrica de palavra, e `gap_before` é de segmento. `VALUE_SCALES` passou a ser aninhado (`word`/`segment`), com um teste por nível. Um contrato auto-descritivo só vale se um teste garantir que ele não mente.
- **Estado:** `resolvido` (1294 testes verdes, validado nos dois vídeos reais).
---
### 2026-08-19 — FCP descartava TODO zoom gerado: `interp` em param vetorial (DTD-válido ≠ FCP-aceito)
- **Sintoma:** ao importar o FCPXML no Final Cut, o aviso `This param element was ignored because it does not support the interpolation attribute on its keyframes (.../adjust-transform[1]/param[1])`. O `<param name="scale">` inteiro era **descartado** — ou seja, o zoom simplesmente não existia no projeto importado, sem erro nem falha visível.
- **Causa raiz:** `add_zoom` escrevia `interp="ease"` em cada `<keyframe>` do parâmetro `scale`. O importador do FCP só aceita `interp` em parâmetros **escalares** (opacidade, volume); `scale` é vetorial (`value="1.25 1.25"`) e admite apenas `curve`.
- **Por que passou por tudo:** o DTD oficial da Apple declara `<!ATTLIST keyframe interp (linear|ease|easeIn|easeOut) "linear">` — ou seja, `interp` é **DTD-válido em qualquer keyframe**. A validação contra o DTD passava com 100% de sucesso, e o teste `test_add_zoom_creates_keyframed_transform` **afirmava** `interp == 'ease'`, travando o comportamento errado. Só a importação real no FCP revelou.
- **Solução adotada:** `curve="smooth"` no lugar de `interp` (o `curve` já é `smooth` por padrão no DTD, mas explícito documenta a intenção e protege contra mudança de default). Teste invertido: agora exige `curve == 'smooth'` **e** ausência de `interp`.
- **Atenção — não confundir com `timept`:** o `<timept>` do `timeMap` (usado em `change_speed`) aceita `interp` normalmente e **não** foi alterado. A restrição é do `<keyframe>` em param vetorial.
- **Aprendizado (o mais importante desta série):** **DTD-válido ≠ aceito pelo Final Cut.** O DTD descreve a gramática, não as regras semânticas do importador. Para qualquer construção nova de XML, validar contra o DTD é o piso, não o teto — só a importação real fecha a verificação. E um teste escrito a partir do próprio código gerado (em vez de um export real do FCP) apenas congela o erro: reforça o padrão já registrado em 2026-08-15 e 2026-08-18 — **extrair a verdade de um export real do FCP, nunca do que nós mesmos geramos.**
- **Estado:** `resolvido` no XML (1286 testes verdes, DTD 1.14 válido) — **pendente de nova confirmação de importação no FCP pelo usuário.**
---
### 2026-08-19 — Primeiro teste em material real: quatro bugs que só apareceram fora dos testes
Rodar o pipeline completo num depoimento real (17 min, 4K, 2320 palavras) expôs quatro
problemas que a suíte inteira, verde, não pegava. Todos vinham de premissas
que só material sintético sustentava.
**1. Teto de 100 MB rejeitava a mídia (14,7 GB).** `MAX_FILE_SIZE` existe para
documentos que lemos **inteiros na memória** (FCPXML, JSON) — onde um arquivo
gigante é o próprio ataque. Mídia nunca é carregada assim: ffmpeg e librosa
leem em fluxo, com timeout e limite de duração próprios. Criado
`MAX_MEDIA_FILE_SIZE` (32 GB) e `_validate_filepath(..., max_size=)`. Afetava
também o `detect_beats`, que já rejeitava qualquer WAV acima de ~10 minutos.
**2. librosa não lia `.mp4` — faltava a extração de áudio.** O PDF previa
"FFmpeg para extração do áudio" e eu pulei essa etapa, analisando o container
direto. Criado `decodable_audio()` em `voice_features.py`: passa adiante
arquivos de áudio nativos e extrai um WAV mono 16 kHz temporário via ffmpeg
para containers de vídeo (16 kHz basta — o teto de pitch é 1 kHz).
**3. O relatório MENTIA sobre o que rodou.** A tabela dizia "Acoustics: yes"
enquanto as duas extrações falhavam, porque reportava `features_capability()`
— se a *biblioteca está instalada* — e não se a *análise funcionou*. Agora o
JSON carrega um bloco `layers` com o que de fato executou. Fundamental porque
"fala monótona" e "acústica não carregou" deixam **os mesmos zeros** nos dados:
sem esse bloco, nem o usuário nem a IA que lê o arquivo conseguem distinguir.
**4. Limiar absoluto de ênfase não generaliza — trocado por percentil.** O
0,85 do PDF eu já havia recalibrado para 0,60 usando dados sintéticos; no
material real o índice **nunca passou de 0,544** (mediana 0,127), então 0,60
ainda selecionava nada. Corrigido de vez trocando o mecanismo: `select_peaks`
pega o **top N%** (padrão 2%), com um piso mínimo apenas como guarda para
áudio genuinamente plano. Qualquer corte fixo ou inunda um material ou zera
o outro; percentil entrega um punhado útil nos dois casos.
- **Aprendizado central:** limiar calibrado em dado sintético é chute. Duas
recalibrações erradas seguidas (0,85 → 0,60, ambas inúteis) só pararam
quando a régua virou **relativa à distribuição do próprio material**.
Sempre que um número governar seleção, prefira percentil a valor absoluto.
- **Observação de qualidade ainda aberta:** no top de ênfase real aparecem
palavras com energia baixíssima (`"No"`, energia 0,07) pontuando alto só
por virem depois de pausa longa. Em entrevista, pausa longa costuma ser o
entrevistador falando — não ênfase. O peso `pause_before` (0,15) com
saturação em 1,5s recompensa o sinal errado; avaliar reduzir o peso ou
ignorar pausas acima de ~3s.
- **Estado:** `resolvido` (1286 testes verdes; saída **validada contra o DTD
oficial FCPXML 1.14 da Apple**) — pendente de importação real no FCP.
---
### 2026-08-19 — Validador acusava desalinhamento de frame em todo projeto NTSC (falso positivo)
- **Sintoma:** o FCPXML gerado a partir de um projeto real 23,976fps acusava `Duration ... is not frame-aligned at 24fps` em clipes que estavam perfeitamente alinhados. Conferido na mão: `1200199/12000s ÷ 1001/24000s = 2398` frames exatos — inteiro, sem resto. O aviso do *arquivo original*, intocado, também era falso.
- **Causa raiz:** `_check_frame_alignment` fazia `fps_int = int(fps)` e multiplicava os segundos por esse inteiro. A 23,976 (`1001/24000s`), uma duração exatamente alinhada **não** é múltiplo inteiro de "24fps" — então todo projeto NTSC (23,976 / 29,97 / 59,94, ou seja, a maioria) era reportado como quebrado. Detalhe irônico: o docstring de `serialize_xml` já alertava para passar a taxa real "so NTSC projects don't get spurious warnings", mas o `int()` logo adiante destruía a correção.
- **Solução adotada:** o validador passou a ler o `frameDuration` exato do formato que a `<sequence>` referencia (`_document_frame_duration`) e a comparar com aritmética de `Fraction` — alinhado é quando `duração / frameDuration` tem denominador 1. A mensagem também passou a nomear a taxa real (`23.976fps`), não uma arredondada.
- **Aprendizado:** nunca converter timebase para inteiro/float para checar alinhamento — o projeto inteiro é construído sobre tempo racional justamente por isso (`TimeValue`), e a validação precisa seguir a mesma regra que a escrita. Falso positivo em validador é pior que ausência de validação: ensina o usuário a ignorar avisos, e aí o aviso verdadeiro passa batido.
- **Estado:** `resolvido` (3 testes de regressão em `TestNTSCFrameAlignment`, incluindo um que garante que desalinhamento **real** continua sendo detectado).
---
### 2026-08-18 — Divisão IA × sistema: a IA devolve DECISÕES, nunca XML; e tudo em tempo de origem
- **Contexto:** definido como a inteligência entra no editor automático. A ideia inicial era mandar o JSON para um serviço externo que devolveria o material já editado; evoluiu para fazer a decisão aqui dentro, com um skill versionado no repo (`.claude/skills/editar-por-voz/SKILL.md`).
- **Decisão 1 — o que a IA NÃO faz:** silêncio, vícios de linguagem, extração acústica, índice de ênfase, diarização e **geração de FCPXML** continuam determinísticos. Tudo que tem resposta objetiva (um limiar decide) não ganha nada indo para um modelo — só custo, latência e perda de reprodutibilidade. Geração de XML em particular é matemática de tempo racional frame a frame: modelo gerando XML produz arquivo sutilmente quebrado.
- **Decisão 2 — a IA devolve uma lista de ações, não mídia editada.** Contrato em `fcpxml/voice_actions.py` (`kind`/`start`/`end`/`params`/`reason`). Motivos: dá para **validar** antes de aplicar; é **reprodutível** (mesma lista → mesmo FCPXML); e o usuário **revisa** antes de qualquer coisa tocar a timeline. `parse_actions` trata a lista como entrada não confiável — uma linha malformada é reportada e pulada, nunca derruba a edição inteira.
- **Decisão 3 (a que evita a pior classe de bug) — todos os tempos em segundos da mídia ORIGINAL.** Cortes deslocam tudo que vem depois: se as decisões viessem em tempo pós-corte, cada destaque cairia silenciosamente no frame errado assim que um corte fosse adicionado. `resolve_actions`/`shift_after_cuts` resolvem o deslocamento na hora de aplicar, e ação que aponta para material removido é **descartada e reportada**, nunca deslizada para o conteúdo vizinho.
- **Bug pego pelo próprio relatório:** título e marcador não apareciam no XML. A causa era `str(TimeValue)` devolvendo o `__repr__` (`TimeValue(3/1s = 3.000s)`) em vez da string racional — o método certo é `to_fcpxml()`. Só foi visível na hora porque o handler reporta o que **não** conseguiu colocar, com a exceção real, em vez de aplicar em silêncio.
- **Aprendizado:** todo handler que aplica uma lista de operações deve relatar as três categorias — aplicadas, descartadas e rejeitadas. Um handler que só conta sucessos transforma bug em "não aconteceu nada" e some do radar. Nunca converter `TimeValue` para string com `str()`: sempre `to_fcpxml()`.
- **Estado:** `resolvido` (1276 testes verdes; aplicação validada contra FCPXML real, incluindo o `examples/sample.fcpxml`) — **pendente de confirmação de importação real no FCP pelo usuário.**
---
### 2026-08-18 — Limiar de ênfase de 0,85 do PDF era inalcançável: média ponderada não chega lá
- **Sintoma:** com a timeline de voz montada, o campo `peak_count` vinha **sempre 0**. Nem a palavra mais alta e mais aguda de um trecho de demonstração ("segurança", energia normalizada 1,0 e pico de tom) era marcada como candidata a punch-in.
- **Investigação:** medido o teto real da fórmula. `compute_emphasis` é uma **média ponderada** de cinco fatores normalizados (energia 0,30 / tom 0,25 / ritmo 0,20 / pausa 0,15 / duração 0,10). Para o resultado passar de 0,85 seria preciso que quase todos os cinco estivessem no máximo **simultaneamente** — o que a fala real não produz: uma palavra com pausa dramática antes dela quase por definição não tem desvio de ritmo alto. Valores medidos: todos os fatores no máximo = 1,00; pico realista (energia e tom máximos, pausa longa, palavra longa, ritmo normal) = **0,80**; pico comum = **0,66**.
- **Causa raiz:** o 0,85 veio literalmente da especificação do PDF (`SE emphasis > 0.85 ENTÃO aplicar punch-in`), que pressupunha outra normalização — provavelmente um índice de máximo, não de média. Copiar a constante sem conferir a distribuição da nossa fórmula tornou o recurso inerte.
- **Solução adotada:** padrão recalibrado para **0,60** (`DEFAULT_VOICE_ANALYSIS_CONFIG` em `fcpxml/model_manager.py`), com o porquê comentado no próprio código. O texto da tela e a descrição da tool passaram a dizer que picos reais ficam na faixa 0,55–0,80 — para que ninguém volte a subir o valor achando que "quanto maior, mais seletivo" sem saber onde fica o teto.
- **Aprendizado:** constante numérica herdada de especificação externa precisa ser **validada contra a distribuição real da fórmula implementada** antes de virar padrão. O sintoma aqui foi silencioso (nenhum erro, nenhum teste vermelho — só um recurso que nunca disparava), e só apareceu porque a saída de demonstração foi inspecionada com dados realistas. Vale gerar uma amostra de verdade e olhar os números sempre que um limiar governar um comportamento.
- **Estado:** `resolvido` (padrão 0,60 verificado: o mesmo trecho passou a marcar corretamente 1 pico).
---
### 2026-08-18 — Configurações de análise de voz: uma fonte de verdade só (config.json do backend), não UserDefaults
- **Contexto:** Fases 2–3 da arquitetura de análise de voz (features acústicas + índice de ênfase) e a tela de configurações pedida pelo usuário para regular limiares de energia, ênfase e emoção.
- **Decisão:** os parâmetros de análise ficam **só** em `~/.fcp-mcp-server/config.json` (via `model_manager.load/save_voice_analysis_config`), diferente do padrão `@AppStorage`/UserDefaults usado por `CaptionsView.swift` para estilo de legenda. Motivo: estilo de legenda é preferência de UI que só é lida na hora de montar os argumentos de uma chamada; já os limiares de análise são lidos **pelo próprio motor** (`handle_analyze_voice_features`) mesmo quando a análise é disparada fora do app (tool MCP direta, script). Duplicar em UserDefaults criaria duas verdades divergentes — a tela mostraria um valor e a análise usaria outro.
- **Onde:** `fcpxml/model_manager.py` (`DEFAULT_VOICE_ANALYSIS_CONFIG`, `load/save_voice_analysis_config`), comandos `voice_analysis`/`set_voice_analysis` em `admin/models_api.py`, tools MCP `get/save_voice_analysis_config`, tela `MacApp/Sources/VoiceAnalysisView.swift`.
- **Cuidado que rendeu teste:** `load_voice_analysis_config` precisa devolver uma **cópia** dos defaults — a primeira versão devolvia o dict aninhado `emphasis_weights` por referência, e quem mutasse o resultado corrompia o default do módulo para o resto do processo. Coberto por `test_defaults_are_not_shared_mutable_state`.
- **Aprendizado:** ao adicionar configuração nova, perguntar "quem lê esse valor?" — se for o motor Python, ele mora no config.json do backend; se for só a montagem de argumentos na UI, UserDefaults serve. E todo default composto (dict/lista) devolvido de um `load_*` precisa ser cópia, nunca a constante do módulo.
- **Estado:** `resolvido` (1201 testes verdes, lint zero erros, ciclo salvar→reler validado pelo bridge) — **a renderização visual da tela no app não pôde ser confirmada por captura de tela** (janela do app em outro Space); a aba foi confirmada via árvore de acessibilidade.
---
### 2026-08-18 — Diarização de locutor já existia pronta e testada, mas órfã (nenhuma tool MCP a expunha)
- **Contexto:** início da implementação da "Arquitetura de Análise de Voz para Editor Automático" (locutor, energia, pitch, ênfase, motor de regras → FCPXML), especificada num PDF trazido pelo usuário. Plano salvo em `~/.claude/plans/volumes-merongo-downloads-arquitetura-a-mossy-micali.md`.
- **Descoberta:** `fcpxml/diarize.py` (diarização via `pyannote/speaker-diarization-3.1`, com `diarization_capability`, `diarize`, `assign_speakers`, `build_speakers`) e `tests/test_diarize.py` já existiam completos e passando, mas nenhuma tool em `server.py` chamava esse módulo — código morto do ponto de vista de uso real. `model_manager.py` também já tinha `load_hf_token`/`save_hf_token` prontos para o token do HuggingFace exigido pelo pyannote.
- **Decisão de arquitetura:** usar pyannote (já é dependência declarada em `pyproject.toml` como extra `diarization`) para diarização bruta por turno, em vez de treinar/rodar SpeechBrain ECAPA-TDNN do zero como o PDF sugeria em primeiro lugar. ECAPA-TDNN fica reservado para uma fase futura (reconhecimento de pessoa cadastrada por cima dos turnos já diarizados), evitando duas libs pesadas resolvendo o mesmo problema.
- **Onde:** nova tool `diarize_media` em `server.py` (handler `handle_diarize_media`), reaproveitando `diarize.py` sem alterá-lo; cache em `_diarization.json` ao lado da mídia, seguindo exatamente o padrão de `_transcript.json`/`_beats.json` já usados por `transcribe_media`/`detect_beats`.
- **Aprendizado:** antes de implementar uma fase "do zero" a partir de uma spec externa, vale sempre grepar o `fcpxml/` por nomes prováveis (`diarize`, `speaker`, etc.) — pode já existir motor pronto e testado, só faltando a camada de exposição via MCP tool.
- **Estado:** `resolvido` (tool nova + testes, 1154 testes verdes, lint zero erros).
---
### 2026-08-18 — Terceira linha "colando" na linha de ênfase: o gap simétrico não bastava para o itálico
- **Sintoma:** no bloco de composição "phrase" (uma palavra de ênfase em itálico grande, cercada por linhas de corpo), a linha logo abaixo da ênfase aparecia quase tocando o texto — mesmo com o slider "Espaçamento entre linhas" da tela de legendas dinâmicas configurado.
- **Investigação:** reproduzido o cálculo de `compose_sentence` fora do FCP com a frase exata do usuário ("de" / "encontrar" / "roupa,") — o gap entre as caixas de tinta dava **exatamente 8pt nos dois lados** (acima e abaixo da ênfase), confirmando que o valor do slider chega corretamente até o layout (`CaptionsView.swift` → `admin/models_api.py` → `server.py` → `compose_sentence`). Não era bug de configuração não aplicada.
- **Causa raiz:** a caixa de tinta medida (`ink_extent`, `fcpxml/text_layout.py`) é vertical e simétrica, mas a inclinação itálica do Playfair Display faz os traços "vazarem" visualmente para baixo além do que a métrica vertical mede — então o mesmo gap numérico lê como mais apertado abaixo da linha de ênfase do que acima dela.
- **Onde:** `fcpxml/text_layout.py::compose_sentence` (função `pair_gap` nova) e constante `_EMPHASIS_ITALIC_CUSHION_RATIO`.
- **Solução adotada:** gap por par de linhas em vez de um valor único para todo o bloco — quando a linha anterior é a de ênfase, soma-se uma folga extra proporcional ao seu `font_size` (`ratio = 0.06`, ~14pt a 230pt) só naquele par; todos os outros pares continuam usando exatamente o `line_gap` do usuário. A folga entra tanto no teste de "cabe na banda" quanto na centralização da pilha, senão o bloco vazaria do box.height ou ficaria descentrado.
- **Aprendizado:** medir a caixa de tinta (ascendente/descendente reais) resolve colisão entre glifos retos, mas não captura o "peso visual" da inclinação itálica — para faces itálicas grandes ao lado de corpo reto, a folga simétrica por ink-box ainda pode ler como assimétrica no render final. Um cushion proporcional ao tamanho da fonte, aplicado só no lado que precisa, corrige sem inflar o espaçamento nos pares que já estavam certos.
- **Estado:** `resolvido` no cálculo (1150 testes verdes, valores conferidos numericamente) — **pendente de confirmação visual real no FCP pelo usuário**.
---
### 2026-08-18 — Build Out desligado libera toda a janela de "Per Object" para o Build In terminar de revelar
- **Sintoma:** em blocos de palavras curtos, a animação de entrada do título ("Text"/Basic Text template) às vezes cortava antes de terminar de revelar a palavra — o corte pro próximo bloco acontecia no meio do reveal.
- **Causa raiz:** `Apply Speed = "2 (Per Object)"` (já presente em `_TEXT_TITLE_PARAMS`) faz o FCP comprimir/esticar a animação **inteira** do template (build in + build out) para caber exatamente na duração real do `<title>`. Com as duas fases ativas, build in e build out disputam a mesma janela comprimida — em clipes curtos, build in não tinha tempo suficiente.
- **Onde:** `fcpxml/writer.py::_TEXT_TITLE_PARAMS` (`FCPXMLModifier._make_text_title_clip`).
- **Descoberta do `key`:** não havia como adivinhar — o usuário desmarcou manualmente "Build Out" no Inspector de um título "Text" isolado no FCP e exportou o FCPXML. O override só aparece no XML quando o valor difere do default do template (por isso um export sem a alteração real não mostra o `param` nenhum). Valor capturado: `<param name="Build Out" key="9999/10000/2/102" value="0"/>`.
- **Solução adotada:** `Build Out` adicionado como primeiro item de `_TEXT_TITLE_PARAMS`, sempre `"0"` (desligado) em todo título gerado. Não foi necessário nenhum parâmetro extra de velocidade — desligar o build out já entrega toda a janela "Per Object" comprimida ao build in, que é o efeito de "sempre acelerado" pedido pelo usuário.
- **Aprendizado:** pra descobrir o `key` de um checkbox/param publicado num template Motion, o export de calibração **precisa** ter o valor realmente alterado no Inspector antes de exportar — reexportar o projeto sem mexer em nada não revela nada (o FCP só escreve params que divergem do default). Reforça o padrão já registrado em 2026-08-15: nunca adivinhar `key`, sempre extrair de um export real.
- **Estado:** `resolvido` no XML (1 novo param verificado no writer) — **pendente de confirmação de importação real no FCP pelo usuário**.
---
### 2026-08-18 — Espaço de coordenadas do modelo de título: tamanho E posição
- **Sintoma (1ª metade):** o bloco caía exatamente onde o preview mostrava, mas
o texto renderizava cerca de **metade** do tamanho configurado — com 213pt a
ênfase deveria ocupar ~87% da largura do quadro e ocupava ~35%.
- **Sintoma (2ª metade, causado pela primeira correção):** ao dobrar só o
`fontSize`, o tamanho ficou certo e as **linhas passaram a se sobrepor** — o
bloco mantinha o espalhamento antigo com o dobro de letra dentro.
- **Causa raiz:** a calibração de tamanhos veio do export manual feito com o
modelo **"Essencial - Título"**, cujo espaço de coordenadas é o canvas de
pontos (metade do quadro). Esse modelo nunca renderizou quando gerado por nós
(entrada de 2026-08-17), então o writer passou a emitir o **"Basic Text >
Text" (Text.moti)** — cujo espaço é o **quadro inteiro** (2160×3840). Tudo o
que esse modelo lê está nesse espaço: `fontSize`, `kerning` **e** `Position`.
- **Onde:** `fcpxml/text_layout.py` (`TEXT_TEMPLATE_FONT_SCALE`,
`position_param`), `fcpxml/writer.py`, `fcpxml/models.py`
(`DynamicSubtitleConfig.text_scale`), `server.py`,
`MacApp/Sources/CaptionsView.swift`.
- **Tentativas que falharam:** (a) procurar a diferença nos params do título
(`Auto-Shrink`, margens, `Layout Method`) — todos idênticos ao export manual;
(b) **converter só o `fontSize`** — corrigiu o tamanho e quebrou o
espaçamento, que é o erro registrado aqui como aprendizado principal.
- **Solução adotada:** um único fator, `TEXT_TEMPLATE_FONT_SCALE = 2.0`,
aplicado ao `fontSize`, ao `kerning` **e** à `Position` na saída. O layout
continua medindo em pontos de canvas — toda constante calibrada depende
disso — e a conversão acontece só na emissão, que é a única forma de os dois
andarem juntos. Exposto como `text_scale`.
- **Aprendizado:** um espaço de coordenadas é indivisível. Converter metade das
grandezas que vivem nele é **pior** do que não converter nenhuma: sem
conversão o erro é uniforme e parece "só um ajuste de tamanho"; pela metade,
tipo e espaçamento se descolam e o defeito muda de cara. Ao trocar o modelo
de título, toda constante calibrada contra o modelo antigo vira suspeita — a
posição foi re-verificada em 2026-08-17 e o tamanho não, e o bug ficou
invisível porque "está no lugar certo" parece "está certo".
- **Estado:** `resolvido`
---
### 2026-08-18 — Preview das legendas dinâmicas desproporcional ao render do FCP
- **Sintoma:** o painel "Legendas Dinâmicas" mostrava um preview que não batia
com o resultado no Final Cut: linhas de apoio coladas nas bordas do quadro,
espaçamento entre linhas errado, a banda do bloco invisível e as cores
aplicadas de forma trocada. O formulário de controles também estava confuso,
com blocos de texto explicativo a cada slider.
- **Causa raiz:** `SubtitlePreviewView` era um desenho aproximado feito à mão
(VStack + Spacer, gap fixo de 14pt, canvas mapeado só na altura) e não
reproduzia `compose_sentence` de `fcpxml/text_layout.py`. Além disso, o app
mandava `inactive_color`, que em `granularity="phrase"` o backend **nunca
usa** — o preview pintava a ênfase com uma cor que o FCP ignoraria.
- **Onde:** `MacApp/Sources/SubtitlePreviewView.swift`,
`MacApp/Sources/CaptionsView.swift`, `server.py`
(`handle_generate_dynamic_subtitles`), `admin/models_api.py` (docstring).
- **Tentativas que falharam:** apenas re-escalar as fontes do preview — a
posição continuava errada, porque o desalinhamento vinha do *stagger* e do
gap, não do tamanho.
- **Solução adotada:** o preview passou a espelhar a geometria do backend —
canvas 1080×1920 pt (largura inclusa), margem lateral de 4%,
`REFERENCE_BLOCK_LINE_GAP` (8 pt), `REFERENCE_STAGGER_RATIO` (0.8) com lados
alternados a partir da esquerda, corpo em Bold, empilhamento sobre a tinta
(cap-height + descida) e compensação do centro do frame do `Text`. O
backend ganhou `emphasis_color` (padrão = `active_color`), e a UI foi
reagrupada em "Linhas de apoio" / "Palavra de ênfase" com sliders em
`LabeledContent` e explicação em tooltip.
- **Aprendizado:** um preview só é útil se for derivado das MESMAS constantes
do gerador. Quando o preview é redesenhado "de olho", ele vira uma segunda
fonte de verdade que diverge silenciosamente. E todo controle exposto na UI
precisa existir de fato no caminho de código que ele diz configurar.
- **Estado:** `resolvido`
---
### 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-19 — Legendas dinâmicas geradas com `bold="0" fontFace="Bold"` não renderizam no FCP
- **Sintoma:** no corte real da Mastopexia, as legendas dinâmicas (composição
progressiva) não apareciam no Final Cut — só as primeiras linhas de cada
bloco surgiam e o restante sumia. O arquivo que o usuário re-exportou do FCP
("legendas dinamicas.fcpxmld") renderizava normalmente.
- **Causa raiz:** o corpo das legendas era definido como
`EDITORIAL_BODY_LOOK = WordLook(88, ..., face="Bold")` e o gravador emitia
`bold="0"` + `fontFace="Bold"` (pois `WordStyle.bold` é `False` por padrão).
Essa combinação é contraditória: no FCPXML negrito é o **atributo** `bold="1"`
(nunca um `fontFace="Bold"`), e itálico é `fontFace="... Italic"` **mais**
`italic="1"`. O FCP re-exporta `bold="1"` (sem `fontFace`) e
`fontFace="Medium Italic"` + `italic="1"`, provando o formato correto.
- **Onde:** `fcpxml/writer.py::_make_text_title_clip` (emissão do `text-style`);
o estilo em si em `fcpxml/models.py::EDITORIAL_BODY_LOOK`.
- **Tentativas que falharam:** corrigir manualmente o XML gerado trocando
`bold="0" fontFace="Bold"` por `bold="1"` — resolvia só aquele arquivo e o
bug reaparecia a cada geração. Também tentei "corrigir" os offsets dos
títulos (achando que estavam fora da realidade por estarem em coordenadas de
source) e quebrei o arquivo com timebases errados (24000, 30000) — os offsets
em source coords estavam corretos o tempo todo (ver entrada de 2026-08-17
sobre "anchored in SOURCE media coordinates").
- **Solução adotada:** em `_make_text_title_clip`, traduzir a face "bold" para
`bold="1"` sem `fontFace`; emitir `italic="1"` quando a face contém "italic";
e não mais emitir `bold="0"` junto de uma face. Agora a saída bate com a
re-exportação do FCP (corpo `bold="1"`, palavra-chave `fontFace` + `italic="1"`).
- **Aprendizado:** o FCPXML do template "Text" usa `bold` (atributo) para peso e
`fontFace`+`italic` para a face itálica; "Bold" não é um valor válido de
`fontFace`. Ao duvidar de um formato, confiar na re-exportação do FCP (saída
canônica) e nunca "corrigir" offsets/times que já seguem a convenção do
gerador. Também: comparar a saída gerada contra o FCP byte a byte por campo
(bold/fontFace/italic) antes de assumir o problema em outro lugar.
- **Estado:** `resolvido`
---
### 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`
---
## 19 — `output_dir` aplicado só como cerca, nunca como destino
- **Data:** 2026-08-19
- **Sintoma:** toda chamada com `output_dir` diferente da pasta do arquivo de
entrada morria com `Output path escapes allowed directory`, apontando para um
caminho que a própria função tinha acabado de montar. Na prática o ajuste
"Pasta do projeto" do app só funcionava quando apontava para a pasta onde o
arquivo já ia cair sozinho — ou seja, nunca fazia nada.
- **Causa raiz:** em `_resolve_io_paths` (`server_tools/_shared.py`) o
`output_dir` virava apenas `anchor_dir` da validação, enquanto o nome do
arquivo continuava saindo de `generate_output_path(filepath, suffix)`, que
preserva o diretório da ENTRADA. Cerca em um lugar, destino em outro: o
caminho gerado ficava fora da própria cerca. Afetava os 18+ handlers de
escrita, não só as legendas onde o erro apareceu.
- **Solução adotada:** quando `output_dir` é passado, o destino padrão passa a
ser `<output_dir>/<nome derivado>`; sem ele, mantém-se o comportamento antigo
(ao lado da entrada). Um `output_path` explícito continua vencendo e continua
obrigado a ficar dentro da âncora. `build_voice_timeline` e
`refine_voice_timeline` passaram a aceitar e repassar `output_dir`; os
leitores procuram na pasta do projeto primeiro e caem para o lado da mídia,
para não perder timelines geradas antes da mudança.
- **Aprendizado:** validação e destino não podem ser derivados de fontes
diferentes. Quando um parâmetro tem dois papéis (permissão e endereço),
aplicar só um dos dois produz um erro que acusa o próprio código — e some da
vista porque o caso que funciona é justamente o caso trivial.
- **Estado:** `resolvido`
---
## 20 — Cadeia de processamento sem o passo que corta
- **Data:** 2026-08-19
- **Sintoma:** o encadeamento do app ia de `analyze_voice` direto para
`remove_silences`/legendas. Dava para medir a voz e legendar o resultado, mas
não para aplicar as decisões de edição — o corte por voz tinha que ser rodado
à mão, fora do app, e era fácil parar no primeiro passo achando que o arquivo
estava pronto.
- **Causa raiz:** `apply_voice_actions` existia como handler MCP mas nunca foi
exposto na ponte `admin/models_api.py`, então o batch não tinha como chamá-lo.
- **Solução adotada:** comando `apply_voice_actions` na ponte (aceita
`actions_path` apontando para o JSON de decisões, com ou sem o embrulho
`{"actions": [...]}`), e a etapa correspondente no batch do app, posicionada
logo após a análise e **antes** de qualquer passo que faça ripple — os
zooms/textos/marcadores são posicionados deslocando a partir da própria lista
de cortes, então rodar depois de outro corte os joga no frame errado sem erro
visível. O botão fica bloqueado se a etapa estiver ligada sem arquivo
escolhido, para a cadeia não quebrar no meio.
- **Aprendizado:** um passo que só existe como ferramenta MCP não existe para
quem usa o app. Vale conferir se toda etapa documentada no fluxo tem
representação na cadeia que o usuário de fato executa.
- **Estado:** `resolvido`
---
## 21 — 2026-08-19 — Teste travado no default antigo de `zoom scale`
- **Sintoma:** `tests/test_voice_actions.py::test_default_scale_when_absent`
quebrando com `KeyError: 'scale'`, sem relação com a alteração em curso.
- **Causa raiz:** `parse_actions` deixou de carimbar `scale=1.3` quando o
parâmetro vem ausente, justamente para que
`server_tools/_shared.py` use o `zoom_scale` configurado pelo usuário. O
teste continuou afirmando o default antigo, então passou a acusar como erro
exatamente o comportamento desejado.
- **Solução adotada:** teste reescrito para o contrato novo — um `scale`
omitido tem que chegar ausente ao aplicador (`test_absent_scale_is_left_absent`).
- **Aprendizado:** quando um default sai do parser e vira configuração, o teste
que afirmava o valor antigo passa a defender o bug. Ao remover um default,
procure o teste que o fixava no mesmo commit — senão ele fica dizendo o
contrário do código, e a próxima pessoa perde tempo achando que quebrou algo.
- **Estado:** `resolvido`
---
## 22 — 2026-08-19 — `VideoPlayer` (AVKit) derruba o app compilado por `swiftc`
- **Sintoma:** "G-ART encerrou inesperadamente" (SIGABRT) toda vez que o
assistente entrava na etapa 5. Nada aparecia na tela antes do crash.
- **Causa raiz:** o app é montado invocando `swiftc` direto
(`MacApp/build_app.sh`), não pelo Xcode. Nesse modo o runtime não consegue
resolver a superclasse Objective-C de `VideoPlayer`:
`failed to demangle superclass of VideoPlayerView from mangled name
'So12AVPlayerViewC'` → `getSuperclassMetadata` chama `fatalError`. É erro de
runtime, então a compilação passa limpa e o problema só aparece ao abrir a
view.
- **Solução adotada:** trocar `VideoPlayer` por um `AVPlayerLayer` dentro de um
`NSViewRepresentable` (`PlayerSurface`/`PlayerLayerView` em
`PhraseReviewView.swift`). Só depende de AVFoundation, que linka normalmente.
Os controles de transporte já viviam na barra da timeline, então não se perde
nada com a chrome do AVKit.
- **Aprendizado:** compilar limpo não prova que um componente de framework
existe em runtime neste build. Ao usar uma view SwiftUI que embrulha uma
classe AppKit/ObjC (AVKit, WebKit, MapKit), abra a tela de fato antes de
concluir. Um harness pequeno (`swiftc` com os mesmos fontes + um `@main` que
monta só aquela view e sai) reproduz o crash em segundos, sem precisar
navegar o app inteiro até lá.
- **Estado:** `resolvido`
---
## 23 — 2026-08-19 — Dividir um módulo em pacote quebra quem faz `patch` nele
- **Sintoma:** ao transformar `fcpxml/writer.py` (4.199 linhas) no pacote
`fcpxml/writer/`, quatro testes passaram a falhar com
`AttributeError: module 'fcpxml.writer' has no attribute 'subprocess'` —
embora nenhuma linha de lógica tivesse mudado.
- **Causa raiz:** os testes usavam `@patch('fcpxml.writer.subprocess.run')`.
Isso não depende da API pública, e sim de *onde o import mora*: com o
módulo dividido, `subprocess` passou a ser importado por
`fcpxml/writer/document.py`, então o alvo do patch deixou de existir.
Re-exportar no `__init__` não resolveria — substituir
`fcpxml.writer.subprocess` não afeta a referência que `document` já tem.
- **Solução adotada:** apontar o patch para o módulo real
(`fcpxml.writer.document.subprocess.run`). Duas armadilhas do tipo foram
evitadas antes: imports relativos precisam de um ponto a mais ao descer um
nível (`from .models` → `from ..models`), inclusive os que ficam *dentro*
de funções, e o `__all__` precisa listar os nomes com underscore que o
resto do projeto já importava, senão a divisão vira quebra de API.
- **Aprendizado:** a suíte protege comportamento, não localização. Antes de
dividir um módulo, procure por `patch('<modulo>.` e por imports relativos
escondidos dentro de funções — são as duas coisas que uma refatoração
puramente mecânica quebra em silêncio, e as únicas que os testes pegam
tarde.
- **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` |
| 11 | 2026-08-18 | Preview das legendas dinâmicas desproporcional ao render do FCP (stagger/gap/canvas divergentes) e `inactive_color` exposto sem efeito | `resolvido` |
| 12 | 2026-08-18 | Espaço de coordenadas do modelo "Text": `fontSize`, `kerning` e `Position` no espaço do quadro — converter só o tamanho descolou o espaçamento | `resolvido` |
| 13 | 2026-08-19 | Reanálise de ênfase implementada no Engine mas sem ferramenta MCP — Fase 4 da skill era inexecutável | `resolvido` |
| 14 | 2026-08-19 | Offset sistemático de ~0,4s no timing por palavra (faster-whisper sem alinhamento forçado) — corrigido manualmente no teste, WhisperX pendente | `parcialmente resolvido` |
| 15 | 2026-08-19 | `add_zoom` perdia o enquadramento real (voltava a 100%) quando dois zooms caiam no mesmo clipe pós-corte; agora empilha ou substitui conforme as janelas se sobrepõem | `resolvido` |
| 16 | 2026-08-19 | `validate_subtitle_layout` acusava colisão severa em títulos que só se tocam na borda, por não-associatividade de float; 7 de 8 colisões reportadas no teste real eram falso positivo | `resolvido` |
| 17 | 2026-08-19 | Linha de ênfase das legendas dinâmicas sem limite de largura — palavra longa/maiúscula estourava o frame inteiro; auto-fit encolhe até caber, nunca abaixo do corpo | `resolvido` |
| 18 | 2026-08-19 | Legendas dinâmicas geradas com `bold="0" fontFace="Bold"` não renderizam no FCP — negrito deve ser `bold="1"` (atributo) e itálico `fontFace`+`italic="1"` | `resolvido` |
| 19 | 2026-08-19 | `output_dir` usado só como cerca de validação e nunca como destino — toda chamada entre pastas falhava acusando o caminho que ela mesma gerou | `resolvido` |
| 20 | 2026-08-19 | `apply_voice_actions` ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar | `resolvido` |
| 21 | 2026-08-19 | Teste ainda afirmava o default `zoom scale=1.3` removido do parser (agora vem do `zoom_scale` do usuário) | `resolvido` |
| 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` |
| 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.