chore(fase0): higiene do repositório + corrige gitignore que escondia fcpxml/models/

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 <adjustment> inexistente
no DTD 1.13 (filtros agora vão direto no <clip>, 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 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-09-23 08:28:44 -04:00
co-authored by Claude Sonnet 5
parent 0fdfe33613
commit d13f643ebc
19 changed files with 2013 additions and 29 deletions
+6 -2
View File
@@ -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/
+80 -5
View File
@@ -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 {<any of the fields above>}
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, <same shape as dynamic_subtitle_config>}
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": "...", <any style field above>}
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": "...", <name/active/style fields>}
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, <same shape as silence_config>}
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,
+4 -4
View File
@@ -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__)
+8 -5
View File
@@ -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 `<clip>`, 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 |
+161
View File
@@ -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: `<title>` aceita `role` (CDATA), NÃO `videoRole` — este último é DTD-inválido para títulos e quebra a validação | `resolvido` |
| 33 | 2026-09-22 | Legenda comum sobreposta à composição dinâmica em `generate_subtitles_by_emphasis`; regenerar acumulava títulos em vez de substituir | `resolvido` |
| 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`.
+42 -8
View File
@@ -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:
+269
View File
@@ -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.
+8 -3
View File
@@ -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 ""
Submodule code/WHISPERX deleted from c9ed3cc6bd
+120
View File
@@ -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",
]
+183
View File
@@ -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"
+93
View File
@@ -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))
+121
View File
@@ -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)}"
)
+165
View File
@@ -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
+248
View File
@@ -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
+304
View File
@@ -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))
+140
View File
@@ -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
+1 -1
View File
@@ -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"]
+60
View File
@@ -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