Files
gart/code/Engine/docs/05_EXPERIENCIAS.md
T
João HenriqueandClaude Sonnet 5 8257155fd3 fix(voz): frases desativadas em sequência deixavam fatias sobrando no corte
phrase_review_to_actions() cortava cada frase desativada isoladamente
(start..end da própria frase) — quando várias seguidas estavam desativadas,
a pausa ENTRE elas não pertencia a nenhuma frase e sobrevivia como um
clipe minúsculo (0,1-0,5s) na timeline final. Confirmado no projeto
Mastopexia real: 29 cuts individuais geravam mais de uma dezena de fatias
sub-segundo; agrupar frases desativadas consecutivas num único cut (do
início da primeira ao fim da última) reduziu para 3 cuts e 4 fatias
residuais (menores, provavelmente do padding do remove_media_silence —
registrado como dívida separada em 09_MANUTENCAO.md §2.5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:49:13 -04:00

148 KiB
Raw Blame History

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 usar: o índice abaixo é o ponto de entrada. Procure o sintoma aqui primeiro; só abra a entrada completa (mais abaixo) se ela for a sua. As entradas ficam em ordem cronológica depois do índice.

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) — agora corrigido em pipeline por alinhamento forçado opcional 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
24 2026-08-19 admin/test_models_api.py existia mas estava fora de testpaths — 13 testes que nunca rodaram resolvido
25 2026-08-20 admin/api/shared.py apontava para admin/code (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior resolvido
26 2026-08-21 generate_voice_script (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava num_ctx; e response.json() de conexão caída escapava como JSONDecodeError resolvido
27 2026-08-21 Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda resolvido

Mantenha o índice acima sempre sincronizado com as entradas mais recentes.


Entradas (ordem cronológica)


Como registrar (template de entrada)

Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.

### [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 implementada: transcribe.py agora roda alinhamento forçado fonético (wav2vec2 via whisperx) como passo opcional pós-transcrição, em fcpxml/forced_align.py (classe ForcedAligner). O erro cai de ~400ms para ~30ms e corrige zoom, corte e gap_before de uma vez. É dependência opcional ([align] extra / pacote whisperx do PyPI) — quando ausente ou em qualquer falha, degrada e devolve os tempos brutos sem quebrar a transcrição. O transcript traz "alignment": true/false e o voice_timeline expõe layers.alignment, para quem lê o JSON saber se o offset manual ainda é necessário. Não reaproveitamos código da pasta WHISPERX/ local (problemas conhecidos) — só a ideia documentada aqui. Exige regerar os _transcript.json/_voice_timeline.json existentes para aplicar nos caches antigos.
  • 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: resolvido — alinhamento forçado implementado em transcribe.py/fcpxml/forced_align.py; paliativo de medição manual mantido apenas para transcripts antigos sem layers.alignment=true.

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:
    <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

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

24 — 2026-08-19 — Teste existia, mas estava fora da suíte

  • Sintoma: admin/test_models_api.py (13 testes) nunca rodava. Não falhava — simplesmente não era coletado, então models_api.py figurava como "coberto" sem que uma única asserção fosse executada em nenhum commit.
  • Causa raiz: testpaths = ["tests"] no pyproject.toml, com o pytest rodando de code/. O arquivo morava em admin/, fora do alcance. Rodá-lo à mão também falhava (ModuleNotFoundError: admin), porque a raiz do repositório não entra no sys.path — ou seja, o único jeito de executá-lo exigia saber de antemão que ele existia e como.
  • Solução adotada: movido para code/tests/test_models_api.py, com o insert da raiz do repositório no sys.path ao lado do import que precisa dele. Passou a rodar no gate: 1441 → 1454 testes.
  • Aprendizado: um teste fora de testpaths é pior que teste nenhum — ele dá a sensação de rede sem ser rede. Ao mover ou criar teste fora da pasta padrão, confirme que a contagem total subiu; se não subiu, ele não está rodando. Vale também para o lint: admin/ ainda não é coberto pelo run_after_fix.sh, que roda só dentro de code/.
  • Estado: resolvido

25 — 2026-08-20 — admin/api/shared.py apontava para admin/code (inexistente)

  • Sintoma: app do usuário crashava em toda ação que passa por server (ex: "Analisar voz"), com ModuleNotFoundError: No module named 'server_tools'. Sobreviveu a duas rodadas de validação minha na sessão anterior — lint zero, 1454 testes verdes, comando testado manualmente pela ponte — sem nenhuma delas pegar o bug.
  • Causa raiz: ao dividir admin/_shared.py (#25 da sessão de refatoração, commit ffaebb3) em admin/api/*.py, o cálculo Path(__file__).resolve().parent.parent / "code" foi copiado sem ajuste. No arquivo original (admin/models_api.py, direto em admin/), dois .parent chegam na raiz do repo. Em admin/api/shared.py, um nível mais fundo, dois .parent param em admin/ — e admin/code nunca existiu. sys.path nunca recebia code/, então import server_tools (que só funciona com code/ no path) falhava assim que qualquer handler tentava from server import ....
  • Por que passou pela validação anterior: todo teste que exercitava esse caminho importava admin.api.* dentro do processo do pytest, que já roda com cwd=code/ sob um venv com install editável (__editable__.fcp_mcp_server*.pth) — isso já deixa fcpxml/server_tools importáveis por conta própria, mascarando qualquer erro no cálculo manual de sys.path. O teste manual pela ponte (uv run python admin/models_api.py analyze_voice ...) tem o mesmo problema: uv run ativa o mesmo venv com o mesmo install editável. Só o app real, chamando o fallback python3 sem uv ou um venv sem o install editável, expõe o bug — que é exatamente a diferença entre o ambiente de teste e o do usuário.
  • Solução adotada: o cálculo de sys.path saiu de cada módulo de comando e passou a existir uma única vez, em admin/api/__init__.py — que roda antes de qualquer submódulo do pacote, então nenhum deles precisa da própria cópia. .parent.parent.parent (três níveis: api/ → admin/ → raiz → code/).
  • Como o teste de regressão foi validado (e por que precisou de duas tentativas): a primeira versão do teste também passava com o bug presente, pelo mesmo motivo do parágrafo acima — rodava em processo com o install editável ativo. Só ficou confiável rodando um subprocess limpo que remove manualmente qualquer entrada site-packages de sys.path antes de importar, isolando o mecanismo real que o __init__.py precisa fornecer. Confirmado nos dois sentidos: falha com o bug reintroduzido, passa com a correção (tests/test_models_api.py::TestCodeDirResolution).
  • Aprendizado: um install editável no venv de teste é uma segunda fonte de verdade que mascara bugs de sys.path — o mesmo defeito de "a suíte passa mas o comportamento real não bate" da entrada #23, só que desta vez nem rodar o comando manualmente pegou, porque o uv run usado para testar caía no mesmo venv "de sorte" que o app não usa. Ao validar correção de caminho/import, rodar num ambiente que não tenha as dependências instaladas por fora do mecanismo sendo testado — ou o teste prova que o ambiente de teste está bem configurado, não que o código está certo.
  • Estado: resolvido

Entrada #26 — Prompt da IA local estoura o contexto do Ollama (e erro de parse escapa)

  • Sintoma: botão "Gerar roteiro por IA local" (etapa 4 do assistente) devolvia "Falha ao gerar roteiro por IA local". Rodando a ponte direto, o erro real aparecia como "Server disconnected without sending a response" ou "Connection refused" do Ollama, e 0 decisões ("Decisões do modelo: 0").
  • Causa raiz (dupla):
    1. build_edit_messages embutia o JSON da voice timeline inteiro no prompt. Uma gravação de 3min vira ~188KB / ~47k tokens (cada palavra carrega energia, pitch, arousal, valence, samples…). Como num_ctx estava em 32768, o prompt estourava a janela e o Ollama dropava a conexão sem resposta.
    2. Quando a conexão cai sem resposta, httpx entrega um body vazio e response.json() lançava JSONDecodeError — que não é httpx.HTTPError, então escapava do try/except de ollama_chat e virava a exceção genérica que o cmd_generate_voice_script transforma em ok:false com a mensagem "Falha ao gerar roteiro por IA local: …".
  • Correção (em fcpxml/llm_local.py + server_tools/voice.py):
    • build_edit_messages agora projeta a timeline (_project_timeline): mantém só text/start/end/speaker/emphasis/pause_before das palavras e id/name dos locutores; descarta layers, scales, samples e os floats de áudio. Caiu de ~47k para ~17k tokens (69KB).
    • Salvaguarda _shrink_to_fit: se ainda passar de max_chars (110k), remove os words dos segmentos de menor peak_emphasis até caber.
    • ollama_chat envolve post+raise_for_status+json() num único except Exception que relança como RuntimeError claro — fim do JSONDecodeError escapando.
    • _extract_json agora desembrulha a lista de 1 elemento [{source, actions}] que alguns modelos devolvem, senão o parse_actions tratava o objeto-wrapper como uma ação sem kind e rejeitava tudo (0 decisões).
    • handle_generate_voice_script levanta RuntimeError com a causa quando o modelo não devolve nenhuma decisão utilizável, então o app mostra a mensagem real ("O modelo local não devolveu decisões utilizáveis: …") em vez do genérico.
  • Validação: tests/test_llm_local.py ganhou test_build_edit_messages_is_compact (prompt < raw, sem samples/energy_raw/pitch_hz) e test_ollama_chat_wraps_empty_response. Ponte testada com Ollama mockado nos dois sentidos (sucesso aplica; falha → ok:false com msg clara).
  • Estado: resolvido

Aprendizado: modelo local tem contexto finito — nunca embutir o objeto de análise cru no prompt; projetar só o que a decisão usa. E qualquer parse de resposta de servidor local deve tratar body vazio/quebrado como erro de transporte, não como sucesso mudo.


2026-08-21 — Cortes escritos rente ao timestamp da palavra soam secos

  • Sintoma: usuário revisou o corte final (projeto Mastopexia) e reportou "os cortes estão muito secos, principalmente no final de frase — falta um tempinho a mais pra concluir as palavras". Também notou que o ar morto antes da primeira fala do vídeo não tinha sido cortado.
  • Causa: o critério 06-texto-corte-marcador.md (e o prompt embutido do modelo local em fcpxml/llm_local.py) instruíam cobrir a frase inteira (start..end = início..fim da frase) ao escrever um cut, sem nenhuma orientação sobre a borda que encosta em fala mantida (não em silêncio puro). Um cut com start exatamente no fim da última palavra mantida engole essa palavra antes dela terminar de soar; um cut com end no início exato da próxima engole o ataque da fala seguinte. É um problema diferente de cortar a pausa curta (proibido, é a própria ênfase) — aqui a pausa natural entre os blocos já existe, e o corte estava comendo essa margem sozinho.
  • Correção:
    • 06-texto-corte-marcador.md ganhou a seção "Nunca corte rente à palavra — deixe uma folga": recuar start/end do corte em ~0,15–0,25s para dentro do próprio corte nas bordas que tocam fala mantida (não em silêncio puro), incluindo o início/fim do vídeo.
    • fcpxml/llm_local.py::_SYSTEM_PROMPT (item 4) recebeu a mesma instrução, para o modelo local gerar decisões já com a folga.
  • Validação manual: reaplicado no projeto Mastopexia real — 10.77 → 95.50 (rente) virou 10.97 → 95.30 (folga de ~0,2s nas duas pontas), e as 4 emendas seguintes receberam o mesmo tratamento; zoom/texto/ marcador continuaram longe o suficiente da nova borda do corte — a folga também evita o problema relacionado (não corrigido em código, só contornado manualmente nesta sessão): um zoom/marker cuja borda cai exatamente em cima do início/fim de um cut é descartado por resolve_actions como "apontando para material cortado", mesmo quando a intenção era ficar bem ao lado. Vale registrar como dívida: resolve_actions poderia tolerar uma margem de meio-frame antes de considerar a ação "dentro" do corte.
  • Estado: resolvido

Aprendizado: "cobrir a frase inteira" não é a instrução completa para um corte — a frase que sobra ao lado do corte também precisa de uma borda que respire. Regra prática: só cortar rente ao timestamp quando a borda encosta em silêncio real (gap_before grande) ou em conteúdo que também será descartado; encostando em fala mantida, sempre recuar.


2026-08-21 — Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando

  • Sintoma: usuário viu, no Final Cut, um clipe minúsculo sobrando entre dois clipes normais na timeline (projeto Mastopexia, confirmado por screenshot). Investigação achou 29 cuts individuais no _phrase_actions.json gerado pela etapa 5, e a timeline final saiu com mais de uma dezena de fatias de 0,1-0,5s entre clipes.
  • Causa: phrase_review_to_actions() (fcpxml/phrase_review.py) gerava um cut por frase desativada, cobrindo só [phrase.start, phrase.end]. Quando duas ou mais frases seguidas estão desativadas, a pausa entre elas nunca pertence a nenhuma frase — não é coberta por nenhum cut — e sobrevive como um clipe próprio, minúsculo, que ninguém pediu para manter.
  • Correção: phrase_review_to_actions() agora agrupa frases desativadas consecutivas (flush_inactive_run()) e emite um único cut cobrindo do início da primeira ao fim da última do grupo, absorvendo as pausas entre elas. Uma frase ativa no meio ainda quebra o grupo — cuts continuam separados quando há conteúdo mantido entre eles.
  • Validação: tests/test_phrase_review.py ganhou test_consecutive_inactive_phrases_merge_into_one_cut, test_inactive_run_at_the_end_still_flushes e test_isolated_inactive_phrases_stay_separate_cuts. No projeto Mastopexia real, 29 cuts individuais viraram 3 cuts mescladas; a contagem de fatias sub-segundo na timeline final caiu de mais de uma dezena para 4 (resíduo menor, provavelmente do padding do remove_media_silence na emenda entre clipes — não investigado a fundo nesta sessão, ver 09_MANUTENCAO.md).
  • Estado: resolvido (a causa principal); a sobra residual do remove_media_silence continua como dívida separada.

Aprendizado: "cortar cada frase desativada" não é a mesma coisa que "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido entre elas — a pausa entre duas coisas descartadas também precisa ser descartada, e ninguém a cobre por definição se o corte for por frase.