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

68 KiB
Raw Blame History

05 — Experiências: Registro de Problemas, Erros e Decisões

Propósito: registrar, de forma cumulativa, todos os problemas estruturais, erros que se repetiram em várias tentativas e decisões difíceis enfrentadas durante o desenvolvimento do G-ART. Serve de memória de trabalho para que futuras implementações não repitam os mesmos erros e para que decisões já tomadas não sejam redescobertas do zero.

Regra: sempre que um problema for detectado (estrutural ou funcional) e houver uma correção ou trabalho em torno dele, adicione um registro aqui antes de prosseguir. Um problema que se repete em várias tentativas é sinal de que merece entrada.


Como registrar (template de entrada)

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

### [DATA] Título curto do problema

- **Sintoma:** o que acontecia / o erro observado.
- **Causa raiz:** o que realmente causava o problema (após investigação).
- **Onde:** arquivo(s) e, se útil, função/linha.
- **Tentativas que falharam:** o que já foi tentado e não funcionou.
- **Solução adotada:** a correção que resolveu.
- **Aprendizado:** regra/comportamento a lembrar nas próximas implementações.
- **Estado:** `aberto` | `resolvido` | `mitigado` | `evitado por design`

Registro de Experiências

2026-08-17 — Garantir que dois blocos nunca se sobreponham: empilhar pela TINTA real, não pela cap-height

  • Sintoma: na composição progressiva, a cedilha de "começar" (Playfair Display Medium Italic, 230pt) invadia a linha de apoio logo abaixo. As caixas "lógicas" não se cruzavam — as renderizadas, sim.
  • Causa raiz: o empilhamento usava altura nominal font_size * 0.75 (cap-height). Numa serifada de display itálica os acentos sobem a 1,007em e os descendentes descem a -0,241em: a tinta real ocupa quase o dobro da cap-height, e a folga nominal some.
  • Onde: fcpxml/font_metrics.py (VERTICAL_METRICS), fcpxml/text_layout.py (ink_extent, compose_sentence, PlacedBlock).
  • Tentativas que falharam: aumentar line_gap — afasta as linhas em todos os casos e perde o bloco compacto da referência, sem garantir nada: basta uma fonte com acentos mais altos para colidir de novo.
  • Solução adotada: métricas verticais reais extraídas das fontes (ascent/descent da caixa de linha que o FCP centra na Position, mais os extremos de tinta por classe de glifo: caixa alta, ascendente, x-height, acento maiúsculo/minúsculo, descendente). ink_extent() calcula o topo e a base da tinta DO TEXTO em questão; compose_sentence empilha essas caixas borda a borda com folga fixa. Não-sobreposição vira propriedade da aritmética, não de um fator de segurança. Fonte sem métricas medidas usa um fallback com 8% de folga extra.
  • Aprendizado: medir largura resolve colisão lado a lado; colisão entre linhas exige medir altura de tinta — e ela depende dos caracteres da linha, não só do corpo da fonte.
  • Estado: resolvido

2026-08-17 — Legendas saíam palavra a palavra centradas, e não como a composição progressiva diagramada da referência

  • Sintoma: o usuário mandou o reel de referência (@fernandoluz.d) e disse "elas devem aparecer assim": [que vão] / [melhorar] / [sua legenda] — um bloco por trecho, palavra-chave grande em serifada itálica, complementares pequenas em grotesca, linhas escalonadas. O gerador entregava um <title> por PALAVRA, todos na mesma família, cada linha centrada.
  • Causa raiz: layout_sentence empacota palavra a palavra e centra cada linha; o rhythm variava tamanho/cor por índice (indigo/amarelo/cinza), não por papel semântico da palavra. Nenhum dos dois produz a diagramação.
  • Onde: fcpxml/text_layout.py (compose_sentence, pick_emphasis_index, PlacedBlock), fcpxml/models.py (looks editoriais, granularity), fcpxml/writer.py (emissão por unidade), fcpxml/font_metrics.py, server.py (parâmetros da tool).
  • Tentativas que falharam: tentar aproximar o visual só trocando os tamanhos do rhythm — sem agrupar as palavras de apoio num único título, o resultado continua sendo legenda corrida.
  • Solução adotada: modo granularity="phrase" (padrão): a frase vira linhas — apoio antes, palavra-chave sozinha, apoio depois —, uma linha por <title>, entrando no instante da sua primeira palavra e todas limpando juntas. Destaque em Playfair Display Medium Italic (métricas reais extraídas da fonte instalada e embutidas em font_metrics), apoio em Helvetica Neue Bold, tudo branco, linhas escalonadas por REFERENCE_STAGGER_RATIO. O modo antigo continua disponível em granularity="word".
  • Aprendizado: o destaque é semântico, não posicional — escolher a palavra por índice num ciclo nunca reproduz uma diagramação. E toda fonte nova exige métricas reais antes de entrar no layout: sem elas, a medição de largura erra e duas linhas colidem.
  • Estado: resolvido

2026-08-17 — Importação recusada: id do <text-style-def> derivado do texto da legenda não é um XML Name válido

  • Sintoma: ao gerar títulos/legendas, a validação de DTD falhava com Syntax of value for attribute ref of text-style is not valid + Syntax of value for attribute id of text-style-def is not valid, e o Final Cut recusava o arquivo na importação.
  • Causa raiz: _make_text_title_clip montava o id como f"{name}_ts0", e name vem do texto da legenda ("3 coisas que você precisa saber - Text"). No DTD, id é do tipo ID e ref do tipo IDREF: o valor precisa ser um XML Name — sem espaços, sem acentos, nunca começando por dígito. Os três casos apareciam de uma vez em texto português.
  • Onde: fcpxml/writer.py (_make_text_title_clip, novo _unique_text_style_id).
  • Tentativas que falharam: confiar em _sanitize_xml_value, que protege conteúdo de atributo (CDATA) mas não impõe as regras de XML Name.
  • Solução adotada: _unique_text_style_id() — dobra o texto para ASCII (NFKD), troca tudo que não seja [A-Za-z0-9_.-] por _, prefixa com ts_ (garante início por letra, inclusive quando o texto é só CJK/emoji e o slug fica vazio) e sufixa um contador conferido contra um cache de ids do documento, mantendo unicidade document-wide sem varrer a árvore por título.
  • Aprendizado: todo atributo do tipo ID/IDREF no FCPXML precisa ser gerado, nunca derivado de texto do usuário. Sanitizar valor de atributo e sanitizar identificador são problemas diferentes.
  • Estado: resolvido

2026-08-17 — Títulos ("Essencial - Título"/"Título Básico") nunca apareciam no FCP; o template que renderiza é o "Text" (Basic Text)

  • Sintoma: título gerado no início do vídeo ficava invisível ou "sumia da timeline" (mas continuava na lista de clipes), mesmo com posição/offset aparentemente corretos. Com o template animado, o texto não desenhava; com o estático, o título caía antes do in-point do clipe e o FCP o descartava.
  • Causa raiz (duas, no mesmo ciclo):
    1. Os dois templates que usávamos — "Essencial - Título" (Essential Title.moti) e "Título Básico" (Bumper:Opener/Basic Title.moti) — não resolvem para um template desenhável no FCP. A importação é silenciosa: nada aparece, sem erro. É o MESMO modo de falha silenciosa já registrado duas vezes antes nesta sessão (uid fabricado).
    2. No caminho animated=False, o offset era gravado como relativo (0s) em vez de coordenadas de mídia-fonte (start do clipe-pai + relativo). O FCP lê 0s como "0s da mídia", antes do in-point do clipe (start="220062843/24000s"), então o título nunca cai sobre o vídeo.
  • Onde: fcpxml/writer.py — _TEXTO_TITLE_UID, _BASIC_TITLE_UID, _TEXTO_TITLE_PARAMS, _make_texto_title_clip, _make_basic_title_clip, generate_dynamic_subtitles.
  • Como o usuário resolveu: criou dois títulos à mão no FCP e exportou (teste.fcpxmld e posição.fcpxmld, FCP 1.14 em inglês). Ambos usam o template "Text" (uid=".../Titles.localized/Basic Text.localized/Text.localized/Text.moti", name="Text"), start fixo 86486400/24000s, e um bloco de <param> com margens/alinhamento/Custom Speed (com <keyframeAnimation> de tempos nominais constantes). A posição é o param Position (chave .../13260/3296672360/1/100/101) com valor estático "x y" — sem <adjust-transform>.
  • Solução adotada: substituir os dois templates por um único "Text" (Basic Text), copiado verbatim dos exports reais. Novo _make_text_title_clip + _ensure_text_title_effect + add_text_title. generate_dynamic_subtitles agora usa sempre o "Text" e grava offset em coordenadas de mídia-fonte (start do pai + relativo) para todos os títulos; posição via param Position, não adjust-transform. Removidos os templates/código morto "Essencial - Título"/"Título Básico".
  • Aprendizado: o único teste que vale para template de título é um roundtrip de importação REAL no FCP — e o padrão-ouro é o export que o próprio FCP produz quando o usuário adiciona o título à mão. Quando isso existir, copiar verbatim (uid, params, start) e não "simplificar" nada. Título conectado SEMPRE usa start do clipe-pai como origem do offset, nunca 0s.
  • Estado: resolvido no XML — pendente de confirmação de importação real no FCP pelo usuário.

2026-08-17 — Legendas dinâmicas sobrepondo entre clipes: título conectado NÃO é aparado pelo out-point do clipe-pai

  • Sintoma: ao gerar, blocos de legenda de um clipe continuavam na tela por cima das legendas do clipe seguinte — duas frases desenhadas ao mesmo tempo.
  • Causa raiz: um <title> conectado a um asset-clip não é cortado pelo fim do clipe-pai; o FCP segue desenhando sobre o que vier depois. O último bloco de cada clipe terminava no end da última palavra do Whisper — que frequentemente ultrapassa o corte — e palavras cujo start já caía depois do corte também eram emitidas.
  • Onde: fcpxml/writer.py, generate_dynamic_subtitles().
  • Tentativas que falharam: comprimir a palavra tardia para o último frame do clipe (empilhava vários títulos no mesmo frame e na mesma lane).
  • Solução adotada: descartar palavras que começam depois da duração do clipe-pai e limitar (clamp) o fim de cada bloco a essa duração. Testes em tests/test_dynamic_subtitles.py::TestClipBoundaryClamping.
  • Aprendizado: nada anexado a um clipe pode sobreviver ao próprio clipe; tempos vindos do Whisper precisam sempre ser recortados pela janela do clipe, não só filtrados pelo start.
  • Estado: resolvido

2026-08-17 — Palavras sobrepostas na tela: largura de texto ESTIMADA subestimava; solução foi embutir as métricas reais das fontes

  • Sintoma: o usuário reportou que as palavras apareciam todas sobrepostas no Final Cut. A verificação automática do XML dizia "0 sobreposições" — porque conferia contra a minha própria estimativa de largura, não contra o que o FCP realmente desenha. Verificar um cálculo com o mesmo cálculo não verifica nada.
  • Duas causas, uma de processo e uma técnica:
    1. Processo: o arquivo que o usuário importou era a versão anterior, gerada antes do posicionamento existir (todas as palavras em 0 -45.4935, literalmente no mesmo ponto). Como o caminho de saída é sempre o mesmo (_dynamic_subtitles.fcpxmld), é fácil reabrir a versão velha sem perceber.
    2. Técnica, e real: measure_text estimava a largura por uma tabela AFM genérica de Helvetica. Comparada com as fontes reais do macOS, o erro ia de -1,4% a +7,0% — e o caso negativo é fatal: uma largura menor que a real faz duas palavras encostarem. "dificuldade" a 170pt media 867,8 contra 880,1 reais.
  • Investigação: as variantes reais (Helvetica.ttc, HelveticaNeue.ttc) diferem entre si em até 21,5% do em em alguns glifos — Light, Regular, Neue e Light Italic têm avanços distintos. Nenhuma tabela única serve para todas.
  • Solução adotada: extrair os avanços reais das fontes do sistema com fontTools e embutir como tabela em fcpxml/font_metrics.py (9 variantes × 143 glifos). O fontTools foi usado só na geração, via uv run --with — não virou dependência do projeto, e o layout não lê fonte em runtime, então o resultado é idêntico em qualquer máquina. Erro medido depois: +2,0% constante (só a margem de segurança), nunca abaixo. Margem de segurança reduzida de 1,03 para 1,02, já que a medida agora é exata. Espaço entre palavras passou de 5 pontos fixos para 14% do corpo da fonte maior.
  • Ferramenta que destravou o problema: gerar um preview HTML que desenha as palavras nas posições calculadas, com as fontes e tamanhos reais. Permite ver o layout sem reimportar no FCP a cada tentativa. Nota: o painel de preview bloqueia JavaScript (CSP), então o HTML precisa ser estático, com as posições já escritas no style de cada elemento — nada de calcular no navegador.
  • Aprendizado: nunca validar uma saída com a mesma estimativa que a produziu. Se o código estima larguras, a verificação tem de medir contra a fonte real, senão ela apenas confirma o próprio erro. E quando existe uma fonte de verdade acessível (o arquivo de fonte no disco), extrair os dados dela e embutir sai mais barato e mais exato do que qualquer aproximação — sem custo de dependência.
  • Estado: resolvido (layout aprovado pelo usuário no preview HTML; confirmação de importação no FCP pendente)

2026-08-17 — Calibrar coordenadas de título pedindo um export ao usuário, em vez de adivinhar a escala

  • Sintoma: para posicionar cada palavra na tela era preciso escrever o param Posição do template Essential Title, mas não havia como saber a unidade nem a escala. O único valor existente no código era 0 -45.4935, idêntico em todos os títulos — variância zero, portanto nada a inferir.
  • Risco reconhecido antes de agir: este mesmo arquivo já registra (entrada de 2026-08-14) que um <param name="Position"> fabricado foi removido justamente por ser inadivinhável, e que nem o DTD nem os testes unitários pegam key/valor inválido. Chutar aqui reproduziria a falha silenciosa pela terceira vez.
  • Solução adotada: em vez de estimar, pedir ao usuário um export do Final Cut com palavras posicionadas à mão. Ele enviou Exemplo Letra.fcpxmld (projeto 2160x3840) com a frase "Toda a minha vida, assim," — cinco palavras posicionadas no Inspetor, o resto no default. Três fatos saíram dos números:
    1. Posição usa a mesma unidade que fontSize. As distâncias centro a centro na linha 1 (254,06 e 265,08) batem com a soma das meias-larguras calculadas pelas métricas Helvetica nos tamanhos 170/128/151 (245,1 e 265,0). Uma unidade diferente apareceria como razão constante; não há nenhuma.
    2. O canvas é 1080x1920 pontos — metade do quadro, porque o FCP posiciona em pontos sobre mídia 2x. A linha 1 vai de -460,3 a +495,7, preenchendo essa largura com margens pequenas, exatamente como o quadro de referência aparenta.
    3. y cresce para cima: "vida," (linha 2) em -233,65 contra a linha 1 em ~-101. Também desambiguou qual param responde ao Inspetor: as cinco palavras carregam valores distintos em 9999/10085/10086/1/100/101, enquanto .../2/358 permanece 0 69 em todos os títulos do arquivo.
  • Validação: o layout recalculado reproduz o do usuário — espaçamento entre linhas 131,8 contra 132,7 (erro de 0,7%) e o y das duas linhas coincidindo na casa decimal. Os valores viraram testes (tests/test_text_layout.py, TestCalibrationAgainstRealExport), então qualquer regressão de escala falha.
  • Descoberta colateral: o espaçamento entre linhas do usuário (132,65) é menor que o corpo da maior fonte da linha (170), o que só fecha porque o texto ocupa a altura de caixa-alta (~0,75 do corpo), não o em-box inteiro. Usar o em-box afastaria as linhas ~40% a mais do que ele fez.
  • Aprendizado: quando um valor não é derivável dos dados em mãos, pedir um artefato de calibração ao usuário custa minutos e elimina a adivinhação. Cinco palavras arrastadas à mão renderam escala, unidade, orientação do eixo, espaçamento e a paleta — tudo o que três rodadas anteriores de chute não conseguiram. E vale desconfiar de qualquer constante que apareça idêntica em todas as instâncias de um arquivo: variância zero significa que ela nunca foi exercitada, não que esteja certa.
  • Estado: resolvido

2026-08-17 — Legendas dinâmicas invisíveis porque eram geradas como CAPTION, não como TÍTULO — e a "referência verificada" do código nunca tinha funcionado

  • Sintoma: legendas dinâmicas nunca apareceram no Final Cut. Importava sem nenhum erro, DTD passava, e nada era desenhado sobre o vídeo. Sintoma idêntico ao das entradas anteriores (uid inválido → descarte silencioso), o que levou várias rodadas de correção a atacarem o alvo errado (uid, offset, dispatch de builder).
  • Causa raiz: o programa emitia caption, não título animado. Duas coisas acopladas, ambas erradas:
    1. _TEXTO_TITLE_UID apontava para .../Subtitles.localized/Subtitle.localized/Subtitle.moti — o template de legenda/caption do FCP, não um template de título.
    2. Cada <title> recebia role="subtitles.subtitles-1". Esse role faz o Final Cut tratar o elemento como legenda e roteá-lo para a pista de captions, que não é desenhada sobre o vídeo a menos que a exibição de legendas esteja ligada.
  • Como foi descoberto: o usuário montou títulos à mão dentro do FCP e exportou (Teste de Texto.fcpxmld, exemplo de arquivos.fcpxmld). Comparando os títulos dele (que aparecem) com os gerados (que somem): os dele usam Essential Title.moti / Essential Fade.moti / Text.moti e **não têm atributo role nenhum**; os gerados usavam Subtitle.moti+role="subtitles.". Prova adicional: no re-export, o FCP devolveu os caption_` com offsets negativos e fora do clipe (−2,13s num clipe de 1,835s), porque os realocou como captions noutro sistema de coordenadas, enquanto os títulos manuais voltaram coerentes dentro do clipe (0,58s e 1,38s).
  • Onde: fcpxml/writer.py (_TEXTO_TITLE_UID, _TEXTO_TITLE_ROLE, _TEXTO_TITLE_PARAMS, _TEXTO_TITLE_START, _make_texto_title_clip), fcpxml/models.py (DynamicSubtitleConfig), server.py, MacApp/Sources/*.swift.
  • Premissa falsa que ancorou os erros anteriores: um comentário no próprio fcpxml/writer.py afirmava que WHISPERX/code/"Teste do dia.fcpxmld" era um export real do FCP "actually imported and played back". O usuário confirmou que nunca funcionou — aquele arquivo é output do próprio programa. Como o comentário foi tratado como fonte de verdade, cada correção seguinte se apoiava nele e reproduzia a estrutura errada (inclusive o role de caption). A entrada anterior desta lista herdou o mesmo erro.
  • Solução adotada:
    1. _TEXTO_TITLE_UID → .../Titles.localized/Essential Titles.localized/Essential Title.localized/Essential Title.moti e nome do efeito → "Essencial - Título".
    2. _TEXTO_TITLE_ROLE removido e elem.set('role', ...) eliminado de _make_texto_title_clip. Nenhum título gerado carrega role.
    3. _TEXTO_TITLE_START → 86486400/24000s (valor que o FCP escreve para o Essential Title).
    4. _TEXTO_TITLE_PARAMS → só os 5 params de layout do Essential Title. Os ~75 params de animação foram deixados de fora de propósito: carregam <keyframeAnimation> com tempos absolutos calibrados à duração de uma instância específica, e replicá-los em títulos de outra duração produz animação truncada/congelada. Sem eles o template Motion anima pelos próprios defaults.
    5. Granularidade: max_words_per_line 4 → 1 e lane_count 3 → 9 (defaults alinhados em models.py, server.py e no MacApp), gerando um <title> por palavra.
    6. Comentários falsos reescritos apontando para os exports reais do usuário.
    7. Novo teste de regressão test_titles_carry_no_caption_role.
  • Aprendizado: legenda dinâmica = título animado, não caption. Um role="subtitles.*" num <title> o esconde atrás do toggle de legendas — importa limpo e nunca aparece, exatamente o mesmo sintoma de um uid inválido, o que torna os dois fáceis de confundir. E, mais importante: um comentário dizendo "verificado" não é verificação. Só vale como referência um arquivo que o usuário confirmou ter saído do Final Cut. Antes de tratar qualquer arquivo como ground truth, checar se ele é output do próprio programa — se os name= seguem o padrão que o código gera (caption_<hex>), ele é.
  • Estado: resolvido no XML (verificado na saída: efeito Essential Title, zero roles, 1 título por palavra, offsets dentro da janela do clipe) — pendente de confirmação de importação real no FCP pelo usuário, que é o único teste que conta neste histórico.

2026-08-17 — Legendas dinâmicas não apareciam no FCP: dispatch misturava Título Básico com bloco/role do Subtitle + offset em coordenada errada (timeline em vez de mídia)

Nota (revisão posterior): esta entrada trata WHISPERX/code/"Teste do dia.fcpxmld" como export real verificado do FCP. Isso está errado — aquele arquivo é output do próprio programa e nunca funcionou. Ver a entrada acima. A parte de offset em coordenada de mídia continua correta (reconfirmada contra exemplo de arquivos.fcpxmld), mas o role="subtitles.*" e o template Subtitle.moti descritos aqui eram a causa real das legendas invisíveis.

  • Sintoma: ao gerar legendas dinâmicas num projeto real (Depoimento da Erika - Original.fcpxmld, clipe único com start="226220995/24000s" ≈ 256.59s na mídia de origem) e abrir o resultado no Final Cut, as legendas simplesmente não apareciam — nem na timeline, nem na lista de roles. O app chamava o handler sem animated, então caía no default e produzia um <title> com ref="r_title_basic" (Título Básico) mas com role="subtitles.subtitles-1", start="86400314/24000s" e os 19 params do template "Legenda" — um híbrido impossível de resolver no FCP (descarte silencioso, padrão documentado nas entradas de 2026-08-15/2026-08-17).
  • Causa raiz (dois bugs num ciclo):
    1. generate_dynamic_subtitles (fcpxml/writer.py) escolhia o efeito certinho por config.animated (linhas 2893-2896), mas SEMPRE construía o clip com _make_texto_title_clip (linha 2944) — ignorando o animated. _make_basic_title_clip (que monta o Título Básico sem role/start e só os 2 params Compactar/Alinhamento) existia mas nunca era chamado (código morto). Resultado default: ref do básico + corpo do subtitle → o FCP descarta silenciosamente.
    2. Mesmo no caminho animado, o offset era escrito como valor relativo à timeline (ex.: 1/4800s). Mas o export real verificado (WHISPERX/code/"Teste do dia.fcpxmld") mostra que o template "Legenda"/Subtitle posiciona os captions em coordenadas da mídia de origem: offset = start_do_clipe + relativo (ex.: 240822582/24000s = start 240817577/24000s + 0.2085s), enquanto o "Título Básico" (r3) usa offset relativo (ex.: 1001/4800s). Escrever offset relativo pequeno num clip cujo start é ~256s/9000s faz o FCP ler ~0s da mídia — antes do in-point do clipe — e a legenda cai "fora" (caso Legendas fora.fcpxmld).
  • Onde: fcpxml/writer.py (generate_dynamic_subtitles, linhas ~2893-2956), fcpxml/models.py (DynamicSubtitleConfig.animated, default errado False), tests/test_dynamic_subtitles.py.
  • Solução adotada:
    1. generate_dynamic_subtitles agora despacha pelo config.animated: True → _make_texto_title_clip (template "Legenda"), False → _make_basic_title_clip (Título Básico). O híbrido impossível não existe mais.
    2. No caminho animado, offset = media_origin + snap(relativo) com media_origin = _parse_time(parent.get('start')) (coordenada da mídia, igual ao export real); no estático, offset permanece relativo (como r3).
    3. DynamicSubtitleConfig.animated agora é True por padrão (decisão do usuário: a feature é a legenda animada/ediável; False só para texto queimado no frame).
    4. Adicionados testes de regressão (test_animated_offset_uses_source_media_coordinates, test_animated_effect_is_legenda_subtitle, test_static_mode_uses_basic_title_no_role, test_animated_and_static_use_separate_effects).
  • Aprendizado: dois templates diferentes num mesmo método exigem dispatch por builder, e cada um tem seu próprio sistema de coordenadas de offset — o template de caption/legenda usa a posição na mídia de origem (nunca um offset relativo pequeno), o título estático usa offset relativo ao clipe. Comparar sempre com o export real (coordenadas + role + params) antes de fechar uma estrutura, e nunca ignorar o branch else de um if config.X que decide o template — builder único = mistura de corpo de um template com ref de outro = descarte silencioso no FCP.
  • Estado: resolvido

2026-08-17 — Clipe-fantasma de 1 frame no início e no fim após remoção de silêncio (raiz real no gerador, diferente da entrada de 2026-08-14)

  • Sintoma: usuário testou remove_silences num projeto real (Depoimento da Erika_silence_removed.fcpxmld) e reportou dois clipinhos minúsculos: o primeiro e o último clipe da spine gerada tinham duration="1001/24000s" — exatamente 1 frame a 23.976fps.
  • Causa raiz: diferente da entrada de 2026-08-14 ("Micro-clips... são criados pelo FCP, não pelo corte") — aqui os slivers já vinham no Info.fcpxml bruto gerado pelo programa, confirmado lendo o XML direto, sem passar pelo FCP. handle_remove_media_silence/cut_clip_ranges (fcpxml/writer.py) aplica um padding (respiro, padrão 0.05s) antes/depois de cada trecho de silêncio cortado. Quando o silêncio detectado toca a própria borda do clipe (começo ou fim), não sobra fala nenhuma daquele lado para o padding "respirar perto de" — o padding vira, sozinho, o segmento "kept" (mantido) daquela ponta, e após o snap para o grid de frames (snap_seconds_to_frame) esse segmento de ~0.05s vira exatamente 1 frame, virando clipe próprio em vez de ser absorvido.
  • Onde: fcpxml/writer.py::FCPXMLModifier.cut_clip_ranges, construção da lista keeps (complemento dos cut_ranges mesclados).
  • Tentativas que falharam: n/a — diagnóstico direto lendo o XML bruto e cruzando com a lógica de cut_clip_ranges; os números batem exatamente (0.05s de padding ≈ 1.2 frames a 23.976fps → arredonda para 1 frame).
  • Solução adotada: a pedido do usuário ("em vez de criar esse [micro-clipe] novo, ele pode pegar o próprio segundo clipe e aumentar a duração dele para começar antes") — depois de montar keeps, se o primeiro segmento tiver menos que ~2 frames de duração, ele é fundido no segmento seguinte (que passa a começar mais cedo); simétrico no fim (o penúltimo segmento passa a terminar mais tarde, absorvendo o último). Isso também restaura o pequeno trecho de silêncio adjacente que teria sido cortado ali — troca aceitável por não deixar clipe-fantasma na timeline.
  • Aprendizado: ao gerar clipes a partir de um algoritmo de corte com padding, sempre checar segmentos residuais nas BORDAS da mídia/clipe (não só entre dois cortes no meio) — o padding não tem "vizinho de fala" do lado de fora do clipe, então o caso de borda precisa de tratamento explícito (fundir em vez de emitir). Existe um padrão irmão já usado alhures no código (_absorb_into_neighbor, fcpxml/writer.py:1112) para a mesma ideia geral.
  • Estado: resolvido

2026-08-17 — As 481 legendas do usuário ficaram todas presas num único clipe errado: generate_dynamic_subtitles resolvia o clipe-pai por name, ambíguo depois de corte de silêncio

  • Sintoma: mesmo com o uid e os offset/duration corrigidos (entradas abaixo), o usuário mandou o .fcpxmld real depois de importar no FCP ("como ficou depois de importar") e as 481 legendas apareciam todas grudadas em ~8-17 segundos de um único clipe da timeline, com frases completamente sem relação entre si ("vontade.", "Sou erica Fernanda, tenho", "sou casada.") — claramente vindas de pontos bem distantes de uma entrevista de ~17 minutos, não de um trecho de 8 segundos.
  • Causa raiz: server.py::handle_generate_dynamic_subtitles itera cada clipe da spine (for el in spine_clips), calcula a janela de palavras correta relativa àquele clipe (el), mas chamava modifier.generate_dynamic_subtitles(name, ...) passando name = el.get("name", "") — uma STRING — em vez do elemento. Depois de qualquer remove_silences/corte com ripple, TODOS os fragmentos resultantes de um clipe original mantêm o mesmo name herdado (aqui, ~482 clipes, todos name="0E6A8829", o nome do asset de origem). _require_clip() resolve por self.clips[key], um dict indexado por id ou, na falta dele, por name (fcpxml/writer.py:_index_elements) — com nomes duplicados, cada novo clipe indexado SOBRESCREVE o anterior, então self.clips["0E6A8829"] acaba apontando para só UM clipe (o último indexado). Toda chamada do loop, para qualquer um dos 482 clipes reais, resolvia para esse mesmo clipe errado — empilhando ali as legendas de quase o vídeo inteiro.
  • Onde: server.py (handle_generate_dynamic_subtitles, a chamada a modifier.generate_dynamic_subtitles), fcpxml/writer.py (generate_dynamic_subtitles, _require_clip, _index_elements).
  • Solução adotada: generate_dynamic_subtitles agora aceita parent_clip como str | ET.Element — se receber o elemento diretamente, usa-o sem passar pelo lookup por nome; só cai em _require_clip(name) (mantido para compatibilidade com chamadas antigas/testes) quando recebe uma string. server.py foi atualizado para passar el (o elemento já em mãos no loop) em vez de name. Adicionado teste de regressão (test_element_param_bypasses_ambiguous_duplicate_name_lookup) que simula dois clipes com o mesmo name e confirma que passar o elemento anexa cada legenda ao clipe certo.
  • Aprendizado: nunca identificar um clipe específico por name num handler que itera múltiplos clipes — qualquer operação de corte/ripple/remoção de silêncio no FCPXML preserva o name original em todos os fragmentos resultantes, então name deixa de ser único assim que o timeline é editado. Sempre que o chamador já tem o ET.Element em mãos (por ter vindo de uma iteração como _iter_spine_clips()), passe o elemento adiante em vez de re-resolvê-lo por um identificador que pode colidir.
  • Estado: resolvido

2026-08-17 — Legendas dinâmicas sumiam silenciosamente do Final Cut por uid de efeito inválido (mistura de dois templates); dois bugs num só ciclo

  • Sintoma: depois de corrigir os erros de frame-boundary do offset/duration (entrada abaixo, mesma sessão), o Final Cut não reportava mais nenhum erro de importação — mas os 481 <title> de legenda simplesmente não apareciam em lugar nenhum: nem na timeline, nem na lista de roles do projeto.
  • Causa raiz:
    1. O uid fixado em _TEXTO_TITLE_UID (.../Titles.localized/Basic Text.localized/Text.localized/Text.moti) mistura os nomes de dois templates diferentes ("Basic Text" e "Text") e não corresponde a nenhum Motion template real instalado no FCP. Quando o uid de um <effect> referenciado por um clipe conectado não resolve para um template existente, o Final Cut descarta silenciosamente os clipes conectados que dependem dele durante a importação — sem erro, sem aviso na UI. É a segunda vez que um uid fabricado aqui é a causa raiz (ver entrada de 2026-08-15 abaixo — da primeira vez foi o "Basic Title", desta vez foi um "Texto" que só passou pelos testes internos porque nunca foi de fato importado de novo no FCP depois de escrito).
    2. Um dos <param> copiados junto (Opacidade = "0") fixava a opacidade do texto em zero — mesmo se o uid estivesse certo, o texto ficaria invisível.
  • Onde: fcpxml/writer.py — _TEXTO_TITLE_UID, _TEXTO_TITLE_PARAMS, _TEXTO_TITLE_START, _ensure_texto_title_effect, _make_texto_title_clip.
  • Solução adotada: o usuário identificou um export real, já importado e reproduzido com sucesso no FCP, presente no próprio repositório em WHISPERX/code/"Teste do dia.fcpxmld"/Info.fcpxml — efeito r4 nomeado "Legenda", uid=".../Titles.localized/Subtitles.localized/Subtitle.localized/Subtitle.moti", usado em cinco <title> conectados por palavra/linha com role="subtitles.subtitles-1". Copiado o uid, o name ("Legenda"), o start fixo (86400314/24000s, diferente do valor anterior), o atributo role, e o bloco de 19 <param> inteiro verbatim — que não inclui nenhum param de opacidade nem <keyframeAnimation> manual (a revelação palavra-a-palavra é nativa do template via os params Animar/Intervalo, não uma curva de velocidade fabricada como no template anterior).
  • Aprendizado: um uid/bloco de params "plausível" que passa nos testes internos (fcpxml/dtd.py, pytest) não é prova de que é real — só um roundtrip de importação de verdade no FCP prova isso, e mesmo assim a falha pode ser silenciosa (sem erro) em vez de uma rejeição explícita. Regra geral reforçada: nunca fabricar/adivinhar uid de efeito nativo do FCP, mesmo que o formato pareça consistente com outros exports reais — sempre copiar de um .fcpxmld/Info.fcpxml que o usuário confirma ter sido importado e reproduzido com sucesso.
  • Estado: resolvido

2026-08-17 — Palavras das legendas dinâmicas empilhadas: o template "Essencial - Título" ANIMA por padrão e a animação ignora a posição estática — solução foi desligar Animar

  • Sintoma: o usuário reportou, repetidamente, todas as palavras uma em cima da outra no Final Cut. O XML gerado tinha posições estáticas e distintas (verificado), mas o FCP ignorava a posição e empilhava tudo — o sintoma persistiu mesmo com value="x y" correto em cada palavra.
  • Causa raiz (a definitiva): o template "Essencial - Título" (Essential Title, Motion) tem um parâmetro Animar (Animate). No export de calibração (Exemplo Letra.fcpxmld, as 5 palavras que o usuário arrastou à mão e funcionaram), cada título carrega ~111 params, incluindo Animar = "4 (Tudo)" mais dezenas de params de animação por caractere (X/Y/Z deslocamento, Objeto Original, Deslocamento Inicial/Final, Direção, Velocidade Personalizada com keyframes). Nós só escrevíamos 5 params de layout e omitíamos o Animar. Sem ele, o FCP usa a animação default do template — a animação de "Tudo" (fly-in 3D por caractere) — e é essa animação que posiciona as letras, não o nosso Posição. Resultado: cada caractere cai na posição default e tudo empilha. As palavras da calibração só ficaram no lugar porque o FCP escreveu o bloco de animação completo junto.
  • Por que NÃO copiar o bloco de animação: os params por caractere (X deslocamento, Y deslocamento, Z deslocamento, Objeto Original) têm valores diferentes por palavra (ex.: X deslocamento = -960.047 em "Tod" vs -243 em "a") — são dados 3D por caractere que o FCP calcula e que não dá para reproduzir. Já os time dos keyframes são idênticos em todas as palavras (0s, 1567433324/1000000000s, 19915648/3840000s, 6686008967/1000000000s), confirmando que são a curva default do template, não calibrados por instância.
  • Onde: fcpxml/writer.py::_make_texto_title_clip, novo _TEXTO_ANIMAR_KEYS (10 chaves .../201/203 de Animar), tests/test_dynamic_subtitles.py.
  • Tentativas que falharam: (1) embrulhar a posição em keyframeAnimation — piorou, pois o param é estático e o FCP descartou tudo para o default 0 -45.4935; (2) reverter só para o valor estático — ainda empilhava, porque a animação default continuava ignorando a posição.
  • Solução adotada: escrever Animar = "0 (Nenhum)" nas 10 chaves de animação do template, desligando a animação. O título fica estático e respeita o Posição gravado; a revelação palavra-por-palavra continua vindo do offset/duration de cada palavra (não da animação). Teste atualizado: 5 params de layout + 10 Animar = "0 (Nenhum)", sem keyframeAnimation.
  • Aprendizado: num template Motion de título, a posição visual pode ser controlada pela animação, não pelo param de layout — se um título "arrastado à mão" funciona e o gerado empilha, comparar o bloco de params inteiro (não só a posição) entre os dois arquivos. O Animar é o interruptor-mestre: 4 (Tudo) anima (e aí só os dados 3D por caractere — irreproduzíveis — colocam as letras no lugar); 0 (Nenhum) desliga e devolve o controle ao param Posição. E: a mesma conclusão errada foi registrada e corrigida duas vezes nesta sessão — a cada iteração, reler o export de calibração inteiro antes de decidir o mecanismo.
  • Estado: resolvido no XML (posições estáticas distintas + 10× Animar = "0 (Nenhum)" verificados no output real; 1124 testes verdes) — pendente de confirmação de importação real no FCP pelo usuário, único teste que conta neste histórico.

2026-08-17 — offset/duration de legendas dinâmicas fora do grid de frames (denominador /23s em vez de /24000s)

  • Sintoma: importação real no Final Cut rejeitada com 457 de 481 erros "O item não está em um limite de quadro de edição", todos apontando para offset/duration de <title> com denominador /23s (ex.: offset="2/23s", duration="67/23s").
  • Causa raiz: generate_dynamic_subtitles (fcpxml/writer.py) construía cada offset/duration com TimeValue.from_seconds(seconds, self.fps). Esse classmethod (fcpxml/models.py) faz int(fps) — para um projeto NTSC a 23.976fps (frameDuration="1001/24000s", self.fps ≈ 23.976), int(fps) trunca para 23, uma base de tempo inválida para o FCPXML. Todo o resto do XML (asset-clips, cortes de silêncio, sequence) já usava a base exata 1001/24000s.
  • Onde: fcpxml/writer.py:2841-2850 (chamada) e fcpxml/models.py:314-318 (TimeValue.from_seconds, bug latente — outros call-sites como marcadores em fcpxml/writer.py:1442,1511 usam o mesmo padrão e podem ter o mesmo problema em taxas NTSC, não corrigido nesta rodada por estar fora do escopo do bug relatado).
  • Solução adotada: trocado TimeValue.from_seconds(start, self.fps) / TimeValue.from_seconds(end - start, self.fps) por self.snap_seconds_to_frame(...) — helper já existente (fcpxml/writer.py:746) que usa a fração exata de frameDuration (via frame_duration_fraction(), fcpxml/writer.py:727) em vez do float truncado, já usado em outro lugar do writer para snap da spine. Também trocado min_dur_seconds = 1.0 / self.fps por float(self.frame_duration_fraction()).
  • Aprendizado: qualquer conversão de segundos-float para TimeValue num projeto FCPXML deve usar a fração exata do frameDuration do <format> da sequência (via frame_duration_fraction()/snap_seconds_to_frame()), nunca int(fps) — taxas NTSC (23.976/29.97/59.94) sempre truncam errado com um fps float.
  • Estado: resolvido

2026-08-15 — Abandonado o Compound Clip em generate_dynamic_subtitles; voltado a títulos soltos em lanes cicladas, com estrutura copiada de um export real

  • Sintoma/decisão: mesmo depois de corrigir os 3 erros de importação do Compound Clip (entrada abaixo), o usuário decidiu recuar da abordagem por completo — "muito problema e muito erro" — e pediu para voltar ao básico: títulos soltos, direto na timeline, em várias lanes, sem Compound Clip.
  • O que mudou: generate_dynamic_subtitles não cria mais <media>/<sequence>/<gap>/<ref-clip> nenhum. Cada linha (chunk de palavras) vira um <title> autônomo, anexado direto no clipe pai via _dtd_insert, ciclando por config.lane_count lanes (round-robin). A duração de cada linha se estende até sua própria lane ser reaproveitada lane_count linhas depois (ou até seu próprio fim, se for uma das últimas) — isso empilha visualmente várias linhas ao mesmo tempo (efeito cascata) sem nunca sobrepor duas linhas na MESMA lane.
  • Fonte da estrutura XML: o usuário mandou um FCPXML real (com exemplo de título.fcpxmld/Info.fcpxml) com 3 títulos criados manualmente no FCP usando o template "Texto" (uid=".../Titles.localized/Basic Text.localized/Text.localized/Text.moti" — diferente do "Basic Title" usado antes). Copiei o bloco de <param> inteiro (margens, alinhamento, quebra automática, o par Opacidade/Velocidade Personalizada com <keyframeAnimation> que parece ser a animação de revelação nativa do template) e o atributo start fixo (86486400/24000s, idêntico nos três títulos do export) como constantes fixas em _TEXTO_TITLE_PARAMS/_TEXTO_TITLE_START — não tentei entender/simplificar esses valores, só copiei verbatim, já que "simplificar" um bloco de params reais foi exatamente o que causou os erros anteriores.
  • Importante (o usuário corrigiu isso no meio da conversa): os offsets/timings do arquivo de exemplo eram só ilustrativos — não estavam sincronizados com nenhuma fala real. O timing de verdade continua vindo 100% da transcrição Whisper (words com start/end reais), usando a mesma lógica de TimeValue/mapeamento fonte→timeline já estabelecida no projeto. Só a ESTRUTURA XML (uid, params, start fixo) foi copiada do exemplo, nunca os números de tempo.
  • Onde: fcpxml/writer.py (FCPXMLModifier.generate_dynamic_subtitles, _make_texto_title_clip, _ensure_texto_title_effect), fcpxml/models.py (DynamicSubtitleConfig.lane_count substituindo lane), server.py, admin/models_api.py, MacApp/Sources/CaptionsView.swift.
  • Aprendizado: quando o usuário oferece um export real do FCP como referência, tratar isso como fonte de verdade para a ESTRUTURA (uid, ordem de elementos, bloco de params), mas nunca para os NÚMEROS de tempo específicos de um exemplo ilustrativo — a menos que ele diga explicitamente que os números também são reais. E: depois de duas rodadas de erro de importação real, a abordagem mais simples e mais próxima de um export real validado sempre vale mais que uma abstração mais "elegante" (Compound Clip) que ninguém verificou contra o importador de verdade do FCP.
  • Estado: resolvido

2026-08-15 — Três rejeições de importação real no Final Cut Pro em generate_dynamic_subtitles (uid fabricado, <title> ancorado em <gap>, duração 0)

  • Sintoma: ao importar de verdade no Final Cut Pro (não só validar internamente), o app recusou o Info.fcpxml com três classes de erro: (1) uid=".../Titles.localized/Basic Text.localized/..." — O item não pôde ser lido; (2) Edição inválida sem nenhuma mídia respectiva apontando para .../gap[1]/title[1]; (3) Um valor inesperado foi encontrado (duration="0/1s") em vários <gap> dentro dos <media> de legenda.
  • Causa raiz:
    1. O uid do efeito "Basic Title" foi inventado (nunca verificado contra um export real) — o caminho correto tem Bumper:Opener.localized, não Basic Text.localized.
    2. Os <title> por palavra estavam sendo anexados como connected clip (via lane) dentro de um <gap> usado só para "segurar" a duração da linha — mas um <gap> não é mídia, e FCP rejeita qualquer clipe conectado a um <gap> como âncora.
    3. Palavras com start/end muito próximos (ou vindas de um transcript com timestamps imprecisos) geravam durações que arredondavam para 0 frames no fps da sequência.
  • Onde: fcpxml/writer.py, FCPXMLModifier.generate_dynamic_subtitles / _make_title_clip / _ensure_basic_title_effect.
  • Tentativas que falharam: validar apenas com fcpxml/dtd.py e com os testes unitários — nenhum dos dois pega uid/params inválidos (o DTD da Apple não estava disponível neste ambiente) nem a regra "conectado precisa de mídia real por trás", que só o importador real do FCP aplica.
  • Solução adotada:
    1. Encontrado um uid real e correto dentro do próprio repositório, em WHISPERX/code/*.fcpxmld/Info.fcpxml (um projeto de verdade exportado pelo usuário) — usado esse valor em vez de inventar um novo. Os únicos dois <param> que esse template realmente usa (Compactar e Alinhamento, com key fixo) também foram copiados de lá; o <param name="Position"> fabricado foi removido.
    2. Reestruturado o compound clip: os <title> por palavra agora são conteúdo primário da spine interna (como o próprio <title> já suporta ser primário), com <gap> só preenchendo silêncio real entre eles — nunca mais como pai/âncora de um clipe conectado.
    3. Adicionado um piso de duração mínima de 1 frame (1.0 / fps) tanto por palavra quanto pela linha inteira, em vez do padding fixo de 0.01s que arredondava para 0 em fps altos.
  • Aprendizado: nunca fabricar uid/key de efeitos nativos do FCP — eles não são adivinháveis e a validação interna (fcpxml/dtd.py) só pega isso se o Final Cut Pro estiver instalado localmente; sempre que possível, procurar/pedir um export real como referência antes de inventar. Além disso, "conectado" (lane) sempre precisa de um clipe com mídia de verdade por trás — um <gap> nunca serve de âncora, mesmo que pareça funcionar nos testes internos (que só checam a árvore XML, não as regras semânticas do importador do FCP). E qualquer duração calculada a partir de subtração de floats de transcrição deve ter um piso de 1/fps, nunca uma constante fixa pequena.
  • Estado: resolvido

2026-08-15 — Corpo de cmd_add_zoom colado por engano dentro de cmd_remove_silences em admin/models_api.py

  • Sintoma: lint (ruff) falhando com F821 Undefined name 'clip_id' e F841 Local variable 'clip_id' is assigned to but never used, em duas funções diferentes do bridge Python↔Swift.
  • Causa raiz: durante uma edição manual (introduzindo _derived_output() para suportar output_dir configurável), o corpo inteiro de cmd_add_zoom (checagem de clip_id + chamada a handle_add_zoom) foi colado dentro de cmd_remove_silences, antes do bloco correto que já chamava handle_remove_media_silence — deixando cmd_remove_silences com código morto/quebrado (chamava o handler errado e checava uma variável inexistente) e cmd_add_zoom truncado (só validava path/clip_id e não fazia mais nada).
  • Onde: admin/models_api.py, funções cmd_remove_silences e cmd_add_zoom.
  • Tentativas que falharam: n/a — identificado direto pelo lint e por leitura do código antes de qualquer tentativa de correção.
  • Solução adotada: movido o fragmento (checagem de clip_id + chamada a handle_add_zoom) de volta para dentro de cmd_add_zoom, removendo-o de cmd_remove_silences, que voltou a conter só a chamada correta a handle_remove_media_silence.
  • Aprendizado: depois de qualquer edição manual em admin/models_api.py (ou qualquer arquivo com várias funções cmd_* de shape parecido), rodar o lint imediatamente pega colagens cruzadas de função — ruff acusa tanto a variável usada-mas-nunca-definida (função que perdeu o trecho) quanto a definida-mas-nunca-usada (função que ganhou o trecho de outra) no mesmo commit, o que é um sinal forte de bloco trocado de lugar, não dois bugs independentes.
  • Estado: resolvido

2026-08-15 — IDs duplicados de text-style-def rejeitados pelo Final Cut Pro em legendas dinâmicas

  • Sintoma: ao importar o FCPXML gerado por generate_dynamic_subtitles, o Final Cut Pro recusava o arquivo com "A validação DTD falhou" e uma lista de IDs como caption_L0W0_ts0 already defined.
  • Causa raiz: os IDs de <title>/<text-style-def> eram montados como caption_L{line_idx}W{word_idx}_ts{i}, com line_idx/word_idx reiniciando em 0 a cada chamada de generate_dynamic_subtitles. Como o handler (handle_generate_dynamic_subtitles em server.py) chama esse método uma vez por clipe da spine na mesma instância de FCPXMLModifier, múltiplos clipes geravam exatamente os mesmos IDs — o DTD exige unicidade de ID no documento inteiro, não por clipe.
  • Onde: fcpxml/writer.py, método FCPXMLModifier.generate_dynamic_subtitles.
  • Tentativas que falharam: nenhuma alternativa testada — o padrão (índices posicionais que resetam por chamada) era o bug desde a primeira implementação; só foi pego ao testar a importação real no Final Cut Pro.
  • Solução adotada: trocar o índice posicional por um prefixo derivado de uuid.uuid4().hex[:8] por linha (caption_{line_uid}_W{word_idx}), garantindo unicidade mesmo entre chamadas repetidas na mesma instância do modifier. De quebra, corrigido também: o <ref-clip> (compound clip) estava sendo anexado via ET.SubElement direto no clipe pai, o que o colocava depois de marcadores (keyword, chapter-marker) já existentes — violando a ordem de filhos exigida pelo DTD (itens-âncora como ref-clip devem vir antes de itens de marcador). Trocado para _dtd_insert(parent, ref_clip), que já resolve essa ordenação.
  • Aprendizado: qualquer ID gerado dentro de um método chamado em loop (uma vez por clipe/iteração) sobre a MESMA árvore XML não pode depender de um índice que reinicia a cada chamada — precisa ser único por invocação (UUID, contador persistido na instância, ou verificação contra os IDs já existentes no documento). Além disso, qualquer elemento anexado a um clipe existente via SubElement direto (em vez de _dtd_insert) só é seguro se o clipe nunca tiver marcadores/filhos de prioridade menor já presentes — na dúvida, sempre usar _dtd_insert.
  • Estado: resolvido

2026-08-14 — Tolerância e ações consolidadas no processamento em lote

  • Sintoma: a tolerância do corte ficava separada dos checkboxes e havia controles individuais repetindo as ações do lote.
  • Causa raiz: o layout foi evoluído incrementalmente, mantendo os fluxos antigos abaixo do novo processamento em lote.
  • Solução adotada: slider dentro do grupo "Remover silêncios", desabilitado quando a opção é desmarcada; campo de frases condicionado ao respectivo checkbox; removidos botões individuais e mantidas apenas as ações finais de abrir pasta e abrir no Final Cut.
  • Aprendizado: quando existe processamento em lote, os parâmetros devem ficar junto da operação e os comandos individuais não devem duplicar o fluxo principal.
  • Estado: resolvido

2026-08-14 — Pasta única e processamento em lote no app

  • Sintoma: cada operação salvava o resultado em locais diferentes e exigia abrir o Finder ou localizar manualmente cada arquivo.
  • Causa raiz: os comandos do bridge usavam apenas generate_output_path ao lado do projeto e a UI oferecia ações independentes, sem uma pasta de trabalho comum.
  • Onde: MacApp/Sources/TranscriptionView.swift, admin/models_api.py e server.py.
  • Solução adotada: nova pasta de saída configurável no topo, cinco checkboxes de processamento e botão único; as etapas são encadeadas e todos os XML/SRT recebem output_dir explícito.
  • Aprendizado: operações relacionadas devem compartilhar uma pasta de saída e uma entrada encadeada, evitando artefatos espalhados pelo sistema.
  • Estado: resolvido

2026-08-14 — Mapeamento de legenda por intervalo, sem duração inventada

  • Sintoma: a tentativa de impor duração mínima gerou legendas deslocadas, duplicadas e piores; havia também cues de duração zero que o FCP rejeitava.
  • Causa raiz: o mapeamento usava apenas o início do segmento e depois estendia artificialmente o fim, ignorando segmentos que atravessavam cortes.
  • Onde: admin/models_api.py (cmd_export_srt).
  • Tentativas que falharam: descartar todo cue menor que 0.5s e preencher cada cue até 1s.
  • Solução adotada: intersectar o intervalo completo da fala com cada janela de clipe mantida, mapear apenas a interseção, mesclar somente partes contíguas do mesmo segmento e omitir apenas spans que viram zero milissegundos.
  • Aprendizado: sincronização deve transformar intervalos fonte→timeline; nunca inventar duração para corrigir legibilidade.
  • Estado: resolvido

2026-08-14 — Exportar legenda a partir da versão cortada, não do projeto original

  • Sintoma: a legenda gerada cobria o projeto original (326s) em vez do vídeo cortado (255s), desalinhada com os frames finais.
  • Causa raiz: o botão "Exportar Legendas (SRT)" usava projectPath (projeto aberto na tela) em vez do resultado da remoção de silêncio (processedPath).
  • Onde: MacApp/Sources/TranscriptionView.swift (exportSubtitles).
  • Tentativas que falharam: gerar sempre do projectPath.
  • Solução adotada: exportSubtitles passa a usar processedPath (a cópia _silence_removed) quando existe, caindo para o projectPath caso contrário — a legenda sempre acompanha o corte mais recente.
  • Aprendizado: artefatos derivados do corte (SRT, marcadores) devem ser gerados da mesma versão editada que o usuário está usando, não do arquivo-fonte original.
  • Estado: resolvido

2026-08-14 — Legenda SRT ultrapassava a duração do projeto (aviso do FCP)

  • Sintoma: ao importar o SRT, o Final Cut avisava "as legendas se estendem além da duração do projeto" e sugeria conectá-las a um clipe vazio no final.
  • Causa raiz: o último bloco de legenda mapeado terminava após o fim da timeline (ex.: SRT até 257.4s num projeto de 255.6s), porque o timestamp final arredondava (round) para cima e nenhum teto impedia o overrun.
  • Onde: admin/models_api.py (cmd_export_srt, srt_stamp).
  • Tentativas que falharam: mapear o fim ao fim do clipe apenas; o último cue ainda podia exceder a sequência real.
  • Solução adotada: calcular timeline_total = _timeline_duration().to_seconds() e clampar tl_start/tl_end de cada cue a esse teto; usar floor (em vez de round) no srt_stamp para nunca subir acima de um limite de frame.
  • Aprendizado: SRT que termina após o último frame do projeto é rejeitado pelo FCP; sempre clampar o último cue ao total da timeline e truncar (não arredondar) timestamps.
  • Estado: resolvido

2026-08-14 — Remoção de gap vazio final como padrão na remoção de silêncio

  • Sintoma: a timeline do resultado de remoção de silêncio podia terminar com um gap "Espaço" vazio após o último clipe (introduzido no round-trip com o Final Cut), deixando um "objeto preto" no final.
  • Causa raiz: nenhum passo garantia a remoção de gaps ao final da spine; o FCP re-adicionava o espaço ao importar.
  • Onde: fcpxml/writer.py (novo FCPXMLModifier.remove_trailing_gaps) e server.py (handle_remove_media_silence).
  • Tentativas que falharam: depender do usuário apagar o gap manualmente no FCP.
  • Solução adotada: remove_trailing_gaps() remove apenas o <gap> final da spine (gaps no meio são preservados) e re-sincroniza a duração da sequência; chamado antes de salvar na remoção de silêncio. cmd_remove_silences (app) já herda via delegação ao handler.
  • Aprendizado: operações que encurtam a timeline devem remover gaps finais para o arquivo exportado terminar onde o conteúdo termina.
  • Estado: resolvido

2026-08-14 — Micro-clips de 1 frame e gap "Espaço" no final são criados pelo FCP, não pelo corte

  • Sintoma: o resultado da remoção de silêncio mostrava, após ~4:11, dezenas de micro-clips de 1 frame (0.042s) e um objeto preto/gap "Espaço" de 755s no final.
  • Causa raiz: o algoritmo gera um arquivo LIMPO (82 clipes, source start monotônico, sem micro-clips). O arquivo exportado pelo Final Cut tinha 162 clipes, 80 regressões de start e o gap "Espaço" — o FCP re-quebrou os clipes e inseriu o gap ao abrir/salvar/exportar, não o programa.
  • Onde: comparação entre _out_test.fcpxml (saída do handle_remove_media_silence) e Legendas fora.fcpxmld (exportado do FCP).
  • Tentativas que falharam: suspeitar do cut_clip_ranges/_filter_children_for_segment; o arquivo gerado pelo programa não tem esses micro-clips.
  • Solução adotada: confirmado que o bug não está no código de corte; é artefato da re-exportação pelo FCP. Ajustar a detecção de silêncio (min_duration/padding) não resolve porque o arquivo gerado já está correto.
  • Aprendizado: antes de assumir bug no gerador, reproduzir a saída crua e comparar com o artefato final — a re-importação no NLE pode reintroduzir clipes/gaps.
  • Estado: resolvido (diagnóstico)

2026-08-14 — Legenda SRT fora de sincronia após corte de silêncio

  • Sintoma: ao exportar legenda depois de remover silêncios, o SRT cobria o vídeo inteiro em vez de apenas os trechos que ficaram — legendas apareciam em partes já cortadas.
  • Causa raiz: cmd_export_srt gerava o SRT direto do transcript da mídia original (segments_to_srt), com timestamps da fonte bruta, ignorando os cortes da timeline editada.
  • Onde: admin/models_api.py (cmd_export_srt).
  • Tentativas que falharam: exportar os segmentos como vinham do Whisper.
  • Solução adotada: mapear cada segmento da fonte para a posição real na timeline com clip_offset + (seg_start - clip_source_start) (mesma lógica do transcript_markers), por clipe da spine editada, descartando falas fora da janela usada e ordenando por tempo.
  • Aprendizado: qualquer artefato derivado da transcrição (SRT, cortes) deve ser mapeado fonte→timeline, nunca usar o transcript bruto; reutilizar o mapa já existente em handle_transcript_markers.
  • Estado: resolvido

2026-08-14 — Terceiro ponto do bug de int(fps): TimeValue.from_timecode() corrompia qualquer segundo decimal em taxa NTSC

  • Sintoma: ao implementar a nova tool transcript_markers (marca no timeline cada frase transcrita), add_marker_at_timeline("317.9857s", ...) lançava ValueError: No spine clip at position 331.478s — uma posição fora da timeline, mesmo com o timestamp de entrada correto e dentro dos limites.
  • Causa raiz: TimeValue.from_timecode() (fcpxml/models.py), no ramo que parseia segundos decimais simples ("12.5s", sem /), calculava frames = round(seconds * fps) com o fps real (float), mas construía o TimeValue como TimeValue(frames, int(fps)) — numerador calculado com o fps certo, denominador truncado. A 23.976fps isso infla o valor em ~1.04x (317.99s virou 331.48s). Terceiro local com essa mesma classe de bug (os outros dois: to_frame_timevalue/_cut_transcript_spans em server.py, já corrigidos na entrada anterior) — from_timecode é usado por _parse_time(), chamado por quase todo o writer.py, então qualquer handler que passe um timecode decimal (não fração) nessa taxa era afetado.
  • Onde: fcpxml/models.py (TimeValue.from_timecode).
  • Tentativas que falharam: nenhuma — bug novo, achado testando o handler novo contra a transcrição real em cache antes de expor na UI.
  • Solução adotada: reconstruir a fração exata do fps via Fraction(fps).limit_denominator(100_000) (recupera 24000/1001 a partir do float com precisão total) e usar frames * fps_frac.denominator / fps_frac.numerator como numerador/denominador — mantém os dois em unidades consistentes. Teste de regressão em tests/test_models.py::test_from_seconds_string_ntsc_rate_exact.
  • Validado: reproduzido o erro exato reportado, corrigido, e o handler novo (transcript_markers) rodou de ponta a ponta contra a transcrição real (54 marcadores, sem erro) depois da correção.
  • Aprendizado: qualquer função que aceite fps: float e construa um TimeValue diretamente (em vez de delegar pra uma fração exata) é suspeita de ter esse bug em taxas NTSC. Ao corrigir uma instância, procurar outras chamadas de int(fps) / round(2400/fps) no arquivo inteiro — não parar na primeira encontrada.
  • Estado: resolvido

2026-08-14 — Legendas visíveis exigem SRT/título, não marcadores

  • Sintoma: pedido de "legenda na timeline que apareça no vídeo" — os marcadores de navegação não mostram texto sobre o vídeo.
  • Causa raiz: marcadores (transcript_markers) são apenas navegação; legenda visível no FCP exige SRT importado como idioma de legenda ou um <title> conectado (fragilmente dependente da versão do FCP).
  • Onde: MacApp/Sources/TranscriptionView.swift, admin/models_api.py (cmd_export_srt), reuso de segments_to_srt.
  • Tentativas que falharam: usar marcadores para legenda; gerar <title> de texto no FCPXML é frágil entre versões.
  • Solução adotada: novo comando export_srt que gera um .srt por mídia transcrita (via transcript cacheado + segments_to_srt), exposto como botão "Exportar Legendas (SRT)". O FCP importa o SRT como legenda nativa e desenha sobre o vídeo.
  • Aprendizado: "legenda" no FCP = SRT/caption, não marcador; sempre distinguir navegação (marker) de texto sobreposto (caption).
  • Estado: resolvido

2026-08-14 — Remoção de silêncio/transcrição gerava XML fora da grade de frame em projetos NTSC (23.976/29.97fps), confirmado por importação real no FCP

  • Sintoma: ao importar no Final Cut Pro o XML gerado por remove_media_silence, dezenas de avisos "O item não está em um limite de quadro de edição" em quase todo asset-clip da spine (projeto real de 82 clipes, Depimento Erika).
  • Causa raiz: handle_remove_media_silence e _cut_transcript_spans (server.py) calculavam os limites de corte com TimeValue(round(seconds*fps) * round(2400/fps), 2400) — uma base fixa de 2400 ticks/segundo. Para 24/25/30/48/50/60fps isso é exato, mas para 23.976fps (frameDuration="1001/24000s") round(2400/23.976) arredonda para 100, tratando cada frame como 1/24s exato em vez do 1001/24000s real — uma correção de Engine/docs/05_EXPERIENCIAS.md (entrada anterior) existia como FCPXMLModifier.snap_spine_times_to_frames() mas só era chamada por handle_add_marker; nenhum handler de corte/ripple a usava.
  • Onde: server.py (handle_remove_media_silence, _cut_transcript_spans) e fcpxml/writer.py (FCPXMLModifier.save()).
  • Tentativas que falharam: nenhuma — a correção certa (Fraction exato) já existia no código, só não estava conectada aos caminhos que realmente cortam a spine.
  • Solução adotada: (1) save() agora chama snap_spine_times_to_frames() incondicionalmente antes de serializar — todo handler que escreve passa por ali, então a proteção é universal e não depende de cada handler lembrar de chamar. (2) Os dois pontos de corte por segundos (to_frame_timevalue / to_frame) agora usam o novo FCPXMLModifier.snap_seconds_to_frame(), que arredonda para o frame mais próximo usando a fração exata de frameDuration em vez da base fixa de 2400. Teste de regressão em tests/test_media_intel.py::test_ntsc_rate_output_stays_frame_aligned reproduz duration="41100/2400s" com a lógica antiga (não-inteiro em frames) e confirma alinhamento exato com a nova.
  • Validado: reimportação real no Final Cut Pro pelo usuário, sem os avisos.
  • Aprendizado: qualquer cálculo de tempo que assuma uma base fixa de ticks (2400, 600, etc.) quebra silenciosamente em taxas NTSC fracionárias (23.976/29.97/59.94fps) — usar sempre Fraction a partir do frameDuration real da sequência, nunca fps arredondado. E quando existir uma correção "canônica" pronta no código, verificar que TODOS os caminhos relevantes a chamam, não só um.
  • Estado: resolvido

2026-08-14 — Fluxo de transcrição dependia de clique redundante e ocultava falhas

  • Sintoma: após selecionar um projeto, era necessário clicar novamente para abrir a transcrição; erros do processo Python podiam não aparecer na interface.
  • Causa raiz: a tela filha era condicionada a um botão intermediário, e o bridge não preservava fragmentos incompletos do JSONL nem convertia saída diferente de zero em erro.
  • Onde: MacApp/Sources/ProjectView.swift e MacApp/Sources/PythonBridge.swift.
  • Tentativas que falharam: depender apenas do callback de linhas completas e deixar a conclusão ignorar o código de saída.
  • Solução adotada: abrir a tela automaticamente após inspect, processar a última linha parcial e propagar falhas do subprocesso.
  • Aprendizado: bridges JSONL precisam tratar chunks arbitrários de stdout e sempre validar o status de saída.
  • Estado: resolvido

2026-08-14 — Limites de quadro no XML exportado após remoção de silêncio

  • Sintoma: o Final Cut Pro rejeitava Info.fcpxml com avisos de que offset e duration não estavam em limites de quadro.
  • Causa raiz: a edição ripple produzia frações de tempo válidas matematicamente, mas desalinhadas do frameDuration exato da sequência.
  • Onde: fcpxml/writer.py e server.py no fluxo de remoção de silêncio.
  • Tentativas que falharam: usar FPS convertido para float e assumir uma base inteira, o que não funciona para 23.976/29.97.
  • Solução adotada: normalizar offset e duration da spine usando a fração exata de frameDuration antes de salvar a cópia modificada.
  • Aprendizado: limites de edição do FCPXML devem ser calculados com Fraction, nunca com FPS arredondado ou floats.
  • Estado: resolvido

2026-08-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.