chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
# Capability Audit & Dual-Mode Roadmap — June 2026
> Produced 2026-06-11 from a 36-agent research sweep (8 researchers + 28 adversarial
> verifications: 22 confirmed, 6 partially-true, 0 refuted). Every load-bearing claim
> below was independently verified against primary sources — including the FCP 12.2
> binary installed on this machine (sdef dump, bundled DTD diff, Info.plist).
---
## 1. Where the ecosystem moved (Feb → Jun 2026)
While this repo idled after v0.7.0 (last push 2026-05-02), the FCP automation space
restructured:
| Event | Date | Why it matters |
|---|---|---|
| **Final Cut Pro 12.0** shipped (FCPXML **1.14**, Transcript Search, Visual Search, Beat Detection) | 2026-01-28 | We emit/target 1.11. FCP 12.2 (Apr 9) is current. Apple is absorbing low-end AI features natively. |
| **SpliceKit** launched (elliotttate + Chris Hocking/FCP Cafe) | 2026-03-30 | Live in-process FCP control via dylib injection. 221 MCP tools. Took the "most powerful" crown. |
| **CommandPost PR #3514** (MCP server over its WebSocket) opened, then abandoned | 2026-03-19 | Author pivoted to SpliceKit. Maintainer still "intends" native MCP. Unmerged. |
| **dreliq9/fcp-mcp** created — names THIS repo in its README as the baseline it beats | 2026-04-23 | "Where the competition stops at one layer." FCPXML engine + AppleScript live layer + ffprobe. 0 stars, but the positioning is public. |
| **SpliceKit went dormant** (no commits/releases since 2026-04-28; June issues unanswered) | 2026-04-28 → | The "live control" crown is sitting on an unmaintained injection layer. |
**Honest position today:** this repo is the most capable *pure-FCPXML* MCP server,
not "the most powerful AI editing MCP in the FCP ecosystem." SpliceKit owns the raw
power axis. The winnable axis is **safety + portability + media intelligence** —
no binary patching, runs on managed Macs, works without FCP installed, survives
every FCP update. Nobody else credibly occupies that quadrant *and* has format depth.
---
## 2. The control-surface map (verified)
### Official Apple surfaces (FCP 12.2, verified on-machine)
| Surface | Can | Cannot |
|---|---|---|
| **AppleScript dictionary** (`ProEditor.sdef`) | Enumerate open libraries → events → projects/sequences: names, IDs, durations, frame rates (100% read-only, sole command is `get`) | Create, modify, delete, export — anything |
| **Apple-event import** (`open` / odoc) | **Zero-click programmatic import** of `.fcpxml`/`.fcpxmld` with `<import-options>` (library location, copy assets, suppress warnings, base url). Officially documented. | Choose *where* in an open timeline content lands |
| **Workflow Extensions** (ProExtensionHost 1.1) | Floating panel inside FCP; read active sequence + playhead; observe selection/sequence changes; **movePlayhead(to:)** (the only write); drag media/FCPXML into timeline; host can network | Add clips, edit timeline content, modify sequences (explicitly excluded by Apple) |
| **FxPlug 4.3.4** | Render-graph plugins (effects/transitions/titles), timing/keyframe/project info | See or touch edit decisions entirely |
| **URL schemes / CLI / App Intents** | **Nothing — none exist** (verified: no CFBundleURLTypes, no appintents metadata, no headless mode) | — |
**The structural asymmetry: import is fully scriptable; there is NO official
programmatic export.** Reading back the user's current timeline requires a human
`File → Export XML` click — or an unofficial surface. Apple added **zero** new
automation hooks across FCP 11.0 → 12.2 (six releases). Do not bet on Apple opening up.
### Unofficial surfaces
| Route | Capability | Fragility | State |
|---|---|---|---|
| **SpliceKit** (MIT) — dylib-injected re-signed FCP copy, full ObjC runtime (78K classes: Flexo/Ozone/TimelineKit…), JSON-RPC on `127.0.0.1:9876` | Everything: blade, retime, color, effects, import/EXPORT, render, playback, 221 MCP tools | Breaks every FCP update; re-signed binary loses entitlements; DMCA §1201(f) asserted not tested; enterprise Macs will refuse it | **Dormant since 04-28** |
| **CommandPost** (MIT, 554★, v2.0.5 Feb 2026) — AXUIElement + per-version string tables; **built-in WebSocket server, port 27480**, executes any registered action by ID | Deep: timeline, full color suite, every inspector, export dialogs (incl. triggering Export XML via UI), media import. Production-proven (Apple's own WWDC videos graded through it) | Needs maintainer fix after essentially every FCP release; **v2 requires a paid LateNite app installed** (~US$10); v1 free but frozen | Alive |
| Raw AX / Keyboard Maestro / Hammerspoon | Menu clicks, shortcut chains | Brittle, shallow; CommandPost supersedes this for FCP | — |
### What FCPXML itself can never carry (the XML-only ceiling, DTD-verified)
- **Magnetic Mask** — Apple docs: "not included in XML exports." Period.
- **Transcript / visual-analysis / beat-detection data** — lives in the library, not the XML. 1.14's only additions are smart-collection *search* hooks referencing it.
- **Effect definitions** — referenced by uid; must exist on the importing machine.
- **Object-tracker + Cinematic depth data** — round-trips ONLY via `.fcpxmld` bundle sidecars (`dataLocator → locator`). Flat `.fcpxml` silently drops them.
- Render files, optimized media, undo history.
Fully round-trippable: clips/spines/lanes, multicam, sync clips, compounds,
**captions** (real CEA-608/ITT elements — Transcribe-to-Captions output lands here),
markers/keywords/roles, keyframed params, adjust-* intrinsics, filters with params.
**DTD version deltas are small** (1.12: filter nameOverride, optical-flow FRC;
1.13: stereo-3D/spatial, hidden-clip-marker, HFR conform; 1.14: smart-collection
search only — zero new timeline elements). Bumping support is cheap; the ground-truth
DTDs ship inside `/Applications/Final Cut Pro.app/.../Interchange.framework/Resources/`
(FCPXMLv1_0.dtd … FCPXMLv1_14.dtd). Apple's online FCPXML reference still documents
1.10 — the app bundle is the only real spec.
---
## 3. Competitive landscape
| Project | Stars | Approach | Status |
|---|---|---|---|
| samuelgursky/**davinci-resolve-mcp** | 1,217 | Real Resolve Python API; 341 granular tools | Active — shows the ceiling when an NLE has an official API (~20× FCP traction) |
| elliotttate/**SpliceKit** | 81 | Injection (live, everything) | Dormant 6 wks |
| **DareDev256/fcpxml-mcp-server** | 51 | Pure FCPXML | This repo |
| FireRed-**OpenStoryline** | 2,895 | Intention-driven editing agent (FCPXML as output) | Agentic rough-cut wave |
| DozaVisuals/**doza-assist** | 46 | Learns editing style from user's past cuts | Pushed daily |
| elliotttate/finalcutpro-mcp | 7 | JXA/System Events, 99 tools | Superseded by SpliceKit |
| Poechant/final-cut-pro-cli | 2 | FCPXML + .fcpbundle FS + minimal AppleScript | v0.1.0 May 27 |
| dreliq9/**fcp-mcp** | 0 | FCPXML + AppleScript live + ffprobe — "the most capable FCP MCP server" | Names us as the incumbent |
| OTIO fcpx adapter | 7 | — | **Dead** (last commit Jun 2024, no modern FCPXML) — OTIO is not a viable FCPXML path; we are more current than the "standard" |
| Eddie AI (commercial) | — | AI assembles → XML handoff → editor finishes in NLE | Validates our exact workflow pattern at NAB scale |
Python FCPXML competition is near zero (PyPI `fcpxml` is a placeholder; OTIO adapter
stale). The Swift side is livelier (pipeline-neo v2.5.2 validates DTDs 1.5–1.14;
orchetect/swift-fcpxml) — useful as reference implementations, not dependencies.
**CapCut verdict (asked 2026-06-11): lane crowded — don't build it.** ≥8 CapCut MCP
servers exist; VectCutAPI (1,977★) and capcut-mate (1,156★) push daily atop
pyJianYingDraft (3,425★). No official API; CN builds encrypt drafts; ByteDance
enforced its trademark (CapCutAPI → VectCutAPI forced rename). FCPXML is a documented,
stable Apple format — the opposite. Stay here.
---
## 4. Dual-mode architecture (decision)
Three tiers sharing one operation vocabulary. The repo audit found the de facto seam:
`FCPXMLModifier`'s ~30 public methods ARE the operation set; handlers are thin
(`_setup_modifier → method → save → _text_result`). Extract a backend Protocol over
that vocabulary; make `_setup_modifier` backend-selecting. Moderate refactor — not a
rewrite. (Known wart to fix on the way: read path uses frame-quantized `Timecode`
while write path uses rational `TimeValue` — unify on TimeValue.)
```
┌────────────────────────────────────┐
│ MCP tool surface (one schema) │
└────────────┬───────────────────────┘
┌────────────┴───────────────────────┐
│ Operation layer (~30 verbs) │
│ trim/split/retime/marker/reorder… │
└──┬──────────────┬──────────────┬───┘
Tier 1: XML │ Tier 2: Live (official) │ Tier 3: Bridges (optional)
─────────── │ ─────────────────────── │ ──────────────────────────
parser/writer │ • push-import (odoc + │ • SpliceKit JSON-RPC :9876
rough_cut/diff │ <import-options>) │ (if patched FCP running)
export — today │ • AppleScript library │ • CommandPost WS :27480
│ inspection (read) │ (if installed — incl.
│ • watch-folder round- │ triggering Export XML →
│ trip ergonomics │ closes the read-back loop)
│ • later: Workflow Ext │
│ (playhead/selection) │
└──────────────────────────┘
+ Media-intelligence layer feeding all tiers:
whisperX/parakeet · PySceneDetect 0.7/TransNetV2 · Beat This! · Silero VAD
· FastVLM/Qwen3-VL via mlx-vlm · ffmpeg-8 preview compiler
```
Principles:
1. **Never patch the binary.** Tier 3 *detects* SpliceKit/CommandPost if the user
installed them; we ship adapters, not injections. Safety is the brand.
2. **The export asymmetry is handled, not hidden:** watch-folder + one-keystroke
export instructions by default; CommandPost bridge automates the click when present;
SpliceKit bridge reads live state when present. Degrade gracefully.
3. **Media intelligence is the real moat.** Nobody couples FCPXML depth + actual
media analysis + preview-without-FCP. That combination is unique as of June 2026.
---
## 5. Roadmap
### v0.8 — Format currency (defensive, ~days)
- Parse FCPXML 1.12–1.14 (deltas enumerated above); tolerate unknown elements losslessly.
- `.fcpxmld` bundle read/write with **sidecar preservation** (don't destroy
object-tracking/Cinematic data — rare third-party tool that doesn't).
- CI: `xmllint --dtdvalid` against Apple's own bundled DTDs.
- Emit 1.13 by default (1.11 forfeits HFR/spatial attrs).
- Bulk media-relink tool (rewrite `media-rep src` paths — known user pain, trivial here).
- Hygiene: CHANGELOG entry for 0.7.0 missing; README badge says 912 tests / Testing
section says 795; README references nonexistent `fcpxml/README.md` and a phantom env var.
### v0.9 — Live mode v1, official surfaces only (~1-2 weeks)
- `push_to_fcp` tool: write FCPXML + `<import-options>`, fire the Apple event
(~20 lines; zero-click; officially supported).
- `list_open_libraries` / `get_fcp_state`: AppleScript read-only inspection via osascript.
- Watch-folder round-trip: user exports XML to a watched dir; server auto-detects,
diffs against last known state.
- Backend Protocol refactor (operation layer); split `server.py` (3,046 lines) into
`tools/` modules while at it.
### v0.10 — Media intelligence (the moat, ~weeks)
- Transcription: whisperX (word-level <100ms) or FluidAudio/Parakeet CoreML →
transcript-driven editing: cut-by-sentence, chapter markers, caption generation
into real FCPXML caption elements.
- Scene cuts: PySceneDetect 0.7 (has `save-fcp`!) + TransNetV2 for hard content.
- Beats: **Beat This!** (`pip install beat-this`, Apr 2026) — madmom is dead, do not dep.
- Silence: Silero VAD v6 / wrap auto-editor (public domain) rather than reimplement.
- Shot understanding: FastVLM / Qwen3-VL via mlx-vlm for "find the b-roll of X."
- **Preview-without-FCP**: compile timeline → ffmpeg trim/concat/xfade filtergraph,
render low-res proxy. Unfilled gap in the entire ecosystem; closes the verify loop.
### v1.0 — Bridges + positioning
- Optional adapters: SpliceKit `:9876` (live read/edit/export when present),
CommandPost `:27480` (action execution incl. Export XML trigger when present).
- Maintained OTIO bridge (the official adapter is dead — become the de facto one).
- Flagship demo: beat-synced music-video rough cut (350-video director credibility —
nobody else can author that demo).
- MCP Registry / Smithery refresh, FCP Cafe + Discord presence (the integration
debates are happening there; demand is explicit and unserved).
### Explicitly NOT doing
- CapCut MCP (crowded, encrypted format, trademark risk).
- Forking/bundling SpliceKit's injection (legal + fragility + enterprise rejection).
- Betting on Apple opening automation (two major versions, zero new hooks).
- madmom, OTIO-as-engine, raw AX scripting maintained solo.
---
## 6. Risks register
1. **SpliceKit revives** → it subsumes the live axis; our safety/portability framing
must be established before that. (Watch: github.com/elliotttate/SpliceKit commits.)
2. **dreliq9-class clones** — the tool taxonomy was replicated in ~a month by a 0-star
repo. Capability alone isn't the moat; distribution + format depth + media layer are.
3. **Apple absorbs from above** (native transcription/beat/visual search already) —
keep value in batch/programmatic workflows Apple won't ship.
4. **FCPXML coverage shrinks relatively** — Apple keeps new AI data library-side.
Means: the live bridges matter more over time, not less.
5. Round-trip lossiness blame: users will blame the tool for Apple's drops (Magnetic
Mask, sidecars). Document loudly; preserve bundles.
---
*Sources: 36-agent verified sweep 2026-06-11. Researcher outputs archived at
`/tmp/fcp-research/` (session-local). Key primary sources: FCP 12.2 sdef + bundled
DTDs (local), developer.apple.com Professional Video Applications docs,
github.com/elliotttate/SpliceKit, CommandPost PR #3514, Apple release notes 102825.*
+95
View File
@@ -0,0 +1,95 @@
# MCP Ecosystem & Companion Tools
How **FCPXML MCP** fits into the broader Model Context Protocol ecosystem, and which companion servers pair well with video editing workflows.
---
## What Is MCP?
The [Model Context Protocol](https://modelcontextprotocol.io/) is an open standard that lets AI models (like Claude) call tools exposed by local servers. Each MCP server is a specialist — it owns one domain and exposes tools for that domain. Claude Desktop (or any MCP client) can connect to multiple servers simultaneously, composing their capabilities in a single conversation.
```
Claude Desktop
├── fcp-mcp-server → Final Cut Pro timeline operations
├── gitnexus → Codebase knowledge graph + architecture analysis
├── filesystem → General file read/write
└── your-custom-server → Whatever you build
```
This is the key insight: **MCP servers compose**. You don't need one server that does everything. You need focused servers that each do one thing well, and an AI client that orchestrates them.
---
## Companion: GitNexus
**What it does:** GitNexus indexes a codebase into a knowledge graph — files, functions, classes, imports, dependencies — and exposes that graph via CLI, MCP tools, and a browser-based Web UI. It enables deep architectural understanding without manually tracing call chains.
**Why it's relevant (relevance: 85%):**
| Capability | How It Helps FCPXML MCP Development |
|------------|-------------------------------------|
| **Knowledge graph indexing** | Maps all 47 tool handlers, their dependencies on parser/writer/models, and cross-module call chains — useful for onboarding contributors |
| **Architecture visualization** | Web UI renders the `server.py → fcpxml/*.py` dispatch tree visually, showing which handlers touch which modules |
| **Impact analysis** | Before changing `TimeValue` arithmetic in `models.py`, query the graph to see every downstream consumer — prevents regressions |
| **MCP tool exposure** | Runs as a sibling MCP server alongside FCPXML MCP — Claude can query the codebase graph *and* manipulate timelines in the same session |
| **CLI for CI** | Index the repo in CI, query for orphan functions or circular imports as part of the lint pipeline |
**Example workflow — using both servers together:**
```
User: "I want to add a new export format. Show me how the existing
export pipeline works, then generate a template FCPXML."
Claude:
1. [GitNexus] Query knowledge graph for export.py call chain
2. [GitNexus] Show all functions that call writer.write_fcpxml()
3. [FCPXML MCP] Generate a sample timeline via auto_rough_cut
4. [FCPXML MCP] Export it via export_resolve_xml to see the pattern
→ Claude synthesizes the architecture + a working example
```
**Setup (alongside FCPXML MCP):**
```json
{
"mcpServers": {
"fcpxml": {
"command": "uv",
"args": ["--directory", "/path/to/fcp-mcp-server", "run", "server.py"],
"env": { "FCP_PROJECTS_DIR": "/Users/you/Movies" }
},
"gitnexus": {
"command": "gitnexus",
"args": ["mcp", "--repo", "/path/to/fcp-mcp-server"]
}
}
}
```
---
## Other Useful Companion Servers
| Server | Domain | Pairing Use Case |
|--------|--------|-----------------|
| **filesystem** | File read/write | Read raw FCPXML files, write export outputs |
| **memory** | Persistent context | Remember project preferences across sessions |
| **fetch** | HTTP requests | Pull beat analysis JSON from remote APIs |
| **sqlite** | Database queries | Track edit history, QC results over time |
---
## Building Your Own MCP Server
If GitNexus and FCPXML MCP inspire you to build a domain-specific server, the pattern is straightforward:
1. **Pick a domain** — one data format, one API, one workflow
2. **Define tools** — each tool is a function with typed inputs and text outputs
3. **Use the MCP SDK** — `pip install mcp`, subclass `Server`, register handlers
4. **Test locally** — `uv run server.py` starts the server, Claude Desktop connects
See [server.py](../server.py) in this repo for a production example of the dispatch-dict pattern with 47 tools.
---
*Last updated: 2026-02-25*
+152
View File
@@ -0,0 +1,152 @@
# Transcription Model Manager — Design
Este documento propõe um **gerenciador explícito de modelos de transcrição** para o
fcp-mcp-server, inspirado no gerenciador de modelos do app **Hex** (Swift/macOS).
Adapta o conceito para a nossa stack (Python + MCP), mantendo a nossa linguagem e
convenções. O Hex é usado apenas como referência de *design*; nada do código dele é
copiado.
> Estado: **design + esqueleto**. Só o módulo `fcpxml/model_manager.py` estruturado
> existe; handlers MCP e testes ficam para uma fase seguinte.
---
## 1. Problema que resolvemos
Hoje `transcribe()` (`fcpxml/transcribe.py`) baixa o modelo do Hugging Face
**automaticamente e de forma implícita** na primeira transcrição:
```python
model = WhisperModel(model_size, compute_type="int8") # baixa sozinho se faltar
```
Isso tem três lacunas, todas resolvidas pelo Hex e que importamos:
1. **Sem visibilidade** — o usuário não sabe quais modelos estão instalados, quais
tamanhos existem, quanto cada um pesa, nem qual está selecionado.
2. **Sem controle** — não dá para *escolher* o modelo ativo, baixar/remover por um
caminho explícito, nem ver progresso.
3. **Sem catálogo** — a escolha é por uma string solta (`"base"`), sem metadados
(stars de acurácia/velocidade, tamanho em disco, recomendado).
Inspiração do Hex (o que copiamos como conceito):
- **Catálogo curado** (`models.json`) com metadados opinativos, em vez de dropdown gigante.
- **Transição de estado clara**: não-instalado → baixando → instalado → em uso.
- **Fallback seguro**: se o selecionado some do disco, troca para o instalado, nunca apaga a seleção do usuário.
- **Progresso em fases**: download (0–50%) → load (50–100%).
---
## 2. Stack e convenções (nada de novo)
| Item | Decisão |
|---|---|
| Linguagem | Python 3.10+ (padrão do repo) |
| FrameWork | `faster-whisper` (extra `[transcribe]`) — já é o nosso motor |
| Download | `huggingface_hub.snapshot_download` — padrão usado pelo faster-whisper |
| Cache do modelo | `~/.cache/huggingface/hub/models--Systran--faster-whisper-<size>` |
| Validação | `ALLOWED_MODELS` existente em `transcribe.py` (allowlist) continua sendo a fonte de verdade |
| Confiança | nomes de modelo ainda passam por allowlist; nunca via `os.system` |
| Segurança | operações de escrita confinadas ao diretório de cache; sem path traversal |
A seleção do modelo ativo é **persistida em um arquivo de config** (ex.:
`~/.fcp-mcp-server/config.json`) para que as ferramentas de transcrição tenham um
modelo padrão consistente entre execuções.
---
## 3. Catálogo curado — `models.json`
Novo arquivo `fcpxml/models.json`, embutido no pacote (espelha a lista `ALLOWED_MODELS`):
```json
[
{"display_name": "Whisper Tiny", "internal_name": "tiny", "language": "Multilingual", "accuracy": 2, "speed": 5, "storage": "151 MB"},
{"display_name": "Whisper Tiny EN", "internal_name": "tiny.en", "language": "English", "accuracy": 2, "speed": 5, "storage": "151 MB"},
{"display_name": "Whisper Base", "internal_name": "base", "language": "Multilingual", "accuracy": 3, "speed": 4, "storage": "290 MB"},
{"display_name": "Whisper Base EN", "internal_name": "base.en", "language": "English", "accuracy": 3, "speed": 4, "storage": "290 MB"},
{"display_name": "Whisper Small", "internal_name": "small", "language": "Multilingual", "accuracy": 4, "speed": 3, "storage": "968 MB"},
{"display_name": "Whisper Medium", "internal_name": "medium", "language": "Multilingual", "accuracy": 5, "speed": 2, "storage": "3.07 GB"},
{"display_name": "Whisper Large v3", "internal_name": "large-v3", "language": "Multilingual", "accuracy": 5, "speed": 1, "storage": "6.21 GB"},
{"display_name": "Whisper Distil v3", "internal_name": "distil-large-v3", "language": "English", "accuracy": 5, "speed": 4, "storage": "1.6 GB"}
]
```
Tamanhos são os do Hugging Face (Systran/faster-whisper). Estrelas numero de packaging.
O catálogo é **fonte de verdade** para os handlers novos: `internal_name` é o único
valor aceito, e deve estar também em `ALLOWED_MODELS`.
---
## 4. Módulo `fcpxml/model_manager.py` — esqueleto
API pública (funções puras + I/O confinado), no estilo dos módulos `transcribe.py` /
`media_intel.py` (lazy import, degrade gracioso, `logger`):
```python
"""model_manager — explicit transcription-model management (Hex-inspired)."""
# ---------- catálogo ----------
def load_catalog() -> list[dict] # lê models.json embutido
def get_catalog_model(internal_name) # busca por internal_name (Allowlist)
# ---------- cache / disco ----------
def model_cache_dir(model_size) -> Path # ~/.cache/huggingface/hub/models--Systran--faster-whisper-<size>
def is_model_downloaded(model_size) -> bool # dir existe e não está vazio/vacilando
def list_installed_models() -> list[str] # varre o cache, filtra por ALLOWED_MODELS
# ---------- download / remoção ----------
def download_model(model_size, *, progress_cb=None) # snapshot_download + callback
def delete_model(model_size) # remove diretório do cache
# ---------- seleção persistida ----------
def load_selected_model() -> str # lê config (~/.fcp-mcp-server/config.json)
def save_selected_model(model_size) # grava; valida via ALLOWED_MODELS
```
Contratos de erro (iguais ao resto do código):
- `download_model` levanta `ValueError` se `model_size` não estiver em `ALLOWED_MODELS`.
- `is_model_downloaded` / `list_installed_models` retornam dados, nunca levantam em I/O — degradam gracioso.
- sem `huggingface_hub`? `download_model` retorna `None`/mensagem de instalação, como `transcribe` faz.
---
## 5. Handlers MCP futuros (fase seguinte — fora deste escopo)
Mapeamentos que queremos quando implementarmos os handlers:
| Hex | Handler MCP proposto |
|---|---|
| `getAvailableModels` + `getRecommendedModels` | `list_transcription_models` — cataloga todos, marca instalado/recomendado |
| `isModelDownloaded` | integrado ao `list_transcription_models` |
| `downloadModel` | `download_transcription_model` — baixa + reporta progresso |
| `selectModel` | `select_transcription_model` — grava a seleção persistida |
| `deleteModel` | `delete_transcription_model` — remove do cache |
| `transcribe` | já existe; passará a ler a seleção persistida como default do `model` |
> Nota de progresso em MCP: protocolo é request/response, sem streaming.
> O download ficará síncrono com callback interno de log (registra fases de
> `download→load`), como os caminhos ffmpeg/whisper já fazem hoje — sem design de
> streaming novo.
---
## 6. Onde escrevemos (referência: Hex)
- Catalog → `fcpxml/models.json` (análogo a `Hex/Resources/Data/models.json`).
- Lógica → `fcpxml/model_manager.py` (análogo a `Hex/Clients/TranscriptionClient.swift`).
- Decisão de estado/fallback → copiada como *regras* (não código) em `load_selected_model`:
- se a seleção não está no cache e outro modelo está, retorna o instalado (fallback),
- nunca apaga a seleção do usuário por um scan falso-negativo.
- UI (model download view do Hex) → **não se aplica**: somos um servidor MCP sem interface
gráfica; a "UI" é o texto estruturado que os handlers retornam (`_markdown_table`).
---
## 7. Próximos passos (fase seguinte)
1. Criar `fcpxml/models.json` com os 8 modelos curados.
2. Implementar o corpo das funções do esqueleto.
3. Adicionar os 4 handlers MCP + registrar em `TOOL_HANDLERS` e `TOOLS`.
4. Fazer `transcribe()` usar a seleção persistida como default.
5. Testes em `tests/test_model_manager.py` (tempdir para cache, allowlist, fallback).
+184
View File
@@ -0,0 +1,184 @@
# Workflow Recipes
Real-world tool chains for common post-production tasks. Each recipe shows what to ask Claude and which tools fire under the hood.
---
## Delivery QC Pipeline
**Scenario:** Final timeline needs quality sign-off before client delivery.
```
"Run a full QC check on /path/to/project.fcpxml"
```
**Tool chain:** `analyze_timeline` → `detect_flash_frames` → `detect_gaps` → `detect_duplicates` → `validate_timeline`
The `validate_timeline` tool returns a 0–100% health score. Anything below 80% flags specific issues. Follow up with:
```
"Fix all flash frames by extending previous clips, then fill any gaps"
```
**Tool chain:** `fix_flash_frames` → `fill_gaps`
Both tools generate `_modified` output files — your original XML is never touched.
---
## YouTube Chapter Export
**Scenario:** 45-minute podcast edit with chapter markers needs YouTube-formatted timestamps.
```
"List all markers in my timeline formatted for YouTube chapters"
```
**Tool chain:** `list_markers` (with format filter)
If chapters don't exist yet but you have a transcript:
```
"Import these YouTube chapters as markers: 0:00 Intro, 2:15 Topic One, 14:30 Deep Dive..."
```
**Tool chain:** `import_transcript_markers` → `list_markers`
For SRT/VTT subtitle files from auto-transcription services:
```
"Import chapters from /path/to/captions.srt as markers"
```
**Tool chain:** `import_srt_markers`
---
## Beat-Synced Music Video Assembly
**Scenario:** 200 B-roll clips tagged by keyword, one music track with beat analysis.
**Step 1 — Import beats:**
```
"Import beat markers from /path/to/beats.json"
```
**Step 2 — Generate assembly:**
```
"Create a rough cut using clips tagged 'performance' and 'broll', target 3:30 duration, accelerating pacing"
```
**Tool chain:** `import_beat_markers` → `auto_rough_cut` or `generate_montage`
**Step 3 — Snap to beats:**
```
"Snap all cuts to the nearest beat marker"
```
**Tool chain:** `snap_to_beats`
The beat JSON format expects an array of timestamps in seconds:
```json
{ "beats": [0.0, 0.48, 0.96, 1.44, 1.92] }
```
---
## Cross-NLE Handoff
**Scenario:** Timeline edited in FCP needs to go to a colorist on DaVinci Resolve and an audio mixer on Pro Tools (via Premiere).
```
"Export my timeline for DaVinci Resolve and also as FCP7 XML for Premiere"
```
**Tool chain:** `export_resolve_xml` + `export_fcp7_xml`
**What changes in each export:**
- **Resolve (FCPXML v1.9):** Compound clips flattened, unsupported attributes stripped, simpler element tree
- **FCP7 XMEML:** Spine-based model converted to track-based model — primary storyline becomes Track 0, connected clip lanes map to higher tracks
---
## Documentary A/B Roll
**Scenario:** Interview footage (A-roll) with cutaway B-roll needs structured assembly.
```
"Generate an A/B roll edit — 'interview' clips as A-roll, 'broll' clips as B-roll, 8-minute target"
```
**Tool chain:** `generate_ab_roll`
The generator alternates between A-roll and B-roll clips, placing B-roll on connected lanes (above the primary storyline). This matches the standard documentary editing pattern where interview audio runs continuously and visuals cut between talking head and supplementary footage.
---
## Social Media Reformat
**Scenario:** 16:9 master edit needs vertical versions for Reels/TikTok and square for feed posts.
```
"Reformat my timeline to 9:16 for Instagram Reels"
```
**Tool chain:** `reformat_timeline` (preset: `9:16`)
Available presets: `9:16` (vertical), `1:1` (square), `4:5` (portrait feed), `4:3` (classic), `16:9` (widescreen). Custom resolutions also supported.
> **Note:** This changes the project format metadata — it doesn't re-frame or crop footage. You'll still need to adjust framing in FCP after import.
---
## Timeline Version Comparison
**Scenario:** Director sent revision notes, you made changes, now need to document what changed.
```
"Compare /path/to/edit_v2.fcpxml with /path/to/edit_v1.fcpxml"
```
**Tool chain:** `diff_timelines`
Returns structured diff: clips added, removed, moved, or trimmed. Marker changes, transition changes, and format changes are all tracked. Useful for revision logs and client communication.
---
## Silence Cleanup
**Scenario:** Long-form interview has dead air that needs trimming.
**Step 1 — Detect:**
```
"Find silence candidates in my timeline"
```
**Tool chain:** `detect_silence_candidates`
Uses heuristics: gaps, ultra-short clips, naming patterns (clips named "silence", "room tone"), and duration anomalies. Results include confidence scores.
**Step 2 — Review and remove:**
```
"Remove all silence candidates with high confidence"
```
**Tool chain:** `remove_silence_candidates` (mode: delete or mark)
Mark mode adds markers instead of deleting — safer for first pass.
---
## Composing Tools in AI Agent Workflows
Each tool in this MCP server follows the same pattern: read FCPXML → process → write modified FCPXML. This makes them composable — the output of one tool is valid input for the next.
When used through Claude Desktop or any MCP client, you describe intent in natural language and the agent selects and chains tools automatically. The 5 built-in MCP prompts (`qc-check`, `youtube-chapters`, `rough-cut`, `timeline-summary`, `cleanup`) are pre-built chains for the most common workflows.
For custom workflows, describe the full pipeline in one message:
```
"Analyze my timeline, fix any flash frames, add chapter markers at every 5-minute
interval, then export for DaVinci Resolve"
```
The agent will chain: `analyze_timeline` → `fix_flash_frames` → `batch_add_markers` → `export_resolve_xml`, passing the modified file through each step.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+613
View File
@@ -0,0 +1,613 @@
# FCP MCP Server - Tool Schemas
Complete MCP tool definitions for all editing capabilities.
---
## Phase 1: Core Editing
### `add_marker`
```json
{
"name": "add_marker",
"description": "Add a marker to the timeline at a specific timecode. Supports chapter markers (for YouTube), to-do markers, and standard markers with custom colors.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"timecode": {
"type": "string",
"description": "Timecode in format HH:MM:SS:FF or seconds (e.g., '00:01:30:00' or '90.5')"
},
"name": {
"type": "string",
"description": "Marker name/label"
},
"marker_type": {
"type": "string",
"enum": ["standard", "chapter", "todo", "completed"],
"default": "standard",
"description": "Type of marker to create"
},
"color": {
"type": "string",
"enum": ["blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple"],
"default": "blue",
"description": "Marker color"
},
"note": {
"type": "string",
"description": "Optional note/description for the marker"
}
},
"required": ["project_path", "timecode", "name"]
}
}
```
---
### `trim_clip`
```json
{
"name": "trim_clip",
"description": "Adjust the in-point and/or out-point of a clip in the timeline. Can trim by timecode or by duration delta.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip (from list_clips)"
},
"trim_start": {
"type": "string",
"description": "New in-point timecode, or delta like '+00:00:01:00' or '-15f' (frames)"
},
"trim_end": {
"type": "string",
"description": "New out-point timecode, or delta like '+00:00:02:00' or '-30f'"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, subsequent clips shift to fill/accommodate the change"
}
},
"required": ["project_path", "clip_id"]
}
}
```
---
### `reorder_clips`
```json
{
"name": "reorder_clips",
"description": "Move one or more clips to a new position in the timeline. Supports moving single clips or batch reordering.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to move (maintains relative order)"
},
"target_position": {
"type": "string",
"description": "Where to insert: 'start', 'end', timecode, or 'after:clip_id' / 'before:clip_id'"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, other clips shift to accommodate"
}
},
"required": ["project_path", "clip_ids", "target_position"]
}
}
```
---
### `add_transition`
```json
{
"name": "add_transition",
"description": "Apply a transition between two clips or at clip boundaries.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Clip ID to add transition to"
},
"position": {
"type": "string",
"enum": ["start", "end", "both"],
"default": "end",
"description": "Where to apply the transition"
},
"transition_type": {
"type": "string",
"enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "dip-to-color", "wipe", "slide"],
"default": "cross-dissolve",
"description": "Type of transition"
},
"duration": {
"type": "string",
"default": "00:00:00:15",
"description": "Transition duration in timecode or frames (e.g., '15f')"
}
},
"required": ["project_path", "clip_id"]
}
}
```
---
## Phase 2: Speed & Precision
### `change_speed`
```json
{
"name": "change_speed",
"description": "Modify the playback speed of a clip. Supports constant speed changes and speed ramps.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip"
},
"speed": {
"type": "number",
"description": "Speed multiplier (0.5 = 50% slow-mo, 2.0 = 2x fast)"
},
"ramp": {
"type": "object",
"description": "Optional speed ramp configuration",
"properties": {
"start_speed": {"type": "number"},
"end_speed": {"type": "number"},
"curve": {
"type": "string",
"enum": ["linear", "ease-in", "ease-out", "ease-in-out"]
}
}
},
"preserve_pitch": {
"type": "boolean",
"default": true,
"description": "Maintain audio pitch when changing speed"
},
"frame_blending": {
"type": "string",
"enum": ["none", "frame-blending", "optical-flow"],
"default": "optical-flow",
"description": "Frame interpolation method for slow-motion"
}
},
"required": ["project_path", "clip_id", "speed"]
}
}
```
---
### `split_clip`
```json
{
"name": "split_clip",
"description": "Split a clip at one or more timecodes, creating separate clips.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip to split"
},
"split_points": {
"type": "array",
"items": {"type": "string"},
"description": "List of timecodes within the clip to split at"
},
"split_type": {
"type": "string",
"enum": ["blade", "blade-all"],
"default": "blade",
"description": "'blade' splits only this clip, 'blade-all' splits all tracks at this point"
}
},
"required": ["project_path", "clip_id", "split_points"]
}
}
```
---
### `delete_clip`
```json
{
"name": "delete_clip",
"description": "Remove a clip from the timeline. Supports ripple delete or leaving a gap.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to delete"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, subsequent clips shift to fill the gap"
}
},
"required": ["project_path", "clip_ids"]
}
}
```
---
## Phase 3: Batch & AI-Powered
### `select_by_keyword`
```json
{
"name": "select_by_keyword",
"description": "Find and return clips matching specific keywords, ratings, or metadata criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"keywords": {
"type": "array",
"items": {"type": "string"},
"description": "Keywords to match (OR logic by default)"
},
"match_mode": {
"type": "string",
"enum": ["any", "all", "none"],
"default": "any",
"description": "'any' = OR, 'all' = AND, 'none' = exclude these keywords"
},
"rating": {
"type": "object",
"properties": {
"min": {"type": "integer", "minimum": 1, "maximum": 5},
"max": {"type": "integer", "minimum": 1, "maximum": 5}
},
"description": "Filter by star rating range"
},
"favorites_only": {
"type": "boolean",
"default": false,
"description": "Only return favorited clips"
},
"exclude_rejected": {
"type": "boolean",
"default": true,
"description": "Exclude clips marked as rejected"
}
},
"required": ["project_path", "keywords"]
}
}
```
---
### `batch_trim`
```json
{
"name": "batch_trim",
"description": "Apply trim operations to multiple clips at once based on criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to trim (or use selection_criteria)"
},
"selection_criteria": {
"type": "object",
"description": "Alternative to clip_ids: select clips dynamically",
"properties": {
"keywords": {"type": "array", "items": {"type": "string"}},
"min_duration": {"type": "string"},
"max_duration": {"type": "string"}
}
},
"trim_operation": {
"type": "object",
"properties": {
"trim_start_by": {"type": "string", "description": "Amount to trim from start"},
"trim_end_by": {"type": "string", "description": "Amount to trim from end"},
"set_duration": {"type": "string", "description": "Set all clips to this exact duration"}
}
},
"ripple": {
"type": "boolean",
"default": true
}
},
"required": ["project_path", "trim_operation"]
}
}
```
---
### `auto_rough_cut`
```json
{
"name": "auto_rough_cut",
"description": "AI-powered rough cut generation. Analyzes source clips by keywords and assembles a timeline based on target duration and pacing preferences.",
"inputSchema": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Path to FCPXML with source clips (library or event export)"
},
"output_path": {
"type": "string",
"description": "Path to write the generated rough cut FCPXML"
},
"target_duration": {
"type": "string",
"description": "Desired final duration (e.g., '00:03:30:00' for 3.5 minutes)"
},
"structure": {
"type": "array",
"description": "Ordered list of segments with keywords and duration targets",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Segment name (e.g., 'Intro', 'Verse 1')"},
"keywords": {"type": "array", "items": {"type": "string"}},
"duration": {"type": "string", "description": "Target duration for this segment"},
"priority": {"type": "string", "enum": ["favorites", "longest", "shortest", "random"]}
}
}
},
"pacing": {
"type": "string",
"enum": ["slow", "medium", "fast", "dynamic"],
"default": "medium",
"description": "Overall pacing feel - affects average cut length"
},
"pacing_config": {
"type": "object",
"description": "Advanced pacing controls",
"properties": {
"min_clip_duration": {"type": "string", "default": "00:00:01:00"},
"max_clip_duration": {"type": "string", "default": "00:00:08:00"},
"avg_clip_duration": {"type": "string"},
"vary_pacing": {"type": "boolean", "default": true, "description": "Vary cut lengths for organic feel"}
}
},
"transitions": {
"type": "object",
"properties": {
"between_segments": {"type": "string", "enum": ["none", "cross-dissolve", "fade-to-black"], "default": "cross-dissolve"},
"within_segments": {"type": "string", "enum": ["none", "cut", "cross-dissolve"], "default": "cut"}
}
},
"include_audio": {
"type": "boolean",
"default": true,
"description": "Include audio from clips"
},
"music_track": {
"type": "string",
"description": "Optional path to music file to lay under the rough cut"
}
},
"required": ["source_path", "output_path", "target_duration"]
}
}
```
---
## Utility Tools
### `batch_add_markers`
```json
{
"name": "batch_add_markers",
"description": "Add multiple markers at once from a list or based on detection criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"markers": {
"type": "array",
"description": "List of markers to add",
"items": {
"type": "object",
"properties": {
"timecode": {"type": "string"},
"name": {"type": "string"},
"marker_type": {"type": "string"},
"color": {"type": "string"}
},
"required": ["timecode", "name"]
}
},
"auto_detect": {
"type": "object",
"description": "Auto-generate markers based on detection",
"properties": {
"at_cuts": {"type": "boolean", "description": "Add marker at every cut point"},
"at_keywords": {"type": "array", "items": {"type": "string"}, "description": "Add marker where keyword appears"},
"at_intervals": {"type": "string", "description": "Add markers at regular intervals (e.g., '00:00:30:00')"}
}
}
},
"required": ["project_path"]
}
}
```
---
### `apply_effect`
```json
{
"name": "apply_effect",
"description": "Apply a video or audio effect to one or more clips.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Clips to apply effect to"
},
"effect_type": {
"type": "string",
"enum": ["color-correction", "lut", "blur", "sharpen", "stabilize", "denoise", "transform"],
"description": "Type of effect to apply"
},
"parameters": {
"type": "object",
"description": "Effect-specific parameters (varies by effect_type)"
}
},
"required": ["project_path", "clip_ids", "effect_type"]
}
}
```
---
### `generate_proxies_list`
```json
{
"name": "generate_proxies_list",
"description": "Analyze project and generate a list of source files that need proxy generation.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"threshold_resolution": {
"type": "string",
"default": "1920x1080",
"description": "Flag sources above this resolution"
},
"output_format": {
"type": "string",
"enum": ["json", "csv", "shell-script"],
"default": "json",
"description": "Output format for the list"
}
},
"required": ["project_path"]
}
}
```
---
### `export_for_color`
```json
{
"name": "export_for_color",
"description": "Export timeline in formats optimized for color grading roundtrip (DaVinci Resolve, Baselight).",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"output_path": {
"type": "string",
"description": "Path for the exported file"
},
"format": {
"type": "string",
"enum": ["fcpxml", "edl", "aaf", "xml-resolve"],
"default": "fcpxml",
"description": "Export format"
},
"include_grades": {
"type": "boolean",
"default": false,
"description": "Include existing color adjustments"
},
"handle_frames": {
"type": "integer",
"default": 24,
"description": "Frames of handles to include for each clip"
}
},
"required": ["project_path", "output_path"]
}
}
```
+389
View File
@@ -0,0 +1,389 @@
# FCPXML Structure Reference
How FCPXML represents different elements and what nodes need modification for each editing operation.
---
## Document Structure
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<!-- Media assets, formats, effects -->
</resources>
<library>
<event name="My Event">
<project name="My Project">
<sequence>
<spine>
<!-- Primary storyline clips -->
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
```
---
## Key Elements
### `<asset>` - Media Reference
```xml
<asset id="r1" name="Interview_01" src="file:///path/to/Interview_01.mov"
start="0s" duration="300s" hasVideo="1" hasAudio="1">
<format id="r2" name="FFVideoFormat1080p30"/>
</asset>
```
### `<clip>` - Timeline Clip
```xml
<clip name="Interview_01" offset="0s" duration="120s" start="30s"
tcFormat="NDF" ref="r1">
<!-- offset: position in timeline -->
<!-- duration: length shown in timeline -->
<!-- start: in-point in source media -->
<!-- ref: links to asset id -->
</clip>
```
### `<video>` / `<audio>` - A/V Components
```xml
<video name="B-Roll" offset="120s" duration="60s" start="0s" ref="r3">
<audio lane="-1" offset="0s" duration="60s" start="0s" ref="r3"/>
</video>
```
### `<gap>` - Empty Space
```xml
<gap name="Gap" offset="180s" duration="30s"/>
```
### `<marker>` - Marker
```xml
<marker start="45s" duration="1/30s" value="Chapter 1"/>
<!-- Chapter marker -->
<chapter-marker start="90s" duration="1/30s" value="Intro"
posterOffset="0s"/>
<!-- To-do marker -->
<marker start="120s" duration="1/30s" value="Fix audio">
<note>Audio levels too low</note>
</marker>
```
### `<keyword>` - Keyword Range
```xml
<clip ref="r1" ...>
<keyword start="0s" duration="30s" value="Interview"/>
<keyword start="30s" duration="15s" value="B-Roll"/>
</clip>
```
### `<transition>` - Transition Effect
```xml
<transition name="Cross Dissolve" offset="119/2s" duration="1s">
<filter-video ref="r10" name="Cross Dissolve"/>
</transition>
```
---
## Edit Operations → XML Changes
### ADD_MARKER
**Target:** Inside `<clip>`, `<video>`, `<audio>`, or `<spine>`
```xml
<!-- Before -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
</clip>
<!-- After: Standard marker at 45s -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<marker start="45s" duration="1/30s" value="Review this"/>
</clip>
<!-- After: Chapter marker -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<chapter-marker start="45s" duration="1/30s" value="Introduction" posterOffset="0s"/>
</clip>
<!-- After: Colored marker -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<marker start="45s" duration="1/30s" value="Needs work">
<marker-color color="3"/> <!-- 0=blue, 1=cyan, 2=green, 3=yellow... -->
</marker>
</clip>
```
---
### TRIM_CLIP
**Target:** `start` and `duration` attributes of `<clip>`
```xml
<!-- Before: 2 minute clip starting at source timecode 30s -->
<clip name="Interview" offset="0s" duration="120s" start="30s" ref="r1"/>
<!-- After: Trim 10s from start (new in-point) -->
<clip name="Interview" offset="0s" duration="110s" start="40s" ref="r1"/>
<!-- After: Trim 10s from end (shorter duration) -->
<clip name="Interview" offset="0s" duration="110s" start="30s" ref="r1"/>
```
**Ripple trim** also requires updating `offset` of all subsequent clips:
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<clip name="B" offset="60s" duration="60s" .../>
<clip name="C" offset="120s" duration="60s" .../>
</spine>
<!-- After: Trim 10s from end of clip A with ripple -->
<spine>
<clip name="A" offset="0s" duration="50s" .../>
<clip name="B" offset="50s" duration="60s" .../> <!-- offset shifted -->
<clip name="C" offset="110s" duration="60s" .../> <!-- offset shifted -->
</spine>
```
---
### REORDER_CLIPS
**Target:** `offset` attributes of all affected clips
```xml
<!-- Before: A, B, C order -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="B" offset="30s" duration="30s" .../>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
<!-- After: Move C to start → C, A, B -->
<spine>
<clip name="C" offset="0s" duration="30s" .../>
<clip name="A" offset="30s" duration="30s" .../>
<clip name="B" offset="60s" duration="30s" .../>
</spine>
```
**Important:** Element order in XML doesn't matter, only `offset` values determine timeline position.
---
### ADD_TRANSITION
**Target:** Insert `<transition>` element between clips
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<clip name="B" offset="60s" duration="60s" .../>
</spine>
<!-- After: Add 1s cross-dissolve between A and B -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<transition name="Cross Dissolve" offset="59s" duration="1s">
<filter-video ref="r_dissolve" name="Cross Dissolve"/>
</transition>
<clip name="B" offset="60s" duration="60s" .../>
</spine>
```
**Note:** Transition `offset` is typically `clip_end - (transition_duration / 2)`
---
### CHANGE_SPEED
**Target:** Add `<timeMap>` or `<conform-rate>` inside clip
```xml
<!-- Constant speed change (50% slow-mo) -->
<clip name="Action" offset="0s" duration="120s" start="0s" ref="r1">
<conform-rate scaleEnabled="1" srcFrameRate="30"/>
<timeMap>
<timept time="0s" value="0s" interp="linear"/>
<timept time="120s" value="60s" interp="linear"/>
</timeMap>
</clip>
<!-- Speed ramp (100% → 50% → 100%) -->
<clip name="Action" offset="0s" duration="90s" start="0s" ref="r1">
<timeMap>
<timept time="0s" value="0s" interp="linear"/>
<timept time="30s" value="30s" interp="smooth2"/>
<timept time="60s" value="45s" interp="smooth2"/>
<timept time="90s" value="75s" interp="linear"/>
</timeMap>
</clip>
```
**Interpolation values:**
- `linear` - constant speed
- `smooth2` - ease in/out
- `smooth` - smoother ease
---
### SPLIT_CLIP
**Target:** Create two clips from one, adjusting `start`, `duration`, `offset`
```xml
<!-- Before: Single 60s clip -->
<spine>
<clip name="Interview" offset="0s" duration="60s" start="0s" ref="r1"/>
</spine>
<!-- After: Split at 30s mark -->
<spine>
<clip name="Interview" offset="0s" duration="30s" start="0s" ref="r1"/>
<clip name="Interview" offset="30s" duration="30s" start="30s" ref="r1"/>
</spine>
```
---
### DELETE_CLIP
**Ripple delete:** Remove clip, shift subsequent clips
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="B" offset="30s" duration="30s" .../>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
<!-- After: Delete B with ripple -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="C" offset="30s" duration="30s" .../> <!-- offset updated -->
</spine>
```
**Non-ripple delete:** Replace with gap
```xml
<!-- After: Delete B without ripple -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<gap name="Gap" offset="30s" duration="30s"/>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
```
---
## Time Format Conversions
FCPXML uses rational time (fractions of seconds):
| Timecode | FCPXML Time |
|----------|-------------|
| 00:00:01:00 @ 30fps | `1s` or `30/30s` |
| 00:00:00:15 @ 30fps | `15/30s` or `1/2s` |
| 00:01:00:00 | `60s` |
| 00:00:02:10 @ 24fps | `58/24s` |
**Conversion formula:**
```
fcpxml_time = (hours * 3600) + (minutes * 60) + seconds + (frames / fps)
```
**Frame-accurate time:**
```
frames_total = (hours * 3600 * fps) + (minutes * 60 * fps) + (seconds * fps) + frames
fcpxml_time = f"{frames_total}/{fps}s"
```
---
## Resource References
Every clip references resources by `id`:
```xml
<resources>
<format id="r1" name="FFVideoFormat1080p30" width="1920" height="1080"
frameDuration="1/30s"/>
<asset id="r2" name="Interview" src="file:///path/to/file.mov"
start="0s" duration="3600s" format="r1"/>
<effect id="r3" name="Cross Dissolve" uid=".../Cross Dissolve"/>
</resources>
<!-- In timeline -->
<clip ref="r2" .../>
<transition>
<filter-video ref="r3"/>
</transition>
```
---
## Compound Clips & Nested Timelines
```xml
<ref-clip name="Nested Sequence" ref="r5" offset="0s" duration="120s">
<!-- r5 points to another sequence -->
</ref-clip>
<!-- Inline compound (multicam, synchronized) -->
<mc-clip name="Multicam" offset="0s" duration="60s">
<mc-source angleID="angle1">
<clip ref="r10" .../>
</mc-source>
<mc-source angleID="angle2">
<clip ref="r11" .../>
</mc-source>
</mc-clip>
```
---
## Audio Specifics
```xml
<!-- Detached audio -->
<clip ref="r1" ...>
<audio lane="-1" ref="r1" offset="0s" duration="60s"/>
</clip>
<!-- Audio only clip -->
<audio-clip name="Music" lane="-2" offset="0s" duration="180s" ref="r20"/>
<!-- Audio adjustments -->
<clip ref="r1" ...>
<adjust-volume amount="-6dB"/>
<audio lane="-1" ref="r1">
<adjust-volume amount="3dB"/>
</audio>
</clip>
```
---
## Critical Implementation Notes
1. **Always validate XML** after modification - FCP will reject malformed FCPXML
2. **Maintain resource integrity** - never orphan asset references
3. **Recalculate all offsets** after any operation that changes clip positions
4. **Preserve existing attributes** - don't strip attributes you don't understand
5. **Handle connected clips** - clips on other lanes may connect to spine clips
6. **Time precision matters** - use rational fractions, not floats
+885
View File
@@ -0,0 +1,885 @@
# writer.py - FCPXML Write Operations
"""
FCPXML Writer Module
Handles all write operations for Final Cut Pro XML files.
Each function takes parsed FCPXML, performs modifications, and returns valid FCPXML.
"""
from dataclasses import dataclass
from typing import Optional, List, Dict, Union
from xml.etree import ElementTree as ET
from enum import Enum
import copy
# ============================================================================
# DATA MODELS
# ============================================================================
class MarkerType(Enum):
STANDARD = "standard"
INCOMPLETE = "todo"
CHAPTER = "chapter"
COMPLETED = "completed"
class MarkerColor(Enum):
BLUE = 0
CYAN = 1
GREEN = 2
YELLOW = 3
ORANGE = 4
RED = 5
PINK = 6
PURPLE = 7
@dataclass
class TimeValue:
"""Represents FCPXML time format (rational or decimal seconds)"""
numerator: int
denominator: int = 1
@classmethod
def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue':
"""
Convert timecode string to TimeValue
Accepts: 'HH:MM:SS:FF', 'HH:MM:SS;FF' (drop-frame), 'XXs', 'XX.XXs', 'X/Ys'
"""
# Handle FCPXML format (e.g., "30s", "15/30s", "100/1s")
if tc.endswith('s'):
tc = tc[:-1]
if '/' in tc:
num, denom = tc.split('/')
return cls(int(num), int(denom))
else:
# Decimal seconds
seconds = float(tc)
frames = int(seconds * fps)
return cls(frames, int(fps))
# Handle timecode format (HH:MM:SS:FF)
parts = tc.replace(';', ':').split(':')
if len(parts) == 4:
h, m, s, f = map(int, parts)
total_frames = (h * 3600 + m * 60 + s) * int(fps) + f
return cls(total_frames, int(fps))
raise ValueError(f"Invalid timecode format: {tc}")
def to_fcpxml(self) -> str:
"""Convert to FCPXML time string"""
if self.denominator == 1:
return f"{self.numerator}s"
return f"{self.numerator}/{self.denominator}s"
def to_seconds(self) -> float:
"""Convert to decimal seconds"""
return self.numerator / self.denominator
def __add__(self, other: 'TimeValue') -> 'TimeValue':
# Find common denominator
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) + (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def __sub__(self, other: 'TimeValue') -> 'TimeValue':
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) - (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def simplify(self) -> 'TimeValue':
"""Reduce fraction to simplest form"""
from math import gcd
divisor = gcd(self.numerator, self.denominator)
return TimeValue(self.numerator // divisor, self.denominator // divisor)
# ============================================================================
# CORE WRITER CLASS
# ============================================================================
class FCPXMLWriter:
"""
Handles all write operations on FCPXML documents.
Usage:
writer = FCPXMLWriter(fcpxml_path)
writer.add_marker(clip_id, timecode, name, marker_type)
writer.save(output_path)
"""
def __init__(self, fcpxml_path: str):
self.tree = ET.parse(fcpxml_path)
self.root = self.tree.getroot()
self.fps = self._detect_fps()
self._build_clip_index()
def _detect_fps(self) -> float:
"""Extract frame rate from format resource"""
for fmt in self.root.findall('.//format'):
frame_dur = fmt.get('frameDuration', '1/30s')
if '/' in frame_dur:
num, denom = frame_dur.replace('s', '').split('/')
return int(denom) / int(num)
return 30.0 # Default
def _build_clip_index(self) -> None:
"""Build index of all clips for fast lookup"""
self.clips: Dict[str, ET.Element] = {}
for i, clip in enumerate(self.root.findall('.//clip')):
clip_id = clip.get('id') or f"clip_{i}"
self.clips[clip_id] = clip
for i, video in enumerate(self.root.findall('.//video')):
vid_id = video.get('id') or f"video_{i}"
self.clips[vid_id] = video
def _get_spine(self) -> ET.Element:
"""Get the primary storyline spine"""
spine = self.root.find('.//spine')
if spine is None:
raise ValueError("No spine found in FCPXML")
return spine
def _recalculate_offsets(self, spine: ET.Element) -> None:
"""Recalculate all clip offsets after modifications"""
current_offset = TimeValue(0)
for child in spine:
if child.tag in ('clip', 'video', 'audio', 'gap', 'transition', 'ref-clip'):
child.set('offset', current_offset.to_fcpxml())
duration_str = child.get('duration', '0s')
duration = TimeValue.from_timecode(duration_str, self.fps)
current_offset = current_offset + duration
def save(self, output_path: str) -> str:
"""Write modified FCPXML to file"""
self.tree.write(output_path, encoding='UTF-8', xml_declaration=True)
return output_path
# ========================================================================
# MARKER OPERATIONS
# ========================================================================
def add_marker(
self,
clip_id: str,
timecode: str,
name: str,
marker_type: MarkerType = MarkerType.STANDARD,
color: Optional[MarkerColor] = None,
note: Optional[str] = None
) -> ET.Element:
"""
Add a marker to a clip.
Args:
clip_id: Target clip identifier
timecode: Position within clip (relative to clip start)
name: Marker label
marker_type: standard, chapter, or todo
color: Optional marker color
note: Optional marker note
Returns:
The created marker element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Convert timecode to FCPXML time
time_value = TimeValue.from_timecode(timecode, self.fps)
# Create marker element
marker = ET.SubElement(clip, marker_type.value)
marker.set('start', time_value.to_fcpxml())
marker.set('duration', f"1/{int(self.fps)}s") # 1 frame duration
marker.set('value', name)
# Add poster offset for chapter markers
if marker_type == MarkerType.CHAPTER:
marker.set('posterOffset', '0s')
# Add color if specified
if color is not None:
color_elem = ET.SubElement(marker, 'marker-color')
color_elem.set('color', str(color.value))
# Add note if specified
if note:
note_elem = ET.SubElement(marker, 'note')
note_elem.text = note
return marker
def batch_add_markers(
self,
markers: List[Dict],
auto_detect: Optional[Dict] = None
) -> List[ET.Element]:
"""
Add multiple markers at once.
Args:
markers: List of marker specs [{clip_id, timecode, name, ...}]
auto_detect: Auto-generate markers (at_cuts, at_keywords, at_intervals)
Returns:
List of created marker elements
"""
created = []
# Handle explicit markers
for m in markers:
marker = self.add_marker(
clip_id=m['clip_id'],
timecode=m['timecode'],
name=m['name'],
marker_type=MarkerType[m.get('marker_type', 'STANDARD').upper()],
color=MarkerColor[m['color'].upper()] if m.get('color') else None,
note=m.get('note')
)
created.append(marker)
# Handle auto-detection
if auto_detect:
if auto_detect.get('at_cuts'):
# Add marker at every cut point
spine = self._get_spine()
for clip in spine.findall('clip'):
offset = clip.get('offset', '0s')
marker = self.add_marker(
clip_id=clip.get('id', 'clip_0'),
timecode='0s', # Start of clip = cut point
name='Cut',
marker_type=MarkerType.STANDARD,
color=MarkerColor.YELLOW
)
created.append(marker)
if auto_detect.get('at_intervals'):
# Add markers at regular intervals
interval = TimeValue.from_timecode(auto_detect['at_intervals'], self.fps)
# Implementation: iterate through timeline at interval steps
pass
return created
# ========================================================================
# TRIM OPERATIONS
# ========================================================================
def trim_clip(
self,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True
) -> ET.Element:
"""
Trim a clip's in-point and/or out-point.
Args:
clip_id: Target clip
trim_start: New in-point or delta ('+1s', '-10f')
trim_end: New out-point or delta
ripple: Whether to shift subsequent clips
Returns:
Modified clip element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current values
current_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
duration_delta = TimeValue(0)
# Handle trim_start
if trim_start:
if trim_start.startswith('+') or trim_start.startswith('-'):
# Delta trim
delta = TimeValue.from_timecode(trim_start.lstrip('+-'), self.fps)
if trim_start.startswith('-'):
new_start = current_start - delta
new_duration = current_duration + delta
else:
new_start = current_start + delta
new_duration = current_duration - delta
else:
# Absolute trim
new_start = TimeValue.from_timecode(trim_start, self.fps)
start_diff = new_start - current_start
new_duration = current_duration - start_diff
clip.set('start', new_start.to_fcpxml())
duration_delta = new_duration - current_duration
current_duration = new_duration
# Handle trim_end
if trim_end:
if trim_end.startswith('+') or trim_end.startswith('-'):
delta = TimeValue.from_timecode(trim_end.lstrip('+-'), self.fps)
if trim_end.startswith('-'):
new_duration = current_duration - delta
else:
new_duration = current_duration + delta
else:
# Absolute end point
end_point = TimeValue.from_timecode(trim_end, self.fps)
new_duration = end_point - current_start
duration_delta = duration_delta + (new_duration - current_duration)
current_duration = new_duration
clip.set('duration', current_duration.to_fcpxml())
# Ripple subsequent clips
if ripple and duration_delta.numerator != 0:
self._ripple_after_clip(clip, duration_delta)
return clip
def _ripple_after_clip(self, clip: ET.Element, delta: TimeValue) -> None:
"""Shift all clips after the given clip by delta"""
spine = self._get_spine()
found_clip = False
for child in spine:
if child == clip:
found_clip = True
continue
if found_clip and child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip'):
current_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = current_offset + delta
child.set('offset', new_offset.to_fcpxml())
# ========================================================================
# REORDER OPERATIONS
# ========================================================================
def reorder_clips(
self,
clip_ids: List[str],
target_position: str,
ripple: bool = True
) -> None:
"""
Move clips to a new position in the timeline.
Args:
clip_ids: Clips to move (maintains relative order)
target_position: 'start', 'end', timecode, or 'after:clip_id'/'before:clip_id'
ripple: Whether to shift other clips
"""
spine = self._get_spine()
# Collect clips to move
clips_to_move = []
for clip_id in clip_ids:
for child in spine:
if child.get('id') == clip_id or child.get('name') == clip_id:
clips_to_move.append(child)
break
if not clips_to_move:
raise ValueError(f"No clips found matching: {clip_ids}")
# Calculate total duration of moving clips
total_duration = TimeValue(0)
for clip in clips_to_move:
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
total_duration = total_duration + dur
# Remove clips from current positions (store for reinsertion)
for clip in clips_to_move:
spine.remove(clip)
# Determine target offset
if target_position == 'start':
target_offset = TimeValue(0)
insert_index = 0
elif target_position == 'end':
# Find end of timeline
last_clip = list(spine)[-1] if len(spine) > 0 else None
if last_clip is not None:
last_offset = TimeValue.from_timecode(last_clip.get('offset', '0s'), self.fps)
last_dur = TimeValue.from_timecode(last_clip.get('duration', '0s'), self.fps)
target_offset = last_offset + last_dur
else:
target_offset = TimeValue(0)
insert_index = len(spine)
elif target_position.startswith('after:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
ref_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
ref_dur = TimeValue.from_timecode(child.get('duration', '0s'), self.fps)
target_offset = ref_offset + ref_dur
insert_index = i + 1
break
elif target_position.startswith('before:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
target_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
insert_index = i
break
else:
# Assume timecode
target_offset = TimeValue.from_timecode(target_position, self.fps)
# Find insert position
insert_index = 0
for i, child in enumerate(spine):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
if child_offset.to_seconds() >= target_offset.to_seconds():
insert_index = i
break
insert_index = i + 1
# Insert clips at new position
current_offset = target_offset
for clip in clips_to_move:
clip.set('offset', current_offset.to_fcpxml())
spine.insert(insert_index, clip)
insert_index += 1
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
current_offset = current_offset + dur
# Recalculate all offsets if ripple
if ripple:
self._recalculate_offsets(spine)
# ========================================================================
# TRANSITION OPERATIONS
# ========================================================================
def add_transition(
self,
clip_id: str,
position: str = 'end',
transition_type: str = 'cross-dissolve',
duration: str = '00:00:00:15'
) -> ET.Element:
"""
Add a transition to a clip.
Args:
clip_id: Target clip
position: 'start', 'end', or 'both'
transition_type: Type of transition
duration: Transition duration
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
trans_duration = TimeValue.from_timecode(duration, self.fps)
# Find clip index in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in primary storyline: {clip_id}")
transitions_added = []
# Map transition type to FCPXML effect name
effect_map = {
'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'
}
effect_name = effect_map.get(transition_type, 'Cross Dissolve')
if position in ('end', 'both'):
# Add transition after clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
# Transition starts at clip_end - (duration / 2)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset + clip_dur - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
# Add filter reference
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index + 1, transition)
transitions_added.append(transition)
if position in ('start', 'both'):
# Add transition before clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index, transition)
transitions_added.append(transition)
return transitions_added[0] if len(transitions_added) == 1 else transitions_added
# ========================================================================
# SPEED OPERATIONS
# ========================================================================
def change_speed(
self,
clip_id: str,
speed: float,
ramp: Optional[Dict] = None,
preserve_pitch: bool = True,
frame_blending: str = 'optical-flow'
) -> ET.Element:
"""
Change clip playback speed.
Args:
clip_id: Target clip
speed: Speed multiplier (0.5 = half speed, 2.0 = double)
ramp: Optional speed ramp config {start_speed, end_speed, curve}
preserve_pitch: Maintain audio pitch
frame_blending: Interpolation method
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current duration
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
if ramp:
# Speed ramp - create timeMap with multiple keyframes
timemap = ET.SubElement(clip, 'timeMap')
start_speed = ramp.get('start_speed', 1.0)
end_speed = ramp.get('end_speed', speed)
curve = ramp.get('curve', 'linear')
# Map curve to FCPXML interpolation
interp_map = {
'linear': 'linear',
'ease-in': 'smooth2',
'ease-out': 'smooth2',
'ease-in-out': 'smooth'
}
interp = interp_map.get(curve, 'linear')
# Calculate output duration based on speed changes
# This is simplified - real implementation needs integral calculus
avg_speed = (start_speed + end_speed) / 2
new_duration_seconds = current_duration.to_seconds() / avg_speed
# Create keyframes
# Start point
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# Mid point (speed transition)
mid_time = new_duration_seconds / 2
mid_value = current_duration.to_seconds() / 2 / start_speed
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{mid_time}s")
tp2.set('value', f"{mid_value}s")
tp2.set('interp', interp)
# End point
tp3 = ET.SubElement(timemap, 'timept')
tp3.set('time', f"{new_duration_seconds}s")
tp3.set('value', current_duration.to_fcpxml())
tp3.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
else:
# Constant speed change
timemap = ET.SubElement(clip, 'timeMap')
new_duration_seconds = current_duration.to_seconds() / speed
source_duration = current_duration.to_seconds()
# Start keyframe
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# End keyframe
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{new_duration_seconds}s")
tp2.set('value', f"{source_duration}s")
tp2.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
# Add conform-rate for frame blending
conform = ET.SubElement(clip, 'conform-rate')
conform.set('scaleEnabled', '1')
conform.set('srcFrameRate', str(int(self.fps)))
# Frame blending attribute
blend_map = {
'none': '0',
'frame-blending': '1',
'optical-flow': '2'
}
# Note: Actual FCP attribute name may vary
# Audio pitch preservation
if preserve_pitch:
audio = clip.find('audio')
if audio is not None:
audio.set('preservePitch', '1')
return clip
# ========================================================================
# SPLIT OPERATIONS
# ========================================================================
def split_clip(
self,
clip_id: str,
split_points: List[str],
split_type: str = 'blade'
) -> List[ET.Element]:
"""
Split a clip at specified timecodes.
Args:
clip_id: Clip to split
split_points: Timecodes within the clip to split at
split_type: 'blade' (this clip only) or 'blade-all' (all tracks)
Returns:
List of resulting clip elements
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Find clip in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in spine: {clip_id}")
# Sort split points
split_times = sorted([TimeValue.from_timecode(sp, self.fps) for sp in split_points])
# Get clip properties
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_name = clip.get('name', 'Clip')
clip_ref = clip.get('ref')
# Remove original clip
spine.remove(clip)
# Create new clips
new_clips = []
current_offset = clip_offset
current_start = clip_start
for i, split_time in enumerate(split_times + [clip_duration]):
if i == 0:
segment_duration = split_time
else:
segment_duration = split_time - split_times[i - 1]
# Create new clip element
new_clip = ET.Element('clip')
new_clip.set('name', f"{clip_name}")
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', current_start.to_fcpxml())
new_clip.set('duration', segment_duration.to_fcpxml())
if clip_ref:
new_clip.set('ref', clip_ref)
# Copy other attributes
for attr in ['tcFormat', 'format']:
if clip.get(attr):
new_clip.set(attr, clip.get(attr))
# Insert into spine
spine.insert(clip_index + i, new_clip)
new_clips.append(new_clip)
# Update for next iteration
current_offset = current_offset + segment_duration
current_start = current_start + segment_duration
# Update clip index
for new_clip in new_clips:
self.clips[new_clip.get('id', new_clip.get('name'))] = new_clip
return new_clips
# ========================================================================
# DELETE OPERATIONS
# ========================================================================
def delete_clip(
self,
clip_ids: List[str],
ripple: bool = True
) -> None:
"""
Delete clips from timeline.
Args:
clip_ids: Clips to delete
ripple: If True, shift subsequent clips. If False, leave gaps.
"""
spine = self._get_spine()
for clip_id in clip_ids:
clip = self.clips.get(clip_id)
if clip is None:
continue
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
# Find clip index
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
continue
if ripple:
# Remove clip and shift others
spine.remove(clip)
# Shift subsequent clips
for child in spine[clip_index:]:
if child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip', 'transition'):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = child_offset - clip_duration
child.set('offset', new_offset.to_fcpxml())
else:
# Replace with gap
gap = ET.Element('gap')
gap.set('name', 'Gap')
gap.set('offset', clip_offset.to_fcpxml())
gap.set('duration', clip_duration.to_fcpxml())
spine.remove(clip)
spine.insert(clip_index, gap)
# Remove from index
del self.clips[clip_id]
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def add_marker(
project_path: str,
clip_id: str,
timecode: str,
name: str,
marker_type: str = 'standard',
color: Optional[str] = None,
note: Optional[str] = None,
output_path: Optional[str] = None
) -> str:
"""
Convenience function to add a marker and save.
Returns:
Path to the modified FCPXML
"""
writer = FCPXMLWriter(project_path)
mt = MarkerType[marker_type.upper()]
mc = MarkerColor[color.upper()] if color else None
writer.add_marker(clip_id, timecode, name, mt, mc, note)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def trim_clip(
project_path: str,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to trim a clip and save."""
writer = FCPXMLWriter(project_path)
writer.trim_clip(clip_id, trim_start, trim_end, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def reorder_clips(
project_path: str,
clip_ids: List[str],
target_position: str,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to reorder clips and save."""
writer = FCPXMLWriter(project_path)
writer.reorder_clips(clip_ids, target_position, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
# Additional convenience functions follow same pattern...
+643
View File
@@ -0,0 +1,643 @@
# Auto Rough Cut Algorithm
The killer feature: AI-powered automatic rough cut generation from source footage.
---
## Overview
`auto_rough_cut` takes:
1. Source clips with keywords/metadata
2. Target duration
3. Structure template (optional)
4. Pacing preferences
And outputs:
- A complete FCPXML timeline with AI-selected clips
- Clips ordered by structure, selected by keywords
- Paced according to preferences
---
## Algorithm Phases
```
┌─────────────────────────────────────────────────────────────────────┐
│ AUTO ROUGH CUT PIPELINE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ INGEST │ → │ SCORE │ → │ SELECT │ → │ ASSEMBLE │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Parse clips Rank clips Pick clips Build FCPXML │
│ Extract meta by relevance per segment with transitions │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Phase 1: INGEST
Parse source FCPXML and extract all usable clips with metadata.
```python
@dataclass
class SourceClip:
id: str
name: str
source_path: str
duration: TimeValue
start: TimeValue # In-point in source
end: TimeValue # Out-point in source
# Metadata
keywords: List[str]
rating: int # 1-5 stars, 0 = unrated
is_favorite: bool
is_rejected: bool
notes: str
# Technical
resolution: Tuple[int, int]
frame_rate: float
has_audio: bool
# Computed
usable_duration: TimeValue # Excluding handles
def ingest_source_clips(fcpxml_path: str) -> List[SourceClip]:
"""
Parse FCPXML and extract all clips with their metadata.
Sources can be:
- Library export (all events/clips)
- Event export (single event)
- Project export (existing timeline - use clips from it)
Returns list of SourceClip objects.
"""
tree = ET.parse(fcpxml_path)
root = tree.getroot()
clips = []
# Find all asset-clips (browser clips) or clips (timeline clips)
for asset_clip in root.findall('.//asset-clip'):
clip = parse_asset_clip(asset_clip)
if not clip.is_rejected: # Skip rejected clips
clips.append(clip)
# Also check for clips in existing timelines (for re-cut workflows)
for clip_elem in root.findall('.//clip'):
clip = parse_timeline_clip(clip_elem)
if not clip.is_rejected:
clips.append(clip)
return clips
```
---
## Phase 2: SCORE
Score each clip's relevance for each segment in the structure.
```python
@dataclass
class ScoredClip:
clip: SourceClip
segment_scores: Dict[str, float] # segment_name -> relevance score
def score_clips(
clips: List[SourceClip],
structure: List[SegmentSpec]
) -> List[ScoredClip]:
"""
Score each clip's relevance to each segment.
Scoring factors:
1. Keyword match (highest weight)
2. Rating (higher = better)
3. Favorite status (bonus)
4. Duration fit (clips close to target get bonus)
"""
scored = []
for clip in clips:
segment_scores = {}
for segment in structure:
score = 0.0
# Keyword matching (0-50 points)
keyword_matches = set(clip.keywords) & set(segment.keywords)
if segment.keywords:
keyword_score = len(keyword_matches) / len(segment.keywords) * 50
else:
keyword_score = 25 # No keywords specified = neutral
score += keyword_score
# Rating (0-20 points)
if clip.rating > 0:
score += clip.rating * 4 # 5 stars = 20 points
else:
score += 10 # Unrated = neutral
# Favorite bonus (0-15 points)
if clip.is_favorite:
score += 15
# Duration fit (0-15 points)
# Clips close to ideal segment clip duration get bonus
target_clip_duration = calculate_ideal_clip_duration(segment)
duration_ratio = clip.usable_duration.to_seconds() / target_clip_duration
if 0.5 <= duration_ratio <= 2.0:
# Within usable range
fit_score = 15 - abs(1.0 - duration_ratio) * 10
score += max(0, fit_score)
segment_scores[segment.name] = score
scored.append(ScoredClip(clip=clip, segment_scores=segment_scores))
return scored
```
### Scoring Weights
| Factor | Weight | Notes |
|--------|--------|-------|
| Keyword match | 50% | Primary selection criteria |
| Rating | 20% | Editor's quality signal |
| Favorite | 15% | Strong preference signal |
| Duration fit | 15% | Practical editing fit |
---
## Phase 3: SELECT
Select clips for each segment based on scores and constraints.
```python
@dataclass
class SegmentSpec:
name: str
keywords: List[str]
duration: TimeValue
priority: str # 'favorites', 'longest', 'shortest', 'random', 'best'
@dataclass
class ClipSelection:
clip: SourceClip
segment: str
in_point: TimeValue
out_point: TimeValue
order: int
def select_clips_for_segment(
scored_clips: List[ScoredClip],
segment: SegmentSpec,
pacing_config: PacingConfig,
already_used: Set[str]
) -> List[ClipSelection]:
"""
Select clips for a single segment.
Algorithm:
1. Filter to clips with positive relevance
2. Sort by priority method
3. Greedily select until duration target met
4. Adjust in/out points to fit
"""
# Filter and sort
candidates = [
sc for sc in scored_clips
if sc.segment_scores.get(segment.name, 0) > 0
and sc.clip.id not in already_used
]
# Sort by priority
if segment.priority == 'favorites':
candidates.sort(key=lambda x: (x.clip.is_favorite, x.segment_scores[segment.name]), reverse=True)
elif segment.priority == 'longest':
candidates.sort(key=lambda x: x.clip.usable_duration.to_seconds(), reverse=True)
elif segment.priority == 'shortest':
candidates.sort(key=lambda x: x.clip.usable_duration.to_seconds())
elif segment.priority == 'random':
import random
random.shuffle(candidates)
else: # 'best' - default
candidates.sort(key=lambda x: x.segment_scores[segment.name], reverse=True)
# Greedy selection
selections = []
remaining_duration = segment.duration.to_seconds()
for scored_clip in candidates:
if remaining_duration <= 0:
break
clip = scored_clip.clip
# Determine clip duration for this segment
ideal_duration = calculate_ideal_clip_duration_for_pacing(
pacing_config,
remaining_duration
)
# Clip the clip to fit
actual_duration = min(
clip.usable_duration.to_seconds(),
ideal_duration,
remaining_duration
)
if actual_duration < pacing_config.min_clip_duration:
continue # Skip clips that would be too short
# Determine in/out points
# Default: use clip's existing in-point
in_point = clip.start
out_point = clip.start + TimeValue.from_seconds(actual_duration)
selections.append(ClipSelection(
clip=clip,
segment=segment.name,
in_point=in_point,
out_point=out_point,
order=len(selections)
))
remaining_duration -= actual_duration
already_used.add(clip.id)
return selections
def calculate_ideal_clip_duration_for_pacing(
config: PacingConfig,
remaining: float
) -> float:
"""
Calculate ideal clip duration based on pacing settings.
Pacing styles:
- slow: 5-10 second cuts
- medium: 2-5 second cuts
- fast: 0.5-2 second cuts
- dynamic: varies based on position
"""
pacing_ranges = {
'slow': (5.0, 10.0),
'medium': (2.0, 5.0),
'fast': (0.5, 2.0),
'dynamic': (1.0, 6.0)
}
min_dur, max_dur = pacing_ranges.get(config.pacing, (2.0, 5.0))
if config.avg_clip_duration:
# User specified exact average
target = config.avg_clip_duration
else:
# Random within range for organic feel
if config.vary_pacing:
import random
target = random.uniform(min_dur, max_dur)
else:
target = (min_dur + max_dur) / 2
# Don't exceed remaining duration
return min(target, remaining)
```
---
## Phase 4: ASSEMBLE
Build the final FCPXML from selected clips.
```python
def assemble_rough_cut(
selections: List[ClipSelection],
structure: List[SegmentSpec],
transitions_config: Dict,
output_path: str
) -> str:
"""
Build FCPXML from clip selections.
Steps:
1. Create FCPXML document structure
2. Add resources for all source clips
3. Build spine with clips in order
4. Add transitions between segments
5. Write to file
"""
# Create document
root = ET.Element('fcpxml', version='1.11')
# Resources section
resources = ET.SubElement(root, 'resources')
add_format_resource(resources)
asset_refs = {}
for selection in selections:
if selection.clip.source_path not in asset_refs:
asset_id = f"r{len(asset_refs) + 1}"
add_asset_resource(resources, selection.clip, asset_id)
asset_refs[selection.clip.source_path] = asset_id
# Library/Event/Project structure
library = ET.SubElement(root, 'library')
event = ET.SubElement(library, 'event', name='Rough Cut')
project = ET.SubElement(event, 'project', name='AI Rough Cut')
sequence = ET.SubElement(project, 'sequence')
spine = ET.SubElement(sequence, 'spine')
# Build timeline
current_offset = TimeValue(0)
current_segment = None
for selection in selections:
# Check if segment changed (for transition)
if selection.segment != current_segment:
if current_segment is not None:
# Add segment transition
trans_type = transitions_config.get('between_segments', 'cross-dissolve')
if trans_type != 'none':
add_transition(spine, current_offset, trans_type, duration='1s')
current_segment = selection.segment
# Add clip
asset_id = asset_refs[selection.clip.source_path]
duration = selection.out_point - selection.in_point
clip_elem = ET.SubElement(spine, 'clip')
clip_elem.set('name', selection.clip.name)
clip_elem.set('offset', current_offset.to_fcpxml())
clip_elem.set('duration', duration.to_fcpxml())
clip_elem.set('start', selection.in_point.to_fcpxml())
clip_elem.set('ref', asset_id)
current_offset = current_offset + duration
# Write file
tree = ET.ElementTree(root)
tree.write(output_path, encoding='UTF-8', xml_declaration=True)
return output_path
```
---
## Complete Algorithm
```python
def auto_rough_cut(
source_path: str,
output_path: str,
target_duration: str,
structure: Optional[List[Dict]] = None,
pacing: str = 'medium',
pacing_config: Optional[Dict] = None,
transitions: Optional[Dict] = None
) -> Dict:
"""
Main entry point for auto rough cut.
Args:
source_path: FCPXML with source clips
output_path: Where to save the rough cut
target_duration: Target total duration (timecode string)
structure: Optional segment structure
pacing: Pacing preset ('slow', 'medium', 'fast', 'dynamic')
pacing_config: Override pacing settings
transitions: Transition settings
Returns:
Dict with stats about the generated cut
"""
# Parse target duration
target = TimeValue.from_timecode(target_duration)
# Default structure if not provided
if structure is None:
structure = [
SegmentSpec(
name='Main',
keywords=[], # Use all clips
duration=target,
priority='best'
)
]
else:
structure = [SegmentSpec(**s) for s in structure]
# Normalize segment durations to match target
structure = normalize_segment_durations(structure, target)
# Build pacing config
config = PacingConfig(
pacing=pacing,
min_clip_duration=pacing_config.get('min_clip_duration', 1.0) if pacing_config else 1.0,
max_clip_duration=pacing_config.get('max_clip_duration', 8.0) if pacing_config else 8.0,
avg_clip_duration=pacing_config.get('avg_clip_duration') if pacing_config else None,
vary_pacing=pacing_config.get('vary_pacing', True) if pacing_config else True
)
# Transition config
trans_config = transitions or {
'between_segments': 'cross-dissolve',
'within_segments': 'cut'
}
# === EXECUTE PIPELINE ===
# Phase 1: Ingest
clips = ingest_source_clips(source_path)
print(f"Ingested {len(clips)} source clips")
# Phase 2: Score
scored_clips = score_clips(clips, structure)
# Phase 3: Select
all_selections = []
used_clips = set()
for segment in structure:
segment_selections = select_clips_for_segment(
scored_clips,
segment,
config,
used_clips
)
all_selections.extend(segment_selections)
print(f"Selected {len(segment_selections)} clips for '{segment.name}'")
# Phase 4: Assemble
assemble_rough_cut(
all_selections,
structure,
trans_config,
output_path
)
# Calculate stats
actual_duration = sum(
(s.out_point - s.in_point).to_seconds()
for s in all_selections
)
return {
'output_path': output_path,
'clips_used': len(all_selections),
'clips_available': len(clips),
'target_duration': target.to_seconds(),
'actual_duration': actual_duration,
'segments': len(structure),
'average_clip_duration': actual_duration / len(all_selections) if all_selections else 0
}
```
---
## Example Usage
```python
# Music video rough cut
result = auto_rough_cut(
source_path='/path/to/raw_footage.fcpxml',
output_path='/path/to/rough_cut.fcpxml',
target_duration='00:03:30:00', # 3:30 music video
structure=[
{
'name': 'Intro',
'keywords': ['Wide', 'Establishing'],
'duration': '00:00:15:00',
'priority': 'best'
},
{
'name': 'Verse 1',
'keywords': ['Performance', 'Artist'],
'duration': '00:00:45:00',
'priority': 'favorites'
},
{
'name': 'Chorus',
'keywords': ['Energy', 'B-Roll', 'Crowd'],
'duration': '00:00:30:00',
'priority': 'best'
},
{
'name': 'Verse 2',
'keywords': ['Performance', 'Close-up'],
'duration': '00:00:45:00',
'priority': 'longest'
},
{
'name': 'Bridge',
'keywords': ['Cinematic', 'Slow-mo'],
'duration': '00:00:20:00',
'priority': 'best'
},
{
'name': 'Final Chorus',
'keywords': ['Energy', 'Performance', 'Crowd'],
'duration': '00:00:40:00',
'priority': 'random' # Mix it up
},
{
'name': 'Outro',
'keywords': ['Wide', 'Fade'],
'duration': '00:00:15:00',
'priority': 'best'
}
],
pacing='dynamic',
pacing_config={
'min_clip_duration': '00:00:00:15', # 15 frames min
'max_clip_duration': '00:00:04:00', # 4 seconds max
'vary_pacing': True
},
transitions={
'between_segments': 'cross-dissolve',
'within_segments': 'cut'
}
)
print(f"Generated {result['actual_duration']:.1f}s rough cut using {result['clips_used']} clips")
```
---
## Future Enhancements
### 1. Beat Detection Integration
```python
# Sync cuts to music beats
auto_rough_cut(
...
music_track='/path/to/song.mp3',
sync_to_beats=True,
beat_detection_sensitivity=0.8
)
```
### 2. AI Content Analysis
```python
# Use vision AI to analyze clip content
auto_rough_cut(
...
analyze_content=True, # Run clips through vision model
prefer_faces=True, # Prioritize clips with faces
avoid_duplicates=True # Don't repeat similar shots
)
```
### 3. Style Templates
```python
# Pre-built pacing templates for genres
auto_rough_cut(
...
style='music_video_hiphop' # Fast cuts, lots of variety
# or 'documentary_interview' # Longer clips, less cuts
# or 'commercial_30sec' # Punchy, tight
)
```
### 4. Multi-cam Support
```python
# Select from multiple camera angles
auto_rough_cut(
...
multicam_mode=True,
angle_variety=0.7, # How much to switch angles
prefer_angle='A-Cam' # Default camera
)
```
---
## Performance Considerations
| Clips | Segments | Expected Time |
|-------|----------|---------------|
| 100 | 5 | < 1 second |
| 500 | 10 | 2-3 seconds |
| 1000 | 20 | 5-8 seconds |
| 5000+ | 50+ | 15-30 seconds |
The algorithm is O(clips × segments) for scoring, O(clips log clips) for sorting, and O(selections) for assembly.
For very large projects, consider:
- Pre-filtering clips by keyword before scoring
- Caching scored clips between runs
- Parallel processing of segments
+750
View File
@@ -0,0 +1,750 @@
# server.py - Complete MCP Server Implementation
"""
Final Cut Pro MCP Server - Full Implementation
All read and write tools for FCPXML manipulation.
"""
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, CallToolResult
from fcpxml.parser import FCPXMLParser
from fcpxml.writer import (
FCPXMLWriter,
MarkerType,
MarkerColor,
add_marker,
trim_clip,
reorder_clips,
)
from fcpxml.rough_cut import auto_rough_cut
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("fcp-mcp-server")
server = Server("fcp-mcp-server")
# Default projects directory from env
PROJECTS_DIR = os.environ.get("FCP_PROJECTS_DIR", os.path.expanduser("~/Movies"))
# ============================================================================
# TOOL REGISTRY
# ============================================================================
def get_tools() -> List[Tool]:
"""Return all available tools."""
return [
# --- READ TOOLS ---
Tool(
name="list_projects",
description="Find FCPXML files in a directory. Returns paths to all Final Cut Pro project exports.",
inputSchema={
"type": "object",
"properties": {
"directory": {"type": "string", "description": "Directory to search (default: ~/Movies)"},
"recursive": {"type": "boolean", "default": True}
}
}
),
Tool(
name="analyze_timeline",
description="Get comprehensive timeline statistics: clip count, duration, cuts per minute, average cut length, pacing analysis.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string", "description": "Path to FCPXML file"}
},
"required": ["project_path"]
}
),
Tool(
name="list_clips",
description="List all clips with timecodes, durations, and source file references.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"include_audio": {"type": "boolean", "default": True}
},
"required": ["project_path"]
}
),
Tool(
name="list_markers",
description="Extract markers (chapter, todo, standard) with timecodes. Can format for YouTube chapters.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"format": {"type": "string", "enum": ["list", "youtube", "csv"], "default": "list"}
},
"required": ["project_path"]
}
),
Tool(
name="list_keywords",
description="Get all keywords/tags applied to clips in the project.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="find_short_cuts",
description="Find clips below a frame threshold - detects flash frames or accidental cuts.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"threshold_frames": {"type": "integer", "default": 6}
},
"required": ["project_path"]
}
),
Tool(
name="find_long_clips",
description="Find clips above a duration threshold - identify pacing issues.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"threshold_seconds": {"type": "number", "default": 10.0}
},
"required": ["project_path"]
}
),
Tool(
name="analyze_pacing",
description="AI analysis of edit pacing with suggestions for improvements.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"target_style": {"type": "string", "enum": ["music_video", "documentary", "commercial", "narrative"]}
},
"required": ["project_path"]
}
),
Tool(
name="export_edl",
description="Generate EDL (Edit Decision List) for color grading roundtrip.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"output_path": {"type": "string"},
"format": {"type": "string", "enum": ["cmx3600", "file32"], "default": "cmx3600"}
},
"required": ["project_path", "output_path"]
}
),
Tool(
name="export_csv",
description="Export timeline data to CSV for spreadsheet analysis.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"output_path": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}, "default": ["name", "start", "duration", "source"]}
},
"required": ["project_path", "output_path"]
}
),
# --- WRITE TOOLS ---
Tool(
name="add_marker",
description="Add a marker to the timeline. Supports chapter markers (for YouTube), to-do markers, and colored standard markers.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"timecode": {"type": "string", "description": "Position in HH:MM:SS:FF or seconds"},
"name": {"type": "string", "description": "Marker label"},
"marker_type": {"type": "string", "enum": ["standard", "chapter", "todo"], "default": "standard"},
"color": {"type": "string", "enum": ["blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple"]},
"note": {"type": "string"},
"output_path": {"type": "string", "description": "Save modified project here (default: overwrites original)"}
},
"required": ["project_path", "timecode", "name"]
}
),
Tool(
name="batch_add_markers",
description="Add multiple markers at once. Can also auto-detect cut points and add markers there.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"markers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timecode": {"type": "string"},
"name": {"type": "string"},
"marker_type": {"type": "string"},
"color": {"type": "string"}
},
"required": ["timecode", "name"]
}
},
"auto_detect": {
"type": "object",
"properties": {
"at_cuts": {"type": "boolean"},
"at_intervals": {"type": "string"}
}
},
"output_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="trim_clip",
description="Adjust a clip's in-point or out-point. Supports absolute timecodes or relative deltas (+1s, -10f).",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string", "description": "Clip identifier from list_clips"},
"trim_start": {"type": "string", "description": "New in-point or delta"},
"trim_end": {"type": "string", "description": "New out-point or delta"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id"]
}
),
Tool(
name="reorder_clips",
description="Move clips to a new position in the timeline.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"target_position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_id'"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_ids", "target_position"]
}
),
Tool(
name="add_transition",
description="Apply a transition between clips.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"position": {"type": "string", "enum": ["start", "end", "both"], "default": "end"},
"transition_type": {"type": "string", "enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "dip-to-color", "wipe"], "default": "cross-dissolve"},
"duration": {"type": "string", "default": "00:00:00:15"},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id"]
}
),
Tool(
name="change_speed",
description="Modify clip playback speed. Supports constant speed and speed ramps.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"speed": {"type": "number", "description": "Speed multiplier (0.5=half, 2.0=double)"},
"ramp": {
"type": "object",
"properties": {
"start_speed": {"type": "number"},
"end_speed": {"type": "number"},
"curve": {"type": "string", "enum": ["linear", "ease-in", "ease-out", "ease-in-out"]}
}
},
"preserve_pitch": {"type": "boolean", "default": True},
"frame_blending": {"type": "string", "enum": ["none", "frame-blending", "optical-flow"], "default": "optical-flow"},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id", "speed"]
}
),
Tool(
name="split_clip",
description="Split a clip at one or more timecodes.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"split_points": {"type": "array", "items": {"type": "string"}},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id", "split_points"]
}
),
Tool(
name="delete_clip",
description="Remove clips from the timeline. Supports ripple delete or leaving gaps.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_ids"]
}
),
Tool(
name="select_by_keyword",
description="Find clips matching keywords, ratings, or favorites status.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
"match_mode": {"type": "string", "enum": ["any", "all", "none"], "default": "any"},
"favorites_only": {"type": "boolean", "default": False},
"exclude_rejected": {"type": "boolean", "default": True}
},
"required": ["project_path", "keywords"]
}
),
Tool(
name="batch_trim",
description="Apply trim operations to multiple clips based on criteria.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"trim_start_by": {"type": "string"},
"trim_end_by": {"type": "string"},
"set_duration": {"type": "string"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="auto_rough_cut",
description="AI-powered rough cut generation. Analyzes source clips by keywords and assembles a timeline based on target duration and pacing.",
inputSchema={
"type": "object",
"properties": {
"source_path": {"type": "string", "description": "FCPXML with source clips"},
"output_path": {"type": "string", "description": "Where to save the rough cut"},
"target_duration": {"type": "string", "description": "Target duration (e.g., '00:03:30:00')"},
"structure": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
"duration": {"type": "string"},
"priority": {"type": "string", "enum": ["favorites", "longest", "shortest", "random", "best"]}
}
}
},
"pacing": {"type": "string", "enum": ["slow", "medium", "fast", "dynamic"], "default": "medium"},
"pacing_config": {
"type": "object",
"properties": {
"min_clip_duration": {"type": "string"},
"max_clip_duration": {"type": "string"},
"vary_pacing": {"type": "boolean"}
}
},
"transitions": {
"type": "object",
"properties": {
"between_segments": {"type": "string", "enum": ["none", "cross-dissolve", "fade-to-black"]},
"within_segments": {"type": "string", "enum": ["none", "cut", "cross-dissolve"]}
}
}
},
"required": ["source_path", "output_path", "target_duration"]
}
),
]
@server.list_tools()
async def list_tools() -> List[Tool]:
"""Return available tools."""
return get_tools()
# ============================================================================
# TOOL HANDLERS
# ============================================================================
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
"""Handle tool calls."""
try:
if name == "list_projects":
result = handle_list_projects(arguments)
elif name == "analyze_timeline":
result = handle_analyze_timeline(arguments)
elif name == "list_clips":
result = handle_list_clips(arguments)
elif name == "list_markers":
result = handle_list_markers(arguments)
elif name == "list_keywords":
result = handle_list_keywords(arguments)
elif name == "find_short_cuts":
result = handle_find_short_cuts(arguments)
elif name == "find_long_clips":
result = handle_find_long_clips(arguments)
elif name == "analyze_pacing":
result = handle_analyze_pacing(arguments)
elif name == "export_edl":
result = handle_export_edl(arguments)
elif name == "export_csv":
result = handle_export_csv(arguments)
elif name == "add_marker":
result = handle_add_marker(arguments)
elif name == "batch_add_markers":
result = handle_batch_add_markers(arguments)
elif name == "trim_clip":
result = handle_trim_clip(arguments)
elif name == "reorder_clips":
result = handle_reorder_clips(arguments)
elif name == "add_transition":
result = handle_add_transition(arguments)
elif name == "change_speed":
result = handle_change_speed(arguments)
elif name == "split_clip":
result = handle_split_clip(arguments)
elif name == "delete_clip":
result = handle_delete_clip(arguments)
elif name == "select_by_keyword":
result = handle_select_by_keyword(arguments)
elif name == "batch_trim":
result = handle_batch_trim(arguments)
elif name == "auto_rough_cut":
result = handle_auto_rough_cut(arguments)
else:
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True
)
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(result, indent=2))]
)
except Exception as e:
logger.exception(f"Error in tool {name}")
return CallToolResult(
content=[TextContent(type="text", text=f"Error: {str(e)}")],
isError=True
)
# ============================================================================
# HANDLER IMPLEMENTATIONS
# ============================================================================
def handle_list_projects(args: Dict) -> Dict:
"""List FCPXML files in directory."""
directory = args.get("directory", PROJECTS_DIR)
recursive = args.get("recursive", True)
path = Path(directory).expanduser()
pattern = "**/*.fcpxml" if recursive else "*.fcpxml"
projects = []
for fcpxml in path.glob(pattern):
stat = fcpxml.stat()
projects.append({
"path": str(fcpxml),
"name": fcpxml.stem,
"size_mb": round(stat.st_size / 1024 / 1024, 2),
"modified": stat.st_mtime
})
return {
"directory": str(path),
"count": len(projects),
"projects": projects
}
def handle_analyze_timeline(args: Dict) -> Dict:
"""Analyze timeline statistics."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
clips = timeline.clips
total_duration = sum(c.duration for c in clips)
cut_count = len(clips) - 1
durations = [c.duration for c in clips]
avg_duration = total_duration / len(clips) if clips else 0
min_duration = min(durations) if durations else 0
max_duration = max(durations) if durations else 0
# Cuts per minute
cpm = (cut_count / total_duration) * 60 if total_duration > 0 else 0
return {
"project_name": timeline.name,
"total_duration_seconds": round(total_duration, 2),
"total_duration_timecode": seconds_to_tc(total_duration),
"clip_count": len(clips),
"cut_count": cut_count,
"cuts_per_minute": round(cpm, 2),
"average_clip_duration": round(avg_duration, 2),
"shortest_clip": round(min_duration, 2),
"longest_clip": round(max_duration, 2),
"marker_count": len(timeline.markers)
}
def handle_list_clips(args: Dict) -> Dict:
"""List all clips with details."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
clips = []
for i, clip in enumerate(timeline.clips):
clips.append({
"id": clip.id or f"clip_{i}",
"name": clip.name,
"offset_tc": seconds_to_tc(clip.offset),
"duration_tc": seconds_to_tc(clip.duration),
"duration_seconds": round(clip.duration, 3),
"source_start_tc": seconds_to_tc(clip.source_start),
"source_file": clip.source_path,
"keywords": clip.keywords,
"is_audio_only": clip.is_audio_only
})
return {
"clip_count": len(clips),
"clips": clips
}
def handle_list_markers(args: Dict) -> Dict:
"""Extract markers."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
fmt = args.get("format", "list")
markers = []
for m in timeline.markers:
markers.append({
"timecode": seconds_to_tc(m.time),
"name": m.name,
"type": m.marker_type,
"note": m.note
})
if fmt == "youtube":
# YouTube chapter format
chapters = []
for m in markers:
if m["type"] == "chapter":
tc = m["timecode"]
# Convert to YouTube format (MM:SS or H:MM:SS)
parts = tc.split(":")
if parts[0] == "00":
yt_tc = f"{parts[1]}:{parts[2]}"
else:
yt_tc = f"{parts[0]}:{parts[1]}:{parts[2]}"
chapters.append(f"{yt_tc} {m['name']}")
return {
"format": "youtube",
"chapters": "\n".join(chapters)
}
return {
"marker_count": len(markers),
"markers": markers
}
def handle_add_marker(args: Dict) -> Dict:
"""Add a marker to the timeline."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
marker_type = MarkerType[args.get("marker_type", "standard").upper()]
color = MarkerColor[args["color"].upper()] if args.get("color") else None
# Find the clip at this timecode, or add to spine
# For simplicity, we'll add to the first clip that contains this timecode
clip_id = find_clip_at_timecode(writer, args["timecode"])
writer.add_marker(
clip_id=clip_id,
timecode=args["timecode"],
name=args["name"],
marker_type=marker_type,
color=color,
note=args.get("note")
)
saved_path = writer.save(output_path)
return {
"success": True,
"marker_added": args["name"],
"at_timecode": args["timecode"],
"output_path": saved_path
}
def handle_trim_clip(args: Dict) -> Dict:
"""Trim a clip's in/out points."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
writer.trim_clip(
clip_id=args["clip_id"],
trim_start=args.get("trim_start"),
trim_end=args.get("trim_end"),
ripple=args.get("ripple", True)
)
saved_path = writer.save(output_path)
return {
"success": True,
"clip_trimmed": args["clip_id"],
"output_path": saved_path
}
def handle_reorder_clips(args: Dict) -> Dict:
"""Reorder clips in timeline."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
writer.reorder_clips(
clip_ids=args["clip_ids"],
target_position=args["target_position"],
ripple=args.get("ripple", True)
)
saved_path = writer.save(output_path)
return {
"success": True,
"clips_moved": args["clip_ids"],
"to_position": args["target_position"],
"output_path": saved_path
}
def handle_auto_rough_cut(args: Dict) -> Dict:
"""Generate AI rough cut."""
result = auto_rough_cut(
source_path=args["source_path"],
output_path=args["output_path"],
target_duration=args["target_duration"],
structure=args.get("structure"),
pacing=args.get("pacing", "medium"),
pacing_config=args.get("pacing_config"),
transitions=args.get("transitions")
)
return result
# ============================================================================
# UTILITIES
# ============================================================================
def seconds_to_tc(seconds: float, fps: float = 30.0) -> str:
"""Convert seconds to timecode string."""
total_frames = int(seconds * fps)
frames = total_frames % int(fps)
total_seconds = total_frames // int(fps)
secs = total_seconds % 60
total_minutes = total_seconds // 60
mins = total_minutes % 60
hours = total_minutes // 60
return f"{hours:02d}:{mins:02d}:{secs:02d}:{frames:02d}"
def find_clip_at_timecode(writer: FCPXMLWriter, timecode: str) -> str:
"""Find clip ID that contains the given timecode."""
from fcpxml.writer import TimeValue
target = TimeValue.from_timecode(timecode, writer.fps)
target_seconds = target.to_seconds()
for clip_id, clip in writer.clips.items():
offset = TimeValue.from_timecode(clip.get('offset', '0s'), writer.fps).to_seconds()
duration = TimeValue.from_timecode(clip.get('duration', '0s'), writer.fps).to_seconds()
if offset <= target_seconds < offset + duration:
return clip_id
# If no clip found, return first clip
return list(writer.clips.keys())[0] if writer.clips else None
# Placeholder implementations for remaining handlers
def handle_list_keywords(args): pass
def handle_find_short_cuts(args): pass
def handle_find_long_clips(args): pass
def handle_analyze_pacing(args): pass
def handle_export_edl(args): pass
def handle_export_csv(args): pass
def handle_batch_add_markers(args): pass
def handle_add_transition(args): pass
def handle_change_speed(args): pass
def handle_split_clip(args): pass
def handle_delete_clip(args): pass
def handle_select_by_keyword(args): pass
def handle_batch_trim(args): pass
# ============================================================================
# MAIN
# ============================================================================
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
+377
View File
@@ -0,0 +1,377 @@
# FCP MCP Server - Implementation Roadmap
Step-by-step plan to build all editing features.
---
## Current State
✅ **DONE:**
- Repository structure
- Basic parser (read FCPXML)
- 10 read-only MCP tools
- Claude Desktop integration config
🔄 **IN PROGRESS:**
- Writer module architecture
- Tool schemas for editing
---
## Implementation Order
### Sprint 1: Core Write Operations (Week 1)
**Day 1-2: TimeValue & Writer Foundation**
```
Tasks:
□ Implement TimeValue class with all operations
□ Create FCPXMLWriter base class
□ Implement _detect_fps()
□ Implement _build_clip_index()
□ Implement save()
□ Write unit tests for TimeValue
```
**Day 3-4: Marker Operations**
```
Tasks:
□ Implement add_marker()
□ Implement batch_add_markers()
□ Handle marker types (standard, chapter, todo)
□ Handle marker colors
□ Test with real FCP export
□ Verify re-import into FCP
```
**Day 5-6: Trim Operations**
```
Tasks:
□ Implement trim_clip() - absolute timecodes
□ Implement trim_clip() - delta trimming
□ Implement _ripple_after_clip()
□ Test with ripple=True and ripple=False
□ Edge case: trim to zero duration (should fail)
□ Edge case: trim beyond clip bounds (should clamp)
```
**Day 7: Reorder Operations**
```
Tasks:
□ Implement reorder_clips() - move to start/end
□ Implement reorder_clips() - move to timecode
□ Implement reorder_clips() - after/before clip
□ Implement _recalculate_offsets()
□ Test multi-clip moves
```
---
### Sprint 2: Advanced Edit Operations (Week 2)
**Day 1-2: Transitions**
```
Tasks:
□ Implement add_transition() - cross-dissolve
□ Implement add_transition() - fade variants
□ Find FCP effect reference IDs
□ Test position: start, end, both
□ Handle transition overlap calculation
```
**Day 3-4: Speed Changes**
```
Tasks:
□ Implement change_speed() - constant speed
□ Implement change_speed() - speed ramps
□ Create timeMap XML structure
□ Test slow-mo (0.5x) and fast (2x)
□ Test speed ramp with different curves
□ Verify frame blending options
```
**Day 5-6: Split & Delete**
```
Tasks:
□ Implement split_clip() - single split point
□ Implement split_clip() - multiple split points
□ Implement delete_clip() - with ripple
□ Implement delete_clip() - with gap
□ Test split preserves clip attributes
□ Test delete updates clip index
```
**Day 7: Selection & Batch**
```
Tasks:
□ Implement select_by_keyword()
□ Implement batch_trim()
□ Test keyword matching modes (any, all, none)
□ Test batch operations on 10+ clips
```
---
### Sprint 3: Auto Rough Cut (Week 3)
**Day 1-2: Ingest Phase**
```
Tasks:
□ Implement ingest_source_clips()
□ Parse asset-clips from library exports
□ Parse clips from event exports
□ Extract all metadata (keywords, ratings, favorites)
□ Test with 100+ clip library
```
**Day 3-4: Score Phase**
```
Tasks:
□ Implement score_clips()
□ Implement keyword matching scoring
□ Implement rating/favorite scoring
□ Implement duration fit scoring
□ Test scoring produces sensible rankings
```
**Day 5-6: Select Phase**
```
Tasks:
□ Implement select_clips_for_segment()
□ Implement all priority modes
□ Implement pacing-based duration calculation
□ Test clip selection respects already_used
□ Test segment duration targets
```
**Day 7: Assemble Phase**
```
Tasks:
□ Implement assemble_rough_cut()
□ Create proper FCPXML document structure
□ Add asset resources correctly
□ Add transitions between segments
□ Test complete rough cut generation
□ Import result into FCP - verify it works
```
---
### Sprint 4: Polish & Ship (Week 4)
**Day 1-2: Error Handling**
```
Tasks:
□ Add validation for all inputs
□ Graceful handling of malformed FCPXML
□ Clear error messages for common issues
□ Logging throughout
```
**Day 3-4: Integration Testing**
```
Tasks:
□ Create test suite with real FCP exports
□ Test each tool end-to-end
□ Test tool combinations (marker + trim + reorder)
□ Performance testing with large projects
```
**Day 5-6: Documentation**
```
Tasks:
□ Complete README with all tools
□ Add usage examples for each tool
□ Create tutorial: "Your first rough cut"
□ Add troubleshooting guide
□ Record demo video
```
**Day 7: Launch**
```
Tasks:
□ Final testing pass
□ Version bump to 1.0.0
□ Push to GitHub
□ Submit to MCP registry
□ Write launch posts
□ Share with FCP communities
```
---
## Testing Strategy
### Unit Tests
```python
# tests/test_timevalue.py
def test_from_timecode_hmsf():
tv = TimeValue.from_timecode("00:01:30:15", fps=30)
assert tv.to_seconds() == 90.5
def test_from_timecode_fcpxml():
tv = TimeValue.from_timecode("2700/30s")
assert tv.to_seconds() == 90.0
def test_addition():
a = TimeValue(30, 30) # 1 second
b = TimeValue(60, 30) # 2 seconds
c = a + b
assert c.to_seconds() == 3.0
def test_simplify():
tv = TimeValue(60, 30)
simplified = tv.simplify()
assert simplified.numerator == 2
assert simplified.denominator == 1
```
### Integration Tests
```python
# tests/test_writer_integration.py
def test_add_marker_reimports():
"""Marker added by writer should be visible in FCP."""
# 1. Create modified FCPXML
writer = FCPXMLWriter("fixtures/sample.fcpxml")
writer.add_marker("clip_0", "00:00:10:00", "Test Marker", MarkerType.CHAPTER)
writer.save("output/test_marker.fcpxml")
# 2. Parse it back
parser = FCPXMLParser("output/test_marker.fcpxml")
timeline = parser.parse()
# 3. Verify marker exists
marker_names = [m.name for m in timeline.markers]
assert "Test Marker" in marker_names
def test_trim_preserves_structure():
"""Trimming shouldn't corrupt other timeline elements."""
writer = FCPXMLWriter("fixtures/sample.fcpxml")
original_clip_count = len(writer.clips)
writer.trim_clip("clip_0", trim_end="-1s")
writer.save("output/test_trim.fcpxml")
parser = FCPXMLParser("output/test_trim.fcpxml")
timeline = parser.parse()
# Same number of clips
assert len(timeline.clips) == original_clip_count
```
### Golden Set Tests
```python
# tests/golden_set.py
"""
Golden set: known-good FCPXML files that must parse correctly.
If any fail, we broke backward compatibility.
"""
GOLDEN_FILES = [
"fixtures/golden/simple_timeline.fcpxml",
"fixtures/golden/multicam_project.fcpxml",
"fixtures/golden/compound_clips.fcpxml",
"fixtures/golden/with_effects.fcpxml",
"fixtures/golden/fcp_10_6_export.fcpxml",
"fixtures/golden/fcp_10_7_export.fcpxml",
"fixtures/golden/fcp_10_8_export.fcpxml",
]
@pytest.mark.parametrize("filepath", GOLDEN_FILES)
def test_golden_file_parses(filepath):
parser = FCPXMLParser(filepath)
timeline = parser.parse()
assert timeline is not None
assert len(timeline.clips) > 0
```
---
## Risk Mitigation
### Risk: FCPXML format changes between FCP versions
**Mitigation:**
- Test against multiple FCP export versions (10.6, 10.7, 10.8)
- Use conservative parsing (ignore unknown elements)
- Version detection in parser
- Golden set tests for each version
### Risk: Generated FCPXML rejected by FCP
**Mitigation:**
- Always validate output XML
- Round-trip testing (export → modify → import)
- Use FCP's own exports as templates
- Keep original attributes we don't understand
### Risk: Data loss from edit operations
**Mitigation:**
- Never overwrite original by default
- Backup before destructive operations
- Validate timeline integrity after each operation
- Undo capability (save original state)
### Risk: Performance with large projects
**Mitigation:**
- Lazy loading of clip metadata
- Index-based clip lookup
- Stream parsing for very large files
- Progress callbacks for long operations
---
## File Structure (Final)
```
fcp-mcp-server/
├── server.py # MCP server entry point
├── fcpxml/
│ ├── __init__.py
│ ├── parser.py # Read operations
│ ├── writer.py # Write operations
│ ├── rough_cut.py # Auto rough cut algorithm
│ ├── models.py # Data classes
│ └── utils.py # TimeValue, converters
├── tests/
│ ├── __init__.py
│ ├── test_parser.py
│ ├── test_writer.py
│ ├── test_rough_cut.py
│ ├── test_timevalue.py
│ ├── golden_set.py
│ └── fixtures/
│ ├── sample.fcpxml
│ └── golden/
├── examples/
│ ├── basic_usage.py
│ ├── rough_cut_example.py
│ └── batch_markers.py
├── docs/
│ ├── TOOL_REFERENCE.md
│ ├── FCPXML_GUIDE.md
│ └── TROUBLESHOOTING.md
├── pyproject.toml
├── requirements.txt
├── LICENSE
└── README.md
```
---
## Definition of Done
A feature is complete when:
1. ✅ Code implemented and working
2. ✅ Unit tests passing
3. ✅ Integration test with real FCP export
4. ✅ Re-import into FCP verified
5. ✅ Error handling for edge cases
6. ✅ Documented in README
7. ✅ Example usage provided
+586
View File
@@ -0,0 +1,586 @@
# models.py - Data Models for FCP MCP Server
"""
Core data models for representing FCPXML structures.
These models provide a clean Python interface for working with
Final Cut Pro timelines, clips, markers, and other elements.
"""
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any, Tuple
from enum import Enum
from datetime import timedelta
# ============================================================================
# ENUMS
# ============================================================================
class MarkerType(Enum):
"""Types of markers in Final Cut Pro.
In FCPXML, INCOMPLETE and COMPLETED are both <marker> elements
distinguished by the completed attribute: '0' = INCOMPLETE, '1' = COMPLETED.
Only CHAPTER uses a separate <chapter-marker> tag.
"""
STANDARD = "standard"
INCOMPLETE = "todo"
CHAPTER = "chapter"
COMPLETED = "completed"
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 ClipType(Enum):
"""Types of clips in timeline."""
VIDEO = "video"
AUDIO = "audio"
COMPOUND = "compound"
MULTICAM = "multicam"
SYNC = "sync"
GAP = "gap"
TITLE = "title"
GENERATOR = "generator"
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
# ============================================================================
# TIME VALUE
# ============================================================================
@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
@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:
num, denom = tc_val.split('/')
return cls(int(num), int(denom))
else:
seconds = float(tc_val)
frames = int(round(seconds * fps))
return cls(frames, int(fps))
# 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:
# HH:MM:SS without frames
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")."""
simplified = self.simplify()
if simplified.denominator == 1:
return f"{simplified.numerator}s"
return f"{simplified.numerator}/{simplified.denominator}s"
def to_seconds(self) -> float:
"""Convert to decimal seconds."""
if self.denominator == 0:
return 0.0
return self.numerator / self.denominator
def to_timecode(self, fps: float = 30.0) -> str:
"""Convert to HH:MM:SS:FF timecode string."""
total_seconds = self.to_seconds()
total_frames = int(round(total_seconds * fps))
frames = int(total_frames % fps)
total_secs = total_frames // int(fps)
secs = total_secs % 60
total_mins = total_secs // 60
mins = total_mins % 60
hours = 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)
from math import gcd
divisor = gcd(abs(self.numerator), abs(self.denominator))
return TimeValue(
self.numerator // divisor,
self.denominator // divisor
)
def __add__(self, other: 'TimeValue') -> 'TimeValue':
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) + (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def __sub__(self, other: 'TimeValue') -> 'TimeValue':
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) - (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def __mul__(self, scalar: float) -> 'TimeValue':
new_num = int(self.numerator * scalar)
return TimeValue(new_num, self.denominator).simplify()
def __truediv__(self, scalar: float) -> 'TimeValue':
new_denom = int(self.denominator * scalar)
return TimeValue(self.numerator, new_denom).simplify()
def __lt__(self, other: 'TimeValue') -> bool:
return self.to_seconds() < other.to_seconds()
def __le__(self, other: 'TimeValue') -> bool:
return self.to_seconds() <= other.to_seconds()
def __gt__(self, other: 'TimeValue') -> bool:
return self.to_seconds() > other.to_seconds()
def __ge__(self, other: 'TimeValue') -> bool:
return self.to_seconds() >= other.to_seconds()
def __eq__(self, other: object) -> bool:
if not isinstance(other, TimeValue):
return False
return abs(self.to_seconds() - other.to_seconds()) < 0.0001
def __repr__(self) -> str:
return f"TimeValue({self.numerator}/{self.denominator}s = {self.to_seconds():.3f}s)"
# ============================================================================
# CORE MODELS
# ============================================================================
@dataclass
class Marker:
"""A marker in the timeline."""
time: float # Position in seconds
name: str
marker_type: str = "standard"
color: Optional[str] = None
note: Optional[str] = None
duration: float = 0.0 # Usually 0 or 1 frame
# Reference to containing clip (if any)
clip_id: Optional[str] = None
@dataclass
class Keyword:
"""A keyword range applied to a clip or portion of a clip."""
value: str # The keyword text
start: float # Start time within clip (seconds)
duration: float # Duration of keyword range
# Some keywords apply to entire clips
is_clip_level: bool = False
@dataclass
class Effect:
"""A video or audio effect applied to a clip."""
name: str
effect_id: str # FCP internal identifier
parameters: Dict[str, Any] = field(default_factory=dict)
# Effect category
is_audio: bool = False
is_transition: bool = False
@dataclass
class Clip:
"""
A clip in the timeline.
Represents video, audio, gap, or compound clips.
"""
# Identity
id: str
name: str
clip_type: str = "video" # video, audio, gap, compound, etc.
# Timeline position
offset: float # Position in timeline (seconds)
duration: float # Duration in timeline (seconds)
# Source reference
source_start: float = 0.0 # In-point in source media
source_path: Optional[str] = None # Path to source file
asset_id: Optional[str] = None # Reference to asset in resources
# Metadata
keywords: List[str] = field(default_factory=list)
rating: int = 0 # 0=unrated, 1-5 stars
is_favorite: bool = False
is_rejected: bool = False
notes: str = ""
# Markers within this clip
markers: List[Marker] = field(default_factory=list)
# Effects applied
effects: List[Effect] = field(default_factory=list)
# Speed modification
speed: float = 1.0 # 1.0 = normal, 0.5 = half speed, 2.0 = double
has_speed_ramp: bool = False
# Audio properties
is_audio_only: bool = False
audio_volume: float = 0.0 # dB adjustment
is_muted: bool = False
# Technical
resolution: Optional[Tuple[int, int]] = None
frame_rate: Optional[float] = None
# Relationships
lane: int = 0 # 0 = primary storyline, negative = audio, positive = connected
connected_to: Optional[str] = None # ID of clip this connects to
@property
def end_offset(self) -> float:
"""Calculate end position in timeline."""
return self.offset + self.duration
@property
def source_end(self) -> float:
"""Calculate end point in source media."""
return self.source_start + (self.duration * self.speed)
def contains_timecode(self, tc: float) -> bool:
"""Check if this clip contains the given timecode."""
return self.offset <= tc < self.end_offset
@dataclass
class Transition:
"""A transition between clips."""
name: str
transition_type: str # cross-dissolve, fade, wipe, etc.
offset: float # Position in timeline
duration: float
# Which clips it connects
from_clip_id: Optional[str] = None
to_clip_id: Optional[str] = None
@dataclass
class Timeline:
"""
A complete FCP timeline/sequence.
Contains all clips, markers, and metadata for a project.
"""
# Identity
name: str
id: Optional[str] = None
# Technical properties
frame_rate: float = 30.0
resolution: Tuple[int, int] = (1920, 1080)
# Content
clips: List[Clip] = field(default_factory=list)
markers: List[Marker] = field(default_factory=list) # Timeline-level markers
transitions: List[Transition] = field(default_factory=list)
# Computed properties
@property
def duration(self) -> float:
"""Total timeline duration in seconds."""
if not self.clips:
return 0.0
return max(c.end_offset for c in self.clips)
@property
def clip_count(self) -> int:
"""Number of clips (excluding gaps)."""
return len([c for c in self.clips if c.clip_type != "gap"])
@property
def cut_count(self) -> int:
"""Number of cuts (transitions between clips)."""
return max(0, self.clip_count - 1)
@property
def cuts_per_minute(self) -> float:
"""Average cuts per minute."""
if self.duration <= 0:
return 0.0
return (self.cut_count / self.duration) * 60
@property
def average_clip_duration(self) -> float:
"""Average clip duration in seconds."""
clips = [c for c in self.clips if c.clip_type != "gap"]
if not clips:
return 0.0
return sum(c.duration for c in clips) / len(clips)
def get_clip_at(self, timecode: float) -> Optional[Clip]:
"""Find the clip at a specific timecode."""
for clip in self.clips:
if clip.contains_timecode(timecode):
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.keywords]
def get_all_markers(self) -> List[Marker]:
"""Get all markers (timeline + clip-level)."""
all_markers = list(self.markers)
for clip in self.clips:
for marker in clip.markers:
# Adjust marker time to timeline position
adjusted = Marker(
time=clip.offset + marker.time,
name=marker.name,
marker_type=marker.marker_type,
color=marker.color,
note=marker.note,
clip_id=clip.id
)
all_markers.append(adjusted)
return sorted(all_markers, key=lambda m: m.time)
# ============================================================================
# ROUGH CUT MODELS
# ============================================================================
@dataclass
class SegmentSpec:
"""Specification for a segment in auto rough cut."""
name: str
keywords: List[str] = field(default_factory=list)
duration: Optional[TimeValue] = None # Target duration
duration_seconds: float = 0.0 # Alternative: duration in seconds
priority: str = "best" # favorites, longest, shortest, random, best
def get_duration_seconds(self) -> float:
"""Get duration as seconds."""
if self.duration:
return self.duration.to_seconds()
return self.duration_seconds
@dataclass
class PacingConfig:
"""Configuration for rough cut pacing."""
pacing: str = "medium" # slow, medium, fast, dynamic
min_clip_duration: float = 1.0 # Minimum seconds per clip
max_clip_duration: float = 8.0 # Maximum seconds per clip
avg_clip_duration: Optional[float] = None # Target average
vary_pacing: bool = True # Randomize within range
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 ClipSelection:
"""A clip selected for inclusion in rough cut."""
clip: Clip
segment: str # Which segment this belongs to
in_point: TimeValue # Where to start in source
out_point: TimeValue # Where to end in source
order: int # Position in final sequence
@property
def duration(self) -> TimeValue:
"""Duration of this selection."""
return self.out_point - self.in_point
@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
# Detailed breakdown
segment_durations: Dict[str, float] = field(default_factory=dict)
clips_per_segment: Dict[str, int] = field(default_factory=dict)
# ============================================================================
# ASSET MODELS
# ============================================================================
@dataclass
class Asset:
"""A media asset referenced by clips."""
id: str
name: str
src: str # File path or URL
# Duration in source
start: float = 0.0
duration: float = 0.0
# Media properties
has_video: bool = True
has_audio: bool = True
format_id: Optional[str] = None
@dataclass
class Format:
"""A format definition (resolution, frame rate, etc.)."""
id: str
name: str
width: int = 1920
height: int = 1080
frame_duration: str = "1/30s" # FCPXML format
@property
def frame_rate(self) -> float:
"""Calculate frame rate from frame duration."""
if '/' in self.frame_duration:
num, denom = self.frame_duration.replace('s', '').split('/')
return int(denom) / int(num)
return 30.0
@dataclass
class FCPXMLDocument:
"""
Complete FCPXML document structure.
Represents the entire file including resources and library structure.
"""
version: str = "1.11"
# Resources
formats: List[Format] = field(default_factory=list)
assets: List[Asset] = field(default_factory=list)
effects: List[Effect] = field(default_factory=list)
# Library structure
library_name: str = "Library"
events: List[str] = field(default_factory=list) # Event names
# Projects/Sequences
timelines: List[Timeline] = field(default_factory=list)
def get_asset(self, asset_id: str) -> Optional[Asset]:
"""Find asset by ID."""
for asset in self.assets:
if asset.id == asset_id:
return asset
return None
def get_format(self, format_id: str) -> Optional[Format]:
"""Find format by ID."""
for fmt in self.formats:
if fmt.id == format_id:
return fmt
return None