chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+501
View File
@@ -33,6 +33,407 @@ Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
## 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
@@ -676,6 +1077,42 @@ Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
<!-- 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
@@ -692,6 +1129,60 @@ Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
---
## 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`
---
## Resumo rápido (índice)
| # | Data | Problema | Estado |
@@ -704,5 +1195,15 @@ Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
| 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` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.