Marca cada título gerado (dynamic/plain) em metadata para que regenerar substitua a saída anterior em vez de empilhar, e usa os spans de ênfase revisados (não os segmentos brutos do Whisper) como janela da composição dinâmica, evitando que ela invada o trecho de legenda comum seguinte. suppress_plain_under_dynamic corta qualquer sobra visível como rede de segurança. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1735 lines
163 KiB
Markdown
1735 lines
163 KiB
Markdown
# 05 — Experiências: Registro de Problemas, Erros e Decisões
|
||
|
||
> **Propósito:** registrar, de forma cumulativa, todos os problemas estruturais,
|
||
> erros que se repetiram em várias tentativas e decisões difíceis enfrentadas
|
||
> durante o desenvolvimento do G-ART. Serve de memória de trabalho para que
|
||
> futuras implementações **não repitam os mesmos erros** e para que decisões já
|
||
> tomadas não sejam redescobertas do zero.
|
||
|
||
**Regra:** sempre que um problema for detectado (estrutural ou funcional) e
|
||
houver uma correção ou trabalho em torno dele, **adicione um registro aqui**
|
||
antes de prosseguir. Um problema que se repete em várias tentativas é sinal de
|
||
que merece entrada.
|
||
|
||
|
||
> **Como usar:** o índice abaixo é o ponto de entrada. Procure o sintoma
|
||
> aqui primeiro; só abra a entrada completa (mais abaixo) se ela for a sua.
|
||
> As entradas ficam em ordem cronológica depois do índice.
|
||
|
||
## Resumo rápido (índice)
|
||
|
||
| # | Data | Problema | Estado |
|
||
|---|------|----------|--------|
|
||
| 1 | 2026-08-14 | Início do registro de experiências | `resolvido` |
|
||
| 4 | 2026-08-14 | XML fora da grade de frame em NTSC (23.976/29.97fps), confirmado no FCP | `resolvido` |
|
||
| 5 | 2026-08-14 | `TimeValue.from_timecode` corrompia segundos decimais em NTSC (3º ponto do bug) | `resolvido` |
|
||
| 6 | 2026-08-17 | Clipe-fantasma de 1 frame no início/fim após remoção de silêncio (padding sem vizinho na borda) | `resolvido` |
|
||
| 7 | 2026-08-17 | Legendas dinâmicas sobrepondo entre clipes (título conectado não é aparado pelo out-point do pai) | `resolvido` |
|
||
| 8 | 2026-08-17 | Importação recusada: `id` de `<text-style-def>` derivado do texto (acentos/espaços/dígito inicial) não é XML Name válido | `resolvido` |
|
||
| 9 | 2026-08-17 | Legendas palavra a palavra centradas em vez da composição progressiva diagramada (bloco por trecho, palavra-chave em display italic) | `resolvido` |
|
||
| 10 | 2026-08-17 | Cedilha/acentos da display italic invadindo a linha vizinha: empilhamento passou a usar a tinta real por classe de glifo | `resolvido` |
|
||
| 11 | 2026-08-18 | Preview das legendas dinâmicas desproporcional ao render do FCP (stagger/gap/canvas divergentes) e `inactive_color` exposto sem efeito | `resolvido` |
|
||
| 12 | 2026-08-18 | Espaço de coordenadas do modelo "Text": `fontSize`, `kerning` e `Position` no espaço do quadro — converter só o tamanho descolou o espaçamento | `resolvido` |
|
||
| 13 | 2026-08-19 | Reanálise de ênfase implementada no Engine mas sem ferramenta MCP — Fase 4 da skill era inexecutável | `resolvido` |
|
||
| 14 | 2026-08-19 | Offset sistemático de ~0,4s no timing por palavra (faster-whisper sem alinhamento forçado) — agora corrigido em pipeline por alinhamento forçado opcional | `resolvido` |
|
||
| 15 | 2026-08-19 | `add_zoom` perdia o enquadramento real (voltava a 100%) quando dois zooms caiam no mesmo clipe pós-corte; agora empilha ou substitui conforme as janelas se sobrepõem | `resolvido` |
|
||
| 16 | 2026-08-19 | `validate_subtitle_layout` acusava colisão severa em títulos que só se tocam na borda, por não-associatividade de float; 7 de 8 colisões reportadas no teste real eram falso positivo | `resolvido` |
|
||
| 17 | 2026-08-19 | Linha de ênfase das legendas dinâmicas sem limite de largura — palavra longa/maiúscula estourava o frame inteiro; auto-fit encolhe até caber, nunca abaixo do corpo | `resolvido` |
|
||
| 18 | 2026-08-19 | Legendas dinâmicas geradas com `bold="0" fontFace="Bold"` não renderizam no FCP — negrito deve ser `bold="1"` (atributo) e itálico `fontFace`+`italic="1"` | `resolvido` |
|
||
| 19 | 2026-08-19 | `output_dir` usado só como cerca de validação e nunca como destino — toda chamada entre pastas falhava acusando o caminho que ela mesma gerou | `resolvido` |
|
||
| 20 | 2026-08-19 | `apply_voice_actions` ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar | `resolvido` |
|
||
| 21 | 2026-08-19 | Teste ainda afirmava o default `zoom scale=1.3` removido do parser (agora vem do `zoom_scale` do usuário) | `resolvido` |
|
||
| 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` |
|
||
| 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` |
|
||
| 24 | 2026-08-19 | `admin/test_models_api.py` existia mas estava fora de `testpaths` — 13 testes que nunca rodaram | `resolvido` |
|
||
| 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` |
|
||
| 26 | 2026-08-21 | `generate_voice_script` (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava `num_ctx`; e `response.json()` de conexão caída escapava como `JSONDecodeError` | `resolvido` |
|
||
| 27 | 2026-08-21 | Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda | `resolvido` |
|
||
| 28 | 2026-08-21 | Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando entre clipes | `resolvido` |
|
||
| 29 | 2026-08-21 | `remove_media_silence` (dB) não corta lacuna sem fala mas com som real — trecho sobrevivia intacto na timeline final | `resolvido` |
|
||
| 30 | 2026-08-24 | `add_zoom` animava `<adjust-transform>` direto no clipe, diferente de como o FCP realmente exporta zoom (clipe de ajuste conectado) | `resolvido` |
|
||
| 31 | 2026-08-24 | Revisão de falantes ("Quem fica na edição") salvava certo, mas etapa 4 (Revisão de frases) lia a timeline crua, ignorando falantes mutados/linhas riscadas | `resolvido` |
|
||
| 32 | 2026-08-24 | Separar legendas dinâmicas de convencionais por role: `<title>` aceita `role` (CDATA), NÃO `videoRole` — este último é DTD-inválido para títulos e quebra a validação | `resolvido` |
|
||
| 33 | 2026-09-22 | Legenda comum sobreposta à composição dinâmica em `generate_subtitles_by_emphasis`; regenerar acumulava títulos em vez de substituir | `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.
|
||
|
||
```markdown
|
||
### [DATA] Título curto do problema
|
||
|
||
- **Sintoma:** o que acontecia / o erro observado.
|
||
- **Causa raiz:** o que realmente causava o problema (após investigação).
|
||
- **Onde:** arquivo(s) e, se útil, função/linha.
|
||
- **Tentativas que falharam:** o que já foi tentado e não funcionou.
|
||
- **Solução adotada:** a correção que resolveu.
|
||
- **Aprendizado:** regra/comportamento a lembrar nas próximas implementações.
|
||
- **Estado:** `aberto` | `resolvido` | `mitigado` | `evitado por design`
|
||
```
|
||
|
||
---
|
||
|
||
## Registro de Experiências
|
||
|
||
### 2026-08-19 — Linha de ênfase das legendas dinâmicas sem limite de largura: auto-fit implementado
|
||
|
||
- **Contexto:** validando `generate_dynamic_subtitles` sobre o corte real do Mastopexia (ver entrada anterior sobre `validate_subtitle_layout`), sobraram 15 títulos `outside_frame` mesmo depois de eliminados os falsos positivos de colisão.
|
||
- **Causa raiz:** `compose_sentence()` (`fcpxml/text_layout.py`) faz wrap das linhas de corpo contra `box.width`, mas a linha de ênfase — sempre uma palavra só, a "key word" em itálico grande — nunca era checada contra largura nenhuma, porque uma palavra sozinha não tem como quebrar em duas linhas. Uma palavra longa (`estruturado,`, `sustentação`, `proporcional`) ou toda maiúscula media mais que o frame inteiro sozinha: `estruturado,` a 460pt (ponto emitido, após `TEXT_TEMPLATE_FONT_SCALE`) mediu **2538px de largura contra 2160px de frame**, estourando os dois lados centrada.
|
||
- **Onde:** `fcpxml/text_layout.py::compose_sentence`.
|
||
- **Solução adotada:** `fit_emphasis()` mede a linha de ênfase e, se ultrapassar `box.width`, encolhe `font_size` e `kerning` pelo mesmo fator — a largura é linear nesses dois parâmetros juntos, então o fator exato é `box.width / width_medida`, sem iteração. Nunca encolhe abaixo do tamanho do corpo (`body_look.font_size`): ênfase do mesmo tamanho que o texto normal deixa de ser ênfase. Como a extensão vertical de tinta (`ink_extent`) também é linear em `font_size`, encolher a largura encolhe a altura usada no empilhamento junto — restaurando de brinde a garantia "sem sobreposição por construção" que o resto da função já tinha, sem precisar de lógica extra para isso.
|
||
- **Efeito medido:** revalidando o mesmo corte, `outside_frame` caiu de 15 para 0, severidade de `severe` para `warning` (restam avisos de área segura, não erros de frame).
|
||
- **Achado colateral:** a única colisão que sobrou depois da correção (`MASTOPEXIA` × `é a cirurgia que`) não era bug nenhum — era um título manual (`Auto-Shrink`, template "Text" nativo do FCP, sem relação com `_make_text_title_clip`) presente no arquivo base reutilizado para o teste, provavelmente de uma edição manual no FCP, não gerado por nenhuma chamada da sessão. Revalidando sobre uma base limpa, sem esse título estranho: **0 colisões**. Vale sempre revalidar sobre uma base conhecida antes de atribuir um achado ao código.
|
||
- **Estado:** `resolvido` — corrigido em `text_layout.py`, suíte completa e lint passando, revalidado sobre o corte real.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — `validate_subtitle_layout` acusava colisão em 7 de 8 casos por ruído de ponto flutuante, não por sobreposição real
|
||
|
||
- **Contexto:** primeiro uso real de `validate_subtitle_layout` (ferramenta nova) sobre o corte do Mastopexia com legendas dinâmicas geradas. Relatou severidade `severe`: 8 colisões, 15 títulos fora do frame, 10 fora da área segura.
|
||
- **Sintoma:** ao rastrear cada colisão reportada pelas frações exatas do FCPXML, 7 dos 8 pares eram títulos **consecutivos que terminam exatamente quando o próximo começa** — o design pretendido ("cada bloco some quando o próximo aparece", `writer.py`'s `block_ends[i] = block_starts[i+1]`) funcionando corretamente. O oitavo (`MASTOPEXIA` × `é a cirurgia que`) era uma sobreposição real de ~0,46s.
|
||
- **Causa raiz:** `temporal_overlap()` em `fcpxml/collision.py` compara `end = start.to_seconds() + duration.to_seconds()` (soma de dois floats já arredondados) contra `start.to_seconds()` de outro título (uma única divisão) — mesmo quando a fração exata subjacente é bit-idêntica nos dois casos, a soma de dois floats arredondados não bate com uma única divisão da soma exata dos numeradores (não-associatividade de ponto flutuante). Medido: diferença de ~4,5×10⁻¹³s — treze ordens de grandeza menor que um frame (~0,04s) — suficiente para inverter `start_b < end_a` de `False` para `True` e disparar uma colisão `severe` fantasma. Contradizia o próprio comentário do código ("a title ending exactly as the next begins is never flagged").
|
||
- **Onde:** `fcpxml/collision.py::temporal_overlap`.
|
||
- **Solução adotada:** tolerância `_BOUNDARY_EPSILON = 1e-6` subtraída de ambos os lados da comparação — muitas ordens de grandeza abaixo de qualquer fronteira de frame real, então não mascara nenhuma sobreposição genuína, só absorve o ruído de arredondamento entre dois caminhos de cálculo do mesmo instante.
|
||
- **Aprendizado:** comparar dois floats derivados do MESMO valor exato por caminhos aritméticos diferentes (soma vs. divisão direta) nunca deve usar igualdade/desigualdade estrita — vale para qualquer checagem "toca a borda mas não deveria contar", não só tempo de título. O sintoma (severidade `severe` sem nenhuma sobreposição visível no material) é o sinal de alerta: sempre rastrear a colisão até as frações exatas do XML antes de aceitar o relatório da ferramenta de validação como verdade.
|
||
- **Estado:** `resolvido` — corrigido em `collision.py`, teste de regressão com os números reais do caso (`test_boundary_survives_float_noise_from_the_writer`), suíte completa (1369 testes) e lint passando, revalidado sobre o corte real: 8 colisões → 1.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — `add_zoom` perdia o enquadramento real quando dois zooms caíam no mesmo clipe pós-corte
|
||
|
||
- **Sintoma:** no mesmo teste real (Mastopexia), o clipe de abertura do corte apareceu "achatado" no FCP — Scale 100% em vez do enquadramento real (~177%) que o projeto original já tinha, enquanto os clipes seguintes apareciam corretos. Rotação e posição estavam certas; só a escala quebrava, e só no primeiro trecho.
|
||
- **Causa raiz:** `add_zoom()` (`fcpxml/writer.py`) lê o enquadramento-base de um clipe só de um jeito: o atributo estático `scale="X Y"` em `<adjust-transform>`. Isso funciona na primeira chamada. Mas quando dois zooms editoriais caem dentro do **mesmo** clipe sobrevivente (dois picos de ênfase que o corte não separou em clipes distintos), a segunda chamada de `add_zoom` encontra não mais um atributo estático, e sim um `<param name="scale">` já **animado** pela primeira — e o código só sabia ler atributo. `stale.get('scale')` voltava `None`, o base virava `1.0` por padrão, e a linha seguinte (`clip.remove(stale)`) **apagava a animação da primeira chamada inteira**, substituindo por uma segunda com base errada.
|
||
- **Onde:** `fcpxml/writer.py::add_zoom` (base scale + merge de keyframes); reproduzido isolando `cut_clip_ranges` + duas chamadas de `add_zoom` no mesmo elemento.
|
||
- **Por que passou despercebido:** o teste existente (`test_only_one_transform_remains`) já chamava `add_zoom` duas vezes no mesmo clipe, mas só checava que sobrava **um** `<adjust-transform>` na árvore — nunca verificou se a base da segunda chamada estava certa. A suíte cobria a estrutura, não o valor.
|
||
- **Solução adotada (duas partes):**
|
||
1. Quando não há atributo `scale` estático, `add_zoom` agora lê o `<param name="scale">` existente e recupera a base como o **menor** valor entre as keyframes — válido porque `MIN_ZOOM_SCALE == 1.0` garante que todo pico é `>= base`, então o menor valor keyframeado é sempre o resting scale, seja ele o de abertura, o de fecho ou qualquer um no meio.
|
||
2. Duas janelas de zoom no mesmo clipe agora só se **substituem** quando as janelas de tempo se sobrepõem (é o mesmo evento sendo reajustado); quando são **disjuntas** (dois picos editoriais distintos que um corte não separou), as keyframes são **empilhadas** no mesmo `keyframeAnimation` em vez de uma apagar a outra — FCPXML aceita quantas keyframes forem necessárias num único `<param>`.
|
||
- **Aprendizado:** "preservar o enquadramento existente" precisa valer em **toda** leitura subsequente do mesmo clipe, não só na primeira. Um mecanismo que lê corretamente da fonte original mas degrada ao reler sua própria saída anterior é o mesmo bug de fundo da entrada #13 (renormalização) por outro ângulo: qualquer estado que o sistema regrava precisa continuar sendo uma fonte de verdade legível, não só um efeito colateral write-only. Vale desconfiar de qualquer `findall()`/leitura de atributo que tenha um "senão assume 1.0/padrão" — é aí que a segunda chamada perde o que a primeira escreveu.
|
||
- **Estado:** `resolvido` — corrigido em `writer.py`, dois testes de regressão adicionados (`test_second_zoom_on_same_clip_keeps_the_real_base_scale`, `test_overlapping_zoom_on_same_clip_replaces_instead_of_stacking`), suíte completa (1344 testes) e lint passando, corte real do Mastopexia regravado e conferido.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Teste real fechou o ciclo, e revelou um offset sistemático de ~0,4s no timing por palavra
|
||
|
||
- **Contexto:** primeiro teste ponta a ponta de `editar-por-voz` num projeto real (Mastopexia, 196,6s de gravação de roteiro com 6 tomadas). Fluxo completo: `build_voice_timeline` → triagem manual (tomada/bastidor/frase abandonada) → `refine_voice_timeline` sobre os sobreviventes → escolha de zoom por função narrativa → `apply_voice_actions`. Corte final: 196,6s → ~50s, 3 clipes.
|
||
- **Sintoma:** antes de rodar `remove_media_silence`, medi manualmente o RMS do áudio real nas emendas propostas pelo corte e achei folgas de ~0,4-0,6s onde o JSON dizia que a fala começava/terminava. Comparando timestamp da transcrição contra o ataque real medido em 6 pontos do vídeo (ffmpeg `astats`), o erro era **sistemático, sempre no início da palavra**, entre +0,35s e +0,51s — os finais de palavra batiam certo (+0,01 a +0,15s).
|
||
- **Causa raiz:** `transcribe.py` usa `word_timestamps=True` do faster-whisper, que deriva os tempos por atenção cruzada — aproximado por natureza, sem alinhamento forçado. O submódulo WHISPERX existe no repositório mas **não é usado** em nenhum ponto do código; não há etapa de alinhamento fonético.
|
||
- **Por que isso importa mais do que parece:** o erro contamina toda decisão temporal a jusante — zoom disparava ~0,4s antes da palavra-alvo, `gap_before` subestimava pausas reais na mesma medida (o que afeta diretamente a régua de silêncio recém-adotada), e as folgas de corte saíam erradas nas emendas.
|
||
- **Decisão tomada:** não rodei `remove_media_silence` bruto sobre o corte. A detecção (ffmpeg, limiar -30dB/0,5s) não distingue "batida entre frases dentro da régua de 1,5s" de "ar morto de emenda" — cortar ambos teria apertado frases fluidas. Corrigi os tempos manualmente medindo o ataque real nos pontos críticos (cabeça, 2 emendas, cauda, 3 zooms) e refiz o corte numa passada só.
|
||
- **Solução adotada (paliativa, aplicada manualmente neste teste):** medir o RMS real com `ffmpeg -af astats=metadata=1:reset=1:length=0.05,ametadata=print` em janelas curtas ao redor de cada ponto crítico antes de fixar um corte ou zoom que dependa de precisão de frame. Não é o padrão do sistema — é o que cobre a lacuna até o alinhamento forçado existir.
|
||
- **Solução estrutural implementada:** `transcribe.py` agora roda alinhamento forçado fonético (wav2vec2 via whisperx) como passo opcional pós-transcrição, em `fcpxml/forced_align.py` (classe `ForcedAligner`). O erro cai de ~400ms para ~30ms e corrige zoom, corte e `gap_before` de uma vez. É **dependência opcional** (`[align]` extra / pacote `whisperx` do PyPI) — quando ausente ou em qualquer falha, degrada e devolve os tempos brutos sem quebrar a transcrição. O `transcript` traz `"alignment": true/false` e o `voice_timeline` expõe `layers.alignment`, para quem lê o JSON saber se o offset manual ainda é necessário. Não reaproveitamos código da pasta `WHISPERX/` local (problemas conhecidos) — só a ideia documentada aqui. Exige regerar os `_transcript.json`/`_voice_timeline.json` existentes para aplicar nos caches antigos.
|
||
- **Aprendizado:** "não reestime tempos no olho" (critério 01) continua certo para decisão *editorial* — mas não cobre erro sistemático de *medição* na fonte dos tempos. Um offset constante e na mesma direção, em vários pontos do material, é sinal de bug no pipeline de transcrição, não de julgamento errado sobre o material. Vale conferir com uma amostra de áudio real antes de confiar cegamente em timestamp de word-level de qualquer fonte nova.
|
||
- **Estado:** `resolvido` — alinhamento forçado implementado em `transcribe.py`/`fcpxml/forced_align.py`; paliativo de medição manual mantido apenas para transcripts antigos sem `layers.alignment=true`.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Capacidade existente sem porta de entrada: a Fase 4 da skill era letra morta
|
||
|
||
- **Sintoma:** a skill `editar-por-voz` manda, como fase obrigatória, reanalisar o material sobrevivente antes de escolher zooms — e o modelo não tinha como cumprir isso. `restrict_to_kept()` e `suggest_zoom_windows()` existiam, estavam testadas e documentadas, mas **nenhuma ferramenta MCP as expunha**. Na prática, todo zoom continuava sendo escolhido com o ranking bruto, exatamente o erro que a entrada anterior descreve.
|
||
- **Causa raiz:** a implementação parou na camada Engine. O critério foi escrito descrevendo chamadas Python, que só os testes conseguiam fazer — a distância entre "existe no `fcpxml/`" e "o modelo consegue chamar" passou despercebida porque a suíte cobria a função, não o caminho.
|
||
- **Onde:** `server.py` (nova tool `refine_voice_timeline` + handler + dispatch), `tests/test_refine_voice_timeline_tool.py`, `.claude/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md`.
|
||
- **Solução adotada:** ferramenta `refine_voice_timeline(media_path, cuts, min_gap, max_zooms, save)`, que numa chamada devolve a comparação bruto × sobreviventes, os picos re-ranqueados e os candidatos a zoom. Handler fino: nada de lógica nova, só o caminho até o que já existia. O critério 04 passou a citar a ferramenta em vez das funções Python.
|
||
- **Aprendizado:** função coberta por teste unitário **não** é capacidade entregue. Toda vez que um critério de skill mandar "rode X", verifique que X é chamável pelo modelo — senão o critério vira instrução impossível, e o modelo segue em frente sem erro visível. Vale um teste de registro (`a tool está em list_tools` + `está no TOOL_HANDLERS`) para cada ferramenta nova.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Ênfase é relativa: analisar o bruto e editar o corte final são perguntas diferentes
|
||
|
||
- **Problema:** os zooms estavam sendo escolhidos a partir da análise do material **bruto**. Mas energia é normalizada contra o momento mais alto da gravação — que era `"Amor, eu tô intacto!"` (energia 1,00), justamente uma das falas **cortadas**. Todo o material que sobrou estava pontuado contra uma referência que o espectador nunca veria, comprimindo artificialmente as notas do corte final.
|
||
- **Solução:** `restrict_to_kept()` filtra a timeline pelos ranges removidos e **re-normaliza sobre os sobreviventes**. Efeito medido: ênfase média subiu de 0,179 (bruto) para 0,197 (só o que ficou) — o material restante passou a usar a escala inteira. E o ranking mudou de figura: `"Aquela"` 0,39→0,42, `"mastopexia"` 0,26→0,34, `"devolver"` entrando com 0,35.
|
||
- **Pré-requisito que virou bug na hora:** re-normalizar exige os valores **brutos**, e o JSON só guardava os normalizados. Adicionados `energy_raw` e `pitch_hz` em cada palavra. A primeira execução saiu com todas as notas caindo — sintoma de estar lendo campo ausente num JSON gerado antes da mudança. Regerar o JSON resolveu; vale lembrar que mudança de esquema exige regerar os caches antes de interpretar qualquer resultado.
|
||
- **Armadilha de API evitada:** `enrich_words` chamava `word_pitch_energy`, que **sobrescreve** `energy`/`pitch_hz` com `None` quando não há frame tracks — então re-analisar um subconjunto zerava tudo. Em vez de remendar restaurando os valores depois (que foi a primeira tentativa, e ficou ilegível), entrou o parâmetro `already_measured`.
|
||
- **`suggest_zoom_windows()`:** propõe uma janela por frase, a partir da palavra de **conteúdo** mais enfática (artigos e conectivos filtrados por `_FUNCTION_WORDS` — um "a" falado alto continua sendo um artigo), indo até o fim da frase; `min_gap` mantém os zooms afastados.
|
||
- **Aprendizado:** "qual o momento mais forte da gravação?" e "qual o momento mais forte do vídeo final?" são perguntas distintas sempre que a métrica for relativa. Toda métrica normalizada precisa ser recalculada quando o conjunto muda — caso contrário ela responde a pergunta errada, silenciosamente.
|
||
- **Estado:** `resolvido` (1325 testes verdes; 3 zooms escolhidos pela re-análise aplicados em clipes distintos, DTD 1.14 válido).
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Forma do punch-in é assimétrica: entrada de 0,5s, saída de 1 frame
|
||
|
||
- **Regra editorial (do usuário):** na palavra de ênfase, zoom in **rápido** (~meio segundo); segura durante a frase de impacto; e no fim **volta de um quadro para o outro, sem transição nenhuma** — o vídeo simplesmente retoma o enquadramento e segue o fluxo.
|
||
- **O que havia:** `add_zoom` tinha um único `ease` (padrão 0,3s) aplicado **simetricamente** na entrada e na saída, produzindo um retorno lento que chama atenção para si.
|
||
- **Solução:** `ease` passou a valer só para a entrada (padrão **0,5s**) e a saída virou **um frame**, calculado do `frameDuration` real da sequência (`ease_out` opcional para quem quiser retorno gradual). Medido no material: entrada 0,501s, hold 3,378s, saída 0,042s = 1 frame a 23,976fps.
|
||
- **Detalhe que quase passou:** o handler em `server.py` forçava `ease=float(action.params.get("ease", 0.3))`, então o padrão novo do writer nunca chegava a valer — o zoom saía com 0,33s de entrada. Um default duplicado em duas camadas é sempre o errado das duas; o handler passou a repassar `ease` **só quando explicitamente informado**, deixando o writer ser o dono do padrão.
|
||
- **Aprendizado:** ao mudar um default, procurar quem já o repassa. Um `params.get("x", <default>)` numa camada acima anula silenciosamente o default da camada que de fato conhece o assunto.
|
||
- **Estado:** `resolvido` (1311 testes verdes, `TestZoomShapeIsAsymmetric` fixa a forma; DTD 1.14 válido) — pendente de conferência visual no FCP.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Export de calibração do FCP fecha o zoom: keyframe só com `time` e `value`
|
||
|
||
- **Como veio:** depois de o zoom continuar não aparecendo, o usuário fez o zoom **à mão no FCP** sobre o mesmo material e exportou o FCPXML (`Mastopexia - exemplo de zoom.fcpxmld`) — o padrão de calibração já registrado em 2026-08-15 e 2026-08-18, agora aplicado a keyframes.
|
||
- **O que o export real mostrou:**
|
||
```xml
|
||
<adjust-transform position="0.160319 0.663249" rotation="90.1008">
|
||
<param name="scale">
|
||
<keyframeAnimation>
|
||
<keyframe time="2329601280/720000s" value="1.77311 1.77311"/>
|
||
```
|
||
1. `position`/`rotation` mantidos como atributos e o atributo `scale` **removido** quando a escala é animada — confirmou a correção de preservação de enquadramento;
|
||
2. o primeiro keyframe cai exatamente no `start` do clipe (3235,557s) — **confirmou** a correção de timebase de origem;
|
||
3. **`<keyframe>` carrega apenas `time` e `value`** — sem `interp` e **sem `curve`**.
|
||
- **Correção final:** removido o `curve="smooth"` que eu havia adicionado ao trocar o `interp`. O DTD permite `curve` (default `smooth`), mas como o importador já havia rejeitado `interp` neste mesmo param vetorial, não há razão para apostar que `curve` sobrevive — o export real do FCP não escreve nenhum dos dois, então passamos a escrever nenhum dos dois. Estrutura agora idêntica à do FCP.
|
||
- **Aprendizado:** ao corrigir um atributo rejeitado pelo importador, **não basta trocar por outro plausível do DTD** — foi o que fiz (`interp` → `curve`) e ficou uma segunda aposta não verificada em cima da primeira. A resposta certa era pedir um export de calibração e copiar. Vale a regra: diante de qualquer incerteza sobre o que o FCP aceita, o caminho mais curto é um export real, não uma segunda leitura do DTD.
|
||
- **Estado:** `resolvido` (1306 testes verdes, estrutura conferida atributo a atributo contra o export do usuário, DTD 1.14 válido) — pendente de confirmação de importação.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Zoom importava como nada: keyframe em tempo relativo, não no timebase de origem
|
||
|
||
- **Sintoma:** usuário importou o FCPXML no FCP e relatou "não tem zoom, não tem corte, não tem nada".
|
||
- **Causa 1 (real) — o zoom:** os `<keyframe>` do `adjust-transform` eram escritos em segundos **relativos ao clipe** (0,08s a 4,80s), mas o clipe tem `start="74637363/24000s"` = **3109,9s** (timecode de origem). O FCP procura a animação no timebase do próprio clipe, não encontra keyframe nenhum na janela dele, e importa o zoom como **nada** — sem erro, sem aviso. Corrigido somando o `start` do clipe (`media_origin + tempo relativo`), exatamente o que `add_text_title` já fazia e **documentava**: *"Anchored in SOURCE media coordinates… so the title lands on screen instead of at ~0s of the media (which FCP silently drops)"*. O `add_zoom` nunca recebeu o mesmo tratamento.
|
||
- **Por que escapou:** todo fixture sintético e o projeto da Erika têm clipe com `start="0s"`, onde relativo e absoluto coincidem. Pior: o teste `test_add_zoom_creates_keyframed_transform` usava uma fixture com `start="10s"` — tinha tudo para pegar o bug — mas afirmava `times == [1.0, 1.5, 2.5, 3.0]`, ou seja, **fixava o comportamento errado**. Reescrito para ancorar em `origin + relativo`, com `assert origin > 0` garantindo que a fixture continue exercitando o caso.
|
||
- **Causa 2 (percepção) — o corte:** os cortes **estavam** no arquivo (3 clipes, 47,6s contra 196,7s do bruto). Mas o XML mantinha `<event name="17-08-2026">` e `<project name="Mastopexia">` idênticos ao original, então a importação criava um projeto homônimo no mesmo evento e o usuário abriu o antigo. Passou-se a renomear o projeto para `<nome> — corte por voz`.
|
||
- **Aprendizado 1:** "não fez nada" pode ser duas coisas muito diferentes — não gerou, ou gerou e o usuário não achou. Vale sempre inspecionar o arquivo antes de concluir, e nunca deixar a saída indistinguível da entrada dentro do app de destino.
|
||
- **Aprendizado 2 (repetição do padrão de 2026-08-19/`interp`):** quando um módulo já resolve um problema de coordenadas e **documenta** a solução no docstring, procurar os irmãos que fazem operação equivalente. `add_text_title` sabia ancorar em coordenadas de origem; `add_zoom` e qualquer outro futuro escritor de keyframes precisam da mesma regra.
|
||
- **Estado:** `resolvido` (1306 testes verdes, keyframes conferidos dentro da janela do clipe: 3297,74–3302,46s para um clipe de 3297,7–3302,6s; DTD 1.14 válido) — pendente de nova importação no FCP.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Primeira edição real ponta a ponta: três bugs de ordem/identidade que a suíte não pegava
|
||
|
||
Triagem editorial completa de um vídeo institucional (196,7s → 47,6s, 76% de redução),
|
||
feita a partir do `_voice_timeline.json`. A geração do FCPXML expôs três bugs, todos
|
||
invisíveis em fixture sintética porque dependem de um projeto **já editado**.
|
||
|
||
**1. `add_zoom` destruía o enquadramento do editor.** O clipe original trazia
|
||
`<adjust-transform position="0.160319 0.663249" rotation="90.1008" scale="1.77311 1.77311"/>`
|
||
— material gravado de lado e reenquadrado à mão. `add_zoom` removia qualquer
|
||
`adjust-transform` existente ("Replace rather than stack a prior zoom") e criava o seu do
|
||
zero, então o trecho com zoom voltava **girado 90°**. Corrigido: os atributos estáticos
|
||
(position/rotation/anchor) são preservados e a escala passa a ser animada **relativa** à
|
||
base (1,77311 → 1,77311 × 1,18 → 1,77311). Comentário "replace rather than stack" estava
|
||
certo na intenção e errado no alcance — nem todo `adjust-transform` é um zoom anterior.
|
||
|
||
**2. Posicionar antes de cortar espalhava o zoom e apagava marcadores.**
|
||
`cut_clip_ranges` divide o clipe e reescreve a spine; o `adjust-transform` era **copiado
|
||
para os 3 pedaços** e os `<marker>` sumiam. Invertida a ordem: **cortes primeiro**,
|
||
posicionamentos depois — `resolve_actions` já converte os tempos para a timeline
|
||
pós-corte, então continuam apontando para o mesmo instante.
|
||
|
||
**3. Depois de cortar, todos os pedaços têm o MESMO nome.** `add_zoom`/`add_marker`
|
||
resolviam o clipe por nome (`_require_clip`), então toda edição caía no **primeiro**
|
||
pedaço. `_require_clip` passou a aceitar um `Element` direto (como `add_text_title` já
|
||
fazia), e o handler passa o elemento exato — mapeando pelo **offset na timeline**, não
|
||
mais pela janela de origem.
|
||
|
||
**Bônus — marcador é ponto, não trecho.** `resolve_actions` exigia que início *e* fim
|
||
sobrevivessem ao corte, descartando justamente os marcadores úteis: os que sinalizam uma
|
||
emenda e por definição encostam na borda do corte. Marcadores passaram a resolver só pelo
|
||
início.
|
||
|
||
- **Aprendizado:** os três bugs são a mesma família — **ordem de operações e identidade de
|
||
elemento** depois de uma operação que reestrutura a árvore. Fixture sintética tem um
|
||
clipe limpo, sem transformações prévias e sem nomes duplicados, então nada disso
|
||
aparece. Testar contra um projeto **real já editado** é categoricamente diferente de
|
||
testar contra XML gerado por nós.
|
||
- **Regressões adicionadas:** `TestZoomPreservesExistingFraming` (5),
|
||
`TestPlacementsLandOnTheRightPieceAfterCuts` (3), `TestMarkersSurviveCutEdges` (4).
|
||
- **Estado:** `resolvido` (1301 testes verdes, DTD 1.14 válido, enquadramento conferido no
|
||
XML) — pendente de importação real no FCP pelo usuário.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Diarização quebrada pelo torchcodec + descoberta: separar tomada de conversa NÃO é diarização
|
||
|
||
**Parte 1 — a falha técnica.** Com token e termos válidos, `diarize()` retornava `None` e o log dizia só "diarization failed" (o `except Exception:` amplo engolia a causa). Rodando o pyannote direto, a causa apareceu: `pyannote.audio` 4.x decodifica áudio via **torchcodec**, que linka contra uma versão específica do FFmpeg — `dlopen(libtorchcodec_core4.dylib): Library not loaded: @rpath/libavutil.56.dylib`. O FFmpeg instalado é outro major, e a diarização caía inteira numa máquina em que tudo o mais funcionava.
|
||
|
||
- **Solução:** `_load_waveform()` em `diarize.py` decodifica o áudio por conta própria (reusando `decodable_audio()` do `voice_features.py`, que já extrai WAV mono 16 kHz via ffmpeg) e passa a `{"waveform": tensor, "sample_rate": sr}` que o pyannote aceita — pulando o torchcodec por completo. Bônus: containers de vídeo passam a funcionar direto. Resultado: 33 turnos, 2 participantes, ~1min40s para 3min17s de áudio.
|
||
- **Aprendizado:** `except Exception` sem registrar a exceção transforma falha diagnosticável em mistério. O log deveria carregar a causa; sem isso, foi preciso reexecutar a biblioteca à mão para ver o erro real.
|
||
|
||
**Parte 2 — a descoberta de produto, mais importante.** Com a diarização funcionando, o `remove_speakers` encontrou só **uma** fala do SPEAKER_01 — e ainda por cima uma atribuição errada. Cruzando os turnos com a transcrição, a explicação apareceu: o pyannote detecta a segunda voz em 44,1–46,0s e 51,4–54,8s, mas a transcrição **não tem nada** nesses intervalos (vãos de 43,8→47,5 e 48,8→55,4). A pessoa da equipe está **fora do microfone**: a diarização a ouve, o Whisper não a transcreve.
|
||
|
||
- **Consequência:** as falas que o usuário quer descartar (*"Amor, eu tô intacto!"*, *"Só clica aí agora na tela."*, *"Posso começar da mastopexia?"*) são **da própria protagonista** — mesma voz, contexto diferente. Cortar por participante não resolve esse caso.
|
||
- **Aprendizado:** "quem fala" e "isso é tomada válida?" são perguntas **diferentes**, e é tentador confundi-las porque ambas soam como "separar as partes do vídeo". Diarização resolve a primeira; só a linguagem resolve a segunda. `remove_speakers` continua válido para o caso em que o entrevistador está microfonado (ex. depoimento da Erika), mas não é a ferramenta para triar tomada de conversa.
|
||
- **Estado:** `resolvido` (diarização funcional, 1294 testes verdes); triagem tomada/conversa fica na camada de linguagem, critérios em `.claude/skills/editar-por-voz/SKILL.md`.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Pausa longa: ruído para ênfase, sinal para estrutura (o mesmo dado, dois usos opostos)
|
||
|
||
- **Sintoma:** no material real, palavras de energia baixíssima lideravam o ranking de ênfase. No Mastopexia, 4 dos 7 picos eram assim: `"mastopexia"` (energia 0,25, pausa 6,2s), `"Aquela"` (0,32, 8,7s), `"Aumenta."` (0,25, 5,7s). O mesmo padrão aparecia no depoimento da Erika.
|
||
- **Causa raiz:** `compute_emphasis` normalizava a pausa contra `max_pause=1,5s` **saturando** — ou seja, uma pausa de 8,7s e uma de 1,5s recebiam nota idêntica (1,0). Mas gap de 6–9s não é ênfase dramática: é troca de tomada, inserção de B-roll ou a outra pessoa falando. O índice estava premiando corte de cena como se fosse entrega enfática.
|
||
- **Solução adotada:** `pause_weight()` — a contribuição sobe até `max_pause` e **cai a zero** acima de `pause_ignore_above` (3s), em vez de saturar. Resultado imediato no mesmo vídeo: o topo passou a ser `"eu"` (energia 1,00), `"o"` (0,87), `"intacto!"` (0,70), `"tô"` (0,74) — todas da mesma frase, que é de fato a fala de impacto.
|
||
- **A virada:** o mesmo dado que era ruído virou o sinal mais útil do documento. Os gaps descartados marcam **onde a tomada recomeçou**. Adicionados `gap_before` e `take_boundary` (>= 3s) em cada segmento; num material de 3min17s isso detectou 6 fronteiras, exatamente onde a médica recomeçava o roteiro.
|
||
- **Descoberta de produto:** o bruto de consultório não é uma tomada — é o mesmo roteiro gravado 3–4 vezes, entremeado de conversa com a equipe (*"Amor, eu tô intacto!"*, *"Posso começar da mastopexia?"*, *"Só clica aí agora na tela."*). Separar tomada válida de conversa é **tarefa de linguagem, não de acústica**: no áudio a conversa é mais solta e mais alta que o texto decorado, então qualquer limiar acústico erra o alvo por construção. Critérios registrados em `.claude/skills/editar-por-voz/SKILL.md`.
|
||
- **Aprendizado:** antes de descartar um sinal por estar poluindo uma métrica, perguntar **para que outra pergunta ele é a resposta**. Aqui a mesma pausa respondia mal "isso foi enfático?" e otimamente "a tomada recomeçou aqui?".
|
||
- **Bug pego pelo próprio teste:** ao adicionar `gap_before`, o teste `test_scales_document_every_word_metric` quebrou — `scales` documentava tudo como métrica de palavra, e `gap_before` é de segmento. `VALUE_SCALES` passou a ser aninhado (`word`/`segment`), com um teste por nível. Um contrato auto-descritivo só vale se um teste garantir que ele não mente.
|
||
- **Estado:** `resolvido` (1294 testes verdes, validado nos dois vídeos reais).
|
||
|
||
---
|
||
|
||
### 2026-08-19 — FCP descartava TODO zoom gerado: `interp` em param vetorial (DTD-válido ≠ FCP-aceito)
|
||
|
||
- **Sintoma:** ao importar o FCPXML no Final Cut, o aviso `This param element was ignored because it does not support the interpolation attribute on its keyframes (.../adjust-transform[1]/param[1])`. O `<param name="scale">` inteiro era **descartado** — ou seja, o zoom simplesmente não existia no projeto importado, sem erro nem falha visível.
|
||
- **Causa raiz:** `add_zoom` escrevia `interp="ease"` em cada `<keyframe>` do parâmetro `scale`. O importador do FCP só aceita `interp` em parâmetros **escalares** (opacidade, volume); `scale` é vetorial (`value="1.25 1.25"`) e admite apenas `curve`.
|
||
- **Por que passou por tudo:** o DTD oficial da Apple declara `<!ATTLIST keyframe interp (linear|ease|easeIn|easeOut) "linear">` — ou seja, `interp` é **DTD-válido em qualquer keyframe**. A validação contra o DTD passava com 100% de sucesso, e o teste `test_add_zoom_creates_keyframed_transform` **afirmava** `interp == 'ease'`, travando o comportamento errado. Só a importação real no FCP revelou.
|
||
- **Solução adotada:** `curve="smooth"` no lugar de `interp` (o `curve` já é `smooth` por padrão no DTD, mas explícito documenta a intenção e protege contra mudança de default). Teste invertido: agora exige `curve == 'smooth'` **e** ausência de `interp`.
|
||
- **Atenção — não confundir com `timept`:** o `<timept>` do `timeMap` (usado em `change_speed`) aceita `interp` normalmente e **não** foi alterado. A restrição é do `<keyframe>` em param vetorial.
|
||
- **Aprendizado (o mais importante desta série):** **DTD-válido ≠ aceito pelo Final Cut.** O DTD descreve a gramática, não as regras semânticas do importador. Para qualquer construção nova de XML, validar contra o DTD é o piso, não o teto — só a importação real fecha a verificação. E um teste escrito a partir do próprio código gerado (em vez de um export real do FCP) apenas congela o erro: reforça o padrão já registrado em 2026-08-15 e 2026-08-18 — **extrair a verdade de um export real do FCP, nunca do que nós mesmos geramos.**
|
||
- **Estado:** `resolvido` no XML (1286 testes verdes, DTD 1.14 válido) — **pendente de nova confirmação de importação no FCP pelo usuário.**
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Primeiro teste em material real: quatro bugs que só apareceram fora dos testes
|
||
|
||
Rodar o pipeline completo num depoimento real (17 min, 4K, 2320 palavras) expôs quatro
|
||
problemas que a suíte inteira, verde, não pegava. Todos vinham de premissas
|
||
que só material sintético sustentava.
|
||
|
||
**1. Teto de 100 MB rejeitava a mídia (14,7 GB).** `MAX_FILE_SIZE` existe para
|
||
documentos que lemos **inteiros na memória** (FCPXML, JSON) — onde um arquivo
|
||
gigante é o próprio ataque. Mídia nunca é carregada assim: ffmpeg e librosa
|
||
leem em fluxo, com timeout e limite de duração próprios. Criado
|
||
`MAX_MEDIA_FILE_SIZE` (32 GB) e `_validate_filepath(..., max_size=)`. Afetava
|
||
também o `detect_beats`, que já rejeitava qualquer WAV acima de ~10 minutos.
|
||
|
||
**2. librosa não lia `.mp4` — faltava a extração de áudio.** O PDF previa
|
||
"FFmpeg para extração do áudio" e eu pulei essa etapa, analisando o container
|
||
direto. Criado `decodable_audio()` em `voice_features.py`: passa adiante
|
||
arquivos de áudio nativos e extrai um WAV mono 16 kHz temporário via ffmpeg
|
||
para containers de vídeo (16 kHz basta — o teto de pitch é 1 kHz).
|
||
|
||
**3. O relatório MENTIA sobre o que rodou.** A tabela dizia "Acoustics: yes"
|
||
enquanto as duas extrações falhavam, porque reportava `features_capability()`
|
||
— se a *biblioteca está instalada* — e não se a *análise funcionou*. Agora o
|
||
JSON carrega um bloco `layers` com o que de fato executou. Fundamental porque
|
||
"fala monótona" e "acústica não carregou" deixam **os mesmos zeros** nos dados:
|
||
sem esse bloco, nem o usuário nem a IA que lê o arquivo conseguem distinguir.
|
||
|
||
**4. Limiar absoluto de ênfase não generaliza — trocado por percentil.** O
|
||
0,85 do PDF eu já havia recalibrado para 0,60 usando dados sintéticos; no
|
||
material real o índice **nunca passou de 0,544** (mediana 0,127), então 0,60
|
||
ainda selecionava nada. Corrigido de vez trocando o mecanismo: `select_peaks`
|
||
pega o **top N%** (padrão 2%), com um piso mínimo apenas como guarda para
|
||
áudio genuinamente plano. Qualquer corte fixo ou inunda um material ou zera
|
||
o outro; percentil entrega um punhado útil nos dois casos.
|
||
|
||
- **Aprendizado central:** limiar calibrado em dado sintético é chute. Duas
|
||
recalibrações erradas seguidas (0,85 → 0,60, ambas inúteis) só pararam
|
||
quando a régua virou **relativa à distribuição do próprio material**.
|
||
Sempre que um número governar seleção, prefira percentil a valor absoluto.
|
||
- **Observação de qualidade ainda aberta:** no top de ênfase real aparecem
|
||
palavras com energia baixíssima (`"No"`, energia 0,07) pontuando alto só
|
||
por virem depois de pausa longa. Em entrevista, pausa longa costuma ser o
|
||
entrevistador falando — não ênfase. O peso `pause_before` (0,15) com
|
||
saturação em 1,5s recompensa o sinal errado; avaliar reduzir o peso ou
|
||
ignorar pausas acima de ~3s.
|
||
- **Estado:** `resolvido` (1286 testes verdes; saída **validada contra o DTD
|
||
oficial FCPXML 1.14 da Apple**) — pendente de importação real no FCP.
|
||
|
||
---
|
||
|
||
### 2026-08-19 — Validador acusava desalinhamento de frame em todo projeto NTSC (falso positivo)
|
||
|
||
- **Sintoma:** o FCPXML gerado a partir de um projeto real 23,976fps acusava `Duration ... is not frame-aligned at 24fps` em clipes que estavam perfeitamente alinhados. Conferido na mão: `1200199/12000s ÷ 1001/24000s = 2398` frames exatos — inteiro, sem resto. O aviso do *arquivo original*, intocado, também era falso.
|
||
- **Causa raiz:** `_check_frame_alignment` fazia `fps_int = int(fps)` e multiplicava os segundos por esse inteiro. A 23,976 (`1001/24000s`), uma duração exatamente alinhada **não** é múltiplo inteiro de "24fps" — então todo projeto NTSC (23,976 / 29,97 / 59,94, ou seja, a maioria) era reportado como quebrado. Detalhe irônico: o docstring de `serialize_xml` já alertava para passar a taxa real "so NTSC projects don't get spurious warnings", mas o `int()` logo adiante destruía a correção.
|
||
- **Solução adotada:** o validador passou a ler o `frameDuration` exato do formato que a `<sequence>` referencia (`_document_frame_duration`) e a comparar com aritmética de `Fraction` — alinhado é quando `duração / frameDuration` tem denominador 1. A mensagem também passou a nomear a taxa real (`23.976fps`), não uma arredondada.
|
||
- **Aprendizado:** nunca converter timebase para inteiro/float para checar alinhamento — o projeto inteiro é construído sobre tempo racional justamente por isso (`TimeValue`), e a validação precisa seguir a mesma regra que a escrita. Falso positivo em validador é pior que ausência de validação: ensina o usuário a ignorar avisos, e aí o aviso verdadeiro passa batido.
|
||
- **Estado:** `resolvido` (3 testes de regressão em `TestNTSCFrameAlignment`, incluindo um que garante que desalinhamento **real** continua sendo detectado).
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Divisão IA × sistema: a IA devolve DECISÕES, nunca XML; e tudo em tempo de origem
|
||
|
||
- **Contexto:** definido como a inteligência entra no editor automático. A ideia inicial era mandar o JSON para um serviço externo que devolveria o material já editado; evoluiu para fazer a decisão aqui dentro, com um skill versionado no repo (`.claude/skills/editar-por-voz/SKILL.md`).
|
||
- **Decisão 1 — o que a IA NÃO faz:** silêncio, vícios de linguagem, extração acústica, índice de ênfase, diarização e **geração de FCPXML** continuam determinísticos. Tudo que tem resposta objetiva (um limiar decide) não ganha nada indo para um modelo — só custo, latência e perda de reprodutibilidade. Geração de XML em particular é matemática de tempo racional frame a frame: modelo gerando XML produz arquivo sutilmente quebrado.
|
||
- **Decisão 2 — a IA devolve uma lista de ações, não mídia editada.** Contrato em `fcpxml/voice_actions.py` (`kind`/`start`/`end`/`params`/`reason`). Motivos: dá para **validar** antes de aplicar; é **reprodutível** (mesma lista → mesmo FCPXML); e o usuário **revisa** antes de qualquer coisa tocar a timeline. `parse_actions` trata a lista como entrada não confiável — uma linha malformada é reportada e pulada, nunca derruba a edição inteira.
|
||
- **Decisão 3 (a que evita a pior classe de bug) — todos os tempos em segundos da mídia ORIGINAL.** Cortes deslocam tudo que vem depois: se as decisões viessem em tempo pós-corte, cada destaque cairia silenciosamente no frame errado assim que um corte fosse adicionado. `resolve_actions`/`shift_after_cuts` resolvem o deslocamento na hora de aplicar, e ação que aponta para material removido é **descartada e reportada**, nunca deslizada para o conteúdo vizinho.
|
||
- **Bug pego pelo próprio relatório:** título e marcador não apareciam no XML. A causa era `str(TimeValue)` devolvendo o `__repr__` (`TimeValue(3/1s = 3.000s)`) em vez da string racional — o método certo é `to_fcpxml()`. Só foi visível na hora porque o handler reporta o que **não** conseguiu colocar, com a exceção real, em vez de aplicar em silêncio.
|
||
- **Aprendizado:** todo handler que aplica uma lista de operações deve relatar as três categorias — aplicadas, descartadas e rejeitadas. Um handler que só conta sucessos transforma bug em "não aconteceu nada" e some do radar. Nunca converter `TimeValue` para string com `str()`: sempre `to_fcpxml()`.
|
||
- **Estado:** `resolvido` (1276 testes verdes; aplicação validada contra FCPXML real, incluindo o `examples/sample.fcpxml`) — **pendente de confirmação de importação real no FCP pelo usuário.**
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Limiar de ênfase de 0,85 do PDF era inalcançável: média ponderada não chega lá
|
||
|
||
- **Sintoma:** com a timeline de voz montada, o campo `peak_count` vinha **sempre 0**. Nem a palavra mais alta e mais aguda de um trecho de demonstração ("segurança", energia normalizada 1,0 e pico de tom) era marcada como candidata a punch-in.
|
||
- **Investigação:** medido o teto real da fórmula. `compute_emphasis` é uma **média ponderada** de cinco fatores normalizados (energia 0,30 / tom 0,25 / ritmo 0,20 / pausa 0,15 / duração 0,10). Para o resultado passar de 0,85 seria preciso que quase todos os cinco estivessem no máximo **simultaneamente** — o que a fala real não produz: uma palavra com pausa dramática antes dela quase por definição não tem desvio de ritmo alto. Valores medidos: todos os fatores no máximo = 1,00; pico realista (energia e tom máximos, pausa longa, palavra longa, ritmo normal) = **0,80**; pico comum = **0,66**.
|
||
- **Causa raiz:** o 0,85 veio literalmente da especificação do PDF (`SE emphasis > 0.85 ENTÃO aplicar punch-in`), que pressupunha outra normalização — provavelmente um índice de máximo, não de média. Copiar a constante sem conferir a distribuição da nossa fórmula tornou o recurso inerte.
|
||
- **Solução adotada:** padrão recalibrado para **0,60** (`DEFAULT_VOICE_ANALYSIS_CONFIG` em `fcpxml/model_manager.py`), com o porquê comentado no próprio código. O texto da tela e a descrição da tool passaram a dizer que picos reais ficam na faixa 0,55–0,80 — para que ninguém volte a subir o valor achando que "quanto maior, mais seletivo" sem saber onde fica o teto.
|
||
- **Aprendizado:** constante numérica herdada de especificação externa precisa ser **validada contra a distribuição real da fórmula implementada** antes de virar padrão. O sintoma aqui foi silencioso (nenhum erro, nenhum teste vermelho — só um recurso que nunca disparava), e só apareceu porque a saída de demonstração foi inspecionada com dados realistas. Vale gerar uma amostra de verdade e olhar os números sempre que um limiar governar um comportamento.
|
||
- **Estado:** `resolvido` (padrão 0,60 verificado: o mesmo trecho passou a marcar corretamente 1 pico).
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Configurações de análise de voz: uma fonte de verdade só (config.json do backend), não UserDefaults
|
||
|
||
- **Contexto:** Fases 2–3 da arquitetura de análise de voz (features acústicas + índice de ênfase) e a tela de configurações pedida pelo usuário para regular limiares de energia, ênfase e emoção.
|
||
- **Decisão:** os parâmetros de análise ficam **só** em `~/.fcp-mcp-server/config.json` (via `model_manager.load/save_voice_analysis_config`), diferente do padrão `@AppStorage`/UserDefaults usado por `CaptionsView.swift` para estilo de legenda. Motivo: estilo de legenda é preferência de UI que só é lida na hora de montar os argumentos de uma chamada; já os limiares de análise são lidos **pelo próprio motor** (`handle_analyze_voice_features`) mesmo quando a análise é disparada fora do app (tool MCP direta, script). Duplicar em UserDefaults criaria duas verdades divergentes — a tela mostraria um valor e a análise usaria outro.
|
||
- **Onde:** `fcpxml/model_manager.py` (`DEFAULT_VOICE_ANALYSIS_CONFIG`, `load/save_voice_analysis_config`), comandos `voice_analysis`/`set_voice_analysis` em `admin/models_api.py`, tools MCP `get/save_voice_analysis_config`, tela `MacApp/Sources/VoiceAnalysisView.swift`.
|
||
- **Cuidado que rendeu teste:** `load_voice_analysis_config` precisa devolver uma **cópia** dos defaults — a primeira versão devolvia o dict aninhado `emphasis_weights` por referência, e quem mutasse o resultado corrompia o default do módulo para o resto do processo. Coberto por `test_defaults_are_not_shared_mutable_state`.
|
||
- **Aprendizado:** ao adicionar configuração nova, perguntar "quem lê esse valor?" — se for o motor Python, ele mora no config.json do backend; se for só a montagem de argumentos na UI, UserDefaults serve. E todo default composto (dict/lista) devolvido de um `load_*` precisa ser cópia, nunca a constante do módulo.
|
||
- **Estado:** `resolvido` (1201 testes verdes, lint zero erros, ciclo salvar→reler validado pelo bridge) — **a renderização visual da tela no app não pôde ser confirmada por captura de tela** (janela do app em outro Space); a aba foi confirmada via árvore de acessibilidade.
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Diarização de locutor já existia pronta e testada, mas órfã (nenhuma tool MCP a expunha)
|
||
|
||
- **Contexto:** início da implementação da "Arquitetura de Análise de Voz para Editor Automático" (locutor, energia, pitch, ênfase, motor de regras → FCPXML), especificada num PDF trazido pelo usuário. Plano salvo em `~/.claude/plans/volumes-merongo-downloads-arquitetura-a-mossy-micali.md`.
|
||
- **Descoberta:** `fcpxml/diarize.py` (diarização via `pyannote/speaker-diarization-3.1`, com `diarization_capability`, `diarize`, `assign_speakers`, `build_speakers`) e `tests/test_diarize.py` já existiam completos e passando, mas nenhuma tool em `server.py` chamava esse módulo — código morto do ponto de vista de uso real. `model_manager.py` também já tinha `load_hf_token`/`save_hf_token` prontos para o token do HuggingFace exigido pelo pyannote.
|
||
- **Decisão de arquitetura:** usar pyannote (já é dependência declarada em `pyproject.toml` como extra `diarization`) para diarização bruta por turno, em vez de treinar/rodar SpeechBrain ECAPA-TDNN do zero como o PDF sugeria em primeiro lugar. ECAPA-TDNN fica reservado para uma fase futura (reconhecimento de pessoa cadastrada por cima dos turnos já diarizados), evitando duas libs pesadas resolvendo o mesmo problema.
|
||
- **Onde:** nova tool `diarize_media` em `server.py` (handler `handle_diarize_media`), reaproveitando `diarize.py` sem alterá-lo; cache em `_diarization.json` ao lado da mídia, seguindo exatamente o padrão de `_transcript.json`/`_beats.json` já usados por `transcribe_media`/`detect_beats`.
|
||
- **Aprendizado:** antes de implementar uma fase "do zero" a partir de uma spec externa, vale sempre grepar o `fcpxml/` por nomes prováveis (`diarize`, `speaker`, etc.) — pode já existir motor pronto e testado, só faltando a camada de exposição via MCP tool.
|
||
- **Estado:** `resolvido` (tool nova + testes, 1154 testes verdes, lint zero erros).
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Terceira linha "colando" na linha de ênfase: o gap simétrico não bastava para o itálico
|
||
|
||
- **Sintoma:** no bloco de composição "phrase" (uma palavra de ênfase em itálico grande, cercada por linhas de corpo), a linha logo abaixo da ênfase aparecia quase tocando o texto — mesmo com o slider "Espaçamento entre linhas" da tela de legendas dinâmicas configurado.
|
||
- **Investigação:** reproduzido o cálculo de `compose_sentence` fora do FCP com a frase exata do usuário ("de" / "encontrar" / "roupa,") — o gap entre as caixas de tinta dava **exatamente 8pt nos dois lados** (acima e abaixo da ênfase), confirmando que o valor do slider chega corretamente até o layout (`CaptionsView.swift` → `admin/models_api.py` → `server.py` → `compose_sentence`). Não era bug de configuração não aplicada.
|
||
- **Causa raiz:** a caixa de tinta medida (`ink_extent`, `fcpxml/text_layout.py`) é vertical e simétrica, mas a inclinação itálica do Playfair Display faz os traços "vazarem" visualmente para baixo além do que a métrica vertical mede — então o mesmo gap numérico lê como mais apertado abaixo da linha de ênfase do que acima dela.
|
||
- **Onde:** `fcpxml/text_layout.py::compose_sentence` (função `pair_gap` nova) e constante `_EMPHASIS_ITALIC_CUSHION_RATIO`.
|
||
- **Solução adotada:** gap por par de linhas em vez de um valor único para todo o bloco — quando a linha anterior é a de ênfase, soma-se uma folga extra proporcional ao seu `font_size` (`ratio = 0.06`, ~14pt a 230pt) só naquele par; todos os outros pares continuam usando exatamente o `line_gap` do usuário. A folga entra tanto no teste de "cabe na banda" quanto na centralização da pilha, senão o bloco vazaria do box.height ou ficaria descentrado.
|
||
- **Aprendizado:** medir a caixa de tinta (ascendente/descendente reais) resolve colisão entre glifos retos, mas não captura o "peso visual" da inclinação itálica — para faces itálicas grandes ao lado de corpo reto, a folga simétrica por ink-box ainda pode ler como assimétrica no render final. Um cushion proporcional ao tamanho da fonte, aplicado só no lado que precisa, corrige sem inflar o espaçamento nos pares que já estavam certos.
|
||
- **Estado:** `resolvido` no cálculo (1150 testes verdes, valores conferidos numericamente) — **pendente de confirmação visual real no FCP pelo usuário**.
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Build Out desligado libera toda a janela de "Per Object" para o Build In terminar de revelar
|
||
|
||
- **Sintoma:** em blocos de palavras curtos, a animação de entrada do título ("Text"/Basic Text template) às vezes cortava antes de terminar de revelar a palavra — o corte pro próximo bloco acontecia no meio do reveal.
|
||
- **Causa raiz:** `Apply Speed = "2 (Per Object)"` (já presente em `_TEXT_TITLE_PARAMS`) faz o FCP comprimir/esticar a animação **inteira** do template (build in + build out) para caber exatamente na duração real do `<title>`. Com as duas fases ativas, build in e build out disputam a mesma janela comprimida — em clipes curtos, build in não tinha tempo suficiente.
|
||
- **Onde:** `fcpxml/writer.py::_TEXT_TITLE_PARAMS` (`FCPXMLModifier._make_text_title_clip`).
|
||
- **Descoberta do `key`:** não havia como adivinhar — o usuário desmarcou manualmente "Build Out" no Inspector de um título "Text" isolado no FCP e exportou o FCPXML. O override só aparece no XML quando o valor difere do default do template (por isso um export sem a alteração real não mostra o `param` nenhum). Valor capturado: `<param name="Build Out" key="9999/10000/2/102" value="0"/>`.
|
||
- **Solução adotada:** `Build Out` adicionado como primeiro item de `_TEXT_TITLE_PARAMS`, sempre `"0"` (desligado) em todo título gerado. Não foi necessário nenhum parâmetro extra de velocidade — desligar o build out já entrega toda a janela "Per Object" comprimida ao build in, que é o efeito de "sempre acelerado" pedido pelo usuário.
|
||
- **Aprendizado:** pra descobrir o `key` de um checkbox/param publicado num template Motion, o export de calibração **precisa** ter o valor realmente alterado no Inspector antes de exportar — reexportar o projeto sem mexer em nada não revela nada (o FCP só escreve params que divergem do default). Reforça o padrão já registrado em 2026-08-15: nunca adivinhar `key`, sempre extrair de um export real.
|
||
- **Estado:** `resolvido` no XML (1 novo param verificado no writer) — **pendente de confirmação de importação real no FCP pelo usuário**.
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Espaço de coordenadas do modelo de título: tamanho E posição
|
||
|
||
- **Sintoma (1ª metade):** o bloco caía exatamente onde o preview mostrava, mas
|
||
o texto renderizava cerca de **metade** do tamanho configurado — com 213pt a
|
||
ênfase deveria ocupar ~87% da largura do quadro e ocupava ~35%.
|
||
- **Sintoma (2ª metade, causado pela primeira correção):** ao dobrar só o
|
||
`fontSize`, o tamanho ficou certo e as **linhas passaram a se sobrepor** — o
|
||
bloco mantinha o espalhamento antigo com o dobro de letra dentro.
|
||
- **Causa raiz:** a calibração de tamanhos veio do export manual feito com o
|
||
modelo **"Essencial - Título"**, cujo espaço de coordenadas é o canvas de
|
||
pontos (metade do quadro). Esse modelo nunca renderizou quando gerado por nós
|
||
(entrada de 2026-08-17), então o writer passou a emitir o **"Basic Text >
|
||
Text" (Text.moti)** — cujo espaço é o **quadro inteiro** (2160×3840). Tudo o
|
||
que esse modelo lê está nesse espaço: `fontSize`, `kerning` **e** `Position`.
|
||
- **Onde:** `fcpxml/text_layout.py` (`TEXT_TEMPLATE_FONT_SCALE`,
|
||
`position_param`), `fcpxml/writer.py`, `fcpxml/models.py`
|
||
(`DynamicSubtitleConfig.text_scale`), `server.py`,
|
||
`MacApp/Sources/CaptionsView.swift`.
|
||
- **Tentativas que falharam:** (a) procurar a diferença nos params do título
|
||
(`Auto-Shrink`, margens, `Layout Method`) — todos idênticos ao export manual;
|
||
(b) **converter só o `fontSize`** — corrigiu o tamanho e quebrou o
|
||
espaçamento, que é o erro registrado aqui como aprendizado principal.
|
||
- **Solução adotada:** um único fator, `TEXT_TEMPLATE_FONT_SCALE = 2.0`,
|
||
aplicado ao `fontSize`, ao `kerning` **e** à `Position` na saída. O layout
|
||
continua medindo em pontos de canvas — toda constante calibrada depende
|
||
disso — e a conversão acontece só na emissão, que é a única forma de os dois
|
||
andarem juntos. Exposto como `text_scale`.
|
||
- **Aprendizado:** um espaço de coordenadas é indivisível. Converter metade das
|
||
grandezas que vivem nele é **pior** do que não converter nenhuma: sem
|
||
conversão o erro é uniforme e parece "só um ajuste de tamanho"; pela metade,
|
||
tipo e espaçamento se descolam e o defeito muda de cara. Ao trocar o modelo
|
||
de título, toda constante calibrada contra o modelo antigo vira suspeita — a
|
||
posição foi re-verificada em 2026-08-17 e o tamanho não, e o bug ficou
|
||
invisível porque "está no lugar certo" parece "está certo".
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-18 — Preview das legendas dinâmicas desproporcional ao render do FCP
|
||
|
||
- **Sintoma:** o painel "Legendas Dinâmicas" mostrava um preview que não batia
|
||
com o resultado no Final Cut: linhas de apoio coladas nas bordas do quadro,
|
||
espaçamento entre linhas errado, a banda do bloco invisível e as cores
|
||
aplicadas de forma trocada. O formulário de controles também estava confuso,
|
||
com blocos de texto explicativo a cada slider.
|
||
- **Causa raiz:** `SubtitlePreviewView` era um desenho aproximado feito à mão
|
||
(VStack + Spacer, gap fixo de 14pt, canvas mapeado só na altura) e não
|
||
reproduzia `compose_sentence` de `fcpxml/text_layout.py`. Além disso, o app
|
||
mandava `inactive_color`, que em `granularity="phrase"` o backend **nunca
|
||
usa** — o preview pintava a ênfase com uma cor que o FCP ignoraria.
|
||
- **Onde:** `MacApp/Sources/SubtitlePreviewView.swift`,
|
||
`MacApp/Sources/CaptionsView.swift`, `server.py`
|
||
(`handle_generate_dynamic_subtitles`), `admin/models_api.py` (docstring).
|
||
- **Tentativas que falharam:** apenas re-escalar as fontes do preview — a
|
||
posição continuava errada, porque o desalinhamento vinha do *stagger* e do
|
||
gap, não do tamanho.
|
||
- **Solução adotada:** o preview passou a espelhar a geometria do backend —
|
||
canvas 1080×1920 pt (largura inclusa), margem lateral de 4%,
|
||
`REFERENCE_BLOCK_LINE_GAP` (8 pt), `REFERENCE_STAGGER_RATIO` (0.8) com lados
|
||
alternados a partir da esquerda, corpo em Bold, empilhamento sobre a tinta
|
||
(cap-height + descida) e compensação do centro do frame do `Text`. O
|
||
backend ganhou `emphasis_color` (padrão = `active_color`), e a UI foi
|
||
reagrupada em "Linhas de apoio" / "Palavra de ênfase" com sliders em
|
||
`LabeledContent` e explicação em tooltip.
|
||
- **Aprendizado:** um preview só é útil se for derivado das MESMAS constantes
|
||
do gerador. Quando o preview é redesenhado "de olho", ele vira uma segunda
|
||
fonte de verdade que diverge silenciosamente. E todo controle exposto na UI
|
||
precisa existir de fato no caminho de código que ele diz configurar.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Garantir que dois blocos nunca se sobreponham: empilhar pela TINTA real, não pela cap-height
|
||
|
||
- **Sintoma:** na composição progressiva, a cedilha de "começar" (Playfair
|
||
Display Medium Italic, 230pt) invadia a linha de apoio logo abaixo. As
|
||
caixas "lógicas" não se cruzavam — as renderizadas, sim.
|
||
- **Causa raiz:** o empilhamento usava altura nominal `font_size * 0.75`
|
||
(cap-height). Numa serifada de display itálica os acentos sobem a 1,007em e
|
||
os descendentes descem a -0,241em: a tinta real ocupa quase o dobro da
|
||
cap-height, e a folga nominal some.
|
||
- **Onde:** `fcpxml/font_metrics.py` (`VERTICAL_METRICS`),
|
||
`fcpxml/text_layout.py` (`ink_extent`, `compose_sentence`, `PlacedBlock`).
|
||
- **Tentativas que falharam:** aumentar `line_gap` — afasta as linhas em todos
|
||
os casos e perde o bloco compacto da referência, sem garantir nada: basta
|
||
uma fonte com acentos mais altos para colidir de novo.
|
||
- **Solução adotada:** métricas verticais reais extraídas das fontes
|
||
(`ascent`/`descent` da caixa de linha que o FCP centra na Position, mais os
|
||
extremos de tinta por classe de glifo: caixa alta, ascendente, x-height,
|
||
acento maiúsculo/minúsculo, descendente). `ink_extent()` calcula o topo e a
|
||
base da tinta DO TEXTO em questão; `compose_sentence` empilha essas caixas
|
||
borda a borda com folga fixa. Não-sobreposição vira propriedade da
|
||
aritmética, não de um fator de segurança. Fonte sem métricas medidas usa um
|
||
fallback com 8% de folga extra.
|
||
- **Aprendizado:** medir largura resolve colisão lado a lado; colisão entre
|
||
linhas exige medir altura de tinta — e ela depende dos caracteres da linha,
|
||
não só do corpo da fonte.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Legendas saíam palavra a palavra centradas, e não como a composição progressiva diagramada da referência
|
||
|
||
- **Sintoma:** o usuário mandou o reel de referência (@fernandoluz.d) e disse
|
||
"elas devem aparecer assim": `[que vão] / [melhorar] / [sua legenda]` — um
|
||
bloco por trecho, palavra-chave grande em serifada itálica, complementares
|
||
pequenas em grotesca, linhas escalonadas. O gerador entregava um `<title>`
|
||
por PALAVRA, todos na mesma família, cada linha centrada.
|
||
- **Causa raiz:** `layout_sentence` empacota palavra a palavra e centra cada
|
||
linha; o `rhythm` variava tamanho/cor por índice (indigo/amarelo/cinza), não
|
||
por papel semântico da palavra. Nenhum dos dois produz a diagramação.
|
||
- **Onde:** `fcpxml/text_layout.py` (`compose_sentence`, `pick_emphasis_index`,
|
||
`PlacedBlock`), `fcpxml/models.py` (looks editoriais, `granularity`),
|
||
`fcpxml/writer.py` (emissão por unidade), `fcpxml/font_metrics.py`,
|
||
`server.py` (parâmetros da tool).
|
||
- **Tentativas que falharam:** tentar aproximar o visual só trocando os
|
||
tamanhos do `rhythm` — sem agrupar as palavras de apoio num único título, o
|
||
resultado continua sendo legenda corrida.
|
||
- **Solução adotada:** modo `granularity="phrase"` (padrão): a frase vira
|
||
linhas — apoio antes, palavra-chave sozinha, apoio depois —, uma linha por
|
||
`<title>`, entrando no instante da sua primeira palavra e todas limpando
|
||
juntas. Destaque em Playfair Display Medium Italic (métricas reais extraídas
|
||
da fonte instalada e embutidas em `font_metrics`), apoio em Helvetica Neue
|
||
Bold, tudo branco, linhas escalonadas por `REFERENCE_STAGGER_RATIO`. O modo
|
||
antigo continua disponível em `granularity="word"`.
|
||
- **Aprendizado:** o destaque é semântico, não posicional — escolher a palavra
|
||
por índice num ciclo nunca reproduz uma diagramação. E toda fonte nova exige
|
||
métricas reais antes de entrar no layout: sem elas, a medição de largura
|
||
erra e duas linhas colidem.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Importação recusada: `id` do `<text-style-def>` derivado do texto da legenda não é um XML Name válido
|
||
|
||
- **Sintoma:** ao gerar títulos/legendas, a validação de DTD falhava com
|
||
`Syntax of value for attribute ref of text-style is not valid` +
|
||
`Syntax of value for attribute id of text-style-def is not valid`, e o Final
|
||
Cut recusava o arquivo na importação.
|
||
- **Causa raiz:** `_make_text_title_clip` montava o id como
|
||
`f"{name}_ts0"`, e `name` vem do texto da legenda
|
||
(`"3 coisas que você precisa saber - Text"`). No DTD, `id` é do tipo `ID` e
|
||
`ref` do tipo `IDREF`: o valor precisa ser um **XML Name** — sem espaços,
|
||
sem acentos, nunca começando por dígito. Os três casos apareciam de uma vez
|
||
em texto português.
|
||
- **Onde:** `fcpxml/writer.py` (`_make_text_title_clip`, novo
|
||
`_unique_text_style_id`).
|
||
- **Tentativas que falharam:** confiar em `_sanitize_xml_value`, que protege
|
||
*conteúdo* de atributo (CDATA) mas não impõe as regras de XML Name.
|
||
- **Solução adotada:** `_unique_text_style_id()` — dobra o texto para ASCII
|
||
(NFKD), troca tudo que não seja `[A-Za-z0-9_.-]` por `_`, prefixa com `ts_`
|
||
(garante início por letra, inclusive quando o texto é só CJK/emoji e o slug
|
||
fica vazio) e sufixa um contador conferido contra um cache de ids do
|
||
documento, mantendo unicidade document-wide sem varrer a árvore por título.
|
||
- **Aprendizado:** todo atributo do tipo `ID`/`IDREF` no FCPXML precisa ser
|
||
gerado, nunca derivado de texto do usuário. Sanitizar valor de atributo e
|
||
sanitizar identificador são problemas diferentes.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Títulos ("Essencial - Título"/"Título Básico") nunca apareciam no FCP; o template que renderiza é o "Text" (Basic Text)
|
||
|
||
- **Sintoma:** título gerado no início do vídeo ficava invisível ou "sumia da
|
||
timeline" (mas continuava na lista de clipes), mesmo com posição/offset
|
||
aparentemente corretos. Com o template animado, o texto não desenhava; com o
|
||
estático, o título caía antes do in-point do clipe e o FCP o descartava.
|
||
- **Causa raiz (duas, no mesmo ciclo):**
|
||
1. Os dois templates que usávamos — "Essencial - Título"
|
||
(`Essential Title.moti`) e "Título Básico" (`Bumper:Opener/Basic
|
||
Title.moti`) — **não resolvem para um template desenhável** no FCP. A
|
||
importação é silenciosa: nada aparece, sem erro. É o MESMO modo de falha
|
||
silenciosa já registrado duas vezes antes nesta sessão (uid fabricado).
|
||
2. No caminho `animated=False`, o offset era gravado como **relativo**
|
||
(`0s`) em vez de coordenadas de mídia-fonte (`start` do clipe-pai +
|
||
relativo). O FCP lê `0s` como "0s da mídia", antes do in-point do clipe
|
||
(`start="220062843/24000s"`), então o título nunca cai sobre o vídeo.
|
||
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_BASIC_TITLE_UID`,
|
||
`_TEXTO_TITLE_PARAMS`, `_make_texto_title_clip`, `_make_basic_title_clip`,
|
||
`generate_dynamic_subtitles`.
|
||
- **Como o usuário resolveu:** criou dois títulos à mão no FCP e exportou
|
||
(`teste.fcpxmld` e `posição.fcpxmld`, FCP 1.14 em inglês). Ambos usam o
|
||
template **"Text"** (`uid=".../Titles.localized/Basic
|
||
Text.localized/Text.localized/Text.moti"`, `name="Text"`), `start` fixo
|
||
`86486400/24000s`, e um bloco de `<param>` com margens/alinhamento/`Custom
|
||
Speed` (com `<keyframeAnimation>` de tempos nominais constantes). A posição
|
||
é o param `Position` (chave `.../13260/3296672360/1/100/101`) com valor
|
||
estático `"x y"` — **sem** `<adjust-transform>`.
|
||
- **Solução adotada:** substituir os dois templates por um único "Text"
|
||
(Basic Text), copiado verbatim dos exports reais. Novo
|
||
`_make_text_title_clip` + `_ensure_text_title_effect` + `add_text_title`.
|
||
`generate_dynamic_subtitles` agora usa sempre o "Text" e grava offset em
|
||
coordenadas de mídia-fonte (`start` do pai + relativo) para **todos** os
|
||
títulos; posição via param `Position`, não `adjust-transform`. Removidos os
|
||
templates/código morto "Essencial - Título"/"Título Básico".
|
||
- **Aprendizado:** o único teste que vale para template de título é um
|
||
roundtrip de importação REAL no FCP — e o padrão-ouro é o export que o
|
||
próprio FCP produz quando o usuário adiciona o título à mão. Quando isso
|
||
existir, copiar **verbatim** (uid, params, `start`) e não "simplificar"
|
||
nada. Título conectado SEMPRE usa `start` do clipe-pai como origem do
|
||
offset, nunca `0s`.
|
||
- **Estado:** `resolvido` no XML — pendente de confirmação de importação real
|
||
no FCP pelo usuário.
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Legendas dinâmicas sobrepondo entre clipes: título conectado NÃO é aparado pelo out-point do clipe-pai
|
||
|
||
- **Sintoma:** ao gerar, blocos de legenda de um clipe continuavam na tela por
|
||
cima das legendas do clipe seguinte — duas frases desenhadas ao mesmo tempo.
|
||
- **Causa raiz:** um `<title>` conectado a um `asset-clip` **não** é cortado
|
||
pelo fim do clipe-pai; o FCP segue desenhando sobre o que vier depois. O
|
||
último bloco de cada clipe terminava no `end` da última palavra do Whisper —
|
||
que frequentemente ultrapassa o corte — e palavras cujo `start` já caía
|
||
depois do corte também eram emitidas.
|
||
- **Onde:** `fcpxml/writer.py`, `generate_dynamic_subtitles()`.
|
||
- **Tentativas que falharam:** comprimir a palavra tardia para o último frame
|
||
do clipe (empilhava vários títulos no mesmo frame e na mesma lane).
|
||
- **Solução adotada:** descartar palavras que começam depois da duração do
|
||
clipe-pai e limitar (`clamp`) o fim de cada bloco a essa duração. Testes em
|
||
`tests/test_dynamic_subtitles.py::TestClipBoundaryClamping`.
|
||
- **Aprendizado:** nada anexado a um clipe pode sobreviver ao próprio clipe;
|
||
tempos vindos do Whisper precisam sempre ser recortados pela janela do clipe,
|
||
não só filtrados pelo `start`.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Palavras sobrepostas na tela: largura de texto ESTIMADA subestimava; solução foi embutir as métricas reais das fontes
|
||
|
||
- **Sintoma:** o usuário reportou que as palavras apareciam **todas sobrepostas** no Final
|
||
Cut. A verificação automática do XML dizia "0 sobreposições" — porque conferia contra a
|
||
minha própria estimativa de largura, não contra o que o FCP realmente desenha. Verificar
|
||
um cálculo com o mesmo cálculo não verifica nada.
|
||
- **Duas causas, uma de processo e uma técnica:**
|
||
1. **Processo:** o arquivo que o usuário importou era a versão anterior, gerada antes do
|
||
posicionamento existir (todas as palavras em `0 -45.4935`, literalmente no mesmo
|
||
ponto). Como o caminho de saída é sempre o mesmo (`_dynamic_subtitles.fcpxmld`), é
|
||
fácil reabrir a versão velha sem perceber.
|
||
2. **Técnica, e real:** `measure_text` estimava a largura por uma tabela AFM genérica de
|
||
Helvetica. Comparada com as fontes reais do macOS, o erro ia de **-1,4% a +7,0%** — e
|
||
o caso negativo é fatal: uma largura menor que a real faz duas palavras encostarem.
|
||
"dificuldade" a 170pt media 867,8 contra 880,1 reais.
|
||
- **Investigação:** as variantes reais (`Helvetica.ttc`, `HelveticaNeue.ttc`) diferem entre
|
||
si em até **21,5% do em** em alguns glifos — Light, Regular, Neue e Light Italic têm
|
||
avanços distintos. Nenhuma tabela única serve para todas.
|
||
- **Solução adotada:** extrair os avanços reais das fontes do sistema com `fontTools` e
|
||
**embutir como tabela** em `fcpxml/font_metrics.py` (9 variantes × 143 glifos). O
|
||
`fontTools` foi usado só na geração, via `uv run --with` — **não** virou dependência do
|
||
projeto, e o layout não lê fonte em runtime, então o resultado é idêntico em qualquer
|
||
máquina. Erro medido depois: **+2,0% constante** (só a margem de segurança), nunca
|
||
abaixo. Margem de segurança reduzida de 1,03 para 1,02, já que a medida agora é exata.
|
||
Espaço entre palavras passou de 5 pontos fixos para 14% do corpo da fonte maior.
|
||
- **Ferramenta que destravou o problema:** gerar um **preview HTML** que desenha as
|
||
palavras nas posições calculadas, com as fontes e tamanhos reais. Permite ver o layout
|
||
sem reimportar no FCP a cada tentativa. Nota: o painel de preview bloqueia JavaScript
|
||
(CSP), então o HTML precisa ser estático, com as posições já escritas no `style` de cada
|
||
elemento — nada de calcular no navegador.
|
||
- **Aprendizado:** **nunca validar uma saída com a mesma estimativa que a produziu.** Se o
|
||
código estima larguras, a verificação tem de medir contra a fonte real, senão ela apenas
|
||
confirma o próprio erro. E quando existe uma fonte de verdade acessível (o arquivo de
|
||
fonte no disco), extrair os dados dela e embutir sai mais barato e mais exato do que
|
||
qualquer aproximação — sem custo de dependência.
|
||
- **Estado:** `resolvido` (layout aprovado pelo usuário no preview HTML; confirmação de
|
||
importação no FCP pendente)
|
||
|
||
### 2026-08-17 — Calibrar coordenadas de título pedindo um export ao usuário, em vez de adivinhar a escala
|
||
|
||
- **Sintoma:** para posicionar cada palavra na tela era preciso escrever o param
|
||
`Posição` do template Essential Title, mas não havia como saber a unidade nem a escala.
|
||
O único valor existente no código era `0 -45.4935`, idêntico em todos os títulos —
|
||
variância zero, portanto nada a inferir.
|
||
- **Risco reconhecido antes de agir:** este mesmo arquivo já registra (entrada de
|
||
2026-08-14) que um `<param name="Position">` **fabricado** foi removido justamente por
|
||
ser inadivinhável, e que nem o DTD nem os testes unitários pegam `key`/valor inválido.
|
||
Chutar aqui reproduziria a falha silenciosa pela terceira vez.
|
||
- **Solução adotada:** em vez de estimar, pedir ao usuário um export do Final Cut com
|
||
palavras posicionadas à mão. Ele enviou `Exemplo Letra.fcpxmld` (projeto 2160x3840) com
|
||
a frase "Toda a minha vida, assim," — cinco palavras posicionadas no Inspetor, o resto
|
||
no default. Três fatos saíram dos números:
|
||
1. **Posição usa a mesma unidade que `fontSize`.** As distâncias centro a centro na
|
||
linha 1 (254,06 e 265,08) batem com a soma das meias-larguras calculadas pelas
|
||
métricas Helvetica nos tamanhos 170/128/151 (245,1 e 265,0). Uma unidade diferente
|
||
apareceria como razão constante; não há nenhuma.
|
||
2. **O canvas é 1080x1920 pontos** — metade do quadro, porque o FCP posiciona em pontos
|
||
sobre mídia 2x. A linha 1 vai de -460,3 a +495,7, preenchendo essa largura com
|
||
margens pequenas, exatamente como o quadro de referência aparenta.
|
||
3. **y cresce para cima**: "vida," (linha 2) em -233,65 contra a linha 1 em ~-101.
|
||
Também desambiguou **qual** param responde ao Inspetor: as cinco palavras carregam
|
||
valores distintos em `9999/10085/10086/1/100/101`, enquanto `.../2/358` permanece
|
||
`0 69` em todos os títulos do arquivo.
|
||
- **Validação:** o layout recalculado reproduz o do usuário — espaçamento entre linhas
|
||
131,8 contra 132,7 (erro de 0,7%) e o y das duas linhas coincidindo na casa decimal.
|
||
Os valores viraram testes (`tests/test_text_layout.py`,
|
||
`TestCalibrationAgainstRealExport`), então qualquer regressão de escala falha.
|
||
- **Descoberta colateral:** o espaçamento entre linhas do usuário (132,65) é menor que o
|
||
corpo da maior fonte da linha (170), o que só fecha porque o texto ocupa a altura de
|
||
caixa-alta (~0,75 do corpo), não o em-box inteiro. Usar o em-box afastaria as linhas
|
||
~40% a mais do que ele fez.
|
||
- **Aprendizado:** quando um valor não é derivável dos dados em mãos, **pedir um artefato
|
||
de calibração ao usuário custa minutos e elimina a adivinhação**. Cinco palavras
|
||
arrastadas à mão renderam escala, unidade, orientação do eixo, espaçamento e a paleta —
|
||
tudo o que três rodadas anteriores de chute não conseguiram. E vale desconfiar de
|
||
qualquer constante que apareça idêntica em todas as instâncias de um arquivo: variância
|
||
zero significa que ela nunca foi exercitada, não que esteja certa.
|
||
- **Estado:** `resolvido`
|
||
|
||
### 2026-08-17 — Legendas dinâmicas invisíveis porque eram geradas como CAPTION, não como TÍTULO — e a "referência verificada" do código nunca tinha funcionado
|
||
|
||
- **Sintoma:** legendas dinâmicas **nunca** apareceram no Final Cut. Importava sem
|
||
nenhum erro, DTD passava, e nada era desenhado sobre o vídeo. Sintoma idêntico ao das
|
||
entradas anteriores (uid inválido → descarte silencioso), o que levou várias rodadas de
|
||
correção a atacarem o alvo errado (uid, offset, dispatch de builder).
|
||
- **Causa raiz:** o programa emitia **caption**, não **título animado**. Duas coisas
|
||
acopladas, ambas erradas:
|
||
1. `_TEXTO_TITLE_UID` apontava para `.../Subtitles.localized/Subtitle.localized/Subtitle.moti`
|
||
— o template de **legenda/caption** do FCP, não um template de título.
|
||
2. Cada `<title>` recebia `role="subtitles.subtitles-1"`. Esse role faz o Final Cut
|
||
tratar o elemento como legenda e roteá-lo para a **pista de captions**, que não é
|
||
desenhada sobre o vídeo a menos que a exibição de legendas esteja ligada.
|
||
- **Como foi descoberto:** o usuário montou títulos à mão dentro do FCP e exportou
|
||
(`Teste de Texto.fcpxmld`, `exemplo de arquivos.fcpxmld`). Comparando os títulos dele
|
||
(que aparecem) com os gerados (que somem): os dele usam `Essential Title.moti` /
|
||
`Essential Fade.moti` / `Text.moti` e **não têm atributo `role` nenhum`**; os gerados
|
||
usavam `Subtitle.moti` + `role="subtitles.*"`. Prova adicional: no re-export, o FCP
|
||
devolveu os `caption_*` com offsets negativos e fora do clipe (−2,13s num clipe de
|
||
1,835s), porque os realocou como captions noutro sistema de coordenadas, enquanto os
|
||
títulos manuais voltaram coerentes dentro do clipe (0,58s e 1,38s).
|
||
- **Onde:** `fcpxml/writer.py` (`_TEXTO_TITLE_UID`, `_TEXTO_TITLE_ROLE`,
|
||
`_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_make_texto_title_clip`),
|
||
`fcpxml/models.py` (`DynamicSubtitleConfig`), `server.py`, `MacApp/Sources/*.swift`.
|
||
- **Premissa falsa que ancorou os erros anteriores:** um comentário no próprio
|
||
`fcpxml/writer.py` afirmava que `WHISPERX/code/"Teste do dia.fcpxmld"` era um export
|
||
real do FCP *"actually imported and played back"*. O usuário confirmou que **nunca
|
||
funcionou** — aquele arquivo é output do próprio programa. Como o comentário foi tratado
|
||
como fonte de verdade, cada correção seguinte se apoiava nele e reproduzia a estrutura
|
||
errada (inclusive o `role` de caption). A entrada anterior desta lista herdou o mesmo erro.
|
||
- **Solução adotada:**
|
||
1. `_TEXTO_TITLE_UID` → `.../Titles.localized/Essential Titles.localized/Essential Title.localized/Essential Title.moti`
|
||
e nome do efeito → `"Essencial - Título"`.
|
||
2. `_TEXTO_TITLE_ROLE` **removido** e `elem.set('role', ...)` eliminado de
|
||
`_make_texto_title_clip`. Nenhum título gerado carrega `role`.
|
||
3. `_TEXTO_TITLE_START` → `86486400/24000s` (valor que o FCP escreve para o Essential Title).
|
||
4. `_TEXTO_TITLE_PARAMS` → só os 5 params de layout do Essential Title. Os ~75 params de
|
||
animação foram deixados de fora de propósito: carregam `<keyframeAnimation>` com tempos
|
||
**absolutos** calibrados à duração de uma instância específica, e replicá-los em títulos
|
||
de outra duração produz animação truncada/congelada. Sem eles o template Motion anima
|
||
pelos próprios defaults.
|
||
5. Granularidade: `max_words_per_line` 4 → **1** e `lane_count` 3 → **9** (defaults
|
||
alinhados em `models.py`, `server.py` e no MacApp), gerando um `<title>` por palavra.
|
||
6. Comentários falsos reescritos apontando para os exports reais do usuário.
|
||
7. Novo teste de regressão `test_titles_carry_no_caption_role`.
|
||
- **Aprendizado:** **legenda dinâmica = título animado, não caption.** Um
|
||
`role="subtitles.*"` num `<title>` o esconde atrás do toggle de legendas — importa limpo
|
||
e nunca aparece, exatamente o mesmo sintoma de um uid inválido, o que torna os dois fáceis
|
||
de confundir. E, mais importante: **um comentário dizendo "verificado" não é verificação.**
|
||
Só vale como referência um arquivo que o usuário confirmou ter saído do Final Cut. Antes de
|
||
tratar qualquer arquivo como ground truth, checar se ele é output do próprio programa —
|
||
se os `name=` seguem o padrão que o código gera (`caption_<hex>`), ele é.
|
||
- **Estado:** `resolvido` no XML (verificado na saída: efeito Essential Title, zero roles,
|
||
1 título por palavra, offsets dentro da janela do clipe) — **pendente de confirmação de
|
||
importação real no FCP pelo usuário**, que é o único teste que conta neste histórico.
|
||
|
||
### 2026-08-17 — Legendas dinâmicas não apareciam no FCP: dispatch misturava Título Básico com bloco/role do Subtitle + `offset` em coordenada errada (timeline em vez de mídia)
|
||
|
||
> **Nota (revisão posterior):** esta entrada trata `WHISPERX/code/"Teste do dia.fcpxmld"`
|
||
> como export real verificado do FCP. **Isso está errado** — aquele arquivo é output do
|
||
> próprio programa e nunca funcionou. Ver a entrada acima. A parte de `offset` em
|
||
> coordenada de mídia continua correta (reconfirmada contra `exemplo de arquivos.fcpxmld`),
|
||
> mas o `role="subtitles.*"` e o template Subtitle.moti descritos aqui eram a causa real
|
||
> das legendas invisíveis.
|
||
|
||
- **Sintoma:** ao gerar legendas dinâmicas num projeto real (`Depoimento da Erika - Original.fcpxmld`, clipe único com `start="226220995/24000s"` ≈ 256.59s na mídia de origem) e abrir o resultado no Final Cut, as legendas simplesmente não apareciam — nem na timeline, nem na lista de roles. O app chamava o handler sem `animated`, então caía no default e produzia um `<title>` com `ref="r_title_basic"` (Título Básico) mas com `role="subtitles.subtitles-1"`, `start="86400314/24000s"` e os 19 params do template "Legenda" — um híbrido impossível de resolver no FCP (descarte silencioso, padrão documentado nas entradas de 2026-08-15/2026-08-17).
|
||
- **Causa raiz (dois bugs num ciclo):**
|
||
1. `generate_dynamic_subtitles` (`fcpxml/writer.py`) escolhia o efeito certinho por `config.animated` (linhas 2893-2896), mas SEMPRE construía o clip com `_make_texto_title_clip` (linha 2944) — ignorando o `animated`. `_make_basic_title_clip` (que monta o Título Básico sem role/start e só os 2 params `Compactar`/`Alinhamento`) existia mas **nunca era chamado** (código morto). Resultado default: ref do básico + corpo do subtitle → o FCP descarta silenciosamente.
|
||
2. Mesmo no caminho animado, o `offset` era escrito como valor **relativo à timeline** (ex.: `1/4800s`). Mas o export real verificado (`WHISPERX/code/"Teste do dia.fcpxmld"`) mostra que o template "Legenda"/Subtitle posiciona os captions em **coordenadas da mídia de origem**: `offset = start_do_clipe + relativo` (ex.: `240822582/24000s` = start `240817577/24000s` + 0.2085s), enquanto o "Título Básico" (r3) usa offset relativo (ex.: `1001/4800s`). Escrever offset relativo pequeno num clip cujo start é ~256s/9000s faz o FCP ler ~0s da mídia — antes do in-point do clipe — e a legenda cai "fora" (caso `Legendas fora.fcpxmld`).
|
||
- **Onde:** `fcpxml/writer.py` (`generate_dynamic_subtitles`, linhas ~2893-2956), `fcpxml/models.py` (`DynamicSubtitleConfig.animated`, default errado `False`), `tests/test_dynamic_subtitles.py`.
|
||
- **Solução adotada:**
|
||
1. `generate_dynamic_subtitles` agora despacha pelo `config.animated`: `True` → `_make_texto_title_clip` (template "Legenda"), `False` → `_make_basic_title_clip` (Título Básico). O híbrido impossível não existe mais.
|
||
2. No caminho animado, `offset = media_origin + snap(relativo)` com `media_origin = _parse_time(parent.get('start'))` (coordenada da mídia, igual ao export real); no estático, offset permanece relativo (como r3).
|
||
3. `DynamicSubtitleConfig.animated` agora é `True` por padrão (decisão do usuário: a feature é a legenda animada/ediável; `False` só para texto queimado no frame).
|
||
4. Adicionados testes de regressão (`test_animated_offset_uses_source_media_coordinates`, `test_animated_effect_is_legenda_subtitle`, `test_static_mode_uses_basic_title_no_role`, `test_animated_and_static_use_separate_effects`).
|
||
- **Aprendizado:** dois templates diferentes num mesmo método exigem dispatch por builder, e cada um tem seu próprio sistema de coordenadas de `offset` — o template de caption/legenda usa a posição na mídia de origem (nunca um offset relativo pequeno), o título estático usa offset relativo ao clipe. Comparar sempre com o export real (coordenadas + role + params) antes de fechar uma estrutura, e nunca ignorar o branch `else` de um `if config.X` que decide o template — builder único = mistura de corpo de um template com ref de outro = descarte silencioso no FCP.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Clipe-fantasma de 1 frame no início e no fim após remoção de silêncio (raiz real no gerador, diferente da entrada de 2026-08-14)
|
||
|
||
- **Sintoma:** usuário testou `remove_silences` num projeto real (`Depoimento da Erika_silence_removed.fcpxmld`) e reportou dois clipinhos minúsculos: o primeiro e o último clipe da spine gerada tinham `duration="1001/24000s"` — exatamente 1 frame a 23.976fps.
|
||
- **Causa raiz:** diferente da entrada de 2026-08-14 ("Micro-clips... são criados pelo FCP, não pelo corte") — aqui os slivers já vinham no `Info.fcpxml` bruto gerado pelo programa, confirmado lendo o XML direto, sem passar pelo FCP. `handle_remove_media_silence`/`cut_clip_ranges` (`fcpxml/writer.py`) aplica um `padding` (respiro, padrão 0.05s) antes/depois de cada trecho de silêncio cortado. Quando o silêncio detectado toca a própria borda do clipe (começo ou fim), não sobra fala nenhuma daquele lado para o padding "respirar perto de" — o padding vira, sozinho, o segmento "kept" (mantido) daquela ponta, e após o snap para o grid de frames (`snap_seconds_to_frame`) esse segmento de ~0.05s vira exatamente 1 frame, virando clipe próprio em vez de ser absorvido.
|
||
- **Onde:** `fcpxml/writer.py::FCPXMLModifier.cut_clip_ranges`, construção da lista `keeps` (complemento dos `cut_ranges` mesclados).
|
||
- **Tentativas que falharam:** n/a — diagnóstico direto lendo o XML bruto e cruzando com a lógica de `cut_clip_ranges`; os números batem exatamente (0.05s de padding ≈ 1.2 frames a 23.976fps → arredonda para 1 frame).
|
||
- **Solução adotada:** a pedido do usuário ("em vez de criar esse [micro-clipe] novo, ele pode pegar o próprio segundo clipe e aumentar a duração dele para começar antes") — depois de montar `keeps`, se o primeiro segmento tiver menos que ~2 frames de duração, ele é fundido no segmento seguinte (que passa a começar mais cedo); simétrico no fim (o penúltimo segmento passa a terminar mais tarde, absorvendo o último). Isso também restaura o pequeno trecho de silêncio adjacente que teria sido cortado ali — troca aceitável por não deixar clipe-fantasma na timeline.
|
||
- **Aprendizado:** ao gerar clipes a partir de um algoritmo de corte com padding, sempre checar segmentos residuais nas BORDAS da mídia/clipe (não só entre dois cortes no meio) — o padding não tem "vizinho de fala" do lado de fora do clipe, então o caso de borda precisa de tratamento explícito (fundir em vez de emitir). Existe um padrão irmão já usado alhures no código (`_absorb_into_neighbor`, `fcpxml/writer.py:1112`) para a mesma ideia geral.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — As 481 legendas do usuário ficaram todas presas num único clipe errado: `generate_dynamic_subtitles` resolvia o clipe-pai por `name`, ambíguo depois de corte de silêncio
|
||
|
||
- **Sintoma:** mesmo com o `uid` e os `offset`/`duration` corrigidos (entradas abaixo), o usuário mandou o `.fcpxmld` real depois de importar no FCP ("como ficou depois de importar") e as 481 legendas apareciam todas grudadas em ~8-17 segundos de um único clipe da timeline, com frases completamente sem relação entre si ("vontade.", "Sou erica Fernanda, tenho", "sou casada.") — claramente vindas de pontos bem distantes de uma entrevista de ~17 minutos, não de um trecho de 8 segundos.
|
||
- **Causa raiz:** `server.py::handle_generate_dynamic_subtitles` itera cada clipe da spine (`for el in spine_clips`), calcula a janela de palavras correta *relativa àquele clipe* (`el`), mas chamava `modifier.generate_dynamic_subtitles(name, ...)` passando `name = el.get("name", "")` — uma STRING — em vez do elemento. Depois de qualquer `remove_silences`/corte com ripple, TODOS os fragmentos resultantes de um clipe original mantêm o mesmo `name` herdado (aqui, ~482 clipes, todos `name="0E6A8829"`, o nome do asset de origem). `_require_clip()` resolve por `self.clips[key]`, um dict indexado por `id` ou, na falta dele, por `name` (`fcpxml/writer.py:_index_elements`) — com nomes duplicados, cada novo clipe indexado SOBRESCREVE o anterior, então `self.clips["0E6A8829"]` acaba apontando para só UM clipe (o último indexado). Toda chamada do loop, para qualquer um dos 482 clipes reais, resolvia para esse mesmo clipe errado — empilhando ali as legendas de quase o vídeo inteiro.
|
||
- **Onde:** `server.py` (`handle_generate_dynamic_subtitles`, a chamada a `modifier.generate_dynamic_subtitles`), `fcpxml/writer.py` (`generate_dynamic_subtitles`, `_require_clip`, `_index_elements`).
|
||
- **Solução adotada:** `generate_dynamic_subtitles` agora aceita `parent_clip` como `str | ET.Element` — se receber o elemento diretamente, usa-o sem passar pelo lookup por nome; só cai em `_require_clip(name)` (mantido para compatibilidade com chamadas antigas/testes) quando recebe uma string. `server.py` foi atualizado para passar `el` (o elemento já em mãos no loop) em vez de `name`. Adicionado teste de regressão (`test_element_param_bypasses_ambiguous_duplicate_name_lookup`) que simula dois clipes com o mesmo `name` e confirma que passar o elemento anexa cada legenda ao clipe certo.
|
||
- **Aprendizado:** **nunca identificar um clipe específico por `name` num handler que itera múltiplos clipes** — qualquer operação de corte/ripple/remoção de silêncio no FCPXML preserva o `name` original em todos os fragmentos resultantes, então `name` deixa de ser único assim que o timeline é editado. Sempre que o chamador já tem o `ET.Element` em mãos (por ter vindo de uma iteração como `_iter_spine_clips()`), passe o elemento adiante em vez de re-resolvê-lo por um identificador que pode colidir.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Legendas dinâmicas sumiam silenciosamente do Final Cut por `uid` de efeito inválido (mistura de dois templates); dois bugs num só ciclo
|
||
|
||
- **Sintoma:** depois de corrigir os erros de frame-boundary do `offset`/`duration` (entrada abaixo, mesma sessão), o Final Cut não reportava mais nenhum erro de importação — mas os 481 `<title>` de legenda simplesmente não apareciam em lugar nenhum: nem na timeline, nem na lista de roles do projeto.
|
||
- **Causa raiz:**
|
||
1. O `uid` fixado em `_TEXTO_TITLE_UID` (`.../Titles.localized/Basic Text.localized/Text.localized/Text.moti`) mistura os nomes de dois templates diferentes ("Basic Text" e "Text") e não corresponde a nenhum Motion template real instalado no FCP. Quando o `uid` de um `<effect>` referenciado por um clipe conectado não resolve para um template existente, o Final Cut **descarta silenciosamente** os clipes conectados que dependem dele durante a importação — sem erro, sem aviso na UI. É a segunda vez que um `uid` fabricado aqui é a causa raiz (ver entrada de 2026-08-15 abaixo — da primeira vez foi o "Basic Title", desta vez foi um "Texto" que só passou pelos testes internos porque nunca foi de fato importado de novo no FCP depois de escrito).
|
||
2. Um dos `<param>` copiados junto (`Opacidade` = `"0"`) fixava a opacidade do texto em zero — mesmo se o `uid` estivesse certo, o texto ficaria invisível.
|
||
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_ensure_texto_title_effect`, `_make_texto_title_clip`.
|
||
- **Solução adotada:** o usuário identificou um export real, já importado e reproduzido com sucesso no FCP, presente no próprio repositório em `WHISPERX/code/"Teste do dia.fcpxmld"/Info.fcpxml` — efeito `r4` nomeado **"Legenda"**, `uid=".../Titles.localized/Subtitles.localized/Subtitle.localized/Subtitle.moti"`, usado em cinco `<title>` conectados por palavra/linha com `role="subtitles.subtitles-1"`. Copiado o `uid`, o `name` ("Legenda"), o `start` fixo (`86400314/24000s`, diferente do valor anterior), o atributo `role`, e o bloco de 19 `<param>` inteiro verbatim — que não inclui nenhum param de opacidade nem `<keyframeAnimation>` manual (a revelação palavra-a-palavra é nativa do template via os params `Animar`/`Intervalo`, não uma curva de velocidade fabricada como no template anterior).
|
||
- **Aprendizado:** um `uid`/bloco de params "plausível" que passa nos testes internos (`fcpxml/dtd.py`, pytest) **não é prova de que é real** — só um roundtrip de importação de verdade no FCP prova isso, e mesmo assim a falha pode ser silenciosa (sem erro) em vez de uma rejeição explícita. Regra geral reforçada: nunca fabricar/adivinhar `uid` de efeito nativo do FCP, mesmo que o formato pareça consistente com outros exports reais — sempre copiar de um `.fcpxmld`/`Info.fcpxml` que o usuário confirma ter sido importado e reproduzido com sucesso.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-17 — Palavras das legendas dinâmicas empilhadas: o template "Essencial - Título" ANIMA por padrão e a animação ignora a posição estática — solução foi desligar `Animar`
|
||
|
||
- **Sintoma:** o usuário reportou, repetidamente, **todas as palavras uma em cima da outra** no Final Cut. O XML gerado tinha posições estáticas e distintas (verificado), mas o FCP **ignorava** a posição e empilhava tudo — o sintoma persistiu mesmo com `value="x y"` correto em cada palavra.
|
||
- **Causa raiz (a definitiva):** o template "Essencial - Título" (Essential Title, Motion) tem um parâmetro **`Animar`** (Animate). No export de calibração (`Exemplo Letra.fcpxmld`, as 5 palavras que o usuário arrastou à mão e funcionaram), cada título carrega **~111 params**, incluindo `Animar = "4 (Tudo)"` mais dezenas de params de animação por caractere (`X/Y/Z deslocamento`, `Objeto Original`, `Deslocamento Inicial/Final`, `Direção`, `Velocidade Personalizada` com keyframes). Nós só escrevíamos 5 params de layout e **omitíamos o `Animar`**. Sem ele, o FCP usa a **animação default** do template — a animação de "Tudo" (fly-in 3D por caractere) — e **é essa animação que posiciona as letras**, não o nosso `Posição`. Resultado: cada caractere cai na posição default e tudo empilha. As palavras da calibração só ficaram no lugar porque o FCP escreveu o bloco de animação completo junto.
|
||
- **Por que NÃO copiar o bloco de animação:** os params por caractere (`X deslocamento`, `Y deslocamento`, `Z deslocamento`, `Objeto Original`) têm valores **diferentes por palavra** (ex.: `X deslocamento = -960.047` em "Tod" vs `-243` em "a") — são dados 3D por caractere que o FCP calcula e que não dá para reproduzir. Já os `time` dos keyframes são **idênticos em todas as palavras** (`0s`, `1567433324/1000000000s`, `19915648/3840000s`, `6686008967/1000000000s`), confirmando que são a curva default do template, não calibrados por instância.
|
||
- **Onde:** `fcpxml/writer.py::_make_texto_title_clip`, novo `_TEXTO_ANIMAR_KEYS` (10 chaves `.../201/203` de `Animar`), `tests/test_dynamic_subtitles.py`.
|
||
- **Tentativas que falharam:** (1) embrulhar a posição em `keyframeAnimation` — piorou, pois o param é estático e o FCP descartou tudo para o default `0 -45.4935`; (2) reverter só para o valor estático — ainda empilhava, porque a animação default continuava ignorando a posição.
|
||
- **Solução adotada:** escrever `Animar = "0 (Nenhum)"` nas 10 chaves de animação do template, desligando a animação. O título fica **estático** e respeita o `Posição` gravado; a revelação palavra-por-palavra continua vindo do `offset`/`duration` de cada palavra (não da animação). Teste atualizado: 5 params de layout + 10 `Animar = "0 (Nenhum)"`, sem `keyframeAnimation`.
|
||
- **Aprendizado:** num template Motion de título, a **posição visual pode ser controlada pela animação, não pelo param de layout** — se um título "arrastado à mão" funciona e o gerado empilha, comparar o bloco de params **inteiro** (não só a posição) entre os dois arquivos. O `Animar` é o interruptor-mestre: `4 (Tudo)` anima (e aí só os dados 3D por caractere — irreproduzíveis — colocam as letras no lugar); `0 (Nenhum)` desliga e devolve o controle ao param `Posição`. E: a mesma conclusão errada foi registrada e corrigida duas vezes nesta sessão — a cada iteração, reler o export de calibração **inteiro** antes de decidir o mecanismo.
|
||
- **Estado:** `resolvido` no XML (posições estáticas distintas + 10× `Animar = "0 (Nenhum)"` verificados no output real; 1124 testes verdes) — **pendente de confirmação de importação real no FCP pelo usuário**, único teste que conta neste histórico.
|
||
|
||
---
|
||
|
||
### 2026-08-17 — `offset`/`duration` de legendas dinâmicas fora do grid de frames (denominador `/23s` em vez de `/24000s`)
|
||
|
||
- **Sintoma:** importação real no Final Cut rejeitada com 457 de 481 erros "O item não está em um limite de quadro de edição", todos apontando para `offset`/`duration` de `<title>` com denominador `/23s` (ex.: `offset="2/23s"`, `duration="67/23s"`).
|
||
- **Causa raiz:** `generate_dynamic_subtitles` (`fcpxml/writer.py`) construía cada offset/duration com `TimeValue.from_seconds(seconds, self.fps)`. Esse classmethod (`fcpxml/models.py`) faz `int(fps)` — para um projeto NTSC a 23.976fps (`frameDuration="1001/24000s"`, `self.fps ≈ 23.976`), `int(fps)` trunca para `23`, uma base de tempo inválida para o FCPXML. Todo o resto do XML (asset-clips, cortes de silêncio, sequence) já usava a base exata `1001/24000s`.
|
||
- **Onde:** `fcpxml/writer.py:2841-2850` (chamada) e `fcpxml/models.py:314-318` (`TimeValue.from_seconds`, bug latente — outros call-sites como marcadores em `fcpxml/writer.py:1442,1511` usam o mesmo padrão e podem ter o mesmo problema em taxas NTSC, não corrigido nesta rodada por estar fora do escopo do bug relatado).
|
||
- **Solução adotada:** trocado `TimeValue.from_seconds(start, self.fps)` / `TimeValue.from_seconds(end - start, self.fps)` por `self.snap_seconds_to_frame(...)` — helper já existente (`fcpxml/writer.py:746`) que usa a fração exata de `frameDuration` (via `frame_duration_fraction()`, `fcpxml/writer.py:727`) em vez do float truncado, já usado em outro lugar do writer para snap da spine. Também trocado `min_dur_seconds = 1.0 / self.fps` por `float(self.frame_duration_fraction())`.
|
||
- **Aprendizado:** qualquer conversão de segundos-float para `TimeValue` num projeto FCPXML deve usar a fração exata do `frameDuration` do `<format>` da sequência (via `frame_duration_fraction()`/`snap_seconds_to_frame()`), nunca `int(fps)` — taxas NTSC (23.976/29.97/59.94) sempre truncam errado com um fps float.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-15 — Abandonado o Compound Clip em `generate_dynamic_subtitles`; voltado a títulos soltos em lanes cicladas, com estrutura copiada de um export real
|
||
|
||
- **Sintoma/decisão:** mesmo depois de corrigir os 3 erros de importação do Compound Clip (entrada abaixo), o usuário decidiu recuar da abordagem por completo — "muito problema e muito erro" — e pediu para voltar ao básico: títulos soltos, direto na timeline, em várias lanes, sem Compound Clip.
|
||
- **O que mudou:** `generate_dynamic_subtitles` não cria mais `<media>`/`<sequence>`/`<gap>`/`<ref-clip>` nenhum. Cada linha (chunk de palavras) vira um `<title>` autônomo, anexado direto no clipe pai via `_dtd_insert`, ciclando por `config.lane_count` lanes (round-robin). A duração de cada linha se estende até sua própria lane ser reaproveitada `lane_count` linhas depois (ou até seu próprio fim, se for uma das últimas) — isso empilha visualmente várias linhas ao mesmo tempo (efeito cascata) sem nunca sobrepor duas linhas na MESMA lane.
|
||
- **Fonte da estrutura XML:** o usuário mandou um FCPXML real (`com exemplo de título.fcpxmld/Info.fcpxml`) com 3 títulos criados manualmente no FCP usando o template **"Texto"** (`uid=".../Titles.localized/Basic Text.localized/Text.localized/Text.moti"` — diferente do "Basic Title" usado antes). Copiei o bloco de `<param>` inteiro (margens, alinhamento, quebra automática, o par Opacidade/Velocidade Personalizada com `<keyframeAnimation>` que parece ser a animação de revelação nativa do template) e o atributo `start` fixo (`86486400/24000s`, idêntico nos três títulos do export) como constantes fixas em `_TEXTO_TITLE_PARAMS`/`_TEXTO_TITLE_START` — não tentei entender/simplificar esses valores, só copiei verbatim, já que "simplificar" um bloco de params reais foi exatamente o que causou os erros anteriores.
|
||
- **Importante (o usuário corrigiu isso no meio da conversa):** os offsets/timings do arquivo de exemplo eram só ilustrativos — não estavam sincronizados com nenhuma fala real. O timing de verdade continua vindo 100% da transcrição Whisper (`words` com `start`/`end` reais), usando a mesma lógica de `TimeValue`/mapeamento fonte→timeline já estabelecida no projeto. Só a ESTRUTURA XML (uid, params, `start` fixo) foi copiada do exemplo, nunca os números de tempo.
|
||
- **Onde:** `fcpxml/writer.py` (`FCPXMLModifier.generate_dynamic_subtitles`, `_make_texto_title_clip`, `_ensure_texto_title_effect`), `fcpxml/models.py` (`DynamicSubtitleConfig.lane_count` substituindo `lane`), `server.py`, `admin/models_api.py`, `MacApp/Sources/CaptionsView.swift`.
|
||
- **Aprendizado:** quando o usuário oferece um export real do FCP como referência, tratar isso como fonte de verdade para a ESTRUTURA (uid, ordem de elementos, bloco de params), mas nunca para os NÚMEROS de tempo específicos de um exemplo ilustrativo — a menos que ele diga explicitamente que os números também são reais. E: depois de duas rodadas de erro de importação real, a abordagem mais simples e mais próxima de um export real validado sempre vale mais que uma abstração mais "elegante" (Compound Clip) que ninguém verificou contra o importador de verdade do FCP.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-15 — Três rejeições de importação real no Final Cut Pro em `generate_dynamic_subtitles` (uid fabricado, `<title>` ancorado em `<gap>`, duração 0)
|
||
|
||
- **Sintoma:** ao importar de verdade no Final Cut Pro (não só validar internamente), o app recusou o `Info.fcpxml` com três classes de erro: (1) `uid=".../Titles.localized/Basic Text.localized/..." — O item não pôde ser lido`; (2) `Edição inválida sem nenhuma mídia respectiva` apontando para `.../gap[1]/title[1]`; (3) `Um valor inesperado foi encontrado (duration="0/1s")` em vários `<gap>` dentro dos `<media>` de legenda.
|
||
- **Causa raiz:**
|
||
1. O `uid` do efeito "Basic Title" foi **inventado** (nunca verificado contra um export real) — o caminho correto tem `Bumper:Opener.localized`, não `Basic Text.localized`.
|
||
2. Os `<title>` por palavra estavam sendo anexados como *connected clip* (via `lane`) dentro de um `<gap>` usado só para "segurar" a duração da linha — mas um `<gap>` não é mídia, e FCP rejeita qualquer clipe conectado a um `<gap>` como âncora.
|
||
3. Palavras com `start`/`end` muito próximos (ou vindas de um transcript com timestamps imprecisos) geravam durações que arredondavam para 0 frames no fps da sequência.
|
||
- **Onde:** `fcpxml/writer.py`, `FCPXMLModifier.generate_dynamic_subtitles` / `_make_title_clip` / `_ensure_basic_title_effect`.
|
||
- **Tentativas que falharam:** validar apenas com `fcpxml/dtd.py` e com os testes unitários — nenhum dos dois pega uid/params inválidos (o DTD da Apple não estava disponível neste ambiente) nem a regra "conectado precisa de mídia real por trás", que só o importador real do FCP aplica.
|
||
- **Solução adotada:**
|
||
1. Encontrado um `uid` **real e correto** dentro do próprio repositório, em `WHISPERX/code/*.fcpxmld/Info.fcpxml` (um projeto de verdade exportado pelo usuário) — usado esse valor em vez de inventar um novo. Os únicos dois `<param>` que esse template realmente usa (`Compactar` e `Alinhamento`, com `key` fixo) também foram copiados de lá; o `<param name="Position">` fabricado foi removido.
|
||
2. Reestruturado o compound clip: os `<title>` por palavra agora são conteúdo **primário** da spine interna (como o próprio `<title>` já suporta ser primário), com `<gap>` só preenchendo silêncio real entre eles — nunca mais como pai/âncora de um clipe conectado.
|
||
3. Adicionado um piso de duração mínima de 1 frame (`1.0 / fps`) tanto por palavra quanto pela linha inteira, em vez do padding fixo de `0.01s` que arredondava para 0 em fps altos.
|
||
- **Aprendizado:** **nunca fabricar `uid`/`key` de efeitos nativos do FCP** — eles não são adivinháveis e a validação interna (`fcpxml/dtd.py`) só pega isso se o Final Cut Pro estiver instalado localmente; sempre que possível, procurar/pedir um export real como referência antes de inventar. Além disso, "conectado" (`lane`) sempre precisa de um clipe com mídia de verdade por trás — um `<gap>` nunca serve de âncora, mesmo que pareça funcionar nos testes internos (que só checam a árvore XML, não as regras semânticas do importador do FCP). E qualquer duração calculada a partir de subtração de floats de transcrição deve ter um piso de `1/fps`, nunca uma constante fixa pequena.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-15 — Corpo de `cmd_add_zoom` colado por engano dentro de `cmd_remove_silences` em `admin/models_api.py`
|
||
|
||
- **Sintoma:** lint (`ruff`) falhando com `F821 Undefined name 'clip_id'` e `F841 Local variable 'clip_id' is assigned to but never used`, em duas funções diferentes do bridge Python↔Swift.
|
||
- **Causa raiz:** durante uma edição manual (introduzindo `_derived_output()` para suportar `output_dir` configurável), o corpo inteiro de `cmd_add_zoom` (checagem de `clip_id` + chamada a `handle_add_zoom`) foi colado dentro de `cmd_remove_silences`, antes do bloco correto que já chamava `handle_remove_media_silence` — deixando `cmd_remove_silences` com código morto/quebrado (chamava o handler errado e checava uma variável inexistente) e `cmd_add_zoom` truncado (só validava `path`/`clip_id` e não fazia mais nada).
|
||
- **Onde:** `admin/models_api.py`, funções `cmd_remove_silences` e `cmd_add_zoom`.
|
||
- **Tentativas que falharam:** n/a — identificado direto pelo lint e por leitura do código antes de qualquer tentativa de correção.
|
||
- **Solução adotada:** movido o fragmento (checagem de `clip_id` + chamada a `handle_add_zoom`) de volta para dentro de `cmd_add_zoom`, removendo-o de `cmd_remove_silences`, que voltou a conter só a chamada correta a `handle_remove_media_silence`.
|
||
- **Aprendizado:** depois de qualquer edição manual em `admin/models_api.py` (ou qualquer arquivo com várias funções `cmd_*` de shape parecido), rodar o lint imediatamente pega colagens cruzadas de função — `ruff` acusa tanto a variável usada-mas-nunca-definida (função que perdeu o trecho) quanto a definida-mas-nunca-usada (função que ganhou o trecho de outra) no mesmo commit, o que é um sinal forte de bloco trocado de lugar, não dois bugs independentes.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-15 — IDs duplicados de `text-style-def` rejeitados pelo Final Cut Pro em legendas dinâmicas
|
||
|
||
- **Sintoma:** ao importar o FCPXML gerado por `generate_dynamic_subtitles`, o Final Cut Pro recusava o arquivo com "A validação DTD falhou" e uma lista de IDs como `caption_L0W0_ts0 already defined`.
|
||
- **Causa raiz:** os IDs de `<title>`/`<text-style-def>` eram montados como `caption_L{line_idx}W{word_idx}_ts{i}`, com `line_idx`/`word_idx` reiniciando em 0 a cada chamada de `generate_dynamic_subtitles`. Como o handler (`handle_generate_dynamic_subtitles` em `server.py`) chama esse método uma vez por clipe da spine na mesma instância de `FCPXMLModifier`, múltiplos clipes geravam exatamente os mesmos IDs — o DTD exige unicidade de ID no documento inteiro, não por clipe.
|
||
- **Onde:** `fcpxml/writer.py`, método `FCPXMLModifier.generate_dynamic_subtitles`.
|
||
- **Tentativas que falharam:** nenhuma alternativa testada — o padrão (índices posicionais que resetam por chamada) era o bug desde a primeira implementação; só foi pego ao testar a importação real no Final Cut Pro.
|
||
- **Solução adotada:** trocar o índice posicional por um prefixo derivado de `uuid.uuid4().hex[:8]` por linha (`caption_{line_uid}_W{word_idx}`), garantindo unicidade mesmo entre chamadas repetidas na mesma instância do modifier. De quebra, corrigido também: o `<ref-clip>` (compound clip) estava sendo anexado via `ET.SubElement` direto no clipe pai, o que o colocava depois de marcadores (`keyword`, `chapter-marker`) já existentes — violando a ordem de filhos exigida pelo DTD (itens-âncora como `ref-clip` devem vir antes de itens de marcador). Trocado para `_dtd_insert(parent, ref_clip)`, que já resolve essa ordenação.
|
||
- **Aprendizado:** qualquer ID gerado dentro de um método chamado em loop (uma vez por clipe/iteração) sobre a MESMA árvore XML não pode depender de um índice que reinicia a cada chamada — precisa ser único por invocação (UUID, contador persistido na instância, ou verificação contra os IDs já existentes no documento). Além disso, qualquer elemento anexado a um clipe existente via `SubElement` direto (em vez de `_dtd_insert`) só é seguro se o clipe nunca tiver marcadores/filhos de prioridade menor já presentes — na dúvida, sempre usar `_dtd_insert`.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Tolerância e ações consolidadas no processamento em lote
|
||
|
||
- **Sintoma:** a tolerância do corte ficava separada dos checkboxes e havia controles individuais repetindo as ações do lote.
|
||
- **Causa raiz:** o layout foi evoluído incrementalmente, mantendo os fluxos antigos abaixo do novo processamento em lote.
|
||
- **Solução adotada:** slider dentro do grupo "Remover silêncios", desabilitado quando a opção é desmarcada; campo de frases condicionado ao respectivo checkbox; removidos botões individuais e mantidas apenas as ações finais de abrir pasta e abrir no Final Cut.
|
||
- **Aprendizado:** quando existe processamento em lote, os parâmetros devem ficar junto da operação e os comandos individuais não devem duplicar o fluxo principal.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Pasta única e processamento em lote no app
|
||
|
||
- **Sintoma:** cada operação salvava o resultado em locais diferentes e exigia abrir o Finder ou localizar manualmente cada arquivo.
|
||
- **Causa raiz:** os comandos do bridge usavam apenas `generate_output_path` ao lado do projeto e a UI oferecia ações independentes, sem uma pasta de trabalho comum.
|
||
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` e `server.py`.
|
||
- **Solução adotada:** nova pasta de saída configurável no topo, cinco checkboxes de processamento e botão único; as etapas são encadeadas e todos os XML/SRT recebem `output_dir` explícito.
|
||
- **Aprendizado:** operações relacionadas devem compartilhar uma pasta de saída e uma entrada encadeada, evitando artefatos espalhados pelo sistema.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Mapeamento de legenda por intervalo, sem duração inventada
|
||
|
||
- **Sintoma:** a tentativa de impor duração mínima gerou legendas deslocadas, duplicadas e piores; havia também cues de duração zero que o FCP rejeitava.
|
||
- **Causa raiz:** o mapeamento usava apenas o início do segmento e depois estendia artificialmente o fim, ignorando segmentos que atravessavam cortes.
|
||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
|
||
- **Tentativas que falharam:** descartar todo cue menor que 0.5s e preencher cada cue até 1s.
|
||
- **Solução adotada:** intersectar o intervalo completo da fala com cada janela de clipe mantida, mapear apenas a interseção, mesclar somente partes contíguas do mesmo segmento e omitir apenas spans que viram zero milissegundos.
|
||
- **Aprendizado:** sincronização deve transformar intervalos fonte→timeline; nunca inventar duração para corrigir legibilidade.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Exportar legenda a partir da versão cortada, não do projeto original
|
||
|
||
- **Sintoma:** a legenda gerada cobria o projeto original (326s) em vez do vídeo cortado (255s), desalinhada com os frames finais.
|
||
- **Causa raiz:** o botão "Exportar Legendas (SRT)" usava `projectPath` (projeto aberto na tela) em vez do resultado da remoção de silêncio (`processedPath`).
|
||
- **Onde:** `MacApp/Sources/TranscriptionView.swift` (`exportSubtitles`).
|
||
- **Tentativas que falharam:** gerar sempre do `projectPath`.
|
||
- **Solução adotada:** `exportSubtitles` passa a usar `processedPath` (a cópia `_silence_removed`) quando existe, caindo para o `projectPath` caso contrário — a legenda sempre acompanha o corte mais recente.
|
||
- **Aprendizado:** artefatos derivados do corte (SRT, marcadores) devem ser gerados da mesma versão editada que o usuário está usando, não do arquivo-fonte original.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Legenda SRT ultrapassava a duração do projeto (aviso do FCP)
|
||
|
||
- **Sintoma:** ao importar o SRT, o Final Cut avisava "as legendas se estendem além da duração do projeto" e sugeria conectá-las a um clipe vazio no final.
|
||
- **Causa raiz:** o último bloco de legenda mapeado terminava após o fim da timeline (ex.: SRT até 257.4s num projeto de 255.6s), porque o timestamp final arredondava (`round`) para cima e nenhum teto impedia o overrun.
|
||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`, `srt_stamp`).
|
||
- **Tentativas que falharam:** mapear o fim ao fim do clipe apenas; o último cue ainda podia exceder a sequência real.
|
||
- **Solução adotada:** calcular `timeline_total = _timeline_duration().to_seconds()` e clampar `tl_start`/`tl_end` de cada cue a esse teto; usar `floor` (em vez de `round`) no `srt_stamp` para nunca subir acima de um limite de frame.
|
||
- **Aprendizado:** SRT que termina após o último frame do projeto é rejeitado pelo FCP; sempre clampar o último cue ao total da timeline e truncar (não arredondar) timestamps.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Remoção de gap vazio final como padrão na remoção de silêncio
|
||
|
||
- **Sintoma:** a timeline do resultado de remoção de silêncio podia terminar com um gap "Espaço" vazio após o último clipe (introduzido no round-trip com o Final Cut), deixando um "objeto preto" no final.
|
||
- **Causa raiz:** nenhum passo garantia a remoção de gaps ao final da spine; o FCP re-adicionava o espaço ao importar.
|
||
- **Onde:** `fcpxml/writer.py` (novo `FCPXMLModifier.remove_trailing_gaps`) e `server.py` (`handle_remove_media_silence`).
|
||
- **Tentativas que falharam:** depender do usuário apagar o gap manualmente no FCP.
|
||
- **Solução adotada:** `remove_trailing_gaps()` remove apenas o `<gap>` final da spine (gaps no meio são preservados) e re-sincroniza a duração da sequência; chamado antes de salvar na remoção de silêncio. `cmd_remove_silences` (app) já herda via delegação ao handler.
|
||
- **Aprendizado:** operações que encurtam a timeline devem remover gaps finais para o arquivo exportado terminar onde o conteúdo termina.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Micro-clips de 1 frame e gap "Espaço" no final são criados pelo FCP, não pelo corte
|
||
|
||
- **Sintoma:** o resultado da remoção de silêncio mostrava, após ~4:11, dezenas de micro-clips de 1 frame (0.042s) e um objeto preto/gap "Espaço" de 755s no final.
|
||
- **Causa raiz:** o algoritmo gera um arquivo LIMPO (82 clipes, source `start` monotônico, sem micro-clips). O arquivo exportado pelo Final Cut tinha 162 clipes, 80 regressões de `start` e o gap "Espaço" — o FCP re-quebrou os clipes e inseriu o gap ao abrir/salvar/exportar, não o programa.
|
||
- **Onde:** comparação entre `_out_test.fcpxml` (saída do `handle_remove_media_silence`) e `Legendas fora.fcpxmld` (exportado do FCP).
|
||
- **Tentativas que falharam:** suspeitar do `cut_clip_ranges`/`_filter_children_for_segment`; o arquivo gerado pelo programa não tem esses micro-clips.
|
||
- **Solução adotada:** confirmado que o bug não está no código de corte; é artefato da re-exportação pelo FCP. Ajustar a detecção de silêncio (`min_duration`/padding) não resolve porque o arquivo gerado já está correto.
|
||
- **Aprendizado:** antes de assumir bug no gerador, reproduzir a saída crua e comparar com o artefato final — a re-importação no NLE pode reintroduzir clipes/gaps.
|
||
- **Estado:** `resolvido` (diagnóstico)
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Legenda SRT fora de sincronia após corte de silêncio
|
||
|
||
- **Sintoma:** ao exportar legenda depois de remover silêncios, o SRT cobria o vídeo inteiro em vez de apenas os trechos que ficaram — legendas apareciam em partes já cortadas.
|
||
- **Causa raiz:** `cmd_export_srt` gerava o SRT direto do transcript da mídia original (`segments_to_srt`), com timestamps da fonte bruta, ignorando os cortes da timeline editada.
|
||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
|
||
- **Tentativas que falharam:** exportar os segmentos como vinham do Whisper.
|
||
- **Solução adotada:** mapear cada segmento da fonte para a posição real na timeline com `clip_offset + (seg_start - clip_source_start)` (mesma lógica do `transcript_markers`), por clipe da spine editada, descartando falas fora da janela usada e ordenando por tempo.
|
||
- **Aprendizado:** qualquer artefato derivado da transcrição (SRT, cortes) deve ser mapeado fonte→timeline, nunca usar o transcript bruto; reutilizar o mapa já existente em `handle_transcript_markers`.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Terceiro ponto do bug de `int(fps)`: `TimeValue.from_timecode()` corrompia qualquer segundo decimal em taxa NTSC
|
||
|
||
- **Sintoma:** ao implementar a nova tool `transcript_markers` (marca no timeline
|
||
cada frase transcrita), `add_marker_at_timeline("317.9857s", ...)` lançava
|
||
`ValueError: No spine clip at position 331.478s` — uma posição **fora** da
|
||
timeline, mesmo com o timestamp de entrada correto e dentro dos limites.
|
||
- **Causa raiz:** `TimeValue.from_timecode()` (`fcpxml/models.py`), no ramo que
|
||
parseia segundos decimais simples (`"12.5s"`, sem `/`), calculava
|
||
`frames = round(seconds * fps)` com o `fps` real (float), mas construía o
|
||
`TimeValue` como `TimeValue(frames, int(fps))` — numerador calculado com o
|
||
fps certo, denominador truncado. A 23.976fps isso infla o valor em ~1.04x
|
||
(317.99s virou 331.48s). Terceiro local com essa mesma classe de bug (os
|
||
outros dois: `to_frame_timevalue`/`_cut_transcript_spans` em `server.py`,
|
||
já corrigidos na entrada anterior) — `from_timecode` é usado por
|
||
`_parse_time()`, chamado por quase todo o `writer.py`, então qualquer
|
||
handler que passe um timecode decimal (não fração) nessa taxa era afetado.
|
||
- **Onde:** `fcpxml/models.py` (`TimeValue.from_timecode`).
|
||
- **Tentativas que falharam:** nenhuma — bug novo, achado testando o handler
|
||
novo contra a transcrição real em cache antes de expor na UI.
|
||
- **Solução adotada:** reconstruir a fração exata do fps via
|
||
`Fraction(fps).limit_denominator(100_000)` (recupera `24000/1001` a partir
|
||
do float com precisão total) e usar `frames * fps_frac.denominator` /
|
||
`fps_frac.numerator` como numerador/denominador — mantém os dois em
|
||
unidades consistentes. Teste de regressão em
|
||
`tests/test_models.py::test_from_seconds_string_ntsc_rate_exact`.
|
||
- **Validado:** reproduzido o erro exato reportado, corrigido, e o handler
|
||
novo (`transcript_markers`) rodou de ponta a ponta contra a transcrição
|
||
real (54 marcadores, sem erro) depois da correção.
|
||
- **Aprendizado:** qualquer função que aceite `fps: float` e construa um
|
||
`TimeValue` diretamente (em vez de delegar pra uma fração exata) é suspeita
|
||
de ter esse bug em taxas NTSC. Ao corrigir uma instância, procurar outras
|
||
chamadas de `int(fps)` / `round(2400/fps)` no arquivo inteiro — não parar
|
||
na primeira encontrada.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Legendas visíveis exigem SRT/título, não marcadores
|
||
|
||
- **Sintoma:** pedido de "legenda na timeline que apareça no vídeo" — os marcadores de navegação não mostram texto sobre o vídeo.
|
||
- **Causa raiz:** marcadores (`transcript_markers`) são apenas navegação; legenda visível no FCP exige SRT importado como idioma de legenda ou um `<title>` conectado (fragilmente dependente da versão do FCP).
|
||
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` (`cmd_export_srt`), reuso de `segments_to_srt`.
|
||
- **Tentativas que falharam:** usar marcadores para legenda; gerar `<title>` de texto no FCPXML é frágil entre versões.
|
||
- **Solução adotada:** novo comando `export_srt` que gera um `.srt` por mídia transcrita (via transcript cacheado + `segments_to_srt`), exposto como botão "Exportar Legendas (SRT)". O FCP importa o SRT como legenda nativa e desenha sobre o vídeo.
|
||
- **Aprendizado:** "legenda" no FCP = SRT/caption, não marcador; sempre distinguir navegação (marker) de texto sobreposto (caption).
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Remoção de silêncio/transcrição gerava XML fora da grade de frame em projetos NTSC (23.976/29.97fps), confirmado por importação real no FCP
|
||
|
||
- **Sintoma:** ao importar no Final Cut Pro o XML gerado por `remove_media_silence`,
|
||
dezenas de avisos "O item não está em um limite de quadro de edição" em quase
|
||
todo `asset-clip` da spine (projeto real de 82 clipes, `Depimento Erika`).
|
||
- **Causa raiz:** `handle_remove_media_silence` e `_cut_transcript_spans`
|
||
(`server.py`) calculavam os limites de corte com
|
||
`TimeValue(round(seconds*fps) * round(2400/fps), 2400)` — uma base fixa de
|
||
2400 ticks/segundo. Para 24/25/30/48/50/60fps isso é exato, mas para
|
||
23.976fps (`frameDuration="1001/24000s"`) `round(2400/23.976)` arredonda para
|
||
100, tratando cada frame como `1/24s` exato em vez do `1001/24000s` real —
|
||
uma correção de `Engine/docs/05_EXPERIENCIAS.md` (entrada anterior) existia
|
||
como `FCPXMLModifier.snap_spine_times_to_frames()` mas só era chamada por
|
||
`handle_add_marker`; nenhum handler de corte/ripple a usava.
|
||
- **Onde:** `server.py` (`handle_remove_media_silence`, `_cut_transcript_spans`)
|
||
e `fcpxml/writer.py` (`FCPXMLModifier.save()`).
|
||
- **Tentativas que falharam:** nenhuma — a correção certa (`Fraction` exato)
|
||
já existia no código, só não estava conectada aos caminhos que realmente
|
||
cortam a spine.
|
||
- **Solução adotada:** (1) `save()` agora chama `snap_spine_times_to_frames()`
|
||
incondicionalmente antes de serializar — todo handler que escreve passa por
|
||
ali, então a proteção é universal e não depende de cada handler lembrar de
|
||
chamar. (2) Os dois pontos de corte por segundos (`to_frame_timevalue` /
|
||
`to_frame`) agora usam o novo `FCPXMLModifier.snap_seconds_to_frame()`, que
|
||
arredonda para o frame mais próximo usando a fração exata de `frameDuration`
|
||
em vez da base fixa de 2400. Teste de regressão em
|
||
`tests/test_media_intel.py::test_ntsc_rate_output_stays_frame_aligned`
|
||
reproduz `duration="41100/2400s"` com a lógica antiga (não-inteiro em frames)
|
||
e confirma alinhamento exato com a nova.
|
||
- **Validado:** reimportação real no Final Cut Pro pelo usuário, sem os avisos.
|
||
- **Aprendizado:** qualquer cálculo de tempo que assuma uma base fixa de ticks
|
||
(2400, 600, etc.) quebra silenciosamente em taxas NTSC fracionárias
|
||
(23.976/29.97/59.94fps) — usar sempre `Fraction` a partir do `frameDuration`
|
||
real da sequência, nunca `fps` arredondado. E quando existir uma correção
|
||
"canônica" pronta no código, verificar que TODOS os caminhos relevantes a
|
||
chamam, não só um.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Fluxo de transcrição dependia de clique redundante e ocultava falhas
|
||
|
||
- **Sintoma:** após selecionar um projeto, era necessário clicar novamente para abrir a transcrição; erros do processo Python podiam não aparecer na interface.
|
||
- **Causa raiz:** a tela filha era condicionada a um botão intermediário, e o bridge não preservava fragmentos incompletos do JSONL nem convertia saída diferente de zero em erro.
|
||
- **Onde:** `MacApp/Sources/ProjectView.swift` e `MacApp/Sources/PythonBridge.swift`.
|
||
- **Tentativas que falharam:** depender apenas do callback de linhas completas e deixar a conclusão ignorar o código de saída.
|
||
- **Solução adotada:** abrir a tela automaticamente após `inspect`, processar a última linha parcial e propagar falhas do subprocesso.
|
||
- **Aprendizado:** bridges JSONL precisam tratar chunks arbitrários de stdout e sempre validar o status de saída.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Limites de quadro no XML exportado após remoção de silêncio
|
||
|
||
- **Sintoma:** o Final Cut Pro rejeitava `Info.fcpxml` com avisos de que
|
||
`offset` e `duration` não estavam em limites de quadro.
|
||
- **Causa raiz:** a edição ripple produzia frações de tempo válidas
|
||
matematicamente, mas desalinhadas do `frameDuration` exato da sequência.
|
||
- **Onde:** `fcpxml/writer.py` e `server.py` no fluxo de remoção de silêncio.
|
||
- **Tentativas que falharam:** usar FPS convertido para float e assumir uma
|
||
base inteira, o que não funciona para 23.976/29.97.
|
||
- **Solução adotada:** normalizar `offset` e `duration` da spine usando a
|
||
fração exata de `frameDuration` antes de salvar a cópia modificada.
|
||
- **Aprendizado:** limites de edição do FCPXML devem ser calculados com
|
||
`Fraction`, nunca com FPS arredondado ou floats.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
<!-- NOVAS ENTRADAS DEVEM SER ADICIONADAS ACIMA DESTA LINHA, SEMPRE NO TOPO
|
||
DA LISTA, PARA QUE A MAIS RECENTE FIQUE EM PRIMEIRO LUGAR. -->
|
||
|
||
### 2026-08-19 — Legendas dinâmicas geradas com `bold="0" fontFace="Bold"` não renderizam no FCP
|
||
|
||
- **Sintoma:** no corte real da Mastopexia, as legendas dinâmicas (composição
|
||
progressiva) não apareciam no Final Cut — só as primeiras linhas de cada
|
||
bloco surgiam e o restante sumia. O arquivo que o usuário re-exportou do FCP
|
||
("legendas dinamicas.fcpxmld") renderizava normalmente.
|
||
- **Causa raiz:** o corpo das legendas era definido como
|
||
`EDITORIAL_BODY_LOOK = WordLook(88, ..., face="Bold")` e o gravador emitia
|
||
`bold="0"` + `fontFace="Bold"` (pois `WordStyle.bold` é `False` por padrão).
|
||
Essa combinação é contraditória: no FCPXML negrito é o **atributo** `bold="1"`
|
||
(nunca um `fontFace="Bold"`), e itálico é `fontFace="... Italic"` **mais**
|
||
`italic="1"`. O FCP re-exporta `bold="1"` (sem `fontFace`) e
|
||
`fontFace="Medium Italic"` + `italic="1"`, provando o formato correto.
|
||
- **Onde:** `fcpxml/writer.py::_make_text_title_clip` (emissão do `text-style`);
|
||
o estilo em si em `fcpxml/models.py::EDITORIAL_BODY_LOOK`.
|
||
- **Tentativas que falharam:** corrigir manualmente o XML gerado trocando
|
||
`bold="0" fontFace="Bold"` por `bold="1"` — resolvia só aquele arquivo e o
|
||
bug reaparecia a cada geração. Também tentei "corrigir" os offsets dos
|
||
títulos (achando que estavam fora da realidade por estarem em coordenadas de
|
||
source) e quebrei o arquivo com timebases errados (24000, 30000) — os offsets
|
||
em source coords estavam corretos o tempo todo (ver entrada de 2026-08-17
|
||
sobre "anchored in SOURCE media coordinates").
|
||
- **Solução adotada:** em `_make_text_title_clip`, traduzir a face "bold" para
|
||
`bold="1"` sem `fontFace`; emitir `italic="1"` quando a face contém "italic";
|
||
e não mais emitir `bold="0"` junto de uma face. Agora a saída bate com a
|
||
re-exportação do FCP (corpo `bold="1"`, palavra-chave `fontFace` + `italic="1"`).
|
||
- **Aprendizado:** o FCPXML do template "Text" usa `bold` (atributo) para peso e
|
||
`fontFace`+`italic` para a face itálica; "Bold" não é um valor válido de
|
||
`fontFace`. Ao duvidar de um formato, confiar na re-exportação do FCP (saída
|
||
canônica) e nunca "corrigir" offsets/times que já seguem a convenção do
|
||
gerador. Também: comparar a saída gerada contra o FCP byte a byte por campo
|
||
(bold/fontFace/italic) antes de assumir o problema em outro lugar.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
### 2026-08-14 — Início do registro de experiências
|
||
|
||
- **Sintoma:** não havia um local centralizado para registrar erros/estruturas
|
||
problemáticas; cada correção era tratada isoladamente.
|
||
- **Causa raiz:** ausência de um artefato de memória de projeto; o contexto de
|
||
bugs já resolvidos se perdia entre sessões.
|
||
- **Onde:** `Engine/docs/05_EXPERIENCIAS.md` (este arquivo, recém-criado).
|
||
- **Tentativas que falharam:** n/a (primeira entrada).
|
||
- **Solução adotada:** criação deste arquivo com template padronizado, integrado
|
||
ao fluxo de validação pós-correção (`Engine/run_after_fix.sh`).
|
||
- **Aprendizado:** registrar problemas continuamente reduz o retrabalho; uma
|
||
entrada clara evita reabrir bugs já entendidos.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 19 — `output_dir` aplicado só como cerca, nunca como destino
|
||
|
||
- **Data:** 2026-08-19
|
||
- **Sintoma:** toda chamada com `output_dir` diferente da pasta do arquivo de
|
||
entrada morria com `Output path escapes allowed directory`, apontando para um
|
||
caminho que a própria função tinha acabado de montar. Na prática o ajuste
|
||
"Pasta do projeto" do app só funcionava quando apontava para a pasta onde o
|
||
arquivo já ia cair sozinho — ou seja, nunca fazia nada.
|
||
- **Causa raiz:** em `_resolve_io_paths` (`server_tools/_shared.py`) o
|
||
`output_dir` virava apenas `anchor_dir` da validação, enquanto o nome do
|
||
arquivo continuava saindo de `generate_output_path(filepath, suffix)`, que
|
||
preserva o diretório da ENTRADA. Cerca em um lugar, destino em outro: o
|
||
caminho gerado ficava fora da própria cerca. Afetava os 18+ handlers de
|
||
escrita, não só as legendas onde o erro apareceu.
|
||
- **Solução adotada:** quando `output_dir` é passado, o destino padrão passa a
|
||
ser `<output_dir>/<nome derivado>`; sem ele, mantém-se o comportamento antigo
|
||
(ao lado da entrada). Um `output_path` explícito continua vencendo e continua
|
||
obrigado a ficar dentro da âncora. `build_voice_timeline` e
|
||
`refine_voice_timeline` passaram a aceitar e repassar `output_dir`; os
|
||
leitores procuram na pasta do projeto primeiro e caem para o lado da mídia,
|
||
para não perder timelines geradas antes da mudança.
|
||
- **Aprendizado:** validação e destino não podem ser derivados de fontes
|
||
diferentes. Quando um parâmetro tem dois papéis (permissão e endereço),
|
||
aplicar só um dos dois produz um erro que acusa o próprio código — e some da
|
||
vista porque o caso que funciona é justamente o caso trivial.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 20 — Cadeia de processamento sem o passo que corta
|
||
|
||
- **Data:** 2026-08-19
|
||
- **Sintoma:** o encadeamento do app ia de `analyze_voice` direto para
|
||
`remove_silences`/legendas. Dava para medir a voz e legendar o resultado, mas
|
||
não para aplicar as decisões de edição — o corte por voz tinha que ser rodado
|
||
à mão, fora do app, e era fácil parar no primeiro passo achando que o arquivo
|
||
estava pronto.
|
||
- **Causa raiz:** `apply_voice_actions` existia como handler MCP mas nunca foi
|
||
exposto na ponte `admin/models_api.py`, então o batch não tinha como chamá-lo.
|
||
- **Solução adotada:** comando `apply_voice_actions` na ponte (aceita
|
||
`actions_path` apontando para o JSON de decisões, com ou sem o embrulho
|
||
`{"actions": [...]}`), e a etapa correspondente no batch do app, posicionada
|
||
logo após a análise e **antes** de qualquer passo que faça ripple — os
|
||
zooms/textos/marcadores são posicionados deslocando a partir da própria lista
|
||
de cortes, então rodar depois de outro corte os joga no frame errado sem erro
|
||
visível. O botão fica bloqueado se a etapa estiver ligada sem arquivo
|
||
escolhido, para a cadeia não quebrar no meio.
|
||
- **Aprendizado:** um passo que só existe como ferramenta MCP não existe para
|
||
quem usa o app. Vale conferir se toda etapa documentada no fluxo tem
|
||
representação na cadeia que o usuário de fato executa.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 21 — 2026-08-19 — Teste travado no default antigo de `zoom scale`
|
||
|
||
- **Sintoma:** `tests/test_voice_actions.py::test_default_scale_when_absent`
|
||
quebrando com `KeyError: 'scale'`, sem relação com a alteração em curso.
|
||
- **Causa raiz:** `parse_actions` deixou de carimbar `scale=1.3` quando o
|
||
parâmetro vem ausente, justamente para que
|
||
`server_tools/_shared.py` use o `zoom_scale` configurado pelo usuário. O
|
||
teste continuou afirmando o default antigo, então passou a acusar como erro
|
||
exatamente o comportamento desejado.
|
||
- **Solução adotada:** teste reescrito para o contrato novo — um `scale`
|
||
omitido tem que chegar ausente ao aplicador (`test_absent_scale_is_left_absent`).
|
||
- **Aprendizado:** quando um default sai do parser e vira configuração, o teste
|
||
que afirmava o valor antigo passa a defender o bug. Ao remover um default,
|
||
procure o teste que o fixava no mesmo commit — senão ele fica dizendo o
|
||
contrário do código, e a próxima pessoa perde tempo achando que quebrou algo.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 22 — 2026-08-19 — `VideoPlayer` (AVKit) derruba o app compilado por `swiftc`
|
||
|
||
- **Sintoma:** "G-ART encerrou inesperadamente" (SIGABRT) toda vez que o
|
||
assistente entrava na etapa 5. Nada aparecia na tela antes do crash.
|
||
- **Causa raiz:** o app é montado invocando `swiftc` direto
|
||
(`MacApp/build_app.sh`), não pelo Xcode. Nesse modo o runtime não consegue
|
||
resolver a superclasse Objective-C de `VideoPlayer`:
|
||
`failed to demangle superclass of VideoPlayerView from mangled name
|
||
'So12AVPlayerViewC'` → `getSuperclassMetadata` chama `fatalError`. É erro de
|
||
runtime, então a compilação passa limpa e o problema só aparece ao abrir a
|
||
view.
|
||
- **Solução adotada:** trocar `VideoPlayer` por um `AVPlayerLayer` dentro de um
|
||
`NSViewRepresentable` (`PlayerSurface`/`PlayerLayerView` em
|
||
`PhraseReviewView.swift`). Só depende de AVFoundation, que linka normalmente.
|
||
Os controles de transporte já viviam na barra da timeline, então não se perde
|
||
nada com a chrome do AVKit.
|
||
- **Aprendizado:** compilar limpo não prova que um componente de framework
|
||
existe em runtime neste build. Ao usar uma view SwiftUI que embrulha uma
|
||
classe AppKit/ObjC (AVKit, WebKit, MapKit), abra a tela de fato antes de
|
||
concluir. Um harness pequeno (`swiftc` com os mesmos fontes + um `@main` que
|
||
monta só aquela view e sai) reproduz o crash em segundos, sem precisar
|
||
navegar o app inteiro até lá.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 23 — 2026-08-19 — Dividir um módulo em pacote quebra quem faz `patch` nele
|
||
|
||
- **Sintoma:** ao transformar `fcpxml/writer.py` (4.199 linhas) no pacote
|
||
`fcpxml/writer/`, quatro testes passaram a falhar com
|
||
`AttributeError: module 'fcpxml.writer' has no attribute 'subprocess'` —
|
||
embora nenhuma linha de lógica tivesse mudado.
|
||
- **Causa raiz:** os testes usavam `@patch('fcpxml.writer.subprocess.run')`.
|
||
Isso não depende da API pública, e sim de *onde o import mora*: com o
|
||
módulo dividido, `subprocess` passou a ser importado por
|
||
`fcpxml/writer/document.py`, então o alvo do patch deixou de existir.
|
||
Re-exportar no `__init__` não resolveria — substituir
|
||
`fcpxml.writer.subprocess` não afeta a referência que `document` já tem.
|
||
- **Solução adotada:** apontar o patch para o módulo real
|
||
(`fcpxml.writer.document.subprocess.run`). Duas armadilhas do tipo foram
|
||
evitadas antes: imports relativos precisam de um ponto a mais ao descer um
|
||
nível (`from .models` → `from ..models`), inclusive os que ficam *dentro*
|
||
de funções, e o `__all__` precisa listar os nomes com underscore que o
|
||
resto do projeto já importava, senão a divisão vira quebra de API.
|
||
- **Aprendizado:** a suíte protege comportamento, não localização. Antes de
|
||
dividir um módulo, procure por `patch('<modulo>.` e por imports relativos
|
||
escondidos dentro de funções — são as duas coisas que uma refatoração
|
||
puramente mecânica quebra em silêncio, e as únicas que os testes pegam
|
||
tarde.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 24 — 2026-08-19 — Teste existia, mas estava fora da suíte
|
||
|
||
- **Sintoma:** `admin/test_models_api.py` (13 testes) nunca rodava. Não
|
||
falhava — simplesmente não era coletado, então `models_api.py` figurava
|
||
como "coberto" sem que uma única asserção fosse executada em nenhum
|
||
commit.
|
||
- **Causa raiz:** `testpaths = ["tests"]` no `pyproject.toml`, com o pytest
|
||
rodando de `code/`. O arquivo morava em `admin/`, fora do alcance. Rodá-lo
|
||
à mão também falhava (`ModuleNotFoundError: admin`), porque a raiz do
|
||
repositório não entra no `sys.path` — ou seja, o único jeito de executá-lo
|
||
exigia saber de antemão que ele existia e como.
|
||
- **Solução adotada:** movido para `code/tests/test_models_api.py`, com o
|
||
insert da raiz do repositório no `sys.path` ao lado do import que precisa
|
||
dele. Passou a rodar no gate: 1441 → 1454 testes.
|
||
- **Aprendizado:** um teste fora de `testpaths` é pior que teste nenhum — ele
|
||
dá a sensação de rede sem ser rede. Ao mover ou criar teste fora da pasta
|
||
padrão, confirme que a contagem total subiu; se não subiu, ele não está
|
||
rodando. Vale também para o lint: `admin/` ainda não é coberto pelo
|
||
`run_after_fix.sh`, que roda só dentro de `code/`.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## 25 — 2026-08-20 — `admin/api/shared.py` apontava para `admin/code` (inexistente)
|
||
|
||
- **Sintoma:** app do usuário crashava em toda ação que passa por `server`
|
||
(ex: "Analisar voz"), com `ModuleNotFoundError: No module named
|
||
'server_tools'`. Sobreviveu a **duas rodadas de validação minha** na sessão
|
||
anterior — lint zero, 1454 testes verdes, comando testado manualmente pela
|
||
ponte — sem nenhuma delas pegar o bug.
|
||
- **Causa raiz:** ao dividir `admin/_shared.py` (#25 da sessão de refatoração,
|
||
commit `ffaebb3`) em `admin/api/*.py`, o cálculo
|
||
`Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajuste.
|
||
No arquivo original (`admin/models_api.py`, direto em `admin/`), dois
|
||
`.parent` chegam na raiz do repo. Em `admin/api/shared.py`, um nível mais
|
||
fundo, dois `.parent` param em `admin/` — e `admin/code` nunca existiu.
|
||
`sys.path` nunca recebia `code/`, então `import server_tools` (que só
|
||
funciona com `code/` no path) falhava assim que qualquer handler tentava
|
||
`from server import ...`.
|
||
- **Por que passou pela validação anterior:** todo teste que exercitava esse
|
||
caminho importava `admin.api.*` **dentro do processo do pytest**, que já
|
||
roda com `cwd=code/` sob um venv com **install editável**
|
||
(`__editable__.fcp_mcp_server*.pth`) — isso já deixa `fcpxml`/`server_tools`
|
||
importáveis por conta própria, mascarando qualquer erro no cálculo manual
|
||
de `sys.path`. O teste manual pela ponte (`uv run python
|
||
admin/models_api.py analyze_voice ...`) tem o mesmo problema: `uv run`
|
||
ativa o mesmo venv com o mesmo install editável. **Só o app real, chamando
|
||
o fallback `python3` sem `uv` ou um venv sem o install editável, expõe o
|
||
bug** — que é exatamente a diferença entre o ambiente de teste e o do
|
||
usuário.
|
||
- **Solução adotada:** o cálculo de `sys.path` saiu de cada módulo de
|
||
comando e passou a existir **uma única vez**, em `admin/api/__init__.py`
|
||
— que roda antes de qualquer submódulo do pacote, então nenhum deles
|
||
precisa da própria cópia. `.parent.parent.parent` (três níveis: `api/` →
|
||
`admin/` → raiz → `code/`).
|
||
- **Como o teste de regressão foi validado (e por que precisou de duas
|
||
tentativas):** a primeira versão do teste também passava com o bug
|
||
presente, pelo mesmo motivo do parágrafo acima — rodava em processo com o
|
||
install editável ativo. Só ficou confiável rodando um `subprocess` limpo
|
||
que remove manualmente qualquer entrada `site-packages` de `sys.path`
|
||
antes de importar, isolando o mecanismo real que o `__init__.py` precisa
|
||
fornecer. Confirmado nos dois sentidos: falha com o bug reintroduzido,
|
||
passa com a correção (`tests/test_models_api.py::TestCodeDirResolution`).
|
||
- **Aprendizado:** um install editável no venv de teste é uma segunda fonte
|
||
de verdade que mascara bugs de `sys.path` — o mesmo defeito de "a suíte
|
||
passa mas o comportamento real não bate" da entrada #23, só que desta vez
|
||
nem *rodar o comando manualmente* pegou, porque o `uv run` usado para
|
||
testar caía no mesmo venv "de sorte" que o app não usa. Ao validar correção
|
||
de caminho/import, rodar num ambiente que não tenha as dependências
|
||
instaladas por fora do mecanismo sendo testado — ou o teste prova que o
|
||
ambiente de teste está bem configurado, não que o código está certo.
|
||
- **Estado:** `resolvido`
|
||
|
||
---
|
||
|
||
## Entrada #26 — Prompt da IA local estoura o contexto do Ollama (e erro de parse escapa)
|
||
|
||
- **Sintoma:** botão "Gerar roteiro por IA local" (etapa 4 do assistente)
|
||
devolvia "Falha ao gerar roteiro por IA local". Rodando a ponte direto, o
|
||
erro real aparecia como *"Server disconnected without sending a response"*
|
||
ou *"Connection refused"* do Ollama, e 0 decisões ("Decisões do modelo: 0").
|
||
- **Causa raiz (dupla):**
|
||
1. `build_edit_messages` embutia o JSON da voice timeline **inteiro** no
|
||
prompt. Uma gravação de 3min vira ~188KB / **~47k tokens** (cada palavra
|
||
carrega energia, pitch, arousal, valence, `samples`…). Como `num_ctx`
|
||
estava em 32768, o prompt estourava a janela e o Ollama **dropava a
|
||
conexão** sem resposta.
|
||
2. Quando a conexão cai sem resposta, `httpx` entrega um body vazio e
|
||
`response.json()` lançava `JSONDecodeError` — que **não** é
|
||
`httpx.HTTPError`, então escapava do `try/except` de `ollama_chat` e
|
||
virava a exceção genérica que o `cmd_generate_voice_script` transforma
|
||
em `ok:false` com a mensagem "Falha ao gerar roteiro por IA local: …".
|
||
- **Correção (em `fcpxml/llm_local.py` + `server_tools/voice.py`):**
|
||
- `build_edit_messages` agora projeta a timeline (**`_project_timeline`**):
|
||
mantém só `text`/`start`/`end`/`speaker`/`emphasis`/`pause_before` das
|
||
palavras e `id`/`name` dos locutores; descarta `layers`, `scales`,
|
||
`samples` e os floats de áudio. Caiu de ~47k para **~17k tokens** (69KB).
|
||
- Salvaguarda `_shrink_to_fit`: se ainda passar de `max_chars` (110k),
|
||
remove os `words` dos segmentos de menor `peak_emphasis` até caber.
|
||
- `ollama_chat` envolve `post`+`raise_for_status`+`json()` num único
|
||
`except Exception` que relança como `RuntimeError` claro — fim do
|
||
`JSONDecodeError` escapando.
|
||
- `_extract_json` agora desembrulha a lista de 1 elemento `[{source,
|
||
actions}]` que alguns modelos devolvem, senão o `parse_actions` tratava o
|
||
objeto-wrapper como uma ação sem `kind` e rejeitava tudo (0 decisões).
|
||
- `handle_generate_voice_script` levanta `RuntimeError` com a causa quando o
|
||
modelo não devolve nenhuma decisão utilizável, então o app mostra a
|
||
mensagem real ("O modelo local não devolveu decisões utilizáveis: …")
|
||
em vez do genérico.
|
||
- **Validação:** `tests/test_llm_local.py` ganhou `test_build_edit_messages_is_compact`
|
||
(prompt < raw, sem `samples`/`energy_raw`/`pitch_hz`) e
|
||
`test_ollama_chat_wraps_empty_response`. Ponte testada com Ollama mockado
|
||
nos dois sentidos (sucesso aplica; falha → `ok:false` com msg clara).
|
||
- **Estado:** `resolvido`
|
||
|
||
> **Aprendizado:** modelo local tem contexto finito — nunca embutir o objeto
|
||
> de análise cru no prompt; projetar só o que a decisão usa. E qualquer parse
|
||
> de resposta de servidor local deve tratar body vazio/quebrado como erro de
|
||
> transporte, não como sucesso mudo.
|
||
|
||
---
|
||
|
||
### 2026-08-21 — Cortes escritos rente ao timestamp da palavra soam secos
|
||
|
||
- **Sintoma:** usuário revisou o corte final (projeto Mastopexia) e reportou
|
||
"os cortes estão muito secos, principalmente no final de frase — falta um
|
||
tempinho a mais pra concluir as palavras". Também notou que o ar morto
|
||
antes da primeira fala do vídeo não tinha sido cortado.
|
||
- **Causa:** o critério `06-texto-corte-marcador.md` (e o prompt embutido do
|
||
modelo local em `fcpxml/llm_local.py`) instruíam cobrir a frase inteira
|
||
(`start..end = início..fim da frase`) ao escrever um `cut`, sem nenhuma
|
||
orientação sobre a borda que encosta em fala **mantida** (não em silêncio
|
||
puro). Um `cut` com `start` exatamente no fim da última palavra mantida
|
||
engole essa palavra antes dela terminar de soar; um `cut` com `end` no
|
||
início exato da próxima engole o ataque da fala seguinte. É um problema
|
||
diferente de cortar a pausa curta (proibido, é a própria ênfase) — aqui a
|
||
pausa natural entre os blocos já existe, e o corte estava comendo essa
|
||
margem sozinho.
|
||
- **Correção:**
|
||
- `06-texto-corte-marcador.md` ganhou a seção "Nunca corte rente à
|
||
palavra — deixe uma folga": recuar `start`/`end` do corte em ~0,15–0,25s
|
||
para dentro do próprio corte nas bordas que tocam fala mantida (não em
|
||
silêncio puro), incluindo o início/fim do vídeo.
|
||
- `fcpxml/llm_local.py::_SYSTEM_PROMPT` (item 4) recebeu a mesma
|
||
instrução, para o modelo local gerar decisões já com a folga.
|
||
- **Validação manual:** reaplicado no projeto Mastopexia real —
|
||
`10.77 → 95.50` (rente) virou `10.97 → 95.30` (folga de ~0,2s nas duas
|
||
pontas), e as 4 emendas seguintes receberam o mesmo tratamento; zoom/texto/
|
||
marcador continuaram longe o suficiente da nova borda do corte — a folga
|
||
também evita o problema relacionado (não corrigido em código, só
|
||
contornado manualmente nesta sessão): um `zoom`/`marker` cuja borda cai
|
||
exatamente em cima do início/fim de um `cut` é descartado por
|
||
`resolve_actions` como "apontando para material cortado", mesmo quando a
|
||
intenção era ficar bem ao lado. Vale registrar como dívida: `resolve_actions`
|
||
poderia tolerar uma margem de meio-frame antes de considerar a ação "dentro"
|
||
do corte.
|
||
- **Estado:** `resolvido`
|
||
|
||
> **Aprendizado:** "cobrir a frase inteira" não é a instrução completa para
|
||
> um corte — a frase que **sobra** ao lado do corte também precisa de uma
|
||
> borda que respire. Regra prática: só cortar rente ao timestamp quando a
|
||
> borda encosta em silêncio real (`gap_before` grande) ou em conteúdo que
|
||
> também será descartado; encostando em fala mantida, sempre recuar.
|
||
|
||
---
|
||
|
||
### 2026-08-21 — Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando
|
||
|
||
- **Sintoma:** usuário viu, no Final Cut, um clipe minúsculo sobrando entre
|
||
dois clipes normais na timeline (projeto Mastopexia, confirmado por
|
||
screenshot). Investigação achou 29 `cut`s individuais no
|
||
`_phrase_actions.json` gerado pela etapa 5, e a timeline final saiu com
|
||
mais de uma dezena de fatias de 0,1-0,5s entre clipes.
|
||
- **Causa:** `phrase_review_to_actions()` (`fcpxml/phrase_review.py`) gerava
|
||
**um `cut` por frase desativada**, cobrindo só `[phrase.start, phrase.end]`.
|
||
Quando duas ou mais frases seguidas estão desativadas, a pausa **entre**
|
||
elas nunca pertence a nenhuma frase — não é coberta por nenhum `cut` — e
|
||
sobrevive como um clipe próprio, minúsculo, que ninguém pediu para manter.
|
||
- **Correção:** `phrase_review_to_actions()` agora agrupa frases desativadas
|
||
**consecutivas** (`flush_inactive_run()`) e emite um único `cut` cobrindo do
|
||
início da primeira ao fim da última do grupo, absorvendo as pausas entre
|
||
elas. Uma frase ativa no meio ainda quebra o grupo — cuts continuam
|
||
separados quando há conteúdo mantido entre eles.
|
||
- **Validação:** `tests/test_phrase_review.py` ganhou
|
||
`test_consecutive_inactive_phrases_merge_into_one_cut`,
|
||
`test_inactive_run_at_the_end_still_flushes` e
|
||
`test_isolated_inactive_phrases_stay_separate_cuts`. No projeto Mastopexia
|
||
real, 29 cuts individuais viraram 3 cuts mescladas; a contagem de fatias
|
||
sub-segundo na timeline final caiu de mais de uma dezena para 4 (resíduo
|
||
menor, provavelmente do padding do `remove_media_silence` na emenda entre
|
||
clipes — não investigado a fundo nesta sessão, ver `09_MANUTENCAO.md`).
|
||
- **Estado:** `resolvido` (a causa principal); a sobra residual do
|
||
`remove_media_silence` continua como dívida separada.
|
||
|
||
> **Aprendizado:** "cortar cada frase desativada" não é a mesma coisa que
|
||
> "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido
|
||
> entre elas — a pausa entre duas coisas descartadas também precisa ser
|
||
> descartada, e ninguém a cobre por definição se o corte for por frase.
|
||
|
||
---
|
||
|
||
### 2026-08-21 — `remove_media_silence` (dB) não pega lacuna sem fala com som real
|
||
|
||
- **Sintoma:** usuário viu, no projeto Mastopexia real, um trecho de ~1,9s
|
||
sem fala (imagem parada antes da tomada começar) que sobreviveu intacto
|
||
na timeline final — depois de `apply_voice_actions`, `remove_media_silence`
|
||
e `generate_dynamic_subtitles` já terem rodado. Achou que era bug de ordem
|
||
no encadeamento das etapas ("corta e depois volta").
|
||
- **Investigação:** não era ordem. Extraído o áudio real do trecho
|
||
(`ffmpeg -af volumedetect`): `mean_volume -21.4dB`, `max_volume 0.0dB` —
|
||
longe do limiar padrão de silêncio (-30dB). Rodado `detect_silence` nos
|
||
mesmos limiares do sistema (-30/-25/-20/-16dB): nenhum sinaliza o trecho.
|
||
O trecho tem som real (roupa, respiração, ambiente) mas nenhuma palavra —
|
||
exatamente o caso que `06-texto-corte-marcador.md` já descrevia
|
||
("ausência de fala não é ausência de som"), só que sem ferramenta para
|
||
agir sobre ele: `remove_media_silence` só enxerga volume, nunca vai
|
||
cortar algo que soa alto mas não tem fala.
|
||
- **Correção:** nova função pura `speech_gap_cut_actions()` em
|
||
`fcpxml/voice_actions.py` — gera `cut`s a partir dos gaps entre
|
||
`words[].start/end` do `_voice_timeline.json` (tempo de fonte, como todo
|
||
`VoiceAction`), com a mesma folga por dentro (`padding`) que
|
||
`speaker_cut_actions()` já usava. Nova tool MCP `remove_speech_gaps`
|
||
(`server_tools/voice.py`, mesmo molde de `remove_speakers`): resolve o
|
||
`media_path`, lê a timeline, gera as ações e reaplica via
|
||
`handle_apply_voice_actions` — não duplica a lógica de corte no FCPXML.
|
||
Deliberadamente não corta a lacuna antes da primeiríssima palavra (pode
|
||
ser quase o arquivo inteiro, antes da tomada começar de verdade).
|
||
- **Ordem revista:** `apply_voice_actions → remove_speech_gaps →
|
||
remove_media_silence → generate_dynamic_subtitles` — a lacuna "sem fala"
|
||
some primeiro (cobertura ampla, por transcrição), o que sobra de silêncio
|
||
técnico *dentro* da fala é apertado depois.
|
||
- **Validação:** `tests/test_voice_actions.py::TestSpeechGapCutActions`
|
||
(gap acima/abaixo do limiar, lacuna antes da 1ª palavra nunca cortada,
|
||
segmentos com palavras sobrepostas não quebram, timeline vazia). Suíte
|
||
completa (1508 testes) roda limpa.
|
||
- **Estado:** `resolvido`
|
||
|
||
> **Aprendizado:** um detector de silêncio por dB nunca vai cobrir "sem fala
|
||
> com som" — são categorias diferentes, não uma questão de calibrar o
|
||
> limiar. Quando já existe transcrição confiável, ela é a fonte melhor para
|
||
> "onde não tem fala": não depende de threshold nenhum, só da própria
|
||
> palavra existir ou não naquele instante.
|
||
|
||
---
|
||
|
||
### 2026-08-24 — Zoom era `<adjust-transform>` no próprio clipe; FCP exporta como clipe de ajuste
|
||
|
||
- **Sintoma:** usuário pediu para o zoom parar de mexer diretamente no
|
||
clipe da timeline e passar a usar um "adjustment clip" com crop
|
||
animado — o jeito como ele já fazia zoom manualmente no FCP.
|
||
- **Investigação:** não havia amostra real no projeto para confirmar a
|
||
forma exata do XML (`adjust-crop`? um `<clip>` com `<adjustment>` como
|
||
`fcpxml/writer/adjustment.py` já fazia para filtros?). O usuário enviou
|
||
um `.fcpxmld` exportado pelo próprio FCP com um zoom manual
|
||
(`exemplo zoom.fcpxmld`), que revelou a forma real: um `<video ref="...">`
|
||
referenciando o efeito nativo `FFAdjustmentEffect` ("Clipe de Ajuste"),
|
||
anexado numa lane acima do clipe, com seu **próprio** `<adjust-transform>`
|
||
animando `scale` de `1 1` até o pico — não `adjust-crop`, e não o wrapper
|
||
`<adjustment>` que `adjustment.py` usa (que, conferido contra o DTD real
|
||
da Apple, **não existe** — aquele módulo gera XML inválido; ver dívida
|
||
em `09_MANUTENCAO.md`). Cruzado com o DTD oficial (`FCPXMLv1_13.dtd`, uma
|
||
cópia local encontrada fora do projeto): `<video>` é `%anchor_item;`
|
||
válido sem precisar de asset, e `adjust-transform` é filho direto seu.
|
||
- **Correção:** `add_zoom` (extraído para `fcpxml/writer/zoom.py`, deixou
|
||
de compartilhar módulo com `change_speed`) agora cria um `<video>`
|
||
conectado em vez de animar o clipe base. Isso **simplificou** a lógica
|
||
antiga: como o clipe de ajuste composita por cima da imagem já
|
||
reenquadrada, não precisa mais ler/preservar rotação, posição ou escala
|
||
do clipe original (a classe de teste inteira sobre "preservar
|
||
enquadramento" — e o bug histórico #15 que ela cobria — deixou de fazer
|
||
sentido); e dois zooms disjuntos no mesmo clipe agora são dois `<video>`
|
||
irmãos, não um merge de keyframes num `<adjust-transform>` só.
|
||
- **Validação:** os 22 testes de zoom em `test_writer.py` reescritos contra
|
||
a nova forma (`clip.find('video').find('adjust-transform')...`), mais
|
||
`test_voice_actions_tool.py`. Offset/duration da timeline gerada
|
||
conferidos byte a byte contra os números reais do `.fcpxmld` de exemplo
|
||
(bateram exatamente). Suíte completa roda limpa.
|
||
- **Estado:** `resolvido`
|
||
|
||
> **Aprendizado:** para decisões de forma exata de XML, um exemplo real
|
||
> exportado pelo próprio FCP vale mais que qualquer inferência — a diferença
|
||
> entre `adjust-crop`, o wrapper inválido de `adjustment.py` e a forma real
|
||
> (`<video ref="FFAdjustmentEffect">`) não dava para cravar sem um dos dois
|
||
> (amostra real ou o DTD oficial da Apple, que também foi cruzado aqui).
|
||
> Peça o exemplo antes de implementar às cegas.
|
||
|
||
---
|
||
|
||
### 2026-08-24 — Revisão de falantes salvava certo, mas a etapa 4 nunca lia o resultado
|
||
|
||
- **Sintoma:** usuário desmarcou falas de bastidor na tela "Quem fica na edição"
|
||
(etapa 3, `SpeakerReviewView`) e clicou "Salvar seleção", mas as falas
|
||
desmarcadas continuavam voltando na revisão de frases (etapa 4) e no roteiro
|
||
final gerado a partir dela.
|
||
- **Causa raiz:** `save_speaker_review` (`fcpxml/speaker_review.py`) e o
|
||
`_voice_timeline_clean.json` que ela grava estavam **corretos** — conferido
|
||
num projeto real: 37 segmentos na timeline crua, 15 marcados `excluded` na
|
||
revisão salva, 22 sobrando no `_clean.json` (37-15=22, bate exato). O bug
|
||
estava um passo adiante: `cmd_build_phrase_review`
|
||
(`admin/api/review.py`), que monta a etapa 4, abria
|
||
`args.get("voice_timeline")` — o arquivo **cru** — direto, sem nunca checar
|
||
se existia um `_voice_timeline_clean.json` ao lado. `generate_voice_script`
|
||
(`server_tools/voice.py`) e `copyForChat` (`WizardView.swift`) já faziam
|
||
essa checagem corretamente; só a etapa 4 ficou de fora.
|
||
- **Onde:** `admin/api/review.py::cmd_build_phrase_review`.
|
||
- **Por que passou despercebido:** a tela de revisão de falantes em si
|
||
funcionava e mostrava "Salvo" — o problema só aparecia num passo seguinte
|
||
e sem nenhum erro, então parecia que "a seleção não estava sendo salva"
|
||
quando na verdade ela salvava certo e era ignorada mais adiante.
|
||
- **Solução adotada:** `cmd_build_phrase_review` agora resolve
|
||
`speaker_review.clean_voice_timeline_path(timeline_path)` primeiro e lê
|
||
esse arquivo quando ele existe, caindo para o cru só na ausência dele —
|
||
mesma checagem que os outros dois pontos já faziam.
|
||
- **Aprendizado:** quando existem **múltiplos pontos de leitura** de um
|
||
mesmo artefato derivado (aqui: três lugares que podem preferir
|
||
`_voice_timeline_clean.json` sobre o cru), adicionar a checagem em um novo
|
||
ponto de leitura não é opcional — ela precisa ser replicada em todos, ou o
|
||
comportamento diverge silenciosamente conforme o caminho que o app tomar.
|
||
Vale grepar por todo lugar que abre o arquivo "canônico" sempre que um
|
||
arquivo "_clean"/derivado for introduzido.
|
||
- **Estado:** `resolvido` — corrigido em `admin/api/review.py`, suíte
|
||
completa (1506 de 1508 testes; as 2 falhas restantes são de ambiente —
|
||
WhisperX/torchcodec sem libs de sistema, sem relação com a mudança) e
|
||
lint do arquivo alterado limpos.
|
||
|
||
---
|
||
|
||
### Entrada 32 — 2026-08-24: `<title>` leva `role`, nunca `videoRole`
|
||
|
||
**Sintoma:** ao atribuir role de vídeo a legendas geradas (para separar
|
||
legendas dinâmicas de convencionais na timeline), a validação contra o DTD
|
||
FCPXML v1.13 quebrou com `No declaration for attribute videoRole of element
|
||
title`.
|
||
|
||
**Causa:** no DTD da Apple, `<title>` (`<!ATTLIST title %clip_attrs;>` +
|
||
`<!ATTLIST title role CDATA #IMPLIED>`) **não** declara `videoRole`. Esse
|
||
atributo existe em `<video>`, `<asset-clip>`, `<clip>` etc., mas não em
|
||
títulos. `<title>` usa o atributo genérico `role` (CDATA). Confirmado no
|
||
`FCPXMLv1_13.dtd` linhas 566–569.
|
||
|
||
**Decisão:** legendas dinâmicas e convencionais recebem `role="titles.dinamicas"`
|
||
e `role="titles.convencionais"` (sub-roles de `titles`, NUNCA `subtitles.*` —
|
||
ver entrada sobre roteamento de captions). O campo de config e o parâmetro dos
|
||
geradores chama-se `role` (não `video_role`). `assign_role` (mixin `RolesMixin`)
|
||
continua correto para clips/vídeos, pois seta `videoRole` neles — não confundir
|
||
os dois caminhos.
|
||
|
||
**Lição:** antes de setar `videoRole` num elemento qualquer, conferir o DTD:
|
||
títulos usam `role`. Teste de regressão em `tests/test_dynamic_subtitles.py`
|
||
(`test_titles_carry_title_subrole`) garante `titles.*` e bloqueia `subtitles.*`.
|
||
|
||
> **Nota de reconciliação:** entradas antigas deste arquivo (2026-08-17)
|
||
> afirmavam "nenhum título gerado carrega `role`" e tinham o teste
|
||
> `test_titles_carry_no_caption_role`. Aquilo referia-se **especificamente**
|
||
> a `role="subtitles.*"` (que roteia o título para a pista de captions e o
|
||
> esconde). A regra continua válida: proibido `subtitles.*`. O que mudou é que
|
||
> agora aplicamos `role="titles.*"` (sub-role de título, válido no DTD e útil
|
||
> para separar dinâmicas de convencionais na timeline). O teste foi renomeado
|
||
> para `test_titles_carry_title_subrole` e passa a exigir `titles.*` + bloquear
|
||
> `subtitles.*`.
|
||
|
||
---
|
||
|
||
### Entrada 33 — 2026-09-22: legenda comum sob a composição dinâmica; regenerar acumulava títulos
|
||
|
||
**Sintoma:** num corte real (Mastopexia), aos 11s a legenda comum "mamas
|
||
também mudam. É" aparecia simultaneamente com a composição dinâmica de
|
||
ênfase, poluindo o quadro com texto duplicado. Gerar novamente as legendas
|
||
(dinâmica ou convencional) sobre um clipe já legendado empilhava um segundo
|
||
conjunto de títulos por cima do anterior em vez de substituí-lo.
|
||
|
||
**Causa raiz — duas falhas distintas:**
|
||
1. **Sem marcação de autoria.** Os três handlers de legenda
|
||
(`handle_generate_dynamic_subtitles`, `handle_generate_plain_subtitles`,
|
||
`handle_generate_subtitles_by_emphasis`) só *adicionavam* títulos —
|
||
nenhum removia o que uma chamada anterior tinha gerado. Sem uma forma de
|
||
distinguir "título que este programa gerou" de "título que o editor
|
||
inseriu manualmente no FCP", uma regeneração não tinha como saber o que é
|
||
seguro apagar.
|
||
2. **Janela da legenda de ênfase maior que a fala.** Em
|
||
`handle_generate_subtitles_by_emphasis`, o cálculo de fim de bloco usava
|
||
os segmentos brutos do Whisper (`data["segments"]`) para decidir até onde
|
||
a composição dinâmica se estende — não os spans de ênfase revisados
|
||
(`spans`). Um segmento do Whisper cobre a frase inteira; a ênfase cobre só
|
||
o trecho grifado. A dinâmica então ficava "seguindo" além do próprio
|
||
áudio que a originou, invadindo o intervalo onde a legenda comum já
|
||
deveria estar sozinha.
|
||
- **Onde:** `code/fcpxml/writer/titles.py` (`TitlesMixin`) e
|
||
`code/server_tools/subtitles.py` (os três handlers de geração).
|
||
- **Solução adotada:**
|
||
- Todo título/composição gerado por este programa carrega uma marca em
|
||
`<metadata><md key="com.gart.subtitle.kind" value="dynamic|plain">`
|
||
(`mark_generated_subtitle`). Um heurístico de compatibilidade
|
||
(`_generated_subtitle_kind`) reconhece a assinatura exata de exports
|
||
antigos sem a marca (efeito/uid/start de texto do G-ART + padrão de nome),
|
||
para não tratar título manual do editor como "nosso" por engano.
|
||
- Cada handler chama `remove_generated_subtitles(el, kinds)` no início,
|
||
apagando só os títulos com a marca do próprio tipo que está sendo
|
||
regerado — títulos manuais e do outro tipo ficam intactos.
|
||
- `generate_dynamic_subtitles` ganhou o parâmetro `hold_between_sentences`
|
||
(default `True`, preserva o comportamento anterior nas chamadas normais).
|
||
`handle_generate_subtitles_by_emphasis` passa `hold_between_sentences=False`
|
||
e usa os `spans` de ênfase revisados como `emphasis_segments` (em vez dos
|
||
segmentos brutos do Whisper) — a composição dinâmica agora encerra no fim
|
||
real da palavra falada quando o próximo bloco pertence a outra frase, e
|
||
nunca ultrapassa a janela de ênfase que a gerou.
|
||
- `suppress_plain_under_dynamic` recorta (fatiando o clipe do título, sem
|
||
duplicar `text-style`) qualquer legenda comum gerada cujo intervalo caia
|
||
dentro de uma composição dinâmica ainda ativa — mesmo que o cálculo de
|
||
janela de algum outro caminho volte a divergir no futuro, isso funciona
|
||
como rede de segurança contra sobreposição visível.
|
||
- **Aprendizado:** um gerador que pode ser chamado de novo sobre a mesma
|
||
timeline **precisa** de uma forma de reconhecer sua própria saída anterior
|
||
antes de decidir "substituir" — sem isso, "regerar" e "empilhar" são
|
||
indistinguíveis. E ao derivar o fim de uma janela temporal a partir de uma
|
||
fonte (segmentos do Whisper, spans de ênfase, etc.), confirme que a fonte
|
||
escolhida tem a granularidade do fenômeno que está sendo delimitado — usar
|
||
a fonte "mais larga disponível" por conveniência cria sobra sistemática.
|
||
- **Teste de regressão:**
|
||
`code/tests/test_subtitle_overlap_regression.py` — roda o handler real
|
||
(`handle_generate_subtitles_by_emphasis`) contra um intervalo de ênfase
|
||
seguido de uma lacuna de fala comum, e confere que nenhuma composição
|
||
dinâmica sobrepõe uma legenda comum; e que chamar o mesmo handler duas
|
||
vezes não duplica títulos gerados nem remove um título manual inserido
|
||
entre as duas chamadas.
|
||
- **Estado:** `resolvido` — 224 testes das suítes de legenda/writer
|
||
passando (incl. o novo regressivo); suíte completa 1540 passando, 8
|
||
skipped, 1 falha e 1 erro de ambiente sem relação com a mudança (WhisperX/
|
||
`extract_pitch` ausente, torchcodec sem libs de sistema); lint dos arquivos
|
||
alterados limpo.
|