From d13f643ebc578ba903b8f2c5818b1d8ca2a35bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Wed, 23 Sep 2026 08:28:44 -0400 Subject: [PATCH] =?UTF-8?q?chore(fase0):=20higiene=20do=20reposit=C3=B3rio?= =?UTF-8?q?=20+=20corrige=20gitignore=20que=20escondia=20fcpxml/models/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 0 do roteiro de reestruturação (Engine/docs/10_MAPA_REESTRUTURACAO.md): move code/WHISPERX (2,6 GB de backups órfãos, sem uso ativo, sem .gitmodules) para ~/Archives/G-ART-WHISPERX-backup fora do workspace git; traz admin/ para o gate de lint de run_after_fix.sh; corrige fcpxml/writer/adjustment.py, que gerava um wrapper inexistente no DTD 1.13 (filtros agora vão direto no , na ordem exigida), com teste de regressão novo. Achado à parte: .gitignore tinha uma regra solta "models/" (pensada só para o cache do Whisper em code/models/) que também escondia do git todo o pacote fcpxml/models/ — nunca commitado, sem proteção nenhuma. Corrigida para /code/models/, ancorada na raiz. Docs atualizados no mesmo commit (02_MODULES, 09_MANUTENCAO, 10_MAPA_REESTRUTURACAO, 05_EXPERIENCIAS #34 e #36), conforme a regra do CLAUDE.md. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 8 +- admin/models_api.py | 85 +++++- admin/models_gui.py | 8 +- code/Engine/docs/02_MODULES.md | 13 +- code/Engine/docs/05_EXPERIENCIAS.md | 161 +++++++++++ code/Engine/docs/09_MANUTENCAO.md | 50 +++- code/Engine/docs/10_MAPA_REESTRUTURACAO.md | 269 ++++++++++++++++++ code/Engine/run_after_fix.sh | 11 +- code/WHISPERX | 1 - code/fcpxml/models/__init__.py | 120 ++++++++ code/fcpxml/models/enums.py | 183 +++++++++++++ code/fcpxml/models/planning.py | 93 +++++++ code/fcpxml/models/qc.py | 121 ++++++++ code/fcpxml/models/subtitles.py | 165 +++++++++++ code/fcpxml/models/timeline.py | 248 +++++++++++++++++ code/fcpxml/models/timing.py | 304 +++++++++++++++++++++ code/fcpxml/writer/adjustment.py | 140 ++++++++++ code/pyproject.toml | 2 +- code/tests/test_writer_adjustment.py | 60 ++++ 19 files changed, 2013 insertions(+), 29 deletions(-) create mode 100644 code/Engine/docs/10_MAPA_REESTRUTURACAO.md delete mode 160000 code/WHISPERX create mode 100644 code/fcpxml/models/__init__.py create mode 100644 code/fcpxml/models/enums.py create mode 100644 code/fcpxml/models/planning.py create mode 100644 code/fcpxml/models/qc.py create mode 100644 code/fcpxml/models/subtitles.py create mode 100644 code/fcpxml/models/timeline.py create mode 100644 code/fcpxml/models/timing.py create mode 100644 code/fcpxml/writer/adjustment.py create mode 100644 code/tests/test_writer_adjustment.py diff --git a/.gitignore b/.gitignore index 8add5b7..08e07b7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ Thumbs.db # Env files (NUNCA commitar — contêm segredos) *.env .env +admin/gart-rag.env # Graphify output (gerado, não rastrear) graphify-out/ @@ -37,6 +38,9 @@ graphify-out/ # FCPXML bundles de exemplo (podem ser grandes) *.fcpxmld/ -# WhisperX models cache -models/ +# Cache de modelos Whisper baixados (código/models, ~11 GB, HuggingFace hub +# format). Âncora em /code/models/ — NUNCA "models/" solto: isso também +# ignorava fcpxml/models/, o pacote de dados do engine (ver +# Engine/docs/05_EXPERIENCIAS.md #36). +/code/models/ whisper/ diff --git a/admin/models_api.py b/admin/models_api.py index a628cb9..aa1bc48 100644 --- a/admin/models_api.py +++ b/admin/models_api.py @@ -68,6 +68,39 @@ Commands: -> {"ok": true, "review_path", "actions_path", "emphasis_count", "removed_count"} + build_speaker_review {"voice_timeline": "..._voice_timeline.json", "fresh": false} + Runs right after `analyze_voice` (wizard step 3): who was detected + (with speaking share and sample lines, default active/kept) plus one + row per transcript segment, for a naming + mute + strike-line screen + before anything reaches the AI. A review saved earlier is merged + back on top unless `fresh` is true. + -> {"ok": true, "reused": bool, "source", "duration", "video_type", + "speakers": [{id, name, display_name, speaking_seconds, share, + segment_count, avg_segment, word_count, samples, + active}], + "segments": [{id, start, end, speaker, text, excluded, words}]} + + recalc_speaker_review {"voice_timeline": "...", "speakers": [...], + "segments": [...], "video_type": ""} + Reruns the same filter+recompute `save_speaker_review` persists to + _voice_timeline_clean.json, but writes nothing — a live preview for + the "Recalcular" button so muting a speaker or striking a line + updates the emphasis/peak numbers shown (pure math over the words + that survived; no new audio pass). + -> {"ok": true, "duration", "peak_count", + "segments": [{start, end, speaker, text, words, ...}]} + + save_speaker_review {"voice_timeline": "...", "speakers": [...], + "segments": [...], "video_type": "", "source": "...", + "duration": 0.0} + Writes _speaker_review.json (the decisions) and + _voice_timeline_clean.json (inactive speakers + struck lines + removed) — the raw _voice_timeline.json is never touched. From here + on, `copyForChat` and `generate_voice_script` should prefer the + _clean file when it exists. + -> {"ok": true, "review_path", "clean_path", "active_speakers", + "muted_speakers", "excluded_segments"} + generate_voice_script {"media_path": "...", "voice_timeline": "...", "filepath": "...", "model": "gemma3:12b", "base_url": "http://localhost:11434", "model_size": "base", "language": "pt"|"auto"|null, @@ -90,15 +123,46 @@ Commands: -> {"ok": true, "models": ["gemma3:12b", ...]} dynamic_subtitle_config {} + Compat: style of the FIRST ACTIVE registered layout (no id/name/ + active). Prefer list_dynamic_subtitle_layouts for the app's UI. -> {"ok": true, "band_height", "block_center_y", "line_gap", "font", "font_size", "emphasis_font", "emphasis_face", "emphasis_size", "active_color", "emphasis_color", "text_scale"} set_dynamic_subtitle_config {} - Persists only the given fields to ~/.fcp-mcp-server/config.json. - generate_dynamic_subtitles reads this as its own fallback default. + Compat: persists style fields onto the first active layout. Prefer + update_dynamic_subtitle_layout for the app's UI. -> {"ok": true, } + list_dynamic_subtitle_layouts {} + All registered "Legendas Dinâmicas" layouts. A single global config + used to hold ONE style; it's now a list of named, independently + toggleable layouts. With 2+ marked `active`, generate_dynamic_subtitles + randomly samples one per subtitle block, alternating styles through + the video. With 0 active, the first registered layout is used. + -> {"ok": true, "layouts": [{"id", "name", "active", "band_height", + "block_center_y", "line_gap", "font", "font_size", + "emphasis_font", "emphasis_face", "emphasis_size", + "active_color", "emphasis_color", "text_scale", "role"}, ...]} + + create_dynamic_subtitle_layout {"name": "...", } + Registers a new layout, active by default. Omitted style fields fall + back to the same defaults as the very first layout. + -> {"ok": true, "layout": {...}} + + update_dynamic_subtitle_layout {"id": "...", } + Updates only the given fields of one registered layout. + -> {"ok": true, "layout": {...}} or {"ok": false, "error": "..."} + + delete_dynamic_subtitle_layout {"id": "..."} + Removes a layout. If it was the last one, a "Padrão" layout is + recreated automatically so there is always at least one registered. + -> {"ok": true, "layouts": [...]} + + set_dynamic_subtitle_layout_active {"id": "...", "active": true} + Toggles one layout's active flag. + -> {"ok": true, "layout": {...}} + silence_config {} -> {"ok": true, "noise_db": -30.0, "min_silence": 0.5, "padding": 0.05} @@ -108,7 +172,10 @@ Commands: -> {"ok": true, } transcribe {"path": "...", "model": "small", "language": "pt"|null, - "hf_token": "..."|null, "num_speakers": ""|null} + "hf_token": "..."|null, "num_speakers": ""|null, + "force": true|false} + `force: true` ignores the existing transcript cache and overwrites it + with a fresh transcription. The default is false. -> JSON-lines: {"type":"progress","fraction":0.5,"stage":"Transcrevendo..."} {"type":"result","transcripts":[{"media","language","words", @@ -184,7 +251,7 @@ _REPO_ROOT = str(Path(__file__).resolve().parent.parent) if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) -from admin.api import ( +from admin.api import ( # noqa: E402 editing, models, project, @@ -194,7 +261,7 @@ from admin.api import ( voice, zoom, ) -from admin.api.shared import emit # noqa: F401 +from admin.api.shared import emit # noqa: E402,F401 def main() -> int: @@ -238,6 +305,11 @@ def main() -> int: "analyze_voice": voice.cmd_analyze_voice, "dynamic_subtitle_config": subtitles.cmd_dynamic_subtitle_config, "set_dynamic_subtitle_config": subtitles.cmd_set_dynamic_subtitle_config, + "list_dynamic_subtitle_layouts": subtitles.cmd_list_dynamic_subtitle_layouts, + "create_dynamic_subtitle_layout": subtitles.cmd_create_dynamic_subtitle_layout, + "update_dynamic_subtitle_layout": subtitles.cmd_update_dynamic_subtitle_layout, + "delete_dynamic_subtitle_layout": subtitles.cmd_delete_dynamic_subtitle_layout, + "set_dynamic_subtitle_layout_active": subtitles.cmd_set_dynamic_subtitle_layout_active, "plain_subtitle_config": subtitles.cmd_plain_subtitle_config, "set_plain_subtitle_config": subtitles.cmd_set_plain_subtitle_config, "apply_voice_actions": voice.cmd_apply_voice_actions, @@ -245,6 +317,9 @@ def main() -> int: "list_ollama_models": voice.cmd_list_ollama_models, "build_phrase_review": review.cmd_build_phrase_review, "save_phrase_review": review.cmd_save_phrase_review, + "build_speaker_review": review.cmd_build_speaker_review, + "recalc_speaker_review": review.cmd_recalc_speaker_review, + "save_speaker_review": review.cmd_save_speaker_review, "project_config": project.cmd_project_config, "set_project_config": project.cmd_set_project_config, "silence_config": editing.cmd_silence_config, diff --git a/admin/models_gui.py b/admin/models_gui.py index 3d17b8f..3d54723 100644 --- a/admin/models_gui.py +++ b/admin/models_gui.py @@ -28,8 +28,8 @@ _CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") if _CODE_DIR not in sys.path: sys.path.insert(0, _CODE_DIR) -from fcpxml.media_intel import media_src_to_path -from fcpxml.model_manager import ( +from fcpxml.media_intel import media_src_to_path # noqa: E402 +from fcpxml.model_manager import ( # noqa: E402 download_model, get_models_dir, is_model_downloaded, @@ -40,8 +40,8 @@ from fcpxml.model_manager import ( save_models_dir, save_selected_model, ) -from fcpxml.parser import parse_fcpxml -from fcpxml.transcribe import transcribe +from fcpxml.parser import parse_fcpxml # noqa: E402 +from fcpxml.transcribe import transcribe # noqa: E402 logger = logging.getLogger(__name__) diff --git a/code/Engine/docs/02_MODULES.md b/code/Engine/docs/02_MODULES.md index 861686c..e9beb20 100644 --- a/code/Engine/docs/02_MODULES.md +++ b/code/Engine/docs/02_MODULES.md @@ -7,7 +7,7 @@ Mapa módulo a módulo do núcleo Python: onde cada coisa mora e o que ela faz. A API pública é reexportada em `fcpxml/__init__.py` — essa é a fonte da verdade do `__all__`. -Versão: `0.6.35` · Última varredura: 2026-08-19 +Versão: `0.13.1` · Última varredura: 2026-09-22 > **Por que existem pacotes aqui.** `writer.py` tinha 4.199 linhas e `models.py` > 1.091, cada um com muitos assuntos dentro. Viraram pacotes com um módulo por @@ -21,14 +21,16 @@ Versão: `0.6.35` · Última varredura: 2026-08-19 | Módulo / pacote | Linhas | Papel | |-----------------|-------:|-------| -| `writer/` | 4.687 | **Edição e escrita de FCPXML** — o coração | +| `writer/` | 5.377 | **Edição e escrita de FCPXML** — o coração | | `models/` | 1.195 | Data classes e enums | | `text_layout.py` | 901 | Diagramação das legendas dinâmicas | | `rough_cut.py` | 798 | Geração de timelines novas | | `model_manager.py` | 748 | Modelos Whisper: catálogo, download, config | | `voice_timeline.py` | 600 | O JSON de voz que a IA lê | +| `analise.py` | 218 | `AnalisadorDeArquivo` — orquestra transcrição/diarização/ênfase/emoção e monta o `_voice_timeline.json`; usado por `voice_timeline.py` | | `phrase_review.py` | 547 | Revisão de frases (etapa 5 do assistente) | -| `speaker_review.py` | 207 | Revisão de falantes (etapa 3 do assistente) | +| `speaker_review.py` | 208 | Revisão de falantes (etapa 3 do assistente) | +| `transcription/` | 325 | Pacote: `engine.py` (adapter faster-whisper), `segments.py`/`text.py`/`timestamps.py` (operações puras sobre transcript); `transcribe.py` é a fachada de compatibilidade | | `collision.py` | 472 | Colisão entre títulos na tela | | `font_metrics.py` | 445 | Largura real de glifos por fonte | | `templates.py` | 387 | Templates de timeline | @@ -68,8 +70,9 @@ editorial, todos operando sobre o mesmo documento e os mesmos índices. | `document.py` | 170 | Assets de vídeo, timebases, `write_fcpxml` | | `markers.py` | 165 | Marcadores: um, por timecode, em lote | | `audio.py` | 162 | Clipes de áudio e cama musical | -| `generator.py` | 90 | `FCPXMLWriter` — orquestra a criação do zero (estado + delegação) | -| `builders.py` | — | Um builder por tipo de elemento: `FormatBuilder`, `AssetBuilder`, `MarkerBuilder`, `KeywordBuilder`, `ClipBuilder`, `SequenceBuilder`, `LibraryBuilder` | +| `generator.py` | 94 | `FCPXMLWriter` — orquestra a criação do zero (estado + delegação) | +| `builders.py` | 174 | Um builder por tipo de elemento: `FormatBuilder`, `AssetBuilder`, `MarkerBuilder`, `KeywordBuilder`, `ClipBuilder`, `SequenceBuilder`, `LibraryBuilder` | +| `adjustment.py` | 140 | `ClipDeAjuste` — camada de ajuste (filtros `filter-video`/`filter-audio` direto no ``, sem uso ainda em `server_tools`/`admin/api`) | | `reorder.py` | 126 | Reordenar e recalcular offsets | | `trim.py` | 125 | Aparar e propagar o ripple | | `transitions.py` | 94 | Transições entre vizinhos | diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md index a737092..02046e2 100644 --- a/code/Engine/docs/05_EXPERIENCIAS.md +++ b/code/Engine/docs/05_EXPERIENCIAS.md @@ -51,6 +51,9 @@ que merece entrada. | 31 | 2026-08-24 | Revisão de falantes ("Quem fica na edição") salvava certo, mas etapa 4 (Revisão de frases) lia a timeline crua, ignorando falantes mutados/linhas riscadas | `resolvido` | | 32 | 2026-08-24 | Separar legendas dinâmicas de convencionais por role: `` aceita `role` (CDATA), NÃO `videoRole` — este último é DTD-inválido para títulos e quebra a validação | `resolvido` | | 33 | 2026-09-22 | Legenda comum sobreposta à composição dinâmica em `generate_subtitles_by_emphasis`; regenerar acumulava títulos em vez de substituir | `resolvido` | +| 34 | 2026-09-22 | `ClipDeAjuste` gerava wrapper `<adjustment>` inválido no DTD; `code/WHISPERX` era 2,6 GB de backup órfão que inflava o lint quando rodado com `--exclude` explícito | `resolvido` | +| 35 | 2026-09-22 | Indexação RAG (`admin/update_rag.py`) abortava a transação inteira ao achar um chunk que estoura o contexto do modelo de embedding | `resolvido` | +| 36 | 2026-09-23 | `.gitignore` com regra `models/` solta escondia do git o pacote inteiro `fcpxml/models/` (dados do engine), não só o cache do Whisper | `resolvido` | > Mantenha o índice acima sempre sincronizado com as entradas mais recentes. @@ -1732,3 +1735,161 @@ conjunto de títulos por cima do anterior em vez de substituí-lo. skipped, 1 falha e 1 erro de ambiente sem relação com a mudança (WhisperX/ `extract_pitch` ausente, torchcodec sem libs de sistema); lint dos arquivos alterados limpo. + +--- + +### Entrada 34 — 2026-09-22: `<adjustment>` inválido no DTD e `WHISPERX` órfão inflando o lint + +**Sintoma 1:** `fcpxml/writer/adjustment.py` (`ClipDeAjuste`, código de uma +sessão anterior não commitado) montava +`<clip><adjustment><filter-video .../></adjustment></clip>` para camadas de +ajuste. Nada usava o módulo ainda (sem chamada em `server_tools`/ +`admin/api`), mas ficava pronto para alguém reusar do jeito errado. + +**Causa 1:** o DTD real da Apple (`FCPXMLv1_13.dtd`) não define nenhum +elemento `<adjustment>`. A produção real de `<clip>` é +`(note?, %timing-params;, %intrinsic-params;, (spine|(%clip_item;)|caption)*, +(%marker_item;)*, audio-channel-source*, (%video_filter_item;)*, +filter-audio*, metadata?)` — ou seja, `filter-video`/`filter-audio` são +filhos diretos do `<clip>`, sem wrapper, e nessa ordem (vídeo antes de +áudio). + +**Solução 1:** `ClipDeAjuste.criar()` agora anexa os filtros direto no +`<clip>`, ordenados com vídeo antes de áudio +(`sorted(filtros, key=lambda f: f.tag != "filter-video")`). Teste de +regressão novo: `tests/test_writer_adjustment.py` (sem wrapper, ordem +correta, um `<effect>` por `uid` em `resources`). + +**Sintoma 2 (achado ao investigar o mesmo módulo):** um `ruff check . +--exclude docs/` rodado manualmente no início desta sessão acusou **510 +erros** — muito acima do que a suíte normalmente reporta. + +**Causa 2:** `code/WHISPERX` era uma pasta `.git` solta de **2,6 GB** dentro +de `code/` (não um submodule registrado — sem `.gitmodules`), contendo +cópias/backups congelados do próprio projeto, incluindo uma cópia inteira e +antiga de `fcp-mcp-server-main` dentro de si mesma. O `pyproject.toml` já +excluía `WHISPERX/` do lint por padrão (`[tool.ruff] exclude = ["docs/", +"WHISPERX/"]`), mas passar `--exclude docs/` na linha de comando +**sobrescreve** esse `exclude` em vez de complementá-lo — foi assim que o +lint passou a varrer os 2,6 GB de código velho lá dentro. Confirmado por +grep que só 3 arquivos no código ativo referenciam "WHISPERX", todos em +comentários explicativos (`fcpxml/diarize.py`, `tests/test_diarize.py`, +`admin/api/shared.py`) — nenhum import ou caminho real dependia da pasta. + +**Solução 2:** pasta movida para `~/Archives/G-ART-WHISPERX-backup` (fora do +workspace git), copiada com `rsync -a --no-perms` e conferida com +`diff -rq` antes de remover o original. `WHISPERX/` também saiu do +`exclude` do ruff em `code/pyproject.toml` (não faz mais sentido excluir um +caminho que não existe mais em `code/`). + +**Aprendizado:** (1) um wrapper de elemento "que faz sentido conceitualmente" +não substitui checar o DTD real antes de escrever o gerador — o padrão do +projeto (`dtd.py`, DTDs em `bm/*/FCPXMLv1_13.dtd`) existe exatamente para +isso. (2) uma flag de linha de comando como `--exclude` em ferramentas de +lint tipicamente **substitui** a config do projeto, não a estende — rodar +`ruff check .` sem flags (herdando `pyproject.toml`) é o comando correto +para refletir o gate real; qualquer variação manual com `--exclude` pode +mentir sobre o estado do lint. (3) uma pasta de backup improvisada dentro do +diretório ativo do projeto (mesmo que "só para não perder nada") é dívida +que cresce sem ninguém perceber — 2,6 GB não apareceram de uma vez. + +**Estado:** `resolvido` — `tests/test_writer_adjustment.py` (3 testes) +passando; `admin/` trazido ao lint gate no mesmo commit (ver +`09_MANUTENCAO.md` §2.3); suíte completa 1543 passando, 8 skipped, 1 falha ++ 1 erro pré-existentes de outro trabalho em andamento (sem relação com +esta correção). + +### Entrada 35 — 2026-09-22: chunk grande demais derrubava a indexação RAG inteira + +**Sintoma:** `admin/update_rag.command` (primeira indexação completa do +G-ART, banco `rag_gart` recém-provisionado) morria sempre no mesmo ponto com +`requests.exceptions.HTTPError: 500 Server Error` na chamada ao Ollama — +sempre logo após imprimir `code/fcpxml/export.py`, ou seja, no arquivo +seguinte na ordem alfabética. + +**Causa raiz:** `code/fcpxml/font_metrics.py` é uma tabela de larguras de +glifo (`METRICS = {...}`), texto extremamente denso em tokens (muitos +números/pontuação curtos) — um chunk de ~4900 caracteres (dentro do limite +`CHUNK_MAX_CHARS = 5000`) virou 2653 tokens no tokenizer do +`nomic-embed-text`, estourando o contexto de 2048 tokens do servidor Ollama +local (`llama.cpp`: "input length exceeds the context length"). Reproduzido +isolando o arquivo e chamando `/api/embeddings` chunk a chunk — 6 dos 9 +chunks falhavam. `CHUNK_MAX_CHARS` mede caracteres, não tokens; assume +implicitamente ~1 token por poucos caracteres, o que não vale para conteúdo +não-prosa (tabelas numéricas, JSON denso). + +Segundo problema, apontado por que a primeira tentativa não recuperou nada: +`admin/update_rag.py::index()` roda a varredura inteira (centenas de +arquivos) em **uma única transação**, com `commit()` só no fim e +`rollback()` em qualquer exceção — um único chunk problemático em um único +arquivo descartava a indexação inteira, mesmo que os outros 300+ arquivos +já tivessem embedado e inserido com sucesso. + +**Solução:** `_embed()` agora detecta essa resposta específica do Ollama +(`ChunkTooLarge`, checado por `500` + `"context length"` no corpo) e o loop +principal captura essa exceção por chunk, pula só aquele chunk (aviso em +stderr) e continua o arquivo — sem abortar a transação. Não trunca nem +reduz `CHUNK_MAX_CHARS` globalmente (afetaria todo o corpus por causa de +poucos arquivos atípicos); a lacuna fica só nos poucos chunks realmente +grandes demais, e o resto do arquivo ainda fica pesquisável. + +**Aprendizado:** um limite de chunk em caracteres é uma aproximação, não uma +garantia de contexto — arquivos de dados brutos (tabelas, mapeamentos +numéricos, JSON/CSV embutido em `.py`) tokenizam bem mais denso que prosa ou +código comum e podem violar o limite do modelo mesmo dentro do teto de +caracteres. Uma indexação em lote sobre centenas de arquivos não deve ficar +tudo-ou-nada numa única transação: uma falha isolada e recuperável (chunk +específico, arquivo específico) deve ser contida ali, não descartar o +trabalho inteiro já validado. + +**Estado:** `resolvido` — indexação completa rodou até o fim: 304 arquivos, +1702 chunks, 0 removidos. Também nesta sessão: criada a pasta `rag/` na raiz +(schema, busca híbrida `search.py`/`search_gart.sh`, `SETUP.md`) — ver +`rag/README.md` para a divisão de responsabilidades com `admin/update_rag.py`. + +--- + +### Entrada 36 — 2026-09-23: `.gitignore` escondia `fcpxml/models/` inteiro do git + +**Sintoma:** ao investigar por que um `git diff` de um arquivo recém-editado +(`fcpxml/models/timeline.py`, durante a correção da Entrada 34) não mostrava +nada, `git status` também não listava o arquivo como modificado nem como +untracked — como se ele simplesmente não existisse para o git. + +**Causa:** `.gitignore` tinha a regra solta `models/` (comentada como +"WhisperX models cache", pensada para ignorar o cache de ~11 GB de modelos +Whisper baixados em `code/models/`). Uma regra sem `/` inicial no +`.gitignore` casa com **qualquer diretório com esse nome em qualquer +profundidade** — não só `code/models/`, mas também `code/fcpxml/models/`, o +pacote de data classes (`TimeValue`, `Clip`, `Timeline`, `Marker`, etc.) que +sustenta todo o engine. Confirmado: `git ls-tree -r HEAD` não tem nenhum +`fcpxml/models.py` nem `fcpxml/models/` em nenhum commit do histórico — o +pacote inteiro (1.234 linhas, 7 módulos) só existia em disco, sem nenhuma +proteção de versionamento, desde que a divisão de `models.py` em pacote foi +feita (sessão anterior, nunca commitada). + +**Risco:** qualquer operação que limpa arquivos não rastreados +(`git clean -fd`, reinstalar do zero, trocar de máquina via `git clone`) +apagaria essa base sem chance de recuperação — nenhum commit para reverter. + +**Solução:** regra trocada para `/code/models/` (ancorada na raiz do repo, +só o cache real), preservando `whisper/` (sem uso hoje, mas inofensiva) e +tudo mais. Confirmado com `git check-ignore -v`: `fcpxml/models/timeline.py` +não é mais ignorado; `code/models/models--Systran--faster-whisper-base` +continua ignorado. `fcpxml/models/` passou a aparecer como `??` no +`git status` — visível, pronto para ser commitado quando o dono do trabalho +revisar. + +**Aprendizado:** regra de `.gitignore` sem `/` inicial (ex.: `models/`) casa +em qualquer profundidade da árvore — é fácil escrever pensando só no caso +que motivou a regra (um cache na raiz) e esquecer que o mesmo nome de pasta +pode existir, com sentido completamente diferente, dentro do código-fonte. +Regra de bolso: nomes de pasta genéricos (`models/`, `build/`, `cache/`, +`data/`) no `.gitignore` deveriam quase sempre vir ancorados (`/caminho/ +exato/`), a menos que a intenção seja mesmo ignorar toda ocorrência do nome +em qualquer lugar da árvore. + +**Estado:** `resolvido` — regra corrigida, `fcpxml/models/` confirmado +visível ao git (não commitado ainda; fica para quem já está com esse +trabalho em andamento decidir quando commitar). Nenhum código alterado, +só o `.gitignore`. diff --git a/code/Engine/docs/09_MANUTENCAO.md b/code/Engine/docs/09_MANUTENCAO.md index 6fb0db6..46c904a 100644 --- a/code/Engine/docs/09_MANUTENCAO.md +++ b/code/Engine/docs/09_MANUTENCAO.md @@ -7,7 +7,7 @@ Este é o documento de rota. Os outros descrevem o que **é**; este diz o que **fazer** e por onde começar quando chega uma implementação, uma melhoria ou uma correção. -Última varredura: 2026-08-21 · 1.498 testes · lint zerado (fora de server.py/ai_edit.py/llm_local.py, pré-existentes) +Última varredura: 2026-09-22 · 1.543 testes passando (+1 falha pré-existente em `test_forced_align.py` e +1 erro pré-existente em `test_refine_voice_timeline_tool.py`, ver §2.6) · lint zerado em `code/` e em `admin/` (fora de server.py/ai_edit.py/fcpxml/analise.py, pré-existentes — outro trabalho em andamento na branch) --- @@ -48,10 +48,12 @@ o olho do usuário. Não é para sair criando suíte de UI — mas lógica pura foi parar na camada de tela (cálculo de trim, mapeamento de tempo) deveria descer para o Python, onde já existe rede. -### 2.3 `admin/` fica fora do lint -`run_after_fix.sh` roda o ruff de dentro de `code/`, então `admin/` — 1.751 -linhas de código que o app depende para funcionar — nunca é verificado. -Incluir mexe no gate, então é decisão consciente, não esquecimento. +### 2.3 ~~`admin/` fica fora do lint~~ — resolvido em 2026-09-22 +`run_after_fix.sh` agora roda um segundo passo (`ruff check --config +pyproject.toml ../admin/`) com a mesma config do engine. Precisou de +`# noqa: E402` em 6 imports de `admin/models_api.py`/`admin/models_gui.py` +(padrão `sys.path.insert` antes do import local, convenção já usada no +projeto). Lint de `admin/` está zerado. ### 2.4 Confirmações visuais pendentes no FCP Várias entradas do `05_EXPERIENCIAS.md` estão marcadas como resolvidas *no XML* @@ -68,9 +70,39 @@ causa raiz. Não investigado a fundo ainda. → `fcpxml/writer/cut.py` (`cut_clip_ranges`, `min_keep_seconds`), padding do `remove_media_silence`. -### 2.6 Submódulo `WHISPERX` com conteúdo modificado e não commitado -Está fora dos commits de propósito, porque ninguém verificou o que mudou lá -dentro. Precisa ser olhado e resolvido — ou commitado, ou revertido. +### 2.6 ~~`fcpxml/writer/adjustment.py` gerava um wrapper `<adjustment>` inválido~~ — resolvido em 2026-09-22 +`ClipDeAjuste` embrulhava filtros num `<clip><adjustment>...</adjustment></clip>`, +que não existe no DTD real da Apple. Corrigido para anexar +`filter-video`/`filter-audio` direto como filhos do `<clip>` (na ordem que o +DTD exige: vídeo antes de áudio). Teste de regressão em +`tests/test_writer_adjustment.py`. Segue sem uso em `server_tools`/`admin/api` +— só deixou de estar pronto pra alguém reusar do jeito errado. +→ `05_EXPERIENCIAS.md` #34. + +### 2.7 `test_refine_voice_timeline_tool.py` quebrado: `voice_timeline.extract_pitch` ausente +`TestRefineVoiceTimelineHandler::test_max_zooms_caps_the_list` tenta +`monkeypatch.setattr(vt, "extract_pitch", ...)` mas `fcpxml/voice_timeline.py` +não tem mais (ou nunca teve, nesta branch) essa função. Pertence ao trabalho +de análise de voz já em andamento nesta branch (`voice_timeline.py` +modificado, não commitado) — não investigado a fundo, só registrado aqui +para não se perder. +→ `fcpxml/voice_timeline.py`, `tests/test_refine_voice_timeline_tool.py`. + +### 2.8 ~~Submódulo `WHISPERX` com conteúdo modificado e não commitado~~ — resolvido em 2026-09-22 +Não era um submódulo git registrado (sem `.gitmodules`) — era uma pasta +`.git` solta de 2,6 GB dentro de `code/`, com cópias/backups congelados do +próprio projeto (`WHISPERX_backup_88476/`, uma cópia inteira e antiga de +`fcp-mcp-server-main`). Só 3 referências no código ativo, todas em +comentários (`fcpxml/diarize.py`, `tests/test_diarize.py`, +`admin/api/shared.py`), nenhum import ou caminho dependia dela. Além do +peso morto, ela também inflava qualquer lint rodado com `--exclude` +explícito (que sobrescreve o `exclude` do `pyproject.toml`) — foi assim que +um `ruff check . --exclude docs/` chegou a acusar 510 erros, quase todos +dentro dela. Movida para `~/Archives/G-ART-WHISPERX-backup` (fora do +workspace git), copiada e verificada (`diff -rq`) antes de remover o +original. `WHISPERX/` também saiu do `exclude` do ruff em +`code/pyproject.toml` — não faz mais sentido excluir um caminho que não +existe mais dentro de `code/`. --- @@ -102,6 +134,8 @@ quanto arquivo gigante. ```bash cd code && ./Engine/run_after_fix.sh # lint zerado + 1.498 testes admin/run_app.command # se mexeu no app (padrão de revisão) +admin/run.command # app + atualização incremental da RAG +rag/search_gart.sh "consulta" # busca híbrida no índice RAG (ver rag/README.md) ``` E, além do script: diff --git a/code/Engine/docs/10_MAPA_REESTRUTURACAO.md b/code/Engine/docs/10_MAPA_REESTRUTURACAO.md new file mode 100644 index 0000000..ebf6cef --- /dev/null +++ b/code/Engine/docs/10_MAPA_REESTRUTURACAO.md @@ -0,0 +1,269 @@ +# 10 - Mapa de Reestruturacao de Funcionalidades + +> Escopo: roteiro pratico para reorganizar o codigo sem quebrar o produto. +> Baseado na varredura de 2026-08-24 sobre engine Python, ponte do app, +> ferramentas MCP e app SwiftUI. + +## 1. Diagnostico rapido + +O projeto ja tem uma arquitetura-alvo correta: `fcpxml/` como engine puro, +`server.py` + `server_tools/` como camada MCP, `admin/` como ponte JSON-lines +do app e `MacApp/` como interface. A melhoria agora nao e "reinventar" a +arquitetura, e reduzir os pontos onde as responsabilidades ainda se misturam. + +### Pontos fortes + +- Engine Python bem testado e com regra clara: logica de timeline fica em + `fcpxml/`. +- `writer/` ja foi quebrado em mixins por assunto, preservando API publica. +- `server.py` funciona como composition root e usa dispatch por dicionario. +- Documentacao interna registra decisoes, armadilhas e padroes do projeto. +- Fluxos criticos tem testes extensos em `code/tests/`. + +### Dores atuais + +- Alguns arquivos voltaram a virar centros de gravidade: + - `server_tools/voice.py` (~999 linhas) + - `server_tools/subtitles.py` (~760 linhas) + - `MacApp/Sources/WizardView.swift` (~979 linhas) + - `MacApp/Sources/TranscriptionView.swift` (~843 linhas) + - `fcpxml/model_manager.py` (~748 linhas) +- `admin/` e `server_tools/` expõem fluxos parecidos por caminhos diferentes, + o que aumenta risco de uma funcionalidade existir no MCP e faltar no app. +- ~~`admin/` ainda fica fora do lint principal~~ — resolvido na Fase 0 + (2026-09-22): `admin/` entrou no gate de `run_after_fix.sh`. +- ~~`WHISPERX` e backups aparecem junto da base ativa~~ — resolvido na + Fase 0 (2026-09-22): movido para fora do workspace git. +- O app SwiftUI quase nao tem rede automatizada; compilar nao garante que uma + tela abre. + +## 2. Mapa de dominios desejado + +```text +Produto + MacApp/ Interface e experiencia do usuario + admin/ Ponte JSON-lines do app + server.py + server_tools/ Entrada MCP + +Engine + fcpxml/models/ Dados e contratos + fcpxml/parser.py FCPXML -> objetos + fcpxml/writer/ Escrita e edicao de XML + fcpxml/voice_* Analise e decisoes por voz + fcpxml/text_layout.py Layout de legendas + fcpxml/model_manager.py Catalogo, configs e modelos + +Suporte + tests/ Rede automatizada + Engine/docs/ Decisoes e operacao + examples/ Fixtures de uso + +Legado / referencia + WHISPERX/ Deve sair do caminho ativo ou virar referencia clara +``` + +Regra de organizacao: uma funcionalidade nasce no engine, depois ganha duas +portas finas se necessario: uma tool MCP em `server_tools/` e um comando do app +em `admin/api/`. + +## 3. Reestruturacao por fases + +### Fase 0 - Higiene antes de mexer — `concluída em 2026-09-22` + +Objetivo: reduzir ruido e proteger a base antes de mover codigo. + +- ~~Decidir o destino de `code/WHISPERX`~~ — não era submodule (sem + `.gitmodules`), era 2,6 GB de backups órfãos do próprio projeto sem + nenhuma referência ativa. Movido para `~/Archives/G-ART-WHISPERX-backup` + (fora do workspace git), copiado com `rsync` e conferido com `diff -rq` + antes de remover o original. Detalhe: essa pasta também inflava qualquer + `ruff check --exclude docs/` manual (a flag sobrescrevia o `exclude` do + `pyproject.toml`, que já ignorava `WHISPERX/`) — ver `05_EXPERIENCIAS.md` + #34. +- ~~Incluir `admin/` em uma checagem de lint separada antes de colocar no + gate obrigatório~~ — checado com a config real do projeto (não o default + do ruff): só 6 erros, todos `E402` por `sys.path.insert` antes de import + local. Resolvido com `# noqa: E402` (convenção já usada no projeto) e + `admin/` entrou direto no gate obrigatório (`run_after_fix.sh`, passo + 2/3), sem precisar de etapa intermediária "separada". +- Corrigido de quebra: `fcpxml/writer/adjustment.py` gerava um `<adjustment>` + inválido no DTD — não estava no escopo original da Fase 0, mas surgiu na + investigação e era pequeno o bastante para resolver junto (ver + `05_EXPERIENCIAS.md` #34). +- Atualizados: `02_MODULES.md` (versão, linhas de `writer/`, módulos novos + `builders.py`/`adjustment.py`/`analise.py`/`transcription/`), + `09_MANUTENCAO.md` (contagem de testes/lint, itens §2.3/§2.6/§2.8 + resolvidos, novo item §2.7 registrando `test_refine_voice_timeline_tool`). +- **Pendente, não fechado nesta rodada:** "documentar oficialmente quais + pastas são produto ativo, legado e backup" como um documento à parte — + o que existia de fato como "legado" (`WHISPERX`) já foi resolvido, não + sobrou candidato claro para justificar um novo documento agora. + +Entrega obtida: lint de `admin/` no gate, `code/writer/adjustment.py` +correto e testado, ~2,6 GB fora do caminho ativo, docs sincronizados com o +código atual. + +### Fase 1 - Contratos entre camadas + +Objetivo: impedir que MCP, app e engine driftam entre si. + +- Criar um registro unico de capacidades, por exemplo: + - nome interno da funcionalidade; + - funcao pura do engine; + - handler MCP, se existir; + - comando `admin`, se existir; + - tela Swift, se existir; + - testes associados. +- Adicionar teste que detecta comandos importantes presentes no MCP mas ausentes + na ponte do app, quando fizer sentido. +- Padronizar o retorno dos comandos `admin/api`: `ok`, `path`, `message`, + `error`, `unchanged`, `artifacts`. + +Entrega esperada: mapa vivo de funcionalidades e menos "funciona no Claude, +nao aparece no app". + +### Fase 2 - Dividir `server_tools/voice.py` + +Objetivo: separar o fluxo de voz por etapas reais do produto. + +Divisao sugerida: + +```text +server_tools/voice/ + __init__.py Reexporta TOOLS e HANDLERS + analysis.py analyze_voice_features, build_voice_timeline + speakers.py diarize_media, remove_speakers + refinement.py refine_voice_timeline, remove_speech_gaps + actions.py apply_voice_actions + local_ai.py generate_voice_script + config.py get/save_voice_analysis_config +``` + +Cuidados: + +- Manter os nomes publicos reexportados para nao quebrar testes/imports. +- Mover em uma etapa por arquivo, rodando testes de voz a cada passo. +- Nao mover regra de negocio para `server_tools/voice/`; se aparecer regra + nova, ela deve descer para `fcpxml/voice_*`. + +Testes minimos: `test_voice_actions.py`, `test_voice_actions_tool.py`, +`test_voice_timeline.py`, `test_voice_timeline_tool.py`, `test_diarize.py`, +`test_voice_features.py`. + +### Fase 3 - Separar `fcpxml/model_manager.py` + +Objetivo: reduzir mistura entre catalogo, download, configuracao e estado. + +Divisao sugerida: + +```text +fcpxml/model_manager/ + __init__.py API publica atual + catalog.py models.json, recomendados, metadata + storage.py diretorios, instalados, migracao + download.py download/cancel/progresso + transcription_config.py modelo selecionado, idioma + voice_config.py analise de voz, silencio, legendas +``` + +Cuidados: + +- Preservar imports atuais via `__init__.py`. +- Separar funcoes puras de funcoes com I/O para facilitar teste. +- Nao acoplar config do app a nomes de tela Swift. + +Testes minimos: `test_models.py`, `test_models_api.py` se existir, +`test_voice_analysis_config.py`, `test_project_config.py`. + +### Fase 4 - Reorganizar o Assistente SwiftUI + +Objetivo: tornar o fluxo de 7 etapas legivel e testavel por partes. + +Divisao sugerida: + +```text +MacApp/Sources/Wizard/ + WizardView.swift Casca, navegacao e estado global + WizardState.swift Estado do fluxo e canAdvance + ProjectStepView.swift + TranscribeStepView.swift + VoiceAnalysisStepView.swift + AIScriptStepView.swift + ReviewStepHost.swift + ProcessStepView.swift + DoneStepView.swift +``` + +Boas praticas para essa fase: + +- Extrair primeiro views pequenas, sem alterar comportamento. +- Depois extrair calculos puros de `canAdvance`, nomes de arquivos e selecao + de artefatos para tipos testaveis. +- Usar harness manual documentado em `08_APP_MACOS.md` para abrir as telas + tocadas. + +Entrega esperada: cada etapa do wizard vira um arquivo com responsabilidade +unica. + +### Fase 5 - Unificar validacao e saida da ponte `admin/` + +Objetivo: deixar os comandos do app tao disciplinados quanto os handlers MCP. + +- Criar helpers de path/output equivalentes aos de `server_tools/_shared`, + ou mover helpers comuns para uma camada compartilhada que nao saiba de MCP. +- Trocar chamadas diretas a `server.generate_output_path` por helper de dominio + que nao puxe `server.py` quando a ponte so precisa de path. +- Adicionar lint de `admin/` ao fluxo de manutencao depois de corrigir erros + existentes. + +Entrega esperada: ponte mais fina, menos import acidental de transporte MCP. + +### Fase 6 - Tests e gates de seguranca + +Objetivo: fazer a reorganizacao ser barata de continuar. + +- Criar testes de "arquitetura": + - `fcpxml/` nao importa `server`, `server_tools` nem `admin`; + - handlers MCP sempre retornam via `_text_result`; + - comandos `admin` retornam JSON no formato padrao. +- Criar teste de import publico para garantir que reexports antigos continuam. +- Para SwiftUI, manter harnesses por tela critica ate existir um build mais + estruturado. + +Entrega esperada: mover arquivos deixa de ser aposta. + +## 4. Prioridade recomendada + +1. Fase 0: limpar mapa ativo vs legado. +2. Fase 1: criar registro de capacidades. +3. Fase 2: dividir voz em `server_tools`. +4. Fase 5: fortalecer `admin/`. +5. Fase 4: quebrar `WizardView`. +6. Fase 3: dividir `model_manager.py`. +7. Fase 6: ampliar gates conforme as fases estabilizam. + +Motivo: primeiro se reduz incerteza, depois se separa o arquivo que mais muda +no fluxo novo de voz, e so entao se mexe nas telas maiores. + +## 5. Checklist para cada refatoracao + +- Mover sem mudar comportamento na primeira passada. +- Preservar API publica com reexports. +- Rodar testes focados depois de cada movimento. +- Rodar `cd code && ./Engine/run_after_fix.sh` antes de concluir. +- Se mexeu em `MacApp/`, compilar e abrir a tela afetada. +- Atualizar docs no mesmo commit. +- Registrar aprendizado em `05_EXPERIENCIAS.md` quando houver bug real. + +## 6. Principios de boas praticas para este projeto + +- Engine puro: sem MCP, sem Swift, sem JSON de tela. +- Camadas de entrada finas: validam, chamam engine, formatam resposta. +- Tempo de timeline sempre racional (`TimeValue`), exceto metricas de audio e + UI onde segundos float sao apenas apresentacao/analise. +- Original nunca e sobrescrito. +- XML sempre entra por `safe_xml.py`. +- Dependencias opcionais continuam lazy. +- Arquivo grande so e problema quando contem varios assuntos. +- Toda funcionalidade importante deve ter dono, porta MCP/app documentada e + teste correspondente. diff --git a/code/Engine/run_after_fix.sh b/code/Engine/run_after_fix.sh index 15cdd3e..3999048 100755 --- a/code/Engine/run_after_fix.sh +++ b/code/Engine/run_after_fix.sh @@ -23,14 +23,19 @@ echo "==> [G-ART] Validação pós-correção iniciada..." echo " Diretório: $REPO_ROOT" echo "" -echo "==> 1/2 Lint (ruff) — deve passar com ZERO erros" +echo "==> 1/3 Lint do engine (ruff, code/) — deve passar com ZERO erros" # A flag --exclude sobrescreve o exclude declarado em pyproject.toml -# (que já ignora docs/ e WHISPERX/). Rode sem flag para herdar a config. +# (que já ignora docs/). Rode sem flag para herdar a config. uv run ruff check . echo " Lint OK ✓" echo "" -echo "==> 2/2 Testes (pytest) — todos devem passar" +echo "==> 2/3 Lint da ponte (ruff, admin/) — mesma config do engine" +uv run ruff check --config pyproject.toml ../admin/ +echo " Lint OK ✓" +echo "" + +echo "==> 3/3 Testes (pytest) — todos devem passar" uv run pytest tests/ -v echo "" diff --git a/code/WHISPERX b/code/WHISPERX deleted file mode 160000 index c9ed3cc..0000000 --- a/code/WHISPERX +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c9ed3cc6bd58590b0ba4cf7451dd16722f06bb38 diff --git a/code/fcpxml/models/__init__.py b/code/fcpxml/models/__init__.py new file mode 100644 index 0000000..cb976d9 --- /dev/null +++ b/code/fcpxml/models/__init__.py @@ -0,0 +1,120 @@ +""" +Data models for Final Cut Pro FCPXML structures. + +Provides a clean Python interface for working with Final Cut Pro timelines, +clips, markers, and other elements. + +Era um módulo de 1.091 linhas com seis famílias de modelo dentro. Agora cada +família tem seu arquivo, e este pacote reexporta tudo — `from .models import +TimeValue` segue valendo em todo o projeto, inclusive para os nomes com +underscore que o writer e a suíte já usavam. + + enums tipos e cores de marcador, transições, ritmo + timing TimeValue (fração racional) e Timecode + timeline clipes, marcadores, lanes, projeto + planning rough cut, ritmo, montagem + qc achados de QC e resultado de validação + subtitles paleta e look das legendas dinâmicas +""" + +from .enums import ( + _MAX_MARKER_TYPE_LENGTH, + MARKER_XML_TAGS, + FlashFrameSeverity, + MarkerColor, + MarkerType, + PacingCurve, + PacingStyle, + TransitionType, + ValidationIssueType, +) +from .planning import ( + MontageConfig, + PacingConfig, + RoughCutResult, + SegmentSpec, +) +from .qc import ( + DuplicateGroup, + FlashFrame, + GapInfo, + ValidationIssue, + ValidationResult, +) +from .subtitles import ( + COLOR_GREY, + COLOR_INDIGO, + COLOR_WHITE, + COLOR_YELLOW, + EDITORIAL_BODY_LOOK, + EDITORIAL_EMPHASIS_LOOK, + REFERENCE_RHYTHM, + DynamicSubtitleConfig, + SubtitlePosition, + WordLook, + WordStyle, +) +from .timeline import ( + AudioClip, + Clip, + CompoundClip, + ConnectedClip, + Keyword, + Marker, + Project, + SilenceCandidate, + Timeline, + Transition, + VideoClip, +) +from .timing import ( + _FCPXML_STANDARD_TIMEBASES, + Timecode, + TimeValue, +) + +__all__ = [ + "AudioClip", + "COLOR_GREY", + "COLOR_INDIGO", + "COLOR_WHITE", + "COLOR_YELLOW", + "Clip", + "CompoundClip", + "ConnectedClip", + "DuplicateGroup", + "DynamicSubtitleConfig", + "EDITORIAL_BODY_LOOK", + "EDITORIAL_EMPHASIS_LOOK", + "FlashFrame", + "FlashFrameSeverity", + "GapInfo", + "Keyword", + "MARKER_XML_TAGS", + "Marker", + "MarkerColor", + "MarkerType", + "MontageConfig", + "PacingConfig", + "PacingCurve", + "PacingStyle", + "Project", + "REFERENCE_RHYTHM", + "RoughCutResult", + "SegmentSpec", + "SilenceCandidate", + "SubtitlePosition", + "TimeValue", + "Timecode", + "Timeline", + "Transition", + "TransitionType", + "ValidationIssue", + "ValidationIssueType", + "ValidationResult", + "VideoClip", + "WordLook", + "WordStyle", + "_FCPXML_STANDARD_TIMEBASES", + "_MAX_MARKER_TYPE_LENGTH", +] diff --git a/code/fcpxml/models/enums.py b/code/fcpxml/models/enums.py new file mode 100644 index 0000000..e08aa63 --- /dev/null +++ b/code/fcpxml/models/enums.py @@ -0,0 +1,183 @@ +"""Enumerações do domínio: tipos e cores de marcador, transições, ritmo. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +from enum import Enum + +# Maximum length for marker type strings to prevent memory abuse +_MAX_MARKER_TYPE_LENGTH = 64 + +class MarkerType(Enum): + """Types of markers in Final Cut Pro. + + Members: + STANDARD — Default marker with no completion state. + INCOMPLETE — Task marker (completed="0" in FCPXML). ← canonical name + TODO — Alias for INCOMPLETE. Kept for backward compatibility; + resolves to the same object (``MarkerType.TODO is + MarkerType.INCOMPLETE``). Python enums treat the first + member with a given value as canonical; all subsequent + members sharing that value become aliases. + CHAPTER — Chapter marker (``<chapter-marker>`` element). + COMPLETED — Task marker with completed="1". + + Serialization helpers: + ``from_string()`` — Accepts values, names, and legacy aliases + (e.g. ``"todo-marker"``). Always returns the + canonical member. + ``from_xml_element()`` — Reads an ``lxml``/``ElementTree`` element and + returns the appropriate type based on the tag + name and ``completed`` attribute. + ``xml_tag`` — The FCPXML element tag to emit when writing. + ``xml_attrs`` — Extra attributes required when writing (e.g. + ``completed="0"`` for INCOMPLETE). + """ + STANDARD = "standard" + INCOMPLETE = "todo" + TODO = "todo" # Backward-compat alias — resolves to INCOMPLETE at runtime + CHAPTER = "chapter" + COMPLETED = "completed" + + @classmethod + def from_string(cls, value: str) -> 'MarkerType': + """Convert a string to MarkerType, accepting both enum names and values. + + Includes input validation: rejects null bytes, control characters, + and excessively long strings to prevent injection and memory abuse. + + Examples: + MarkerType.from_string("todo") -> MarkerType.INCOMPLETE + MarkerType.from_string("TODO") -> MarkerType.INCOMPLETE + MarkerType.from_string("completed") -> MarkerType.COMPLETED + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + if '\x00' in value or any(ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value): + raise ValueError("Marker type contains invalid control characters") + if len(value) > _MAX_MARKER_TYPE_LENGTH: + raise ValueError( + f"Marker type exceeds maximum length ({_MAX_MARKER_TYPE_LENGTH} chars)" + ) + lowered = value.strip().lower() + if not lowered: + raise ValueError("Marker type cannot be empty") + # Accept legacy aliases from older specs (e.g. "todo-marker" → INCOMPLETE) + aliases = { + "todo-marker": "todo", + "completed-marker": "completed", + "chapter-marker": "chapter", + } + lowered = aliases.get(lowered, lowered) + try: + return cls(lowered) + except ValueError: + raise ValueError( + f"Invalid marker type: '{value}'. " + f"Valid types: {', '.join(m.value for m in cls)}" + ) + + @classmethod + def from_xml_element(cls, elem) -> 'MarkerType': + """Determine MarkerType from an XML element's tag and attributes. + + Centralises the parse-side mapping so the parser doesn't need to + know about completed-attribute semantics. + + Rules (in priority order): + 1. <chapter-marker> tag → CHAPTER (completed attr ignored) + 2. completed='0' (exact) → INCOMPLETE + 3. completed='1' (exact) → COMPLETED + 4. Everything else → STANDARD (including whitespace-padded, + absent, empty, or non-boolean completed values) + + Matching is intentionally strict — no .strip(), no case folding. + This prevents whitespace-injected attributes like ' 0 ' from + being misclassified. + """ + if elem.tag == 'chapter-marker': + return cls.CHAPTER + completed = elem.get('completed') + if completed == '0': + return cls.INCOMPLETE + if completed == '1': + return cls.COMPLETED + return cls.STANDARD + + @property + def xml_tag(self) -> str: + """Return the FCPXML element tag for this marker type.""" + return 'chapter-marker' if self == MarkerType.CHAPTER else 'marker' + + @property + def xml_attrs(self) -> dict: + """Return extra XML attributes this marker type requires when writing. + + Centralises the write-side mapping so both FCPXMLModifier and + FCPXMLWriter use a single source of truth. + """ + if self == MarkerType.CHAPTER: + return {'posterOffset': '0s'} + if self == MarkerType.INCOMPLETE: + return {'completed': '0'} + if self == MarkerType.COMPLETED: + return {'completed': '1'} + return {} + +# Recognised marker XML tags — used by the parser for single-pass collection +# and by the writer to validate element creation. +MARKER_XML_TAGS = ('marker', 'chapter-marker') + +class MarkerColor(Enum): + """Marker color options (FCP internal values).""" + BLUE = 0 + CYAN = 1 + GREEN = 2 + YELLOW = 3 + ORANGE = 4 + RED = 5 + PINK = 6 + PURPLE = 7 + +class TransitionType(Enum): + """Built-in transition types.""" + CROSS_DISSOLVE = "Cross Dissolve" + FADE_TO_BLACK = "Fade to Color" + FADE_FROM_BLACK = "Fade from Color" + DIP_TO_COLOR = "Dip to Color" + WIPE = "Wipe" + SLIDE = "Slide" + +class PacingStyle(Enum): + """Pacing presets for rough cut generation.""" + SLOW = "slow" # 5-10 second cuts + MEDIUM = "medium" # 2-5 second cuts + FAST = "fast" # 0.5-2 second cuts + DYNAMIC = "dynamic" # Varies throughout + +class FlashFrameSeverity(Enum): + """Severity levels for flash frame detection.""" + CRITICAL = "critical" # < 2 frames, almost certainly an error + WARNING = "warning" # < 6 frames, potentially intentional but suspicious + +class PacingCurve(Enum): + """Pacing curves for montage generation.""" + CONSTANT = "constant" # Same clip duration throughout + ACCELERATING = "accelerating" # Starts slow, gets faster + DECELERATING = "decelerating" # Starts fast, gets slower + PYRAMID = "pyramid" # Slow → fast → slow + +class ValidationIssueType(Enum): + """Types of timeline validation issues.""" + FLASH_FRAME = "flash_frame" + GAP = "gap" + DUPLICATE = "duplicate" + ORPHAN_REF = "orphan_ref" + INVALID_OFFSET = "invalid_offset" + # DTD validation types (v0.6.0) + ELEMENT_ORDER = "element_order" + MISSING_ATTRIBUTE = "missing_attribute" + INVALID_TIMEBASE = "invalid_timebase" + FRAME_MISALIGNMENT = "frame_misalignment" + MISSING_EFFECT_REF = "missing_effect_ref" + MISSING_MEDIA_REP = "missing_media_rep" diff --git a/code/fcpxml/models/planning.py b/code/fcpxml/models/planning.py new file mode 100644 index 0000000..15dbe22 --- /dev/null +++ b/code/fcpxml/models/planning.py @@ -0,0 +1,93 @@ +"""Especificações de geração: rough cut, ritmo e montagem. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +from .enums import PacingCurve + + +@dataclass +class SegmentSpec: + """Specification for a segment in auto rough cut.""" + name: str + keywords: List[str] = field(default_factory=list) + duration_seconds: float = 0.0 + priority: str = "best" # favorites, longest, shortest, random, best + +@dataclass +class PacingConfig: + """Configuration for rough cut pacing.""" + pacing: str = "medium" # slow, medium, fast, dynamic + min_clip_duration: float = 1.0 + max_clip_duration: float = 8.0 + avg_clip_duration: Optional[float] = None + vary_pacing: bool = True + + def get_duration_range(self) -> Tuple[float, float]: + """Get min/max based on pacing style.""" + ranges = { + "slow": (5.0, 10.0), + "medium": (2.0, 5.0), + "fast": (0.5, 2.0), + "dynamic": (1.0, 6.0), + } + return ranges.get(self.pacing, (2.0, 5.0)) + +@dataclass +class RoughCutResult: + """Result of auto rough cut generation.""" + output_path: str + clips_used: int + clips_available: int + target_duration: float + actual_duration: float + segments: int + average_clip_duration: float + +@dataclass +class MontageConfig: + """Configuration for montage generation with pacing curves.""" + target_duration: float # Target duration in seconds + pacing_curve: 'PacingCurve' + start_duration: float = 2.0 # Clip duration at start + end_duration: float = 0.5 # Clip duration at end + min_duration: float = 0.2 # Minimum allowed clip duration + max_duration: float = 5.0 # Maximum allowed clip duration + + def get_duration_at_position(self, position: float) -> float: + """ + Calculate clip duration for a given position (0.0 to 1.0). + + Args: + position: Position in montage (0.0 = start, 1.0 = end) + + Returns: + Target duration in seconds for a clip at this position + """ + if self.pacing_curve == PacingCurve.CONSTANT: + duration = (self.start_duration + self.end_duration) / 2 + + elif self.pacing_curve == PacingCurve.ACCELERATING: + # Linear interpolation from start to end duration + duration = self.start_duration + (self.end_duration - self.start_duration) * position + + elif self.pacing_curve == PacingCurve.DECELERATING: + # Reverse: start fast, end slow + duration = self.end_duration + (self.start_duration - self.end_duration) * position + + elif self.pacing_curve == PacingCurve.PYRAMID: + # Slow → fast → slow (parabolic curve) + if position < 0.5: + # First half: slow to fast + duration = self.start_duration + (self.end_duration - self.start_duration) * (position * 2) + else: + # Second half: fast to slow + duration = self.end_duration + (self.start_duration - self.end_duration) * ((position - 0.5) * 2) + else: + duration = self.start_duration + + # Clamp to min/max + return max(self.min_duration, min(self.max_duration, duration)) diff --git a/code/fcpxml/models/qc.py b/code/fcpxml/models/qc.py new file mode 100644 index 0000000..845cb42 --- /dev/null +++ b/code/fcpxml/models/qc.py @@ -0,0 +1,121 @@ +"""Achados de QC e o resultado de uma validação. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .enums import FlashFrameSeverity, ValidationIssueType +from .timing import Timecode + + +@dataclass +class FlashFrame: + """ + Represents a detected flash frame (ultra-short clip). + + Flash frames are typically editing errors - clips that are too short + to be perceived as intentional cuts. + """ + clip_name: str + clip_id: str + start: Timecode + duration_frames: int + duration_seconds: float + severity: 'FlashFrameSeverity' + + @property + def is_critical(self) -> bool: + """Check if this is a critical flash frame.""" + return self.severity == FlashFrameSeverity.CRITICAL + +@dataclass +class GapInfo: + """ + Represents a detected gap in the timeline. + + Gaps can be intentional (black frames) or errors from deleted clips. + """ + start: Timecode + duration_frames: int + duration_seconds: float + previous_clip: Optional[str] = None # Clip name before the gap + next_clip: Optional[str] = None # Clip name after the gap + + @property + def timecode(self) -> str: + """Get timecode string for the gap start.""" + return self.start.to_smpte() + +@dataclass +class DuplicateGroup: + """ + Represents a group of clips using the same source media. + + Useful for detecting duplicate clips that may be unintentional. + """ + source_ref: str # The asset/media reference ID + source_name: str # Human-readable source name + clips: List[Dict[str, Any]] = field(default_factory=list) # List of clip info dicts + + @property + def count(self) -> int: + """Number of clips using this source.""" + return len(self.clips) + + @property + def has_overlapping_ranges(self) -> bool: + """Check if any clips use overlapping portions of the source.""" + # Sort clips by source_start + sorted_clips = sorted(self.clips, key=lambda c: c.get('source_start', 0)) + for i in range(len(sorted_clips) - 1): + curr_end = sorted_clips[i].get('source_start', 0) + sorted_clips[i].get('source_duration', 0) + next_start = sorted_clips[i + 1].get('source_start', 0) + if curr_end > next_start: + return True + return False + +@dataclass +class ValidationIssue: + """ + Represents a single validation issue found in a timeline. + + Used by validate_timeline to report problems. + """ + issue_type: 'ValidationIssueType' + severity: str # "error", "warning", "info" + message: str + timecode: Optional[str] = None + clip_name: Optional[str] = None + details: Dict[str, Any] = field(default_factory=dict) + +@dataclass +class ValidationResult: + """ + Result of timeline validation. + + Provides a health score and categorized list of issues. + """ + is_valid: bool + health_score: int # 0-100 percentage + issues: List[ValidationIssue] = field(default_factory=list) + flash_frames: List[FlashFrame] = field(default_factory=list) + gaps: List[GapInfo] = field(default_factory=list) + duplicates: List[DuplicateGroup] = field(default_factory=list) + + @property + def error_count(self) -> int: + return len([i for i in self.issues if i.severity == "error"]) + + @property + def warning_count(self) -> int: + return len([i for i in self.issues if i.severity == "warning"]) + + def summary(self) -> str: + """Generate a summary string.""" + return ( + f"Timeline Health: {self.health_score}% | " + f"Errors: {self.error_count} | Warnings: {self.warning_count} | " + f"Flash frames: {len(self.flash_frames)} | Gaps: {len(self.gaps)}" + ) diff --git a/code/fcpxml/models/subtitles.py b/code/fcpxml/models/subtitles.py new file mode 100644 index 0000000..d3d7abd --- /dev/null +++ b/code/fcpxml/models/subtitles.py @@ -0,0 +1,165 @@ +"""Aparência das legendas dinâmicas: paleta, look por palavra, configuração. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +from dataclasses import dataclass, field +from typing import Optional + +from ..text_layout import REFERENCE_BLOCK_LINE_GAP, TEXT_TEMPLATE_FONT_SCALE + +# The palette and type treatment of the calibration export +# ("Exemplo Letra.fcpxmld", sentence "Toda a minha vida, assim,"), copied +# verbatim from what the user set in Final Cut's Inspector. +COLOR_INDIGO = "0.156863 0 0.596079 1" + +COLOR_YELLOW = "0.997808 0.882664 0.0388632 1" + +COLOR_GREY = "0.7 0.7 0.7 1" + +COLOR_WHITE = "1 1 1 1" + +@dataclass +class WordLook: + """How one word is set: size, colour and type treatment. + + A sentence cycles through a tuple of these, so its typography reads with a + deliberate rhythm rather than a uniform block. + """ + font_size: int + color: str + font: str = "Helvetica Neue" + face: Optional[str] = None # Final Cut's fontFace, e.g. "Light Italic" + kerning: float = 2.048 + + @property + def italic(self) -> bool: + return bool(self.face) and "italic" in self.face.lower() + +# One entry per word of the reference sentence, in order: +# Toda(170, indigo, Helvetica Light) a(128, yellow) minha(151, grey) +# vida,(128, white) assim,(128, grey, Light Italic) +REFERENCE_RHYTHM = ( + WordLook(170, COLOR_INDIGO, font="Helvetica", face="Light", kerning=2.72), + WordLook(128, COLOR_YELLOW), + WordLook(151, COLOR_GREY, kerning=2.416), + WordLook(128, COLOR_WHITE), + WordLook(128, COLOR_GREY, face="Light Italic"), +) + +# The progressive-composition look (reference: the reel the user sent, +# 2026-08-17). Supporting text in a small grotesque, the sentence's key word +# large in a display italic, everything white — the two-font contrast IS the +# style. Playfair Display ships in the user's ~/Library/Fonts and its real +# advance widths are embedded in font_metrics, so the lines can be measured +# rather than guessed. Both are plain WordLooks: swap them for any installed +# family (a script/calligraphic face for the emphasis, say) and layout follows. +EDITORIAL_EMPHASIS_LOOK = WordLook( + 230, COLOR_WHITE, font="Playfair Display", face="Medium Italic", kerning=0.0, +) + +EDITORIAL_BODY_LOOK = WordLook( + 88, COLOR_WHITE, font="Helvetica Neue", face="Bold", kerning=1.2, +) + +@dataclass +class WordStyle: + """Per-word text styling for dynamic (karaoke-style) subtitles. + + ``rhythm`` drives size, colour and face, cycling by the word's index within + its sentence — deterministic, so regenerating a transcript twice yields the + same look. ``font``/``font_size`` are the fallback when ``rhythm`` is empty. + """ + font: str = "Helvetica Neue" + font_size: int = 128 + active_color: str = COLOR_WHITE + inactive_color: str = COLOR_GREY + bold: bool = False + kerning: float = 2.048 + rhythm: tuple = REFERENCE_RHYTHM + # Progressive composition only (granularity="phrase"). + emphasis_look: Optional[WordLook] = None + body_look: Optional[WordLook] = None + + def look_for(self, index: int) -> WordLook: + """The look for the word at *index* within its sentence.""" + if not self.rhythm: + return WordLook( + self.font_size, self.active_color, + font=self.font, kerning=self.kerning, + ) + return self.rhythm[index % len(self.rhythm)] + + def look_for_emphasis(self) -> WordLook: + """The look for a composition's key word (progressive composition).""" + return self.emphasis_look or EDITORIAL_EMPHASIS_LOOK + + def look_for_body(self) -> WordLook: + """The look for a composition's supporting lines.""" + return self.body_look or EDITORIAL_BODY_LOOK + +@dataclass +class SubtitlePosition: + """Screen position for generated title clips, in FCP title coordinate space.""" + x: float = 0.0 + y: float = -300.0 + alignment: str = "center" # left | center | right + +@dataclass +class DynamicSubtitleConfig: + """Options for FCPXMLWriter.generate_dynamic_subtitles(). + + Dynamic subtitles are animated TITLES, not captions. Both templates below + render on the video title lane and never carry a ``subtitles.*`` role — a + ``role="subtitles.*"`` would make Final Cut treat them as captions and + hide them behind the caption-display toggle. They DO carry a + ``titles.*`` sub-role (``role``), which groups them in Final Cut's + role index and lanes them with a distinct colour, without ever being + mistaken for closed captions. + + ``animated`` picks the template: True uses "Essencial - Título" + (Essential Title), which animates on its own Motion defaults; False uses + the static "Título Básico" (Basic Title). Default is True — the animated + reveal is the feature's purpose. + + Words are grouped into sentences and laid out as a compact typographic + block: each word becomes its own positioned ``<title>``, appearing as it is + spoken and accumulating on screen, with every word of a block clearing at + the same instant so the sentence vanishes as a whole. + + ``band_height`` is the fraction of frame height the block may occupy, and + ``block_center_y`` its centre in canvas points (negative is below frame + centre). The defaults reproduce the calibration export the user built by + hand: a block of at most three lines sitting just below centre. A sentence + taller than the band splits into successive blocks. + """ + style: WordStyle = field(default_factory=WordStyle) + position: SubtitlePosition = field(default_factory=SubtitlePosition) + animated: bool = True + band_height: float = 0.22 + block_center_y: float = -167.0 + # "phrase": one title per LINE of the composition — supporting words + # grouped, the key word alone and large (the reference look). "word": one + # title per word, the earlier rhythm. + granularity: str = "phrase" + # Ratio between the template's fontSize space and the canvas-point space + # its Position uses. See text_layout.TEXT_TEMPLATE_FONT_SCALE: the "Text" + # (Text.moti) template sizes type in frame pixels, so a size chosen in + # points renders half as large unless it is converted on the way out. + text_scale: float = TEXT_TEMPLATE_FONT_SCALE + # Vertical air between stacked lines, in canvas points. Negative values + # deliberately overlap the lines — the display italic tucking under the + # line above is a real editorial look, and the stacking arithmetic places + # ink boxes edge to edge, so a negative gap moves them by exactly that + # much rather than colliding unpredictably. + line_gap: float = REFERENCE_BLOCK_LINE_GAP + # Final Cut role for every title this generator emits. A ``titles.*`` + # sub-role (NOT ``subtitles.*``) groups the clips in the role index and + # tints their lane, keeping dynamic captions distinct from plain + # ones and from Final Cut's own closed-caption toggle. + role: str = "titles.dinamicas" + # Run the post-generation collision validation (collision.validate_titles) + # and refuse to emit when it reports a blocking overlap. Off by default so + # generation stays byte-identical to before this flag existed; flip it on + # for a guaranteed no-collision export. + validate: bool = False diff --git a/code/fcpxml/models/timeline.py b/code/fcpxml/models/timeline.py new file mode 100644 index 0000000..cfd04f6 --- /dev/null +++ b/code/fcpxml/models/timeline.py @@ -0,0 +1,248 @@ +"""O que existe numa timeline: clipes, marcadores, lanes, projeto. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from .enums import MarkerColor, MarkerType +from .timing import Timecode + + +@dataclass +class Keyword: + """Represents a keyword/tag applied to a clip.""" + value: str + start: Optional[Timecode] = None + duration: Optional[Timecode] = None + + +@dataclass +class ParametroEfeito: + """Um parâmetro de um filtro de efeito (``<param>`` dentro do filtro).""" + nome: str + valor: str + chave: str = "" + metadado: str = "" + + +@dataclass +class EfeitoAjuste: + """Um efeito aplicado por uma camada de ajuste (adjustment layer). + + ``uid`` é o UUID do efeito interno do Final Cut (ver ``FCP_EFFECTS`` em + ``fcpxml/writer/helpers.py`` para os efeitos built-in). ``tipo`` é + ``"video"`` ou ``"audio"`` — decide se vira ``<filter-video>`` ou + ``<filter-audio>``, filho direto do ``<clip>`` da camada de ajuste (o + DTD não define wrapper ``<adjustment>``). + """ + nome: str + uid: str + tipo: str = "video" + parametros: List[ParametroEfeito] = field(default_factory=list) + +@dataclass +class Marker: + """Represents a marker in the timeline.""" + name: str + start: Timecode + duration: Optional[Timecode] = None + marker_type: MarkerType = MarkerType.STANDARD + note: str = "" + color: Optional[MarkerColor] = None + + def to_youtube_timestamp(self) -> str: + """Format as YouTube chapter timestamp.""" + total_seconds = int(self.start.seconds) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + secs = total_seconds % 60 + if hours > 0: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + +@dataclass +class Clip: + """Represents a clip in the timeline.""" + name: str + start: Timecode + duration: Timecode + source_start: Optional[Timecode] = None + source_end: Optional[Timecode] = None + media_path: str = "" + markers: List[Marker] = field(default_factory=list) + keywords: List[Keyword] = field(default_factory=list) + + # Extended metadata + rating: int = 0 # 0=unrated, 1-5 stars + is_favorite: bool = False + is_rejected: bool = False + + # Roles (FCP audio/video role assignments) + audio_role: str = "" + video_role: str = "" + + # Connected clips (B-roll, titles, audio attached to this clip) + connected_clips: List['ConnectedClip'] = field(default_factory=list) + + # Edit-time correction, in degrees, from a Transform filter on the clip + # (e.g. straightening a tilted phone shot) — not the camera's own + # recorded orientation, which lives in the media file itself. + rotation: float = 0.0 + + @property + def end(self) -> Timecode: + return Timecode( + frames=self.start.frames + self.duration.frames, + frame_rate=self.start.frame_rate + ) + + @property + def duration_seconds(self) -> float: + return self.duration.seconds + + @property + def keyword_values(self) -> List[str]: + """Get list of keyword strings.""" + return [k.value for k in self.keywords] + +@dataclass +class AudioClip(Clip): + """Audio-specific clip.""" + channels: int = 2 + sample_rate: int = 48000 + role: str = "dialogue" + +@dataclass +class VideoClip(Clip): + """Video-specific clip.""" + width: int = 1920 + height: int = 1080 + has_audio: bool = True + +@dataclass +class ConnectedClip: + """A clip connected to a primary storyline clip (B-roll, titles, audio). + + In FCP's magnetic timeline, connected clips hang off spine clips via lanes. + Positive lanes are above (video overlays), negative lanes are below (audio). + """ + name: str + start: Timecode + duration: Timecode + lane: int = 1 + offset: Optional[Timecode] = None + source_start: Optional[Timecode] = None + media_path: str = "" + clip_type: str = "asset-clip" + role: str = "" + ref_id: str = "" + parent_clip_name: str = "" + markers: List[Marker] = field(default_factory=list) + keywords: List[Keyword] = field(default_factory=list) + rotation: float = 0.0 + + @property + def duration_seconds(self) -> float: + return self.duration.seconds + +@dataclass +class CompoundClip: + """A compound clip (ref-clip) containing a nested timeline.""" + name: str + ref_id: str + duration: Timecode + start: Timecode + clips: List[Clip] = field(default_factory=list) + connected_clips: List[ConnectedClip] = field(default_factory=list) + + @property + def duration_seconds(self) -> float: + return self.duration.seconds + +@dataclass +class SilenceCandidate: + """A potential silence region detected by timeline heuristics.""" + start_timecode: str + duration_seconds: float + reason: str # "gap", "ultra_short", "name_match", "duration_anomaly" + confidence: float = 0.5 # 0.0 to 1.0 + clip_name: Optional[str] = None + clip_index: Optional[int] = None + +@dataclass +class Transition: + """Represents a transition between clips.""" + name: str + duration: Timecode + start: Timecode + transition_type: str = "cross-dissolve" + +@dataclass +class Timeline: + """Represents a Final Cut Pro timeline/sequence.""" + name: str + duration: Timecode + frame_rate: float = 24.0 + width: int = 1920 + height: int = 1080 + clips: List[Clip] = field(default_factory=list) + audio_clips: List[AudioClip] = field(default_factory=list) + transitions: List[Transition] = field(default_factory=list) + markers: List[Marker] = field(default_factory=list) + connected_clips: List[ConnectedClip] = field(default_factory=list) + compound_clips: List[CompoundClip] = field(default_factory=list) + + @property + def total_clips(self) -> int: + return len(self.clips) + + @property + def total_cuts(self) -> int: + return max(0, len(self.clips) - 1) + + @property + def average_clip_duration(self) -> float: + if not self.clips: + return 0.0 + return sum(c.duration_seconds for c in self.clips) / len(self.clips) + + @property + def cuts_per_minute(self) -> float: + """Average cuts per minute.""" + if self.duration.seconds <= 0: + return 0.0 + return (self.total_cuts / self.duration.seconds) * 60 + + def get_clips_shorter_than(self, seconds: float) -> List[Clip]: + """Find clips shorter than threshold (flash frame detection).""" + return [c for c in self.clips if c.duration_seconds < seconds] + + def get_clips_longer_than(self, seconds: float) -> List[Clip]: + """Find clips longer than threshold.""" + return [c for c in self.clips if c.duration_seconds > seconds] + + def get_clip_at(self, timecode: float) -> Optional[Clip]: + """Find the clip at a specific timecode (seconds).""" + for clip in self.clips: + start_sec = clip.start.seconds + end_sec = clip.end.seconds + if start_sec <= timecode < end_sec: + return clip + return None + + def get_clips_by_keyword(self, keyword: str) -> List[Clip]: + """Find all clips with a specific keyword.""" + return [c for c in self.clips if keyword in c.keyword_values] + +@dataclass +class Project: + """Represents a Final Cut Pro project/library.""" + name: str + timelines: List[Timeline] = field(default_factory=list) + fcpxml_version: str = "1.13" + + @property + def primary_timeline(self) -> Optional[Timeline]: + return self.timelines[0] if self.timelines else None diff --git a/code/fcpxml/models/timing.py b/code/fcpxml/models/timing.py new file mode 100644 index 0000000..0256bd4 --- /dev/null +++ b/code/fcpxml/models/timing.py @@ -0,0 +1,304 @@ +"""Tempo em fração racional — TimeValue e o Timecode que o embrulha. + +Extraído de models.py — ver fcpxml/models/__init__.py. +""" + +import operator +from dataclasses import dataclass +from fractions import Fraction +from functools import total_ordering +from math import gcd +from typing import Callable + +# Standard FCPXML timebase denominators that FCP's DTD validator accepts. +# TimeValue.to_fcpxml() only simplifies fractions when the result uses one +# of these denominators, preventing values like "8/3s" that FCP rejects. +_FCPXML_STANDARD_TIMEBASES = frozenset({ + 1, 24, 25, 30, 48, 50, 60, 90, 96, 100, 120, + 240, 600, 2400, 4800, 9600, 48000, +}) + +@total_ordering +@dataclass +class TimeValue: + """ + Represents time in FCPXML's rational format. + + FCPXML uses fractions of seconds (e.g., "90/30s" for 3 seconds at 30fps). + This class handles conversion between timecode, seconds, and FCPXML format. + + Examples: + TimeValue(90, 30) # 3 seconds at 30fps + TimeValue(1, 1) # 1 second + TimeValue.from_timecode("00:01:30:15", fps=30) # 90.5 seconds + """ + numerator: int + denominator: int = 1 + + def __post_init__(self): + if self.denominator == 0: + raise ValueError( + f"TimeValue denominator cannot be zero (got {self.numerator}/0). " + "This would corrupt all downstream time calculations." + ) + # Normalize sign: denominator must always be positive. + # Cross-multiplication in __lt__/__eq__ assumes positive denominators; + # __hash__ assumes canonical form. Without this, TimeValue(1, -2) + # compares/hashes incorrectly against TimeValue(-1, 2). + if self.denominator < 0: + # Use object.__setattr__ because dataclass may be frozen-like + object.__setattr__(self, 'numerator', -self.numerator) + object.__setattr__(self, 'denominator', -self.denominator) + + @classmethod + def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue': + """ + Create TimeValue from various string formats. + + Supported formats: + - "HH:MM:SS:FF" - Standard timecode + - "HH:MM:SS;FF" - Drop-frame timecode + - "30s" - Seconds + - "90/30s" - FCPXML rational format + - "15f" - Frames + """ + if not tc: + return cls(0, 1) + + tc = str(tc).strip() + + # FCPXML format: "90/30s" or "30s" + if tc.endswith('s'): + tc_val = tc[:-1] + if '/' in tc_val: + parts = tc_val.split('/', 1) + num, denom = int(parts[0]), int(parts[1]) + if denom == 0: + raise ValueError(f"Zero denominator in timecode: {tc}") + return cls(num, denom) + else: + seconds = float(tc_val) + frames = int(round(seconds * fps)) + # int(fps) truncates NTSC rates (23.976/29.97/59.94fps) to + # their nominal integer, mismatching the numerator (computed + # with the real fps) against the denominator — e.g. at + # 23.976fps this silently produced values ~1.04x too large. + # Reconstruct the exact rational fps (24000/1001, etc.) from + # the float instead, so numerator and denominator agree. + fps_frac = Fraction(fps).limit_denominator(100_000) + return cls(frames * fps_frac.denominator, fps_frac.numerator) + + # Frame format: "15f" + if tc.endswith('f'): + frames = int(tc[:-1]) + return cls(frames, int(fps)) + + # Timecode format: "HH:MM:SS:FF" or "HH:MM:SS;FF" + if ':' in tc or ';' in tc: + parts = tc.replace(';', ':').split(':') + if len(parts) == 4: + h, m, s, f = map(int, parts) + total_frames = int((h * 3600 + m * 60 + s) * fps + f) + return cls(total_frames, int(fps)) + elif len(parts) == 3: + h, m, s = map(int, parts) + total_frames = int((h * 3600 + m * 60 + s) * fps) + return cls(total_frames, int(fps)) + + # Try as plain number (seconds) + try: + seconds = float(tc) + frames = int(round(seconds * fps)) + return cls(frames, int(fps)) + except ValueError: + raise ValueError(f"Invalid timecode format: {tc}") + + @classmethod + def from_seconds(cls, seconds: float, fps: float = 30.0) -> 'TimeValue': + """Create TimeValue from decimal seconds.""" + frames = int(round(seconds * fps)) + return cls(frames, int(fps)) + + @classmethod + def zero(cls) -> 'TimeValue': + """Return zero time value.""" + return cls(0, 1) + + def to_fcpxml(self) -> str: + """Convert to FCPXML time string (e.g., "90/30s"). + + Only simplifies when the denominator reduces to 1 (whole seconds) + or stays a standard FCPXML timebase. Avoids producing denominators + like 3, 7, etc. that FCP's DTD validator may reject. + """ + simplified = self.simplify() + if simplified.denominator == 1: + return f"{simplified.numerator}s" + # Keep original denominator if simplification produces a non-standard + # denominator (not a multiple of common timebases: 24, 30, 25, 2400) + if simplified.denominator in _FCPXML_STANDARD_TIMEBASES: + return f"{simplified.numerator}/{simplified.denominator}s" + # Fall back to unsimplified form + return f"{self.numerator}/{self.denominator}s" + + def to_seconds(self) -> float: + """Convert to decimal seconds.""" + return self.numerator / self.denominator + + def to_timecode(self, fps: float = 30.0) -> str: + """Convert to HH:MM:SS:FF timecode string.""" + total_frames = int(round(self.to_seconds() * fps)) + total_secs, frames = divmod(total_frames, int(fps)) + total_mins, secs = divmod(total_secs, 60) + hours, mins = divmod(total_mins, 60) + return f"{hours:02d}:{mins:02d}:{secs:02d}:{frames:02d}" + + def to_frames(self, fps: float = 30.0) -> int: + """Convert to frame count.""" + return int(round(self.to_seconds() * fps)) + + def simplify(self) -> 'TimeValue': + """Reduce fraction to simplest form.""" + if self.numerator == 0: + return TimeValue(0, 1) + divisor = gcd(abs(self.numerator), abs(self.denominator)) + return TimeValue( + self.numerator // divisor, + self.denominator // divisor + ) + + @staticmethod + def _lcm_denom(d1: int, d2: int) -> int: + """LCM of two denominators for cross-timebase arithmetic.""" + return d1 // gcd(d1, d2) * d2 + + def _binop(self, other: 'TimeValue', op: Callable[[int, int], int]) -> 'TimeValue': + """Shared logic for add/sub: same-denom fast path, then LCM alignment.""" + if self.denominator == other.denominator: + return TimeValue(op(self.numerator, other.numerator), self.denominator) + lcd = TimeValue._lcm_denom(self.denominator, other.denominator) + return TimeValue( + op( + self.numerator * (lcd // self.denominator), + other.numerator * (lcd // other.denominator), + ), + lcd, + ) + + def __add__(self, other: 'TimeValue') -> 'TimeValue': + return self._binop(other, operator.add) + + def __sub__(self, other: 'TimeValue') -> 'TimeValue': + return self._binop(other, operator.sub) + + def __mul__(self, scalar: float) -> 'TimeValue': + new_num = round(self.numerator * scalar) + return TimeValue(new_num, self.denominator) + + def __truediv__(self, scalar: float) -> 'TimeValue': + if scalar == 0: + raise ZeroDivisionError("Cannot divide TimeValue by zero") + new_denom = round(self.denominator * scalar) + if new_denom == 0: + raise ZeroDivisionError( + f"Division by {scalar} rounds denominator {self.denominator} to zero" + ) + return TimeValue(self.numerator, new_denom) + + def __lt__(self, other: 'TimeValue') -> bool: + # Cross-multiply to compare without float conversion: + # a/b < c/d ↔ a*d < c*b (denominators are always positive) + return self.numerator * other.denominator < other.numerator * self.denominator + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TimeValue): + return False + # Cross-multiply for exact integer comparison + return self.numerator * other.denominator == other.numerator * self.denominator + + def __hash__(self) -> int: + # Delegate to simplify() — single source of truth for canonical form. + # __post_init__ guarantees denominator > 0, so no zero guard needed. + s = self.simplify() + return hash((s.numerator, s.denominator)) + + def snap_to_frame(self, fps: float) -> 'TimeValue': + """Round this time value to the nearest frame boundary at the given fps. + + Uses the 2400-tick timebase (LCM of common frame rates) so results + always land on clean frame boundaries. + + Args: + fps: Frame rate to snap to (e.g. 24, 30, 60) + + Returns: + New TimeValue snapped to the nearest frame in 2400-tick timebase. + """ + fps_int = int(fps) + if fps_int <= 0: + raise ValueError(f"fps must be positive, got {fps}") + ticks_per_frame = 2400 // fps_int + total_ticks = round(self.to_seconds() * 2400) + snapped_ticks = round(total_ticks / ticks_per_frame) * ticks_per_frame + return TimeValue(snapped_ticks, 2400) + + def is_standard_timebase(self) -> bool: + """Check if this TimeValue's denominator is an FCP-accepted timebase.""" + simplified = self.simplify() + return simplified.denominator in _FCPXML_STANDARD_TIMEBASES + + def __repr__(self) -> str: + return f"TimeValue({self.numerator}/{self.denominator}s = {self.to_seconds():.3f}s)" + +@dataclass +class Timecode: + """ + Represents a timecode value. + + Note: This class exists for backwards compatibility with the parser. + New code should prefer TimeValue for rational time math. + """ + frames: int + frame_rate: float = 24.0 + drop_frame: bool = False + + @property + def seconds(self) -> float: + return self.frames / self.frame_rate + + @property + def total_frames(self) -> int: + return self.frames + + def to_smpte(self) -> str: + """Convert to SMPTE timecode string (HH:MM:SS:FF).""" + total_seconds = int(self.seconds) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + secs = total_seconds % 60 + frames = int((self.seconds - total_seconds) * self.frame_rate) + separator = ";" if self.drop_frame else ":" + return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{frames:02d}" + + @classmethod + def from_rational(cls, rational_str: str, frame_rate: float = 24.0) -> "Timecode": + """Parse FCPXML rational time format (e.g., '3600/24s').""" + if not rational_str: + return cls(frames=0, frame_rate=frame_rate) + if rational_str.endswith('s'): + rational_str = rational_str[:-1] + if '/' in rational_str: + num, denom = rational_str.split('/') + seconds = int(num) / int(denom) + else: + seconds = float(rational_str) + frames = int(seconds * frame_rate) + return cls(frames=frames, frame_rate=frame_rate) + + def to_rational(self) -> str: + """Convert to FCPXML rational format.""" + return f"{self.frames}/{int(self.frame_rate)}s" + + def to_time_value(self) -> TimeValue: + """Convert to TimeValue for rational math.""" + return TimeValue(self.frames, int(self.frame_rate)) diff --git a/code/fcpxml/writer/adjustment.py b/code/fcpxml/writer/adjustment.py new file mode 100644 index 0000000..9a4cf32 --- /dev/null +++ b/code/fcpxml/writer/adjustment.py @@ -0,0 +1,140 @@ +"""Clip de ajuste (adjustment layer) — criação do elemento FCPXML. + +No Final Cut, uma "camada de ajuste" é um ``<clip>`` que carrega filtros +(``filter-video`` / ``filter-audio``) diretamente como filhos — o DTD do +FCPXML 1.13 não define nenhum elemento ``<adjustment>`` como wrapper (ver +``<!ELEMENT clip>`` em ``FCPXMLv1_13.dtd``: ``filter-video``/``filter-audio`` +vêm depois de ``audio-channel-source*`` e antes de ``metadata?``, sem +elemento intermediário). Tudo que está abaixo do clip na timeline herda +esses filtros — é como se o efeito fosse aplicado a uma faixa inteira de +uma vez. + +Esta classe monta esse elemento a partir de dados de alto nível (duração + +lista de ``EfeitoAjuste``), cuidando de criar os recursos ``<effect>`` +correspondentes na seção ``<resources>`` e de referenciá-los pelos filtros. +""" + +import xml.etree.ElementTree as ET +from typing import Callable, List, Optional + +from ..models.timeline import EfeitoAjuste +from ..models.timing import TimeValue + + +def _para_racional(tempo) -> str: + """Aceita ``TimeValue`` ou uma string FCPXML já formatada ("90/30s").""" + if isinstance(tempo, TimeValue): + return tempo.to_fcpxml() + if tempo is None: + return "0/1s" + return str(tempo) + + +def _id_recurso_unico(resources: ET.Element, prefixo: str = "r_ajuste") -> str: + """Gera um ``id`` de recurso ainda ausente em ``resources``.""" + existentes = {r.get("id") for r in resources.findall("*") if r.get("id")} + contador = 1 + while f"{prefixo}_{contador}" in existentes: + contador += 1 + return f"{prefixo}_{contador}" + + +class ClipDeAjuste: + """Cria um clip de ajuste (adjustment layer) pronto para a spine. + + Exemplo:: + + from fcpxml.models.timing import TimeValue + from fcpxml.models.timeline import EfeitoAjuste, ParametroEfeito + from fcpxml.writer.adjustment import ClipDeAjuste + + efeito = EfeitoAjuste( + nome="Color Curves", uid="...UUID...", tipo="video", + parametros=[ParametroEfeito(nome="Amount", valor="0.5", + chave=".../9999")], + ) + clip = ClipDeAjuste( + nome="Ajuste de cor", + duracao=TimeValue(300, 30), + efeitos=[efeito], + ).criar(resources) + spine.append(clip) + """ + + def __init__( + self, + nome: str, + duracao, + efeitos: List[EfeitoAjuste], + offset=None, + formato_tc: str = "NDF", + ): + self.nome = nome + self.duracao = duracao + self.efeitos = efeitos + self.offset = offset + self.formato_tc = formato_tc + + def criar( + self, + resources: ET.Element, + proximo_id: Optional[Callable[[], str]] = None, + ) -> ET.Element: + """Monta o ``<clip>`` de ajuste e seus recursos ``<effect>``. + + ``resources`` é a seção ``<resources>`` do documento (onde os + ``<effect>`` são registrados). ``proximo_id`` é um gerador opcional + de ids de recurso; sem ele, usa um id único baseado em ``resources``. + """ + def gerar_id() -> str: + if proximo_id: + return proximo_id() + return _id_recurso_unico(resources) + + filtros: List[ET.Element] = [] + for efeito in self.efeitos: + efeito_id = self._garantir_recurso(resources, efeito, gerar_id) + filtros.append(self._montar_filtro(efeito, efeito_id)) + + clip = ET.Element( + "clip", + name=self.nome, + duration=_para_racional(self.duracao), + tcFormat=self.formato_tc, + ) + if self.offset is not None: + clip.set("offset", _para_racional(self.offset)) + + # O DTD exige filter-video* antes de filter-audio* como filhos + # diretos do clip (sem wrapper <adjustment>). + for filtro in sorted(filtros, key=lambda f: f.tag != "filter-video"): + clip.append(filtro) + return clip + + def _garantir_recurso( + self, resources: ET.Element, efeito: EfeitoAjuste, gerar_id: Callable[[], str] + ) -> str: + """Devolve o ``id`` do ``<effect>`` de *efeito*, criando-o se ausente.""" + for existente in resources.findall("effect"): + if existente.get("uid") == efeito.uid: + return existente.get("id") + efeito_id = gerar_id() + recurso = ET.SubElement(resources, "effect") + recurso.set("id", efeito_id) + recurso.set("name", efeito.nome) + recurso.set("uid", efeito.uid) + return efeito_id + + def _montar_filtro(self, efeito: EfeitoAjuste, efeito_id: str) -> ET.Element: + """Monta o ``<filter-video>``/``<filter-audio>`` de um efeito.""" + tag = "filter-video" if efeito.tipo == "video" else "filter-audio" + filtro = ET.Element(tag, ref=efeito_id, name=efeito.nome) + for parametro in efeito.parametros: + param = ET.SubElement(filtro, "param") + param.set("name", parametro.nome) + if parametro.chave: + param.set("key", parametro.chave) + param.set("value", parametro.valor) + if parametro.metadado: + param.set("metadata", parametro.metadado) + return filtro diff --git a/code/pyproject.toml b/code/pyproject.toml index 71b2ebe..93148c5 100755 --- a/code/pyproject.toml +++ b/code/pyproject.toml @@ -76,7 +76,7 @@ target-version = ['py310'] [tool.ruff] line-length = 100 -exclude = ["docs/", "WHISPERX/"] +exclude = ["docs/"] [tool.ruff.lint] select = ["E", "F", "I", "N", "W"] diff --git a/code/tests/test_writer_adjustment.py b/code/tests/test_writer_adjustment.py new file mode 100644 index 0000000..1b032e7 --- /dev/null +++ b/code/tests/test_writer_adjustment.py @@ -0,0 +1,60 @@ +"""ClipDeAjuste deve gerar filtros como filhos diretos do <clip>. + +O DTD 1.13 não define nenhum elemento <adjustment> — filter-video/ +filter-audio vêm direto no <clip>, depois de audio-channel-source* e antes +de metadata?. Ver Engine/docs/09_MANUTENCAO.md §2.6 e 05_EXPERIENCIAS.md. +""" + +import xml.etree.ElementTree as ET + +from fcpxml.models.timeline import EfeitoAjuste, ParametroEfeito +from fcpxml.models.timing import TimeValue +from fcpxml.writer.adjustment import ClipDeAjuste + + +def test_criar_nao_usa_wrapper_adjustment(): + resources = ET.Element("resources") + efeito = EfeitoAjuste( + nome="Color Curves", + uid="FFFF0000-0000-0000-0000-000000000000", + tipo="video", + parametros=[ParametroEfeito(nome="Amount", valor="0.5", chave=".../9999")], + ) + clip = ClipDeAjuste( + nome="Ajuste de cor", duracao=TimeValue(300, 30), efeitos=[efeito] + ).criar(resources) + + assert clip.find("adjustment") is None + filtro = clip.find("filter-video") + assert filtro is not None + assert filtro.get("name") == "Color Curves" + assert filtro in list(clip) + + +def test_filter_video_vem_antes_de_filter_audio(): + resources = ET.Element("resources") + efeito_audio = EfeitoAjuste( + nome="Gain", uid="AAAA0000-0000-0000-0000-000000000000", tipo="audio" + ) + efeito_video = EfeitoAjuste( + nome="Blur", uid="BBBB0000-0000-0000-0000-000000000000", tipo="video" + ) + clip = ClipDeAjuste( + nome="Ajuste misto", + duracao=TimeValue(300, 30), + efeitos=[efeito_audio, efeito_video], + ).criar(resources) + + tags = [child.tag for child in clip] + assert tags == ["filter-video", "filter-audio"] + + +def test_criar_registra_recurso_effect_uma_vez_por_uid(): + resources = ET.Element("resources") + efeito = EfeitoAjuste( + nome="Color Curves", uid="FFFF0000-0000-0000-0000-000000000000", tipo="video" + ) + ClipDeAjuste(nome="A", duracao=TimeValue(300, 30), efeitos=[efeito]).criar(resources) + ClipDeAjuste(nome="B", duracao=TimeValue(300, 30), efeitos=[efeito]).criar(resources) + + assert len(resources.findall("effect")) == 1