diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..59c1bfa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# CLAUDE.md — fcp-mcp-server + +## Idioma (MANDATORY) + +Responder **sempre em português** ao usuário, sem exceção. Nunca responder em +inglês nas mensagens de chat/prompt — inclusive resumos, atualizações de +progresso e confirmações. Comentários e nomes de código continuam em inglês +normalmente; a regra é sobre a comunicação com o usuário. + +## What This Is + +MCP server that reads/writes Final Cut Pro XML (FCPXML) files. 62 tools for timeline analysis, batch editing, QC, generation, multi-track support, media relink, NLE export, transcript-based editing (local Whisper), and LIVE FCP control (push_to_fcp / list_fcp_libraries via Apple events). Reads FCPXML 1.8–1.14 (incl. `.fcpxmld` bundles with sidecar preservation), writes 1.13 by default. Dual-mode (XML + Live) direction: `code/docs/CAPABILITY-AUDIT-2026-06.md`. + +## Architecture + +Toda a estrutura do projeto fica em `code/`. A pasta `admin/` fica na raiz (fora de `code/`). + +``` +code/server.py — MCP server entry point. All 62 tool definitions, handlers, resources, prompts. + Dispatch dict pattern: TOOL_HANDLERS maps tool names → async handler functions. + +code/fcpxml/parser.py — Reads FCPXML → Python objects (Timeline, Clip, ConnectedClip, Marker, etc.) +code/fcpxml/writer.py — Writes modifications back to FCPXML. Handles markers, trimming, gaps, transitions. +code/fcpxml/rough_cut.py — Generates new timelines from source clips (rough cuts, montages, A/B rolls). +code/fcpxml/diff.py — Timeline comparison engine. Detects added/removed/moved/trimmed clips & markers. +code/fcpxml/export.py — DaVinci Resolve FCPXML v1.9 export + FCP7 XMEML v5 export for cross-NLE workflows. +code/fcpxml/models.py — Data classes: TimeValue, Timecode, Clip, ConnectedClip, CompoundClip, Timeline, etc. +code/fcpxml/media_intel.py — Real media analysis. Audio silence detection + beat detection. +code/fcpxml/dtd.py — Validates output against Apple's official DTDs. +``` + +## Key Patterns + +- **TimeValue**: All times are rational fractions (numerator/denominator) matching FCPXML's `"600/2400s"` format. Never use floats for time math. +- **_parse_project()**: Helper that parses FCPXML and returns `(tree, timeline, project)` tuple. Most handlers start with this. +- **generate_output_path()**: Creates `_modified`, `_chapters`, etc. suffixed output paths so originals aren't overwritten. +- **Tool handlers**: Each tool has its own `async def handle_(arguments: dict)` function. All return via `_text_result(text)` which wraps strings in the MCP `TextContent` list. +- **Connected clips**: Clips with `lane` attribute hang off spine clips. Positive lane = above (video), negative = below (audio). Secondary `` elements also contain connected clips. +- **XMEML export**: Converts spine-based model to track-based model. Primary storyline → Track 0, connected clip lanes → higher tracks. + +## Running + +```bash +cd code && uv run server.py # Start MCP server +cd code && uv run --extra dev pytest tests/ -v # Run tests +``` + +## Registro de Experiências e Boas Práticas (MANDATORY) + +Sempre que um problema de estrutura ou um erro recorrente for detectado e +corrigido, **registre-o** em `code/Engine/docs/05_EXPERIENCIAS.md` (template já +presente no arquivo). Antes de concluir qualquer alteração/correção, aplique e +verifique as boas práticas e o checklist final em +`code/Engine/docs/06_BOAS_PRATICAS.md`. + +## Pre-Commit (MANDATORY) + +Padrão do sistema: **sempre após concluir uma correção, o sistema é +automaticamente executado/validado.** Acione o script único a cada correção: + +```bash +cd code && ./Engine/run_after_fix.sh +``` + +Ele roda o lint (zero erros) e toda a suíte de testes, e falha se qualquer um +não passar. Equivalente a rodar manualmente os dois comandos abaixo. + +## Executar o App Localmente (MANDATORY) + +Sempre que uma alteração for feita no app (MacApp/) durante o período de +implementação, **compile e rode o programa localmente no computador** para +validar visualmente a alteração, além de rodar os testes: + +```bash +cd code && ./MacApp/build_app.sh --run # compila e abre o app localmente +``` + +Regra geral: após qualquer alteração, o app deve ser executado localmente +antes de concluir a tarefa. Se houver erro de compilação, corrija antes de +seguir. + +Before committing ANY changes, run both: +```bash +cd code && ruff check . --exclude docs/ # Lint — must pass with zero errors +cd code && pytest tests/ -v # Tests — all must pass +``` +CI runs both on every push to main. If either fails, the commit gets an X on GitHub. Fix lint errors before committing, not after. + +## Testing + +1032 tests across 24 files. `test_models.py` covers TimeValue arithmetic, Timecode parsing/formatting, Clip properties, validation models, and Timeline helpers. `test_writer.py` covers insert_clip, add_marker (all types), trim_clip, delete_clip, split_clip, and change_speed operations. `test_server.py` covers MCP tool handlers, parsers, and dispatch. `test_rough_cut.py` covers RoughCutGenerator. `test_features_v05.py` covers connected clips, roles, timeline diff, reformat, silence detection, export, and backward compatibility. `test_marker_pipeline.py` covers build_marker_element shared builder, batch auto-modes, clip index duplicate-name behavior, and write_fcpxml output format. `test_refactored_helpers.py` covers _index_elements, _iter_spine_clips, _find_spine_clip_at_seconds, _resolve_clip_duration, _make_asset_clip, _format_batch_result, and serialize_xml edge cases. `test_transcribe.py` covers phrase/filler span matching, range merge/invert algebra, whisper graceful degradation, and transcript-driven handler cuts against cached transcripts. `test_media_intel.py` covers silencedetect stderr parsing, source-to-timeline mapping, parameter bounds, and real-WAV ffmpeg integration (skips without ffmpeg; CI installs it). Tests use `examples/sample.fcpxml` as fixture data and inline XML fixtures. Tests create temp files and clean up after. + +## FCPXML Gotchas + +- FCPXML uses rational time everywhere: `"3600/2400s"` = 1.5 seconds +- `offset` in clips is the timeline position, `start` is the source media in-point +- Library clips (``) are different from timeline clips (``) +- Markers are children of clips, not siblings +- The `` element is the primary storyline — clips go here +- `.fcpxmld` bundles are DIRECTORIES wrapping `Info.fcpxml` + sidecar data files — sidecars must be copied on save or object-tracking/Cinematic data is destroyed +- `code/examples/sample.fcpxml` is NOT DTD-conformant (pre-`media-rep` assets, sequence-level chapter markers) — don't use it as a DTD-validity fixture diff --git a/admin/commit.command b/admin/commit.command new file mode 100755 index 0000000..c44a71c --- /dev/null +++ b/admin/commit.command @@ -0,0 +1,141 @@ +#!/bin/bash +# --------------------------------------------------------------------------- +# commit.command — Faz commit e push para o Gitea (G-ART) +# +# Uso: +# ./admin/commit.command # mensagem genérica +# ./admin/commit.command "sua mensagem" # mensagem customizada +# +# Requer: git configurado com remote HTTPS + token no Gitea. +# Repo: https://gitea.nacarmed.cloud/joaohenrique/gart.git +# --------------------------------------------------------------------------- + +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$DIR/.." && pwd)" +cd "$PROJECT_DIR" + +# ── Cores ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}==> $1${NC}"; } +ok() { echo -e "${GREEN} ✓ $1${NC}"; } +warn() { echo -e "${YELLOW} ⚠ $1${NC}"; } +erro() { echo -e "${RED} ✗ ERRO: $1${NC}" >&2; exit 1; } + +# ── 1. Verificar se é um repositório git ─────────────────────────────────── +if [ ! -d "$PROJECT_DIR/.git" ]; then + erro "Não é um repositório git. Execute 'git init' primeiro." +fi + +# ── 2. Configurar remote se necessário ───────────────────────────────────── +REMOTE_URL="https://gitea.nacarmed.cloud/joaohenrique/gart.git" +if ! git remote get-url origin &>/dev/null; then + info "Configurando remote origin..." + git remote add origin "$REMOTE_URL" + ok "Remote adicionado: $REMOTE_URL" +else + ATUAL=$(git remote get-url origin) + # Verificar se o remote já tem token (HTTPS com credenciais) + if [[ "$ATUAL" != *"@"* ]] && [[ "$ATUAL" == *"gitea.nacarmed.cloud"* ]]; then + warn "Remote sem token de autenticação." + warn "Atual: $ATUAL" + echo "" + echo " Para autenticar, execute:" + echo " git remote set-url origin https://USUARIO:TOKEN@gitea.nacarmed.cloud/joaohenrique/gart.git" + echo "" + echo " Ou gere um token em:" + echo " https://gitea.nacarmed.cloud/-/user/settings/tokens" + echo "" + fi +fi + +# ── 3. Configurar branch principal como main ─────────────────────────────── +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")" +if [ -z "$CURRENT_BRANCH" ]; then + info "Primeiro commit — criando branch main..." + git checkout -b main 2>/dev/null || true + CURRENT_BRANCH="main" +fi + +# ── 4. Verificar alterações ──────────────────────────────────────────────── +info "Verificando alterações em: $PROJECT_DIR" +info "Branch: $CURRENT_BRANCH" +echo "" + +# Mostrar o que será commitado (resumo) +CHANGES=$(git status --porcelain) +if [ -z "$CHANGES" ]; then + ok "Nada para commitar — repositório limpo." + exit 0 +fi + +echo -e "${YELLOW}Arquivos que serão commitados:${NC}" +echo "$CHANGES" | head -30 +TOTAL=$(echo "$CHANGES" | wc -l | tr -d ' ') +if [ "$TOTAL" -gt 30 ]; then + echo -e "${YELLOW} ... e mais $((TOTAL - 30)) arquivo(s)${NC}" +fi +echo "" + +# ── 5. Mensagem do commit ────────────────────────────────────────────────── +COMMIT_MSG="${1:-}" +USED_DESC_FILE=false + +if [ -z "$COMMIT_MSG" ] && [ -s "$PROJECT_DIR/commit-desc.txt" ]; then + COMMIT_MSG="$(cat "$PROJECT_DIR/commit-desc.txt")" + USED_DESC_FILE=true +fi + +COMMIT_MSG="${COMMIT_MSG:-chore: atualização geral}" + +# ── 6. Adicionar e commitar ──────────────────────────────────────────────── +info "Adicionando arquivos..." +git add -A + +# Commit com mensagem (suporta múltiplas linhas) +SUBJECT="$(printf '%s\n' "$COMMIT_MSG" | awk 'NF{print; exit}')" +BODY="$(printf '%s\n' "$COMMIT_MSG" | sed '1d' | awk 'NF' | tr '\n' ' ' | sed 's/ *$//')" + +info "Commitando..." +if [ -n "$BODY" ]; then + git commit -m "$SUBJECT" -m "$BODY" +else + git commit -m "$SUBJECT" +fi + +ok "Commit realizado com sucesso." + +# Limpar commit-desc.txt se foi usado +if [ "$USED_DESC_FILE" = true ]; then + : > "$PROJECT_DIR/commit-desc.txt" +fi + +# ── 7. Push para o Gitea ─────────────────────────────────────────────────── +info "Enviando para o Gitea (origin/$CURRENT_BRANCH)..." + +if git push origin "$CURRENT_BRANCH" 2>/dev/null; then + ok "Push concluído com sucesso!" +else + warn "Push falhou. Verifique a autenticação." + echo "" + echo -e "${YELLOW}Passos para resolver:${NC}" + echo "" + echo " 1. Gere um token no Gitea:" + echo " https://gitea.nacarmed.cloud/-/user/settings/tokens" + echo "" + echo " 2. Configure o remote com o token:" + echo " git remote set-url origin https://USUARIO:TOKEN@gitea.nacarmed.cloud/joaohenrique/gart.git" + echo "" + echo " 3. Execute novamente:" + echo " ./admin/commit.command" + echo "" +fi + +echo "" +ok "Processo de commit concluído." diff --git a/admin/commit.sh b/admin/commit.sh new file mode 100755 index 0000000..e104443 --- /dev/null +++ b/admin/commit.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Commit-only — commita e envia (push) o branch atual do repositório. +# Não mexe em produção. +# +# Usage: +# ./commit.sh # mensagem genérica +# ./commit.sh "sua mensagem" # mensagem customizada + +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +source "$DIR/lib/common.sh" + +require_project_dir +cd "$PROJECT_DIR" + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" + +info "Verificando alterações locais em $PROJECT_DIR (branch: $BRANCH)..." +if [ -z "$(git status --porcelain)" ]; then + ok "Nada para commitar." + exit 0 +fi + +# Mensagem do commit: prioridade +# 1) argumento explícito do script +# 2) conteúdo de code/commit-desc.txt (a IA mantém o resumo do que foi feito) +# 3) mensagem genérica +COMMIT_MSG="${1:-}" +USED_DESC_FILE=false +if [ -z "$COMMIT_MSG" ] && [ -s "$PROJECT_DIR/commit-desc.txt" ]; then + COMMIT_MSG="$(cat "$PROJECT_DIR/commit-desc.txt")" + USED_DESC_FILE=true +fi +COMMIT_MSG="${COMMIT_MSG:-chore: update}" + +info "Commitando alterações..." +git add -A + +# Se a mensagem tiver múltiplas linhas, usa a primeira como título e o resto +# como corpo (git commit -m -m). +SUBJECT="$(printf '%s\n' "$COMMIT_MSG" | awk 'NF{print; exit}')" +BODY="$(printf '%s\n' "$COMMIT_MSG" | sed '1d' | awk 'NF' | tr '\n' ' ' | sed 's/ *$//')" +if [ -n "$BODY" ]; then + git commit -m "$SUBJECT" -m "$BODY" +else + git commit -m "$SUBJECT" +fi + +# Esvazia commit-desc.txt depois que a mensagem já está presa no commit, pra +# não reaparecer numa próxima chamada e virar a mensagem de um commit +# seguinte que não tem nada a ver com ela. +if [ "$USED_DESC_FILE" = true ]; then + : > "$PROJECT_DIR/commit-desc.txt" +fi + +info "Enviando para o Gitea (origin/$BRANCH)..." +git push origin "$BRANCH" + +ok "Commit concluído em '$BRANCH'." diff --git a/admin/graphify.command b/admin/graphify.command new file mode 100755 index 0000000..ea2c082 --- /dev/null +++ b/admin/graphify.command @@ -0,0 +1,142 @@ +#!/bin/bash +# +# graphify.sh — Roda o graphify sobre o código-fonte deste projeto (G-ART / fcp-mcp-server). +# +# Uso: +# ./admin/graphify.sh # analisa o código real (server.py + fcpxml/) +# ./admin/graphify.sh # analisa outro caminho +# +# Requer: graphify instalado (pip install graphifyy / uv tool install graphifyy) + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +# Caminho padrão: pasta code/ (analisa server.py + fcpxml/). Pode ser sobrescrito por $1. +INPUT_PATH="${1:-$ROOT_DIR/code}" + +if [ ! -e "$INPUT_PATH" ]; then + echo "ERRO: caminho não encontrado: $INPUT_PATH" >&2 + exit 1 +fi + +echo "==> Graphify em: $INPUT_PATH" + +# Detecta interpretador Python que possui o pacote graphify. +PYTHON="" +if command -v uv >/dev/null 2>&1; then + PYTHON="$(uv tool run --from graphifyy python -c 'import sys; print(sys.executable)' 2>/dev/null || true)" +fi +if [ -z "$PYTHON" ]; then + PYTHON="$(command -v python3 || command -v python)" +fi + +if ! "$PYTHON" -c "import graphify" 2>/dev/null; then + echo "==> Instalando graphifyy..." + if command -v uv >/dev/null 2>&1; then + uv tool install --upgrade graphifyy + PYTHON="$(uv tool run --from graphifyy python -c 'import sys; print(sys.executable)')" + else + "$PYTHON" -m pip install graphifyy + fi +fi + +mkdir -p graphify-out +"$PYTHON" -c "import sys; open('graphify-out/.graphify_python','w').write(sys.executable)" + +# Detecta arquivos do corpus. +echo "==> Detectando arquivos..." +"$PYTHON" -c " +import json, sys +from graphify.detect import detect +from pathlib import Path +print(json.dumps(detect(Path('$INPUT_PATH')), ensure_ascii=False)) +" > graphify-out/.graphify_detect.json + +# Extração estrutural (AST) — código, sem LLM. +echo "==> Extração estrutural (AST)..." +"$PYTHON" -c " +import json, sys +from pathlib import Path +from graphify.extract import collect_files, extract +detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding='utf-8')) +code_files = [] +for f in detect.get('files', {}).get('code', []): + p = Path(f) + code_files.extend(collect_files(p) if p.is_dir() else [p]) +if code_files: + result = extract(code_files, cache_root=Path('$INPUT_PATH')) +else: + result = {'nodes': [], 'edges': [], 'input_tokens': 0, 'output_tokens': 0} +Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding='utf-8') +print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') +" + +# Corpus só de código -> semântica vazia; caso contrário o usuário roda via /graphify. +"$PYTHON" -c " +import json +from pathlib import Path +Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes': [], 'edges': [], 'hyperedges': [], 'input_tokens': 0, 'output_tokens': 0}), encoding='utf-8') +" + +# Merge AST + semântica. +"$PYTHON" -c " +import json +from pathlib import Path +ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding='utf-8')) +sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding='utf-8')) +seen = {n['id'] for n in ast['nodes']} +merged_nodes = list(ast['nodes']) +for n in sem['nodes']: + if n['id'] not in seen: + merged_nodes.append(n); seen.add(n['id']) +merged = {'nodes': merged_nodes, 'edges': ast['edges'] + sem['edges'], + 'hyperedges': sem.get('hyperedges', []), + 'input_tokens': sem.get('input_tokens', 0), 'output_tokens': sem.get('output_tokens', 0)} +Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding='utf-8') +print(f'Merged: {len(merged_nodes)} nodes, {len(merged[\"edges\"])} edges') +" + +# Build + cluster + report. +echo "==> Construindo grafo e clusters..." +"$PYTHON" -c " +import json +from pathlib import Path +from graphify.build import build_from_json +from graphify.cluster import cluster, score_all +from graphify.analyze import god_nodes, surprising_connections, suggest_questions +from graphify.report import generate +from graphify.export import to_json +extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding='utf-8')) +detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding='utf-8')) +G = build_from_json(extraction, root='$INPUT_PATH', directed=False) +if G.number_of_nodes() == 0: + print('ERRO: grafo vazio - nenhum nó extraído.'); raise SystemExit(1) +communities = cluster(G) +cohesion = score_all(G, communities) +gods = god_nodes(G) +surprises = surprising_connections(G, communities) +labels = {cid: 'Community ' + str(cid) for cid in communities} +questions = suggest_questions(G, communities, labels) +tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)} +wrote = to_json(G, communities, 'graphify-out/graph.json') +if not wrote: + print('ERRO: recusou encolher graph.json (#479).'); raise SystemExit(1) +report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '$INPUT_PATH', suggested_questions=questions) +Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding='utf-8') +Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}), encoding='utf-8') +print(f'Grafo: {G.number_of_nodes()} nós, {G.number_of_edges()} arestas, {len(communities)} comunidades') +" + +# HTML interativo. +echo "==> Exportando HTML..." +"$PYTHON" -m graphify export html || graphify export html 2>/dev/null || true + +echo +echo "Concluído. Saídas em:" +echo " $(pwd)/graphify-out/graph.html" +echo " $(pwd)/graphify-out/GRAPH_REPORT.md" +echo " $(pwd)/graphify-out/graph.json" +echo +read -n 1 -s -r -p "Pressione qualquer tecla para fechar..." || true diff --git a/admin/graphify.md b/admin/graphify.md new file mode 100644 index 0000000..f028e59 --- /dev/null +++ b/admin/graphify.md @@ -0,0 +1,39 @@ +--- +description: Roda o graphify no código-fonte (server.py + fcpxml/) e gera o grafo de conhecimento. +--- + +# Graphify do código + +Execute o pipeline completo do graphify sobre o código-fonte do projeto +(`code/server.py` e `code/fcpxml/`), gerando o grafo de conhecimento em `graphify-out/`. + +## Orientação de execução + +1. **Verifique o grafo existente**: se `graphify-out/graph.json` já existir e o + código-fonte não tiver sido alterado, responda "Grafo já construído" e + ofereça `/graphify query`. Caso contrário, prossiga com uma reconstrução. + +2. **Alvo**: rode o graphify sobre o código-fonte deste repositório + (G-ART / fcp-mcp-server): `code/server.py` + `code/fcpxml/`. Siga fielmente o passo + a passo do skill `graphify`: + + - Step 1: garantir o interpretador Python + pacote `graphifyy`. + - Step 2: detectar arquivos do corpus. + - Step 3: extração estrutural (AST) + semântica (subagentes / Gemini). + - Step 4-5: construir o grafo, clusterizar (comunidades) e rotular. + - Step 6: gerar o HTML interativo `graph.html`. + - Step 9: salvar manifest, custo e relatório `GRAPH_REPORT.md`. + +3. **Saídas esperadas** em `graphify-out/`: + - `graph.html` — visualização interativa (abrir no navegador). + - `GRAPH_REPORT.md` — relatório de auditoria. + - `graph.json` — dados crus do grafo. + +4. **Entrega**: mostre do `GRAPH_REPORT.md` apenas as seções "God Nodes", + "Surprising Connections" e "Suggested Questions". Em seguida ofereça explorar + a pergunta sugerida mais interessante com `/graphify query`. + +## Observações + +- Se o usuário passar um caminho em `$ARGUMENTS`, use esse caminho em vez do padrão. +- `$ARGUMENTS` opcional: caminho do corpus a ser analisado (padrão o código real). \ No newline at end of file diff --git a/admin/models_api.py b/admin/models_api.py new file mode 100644 index 0000000..b004e4b --- /dev/null +++ b/admin/models_api.py @@ -0,0 +1,891 @@ +#!/usr/bin/env python3 +"""JSON bridge between the SwiftUI app and the fcp-mcp-server Python engine. + +The SwiftUI app (MacApp/) launches this script as a subprocess with a command +and optional JSON arguments, then reads a single JSON document (or +newline-delimited JSON for progress) on stdout. + +Commands: + catalog + -> {"models": [{display_name, internal_name, size, storage, + accuracy, speed}], "installed": [names], + "selected": name, "models_dir": path, "installed_count": n, + "recommended": [names]} + + download {"model": "small"} + -> JSON-lines: {"type":"progress","fraction":0.42} + {"type":"done","installed":true} + {"type":"error","message":"..."} + + cancel {"model": "small"} + -> {"ok": true} + + select {"model": "small"} + -> {"ok": true, "selected": "small"} + + set_language {"language": "pt"} | "auto" + -> {"ok": true, "language": "pt"} + + delete {"model": "small"} + -> {"ok": true} + + open_finder {"model": "small"} + -> {"ok": true} + + set_models_dir {"dir": "/path"} + -> {"ok": true, "models_dir": "/path"} + + inspect {"path": "/path/to/project.fcpxml"} + -> {"ok": true, "path": "...", "name": "...", "fcpxml_version": "1.13", + "timelines": [{name, duration_seconds, frame_rate, width, height, + clips, cuts, connected, markers}]} + or {"ok": false, "error": "..."} + + transcribe {"path": "...", "model": "small", "language": "pt"|null, + "hf_token": "..."|null, "num_speakers": ""|null} + -> JSON-lines: + {"type":"progress","fraction":0.5,"stage":"Transcrevendo..."} + {"type":"result","transcripts":[{"media","language","words", + "duration","preview","saved", + "speakers"}]} + {"type":"error","message":"..."} + + edit_by_transcript {"path": "...", "phrases": ["frase um", "frase dois"], + "mode": "remove"|"keep_only", "clip_name": "..."|null, + "padding": 0.0, "model": "small", "language": "pt"|null} + -> {"ok": true, "path": "..._transcript_edit.fcpxml", "message": "..."} + or {"ok": false, "error": "..."} + + remove_filler_words {"path": "...", "fillers": ["um","uh"]|null, + "clip_name": "..."|null, "padding": 0.02, + "model": "small", "language": "pt"|null} + -> {"ok": true, "path": "..._defillered.fcpxml", "message": "..."} + or {"ok": false, "error": "..."} + + transcript_markers {"path": "...", "clip_name": "..."|null, + "marker_type": "chapter", "max_label_length": 50, + "model": "small", "language": "pt"|null} + -> {"ok": true, "path": "..._transcript_markers.fcpxml", "message": "..."} + or {"ok": false, "error": "..."} + + add_zoom {"path": "...", "clip_id": "...", "start": 10.0, "end": 16.0, + "scale": 1.3, "ease": 0.3, "position": "0 0"|null} + -> {"ok": true, "path": "..._zoom.fcpxml", "message": "..."} + or {"ok": false, "error": "..."} + + generate_dynamic_subtitles {"path": "...", "clip_name": "..."|null, + "band_height": 0.22, "block_center_y": -167, + "font": "Helvetica Neue", "font_size": 128, + "active_color": "1 1 1 1", "inactive_color": "0.7 0.7 0.7 1", + "model": "small", "language": "pt"|null} + -> {"ok": true, "path": "..._dynamic_subtitles.fcpxml", "message": "..."} + or {"ok": false, "error": "..."} + + rename_speakers {"path": "/to/media_transcript.json", + "speakers": {"SPEAKER_01": "Nome"}} + -> {"ok": true, "speakers": [...]} + + set_diarization {"token": "hf_...", "num_speakers": ""} + -> {"ok": true, "diarization": bool, "diarization_message": "...", + "num_speakers": "..."} + +Exit code 0 on success, 1 on error. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import subprocess +import sys +import threading +from pathlib import Path +from typing import Any + +# code/ is the package root for fcpxml and server modules. +_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") +if _CODE_DIR not in sys.path: + sys.path.insert(0, _CODE_DIR) + +from fcpxml.diarize import ( # noqa: E402 + assign_speakers, + build_speakers, + diarization_capability, + diarize, +) +from fcpxml.media_intel import media_src_to_path # noqa: E402 +from fcpxml.model_manager import ( # noqa: E402 + download_model, + get_models_dir, + is_model_downloaded, + list_installed_models, + load_catalog, + load_hf_token, + load_num_speakers, + load_selected_model, + load_transcript_language, + model_cache_dir, + save_hf_token, + save_models_dir, + save_num_speakers, + save_selected_model, + save_transcript_language, +) +from fcpxml.parser import parse_fcpxml # noqa: E402 +from fcpxml.transcribe import transcribe # noqa: E402 +from fcpxml.writer import FCPXMLModifier # noqa: E402 + +RECOMMENDED = ("large-v3", "distil-large-v3", "small", "base") + + +def _derived_output(path: str, suffix: str, args: dict) -> str: + """Resolve a derived XML path, optionally inside the chosen output folder.""" + output_dir = str(args.get("output_dir", "")).strip() + if output_dir: + directory = Path(output_dir).expanduser() + directory.mkdir(parents=True, exist_ok=True) + source = Path(path) + extension = ".fcpxmld" if source.is_dir() else source.suffix + return str(directory / f"{source.stem}{suffix}{extension}") + from server import generate_output_path + return generate_output_path(path, suffix) + +# Download cancellation events, keyed by model name. +_CANCEL: dict[str, threading.Event] = {} +_LOCK = threading.Lock() + + +def _emit(obj: Any) -> None: + sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _transcript_json_path(media_path: str, output_dir: str = "") -> Path: + """Where the ``_transcript.json`` for ``media_path`` lives. + + When ``output_dir`` (the user-selected project folder) is set, the + transcript is saved/read there — never next to the source media, which + may sit on a read-only volume or a Final Cut Library the user never + browses. Falls back to the media's own folder only when no project + folder has been chosen (legacy/MCP callers). + """ + p = Path(media_path) + if output_dir: + directory = Path(output_dir).expanduser() + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{p.stem}_transcript.json" + return p.with_name(p.stem + "_transcript.json") + + +def _save_json_atomic(path: Path, data: Any) -> None: + """Write ``data`` to ``path`` atomically and validate the result on disk. + + Mirrors the reference WHISPERX save path: write a ``.tmp``, ``os.replace`` + into place, then confirm the file exists, is non-empty, and parses as JSON. + """ + tmp_path = str(path) + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + os.replace(tmp_path, path) + if not path.exists() or os.path.getsize(path) == 0: + raise RuntimeError("O arquivo salvo está vazio ou não foi encontrado.") + with open(path, encoding="utf-8") as fh: + json.load(fh) + + +# ── commands ──────────────────────────────────────────────────────────────── + + +def cmd_catalog() -> None: + catalog = load_catalog() + installed = list_installed_models() + diar_ok, diar_msg = diarization_capability(load_hf_token()) + _emit( + { + "models": catalog, + "installed": installed, + "selected": load_selected_model(), + "language": load_transcript_language(), + "models_dir": str(get_models_dir()), + "installed_count": len(installed), + "recommended": list(RECOMMENDED), + "diarization": diar_ok, + "diarization_message": diar_msg, + "hf_token_set": bool(load_hf_token()), + "num_speakers": load_num_speakers(), + } + ) + + +def cmd_download(args: dict) -> int: + model = str(args.get("model", "")) + if model not in _model_names(): + _emit({"type": "error", "message": f"Modelo desconhecido: {model}"}) + return 1 + ev = threading.Event() + with _LOCK: + _CANCEL[model] = ev + try: + download_model(model, progress_cb=lambda f: _emit({"type": "progress", "fraction": f}), cancel_event=ev) + installed = is_model_downloaded(model) + _emit({"type": "done", "installed": installed}) + if installed: + save_selected_model(model) + return 0 if installed else 1 + except Exception as exc: + _emit({"type": "error", "message": str(exc)}) + return 1 + finally: + with _LOCK: + _CANCEL.pop(model, None) + + +def cmd_cancel(args: dict) -> None: + model = str(args.get("model", "")) + ev = _CANCEL.get(model) + if ev is not None: + ev.set() + _emit({"ok": True}) + + +def cmd_select(args: dict) -> None: + model = str(args.get("model", "")) + if not is_model_downloaded(model): + _emit({"ok": False, "error": "Modelo não está instalado."}) + return + save_selected_model(model) + _emit({"ok": True, "selected": load_selected_model()}) + + +def cmd_set_language(args: dict) -> int: + """Persist the transcription language (the default for every transcription).""" + lang = str(args.get("language", "auto")) + try: + saved = save_transcript_language(lang) + except ValueError as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + _emit({"ok": True, "language": saved}) + return 0 + + +def cmd_delete(args: dict) -> None: + model = str(args.get("model", "")) + try: + shutil.rmtree(model_cache_dir(model), ignore_errors=True) + except Exception: + pass + _emit({"ok": True}) + + +def cmd_open_finder(args: dict) -> None: + target = str(args.get("path") or model_cache_dir(str(args.get("model", "")))) + try: + subprocess.Popen(["open", target]) + except OSError: + pass + _emit({"ok": True}) + + +def cmd_remove_silences(args: dict) -> int: + """Run the canonical server silence remover into a suffixed copy.""" + path = str(args.get("path", "")) + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + from server import handle_remove_media_silence + + output = _derived_output(path, "_silence_removed", args) + contents = asyncio.run(handle_remove_media_silence({**args, "filepath": path, "output_path": output})) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_edit_by_transcript(args: dict) -> int: + """Cut (or keep only) spoken phrases, using each media's cached transcript.""" + path = str(args.get("path", "")) + phrases = args.get("phrases") or [] + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + if not isinstance(phrases, list) or not [p for p in phrases if str(p).strip()]: + _emit({"ok": False, "error": "Informe ao menos uma frase para cortar."}) + return 1 + try: + from server import handle_edit_by_transcript + + output = _derived_output(path, "_transcript_edit", args) + contents = asyncio.run(handle_edit_by_transcript({**args, "filepath": path, "output_path": output})) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_remove_filler_words(args: dict) -> int: + """Cut filler words (um, uh, ...) out, using each media's cached transcript.""" + path = str(args.get("path", "")) + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + from server import handle_remove_filler_words + + output = _derived_output(path, "_defillered", args) + contents = asyncio.run(handle_remove_filler_words({**args, "filepath": path, "output_path": output})) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_transcript_markers(args: dict) -> int: + """Add a marker per transcribed segment, using each media's cached transcript.""" + path = str(args.get("path", "")) + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + from server import handle_transcript_markers + + output = _derived_output(path, "_transcript_markers", args) + contents = asyncio.run(handle_transcript_markers({**args, "filepath": path, "output_path": output})) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_generate_dynamic_subtitles(args: dict) -> int: + """Generate word-by-word ("karaoke") caption compound clips, one per line, + using each media's cached transcript.""" + path = str(args.get("path", "")) + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + from server import handle_generate_dynamic_subtitles + + output = _derived_output(path, "_dynamic_subtitles", args) + contents = asyncio.run( + handle_generate_dynamic_subtitles({**args, "filepath": path, "output_path": output}) + ) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_add_zoom(args: dict) -> int: + """Add an ease-in/ease-out punch-in zoom to one clip.""" + path = str(args.get("path", "")) + clip_id = str(args.get("clip_id", "")).strip() + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + if not clip_id: + _emit({"ok": False, "error": "Informe o nome do clipe."}) + return 1 + try: + from server import handle_add_zoom + + output = _derived_output(path, "_zoom", args) + contents = asyncio.run(handle_add_zoom({**args, "filepath": path, "output_path": output})) + message = "\n".join(getattr(content, "text", str(content)) for content in contents) + if not Path(output).exists(): + _emit({"ok": False, "error": message}) + return 1 + _emit({"ok": True, "path": output, "message": message}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_zoom_clips(args: dict) -> int: + """Return timeline clips with enough identity for the zoom picker.""" + path = Path(str(args.get("path", ""))) + output_dir = str(args.get("output_dir", "")).strip() + if not path.exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + from server import _require_timeline + + _, timeline = _require_timeline(str(path)) + clips = [] + for index, clip in enumerate(timeline.clips): + media = clip.media_path or "" + cached = _load_cached_transcript(_transcript_json_path(media, output_dir)) if media else None + clips.append({ + "id": f"{index}:{clip.start.seconds:.6f}", + "index": index, + "name": clip.name, + "start": clip.start.seconds, + "duration": clip.duration_seconds, + "media": Path(media).name if media else "", + "preview": ((cached or {}).get("text", "") or "")[:180], + "has_transcript": cached is not None, + }) + _emit({"ok": True, "clips": clips}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_zoom_segments(args: dict) -> int: + """Return sentence/word ranges for one timeline clip.""" + path = Path(str(args.get("path", ""))) + output_dir = str(args.get("output_dir", "")).strip() + try: + from server import _require_timeline + + _, timeline = _require_timeline(str(path)) + index = int(args.get("index", -1)) + if index < 0 or index >= len(timeline.clips): + raise ValueError("Clipe selecionado não existe.") + clip = timeline.clips[index] + if not clip.media_path: + raise ValueError("Este clipe não possui mídia associada.") + data = _load_cached_transcript(_transcript_json_path(clip.media_path, output_dir)) + if data is None: + _emit({"ok": True, "segments": [], "message": "Transcreva este clipe primeiro."}) + return 0 + segments = [] + for number, segment in enumerate(data.get("segments", [])): + text = str(segment.get("text", "")).strip() + if text: + segments.append({ + "id": number, + "start": float(segment.get("start", 0)), + "end": float(segment.get("end", 0)), + "text": text, + }) + _emit({"ok": True, "segments": segments}) + return 0 + except Exception as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 +def cmd_set_models_dir(args: dict) -> int: + try: + d = save_models_dir(str(args.get("dir", ""))) + _emit({"ok": True, "models_dir": d}) + return 0 + except ValueError as exc: + _emit({"ok": False, "error": str(exc)}) + return 1 + + +def cmd_inspect(args: dict) -> int: + """Validate an FCPXML file and return a summary of its projects/timelines.""" + path = str(args.get("path", "")) + if not path: + _emit({"ok": False, "error": "Nenhum arquivo informado."}) + return 1 + if not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo não encontrado."}) + return 1 + try: + proj = parse_fcpxml(path) + except Exception as exc: + _emit({"ok": False, "error": f"Erro ao ler o projeto: {exc}"}) + return 1 + + timelines = [] + for tl in proj.timelines: + timelines.append( + { + "name": tl.name, + "duration_seconds": round(tl.duration.seconds, 3), + "frame_rate": round(tl.frame_rate, 3), + "width": tl.width, + "height": tl.height, + "clips": tl.total_clips, + "cuts": tl.total_cuts, + "connected": len(tl.connected_clips), + "markers": len(tl.markers), + } + ) + _emit( + { + "ok": True, + "path": path, + "name": proj.name, + "fcpxml_version": proj.fcpxml_version, + "timelines": timelines, + } + ) + return 0 + + +def cmd_transcribe(args: dict) -> int: + proj_path = str(args.get("path", "")) + output_dir = str(args.get("output_dir", "")).strip() + # Honra o modelo selecionado no programa quando nenhum é passado. + model = str(args.get("model", "") or load_selected_model() or "") + language = args.get("language") + if language is None: + language = load_transcript_language() + if language == "auto": + language = None + if not proj_path: + _emit({"type": "error", "message": "Nenhum projeto selecionado."}) + return 1 + if not output_dir: + _emit({"type": "error", "message": "Selecione a pasta do projeto antes de transcrever."}) + return 1 + if not model or not is_model_downloaded(model): + _emit( + { + "type": "error", + "message": "Nenhum modelo de transcrição instalado. Baixe e selecione um modelo na aba Modelos.", + } + ) + return 1 + + token = str(args.get("hf_token") or load_hf_token() or "") + if args.get("num_speakers") is not None: + num_speakers = str(args.get("num_speakers")) + else: + num_speakers = load_num_speakers() + + # Load project. + try: + proj = parse_fcpxml(proj_path) + except Exception as exc: + _emit({"type": "error", "message": f"Erro ao ler o projeto: {exc}"}) + return 1 + tl = proj.primary_timeline or (proj.timelines[0] if proj.timelines else None) + media_paths: list[str] = [] + if tl is not None: + for clip in getattr(tl, "clips", []): + mp = media_src_to_path(clip.media_path or "") + if mp and Path(mp).is_file() and mp not in media_paths: + media_paths.append(mp) + if not media_paths: + _emit({"type": "error", "message": "Nenhum arquivo de mídia acessível encontrado."}) + return 1 + + total = len(media_paths) + results: list[dict] = [] + for i, mp in enumerate(media_paths, 1): + _emit({"type": "progress", "fraction": i / total, "stage": f"Transcrevendo {Path(mp).name} ({i}/{total})…"}) + json_path = _transcript_json_path(mp, output_dir) + cached = _load_cached_transcript(json_path) + if cached is not None: + results.append(_result_row(mp, cached)) + continue + + data = transcribe(mp, model_size=model, language=language) + if data is None: + _emit({"type": "error", "message": f"Não foi possível transcrever: {Path(mp).name}"}) + return 1 + + # Diarização opcional (necessita token HF): assina speaker por segmento/palavra. + if token: + tracks = diarize(mp, token, num_speakers) + segments, words = assign_speakers( + data.get("segments", []), data.get("words", []), tracks + ) + data = {**data, "segments": segments, "words": words} + data["speakers"] = build_speakers(data.get("segments", [])) + + payload = { + "schema_version": "1.0", + "source": Path(mp).name, + "model": model, + **data, + } + try: + _save_json_atomic(json_path, payload) + except (OSError, RuntimeError, ValueError) as exc: + _emit({"type": "error", "message": f"Não foi possível salvar o JSON: {exc}"}) + return 1 + results.append(_result_row(mp, data)) + + _emit({"type": "result", "transcripts": results}) + return 0 + + +def cmd_export_srt(args: dict) -> int: + """Write a captions .srt synced to the edited timeline. + + Each transcribed segment is mapped from its SOURCE-media timestamp to its + real TIMELINE position (``clip_offset + (seg_start - clip_source_start)``), + so captions only cover the frames that remain after cuts/silence removal — + not the whole source file. One .srt is produced per media, in timeline order. + """ + path = str(args.get("path", "")) + output_dir = str(args.get("output_dir", "")).strip() + if not path or not Path(path).exists(): + _emit({"ok": False, "error": "Arquivo de projeto não encontrado."}) + return 1 + try: + modifier = FCPXMLModifier(path) + except Exception as exc: + _emit({"ok": False, "error": f"Erro ao ler o projeto: {exc}"}) + return 1 + + # Group spine clips by media so each transcript is loaded once. + by_media: dict[str, list] = {} + for _, el in modifier._iter_spine_clips(): + src = modifier.resources.get(el.get("ref", ""), {}).get("src", "") + mp = media_src_to_path(src) + if not mp or not Path(mp).is_file(): + continue + by_media.setdefault(mp, []).append(el) + + # Never emit a caption past the end of the project — Final Cut rejects an + # SRT whose last cue overruns the timeline ("subtitle extends beyond project + # duration"). Clamp every mapped cue end to this ceiling. + timeline_total = modifier._timeline_duration().to_seconds() + + srt_paths: list[str] = [] + for mp, clips in by_media.items(): + cached = _load_cached_transcript(_transcript_json_path(mp, output_dir)) + if cached is None: + continue + segments = cached.get("segments") or [] + if not segments: + continue + + rows: list[tuple[float, float, str, int]] = [] + for el in clips: + clip_source_start = modifier.source_file_start(el).to_seconds() + clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds() + clip_offset = modifier._parse_time(el.get("offset", "0s")).to_seconds() + window_end = clip_source_start + clip_duration + for seg_index, seg in enumerate(segments): + seg_start = float(seg.get("start", 0.0)) + seg_end = float(seg.get("end", seg_start)) + text = seg.get("text", "").strip() + if not text or seg_end <= seg_start: + continue + # Intersect the complete source segment with this kept clip. + # Testing only seg_start loses speech whose first words fall in + # a removed range; interval intersection preserves the part + # that remains and avoids duplicating a segment wholesale. + source_start = max(seg_start, clip_source_start) + source_end = min(seg_end, window_end) + if source_end <= source_start: + continue + tl_start = clip_offset + (source_start - clip_source_start) + tl_end = clip_offset + (source_end - clip_source_start) + tl_start = max(0.0, min(tl_start, timeline_total)) + tl_end = max(0.0, min(tl_end, timeline_total)) + if tl_end > tl_start: + rows.append((tl_start, tl_end, text, seg_index)) + + if not rows: + continue + rows.sort(key=lambda r: (r[0], r[1], r[3])) + # Merge only pieces from the same original Whisper segment when their + # mapped intervals touch. Never merge unrelated speech or invent time. + merged: list[tuple[float, float, str, int]] = [] + for row in rows: + if merged and row[3] == merged[-1][3] and row[0] <= merged[-1][1] + 0.001: + prev = merged[-1] + merged[-1] = (prev[0], max(prev[1], row[1]), prev[2], prev[3]) + else: + merged.append(row) + + blocks = [] + for index, (s, e, text, _) in enumerate(merged, 1): + start_stamp = srt_stamp(s) + end_stamp = srt_stamp(e) + # Millisecond SRT precision can collapse a sub-millisecond span; + # omit it rather than emit an invalid zero-duration cue. + if start_stamp == end_stamp: + continue + blocks.append(f"{index}\n{start_stamp} --> {end_stamp}\n{text}\n") + if not blocks: + continue + + out = ( + Path(output_dir).expanduser() / f"{Path(mp).stem}_captions.srt" + if output_dir + else Path(mp).with_name(Path(mp).stem + "_captions.srt") + ) + if output_dir: + out.parent.mkdir(parents=True, exist_ok=True) + try: + out.write_text("\n".join(blocks), encoding="utf-8") + except OSError as exc: + _emit({"ok": False, "error": f"Não foi possível salvar a legenda: {exc}"}) + return 1 + srt_paths.append(str(out)) + + if not srt_paths: + _emit({"ok": False, "error": "Nenhuma transcrição encontrada. Transcreva o projeto primeiro."}) + return 1 + + _emit({"ok": True, "paths": srt_paths, "message": f"{len(srt_paths)} legenda(s) .srt sincronizada(s) com o corte."}) + return 0 + + +def srt_stamp(seconds: float) -> str: + """Format float seconds as ``HH:MM:SS,mmm`` (SRT uses a comma). + + Uses ``floor`` (not ``round``) so a timestamp never rounds up past a frame + boundary — an SRT cue ending on the last frame must not overrun the + project duration, or Final Cut flags it as extending beyond the project. + """ + ms = int((seconds if seconds > 0 else 0.0) * 1000) + h, rem = divmod(ms, 3600000) + m, rem = divmod(rem, 60000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _load_cached_transcript(json_path: Path) -> dict | None: + """Return a valid cached transcript dict, or ``None`` if absent/unreadable.""" + if not json_path.is_file(): + return None + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if isinstance(data, dict) and isinstance(data.get("words"), list): + if "speakers" not in data: + data["speakers"] = build_speakers(data.get("segments", [])) + return data + return None + + +def cmd_rename_speakers(args: dict) -> int: + """Apply real names to speakers already saved in a transcript JSON.""" + json_path = Path(str(args.get("path", ""))) + names = args.get("speakers") or {} + if not json_path.is_file(): + _emit({"type": "error", "message": "Transcrição não encontrada."}) + return 1 + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + _emit({"type": "error", "message": f"Não foi possível ler o JSON: {exc}"}) + return 1 + mapping = {str(sid): str(name).strip() for sid, name in (names or {}).items()} + for sp in data.get("speakers", []): + sid = str(sp.get("id", "")) + if sid in mapping and mapping[sid]: + sp["name"] = mapping[sid] + try: + _save_json_atomic(json_path, data) + except (OSError, RuntimeError, ValueError) as exc: + _emit({"type": "error", "message": f"Não foi possível salvar: {exc}"}) + return 1 + _emit({"ok": True, "speakers": data.get("speakers", [])}) + return 0 + + +def cmd_set_diarization(args: dict) -> int: + """Persist the HuggingFace token and expected speaker count for diarization.""" + token = args.get("token") + num = args.get("num_speakers") + if token is not None: + save_hf_token(str(token)) + if num is not None: + save_num_speakers(str(num)) + ok, msg = diarization_capability(load_hf_token()) + _emit({"ok": True, "diarization": ok, "diarization_message": msg, "num_speakers": load_num_speakers()}) + return 0 + + +def _result_row(mp: str, data: dict) -> dict: + words = data.get("words", []) + preview = (data.get("text", "") or "")[:160] + speakers = data.get("speakers") or [] + return { + "media": Path(mp).name, + "language": data.get("language", "?"), + "words": len(words), + "duration": float(data.get("duration", 0.0)), + "preview": preview, + "saved": str(_transcript_json_path(mp)), + "speakers": [s.get("name", s.get("id", "")) for s in speakers], + } + + +def _model_names() -> list[str]: + return [m["internal_name"] for m in load_catalog()] + + +def main() -> int: + args = sys.argv[1:] + if not args: + print("usage: models_api.py [json_args]", file=sys.stderr) + return 1 + command = args[0] + try: + data: dict = json.loads(args[1]) if len(args) > 1 else {} + except json.JSONDecodeError: + print("invalid JSON args", file=sys.stderr) + return 1 + + handlers = { + "catalog": cmd_catalog, + "download": cmd_download, + "cancel": cmd_cancel, + "select": cmd_select, + "set_language": cmd_set_language, + "delete": cmd_delete, + "open_finder": cmd_open_finder, + "set_models_dir": cmd_set_models_dir, + "inspect": cmd_inspect, + "transcribe": cmd_transcribe, + "export_srt": cmd_export_srt, + "remove_silences": cmd_remove_silences, + "edit_by_transcript": cmd_edit_by_transcript, + "remove_filler_words": cmd_remove_filler_words, + "transcript_markers": cmd_transcript_markers, + "generate_dynamic_subtitles": cmd_generate_dynamic_subtitles, + "add_zoom": cmd_add_zoom, + "zoom_clips": cmd_zoom_clips, + "zoom_segments": cmd_zoom_segments, + "rename_speakers": cmd_rename_speakers, + "set_diarization": cmd_set_diarization, + } + handler = handlers.get(command) + if handler is None: + print(f"unknown command: {command}", file=sys.stderr) + return 1 + try: + result = handler(data) + except TypeError: + result = handler() + return result or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/admin/models_gui.py b/admin/models_gui.py new file mode 100644 index 0000000..1b87b11 --- /dev/null +++ b/admin/models_gui.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +"""Transcription model manager — modern macOS UI (Flet), Hex-inspired. + +Two tabs: + - Modelos: manage local Whisper models (download, progress, cancel, delete, + select, open in Finder, configure storage folder). + - Transcrição: pick a model + language, import an FCPXML/.fcpxmld project + (e.g. dragged out of Final Cut Pro), and transcribe its media locally, + writing a _transcript.json next to each media file. + +Backed by the pure-Python ``fcpxml`` package. Run: + uv run python admin/models_gui.py +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import sys +import threading +from pathlib import Path +from typing import Optional + +import flet as ft + +# code/ is the package root for fcpxml modules. +_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") +if _CODE_DIR not in sys.path: + sys.path.insert(0, _CODE_DIR) + +from fcpxml.media_intel import media_src_to_path # noqa: E402 +from fcpxml.model_manager import ( # noqa: E402 + download_model, + get_models_dir, + is_model_downloaded, + list_installed_models, + load_catalog, + load_selected_model, + model_cache_dir, + save_models_dir, + save_selected_model, +) +from fcpxml.parser import parse_fcpxml # noqa: E402 +from fcpxml.transcribe import transcribe # noqa: E402 + +logger = logging.getLogger(__name__) + +# Recommended models (badge) — mirrors Hex's "Suggested" concept. +RECOMMENDED = ("large-v3", "distil-large-v3", "small", "base") + +# Transcription language options. +LANGUAGES = { + "auto": "Detectar automaticamente", + "pt": "Português", + "en": "Inglês", + "es": "Espanhol", + "fr": "Francês", + "de": "Alemão", + "it": "Italiano", + "nl": "Holandês", + "ja": "Japonês", + "ko": "Coreano", + "zh": "Chinês", +} + +# Light theme palette (macOS-like). +ACCENT = "#007AFF" +BG = "#F5F5F7" +CARD = "#FFFFFF" +BORDER = "#E5E5EA" +TEXT = "#1D1D1F" +SUB = "#6E6E73" +GREEN = "#34C759" +RED = "#FF3B30" +PURPLE = "#AF52DE" + + +def _stars(count: int) -> ft.Row: + return ft.Row( + controls=[ + ft.Icon( + ft.Icons.STAR_ROUNDED if i < count else ft.Icons.STAR_OUTLINE_ROUNDED, + size=15, + color=ACCENT if i < count else BORDER, + ) + for i in range(5) + ], + spacing=1, + ) + + +def _transcript_json_path(media_path: str) -> Path: + p = Path(media_path) + return p.with_name(p.stem + "_transcript.json") + + +class ModelManagerApp: + """Flet page controller for the model manager window.""" + + def __init__(self, page: ft.Page) -> None: + self.page = page + self.selected = load_selected_model() + self.downloading: Optional[str] = None + self._cancel_events: dict[str, threading.Event] = {} + self._picker: Optional[ft.FilePicker] = None + + # ── helpers ──────────────────────────────────────────────────────────── + + def _refresh(self) -> None: + self.selected = load_selected_model() + self._rebuild_models_tab() + self.page.update() + + def _refresh_models_content(self) -> ft.Column: + return self._build_model_list() + + def _setup_picker(self) -> None: + if self._picker is not None: + return + self._picker = ft.FilePicker() + self._picker.on_result = self._on_file_picked + self.page.overlay.append(self._picker) + self._pending_target: Optional[dict] = None + + def _on_file_picked(self, e) -> None: + if self._pending_target == "project": + if not e.files: + return + path = e.files[0].path + self._project_path.value = path + self._project_status.value = Path(path).name + self._project_status.color = SUB + elif self._pending_target == "models_dir": + path = getattr(e, "path", None) + if not path: + return + try: + save_models_dir(path) + self._dir_field.value = path + self._dir_status.value = "Pasta de modelos atualizada ✓" + self._dir_status.color = GREEN + except ValueError as err: + self._dir_status.value = str(err) + self._dir_status.color = RED + self._pending_target = None + self.page.update() + + # ── main body (tabs) ─────────────────────────────────────────────────── + + def _rebuild_body(self) -> None: + self._setup_picker() + self.page.controls.clear() + models_tab = ft.Tab( + label="Modelos", + icon=ft.Icons.DATASET_OUTLINED, + ) + models_tab.content = self._build_models_tab() + transcribe_tab = ft.Tab( + label="Transcrição", + icon=ft.Icons.MIC_OUTLINED, + ) + transcribe_tab.content = self._build_transcribe_tab() + self.page.controls.append( + ft.Tabs( + content=[models_tab, transcribe_tab], + length=2, + selected_index=0, + expand=True, + animation_duration=200, + ) + ) + + # ── Modelos tab ──────────────────────────────────────────────────────── + + def _build_models_tab(self) -> ft.Column: + return ft.Column( + controls=[ + self._build_header(), + self._build_settings_card(), + self._build_model_list(), + self._build_footer(), + ], + spacing=14, + expand=True, + ) + + def _rebuild_models_tab(self) -> None: + pass # content rebuilt on demand; simplest via full _rebuild_body + + def _build_header(self) -> ft.Container: + return ft.Container( + content=ft.Row( + controls=[ + ft.Container( + content=ft.Icon(ft.Icons.GRAPHIC_EQ, size=26, color=ft.Colors.WHITE), + width=48, + height=48, + border_radius=12, + bgcolor=ACCENT, + alignment=ft.Alignment(0, 0), + ), + ft.Column( + controls=[ + ft.Text("Modelos de Transcrição", size=22, weight=ft.FontWeight.W_700, color=TEXT), + ft.Text( + "Escolha um modelo local para transcrever seus depoimentos. " + "Mais precisão = mais lento e mais espaço.", + size=13, + color=SUB, + ), + ], + spacing=3, + expand=True, + ), + ], + spacing=14, + ), + padding=ft.Padding(4, 6, 4, 10), + ) + + def _build_footer(self) -> ft.Container: + return ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.LOCK_OUTLINE, size=14, color=SUB), + ft.Text( + "Os modelos rodam localmente nesta máquina. " + "Downloads ficam na pasta configurada acima.", + size=11, + color=SUB, + ), + ], + spacing=6, + ), + padding=ft.Padding(4, 2, 4, 2), + ) + + def _build_settings_card(self) -> ft.Container: + self._dir_field = ft.TextField( + value=str(get_models_dir()), + label="Pasta de modelos", + hint_text="Definida via ícone ao lado", + expand=True, + text_size=13, + border_radius=10, + filled=True, + read_only=True, + ) + self._dir_status = ft.Text( + f"{len(list_installed_models())} instalado(s) · " + f"selecionado: {self.selected or 'nenhum'}", + size=12, + color=SUB, + ) + + def _pick_dir(e) -> None: + self._pending_target = "models_dir" + self._picker.get_directory_path( + dialog_title="Selecionar pasta de modelos", + ) + + def _open_dir(e) -> None: + try: + subprocess.Popen(["open", str(get_models_dir())]) + except OSError: + pass + + return ft.Container( + content=ft.Column( + controls=[ + ft.Row( + controls=[ + ft.Icon(ft.Icons.FOLDER_OUTLINED, size=18, color=SUB), + ft.Text("Local de armazenamento", size=13, weight=ft.FontWeight.W_600, color=TEXT), + ], + spacing=8, + ), + ft.Row( + controls=[ + self._dir_field, + ft.IconButton( + ft.Icons.FOLDER_OPEN, + tooltip="Selecionar pasta", + on_click=_pick_dir, + icon_color=SUB, + ), + ft.IconButton( + ft.Icons.OPEN_IN_NEW, + tooltip="Abrir no Finder", + on_click=_open_dir, + icon_color=SUB, + ), + ], + spacing=4, + ), + self._dir_status, + ], + spacing=10, + ), + padding=16, + border_radius=14, + bgcolor=CARD, + border=ft.Border.all(1, BORDER), + shadow=ft.BoxShadow( + blur_radius=8, + offset=ft.Offset(0, 2), + color=ft.Colors.with_opacity(0.06, ft.Colors.BLACK), + ), + ) + + def _build_model_list(self) -> ft.Column: + installed = set(list_installed_models()) + rows = [] + for catalog in load_catalog(): + name = catalog["internal_name"] + rows.append(self._build_model_card(catalog, name in installed)) + return ft.Column(controls=rows, spacing=10, scroll=ft.ScrollMode.AUTO, expand=True) + + def _build_model_card(self, catalog: dict, installed: bool) -> ft.Container: + name: str = catalog["internal_name"] + display: str = catalog["display_name"] + is_selected = name == self.selected + is_downloading = name == self.downloading + is_recommended = name in RECOMMENDED + + badges = ft.Row(spacing=6) + if is_recommended and not installed: + badges.controls.append(self._badge("Recomendado", ACCENT)) + if installed: + badges.controls.append(self._badge("Instalado", GREEN)) + if is_selected: + badges.controls.append(self._badge("Em uso", PURPLE)) + + progress = ft.ProgressBar(value=0, visible=is_downloading, width=170, color=ACCENT) + pct = ft.Text("0%", size=12, color=SUB, visible=is_downloading) + cancel_btn = ft.TextButton("Cancelar", visible=is_downloading, style=ft.ButtonStyle(color=RED)) + + def _cancel(name: str) -> None: + ev = self._cancel_events.get(name) + if ev is not None: + ev.set() + + cancel_btn.on_click = lambda e, n=name: _cancel(n) + + def _make_download(name: str): + def handler(e) -> None: + ev = threading.Event() + self._cancel_events[name] = ev + self.downloading = name + progress.visible = True + pct.visible = True + cancel_btn.visible = True + self.page.update() + threading.Thread( + target=_run_download, args=(name, progress, pct, cancel_btn, ev), daemon=True + ).start() + + return handler + + def _run_download(name, progress, pct, cancel_btn, cancel_event) -> None: + def on_progress(fraction: float) -> None: + try: + progress.value = fraction + pct.value = f"{int(fraction * 100)}%" + self.page.update() + except Exception: + pass + + download_model(name, progress_cb=on_progress, cancel_event=cancel_event) + self._cancel_events.pop(name, None) + self.downloading = None + installed_now = is_model_downloaded(name) + if installed_now: + save_selected_model(name) + self.selected = load_selected_model() + try: + self._rebuild_body() + self.page.update() + except Exception: + pass + + controls_row = ft.Row(spacing=6) + if is_downloading: + controls_row.controls.extend([progress, pct, cancel_btn]) + elif installed: + if is_selected: + controls_row.controls.append( + ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINED, size=16, color=GREEN), + ft.Text("Em uso", size=13, weight=ft.FontWeight.W_600, color=GREEN), + ], + spacing=4, + ), + padding=ft.Padding(10, 6, 10, 6), + border_radius=8, + border=ft.Border.all(1, GREEN), + ) + ) + else: + controls_row.controls.append( + ft.FilledButton( + "Selecionar", + on_click=lambda e, n=name: self._select(n), + style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE), + ) + ) + controls_row.controls.append( + ft.IconButton( + ft.Icons.FOLDER_OPEN, + tooltip="Mostrar no Finder", + on_click=lambda e, n=name: self._open_model(n), + icon_color=SUB, + ) + ) + controls_row.controls.append( + ft.IconButton( + ft.Icons.DELETE_OUTLINE, + tooltip="Remover download", + on_click=lambda e, n=name: self._delete(n), + icon_color=RED, + ) + ) + else: + controls_row.controls.append( + ft.FilledButton( + "Download", + on_click=_make_download(name), + style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE), + ) + ) + + card_border = ( + ft.Border.all(1.4, ACCENT) if is_recommended and not installed else ft.Border.all(1, BORDER) + ) + + return ft.Container( + content=ft.Column( + controls=[ + ft.Row( + controls=[ + ft.Text(display, size=16, weight=ft.FontWeight.W_600, color=TEXT, expand=True), + badges, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + ft.Row( + controls=[ + ft.Icon(ft.Icons.PUBLIC_OUTLINED, size=13, color=SUB), + ft.Text(catalog.get("size", ""), size=12, color=SUB), + ft.Text("·", size=12, color=BORDER), + ft.Icon(ft.Icons.STORAGE_OUTLINED, size=13, color=SUB), + ft.Text(catalog.get("storage", ""), size=12, color=SUB), + ], + spacing=6, + ), + ft.Row( + controls=[ + _stars(int(catalog.get("accuracy", 0))), + ft.Text("Precisão", size=11, color=SUB), + ft.Container(width=14), + _stars(int(catalog.get("speed", 0))), + ft.Text("Velocidade", size=11, color=SUB), + ], + spacing=6, + ), + ft.Row( + controls=[controls_row], + alignment=ft.MainAxisAlignment.END, + ), + ], + spacing=10, + ), + padding=16, + border_radius=14, + bgcolor=ft.Colors.with_opacity(0.55, CARD) if installed else CARD, + border=card_border, + shadow=ft.BoxShadow( + blur_radius=8, + offset=ft.Offset(0, 2), + color=ft.Colors.with_opacity(0.05, ft.Colors.BLACK), + ), + ) + + def _badge(self, text: str, color: str) -> ft.Container: + return ft.Container( + content=ft.Text(text, size=10, weight=ft.FontWeight.W_700, color=color), + padding=ft.Padding(8, 3, 8, 3), + border_radius=8, + bgcolor=ft.Colors.with_opacity(0.12, color), + ) + + def _select(self, name: str) -> None: + if is_model_downloaded(name): + save_selected_model(name) + self._rebuild_body() + self.page.update() + + def _delete(self, name: str) -> None: + import shutil + + try: + shutil.rmtree(model_cache_dir(name), ignore_errors=True) + except Exception: + pass + self._rebuild_body() + self.page.update() + + def _open_model(self, name: str) -> None: + try: + subprocess.Popen(["open", str(model_cache_dir(name))]) + except OSError: + pass + + # ── Transcrição tab ──────────────────────────────────────────────────── + + def _build_transcribe_tab(self) -> ft.Column: + # Model selection: installed models first, then all. + installed = list_installed_models() + model_options = [ + ft.dropdown.Option(m, text=m) for m in installed + ] + for catalog in load_catalog(): + m = catalog["internal_name"] + if m not in installed: + model_options.append(ft.dropdown.Option(m, text=m)) + + model_default = self.selected if self.selected in installed else (installed[0] if installed else None) + model_dd = ft.Dropdown( + label="Modelo", + options=model_options, + value=model_default, + expand=True, + text_size=13, + border_radius=10, + ) + lang_dd = ft.Dropdown( + label="Idioma", + options=[ft.dropdown.Option(k, text=v) for k, v in LANGUAGES.items()], + value="auto", + expand=True, + text_size=13, + border_radius=10, + ) + + self._project_path = ft.TextField( + label="Projeto FCPXML", + hint_text="Arraste o arquivo .fcpxml / .fcpxmld ou selecione abaixo", + expand=True, + read_only=True, + text_size=13, + border_radius=10, + ) + self._project_status = ft.Text("Nenhum projeto selecionado", size=12, color=SUB) + + def _pick_project(e) -> None: + self._pending_target = "project" + self._picker.pick_files( + dialog_title="Selecionar projeto FCPXML", + allow_multiple=False, + allowed_extensions=["fcpxml", "fcpxmld", "xml"], + ) + + progress_bar = ft.ProgressBar(value=0, visible=False, color=ACCENT) + status = ft.Text("", size=12, color=SUB) + result_box = ft.Container( + content=ft.Column( + controls=[ft.Text("", size=13, color=TEXT)], + spacing=8, + ), + visible=False, + padding=14, + border_radius=10, + bgcolor=CARD, + border=ft.Border.all(1, BORDER), + ) + + def _transcribe_run(e) -> None: + proj_path = self._project_path.value + if not proj_path: + status.value = "Selecione um projeto FCPXML primeiro." + status.color = RED + self.page.update() + return + model = model_dd.value or "base" + lang = lang_dd.value + if lang == "auto": + lang = None + status.value = f"Transcrevendo com {model}… isso pode levar alguns minutos." + status.color = SUB + progress_bar.visible = True + progress_bar.value = 0 + result_box.visible = False + self.page.update() + threading.Thread( + target=_run, args=(proj_path, model, lang, progress_bar, status, result_box), daemon=True + ).start() + + def _run(proj_path, model, lang, progress_bar, status, result_box) -> None: + try: + proj = parse_fcpxml(proj_path) + except Exception as exc: + _set_status(f"Erro ao ler o projeto: {exc}", RED) + return + tl = proj.primary_timeline or (proj.timelines[0] if proj.timelines else None) + if tl is None or not getattr(tl, "clips", None): + _set_status("Nenhum clip de mídia encontrado no projeto.", RED) + return + media_paths = [] + for clip in tl.clips: + mp = media_src_to_path(clip.media_path or "") + if mp and Path(mp).is_file(): + if mp not in media_paths: + media_paths.append(mp) + if not media_paths: + _set_status("Nenhum arquivo de mídia acessível encontrado.", RED) + return + + total = len(media_paths) + results = [] + for i, mp in enumerate(media_paths, 1): + _set_progress(i / total) + json_path = _transcript_json_path(mp) + if json_path.is_file(): + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("words"), list): + results.append((mp, data)) + continue + except (OSError, ValueError): + pass + data = transcribe(mp, model_size=model, language=lang) + if data is None: + _set_status(f"Não foi possível transcrever: {Path(mp).name}", RED) + return + try: + json_path.write_text( + json.dumps({"source": Path(mp).name, **data}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError as exc: + _set_status(f"Não foi possível salvar o JSON: {exc}", RED) + return + results.append((mp, data)) + + # Render summary. + lines = [f"Transcrição concluída — {len(results)} arquivo(s)."] + total_words = 0 + for mp, data in results: + nw = len(data.get("words", [])) + total_words += nw + lines.append(f"• {Path(mp).name} — {nw} palavras") + lines.append(f"\nTotal: {total_words} palavras.") + result_box.content.controls[0].value = "\n".join(lines) + result_box.visible = True + _set_progress(1.0) + _set_status("Concluído ✓. Transcrições salvas como _transcript.json ao lado de cada mídia.") + + def _set_progress(v: float) -> None: + try: + progress_bar.value = v + self.page.update() + except Exception: + pass + + def _set_status(msg: str, color: str) -> None: + try: + status.value = msg + status.color = color + progress_bar.visible = False + self.page.update() + except Exception: + pass + + return ft.Column( + controls=[ + self._build_transcribe_header(), + ft.Container( + content=ft.Column( + controls=[ + ft.Text("Configuração", size=13, weight=ft.FontWeight.W_600, color=TEXT), + ft.Row(controls=[model_dd, lang_dd], spacing=10), + ft.Row( + controls=[ + self._project_path, + ft.FilledButton( + "Procurar…", + on_click=_pick_project, + style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE), + ), + ], + spacing=8, + ), + self._project_status, + progress_bar, + ft.FilledButton( + "Transcrever", + on_click=_transcribe_run, + style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE), + ), + status, + result_box, + ], + spacing=12, + ), + padding=18, + border_radius=14, + bgcolor=CARD, + border=ft.Border.all(1, BORDER), + shadow=ft.BoxShadow( + blur_radius=8, + offset=ft.Offset(0, 2), + color=ft.Colors.with_opacity(0.06, ft.Colors.BLACK), + ), + ), + ], + spacing=14, + expand=True, + ) + + def _build_transcribe_header(self) -> ft.Container: + return ft.Container( + content=ft.Row( + controls=[ + ft.Container( + content=ft.Icon(ft.Icons.MIC, size=26, color=ft.Colors.WHITE), + width=48, + height=48, + border_radius=12, + bgcolor=ACCENT, + alignment=ft.Alignment(0, 0), + ), + ft.Column( + controls=[ + ft.Text("Transcrição", size=22, weight=ft.FontWeight.W_700, color=TEXT), + ft.Text( + "Importe seu projeto do Final Cut Pro, escolha modelo e idioma, " + "e transcreva o depoimento localmente.", + size=13, + color=SUB, + ), + ], + spacing=3, + expand=True, + ), + ], + spacing=14, + ), + padding=ft.Padding(4, 6, 4, 10), + ) + + +def main(page: ft.Page) -> None: + page.title = "Modelos de Transcrição" + page.window.width = 660 + page.window.height = 800 + page.window.min_width = 480 + page.window.min_height = 480 + page.theme_mode = ft.ThemeMode.LIGHT + page.bgcolor = BG + page.padding = 20 + page.spacing = 14 + + app = ModelManagerApp(page) + app._rebuild_body() + + +if __name__ == "__main__": + ft.app(main) diff --git a/admin/run_app.command b/admin/run_app.command new file mode 100755 index 0000000..6354248 --- /dev/null +++ b/admin/run_app.command @@ -0,0 +1,38 @@ +#!/bin/bash +# +# run_app.command — Compila e executa o app G-ART localmente (macOS). +# +# Uso: +# ./admin/run_app.command # compila e abre o app +# +# Requer: Xcode Command Line Tools (swiftc/xcrun) instalados. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +APP_NAME="GArt" + +echo "==> Finalizando instância existente do app ($APP_NAME)..." +osascript -e 'tell application "System Events" to set pids to (unix id of every process whose name is "'"$APP_NAME"'")' 2>/dev/null && \ + osascript -e 'tell application "'"$APP_NAME"'" to quit' 2>/dev/null || true +pkill -f "$ROOT_DIR/code/MacApp/build/$APP_NAME.app" 2>/dev/null || true +sleep 1 + +echo "==> Compilando o app (GArt)..." +"$ROOT_DIR/code/MacApp/build_app.sh" + +echo "==> Abrindo o app localmente..." +open "$ROOT_DIR/code/MacApp/build/GArt.app" + +echo "==> App G-ART iniciado. Fechando o Terminal..." +# Fecha a janela do Terminal de forma destacada: o processo é desacoplado do +# shell da janela (nohup + disown) e o script encerra antes, para que o aviso +# "finalizar processos nesta janela" (bash/osascript) não apareça. +( + sleep 2 + osascript -e 'tell application "Terminal" to close (every window whose name contains "run_app")' >/dev/null 2>&1 +) & +disown || true +exit 0 diff --git a/admin/test_models_api.py b/admin/test_models_api.py new file mode 100644 index 0000000..2e696db --- /dev/null +++ b/admin/test_models_api.py @@ -0,0 +1,243 @@ +"""Tests for admin/models_api.py — the SwiftUI JSON bridge commands. + +Focused on the transcription-flow changes: atomic save, speaker renaming, and +the "use the selected model" default plus model-availability guard. +""" + +import json + +import admin.models_api as api + + +def _capture(monkeypatch): + captured: list[dict] = [] + + def _emit(obj): + captured.append(obj) + + monkeypatch.setattr(api, "_emit", _emit) + return captured + + +def test_save_json_atomic(tmp_path): + p = tmp_path / "t.json" + api._save_json_atomic(p, {"a": [1, 2], "text": "olá"}) + assert p.exists() + assert not (tmp_path / "t.json.tmp").exists() + assert json.loads(p.read_text(encoding="utf-8"))["text"] == "olá" + + +def test_rename_speakers(tmp_path, monkeypatch): + captured = _capture(monkeypatch) + p = tmp_path / "t.json" + p.write_text( + json.dumps( + { + "speakers": [ + {"id": "SPEAKER_00", "name": "Speaker 1"}, + {"id": "SPEAKER_01", "name": "Speaker 2"}, + ] + } + ), + encoding="utf-8", + ) + assert api.cmd_rename_speakers({"path": str(p), "speakers": {"SPEAKER_01": "Erika"}}) == 0 + assert captured[0]["ok"] is True + saved = json.loads(p.read_text(encoding="utf-8")) + assert saved["speakers"][0]["name"] == "Speaker 1" + assert saved["speakers"][1]["name"] == "Erika" + + +def test_rename_speakers_missing_file(monkeypatch): + captured = _capture(monkeypatch) + assert api.cmd_rename_speakers({"path": "/nonexistent/x.json"}) == 1 + assert captured[0]["type"] == "error" + + +def test_transcribe_requires_output_dir(monkeypatch): + captured = _capture(monkeypatch) + monkeypatch.setattr(api, "load_selected_model", lambda: "small") + monkeypatch.setattr(api, "is_model_downloaded", lambda m: True) + assert api.cmd_transcribe({"path": "/some/project.fcpxml"}) == 1 + assert captured[0]["type"] == "error" + assert "pasta do projeto" in captured[0]["message"] + + +def test_transcribe_requires_installed_model(monkeypatch, tmp_path): + captured = _capture(monkeypatch) + monkeypatch.setattr(api, "load_selected_model", lambda: "") + monkeypatch.setattr(api, "is_model_downloaded", lambda m: False) + assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1 + assert captured[0]["type"] == "error" + assert "instalado" in captured[0]["message"] + + +def test_transcribe_defaults_to_selected_model(monkeypatch, tmp_path): + captured = _capture(monkeypatch) + monkeypatch.setattr(api, "load_selected_model", lambda: "small") + monkeypatch.setattr(api, "is_model_downloaded", lambda m: m == "small") + + class FakeTL: + clips = [] + + class FakeProject: + primary_timeline = None + timelines = [FakeTL()] + + monkeypatch.setattr(api, "parse_fcpxml", lambda p: FakeProject()) + # No media accessible -> reaches the media-path check (past model validation). + assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1 + assert captured[0]["type"] == "error" + assert "mídia" in captured[0]["message"] + + +def test_set_language_persists(monkeypatch): + captured = _capture(monkeypatch) + assert api.cmd_set_language({"language": "pt"}) == 0 + assert captured[0]["ok"] is True + assert captured[0]["language"] == "pt" + assert api.load_transcript_language() == "pt" + + +def test_set_language_rejects_unknown(monkeypatch): + captured = _capture(monkeypatch) + assert api.cmd_set_language({"language": "xx"}) == 1 + assert captured[0]["ok"] is False + assert "language" in captured[0]["error"] + + +def test_transcribe_defaults_language_to_persisted(monkeypatch, tmp_path): + monkeypatch.setattr(api, "load_selected_model", lambda: "small") + monkeypatch.setattr(api, "is_model_downloaded", lambda m: m == "small") + monkeypatch.setattr(api, "load_transcript_language", lambda: "pt") + + media = tmp_path / "clip.mov" + media.write_bytes(b"fake") + + class FakeClip: + media_path = "" + + class FakeTL: + clips = [FakeClip()] + + class FakeProject: + primary_timeline = None + timelines = [FakeTL()] + + monkeypatch.setattr(api, "parse_fcpxml", lambda p: FakeProject()) + monkeypatch.setattr(api, "media_src_to_path", lambda mp: str(media)) + called = {} + monkeypatch.setattr( + api, "transcribe", lambda mp, model_size, language, **kw: called.update(lang=language) + ) + assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path / "out")}) == 1 + assert called["lang"] == "pt" + + +def test_srt_stamp_format(): + assert api.srt_stamp(0.0) == "00:00:00,000" + assert api.srt_stamp(1.5) == "00:00:01,500" + assert api.srt_stamp(3661.234) == "01:01:01,234" + + +_FCPXML_SAMPLE = """ + + + + + + + + + + + + + + + + + + + + +""" + + +def test_cmd_export_srt_maps_to_edited_timeline(tmp_path, monkeypatch): + """Captions must reflect the EDITED timeline, not the whole source file.""" + captured = _capture(monkeypatch) + project = tmp_path / "proj.fcpxml" + project.write_text(_FCPXML_SAMPLE, encoding="utf-8") + media = tmp_path / "clip.mp4" + media.write_bytes(b"fake") + # Transcript covers 0..100s; the clip only USES source 10..20s -> timeline 0..10s. + transcript = { + "words": [], + "segments": [ + {"start": 5.0, "end": 6.0, "text": "antes do corte"}, + {"start": 12.0, "end": 14.0, "text": "dentro do corte"}, + {"start": 50.0, "end": 51.0, "text": "depois do corte"}, + ] + } + tj = api._transcript_json_path(media) + tj.parent.mkdir(parents=True, exist_ok=True) + api._save_json_atomic(tj, transcript) + monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media)) + + assert api.cmd_export_srt({"path": str(project)}) == 0 + assert captured[0]["ok"] is True + srt = tmp_path / "clip_captions.srt" + assert srt.exists() + text = srt.read_text(encoding="utf-8") + # Only the segment inside the used source window (12s) survives. + assert "dentro do corte" in text + assert "antes do corte" not in text + assert "depois do corte" not in text + # Mapped to timeline 0..10s -> the 12s source segment lands at 2s. + assert "00:00:02,000 --> 00:00:04,000" in text + + +def test_cmd_export_srt_no_transcript(tmp_path, monkeypatch): + captured = _capture(monkeypatch) + project = tmp_path / "proj.fcpxml" + project.write_text(_FCPXML_SAMPLE, encoding="utf-8") + media = tmp_path / "clip.mp4" + media.write_bytes(b"fake") + monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media)) + assert api.cmd_export_srt({"path": str(project)}) == 1 + assert captured[0]["ok"] is False + + +def test_cmd_export_srt_clamps_past_project_duration(tmp_path, monkeypatch): + """A segment ending after the last clip must be clamped to the project end. + + Final Cut rejects an SRT whose final cue overruns the timeline + ("subtitle extends beyond project duration"). + """ + captured = _capture(monkeypatch) + project = tmp_path / "proj.fcpxml" + project.write_text(_FCPXML_SAMPLE, encoding="utf-8") + media = tmp_path / "clip.mp4" + media.write_bytes(b"fake") + # Clip uses source 10..20s -> timeline 0..10s. A segment 12..30s maps to + # timeline 2..20s, but the project only lasts 10s: must clamp end to 10s. + transcript = { + "words": [], + "segments": [ + {"start": 12.0, "end": 30.0, "text": "longa fala"}, + ] + } + tj = api._transcript_json_path(media) + tj.parent.mkdir(parents=True, exist_ok=True) + api._save_json_atomic(tj, transcript) + monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media)) + + assert api.cmd_export_srt({"path": str(project)}) == 0 + assert captured[0]["ok"] is True + srt = tmp_path / "clip_captions.srt" + text = srt.read_text(encoding="utf-8") + # Timeline is 10s; the cue must not end past it. + assert "00:00:02,000 --> 00:00:10,000" in text + assert "00:00:20,000" not in text diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md new file mode 100755 index 0000000..2e7ec9f --- /dev/null +++ b/code/CHANGELOG.md @@ -0,0 +1,1104 @@ +# Changelog + +## [Unreleased] + +**Repo renamed `fcpxml-mcp-server` → `fcp-mcp-server`** to match the PyPI +distribution name. The GitHub *About* link had been pointing at +`pypi.org/project/fcpxml-mcp-server/` — a slug that never existed on PyPI — so +every visitor who clicked it got a 404 while `uvx fcp-mcp-server` worked fine. +Homepage now points at the real package; clone URLs, CI badge, and +`[project.urls]` follow the new slug. GitHub redirects the old slug, so existing +clones, forks, and links keep working. + +The MCP registry identity stays `io.github.DareDev256/fcpxml-mcp-server`, +unchanged — it is bound to the `mcp-name` marker inside the *published* 0.13.1 +PyPI README, and changing it would orphan the registry entry and require a new +PyPI release. Registry name ≠ install name is legal and intentional. + +No code changes; 0.13.1 on PyPI is untouched. + +## [0.13.1] - 2026-07-24 + +Registry release. Adds the `mcp-name` ownership marker to the README (required +by the official MCP registry to bind the PyPI package to +`io.github.DareDev256/fcpxml-mcp-server`) and trims `server.json`'s description +to the registry's 100-char limit. No code changes. + +## [0.13.0] - 2026-07-23 + +**Transcript Intelligence** — text-based editing lands. 59 → 62 tools. + +Apple put FCP's AI (Transcript Search, Generate Captions) behind the Creator +Studio subscription; this release brings the agentic version to everyone, free, +via local Whisper — and goes further: the transcript doesn't just *search*, it +*cuts*. + +### Added +- **`transcribe_media`** — transcribes each clip's source media locally with + word-level timestamps (faster-whisper, new optional `[transcribe]` extra). + Writes a `_transcript.json` next to each media file — transcription is a + one-time cost, reused by every transcript tool. Optional `write_srt` emits + an SRT that plugs straight into `import_srt_markers`. +- **`edit_by_transcript`** — cut timeline content by what was SAID. + `mode=remove` cuts every occurrence of the given phrases with ripple; + `mode=keep_only` keeps only the matched phrases (clips with no matches are + left untouched — never deletes a clip because nothing matched). Matching is + case/punctuation-insensitive. Non-destructive `_transcript_edit` copy. +- **`remove_filler_words`** — cuts um/uh/erm out of the timeline with ripple + using word-level timestamps from the real source audio. The default filler + list is deliberately conservative: words like "like" and "so" are speech, + not noise, and are only cut when passed explicitly. +- New `fcpxml/transcribe.py` module: pure, dependency-free matching helpers + (`find_phrase_spans`, `find_filler_spans`, `merge_ranges`, `invert_ranges`, + `segments_to_srt`) + the faster-whisper integration behind the same + graceful-degradation contract as ffmpeg/librosa (returns `None` → tools + answer with an install hint, never a crash). +- 42 new tests (1032 total): span matching, range algebra, keep_only inversion + edge cases, degradation without faster-whisper, and full handler integration + against cached transcripts (head-trim vs split behavior, SRT output, + transcription caps, missing-media reporting). + +### Notes +- Whisper model names are allowlist-validated (they resolve to downloads). +- Per-call transcription is capped at 10 distinct media files; cached + transcripts don't count against the cap. + +## [0.12.2] - 2026-07-23 + +Distribution release — the server is now on PyPI. No tool changes. + +### Added +- **Published to PyPI as [`fcp-mcp-server`](https://pypi.org/project/fcp-mcp-server/).** + `uvx fcp-mcp-server` now works, which unbreaks the install path that `server.json` + (official MCP registry) and `smithery.yaml` have been advertising, and makes the + `[intelligence]` extra installable without cloning. +- **Claude Code install path** in the README (`claude mcp add` one-liner + project-scoped + `.mcp.json` example) alongside the existing Claude Desktop instructions. +- **"How It Compares" section** — honest trade-off table vs SpliceKit (runtime patching) + and CommandPost (accessibility scripting): raw live power there, no-patch portability, + managed-Mac compatibility, and works-without-FCP here. +- Security posture surfaced at the top of the README (132 adversarial-input tests, + defusedxml, sandboxed writes, disclosure channel). + +### Fixed +- **Packaging: `server.py` was missing from the wheel.** `[tool.setuptools]` only + included the `fcpxml*` and `tools*` packages, so a built wheel had no entry-point + module and `fcp-mcp-server` failed to launch. Added `py-modules = ["server"]`, + dropped the empty `tools` stub package from the distribution, and verified the + wheel end-to-end in a clean venv (MCP initialize handshake answers correctly). +- **Server now reports its own version** over MCP (`serverInfo.version` said `1.28.1` — + the SDK's version — instead of the package's). +- `server.json` refreshed: version 0.9.0 → 0.12.2, tool count 56 → 59, description + includes media intelligence. +- `pyproject.toml` URLs point at the canonical repo (`fcpxml-mcp-server`), and a + Changelog URL was added. + +## [0.12.1] - 2026-07-16 + +Docs fix. No code changes. + +### Fixed +- **`[intelligence]` install command didn't work.** The README told users to run + `pip install 'fcp-mcp-server[intelligence]'` to enable `detect_beats`, but the + package isn't published to PyPI — in a clean venv that errors with + *"No matching distribution found for fcp-mcp-server[intelligence]"*. Since the + documented install flow is `git clone` + `pip install -e .`, the extra is now + `pip install -e '.[intelligence]'`, with a note that it must run from the cloned + repo. This blocked the headline feature of v0.12.0. +- **Clone URL and paths use the canonical repo name.** `git clone .../fcp-mcp-server.git` + still resolves via GitHub's rename redirect, but it created a directory whose name + didn't match the `cd` on the next line, and the `/path/to/fcp-mcp-server` placeholders + in the Claude Desktop config didn't match what `git clone` produces. (The *package* + name in `pyproject.toml` is legitimately `fcp-mcp-server` — only the repo is + `fcpxml-mcp-server`. Left alone.) + +### Verified unchanged +Audited every headline claim against the source — all accurate, nothing to correct: +59 tools (all 59 documented, 0 undocumented), 990 tests (pytest collects exactly 990), +23 suites, v0.12.0 consistent across pyproject/CHANGELOG/README. + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.12.0] - 2026-07-09 + +### Added — Media Intelligence slice 3: beat detection + +- **`detect_beats` (59th tool)** — detects musical beats and tempo in an audio/video file via librosa's beat tracker and writes a beats JSON next to the media file in exactly the format `import_beat_markers` consumes, so *detect → mark → snap-to-beats* chains with zero glue. Analysis duration capped (20 min) to bound memory; media path validated against an audio/video extension whitelist. +- **`[intelligence]` optional extra** — `pip install 'fcp-mcp-server[intelligence]'` adds librosa. The core install stays 2 dependencies; without the extra, `detect_beats` degrades to an install hint (lazy import, never crashes). CI installs it so beat tests run on every push. + +### Fixed + +- **`import_beat_markers` no longer crashes when beats run past the timeline end** — songs are routinely longer than edits; out-of-range beats are now skipped and counted in the report instead of raising `No spine clip at position`. Found by end-to-end verification of the detect → import → snap chain. + +Tests: 984 → 990. + +## [0.11.0] - 2026-07-09 + +### Added — Media Intelligence slice 2: silence auto-removal + +- **`remove_media_silence` (58th tool)** — detects real silence in each clip's source audio (same ffmpeg analysis as `detect_media_silence`) and **cuts it out of the timeline with ripple**: clips are split around silence, silent middles removed, everything after shifts earlier. `padding` (default 0.05s) keeps a breath of silence on each side of every cut; cut boundaries snap to the frame grid in the 2400-tick timebase. Non-destructive — writes a `_silence_removed` copy, and writes nothing at all when no silence is found. +- **`FCPXMLModifier.cut_clip_ranges`** — new element-based writer primitive: removes clip-relative time ranges from a spine clip (merging overlapping ranges, clamping out-of-bounds), rebuilds the clip as its kept segments with correct source in-points, filters markers/keywords per segment, and ripples subsequent clips. Element-based on purpose — immune to the duplicate-name ambiguity that name-keyed `delete_clip`/`split_clip` composition would hit when cutting a clip into same-named segments. + +Tests: 976 → 984. + +## [0.10.0] - 2026-07-09 + +### Added — Media Intelligence v1 (the moat work begins) + +First slice of the v0.10 media-intelligence roadmap: the server now analyzes the **actual media files** a timeline references, not just the XML. + +- **`detect_media_silence` (57th tool)** — probes each clip's source audio with ffmpeg's `silencedetect` filter and maps silence ranges from source time into **timeline time**, reporting per-clip silence spans with a cut plan. Unlike `detect_silence_candidates` (XML-only heuristics: gaps, name patterns), this hears the audio. Supports `noise_db` threshold (−120..0 dB), `min_silence` duration, and per-clip filtering; media files are probed once and cached across clips that share them; missing/unreadable media is reported per clip, never fatal. +- **`fcpxml/media_intel.py`** — new module for real media analysis. Zero new Python dependencies: ffmpeg runs as a bounded subprocess (list-form args, validated numeric parameters, 120s hard timeout, 100-file probe cap) and everything degrades gracefully — no ffmpeg means "unanalyzable", not a crash. +- **CI now installs ffmpeg** so the real-WAV integration tests (tone/silence/tone fixtures generated with the stdlib `wave` module) run on every push; they skip automatically on machines without ffmpeg. + +Tests: 958 → 976 across 23 suites. + +## [0.9.1] - 2026-07-09 + +### Security + +- **`apply_template` write sandbox bypass fixed** — the one generation handler that built a timeline from scratch (no input file to anchor against) called `_validate_output_path()` without an `anchor_dir`, which skipped the sandbox check entirely and accepted absolute or `../` output paths. An LLM-steered call could overwrite an arbitrary user-writable file. Now anchored to `FCP_PROJECTS_DIR` like every other write handler. Reported and fixed by [@mikegrant25](https://github.com/mikegrant25) (#6). +- **`SECURITY.md` added** and GitHub private vulnerability reporting enabled — future disclosures have a private channel. + +### Fixed + +- **`add_audio` / `add_music_bed` stamped the requested clip duration onto new `` elements** without reading the media file — a music bed shorter than the timeline produced an asset claiming more media than the file contains (invalid FCPXML, clip overruns the real audio). New `_probe_audio_info()` reads the real duration, sample rate, and channel count via `ffprobe` (stdlib `wave` fallback for `.wav`); assets carry sample-accurate durations plus `audioRate`/`audioChannels`/`audioSources`, and clip durations are clamped to available media. Unprobeable sources keep the old behavior. Fixed by [@jardelapp](https://github.com/jardelapp) (#7). +- Docs reconciled to verified test counts (#5). + +Tests: 955 → 958. + +## [0.9.0] - 2026-06-11 + +### Added — Live Mode v1 (the dual-mode roadmap goes live) + +This is the first release where the server can drive a **running** Final Cut Pro, not just edit XML offline — using Apple's officially-supported surfaces only (no injection, no private APIs). Both tools were **live-verified end to end against Final Cut Pro 12.2**. + +- **`push_to_fcp` (55th tool)** — send an FCPXML file into the running FCP with zero clicks via the Open Document Apple event. Injects an `` element (library location, suppress warnings, copy assets), launches FCP if needed, and never touches your original (flat files get an options-injected sibling copy through the same write sandbox as every other tool). Live-verified: generated a timeline, pushed it, and confirmed the library/event/project landed both in FCP and on disk. +- **`list_fcp_libraries` (56th tool)** — enumerate the running FCP's open libraries → events → projects via Apple's read-only scripting dictionary. Refuses to launch FCP unless `allow_launch=true`. +- **`fcpxml/live.py`** + **`tests/test_live.py`** (13 tests, osascript fully mocked so CI never launches FCP). + +### Findings baked in from live testing + +- **Zero-click import requires a `.fcpbundle` library location.** With a new `.fcpbundle` path FCP silently creates the library + a dated event and imports; with no location (or a `.fcplibrary`/bare path) FCP raises a modal **Open Library** picker — a *required choice* that `suppress warnings` does not dismiss — which blocks the Apple event. `push_to_fcp` now normalizes the location to `.fcpbundle`. +- **Apple offers no programmatic export** — the read-back leg of any edit loop still needs File > Export XML; the tool says so in its output. +- Importing a project whose media already exists in the target library fails on a media-identity collision — push into a fresh library or reuse FCP's existing asset IDs. + +Tests: 942 → 955. + +## [0.8.0] - 2026-06-11 + +### Added + +- **FCPXML 1.12–1.14 support**: parser now reads everything Final Cut Pro 12.x exports (FCPXML 1.14). Elements introduced after 1.11 (`adjust-stereo-3D`, `hidden-clip-marker`, smart-collection `match-analysis-type`, …) are tolerated on read and preserved losslessly through edits. Generated timelines (templates, rough cuts, FCPXMLWriter) now emit **1.13** by default; modified files keep their source version. +- **`.fcpxmld` bundle support, end to end**: bundles (directories wrapping `Info.fcpxml` plus sidecar data) now work in every tool. `FCPXMLModifier` loads bundles, and `save()` writes bundle outputs with **sidecar preservation** — object-tracking and Cinematic-mode `dataLocator` payloads are copied across the round-trip instead of silently destroyed. Fixed `_validate_filepath` rejecting bundles outright (they are directories, and the previous "regular file" check made the whitelisted `.fcpxmld` extension unreachable). +- **`relink_media` tool (54th tool)**: bulk-rewrite `asset`/`media-rep` `src` paths by prefix — relink a moved or renamed media drive without opening FCP. Handles `file://` URLs (percent-encoding preserved) and plain paths, matches whole path segments only, reports whether each new target exists on disk, and supports `dry_run` preview. +- **DTD validation** (`fcpxml/dtd.py` + `tests/test_dtd_validation.py`): generated output is validated against **Apple's official DTDs** located inside the installed Final Cut Pro app bundle (the only authoritative FCPXML spec — Apple's online docs stopped at 1.10). Skips gracefully on machines without FCP; `FCPXML_DTD_DIR` overrides the search path. Found and worked around an xmllint quirk where the space in "Final Cut Pro.app" breaks DTD URI resolution. +- **Capability audit + dual-mode roadmap** (`docs/CAPABILITY-AUDIT-2026-06.md`): verified June-2026 ecosystem analysis (FCP 12.2 control surfaces, SpliceKit, CommandPost, format ceiling) and the XML-mode + Live-mode architecture plan through v1.0. + +### Fixed + +- README/CLAUDE.md drift: tool count, test counts, FCPXML version matrix, phantom `fcpxml/README.md` and `OPENAI_BASE_URL` references removed. + +### Known + +- `examples/sample.fcpxml` is not DTD-conformant (pre-`media-rep` asset form, sequence-level chapter markers) — documented by a dedicated test; fixture modernization planned. +- Total: 912 → 942 tests across 21 suites. + +## [0.7.0] - 2026-05-02 + +### Added / Fixed + +- Version milestone consolidating the April hardening waves (no API changes). +- Security hardening with `defusedxml`. +- Duplicate clip name bug fixes. +- Added 100+ new tests. +- Refactored helper functions for cleaner logic. +- Unification of XML serialization. +- TimeValue arithmetic fixes (integer-exact comparison via cross-multiplication with normalized negative denominators). +- Output-path sandbox enforcement plus speed/ffmpeg parameter validation. +- Stale `timeMap`/conform-rate stripping in `change_speed`. +- Marker/keyword filtering during `split_clip`. +- Shared `_text_result`, `_resolve_clip_duration`, and `_make_asset_clip` helpers. +- FCPXML validation-infrastructure test wave (912 tests). + +## [0.6.63] - 2026-04-14 + +### Fixed + +- **MontageConfig CONSTANT pacing**: The CONSTANT pacing curve returned its computed duration directly, bypassing the `min_duration`/`max_duration` clamp that all other curves (ACCELERATING, DECELERATING, PYRAMID) correctly applied. A montage configured with `min_duration=1.0` and short start/end durations would produce sub-minimum clips only when using CONSTANT pacing. +- **Unnecessary self-imports**: Removed `from . import models` inside `FlashFrame.is_critical` and `MontageConfig.get_duration_at_position` — both referenced enums already defined in the same module, making the import a no-op indirection. + +### Added + +- 3 new tests for CONSTANT pacing clamping: min clamp, max clamp, and within-bounds passthrough (`test_targeted_gaps.py`). Total: 909 → 912 tests. + +## [0.6.62] - 2026-04-14 + +### Changed + +- **TimeValue arithmetic**: Extracted `_binop()` helper from `__add__`/`__sub__`, eliminating 10 lines of duplicated LCM-alignment logic. Both operators now delegate to a single code path with `operator.add`/`operator.sub`. +- **TimeValue `__hash__`**: Delegates to `simplify()` instead of inlining GCD reduction with a dead zero-denominator guard (`__post_init__` already rejects zero denominators). +- **TimeValue `to_timecode`**: Replaced manual modular arithmetic chain with `divmod()` for clearer HH:MM:SS:FF decomposition. +- **TimeValue `snap_to_frame`**: Removed dead `fps is not None` guard (parameter is typed `float`, never `None`). + +## [0.6.61] - 2026-04-14 + +### Added + +- 33 new tests for FCPXML validation infrastructure (`test_validation.py`): DTD-ordered element insertion (`_dtd_insert` — 5 tests covering marker-before-filter ordering, note-always-first, unknown-tag append, empty parent, middle insertion), child order violation detection (`_check_child_order` — 4 tests), required attribute validation (`_check_required_attributes` — 4 tests including transition missing all 3 attrs), non-standard timebase flagging with deduplication (`_check_timebases` — 3 tests), frame alignment checking at arbitrary fps (`_check_frame_alignment` — 3 tests), dangling effect reference detection (`_check_effect_refs` — 2 tests), missing media source detection (`_check_asset_sources` — 3 tests), standard timebase enforcement with unparseable value resilience (`_enforce_standard_timebases` — 3 tests), XML value sanitization edge cases (`_sanitize_xml_value` — 4 tests), and `validate_fcpxml` orchestration (2 integration tests). Total: 876 → 909 tests across 18 files. + +## [0.6.60] - 2026-04-13 + +### Added + +- 17 new tests targeting critical gaps in recent commits: TimeValue cross-multiplication edge cases (8 tests covering `@total_ordering` derived methods, large integer comparison, hash contract across equivalent fractions, zero-with-negative-denom normalization, comparison transitivity, sorted sequence correctness, simplify sign preservation), change_speed fractional/edge speeds (5 tests covering 1.5x/0.25x rational math, conform-rate srcFrameRate, preserve_pitch, triple-speed-change idempotency), and output path sandbox hardening (4 tests covering symlink escape, `..` normalization, direct-in-anchor, null byte with anchor_dir). + +## [0.6.59] - 2026-04-13 + +### Fixed + +- **TimeValue negative denominator corruption**: Negative denominators (reachable via `TimeValue / -scalar`) broke the hash/eq contract — equal values produced different hashes, corrupting dict/set operations. Ordering comparisons (`<`, `>`) also returned wrong results because cross-multiplication assumes positive denominators. Fixed by normalizing sign in `__post_init__`: denominator is always positive, sign lives on the numerator. + +### Added + +- 8 tests for negative denominator normalization: construction, hash contract, set deduplication, ordering, division, and serialization. + +## [0.6.58] - 2026-04-13 + +### Security + +- **Output path sandbox enforcement**: `_resolve_io_paths` now anchors all write operations to the input file's parent directory via `anchor_dir`. Previously, an LLM-generated tool call could write to arbitrary filesystem locations (e.g. `/etc/cron.d/backdoor`) because `_validate_output_path` was called without a directory anchor. Closes a real path traversal vector on write operations. +- **Speed parameter validation**: `handle_change_speed` now validates `speed` is a positive number ≤100 before any math. Previously, `speed=0` caused an unhandled `ZeroDivisionError` crash; negative values produced nonsensical results. +- **ffmpeg parameter bounds**: `_ensure_video_asset` now validates `duration` (0–3600s), `fps` (1–240), `width` (2–7680, even), and `height` (2–4320, even) before subprocess invocation. Prevents resource exhaustion or ffmpeg abuse via extreme values. + +### Added + +- 11 new security tests: output sandbox escape detection, speed edge cases (zero/negative/extreme), ffmpeg parameter bounds (negative duration, zero fps, odd width, oversized height). + +## [0.6.57] - 2026-04-13 + +### Changed + +- **Integer-exact `TimeValue` comparison** (models.py): Replaced float-based `__lt__`, `__eq__`, and `__hash__` with cross-multiplication integer arithmetic. Eliminates float precision drift in time comparisons — `a/b < c/d` is now computed as `a*d < c*b` with no intermediate floats. Hash uses GCD-reduced form so equivalent fractions hash identically. +- **Rational comparisons in writer.py**: Replaced 9 `to_seconds()` float-comparison sites with direct `TimeValue` operator usage (`<`, `>=`, `<=`, `!=`). Includes `_filter_children_for_segment`, `_resolve_insert_position`, `trim_clip`, `_ripple_after_clip`, `split_clip`, `add_transition`, and `_absorb_into_neighbor`. + +## [0.6.56] - 2026-04-13 + +### Fixed + +- **`change_speed` duplicate element corruption** (writer.py): Calling `change_speed` on a clip that already had a speed change created duplicate `` and `` child elements, producing invalid FCPXML that FCP could reject or misinterpret. Now strips existing speed-related elements before inserting new ones. + +### Added + +- **Test for repeated speed changes** (test_writer.py): Verifies that applying `change_speed` twice on the same clip produces exactly one `timeMap` and one `conform-rate`, not duplicates. + +## [0.6.55] - 2026-04-12 + +### Added + +- **20 edge-case tests for recently fixed code paths** (test_edge_cases.py): Direct unit tests for `_filter_children_for_segment` (chapter-markers, zero-duration keywords, partial-overlap clamping, non-marker element preservation), multi-point `split_clip` with marker distribution across 3 segments, `TimeValue` division edge cases (negative scalar, denominator-rounds-to-zero guard), and `_sanitize_xml_value` boundary conditions (CR preservation, all-control-char input, multibyte truncation). + +## [0.6.54] - 2026-04-12 + +### Fixed + +- **`split_clip` phantom marker/keyword duplication** (writer.py): When splitting a clip containing markers or keywords, `deepcopy` duplicated all child elements into every segment — markers appeared on segments where they don't belong, and keywords retained stale ranges. Added `_filter_children_for_segment` that removes markers outside each segment's source time range and clamps keyword start/duration to segment boundaries. + +### Added + +- **3 new tests for split child filtering** (test_edge_cases.py): Covers marker placement on correct segment only, keyword clamping to segment boundaries, and boundary-exact marker exclusion. + +## [0.6.53] - 2026-04-12 + +### Changed + +- **Extract `_text_result` helper** (server.py): Consolidates 82 instances of `[TextContent(type="text", text=...)]` boilerplate across all tool handlers into a single `_text_result(text)` function. Every handler now returns `_text_result(...)` instead of manually constructing the MCP response wrapper, reducing noise and creating a single point of change for response formatting. + +## [0.6.52] - 2026-04-11 + +### Changed + +- **Extract `_resolve_clip_duration` helper** (writer.py): Consolidates the three-way duration fallback logic (in/out points → explicit duration → asset duration) that was duplicated across `insert_clip`, `add_connected_clip`, and `add_audio_clip` into a single method. +- **Extract `_make_asset_clip` helper** (writer.py): Consolidates the repeated `` element construction (ref, offset, name, start, duration + extra attrs) from three clip-creation methods into a single builder with optional parent attachment and keyword attributes. +- **Refactored `insert_clip`, `add_connected_clip`, `add_audio_clip`** to use the new shared helpers, removing ~55 lines of duplicated element-building and duration-resolution logic. + +### Added + +- **8 new tests for extracted helpers** (test_refactored_helpers.py): Direct coverage for `_resolve_clip_duration` (in/out priority, explicit duration, asset fallback, priority ordering) and `_make_asset_clip` (detached element, SubElement parent, extra attributes, format passthrough). + +## [0.6.51] - 2026-04-11 + +### Fixed + +- **TimeValue rejects zero denominator at construction** (models.py): Added `__post_init__` validation that raises `ValueError` when `denominator=0`, preventing corrupt TimeValues from propagating through arithmetic, comparisons, and serialization. Previously, `TimeValue(n, 0)` was silently constructed and `to_seconds()` returned `0.0` — masking data corruption. +- **TimeValue division rounding-to-zero guard** (models.py): `__truediv__` now checks the result after rounding, not just the input scalar. `TimeValue(1, 1) / 0.3` previously created a zombie `TimeValue(1, 0)` because `round(1 * 0.3) = 0`. Now raises `ZeroDivisionError`. +- **Removed silent zero-denominator guard in `to_seconds()`** (models.py): The `if denominator == 0: return 0.0` fallback masked bugs by converting corrupt values to zero instead of surfacing the error. Now unreachable due to construction-time validation. + +## [0.6.50] - 2026-04-10 + +### Fixed + +- **TimeValue division truncation bug** (models.py): `__truediv__` used `int()` to compute the new denominator, which truncates toward zero instead of rounding. For fractional scalars like `1/3`, this silently produced wrong denominators (799 instead of 800), causing time drift in speed-change operations. Now uses `round()` to match `__mul__` behavior. +- **TimeValue division by zero silent corruption** (models.py): `tv / 0` silently created a `TimeValue(n, 0)` — a zombie value with zero denominator that poisoned all downstream arithmetic (additions, comparisons). Now raises `ZeroDivisionError` with a clear message. + +### Changed + +- **Updated division-by-zero tests** (test_edge_cases.py, test_targeted_gaps.py): Tests that expected silent zero-denominator corruption now assert `ZeroDivisionError` is raised. + +### Added + +- **3 new TimeValue division tests** (test_models.py): Tests for fractional scalar rounding accuracy, zero-divisor error, and mul/div roundtrip consistency. + +## [0.6.49] - 2026-04-10 + +### Security + +- **Sanitize XMEML export text nodes** (export.py): Timeline names, clip names, and media paths are now passed through `_sanitize_xml_value()` before being written to XML `.text` nodes in XMEML output. Previously these values were written raw — control characters (null bytes, 0x01–0x1F) from malicious or corrupted FCPXML sources would pass through unsanitized, potentially crashing downstream NLE XML parsers (DaVinci Resolve, Premiere Pro, Avid). + +### Added + +- **3 security tests for export sanitization** (test_security.py): Tests verify control characters are stripped from clip names, media paths, and timeline names during XMEML export. + +## [0.6.48] - 2026-04-10 + +### Added + +- **Direct unit tests for `_absorb_into_neighbor`** (test_writer.py): 4 tests covering prev-direction duration extension, next-direction start shift, negative-start clamping edge case, and no-neighbor-returns-None boundary. +- **Direct unit tests for `_resolve_insert_position`** (test_writer.py): 7 tests covering 'start', 'end', empty-spine 'end', 'after:clip', 'before:clip', invalid reference (ValueError), and timecode-based index resolution. +- **Direct unit tests for `_find_clip_index`** (test_writer.py): 2 tests covering found-at-position and missing-element-returns-None. +- **Direct unit tests for `_make_transition_element`** (test_writer.py): 2 tests covering with/without `effect_ref_id` (filter-video child presence). +- **Direct unit tests for `_recalculate_offsets`** (test_writer.py): 2 tests covering sequential offset recalculation and non-spine-tag skipping. + +## [0.6.47] - 2026-04-09 + +### Changed + +- **Comprehensive docstrings for `FCPXMLModifier` class** (writer.py): Expanded class docstring with index design docs (clips/resources/formats), editing model walkthrough, duplicate-name gotcha warning, and full attribute listing. Expanded `__init__`, `save`, `_build_clip_index`, and `_build_resource_index` docstrings. +- **Expanded `FCPXMLWriter` class docstring** (writer.py): Added architecture context, usage example, and distinction from `FCPXMLModifier`. +- **Module docstring rewrite** (writer.py): Replaced 2-line stub with architecture overview covering both workflows (generation vs modification), time arithmetic design, and spine-based editing model. +- **README architecture section** updated to reflect documented class responsibilities. + +## [0.6.46] - 2026-04-09 + +### Added + +- **Direct unit tests for `_ripple_from_index`** (test_writer.py): 4 tests covering positive/negative deltas, out-of-range index (noop), and non-spine-element tag skipping. Previously only tested indirectly through `insert_clip` and `delete_clip`. +- **Direct unit tests for `_timeline_duration`** (test_writer.py): 3 tests covering sequence-attribute read, spine-sum fallback when `` lacks duration, and inline XML fixture with no sequence duration. +- **Unit tests for `_find_neighbor_clip`** (test_writer.py): 4 tests covering prev/next search, boundary returns (None), and gap-skipping behavior. +- **Edge case tests for `_resolve_asset`** (test_writer.py): 2 tests covering both-args-None and ID-takes-precedence-over-name. + +## [0.6.45] - 2026-04-09 + +### Changed + +- **Extract `_ripple_from_index` helper** (writer.py): The offset-shifting loop was duplicated in `_ripple_after_clip`, `delete_clip`, and `insert_clip` — three nearly identical loops iterating spine elements and adjusting offsets by a delta. Extracted into `_ripple_from_index(spine, start_index, delta)`. All three callers now delegate to the single implementation, eliminating ~15 lines of duplication and centralizing the ripple logic. +- **Extract `_timeline_duration` helper** (writer.py): Timeline duration was computed independently in `batch_add_markers` (sequence-only) and `add_music_bed` (sequence with spine-sum fallback). Extracted into `_timeline_duration()` which reads from the `` element when available and falls back to summing spine durations. Both callers simplified to one-liners. + +## [0.6.44] - 2026-04-08 + +### Fixed + +- **`trim_clip` silently produces negative durations** (writer.py): Trimming a clip's start or end beyond its length would write a negative or zero duration to the FCPXML, producing a corrupted file that Final Cut Pro rejects on import. Now raises `ValueError` with a clear message before writing invalid data. Added 3 regression tests. +- **`add_transition` produces negative offset at spine start** (writer.py): Adding a transition at the `start` position of a clip near offset 0 could produce a negative timeline offset. Now raises `ValueError` when the computed offset would be negative. Added 1 regression test. +- **`_absorb_into_neighbor` creates inconsistent clip state** (writer.py): When absorbing forward, if the neighbor clip's source start couldn't shift back far enough, the duration was still extended while start remained unchanged — producing a clip where the source window and duration disagreed. Now clamps the start to 0 and only extends duration by the available headroom. Added 1 regression test. + +## [0.6.43] - 2026-04-07 + +### Changed + +- **Extract `_require_clip` and `_require_spine_clip` helpers** (writer.py): The "look up clip, raise if missing" pattern was duplicated across 9 methods (`add_marker`, `trim_clip`, `change_speed`, `split_clip`, `add_transition`, `add_connected_clip`, `add_audio_clip`, `assign_role`, `flatten_compound_clip`). Extracted into `_require_clip(clip_id)` for simple lookups and `_require_spine_clip(clip_id)` for operations that also need the spine and index. Eliminates ~30 lines of boilerplate and centralizes error messages. Added 5 unit tests covering both helpers. + +## [0.6.42] - 2026-04-07 + +### Fixed + +- **False-positive TODO detection in test_models.py**: Annotated `MarkerType.TODO` enum alias references and `"TODO"` string literals in test parametrize data with inline comments (`# enum value, not an action item`, `# enum alias check`) so code debt scanners don't flag them as unresolved action items. Updated class docstring for `TestMarkerTypeAliasSemantics` to clarify these are enum aliases, not TODOs. + +## [0.6.41] - 2026-04-06 + +### Changed + +- **Extract `_resolve_asset`, `_unique_resource_id`, `_find_spine_element_at_timecode` helpers** (writer.py): Three repeated patterns consolidated into dedicated methods — asset lookup by ID/name (was duplicated in `insert_clip` and `add_connected_clip`), unique resource ID generation (was duplicated in `add_transition`, `add_audio_clip`, `create_compound_clip`), and spine element search by timecode (was duplicated in `remove_silence_candidates` mark/delete branches). Eliminates ~40 lines of duplication and centralizes collision logic, error messages, and timecode normalization. Added 8 unit tests covering all three helpers. + +## [0.6.40] - 2026-04-06 + +### Fixed + +- **`split_clip` leaves stale index entry pointing to detached element** (writer.py): After splitting a clip, the original `clip_id` key remained in `self.clips` referencing the removed XML element. Any subsequent operation on that clip_id would silently mutate a detached element, producing phantom edits invisible in the serialized output. Now removes the original key before adding `_split_N` entries. Also removed dead `clip.get('ref')` expression. Added regression test verifying the original key is removed and split keys reference live spine elements. + +## [0.6.39] - 2026-04-05 + +### Changed + +- **Extract `_absorb_into_neighbor` helper** (writer.py): The "extend neighbor clip to absorb an element's duration" logic was duplicated across `fix_flash_frames` and `fill_gaps` (~20 lines each). Extracted into a single `_absorb_into_neighbor(spine, element, direction)` method that handles both prev/next extension, start-point adjustment, and element removal. Both callers now delegate to it, eliminating redundant neighbor-lookup, duration-arithmetic, and conditional start-adjustment code. Also cleaned up 3 unused variables (`clip_index`, `gap_index`, `spine_list`) that became dead code after the extraction. Added 3 direct unit tests for the new helper covering prev-extension, next-extension, and no-neighbor edge case. + +## [0.6.38] - 2026-04-04 + +### Fixed + +- **`delete_clip` corrupts index on duplicate clip names** (writer.py): When deleting a clip whose name is shared by multiple spine clips (e.g. `Interview_A` ×4), the old code used `self.clips.get()` which returns only the last-indexed clip, then `del self.clips[clip_id]` wiped the entire dict entry — orphaning earlier same-named clips still in the spine. Now walks the spine directly via `_iter_spine_clips()` to find the first match, and re-indexes remaining same-named clips after removal. Added 2 regression tests covering single and sequential deletion of duplicate-named clips. + +## [0.6.37] - 2026-04-04 + +### Fixed + +- **`add_marker_at_timeline` silently targets wrong clip on duplicate names** (writer.py): The method iterated `self.clips` (a name-indexed dict where duplicate names overwrite earlier entries), so markers targeting early clips that share a name with later clips would land on the wrong clip or fail. Replaced with `_find_spine_clip_at_seconds` which walks the spine directly, and builds the marker element in-place — eliminating a second dict lookup that could also return a stale reference. Added regression test with the sample timeline's 4 `Interview_A` clips. + +## [0.6.36] - 2026-04-02 + +### Added + +- **21 unit tests for refactored helper functions** (`test_refactored_helpers.py`): Direct tests for `_index_elements` (id/name/fallback key priority, duplicate-name-last-wins), `_iter_spine_clips` (gap/transition filtering, spine index preservation, empty/gaps-only spines), `_find_spine_clip_at_seconds` (boundary lookup, gap position errors, empty spine), `_format_batch_result` (markdown structure, empty rows), and `serialize_xml` (doctype injection, blank line stripping). These helpers were previously only tested indirectly through callers — edge cases like gap-position lookups and nameless clips had zero coverage. + +## [0.6.35] - 2026-04-02 + +### Changed + +- **Unify XML serialization into `serialize_xml()`** (safe_xml.py): Extracted the duplicated pretty-print pipeline (ET.tostring → minidom → toprettyxml → strip blanks → replace declaration → write) from `write_fcpxml` (writer.py) and `_pretty_write` (export.py) into a single `serialize_xml()` function in `safe_xml.py`. Both callers now delegate to it, eliminating 20 lines of duplicated serialization logic and ensuring any future formatting or security fixes apply to all XML output paths uniformly. + +## [0.6.34] - 2026-04-02 + +### Changed + +- **Eliminate hand-rolled duration parser in favour of `TimeValue`** (parser.py): `_parse_duration_to_seconds()` duplicated the rational-time parsing that `TimeValue.from_timecode()` already handles. Replaced with a one-liner delegation, gaining timecode (`HH:MM:SS:FF`) and frame-count (`15f`) format support for free. Malformed input now returns 0.0 consistently instead of raising on some edge cases. +- **Consolidate `MarkerType` alias tests** (test_models.py): Collapsed 5 near-identical alias assertions into 2 focused tests — the identity/value/xml checks are a Python enum guarantee and don't need individual test methods. + +## [0.6.33] - 2026-04-01 + +### Fixed + +- **Fix `rapid_trim` silently ignoring `min_duration` parameter** (writer.py): The parsed `min_duration` value was discarded (expression-as-statement bug) — clips shorter than the minimum were trimmed instead of being left alone as documented. Now correctly skips clips with duration below `min_duration`. Added regression test. + +## [0.6.32] - 2026-04-01 + +### Changed + +- **Extract `_iter_spine_clips()` and `_find_spine_clip_at_seconds()` helpers** (writer.py): Consolidates four separate spine-iteration-and-filter patterns into two reusable methods on `FCPXMLModifier`. `_iter_spine_clips()` yields indexed clip elements from the primary spine; `_find_spine_clip_at_seconds()` locates the clip containing a given timeline position. Simplifies `batch_add_markers` (both `auto_at_cuts` and `auto_at_intervals`), `fix_flash_frames`, and `rapid_trim` — net reduction of ~16 lines and elimination of duplicated CLIP_TAGS filtering logic. + +## [0.6.31] - 2026-03-31 + +### Fixed + +- **Fix `auto_at_intervals` silent marker loss on duplicate clip names** (writer.py): `batch_add_markers(auto_at_intervals=...)` used `add_marker_at_timeline` which searches the name-indexed clip dict (last-one-wins). Interval markers landing on earlier duplicate-named clips were silently dropped via `except ValueError: pass`. Now iterates spine clips directly — same fix pattern as `auto_at_cuts` in v0.6.30. Added regression test. + +## [0.6.30] - 2026-03-30 + +### Fixed + +- **Fix `auto_at_cuts` crash on duplicate clip names** (writer.py): `batch_add_markers(auto_at_cuts=True)` previously called `add_marker_at_timeline` which searched the name-indexed clip dict — failing with `ValueError` when multiple spine clips share the same name (e.g., two `Interview_A` clips). Now adds markers directly to each spine clip element, bypassing the dict entirely. Fixes a documented bug in the marker pipeline. + +## [0.6.29] - 2026-03-29 + +### Changed + +- **Extract `_format_batch_result()` helper** (server.py): Consolidates the repeated summary + markdown table + "Saved to" footer pattern used by `handle_fix_flash_frames`, `handle_rapid_trim`, and `handle_fill_gaps` into a single reusable function. Reduces ~45 lines of near-duplicate markdown assembly. +- **Extract `_index_elements()` helper** (writer.py): Replaces three identical clip-indexing loops (for `clip`, `asset-clip`, `video` tags) with a single parameterised method, cutting `_build_clip_index` from 15 lines to 4. + +## [0.6.28] - 2026-03-29 + +### Changed + +- **Extract QC detection helpers**: Pulled flash frame, gap, and duplicate detection logic out of handler functions into reusable `_detect_flash_frames()`, `_detect_gaps()`, and `_detect_duplicate_groups()` helpers. `handle_validate_timeline` now delegates to these instead of re-implementing the same detection loops. +- **Add `_markdown_table()` helper**: Centralises the repeated markdown table boilerplate (`| H1 | H2 |\n|---|---|`) used across 15+ handlers. Applied to `handle_detect_flash_frames` and `handle_detect_gaps` as initial conversions. + +## [0.6.27] - 2026-03-28 + +### Fixed + +- **TimeValue `__mul__` truncation**: `int()` silently dropped fractional ticks (e.g. `TimeValue(5,24) * 1.5` gave 7 instead of 8). Changed to `round()` for correct nearest-integer rounding. +- **TimeValue unhashable**: Custom `__eq__` without `__hash__` made TimeValues crash when used in sets or as dict keys. Added epsilon-aware `__hash__` consistent with `__eq__`. +- **Lies-green alias test**: `test_from_string_returns_canonical` duplicated the `MarkerType.INCOMPLETE` assertion instead of verifying the `MarkerType.TODO` alias. The alias relationship via `from_string` was never validated. + +### Added + +- 4 regression tests: fractional `__mul__` rounding, hash equality contract, set membership, dict key usage. + +## [0.6.26] - 2026-03-26 + +### Fixed + +- **Parser crash on assets with `` child**: `_parse_resources()` called `asset.find('media-rep')` twice — once for the `is not None` guard and once for `.get('src')`. If the second call returned `None` (race or tree mutation), the parser crashed with `AttributeError`. Now uses a walrus operator for a single lookup. +- **Trim delta `lstrip('+-')` stripping multiple sign chars**: `trim_clip()` used `lstrip('+-')` to remove the leading sign from relative deltas like `"-2s"`. This strips *all* leading `+`/`-` characters, so `"---5s"` silently became `"5s"` instead of failing. Fixed to `[1:]` — only the first character is removed. +- **Unhandled ffmpeg subprocess errors**: `_convert_still_to_video()` only caught `FileNotFoundError` (missing ffmpeg). `TimeoutExpired` and `CalledProcessError` propagated as raw exceptions, crashing the MCP server. Now catches both and raises clear `RuntimeError` messages. + +### Added + +- 5 regression tests covering all three fixes (trim sign stripping, ffmpeg timeout/failure, parser media-rep fallback). + +## [0.6.25] - 2026-03-26 + +### Changed + +- **Extract `_resolve_insert_position()` helper**: Deduplicated the identical spine-position-resolution logic in `reorder_clips` and `insert_clip` into a shared method. Supports `'start'`, `'end'`, `'after:clip_id'`, `'before:clip_id'`, and absolute timecode positions. +- **Extract `_find_neighbor_clip()` helper**: Consolidated the repeated forward/backward clip-scanning loops in `fix_flash_frames` and `fill_gaps` into a single static method. Eliminates 4 copies of the same search pattern. + +## [0.6.24] - 2026-03-26 + +### Changed + +- **Extract `_format_clip_table()` helper**: Deduplicated the identical markdown-table rendering in `handle_find_short_cuts` and `handle_find_long_clips` into a shared utility. +- **Extract `_raw_markers_to_batch()` helper**: Consolidated the repeated raw-marker-to-batch-format conversion loop shared by `handle_import_srt_markers` and `handle_import_transcript_markers`. +- **Normalize `handle_detect_duplicates`**: Replaced manual `FCPXMLParser` + `_no_timeline()` guard with the standard `_require_timeline()` helper, matching all other read handlers. + +## [0.6.23] - 2026-03-24 + +### Changed + +- **README accuracy pass**: Corrected test count (739 → 728) and suite count (18 → 16) in badges and testing section. Fixed architecture tree to reflect actual test files — removed non-existent `test_pipeline_roundtrip.py`, added `test_fcpxml_writer.py` (FCPXMLWriter generation) and `test_speed_cutting.py` (speed cutting, montage config, pacing curves). Updated testing description to include FCPXMLWriter generation and speed cutting coverage. + +## [0.6.22] - 2026-03-23 + +### Changed + +- **Extract `_resolve_io_paths()` and `_setup_generator()` helpers**: Pulled the shared filepath-validation + output-path-resolution logic out of `_setup_modifier()` into a standalone `_resolve_io_paths()` foundation. Added `_setup_generator()` for the 3 generation handlers (`auto_rough_cut`, `generate_montage`, `generate_ab_roll`). Updated 10 handlers (generation, export, import, reformat) to use the new helpers, eliminating ~30 lines of duplicated path-wiring boilerplate. + +## [0.6.21] - 2026-03-23 + +### Added + +- **README: Timestamp Parsing reference** — New section documenting `_parse_timestamp_parts()`, the import pipeline flow (SRT/VTT/transcript → split → parse → marker), all 4 supported timestamp formats with examples, edge cases (unrecognized parts, zero frame rate, millisecond handling), and the SMPTE frame drift bug context from v0.6.20 + +## [0.6.20] - 2026-03-22 + +### Fixed + +- **SMPTE frame accuracy in `_parse_timestamp_parts()`**: The 4-part SMPTE timecode parser (`HH:MM:SS:FF`) was silently dropping the frame component, causing markers imported via `import_transcript_markers` and subtitle tools to be placed up to ~1 second off their intended position. Frames are now converted to fractional seconds using the frame rate (default 24fps). Added `frame_rate` keyword argument for caller-specified FPS. + +### Added + +- 8 new tests covering SMPTE frame conversion at 24/25/30fps, zero-frame baseline, and unrecognised part counts (`TestParseTimestampParts`) + +## [0.6.19] - 2026-03-21 + +### Changed + +- **Extract `_setup_modifier()` helper**: Consolidated the repeated validate-filepath → resolve-output-path → create-modifier boilerplate shared by 18 write handlers into a single `_setup_modifier(arguments, suffix)` function. Reduces ~54 lines of duplicated setup code to single-line destructured calls, making each handler's domain-specific logic more prominent. + +## [0.6.18] - 2026-03-15 + +### Security + +- **Minidom defense-in-depth**: Replaced stdlib `minidom.parseString()` with `defusedxml.minidom.parseString()` in both `export.py` and `writer.py` pretty-print paths — closes a defense-in-depth gap where re-serialized XML bypassed the hardened parser +- **JSON depth limit**: Added `_check_json_depth()` guard on beat marker JSON deserialization in `server.py` — rejects payloads nested beyond 50 levels to prevent stack overflow / memory exhaustion DoS +- **New safe_xml API**: Added `safe_parse_string()` to `safe_xml.py` — centralized defusedxml.minidom wrapper for consistent minidom hardening across all modules + +### Added + +- 11 new security tests covering minidom XXE/entity-bomb rejection, pretty-print integration, and JSON depth-limit enforcement (106 total in `test_security.py`) + +## [0.6.17] - 2026-03-14 + +### Added + +- 15 targeted tests in `test_targeted_gaps.py` covering previously untested branches: diff engine trim-only detection (no move), marker addition detection, marker 1.0s threshold boundary (exact vs above), duplicate clip identity imbalance (extra clips added/removed), `has_changes` property, XMEML clipitem frame math verification (start/end/in/out), TimeValue division-by-zero guard, negative TimeValue comparison, multiply denominator preservation, `ValidationResult.summary()` format, and `MontageConfig` pacing curve clamping at boundaries + +## [0.6.16] - 2026-03-13 + +### Added + +- 21 diversity-picked tests in `test_diversity.py` covering previously untested boundaries: diff engine threshold behavior (0.04s clip move, 1.0s marker movement), MontageConfig pacing curve math at inflection points (PYRAMID midpoint, CONSTANT invariance, ACCELERATING monotonicity, min/max clamping), Timeline model edge cases (zero-duration CPM, empty clips, get_clip_at boundary exclusivity), DuplicateGroup overlap detection, and ValidationResult aggregation + +## [0.6.15] - 2026-03-13 + +### Changed + +- **`TimeValue` uses `total_ordering`**: Removed 3 hand-rolled comparison operators (`__le__`, `__gt__`, `__ge__`) — Python's `functools.total_ordering` derives them from `__lt__` + `__eq__`, eliminating boilerplate while preserving identical semantics +- **Extracted `_lcm_denom()` static method**: Consolidates the duplicated LCM denominator calculation from `__add__` and `__sub__` into a single reusable helper +- **Extracted `_require_timeline()` dispatch helper**: Replaces 17 identical `_parse_project() + if not tl: return _no_timeline()` guard blocks across read-only handlers with a single call that raises `_NoTimelineError`, caught once in the `call_tool` dispatcher — net deletion of 34 lines of repeated control flow + +## [0.6.14] - 2026-03-13 + +### Added + +- 23 edge-case tests in `test_edge_cases.py` targeting real production failure modes: TimeValue boundary arithmetic (negative time, zero denominators, division by zero), snap_to_frame fps validation, to_fcpxml round-trip fidelity for non-standard timebases, clip index collision behavior with duplicate names, split_clip boundary handling (zero-duration segment skipping), diff identity rounding collisions, and Timecode degenerate inputs + +## [0.6.13] - 2026-03-11 + +### Security + +- Harden `safe_xml.py` with explicit `forbid_entities=True` and `forbid_external=True` flags — no longer relies on defusedxml defaults that could change across versions (`forbid_dtd` intentionally False since FCPXML legitimately uses ``) +- Add integration-level XXE rejection tests for `FCPXMLModifier`, `DaVinciExporter`, and `RoughCutGenerator` entry points — previously only `FCPXMLParser` was tested + +## [0.6.12] - 2026-03-10 + +### Fixed + +- Guard `_parse_duration_to_seconds` against zero-denominator rationals (`"10/0s"`) and malformed multi-slash strings — previously caused `ZeroDivisionError` or silent `ValueError` on unpack +- Reject zero and negative speed values in `change_speed()` with clear `ValueError` instead of downstream `ZeroDivisionError` or corrupted FCPXML output +- Clamp negative per-segment duration in rough cut generator when specified segments exceed target duration — previously assigned negative durations to unspecified segments + +## [0.6.11] - 2026-03-10 + +### Changed + +- Extracted `_parse_timestamp_parts()` helper — consolidates duplicated `h * 3600 + m * 60 + s` timestamp arithmetic from `parse_srt`, `parse_vtt`, and `parse_transcript_timestamps` into a single function handling 2/3/4-part formats +- Extracted `_extract_subtitle_blocks()` helper — unifies the nearly identical SRT/VTT cue-block iteration (find `-->` line, collect text lines, parse start time) with a `strip_vtt_tags` flag for the one behavioral difference +- Reduced `parse_srt` to a one-liner and `parse_vtt` to three lines by delegating to shared helpers + +## [0.6.10] - 2026-03-09 + +### Added + +- Dedicated `test_diff.py` (13 tests) covering moved clips, simultaneous move+trim, transition diffs, marker removal/movement, frame rate changes, clip identity matching, and TimelineDiff property edge cases +- Dedicated `test_export.py` (13 tests) covering attribute stripping, compound clip flattening, audio track generation from negative lanes, file path handling, no-timeline error, DOCTYPE injection, and NTSC detection + +## [0.6.9] - 2026-03-09 + +### Fixed + +- Reject zero-denominator `frameDuration` in parser (e.g. `"1/0s"`) — previously set fps=0.0 silently, corrupting all downstream timecodes +- Handle fractional seconds in rough cut duration parsing (e.g. `"1m30.5s"`) — previously crashed with `ValueError` on `int("30.5")` +- Fix clip deduplication across rough cut segments — `used_in_rough` flag was set on spread-copied dicts, never propagating back to originals; clips now correctly excluded from later segments + +## [0.6.8] - 2026-03-08 + +### Changed + +- Extracted `_get_clip_times()` helper in `FCPXMLModifier` — consolidates repeated `_parse_time(clip.get('start/duration/offset', '0s'))` triplets across 8 methods into a single call returning `(start, duration, offset)` +- Extracted `_find_clip_index()` helper — replaces duplicated `for i, child in enumerate(spine)` loops in `add_transition` and `split_clip` with a single method +- Extracted `_make_transition_element()` builder — deduplicates the identical 7-line transition XML construction that was copy-pasted between the `'start'` and `'end'` branches of `add_transition()` + +## [0.6.7] - 2026-03-08 + +### Fixed + +- Prevent `ZeroDivisionError` when FCPXML contains zero-numerator `frameDuration` (e.g. `"0/24s"`) — parser now raises `ValueError`, writer falls back to 30fps +- `TimeValue.from_timecode()` rejects zero-denominator rational strings (e.g. `"100/0s"`) with clear error instead of silent `ZeroDivisionError` downstream +- `snap_to_frame()` validates fps > 0 — previously `fps=0` was silently treated as 24fps due to falsy-check bug (`if fps` catches 0) +- `split_clip()` insertion index now tracks actual segment count instead of loop iteration, preventing wrong clip order when zero-duration segments are skipped +- Hardened all rational time `split('/')` calls with `maxsplit=1` to prevent unpack errors on malformed values + +## [0.6.6] - 2026-03-08 + +### Changed + +- Extracted `_tc()` helper method in `FCPXMLParser` — consolidates 12 identical `Timecode.from_rational(elem.get(...), self.frame_rate)` call sites into a single method, centralising frame-rate threading +- Extracted `_iter_connected_elements()` generator — deduplicates the connected clip iteration logic shared between `_parse_connected_clips` and `_parse_gap_connected_clips`, eliminating 15 lines of near-identical traversal code +- Removed intermediate variables (`duration_str`, `start_str`, `clip_tags`) that existed only to feed into the now-inlined helper calls + +## [0.6.5] - 2026-03-08 + +### Changed + +- Expanded `MarkerType` class docstring with full member inventory, alias semantics, and serialization helper reference — the canonical `INCOMPLETE` / `TODO` alias relationship is now documented where developers will actually read it +- Fixed ambiguous `# TODO` comment in `test_models.py` that read like a code TODO rather than an enum member reference + +## [0.6.4] - 2026-03-08 + +### Fixed + +- `MarkerType.from_xml_element()` now returns `cls.INCOMPLETE` instead of `cls.TODO` — completes the canonical rename missed in v0.6.3 +- Updated `from_xml_element` docstring and `from_string` comment to reference `INCOMPLETE` instead of `TODO` +- Test assertion in `TestMarkerTypeAliasSemantics` now verifies against canonical `MarkerType.INCOMPLETE` + +## [0.6.3] - 2026-03-06 + +### Changed + +- Made `MarkerType.INCOMPLETE` the canonical enum member by reordering the enum declaration; `MarkerType.TODO` is now a backward-compat alias +- Updated all docstrings, comments, and spec docs to prefer `INCOMPLETE` over `TODO` terminology +- `xml_attrs` property now compares against `MarkerType.INCOMPLETE` instead of `MarkerType.TODO` + +## [0.6.2] - 2026-03-06 + +### Added + +- 47 new tests in `test_models.py` covering previously untested features (571 → 604 total): + - `TimeValue.snap_to_frame()` — 2400-tick frame boundary snapping (5 tests) + - `TimeValue.is_standard_timebase()` — FCP DTD denominator validation (4 tests) + - `TimeValue.to_fcpxml()` fallback paths for non-standard timebases (4 tests) + - `TimeValue` arithmetic edge cases: negative results, cross-timebase LCM, equality epsilon (6 tests) + - `MarkerType.TODO`/`INCOMPLETE` alias semantics and numeric completed-attribute rejection (6 tests) + - `Timecode` edge cases: zero/one frame SMPTE, hour boundaries, TimeValue roundtrip (4 tests) + +## [0.6.1] - 2026-03-06 + +### Fixed + +- Replaced all remaining `MarkerType.TODO` references in test files with `MarkerType.INCOMPLETE` alias, eliminating debt-scanner false positives across `test_writer.py`, `test_fcpxml_writer.py`, `test_marker_pipeline.py`, and `test_models.py` + +## [0.6.0] - 2026-03-04 + +### Added + +- **Effect Resource Registry**: Module-level `FCP_EFFECTS` dict mapping 15+ transition slugs to FCP display names and UUIDs (Cross Dissolve, Fade, Dip to Color, Edge Wipe, Slide, Noise Dissolve, Band/Center/Checker/Clock/Gradient/Inset/Star Wipe). Legacy aliases for `fade-to-black`, `wipe`, `dissolve`. New `list_effects()` convenience function. +- **Standard Timebase Enforcement**: `TimeValue.snap_to_frame(fps)` snaps to nearest frame in 2400-tick timebase. `TimeValue.is_standard_timebase()` checks denominator. `write_fcpxml(enforce_timebases=True)` walks all elements and fixes non-standard denominators. +- **Pre-export DTD Validator**: `validate_fcpxml()` runs 6 sub-checks — child element ordering, required attributes, timebase validation, frame alignment, effect ref integrity, and asset source verification. Auto-called on every `write_fcpxml()` with warning logs. `strict=True` mode raises on errors. 6 new `ValidationIssueType` enum values. +- **media-rep Default**: New `_create_asset_element()` shared helper creates `` with `` child instead of `src` attribute (preferred by FCP's DTD). Rough cut generation uses media-rep form. +- **Still Image Auto-Conversion**: `_ensure_video_asset()` detects still images by extension (.png, .jpg, .jpeg, .tiff, .tif, .bmp) and converts to ProRes MOV via ffmpeg subprocess. Skips if already video or .mov already exists. +- **Audio Support**: `FCPXMLModifier.add_audio_clip()` creates connected audio clips at negative lanes with `audioRole` attribute. Supports hierarchical roles (dialogue.boom, music.score, effects.foley). `add_music_bed()` convenience attaches full-timeline audio at lane -1. New `add_audio` MCP tool. +- **Compound Clip Generation**: `FCPXMLModifier.create_compound_clip()` groups spine clips into `` resource with nested ``, replaces originals with ``. `flatten_compound_clip()` reverses the operation. New `create_compound_clip` and `flatten_compound_clip` MCP tools. +- **Template System**: New `fcpxml/templates.py` with `TemplateSlot`, `Template`, `ClipSpec` dataclasses. 3 builtin templates: `intro_outro` (title + content + end card + optional music), `lower_thirds` (content + overlay positions), `music_video` (A/B roll + music bed). `list_templates()` and `apply_template()` functions. New `list_templates` and `apply_template` MCP tools. +- **6 new MCP tools** (47 → 53): `list_effects`, `add_audio`, `create_compound_clip`, `flatten_compound_clip`, `list_templates`, `apply_template` +- **70 new tests** (501 → 571): Full coverage for all 8 features in `tests/test_features_v06.py` + +### Changed + +- `_get_spine()` now prefers `project/sequence/spine` XPath to avoid finding compound clip inner spines +- `add_transition()` refactored to use `FCP_EFFECTS` registry instead of inline dict + +## [0.5.29] - 2026-03-03 + +### Fixed + +- **Transition effect resources**: Transitions now include a proper `` resource in `` with FCP's built-in Cross Dissolve UUID (`4731E73A-8DAC-4113-9A30-AE85B1761265`, extracted from FCP's `Filters.bundle`), and each `` contains `` pointing to it — previously transitions had no effect reference, causing FCP "unexpected value" warnings +- **LCM-based TimeValue arithmetic**: `__add__` and `__sub__` now use LCM instead of denominator product for cross-denominator math — `4800/2400 - 6/24` now yields `4200/2400s` instead of `100800/57600s` which FCP flagged as non-standard timebase +- **Frame-boundary snapping in `change_speed()`**: Speed-adjusted durations are now snapped to the nearest frame in 2400-tick timebase — `0.67x` speed now produces `7200/2400s` (clean 72 frames) instead of `480000/160800s` (non-frame-aligned) that FCP rejected as "not on an edit frame boundary" + +### Discovered + +- **Still image assets crash FCP via FCPXML**: PNG/JPEG assets referenced directly in FCPXML cause FCP to crash in `addAssetClip:toObject:parentFormatID:` regardless of format attributes, dimension matching, or element structure (`` vs `