Ao dividir _shared.py em admin/api/*.py ontem, o cálculo `Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajustar para o nível de diretório novo. No arquivo original (admin/models_api.py, direto em admin/) dois `.parent` chegavam 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 toda ação que passa por `server` (analisar voz, aplicar decisões) crashava o app com ModuleNotFoundError: server_tools. O bug sobreviveu a duas rodadas de validação da sessão anterior — lint zero, 1454 testes verdes, comando testado manualmente pela ponte — porque todos rodam num venv com install editável (__editable__.fcp_mcp_server.pth) que já deixa fcpxml/server_tools importáveis por conta própria, mascarando qualquer erro no cálculo manual de sys.path. Só o app real, no fallback sem uv, expõe o bug. Correção: o cálculo de sys.path sai de cada módulo de comando (estava duplicado em nove arquivos) e passa a existir uma única vez em admin/api/__init__.py, que roda antes de qualquer submódulo — nenhum precisa mais da própria cópia. O teste de regressão precisou de duas tentativas pelo mesmo motivo do bug: a primeira versão também passava com o bug presente, por rodar no mesmo venv "de sorte". Só ficou confiável isolando um subprocess que remove site-packages do sys.path antes de importar — confirmado nos dois sentidos, falha com o bug reintroduzido e passa com a correção (TestCodeDirResolution). Detalhe completo, incluindo por que o comando manual não pegou: Engine/docs/05_EXPERIENCIAS.md #25. Lint zerado, 1457 testes passando (3 novos), app compilado. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 KiB
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) — corrigido manualmente no teste, WhisperX pendente | parcialmente resolvido |
| 15 | 2026-08-19 | add_zoom perdia o enquadramento real (voltava a 100%) quando dois zooms caiam no mesmo clipe pós-corte; agora empilha ou substitui conforme as janelas se sobrepõem |
resolvido |
| 16 | 2026-08-19 | validate_subtitle_layout acusava colisão severa em títulos que só se tocam na borda, por não-associatividade de float; 7 de 8 colisões reportadas no teste real eram falso positivo |
resolvido |
| 17 | 2026-08-19 | Linha de ênfase das legendas dinâmicas sem limite de largura — palavra longa/maiúscula estourava o frame inteiro; auto-fit encolhe até caber, nunca abaixo do corpo | resolvido |
| 18 | 2026-08-19 | Legendas dinâmicas geradas com bold="0" fontFace="Bold" não renderizam no FCP — negrito deve ser bold="1" (atributo) e itálico fontFace+italic="1" |
resolvido |
| 19 | 2026-08-19 | output_dir usado só como cerca de validação e nunca como destino — toda chamada entre pastas falhava acusando o caminho que ela mesma gerou |
resolvido |
| 20 | 2026-08-19 | apply_voice_actions ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar |
resolvido |
| 21 | 2026-08-19 | Teste ainda afirmava o default zoom scale=1.3 removido do parser (agora vem do zoom_scale do usuário) |
resolvido |
| 22 | 2026-08-19 | VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc — etapa 5 fechava o app; trocado por AVPlayerLayer |
resolvido |
| 23 | 2026-08-19 | Dividir writer.py em pacote quebrou @patch('fcpxml.writer.subprocess') — a suíte protege comportamento, não localização |
resolvido |
| 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 |
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_subtitlessobre o corte real do Mastopexia (ver entrada anterior sobrevalidate_subtitle_layout), sobraram 15 títulosoutside_framemesmo depois de eliminados os falsos positivos de colisão. - Causa raiz:
compose_sentence()(fcpxml/text_layout.py) faz wrap das linhas de corpo contrabox.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ósTEXT_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 ultrapassarbox.width, encolhefont_sizeekerningpelo 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 emfont_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_framecaiu de 15 para 0, severidade desevereparawarning(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 emtext_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 severidadesevere: 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'sblock_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()emfcpxml/collision.pycomparaend = start.to_seconds() + duration.to_seconds()(soma de dois floats já arredondados) contrastart.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 inverterstart_b < end_adeFalseparaTruee disparar uma colisãoseverefantasma. 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-6subtraí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
severesem 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 emcollision.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áticoscale="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 deadd_zoomencontra 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')voltavaNone, o base virava1.0por 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 isolandocut_clip_ranges+ duas chamadas deadd_zoomno mesmo elemento. - Por que passou despercebido: o teste existente (
test_only_one_transform_remains) já chamavaadd_zoomduas 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):
- Quando não há atributo
scaleestático,add_zoomagora lê o<param name="scale">existente e recupera a base como o menor valor entre as keyframes — válido porqueMIN_ZOOM_SCALE == 1.0garante 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. - 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
keyframeAnimationem vez de uma apagar a outra — FCPXML aceita quantas keyframes forem necessárias num único<param>.
- Quando não há atributo
- 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 emwriter.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-voznum 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_timelinesobre 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 (ffmpegastats), 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.pyusaword_timestamps=Truedo 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_beforesubestimava 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_silencebruto 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=printem janelas curtas ao redor de cada ponto crítico antes de fixar um corte ou zoom que dependa de precisão de frame. Não é o padrão do sistema — é o que cobre a lacuna até o alinhamento forçado existir. - Solução estrutural ainda pendente: ligar o WhisperX (ou alinhamento forçado equivalente) em
transcribe.py, o que levaria o erro de ~400ms para ~30ms e corrigiria zoom, corte egap_beforede uma vez, sem paliativo por projeto. Não implementado ainda — é mudança de pipeline, exige regerar todos os_transcript.json/_voice_timeline.jsonexistentes. - Aprendizado: "não reestime tempos no olho" (critério 01) continua certo para decisão editorial — mas não cobre erro sistemático de medição na fonte dos tempos. Um offset constante e na mesma direção, em vários pontos do material, é sinal de bug no pipeline de transcrição, não de julgamento errado sobre o material. Vale conferir com uma amostra de áudio real antes de confiar cegamente em timestamp de word-level de qualquer fonte nova.
- Estado:
parcialmente resolvido— paliativo documentado e aplicado neste teste; correção estrutural (WhisperX) pendente de implementação.
2026-08-19 — Capacidade existente sem porta de entrada: a Fase 4 da skill era letra morta
- Sintoma: a skill
editar-por-vozmanda, como fase obrigatória, reanalisar o material sobrevivente antes de escolher zooms — e o modelo não tinha como cumprir isso.restrict_to_kept()esuggest_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 toolrefine_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_rawepitch_hzem 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_wordschamavaword_pitch_energy, que sobrescreveenergy/pitch_hzcomNonequando 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âmetroalready_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_gapmanté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_zoomtinha um únicoease(padrão 0,3s) aplicado simetricamente na entrada e na saída, produzindo um retorno lento que chama atenção para si. - Solução:
easepassou a valer só para a entrada (padrão 0,5s) e a saída virou um frame, calculado doframeDurationreal da sequência (ease_outopcional 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.pyforçavaease=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 repassareasesó 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,TestZoomShapeIsAsymmetricfixa 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"/>position/rotationmantidos como atributos e o atributoscaleremovido quando a escala é animada — confirmou a correção de preservação de enquadramento;- o primeiro keyframe cai exatamente no
startdo clipe (3235,557s) — confirmou a correção de timebase de origem; <keyframe>carrega apenastimeevalue— seminterpe semcurve.
- Correção final: removido o
curve="smooth"que eu havia adicionado ao trocar ointerp. O DTD permitecurve(defaultsmooth), mas como o importador já havia rejeitadointerpneste mesmo param vetorial, não há razão para apostar quecurvesobrevive — 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>doadjust-transformeram escritos em segundos relativos ao clipe (0,08s a 4,80s), mas o clipe temstart="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 ostartdo clipe (media_origin + tempo relativo), exatamente o queadd_text_titlejá 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)". Oadd_zoomnunca 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 testetest_add_zoom_creates_keyframed_transformusava uma fixture comstart="10s"— tinha tudo para pegar o bug — mas afirmavatimes == [1.0, 1.5, 2.5, 3.0], ou seja, fixava o comportamento errado. Reescrito para ancorar emorigin + relativo, comassert origin > 0garantindo 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_titlesabia ancorar em coordenadas de origem;add_zoome 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()emdiarize.pydecodifica o áudio por conta própria (reusandodecodable_audio()dovoice_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 Exceptionsem 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_speakerscontinua 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_emphasisnormalizava a pausa contramax_pause=1,5ssaturando — 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_pausee cai a zero acima depause_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_beforeetake_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 testetest_scales_document_every_word_metricquebrou —scalesdocumentava tudo como métrica de palavra, egap_beforeé de segmento.VALUE_SCALESpassou 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_zoomescreviainterp="ease"em cada<keyframe>do parâmetroscale. O importador do FCP só aceitainterpem parâmetros escalares (opacidade, volume);scaleé vetorial (value="1.25 1.25") e admite apenascurve. - 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 testetest_add_zoom_creates_keyframed_transformafirmavainterp == 'ease', travando o comportamento errado. Só a importação real no FCP revelou. - Solução adotada:
curve="smooth"no lugar deinterp(ocurvejá ésmoothpor padrão no DTD, mas explícito documenta a intenção e protege contra mudança de default). Teste invertido: agora exigecurve == 'smooth'e ausência deinterp. - Atenção — não confundir com
timept: o<timept>dotimeMap(usado emchange_speed) aceitainterpnormalmente 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:
resolvidono 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 pesopause_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 24fpsem clipes que estavam perfeitamente alinhados. Conferido na mão:1200199/12000s ÷ 1001/24000s = 2398frames exatos — inteiro, sem resto. O aviso do arquivo original, intocado, também era falso. - Causa raiz:
_check_frame_alignmentfaziafps_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 deserialize_xmljá alertava para passar a taxa real "so NTSC projects don't get spurious warnings", mas oint()logo adiante destruía a correção. - Solução adotada: o validador passou a ler o
frameDurationexato do formato que a<sequence>referencia (_document_frame_duration) e a comparar com aritmética deFraction— alinhado é quandoduração / frameDurationtem 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 emTestNTSCFrameAlignment, 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_actionstrata 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_cutsresolvem 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
TimeValuepara string comstr(): sempreto_fcpxml(). - Estado:
resolvido(1276 testes verdes; aplicação validada contra FCPXML real, incluindo oexamples/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_countvinha 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_CONFIGemfcpxml/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(viamodel_manager.load/save_voice_analysis_config), diferente do padrão@AppStorage/UserDefaults usado porCaptionsView.swiftpara 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), comandosvoice_analysis/set_voice_analysisemadmin/models_api.py, tools MCPget/save_voice_analysis_config, telaMacApp/Sources/VoiceAnalysisView.swift. - Cuidado que rendeu teste:
load_voice_analysis_configprecisa devolver uma cópia dos defaults — a primeira versão devolvia o dict aninhadoemphasis_weightspor referência, e quem mutasse o resultado corrompia o default do módulo para o resto do processo. Coberto portest_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 viapyannote/speaker-diarization-3.1, comdiarization_capability,diarize,assign_speakers,build_speakers) etests/test_diarize.pyjá existiam completos e passando, mas nenhuma tool emserver.pychamava esse módulo — código morto do ponto de vista de uso real.model_manager.pytambém já tinhaload_hf_token/save_hf_tokenprontos para o token do HuggingFace exigido pelo pyannote. - Decisão de arquitetura: usar pyannote (já é dependência declarada em
pyproject.tomlcomo extradiarization) 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_mediaemserver.py(handlerhandle_diarize_media), reaproveitandodiarize.pysem alterá-lo; cache em_diarization.jsonao lado da mídia, seguindo exatamente o padrão de_transcript.json/_beats.jsonjá usados portranscribe_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_sentencefora 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çãopair_gapnova) 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 oline_gapdo 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:
resolvidono 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 oparamnenhum). Valor capturado:<param name="Build Out" key="9999/10000/2/102" value="0"/>. - Solução adotada:
Build Outadicionado 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
keyde 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 adivinharkey, sempre extrair de um export real. - Estado:
resolvidono 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,kerningePosition. - 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ó ofontSize— 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 aofontSize, aokerninge àPositionna 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 comotext_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:
SubtitlePreviewViewera um desenho aproximado feito à mão (VStack + Spacer, gap fixo de 14pt, canvas mapeado só na altura) e não reproduziacompose_sentencedefcpxml/text_layout.py. Além disso, o app mandavainactive_color, que emgranularity="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 doText. O backend ganhouemphasis_color(padrão =active_color), e a UI foi reagrupada em "Linhas de apoio" / "Palavra de ênfase" com sliders emLabeledContente 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/descentda 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_sentenceempilha 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_sentenceempacota palavra a palavra e centra cada linha; orhythmvariava 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 emfont_metrics), apoio em Helvetica Neue Bold, tudo branco, linhas escalonadas porREFERENCE_STAGGER_RATIO. O modo antigo continua disponível emgranularity="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_clipmontava o id comof"{name}_ts0", enamevem do texto da legenda ("3 coisas que você precisa saber - Text"). No DTD,idé do tipoIDerefdo tipoIDREF: 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 comts_(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/IDREFno 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):
- 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). - No caminho
animated=False, o offset era gravado como relativo (0s) em vez de coordenadas de mídia-fonte (startdo clipe-pai + relativo). O FCP lê0scomo "0s da mídia", antes do in-point do clipe (start="220062843/24000s"), então o título nunca cai sobre o vídeo.
- Os dois templates que usávamos — "Essencial - Título"
(
- 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.fcpxmldeposiçã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"),startfixo86486400/24000s, e um bloco de<param>com margens/alinhamento/Custom Speed(com<keyframeAnimation>de tempos nominais constantes). A posição é o paramPosition(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_subtitlesagora usa sempre o "Text" e grava offset em coordenadas de mídia-fonte (startdo pai + relativo) para todos os títulos; posição via paramPosition, nãoadjust-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 usastartdo clipe-pai como origem do offset, nunca0s. - Estado:
resolvidono 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 umasset-clipnão é cortado pelo fim do clipe-pai; o FCP segue desenhando sobre o que vier depois. O último bloco de cada clipe terminava noendda última palavra do Whisper — que frequentemente ultrapassa o corte — e palavras cujostartjá 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 emtests/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:
- 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. - Técnica, e real:
measure_textestimava 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.
- Processo: o arquivo que o usuário importou era a versão anterior, gerada antes do
posicionamento existir (todas as palavras em
- 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
fontToolse embutir como tabela emfcpxml/font_metrics.py(9 variantes × 143 glifos). OfontToolsfoi usado só na geração, viauv 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
stylede 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çãodo template Essential Title, mas não havia como saber a unidade nem a escala. O único valor existente no código era0 -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 pegamkey/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:- 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. - 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.
- 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/358permanece0 69em todos os títulos do arquivo.
- Posição usa a mesma unidade que
- 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:
_TEXTO_TITLE_UIDapontava para.../Subtitles.localized/Subtitle.localized/Subtitle.moti— o template de legenda/caption do FCP, não um template de título.- Cada
<title>recebiarole="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 usamEssential Title.moti/Essential Fade.moti/Text.motie **não têm atributorolenenhum**; os gerados usavamSubtitle.moti+role="subtitles.". Prova adicional: no re-export, o FCP devolveu oscaption_` 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.pyafirmava queWHISPERX/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 orolede caption). A entrada anterior desta lista herdou o mesmo erro. - Solução adotada:
_TEXTO_TITLE_UID→.../Titles.localized/Essential Titles.localized/Essential Title.localized/Essential Title.motie nome do efeito →"Essencial - Título"._TEXTO_TITLE_ROLEremovido eelem.set('role', ...)eliminado de_make_texto_title_clip. Nenhum título gerado carregarole._TEXTO_TITLE_START→86486400/24000s(valor que o FCP escreve para o Essential Title)._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.- Granularidade:
max_words_per_line4 → 1 elane_count3 → 9 (defaults alinhados emmodels.py,server.pye no MacApp), gerando um<title>por palavra. - Comentários falsos reescritos apontando para os exports reais do usuário.
- 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 osname=seguem o padrão que o código gera (caption_<hex>), ele é. - Estado:
resolvidono 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 deoffsetem coordenada de mídia continua correta (reconfirmada contraexemplo de arquivos.fcpxmld), mas orole="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 comstart="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 semanimated, então caía no default e produzia um<title>comref="r_title_basic"(Título Básico) mas comrole="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):
generate_dynamic_subtitles(fcpxml/writer.py) escolhia o efeito certinho porconfig.animated(linhas 2893-2896), mas SEMPRE construía o clip com_make_texto_title_clip(linha 2944) — ignorando oanimated._make_basic_title_clip(que monta o Título Básico sem role/start e só os 2 paramsCompactar/Alinhamento) existia mas nunca era chamado (código morto). Resultado default: ref do básico + corpo do subtitle → o FCP descarta silenciosamente.- Mesmo no caminho animado, o
offsetera 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= start240817577/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" (casoLegendas fora.fcpxmld).
- Onde:
fcpxml/writer.py(generate_dynamic_subtitles, linhas ~2893-2956),fcpxml/models.py(DynamicSubtitleConfig.animated, default erradoFalse),tests/test_dynamic_subtitles.py. - Solução adotada:
generate_dynamic_subtitlesagora despacha peloconfig.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.- No caminho animado,
offset = media_origin + snap(relativo)commedia_origin = _parse_time(parent.get('start'))(coordenada da mídia, igual ao export real); no estático, offset permanece relativo (como r3). DynamicSubtitleConfig.animatedagora éTruepor padrão (decisão do usuário: a feature é a legenda animada/ediável;Falsesó para texto queimado no frame).- 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 branchelsede umif config.Xque 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_silencesnum projeto real (Depoimento da Erika_silence_removed.fcpxmld) e reportou dois clipinhos minúsculos: o primeiro e o último clipe da spine gerada tinhamduration="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.fcpxmlbruto gerado pelo programa, confirmado lendo o XML direto, sem passar pelo FCP.handle_remove_media_silence/cut_clip_ranges(fcpxml/writer.py) aplica umpadding(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 listakeeps(complemento doscut_rangesmesclados). - 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
uide osoffset/durationcorrigidos (entradas abaixo), o usuário mandou o.fcpxmldreal 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_subtitlesitera cada clipe da spine (for el in spine_clips), calcula a janela de palavras correta relativa àquele clipe (el), mas chamavamodifier.generate_dynamic_subtitles(name, ...)passandoname = el.get("name", "")— uma STRING — em vez do elemento. Depois de qualquerremove_silences/corte com ripple, TODOS os fragmentos resultantes de um clipe original mantêm o mesmonameherdado (aqui, ~482 clipes, todosname="0E6A8829", o nome do asset de origem)._require_clip()resolve porself.clips[key], um dict indexado poridou, na falta dele, porname(fcpxml/writer.py:_index_elements) — com nomes duplicados, cada novo clipe indexado SOBRESCREVE o anterior, entãoself.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 amodifier.generate_dynamic_subtitles),fcpxml/writer.py(generate_dynamic_subtitles,_require_clip,_index_elements). - Solução adotada:
generate_dynamic_subtitlesagora aceitaparent_clipcomostr | 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.pyfoi atualizado para passarel(o elemento já em mãos no loop) em vez dename. Adicionado teste de regressão (test_element_param_bypasses_ambiguous_duplicate_name_lookup) que simula dois clipes com o mesmonamee confirma que passar o elemento anexa cada legenda ao clipe certo. - Aprendizado: nunca identificar um clipe específico por
namenum handler que itera múltiplos clipes — qualquer operação de corte/ripple/remoção de silêncio no FCPXML preserva onameoriginal em todos os fragmentos resultantes, entãonamedeixa de ser único assim que o timeline é editado. Sempre que o chamador já tem oET.Elementem 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:
- O
uidfixado 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 ouidde 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 umuidfabricado 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). - Um dos
<param>copiados junto (Opacidade="0") fixava a opacidade do texto em zero — mesmo se ouidestivesse certo, o texto ficaria invisível.
- O
- 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— efeitor4nomeado "Legenda",uid=".../Titles.localized/Subtitles.localized/Subtitle.localized/Subtitle.moti", usado em cinco<title>conectados por palavra/linha comrole="subtitles.subtitles-1". Copiado ouid, oname("Legenda"), ostartfixo (86400314/24000s, diferente do valor anterior), o atributorole, 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 paramsAnimar/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/adivinharuidde efeito nativo do FCP, mesmo que o formato pareça consistente com outros exports reais — sempre copiar de um.fcpxmld/Info.fcpxmlque 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, incluindoAnimar = "4 (Tudo)"mais dezenas de params de animação por caractere (X/Y/Z deslocamento,Objeto Original,Deslocamento Inicial/Final,Direção,Velocidade Personalizadacom keyframes). Nós só escrevíamos 5 params de layout e omitíamos oAnimar. 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 nossoPosiçã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.047em "Tod" vs-243em "a") — são dados 3D por caractere que o FCP calcula e que não dá para reproduzir. Já ostimedos 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/203deAnimar),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 default0 -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 oPosiçãogravado; a revelação palavra-por-palavra continua vindo dooffset/durationde cada palavra (não da animação). Teste atualizado: 5 params de layout + 10Animar = "0 (Nenhum)", semkeyframeAnimation. - 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 paramPosiçã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:
resolvidono 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/durationde<title>com denominador/23s(ex.:offset="2/23s",duration="67/23s"). - Causa raiz:
generate_dynamic_subtitles(fcpxml/writer.py) construía cada offset/duration comTimeValue.from_seconds(seconds, self.fps). Esse classmethod (fcpxml/models.py) fazint(fps)— para um projeto NTSC a 23.976fps (frameDuration="1001/24000s",self.fps ≈ 23.976),int(fps)trunca para23, 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 exata1001/24000s. - Onde:
fcpxml/writer.py:2841-2850(chamada) efcpxml/models.py:314-318(TimeValue.from_seconds, bug latente — outros call-sites como marcadores emfcpxml/writer.py:1442,1511usam 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)porself.snap_seconds_to_frame(...)— helper já existente (fcpxml/writer.py:746) que usa a fração exata deframeDuration(viaframe_duration_fraction(),fcpxml/writer.py:727) em vez do float truncado, já usado em outro lugar do writer para snap da spine. Também trocadomin_dur_seconds = 1.0 / self.fpsporfloat(self.frame_duration_fraction()). - Aprendizado: qualquer conversão de segundos-float para
TimeValuenum projeto FCPXML deve usar a fração exata doframeDurationdo<format>da sequência (viaframe_duration_fraction()/snap_seconds_to_frame()), nuncaint(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_subtitlesnã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 porconfig.lane_countlanes (round-robin). A duração de cada linha se estende até sua própria lane ser reaproveitadalane_countlinhas 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 atributostartfixo (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 (
wordscomstart/endreais), usando a mesma lógica deTimeValue/mapeamento fonte→timeline já estabelecida no projeto. Só a ESTRUTURA XML (uid, params,startfixo) 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_countsubstituindolane),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.fcpxmlcom 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 respectivaapontando 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:
- O
uiddo efeito "Basic Title" foi inventado (nunca verificado contra um export real) — o caminho correto temBumper:Opener.localized, nãoBasic Text.localized. - Os
<title>por palavra estavam sendo anexados como connected clip (vialane) 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. - Palavras com
start/endmuito próximos (ou vindas de um transcript com timestamps imprecisos) geravam durações que arredondavam para 0 frames no fps da sequência.
- O
- Onde:
fcpxml/writer.py,FCPXMLModifier.generate_dynamic_subtitles/_make_title_clip/_ensure_basic_title_effect. - Tentativas que falharam: validar apenas com
fcpxml/dtd.pye 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:
- Encontrado um
uidreal e correto dentro do próprio repositório, emWHISPERX/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 (CompactareAlinhamento, comkeyfixo) também foram copiados de lá; o<param name="Position">fabricado foi removido. - 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. - 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 de0.01sque arredondava para 0 em fps altos.
- Encontrado um
- Aprendizado: nunca fabricar
uid/keyde 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 de1/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 comF821 Undefined name 'clip_id'eF841 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 suportaroutput_dirconfigurável), o corpo inteiro decmd_add_zoom(checagem declip_id+ chamada ahandle_add_zoom) foi colado dentro decmd_remove_silences, antes do bloco correto que já chamavahandle_remove_media_silence— deixandocmd_remove_silencescom código morto/quebrado (chamava o handler errado e checava uma variável inexistente) ecmd_add_zoomtruncado (só validavapath/clip_ide não fazia mais nada). - Onde:
admin/models_api.py, funçõescmd_remove_silencesecmd_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 ahandle_add_zoom) de volta para dentro decmd_add_zoom, removendo-o decmd_remove_silences, que voltou a conter só a chamada correta ahandle_remove_media_silence. - Aprendizado: depois de qualquer edição manual em
admin/models_api.py(ou qualquer arquivo com várias funçõescmd_*de shape parecido), rodar o lint imediatamente pega colagens cruzadas de função —ruffacusa 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 comocaption_L0W0_ts0 already defined. - Causa raiz: os IDs de
<title>/<text-style-def>eram montados comocaption_L{line_idx}W{word_idx}_ts{i}, comline_idx/word_idxreiniciando em 0 a cada chamada degenerate_dynamic_subtitles. Como o handler (handle_generate_dynamic_subtitlesemserver.py) chama esse método uma vez por clipe da spine na mesma instância deFCPXMLModifier, 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étodoFCPXMLModifier.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 viaET.SubElementdireto 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 comoref-clipdevem 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
SubElementdireto (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_pathao lado do projeto e a UI oferecia ações independentes, sem uma pasta de trabalho comum. - Onde:
MacApp/Sources/TranscriptionView.swift,admin/models_api.pyeserver.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_direxplí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:
exportSubtitlespassa a usarprocessedPath(a cópia_silence_removed) quando existe, caindo para oprojectPathcaso 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 clampartl_start/tl_endde cada cue a esse teto; usarfloor(em vez deround) nosrt_stamppara 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(novoFCPXMLModifier.remove_trailing_gaps) eserver.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
startmonotônico, sem micro-clips). O arquivo exportado pelo Final Cut tinha 162 clipes, 80 regressões destarte 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 dohandle_remove_media_silence) eLegendas 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_srtgerava 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 dotranscript_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çavaValueError: 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/), calculavaframes = round(seconds * fps)com ofpsreal (float), mas construía oTimeValuecomoTimeValue(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_spansemserver.py, já corrigidos na entrada anterior) —from_timecodeé usado por_parse_time(), chamado por quase todo owriter.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)(recupera24000/1001a partir do float com precisão total) e usarframes * fps_frac.denominator/fps_frac.numeratorcomo numerador/denominador — mantém os dois em unidades consistentes. Teste de regressão emtests/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: floate construa umTimeValuediretamente (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 deint(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 desegments_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_srtque gera um.srtpor 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 todoasset-clipda spine (projeto real de 82 clipes,Depimento Erika). - Causa raiz:
handle_remove_media_silencee_cut_transcript_spans(server.py) calculavam os limites de corte comTimeValue(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 como1/24sexato em vez do1001/24000sreal — uma correção deEngine/docs/05_EXPERIENCIAS.md(entrada anterior) existia comoFCPXMLModifier.snap_spine_times_to_frames()mas só era chamada porhandle_add_marker; nenhum handler de corte/ripple a usava. - Onde:
server.py(handle_remove_media_silence,_cut_transcript_spans) efcpxml/writer.py(FCPXMLModifier.save()). - Tentativas que falharam: nenhuma — a correção certa (
Fractionexato) já existia no código, só não estava conectada aos caminhos que realmente cortam a spine. - Solução adotada: (1)
save()agora chamasnap_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 novoFCPXMLModifier.snap_seconds_to_frame(), que arredonda para o frame mais próximo usando a fração exata deframeDurationem vez da base fixa de 2400. Teste de regressão emtests/test_media_intel.py::test_ntsc_rate_output_stays_frame_alignedreproduzduration="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
Fractiona partir doframeDurationreal da sequência, nuncafpsarredondado. 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.swifteMacApp/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.fcpxmlcom avisos de queoffsetedurationnão estavam em limites de quadro. - Causa raiz: a edição ripple produzia frações de tempo válidas
matematicamente, mas desalinhadas do
frameDurationexato da sequência. - Onde:
fcpxml/writer.pyeserver.pyno 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
offsetedurationda spine usando a fração exata deframeDurationantes 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 emitiabold="0"+fontFace="Bold"(poisWordStyle.boldéFalsepor padrão). Essa combinação é contraditória: no FCPXML negrito é o atributobold="1"(nunca umfontFace="Bold"), e itálico éfontFace="... Italic"maisitalic="1". O FCP re-exportabold="1"(semfontFace) efontFace="Medium Italic"+italic="1", provando o formato correto. - Onde:
fcpxml/writer.py::_make_text_title_clip(emissão dotext-style); o estilo em si emfcpxml/models.py::EDITORIAL_BODY_LOOK. - Tentativas que falharam: corrigir manualmente o XML gerado trocando
bold="0" fontFace="Bold"porbold="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" parabold="1"semfontFace; emitiritalic="1"quando a face contém "italic"; e não mais emitirbold="0"junto de uma face. Agora a saída bate com a re-exportação do FCP (corpobold="1", palavra-chavefontFace+italic="1"). - Aprendizado: o FCPXML do template "Text" usa
bold(atributo) para peso efontFace+italicpara a face itálica; "Bold" não é um valor válido defontFace. 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_dirdiferente da pasta do arquivo de entrada morria comOutput 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) ooutput_dirvirava apenasanchor_dirda validação, enquanto o nome do arquivo continuava saindo degenerate_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). Umoutput_pathexplícito continua vencendo e continua obrigado a ficar dentro da âncora.build_voice_timelineerefine_voice_timelinepassaram a aceitar e repassaroutput_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_voicedireto pararemove_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_actionsexistia como handler MCP mas nunca foi exposto na ponteadmin/models_api.py, então o batch não tinha como chamá-lo. - Solução adotada: comando
apply_voice_actionsna ponte (aceitaactions_pathapontando 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_absentquebrando comKeyError: 'scale', sem relação com a alteração em curso. - Causa raiz:
parse_actionsdeixou de carimbarscale=1.3quando o parâmetro vem ausente, justamente para queserver_tools/_shared.pyuse ozoom_scaleconfigurado 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
scaleomitido 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
swiftcdireto (MacApp/build_app.sh), não pelo Xcode. Nesse modo o runtime não consegue resolver a superclasse Objective-C deVideoPlayer:failed to demangle superclass of VideoPlayerView from mangled name 'So12AVPlayerViewC'→getSuperclassMetadatachamafatalError. É erro de runtime, então a compilação passa limpa e o problema só aparece ao abrir a view. - Solução adotada: trocar
VideoPlayerpor umAVPlayerLayerdentro de umNSViewRepresentable(PlayerSurface/PlayerLayerViewemPhraseReviewView.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 (
swiftccom os mesmos fontes + um@mainque 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 pacotefcpxml/writer/, quatro testes passaram a falhar comAttributeError: 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,subprocesspassou a ser importado porfcpxml/writer/document.py, então o alvo do patch deixou de existir. Re-exportar no__init__não resolveria — substituirfcpxml.writer.subprocessnão afeta a referência quedocumentjá 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ãomodels_api.pyfigurava como "coberto" sem que uma única asserção fosse executada em nenhum commit. - Causa raiz:
testpaths = ["tests"]nopyproject.toml, com o pytest rodando decode/. O arquivo morava emadmin/, fora do alcance. Rodá-lo à mão também falhava (ModuleNotFoundError: admin), porque a raiz do repositório não entra nosys.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 nosys.pathao 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 pelorun_after_fix.sh, que roda só dentro decode/. - 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"), comModuleNotFoundError: 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, commitffaebb3) emadmin/api/*.py, o cálculoPath(__file__).resolve().parent.parent / "code"foi copiado sem ajuste. No arquivo original (admin/models_api.py, direto emadmin/), dois.parentchegam na raiz do repo. Emadmin/api/shared.py, um nível mais fundo, dois.parentparam emadmin/— eadmin/codenunca existiu.sys.pathnunca recebiacode/, entãoimport server_tools(que só funciona comcode/no path) falhava assim que qualquer handler tentavafrom 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 comcwd=code/sob um venv com install editável (__editable__.fcp_mcp_server*.pth) — isso já deixafcpxml/server_toolsimportáveis por conta própria, mascarando qualquer erro no cálculo manual desys.path. O teste manual pela ponte (uv run python admin/models_api.py analyze_voice ...) tem o mesmo problema:uv runativa o mesmo venv com o mesmo install editável. Só o app real, chamando o fallbackpython3semuvou 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.pathsaiu de cada módulo de comando e passou a existir uma única vez, emadmin/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
subprocesslimpo que remove manualmente qualquer entradasite-packagesdesys.pathantes de importar, isolando o mecanismo real que o__init__.pyprecisa 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 ouv runusado 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