"""Base comum dos comandos da ponte: saída JSON, caminhos derivados e cache. A saída passa toda por `emit`. Os módulos de comando chamam `shared.emit(...)` pelo módulo, e não pelo nome importado, de propósito: assim trocar `emit` num lugar só — como a suíte faz para capturar a saída — continua alcançando todos os comandos, o que deixaria de valer se cada um tivesse ligado o nome no seu próprio import. """ from __future__ import annotations import json import os 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.media_intel import media_src_to_path from fcpxml.parser import parse_fcpxml from fcpxml.diarize import build_speakers # noqa: E402 """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": "..."} analyze_voice {"path": "...", "output_dir": "...", "model": "...", "language": "pt"|"auto"|null, "hf_token": "..."|null, "num_speakers": ""|null} Build the voice timeline (transcript+diarization+acoustics) for every unique source media — analysis only, writes _voice_timeline.json next to each media, `path` passes through unchanged. Meant as one entry in the batch operations list (see processBatchStep), so `refine_voice_timeline` never has to reopen the audio later. -> {"ok": true, "path": "...", "message": "..."} or {"ok": false, "error": "..."} build_phrase_review {"voice_timeline": "..._voice_timeline.json", "actions": {...}|[...]|null, "fresh": false} The reviewable script for the wizard's emphasis step: every phrase with the AI's decision already applied (active/emphasis/trim). A review saved earlier for the same timeline is returned as-is unless `fresh` is true. -> {"ok": true, "reused": bool, "source", "duration", "speakers", "phrases": [{index, start, end, trim_start, trim_end, text, speaker, active, emphasis (0-3), track, peak_emphasis, take_boundary, gap_before, reason, words}], "errors": [...]} save_phrase_review {"voice_timeline": "...", "phrases": [...], "source": "...", "duration": 0.0, "speakers": [...]} Writes _phrase_review.json plus the _phrase_actions.json derived from it. -> {"ok": true, "review_path", "actions_path", "emphasis_count", "removed_count"} dynamic_subtitle_config {} -> {"ok": true, "band_height", "block_center_y", "line_gap", "font", "font_size", "emphasis_font", "emphasis_face", "emphasis_size", "active_color", "emphasis_color", "text_scale"} set_dynamic_subtitle_config {} Persists only the given fields to ~/.fcp-mcp-server/config.json. generate_dynamic_subtitles reads this as its own fallback default. -> {"ok": true, } silence_config {} -> {"ok": true, "noise_db": -30.0, "min_silence": 0.5, "padding": 0.05} set_silence_config {"noise_db": -30.0, "min_silence": 0.5, "padding": 0.05} Persists only the given fields. detect_media_silence and remove_media_silence read this as their own fallback default. -> {"ok": true, } 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, "emphasis_font": "Playfair Display", "emphasis_face": "Medium Italic", "emphasis_size": 265, "active_color": "1 1 1 1", "emphasis_color": "1 1 1 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": "..."} acoustics_capability Whether librosa (pitch/energy for voice analysis) is installed. -> {"ok": true, "available": bool, "message": "..."} voice_analysis -> {"ok": true, "energy_threshold": 0.5, "emphasis_threshold": 0.85, "emphasis_weights": {...}, "emotion_enabled": false, "emotion_sensitivity": 0.5} set_voice_analysis {"energy_threshold": 0.6, "emphasis_threshold": 0.9, "emphasis_weights": {"energy": 0.4}|null, "emotion_enabled": true, "emotion_sensitivity": 0.5} -> same shape as voice_analysis (only given fields change) Exit code 0 on success, 1 on error. """ _CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") if _CODE_DIR not in sys.path: sys.path.insert(0, _CODE_DIR) 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) def _is_no_change_message(message: str) -> bool: """Whether a tool completed cleanly without needing to save a new file.""" text = message.lower() return any( token in text for token in ( "no cuts to make", "no silence", "file unchanged", "nothing saved", ) ) def _emit_no_change_or_error(path: str, message: str) -> int: if _is_no_change_message(message): emit({"ok": True, "path": path, "unchanged": True, "message": message}) return 0 emit({"ok": False, "error": message}) return 1 # Serializa a escrita em stdout. A ponte é JSON-lines: dois comandos # escrevendo ao mesmo tempo entrelaçariam documentos e o app leria lixo. _OUT_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) def _project_media_paths(path: str) -> list[str]: proj = parse_fcpxml(path) 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) return media_paths def _voice_timeline_json_path(media_path: str, output_dir: str = "") -> Path: p = Path(media_path) if output_dir: directory = Path(output_dir).expanduser() directory.mkdir(parents=True, exist_ok=True) return directory / f"{p.stem}_voice_timeline.json" return p.with_name(p.stem + "_voice_timeline.json") def _load_cached_voice_timeline(json_path: Path, media_path: str) -> dict | None: try: with open(json_path, encoding="utf-8") as fh: data = json.load(fh) except (OSError, json.JSONDecodeError, UnicodeDecodeError): return None if not isinstance(data, dict): return None if data.get("source") != Path(media_path).name: return None if not isinstance(data.get("segments"), list): return None return data 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