Files
gart/code/Engine/docs/05_EXPERIENCIAS.md
T

709 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 — Experiências: Registro de Problemas, Erros e Decisões
> **Propósito:** registrar, de forma cumulativa, todos os problemas estruturais,
> erros que se repetiram em várias tentativas e decisões difíceis enfrentadas
> durante o desenvolvimento do G-ART. Serve de memória de trabalho para que
> futuras implementações **não repitam os mesmos erros** e para que decisões já
> tomadas não sejam redescobertas do zero.
**Regra:** sempre que um problema for detectado (estrutural ou funcional) e
houver uma correção ou trabalho em torno dele, **adicione um registro aqui**
antes de prosseguir. Um problema que se repete em várias tentativas é sinal de
que merece entrada.
---
## Como registrar (template de entrada)
Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
```markdown
### [DATA] Título curto do problema
- **Sintoma:** o que acontecia / o erro observado.
- **Causa raiz:** o que realmente causava o problema (após investigação).
- **Onde:** arquivo(s) e, se útil, função/linha.
- **Tentativas que falharam:** o que já foi tentado e não funcionou.
- **Solução adotada:** a correção que resolveu.
- **Aprendizado:** regra/comportamento a lembrar nas próximas implementações.
- **Estado:** `aberto` | `resolvido` | `mitigado` | `evitado por design`
```
---
## Registro de Experiências
### 2026-08-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-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`
---
## 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` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.