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:
co-authored by
Claude Sonnet 5
parent
0fdfe33613
commit
d13f643ebc
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user