4143 lines
175 KiB
Python
Executable File
4143 lines
175 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
FCPXML MCP Server — Batch operations and analysis for Final Cut Pro XML files.
|
|
|
|
Provides 53 tools, MCP resources for file discovery, and pre-built prompt
|
|
workflows for common editing tasks.
|
|
|
|
Author: DareDev256 (https://github.com/DareDev256)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Sequence
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import (
|
|
GetPromptResult,
|
|
Prompt,
|
|
PromptArgument,
|
|
PromptMessage,
|
|
Resource,
|
|
TextContent,
|
|
Tool,
|
|
)
|
|
|
|
from fcpxml.diff import compare_timelines
|
|
from fcpxml.export import DaVinciExporter
|
|
from fcpxml.media_intel import (
|
|
detect_beats,
|
|
detect_silence,
|
|
map_silence_to_timeline,
|
|
media_src_to_path,
|
|
)
|
|
from fcpxml.models import (
|
|
DuplicateGroup,
|
|
DynamicSubtitleConfig,
|
|
FlashFrame,
|
|
FlashFrameSeverity,
|
|
GapInfo,
|
|
MarkerType,
|
|
SegmentSpec,
|
|
Timecode,
|
|
TimeValue,
|
|
WordLook,
|
|
WordStyle,
|
|
)
|
|
from fcpxml.parser import FCPXMLParser
|
|
from fcpxml.rough_cut import RoughCutGenerator
|
|
from fcpxml.templates import ClipSpec, apply_template, list_templates
|
|
from fcpxml.transcribe import (
|
|
DEFAULT_FILLERS,
|
|
find_filler_spans,
|
|
find_phrase_spans,
|
|
invert_ranges,
|
|
merge_ranges,
|
|
segments_to_srt,
|
|
transcribe,
|
|
)
|
|
from fcpxml.writer import FCPXMLModifier, list_effects
|
|
|
|
__version__ = "0.13.1"
|
|
|
|
server = Server("fcp-mcp-server", version=__version__)
|
|
PROJECTS_DIR = os.environ.get("FCP_PROJECTS_DIR", os.path.expanduser("~/Movies"))
|
|
# When set explicitly via env var, enforce sandbox boundaries on list_projects.
|
|
_SANDBOX_ENABLED = "FCP_PROJECTS_DIR" in os.environ
|
|
|
|
# Maximum file size for parsing (100 MB).
|
|
MAX_FILE_SIZE = 100 * 1024 * 1024
|
|
|
|
|
|
# ============================================================================
|
|
# SECURITY UTILITIES
|
|
# ============================================================================
|
|
|
|
# Maximum nesting depth for JSON deserialization (beat markers, configs).
|
|
# Prevents stack overflow / memory exhaustion from deeply nested payloads.
|
|
_MAX_JSON_DEPTH = 50
|
|
|
|
|
|
def _check_json_depth(obj: object, _depth: int = 0) -> None:
|
|
"""Reject JSON structures nested beyond _MAX_JSON_DEPTH.
|
|
|
|
Prevents denial-of-service via deeply nested objects that exhaust the
|
|
call stack or memory during downstream processing. Called after
|
|
json.load() since Python's json module has no built-in depth limit.
|
|
"""
|
|
if _depth > _MAX_JSON_DEPTH:
|
|
raise ValueError(
|
|
f"JSON nesting depth exceeds {_MAX_JSON_DEPTH} — "
|
|
"file may be malformed or adversarial"
|
|
)
|
|
if isinstance(obj, dict):
|
|
for v in obj.values():
|
|
_check_json_depth(v, _depth + 1)
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
_check_json_depth(item, _depth + 1)
|
|
|
|
|
|
def _validate_filepath(filepath: str, allowed_extensions: tuple[str, ...] | None = None) -> str:
|
|
"""Validate a user-provided file path against traversal and size attacks.
|
|
|
|
Resolves symlinks, blocks null bytes, enforces extension whitelist, and
|
|
checks file size before any parsing takes place.
|
|
|
|
Raises:
|
|
ValueError: For invalid paths (null bytes, bad extensions, oversized).
|
|
FileNotFoundError: When the resolved path does not exist.
|
|
"""
|
|
if '\x00' in filepath:
|
|
raise ValueError("Invalid file path: null byte detected")
|
|
|
|
resolved = Path(filepath).resolve()
|
|
|
|
if not resolved.exists():
|
|
raise FileNotFoundError(f"File not found: {filepath}")
|
|
|
|
# .fcpxmld bundles are directories (a package wrapping Info.fcpxml plus
|
|
# sidecar data files for object tracking / Cinematic mode). The size
|
|
# check applies to the inner Info.fcpxml, which is what gets parsed.
|
|
if resolved.is_dir():
|
|
if resolved.suffix.lower() != '.fcpxmld':
|
|
raise ValueError(f"Not a regular file: {filepath}")
|
|
inner = resolved / 'Info.fcpxml'
|
|
if not inner.is_file():
|
|
raise ValueError(f"Invalid bundle (no Info.fcpxml): {filepath}")
|
|
size_target = inner
|
|
elif not resolved.is_file():
|
|
raise ValueError(f"Not a regular file: {filepath}")
|
|
else:
|
|
size_target = resolved
|
|
|
|
if allowed_extensions and resolved.suffix.lower() not in allowed_extensions:
|
|
raise ValueError(
|
|
f"Invalid file type '{resolved.suffix}'. "
|
|
f"Allowed: {', '.join(allowed_extensions)}"
|
|
)
|
|
|
|
if size_target.stat().st_size > MAX_FILE_SIZE:
|
|
size_mb = size_target.stat().st_size / (1024 * 1024)
|
|
raise ValueError(f"File too large ({size_mb:.1f} MB). Maximum: {MAX_FILE_SIZE // (1024 * 1024)} MB")
|
|
|
|
return str(resolved)
|
|
|
|
|
|
def _validate_output_path(output_path: str, *, anchor_dir: str | None = None) -> str:
|
|
"""Validate an output path with optional sandbox enforcement.
|
|
|
|
Resolves traversal, blocks null bytes, ensures parent exists, and — when
|
|
*anchor_dir* is provided — verifies the resolved output lives under that
|
|
directory. This prevents LLM-generated tool calls from writing to
|
|
arbitrary filesystem locations (e.g. ``/etc/cron.d/backdoor``).
|
|
|
|
Args:
|
|
output_path: The raw output path to validate.
|
|
anchor_dir: If set, the resolved output must be a child of this
|
|
directory. Typically the parent directory of the input file so
|
|
outputs stay co-located with their sources.
|
|
|
|
Raises:
|
|
ValueError: For null bytes, missing parent, or sandbox escape.
|
|
"""
|
|
if '\x00' in output_path:
|
|
raise ValueError("Invalid output path: null byte detected")
|
|
|
|
resolved = Path(output_path).resolve()
|
|
|
|
if not resolved.parent.exists():
|
|
raise ValueError(f"Output directory does not exist: {resolved.parent}")
|
|
|
|
if anchor_dir is not None:
|
|
anchor = Path(anchor_dir).resolve()
|
|
try:
|
|
resolved.relative_to(anchor)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Output path escapes allowed directory: "
|
|
f"{resolved} is not under {anchor}"
|
|
)
|
|
|
|
return str(resolved)
|
|
|
|
|
|
def _validate_directory(directory: str, *, allowed_root: str | None = None) -> str:
|
|
"""Validate a user-provided directory path against traversal and injection.
|
|
|
|
Resolves symlinks, blocks null bytes, and verifies the path is a real
|
|
directory. When *allowed_root* is given, the resolved path must be a
|
|
descendant of (or equal to) that root — preventing filesystem enumeration
|
|
beyond the project workspace.
|
|
|
|
Raises:
|
|
ValueError: For invalid paths (null bytes, not a directory, sandbox escape).
|
|
"""
|
|
if '\x00' in directory:
|
|
raise ValueError("Invalid directory path: null byte detected")
|
|
|
|
resolved = Path(directory).resolve()
|
|
|
|
if not resolved.is_dir():
|
|
raise ValueError(f"Not a valid directory: {directory}")
|
|
|
|
if allowed_root is not None:
|
|
root = Path(allowed_root).resolve()
|
|
try:
|
|
resolved.relative_to(root)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Directory escapes allowed root: "
|
|
f"{resolved} is not under {root}"
|
|
)
|
|
|
|
return str(resolved)
|
|
|
|
|
|
# ============================================================================
|
|
# UTILITIES
|
|
# ============================================================================
|
|
|
|
def find_fcpxml_files(directory: str) -> list[str]:
|
|
"""Find all FCPXML files in a directory."""
|
|
path = Path(directory)
|
|
files = list(str(f) for f in path.rglob("*.fcpxml"))
|
|
files.extend(str(f) for f in path.rglob("*.fcpxmld"))
|
|
return sorted(files)
|
|
|
|
|
|
def format_timecode(tc) -> str:
|
|
"""Format a Timecode object to SMPTE string."""
|
|
return tc.to_smpte() if tc else "00:00:00:00"
|
|
|
|
|
|
def format_duration(seconds: float) -> str:
|
|
"""Format seconds into human-readable duration."""
|
|
if seconds < 1:
|
|
return f"{seconds*1000:.0f}ms"
|
|
elif seconds < 60:
|
|
return f"{seconds:.2f}s"
|
|
return f"{int(seconds // 60)}m {seconds % 60:.1f}s"
|
|
|
|
|
|
def _format_clip_table(clips: list, header: str) -> str:
|
|
"""Render a list of clips as a markdown table with timecodes and durations.
|
|
|
|
Shared by handlers that filter clips by duration threshold
|
|
(find_short_cuts, find_long_clips).
|
|
"""
|
|
result = f"{header}\n\n| Name | TC | Duration |\n|------|----|---------|\n"
|
|
result += "\n".join(
|
|
f"| {c.name} | {format_timecode(c.start)} | {format_duration(c.duration_seconds)} |"
|
|
for c in clips
|
|
)
|
|
return result
|
|
|
|
|
|
def _markdown_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
"""Build a markdown table from headers and rows.
|
|
|
|
Returns header row, separator row, and data rows as a single string.
|
|
Callers avoid repeating the ``| H1 | H2 |\\n|---|---|`` boilerplate
|
|
that appears in 15+ handlers.
|
|
"""
|
|
header_line = "| " + " | ".join(headers) + " |"
|
|
sep_line = "|" + "|".join("------" for _ in headers) + "|"
|
|
data_lines = "\n".join(
|
|
"| " + " | ".join(str(c) for c in row) + " |" for row in rows
|
|
)
|
|
return f"{header_line}\n{sep_line}\n{data_lines}"
|
|
|
|
|
|
def _format_batch_result(
|
|
title: str,
|
|
summary: dict[str, str],
|
|
headers: list[str],
|
|
rows: list[list[str]],
|
|
output_path: str,
|
|
) -> str:
|
|
"""Build a standard batch-operation result with summary, table, and save footer.
|
|
|
|
Used by batch fix handlers (flash frames, rapid trim, fill gaps) that all
|
|
share the same markdown structure: ``# Title → ## Summary → ## Details table
|
|
→ Saved to`` footer.
|
|
"""
|
|
summary_lines = "\n".join(f"- **{k}**: {v}" for k, v in summary.items())
|
|
table = _markdown_table(headers, rows)
|
|
return (
|
|
f"# {title}\n\n"
|
|
f"## Summary\n{summary_lines}\n\n"
|
|
f"## Details\n{table}\n\n"
|
|
f"Saved to: `{output_path}`"
|
|
)
|
|
|
|
|
|
def _fmt_suggestions(suggestions: list[str]) -> str:
|
|
"""Format pacing suggestions as markdown list (Python 3.10 compatible)."""
|
|
if not suggestions:
|
|
return "- Pacing looks good!"
|
|
nl = "\n"
|
|
return nl.join(f"- {s}" for s in suggestions)
|
|
|
|
|
|
def generate_output_path(input_path: str, suffix: str = "_modified") -> str:
|
|
"""Generate output path from input path.
|
|
|
|
The suffix is sanitized to prevent path-component injection — only
|
|
alphanumeric, hyphen, underscore, and dot characters survive.
|
|
"""
|
|
# Strip anything that could inject path separators or traversal sequences
|
|
clean_suffix = re.sub(r'[^a-zA-Z0-9._-]', '', suffix)
|
|
if not clean_suffix:
|
|
clean_suffix = "_modified"
|
|
p = Path(input_path)
|
|
return str(p.parent / f"{p.stem}{clean_suffix}{p.suffix}")
|
|
|
|
|
|
def _parse_project(filepath: str):
|
|
"""Parse an FCPXML file and return the project with its primary timeline."""
|
|
filepath = _validate_filepath(filepath, ('.fcpxml', '.fcpxmld'))
|
|
project = FCPXMLParser().parse_file(filepath)
|
|
if not project.timelines:
|
|
return None, None
|
|
return project, project.primary_timeline
|
|
|
|
|
|
def _text_result(text: str) -> list[TextContent]:
|
|
"""Wrap a string in the MCP TextContent list that every tool handler returns."""
|
|
return [TextContent(type="text", text=text)]
|
|
|
|
|
|
def _no_timeline():
|
|
"""Standard response when no timelines are found."""
|
|
return _text_result("No timelines found")
|
|
|
|
|
|
def _require_timeline(filepath: str):
|
|
"""Parse FCPXML and return (project, timeline), raising if no timeline exists.
|
|
|
|
Centralises the repeated _parse_project + _no_timeline guard that
|
|
appears in every read-only timeline handler. Returns a tuple so
|
|
callers can destructure directly::
|
|
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
"""
|
|
project, tl = _parse_project(filepath)
|
|
if not tl:
|
|
raise _NoTimelineError()
|
|
return project, tl
|
|
|
|
|
|
class _NoTimelineError(Exception):
|
|
"""Sentinel raised by _require_timeline when no timelines exist."""
|
|
|
|
|
|
def _resolve_io_paths(
|
|
arguments: dict,
|
|
suffix: str = "_modified",
|
|
) -> tuple[str, str]:
|
|
"""Validate input filepath and resolve the output path.
|
|
|
|
Shared foundation for every handler that reads an FCPXML and writes
|
|
a derived file. Validates the input, falls back to a suffixed
|
|
output name when ``output_path`` is not supplied, and sandbox-checks
|
|
the result.
|
|
|
|
Args:
|
|
arguments: Tool arguments dict (must contain ``filepath``; may
|
|
contain ``output_path``).
|
|
suffix: Default output filename suffix when ``output_path`` is
|
|
not provided (e.g. ``"_modified"``, ``"_beats"``).
|
|
|
|
Returns:
|
|
``(filepath, output_path)`` tuple with both paths validated.
|
|
"""
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
# Anchor write operations to the input file's directory so LLM-generated
|
|
# tool calls cannot write to arbitrary filesystem locations (e.g.
|
|
# /etc/cron.d/backdoor). When the explicit sandbox is off, the anchor
|
|
# still prevents writes outside the source directory tree.
|
|
output_dir = arguments.get("output_dir")
|
|
anchor = _validate_directory(str(output_dir)) if output_dir else str(Path(filepath).resolve().parent)
|
|
output_path = _validate_output_path(
|
|
arguments.get("output_path") or generate_output_path(filepath, suffix),
|
|
anchor_dir=anchor,
|
|
)
|
|
return filepath, output_path
|
|
|
|
|
|
def _setup_modifier(
|
|
arguments: dict,
|
|
suffix: str = "_modified",
|
|
) -> tuple[str, str, "FCPXMLModifier"]:
|
|
"""Common setup for write handlers: validate paths and create modifier.
|
|
|
|
Consolidates the repeated validate-filepath → resolve-output-path →
|
|
create-modifier boilerplate shared by 18+ write handlers.
|
|
|
|
Args:
|
|
arguments: Tool arguments dict (must contain ``filepath``; may
|
|
contain ``output_path``).
|
|
suffix: Default output filename suffix when ``output_path`` is
|
|
not provided (e.g. ``"_modified"``, ``"_flash_fixed"``).
|
|
|
|
Returns:
|
|
``(filepath, output_path, modifier)`` tuple ready for the
|
|
handler's domain-specific operation.
|
|
"""
|
|
filepath, output_path = _resolve_io_paths(arguments, suffix)
|
|
modifier = FCPXMLModifier(filepath)
|
|
return filepath, output_path, modifier
|
|
|
|
|
|
def _setup_generator(
|
|
arguments: dict,
|
|
suffix: str = "_roughcut",
|
|
) -> tuple[str, str, "RoughCutGenerator"]:
|
|
"""Common setup for generation handlers: validate paths and create generator.
|
|
|
|
Args:
|
|
arguments: Tool arguments dict (must contain ``filepath`` and
|
|
``output_path``).
|
|
suffix: Default output filename suffix.
|
|
|
|
Returns:
|
|
``(filepath, output_path, generator)`` tuple.
|
|
"""
|
|
filepath, output_path = _resolve_io_paths(arguments, suffix)
|
|
generator = RoughCutGenerator(filepath)
|
|
return filepath, output_path, generator
|
|
|
|
|
|
def _parse_timestamp_parts(
|
|
parts: list[str], *, frame_rate: float = 24.0
|
|
) -> float | None:
|
|
"""Convert colon-separated timestamp parts to total seconds.
|
|
|
|
Handles 2-part (M:SS), 3-part (H:MM:SS / HH:MM:SS.ms), and
|
|
4-part (HH:MM:SS:FF SMPTE) formats. Returns ``None`` when the
|
|
part count is unrecognised so callers can skip.
|
|
|
|
Args:
|
|
parts: Colon-split timestamp components.
|
|
frame_rate: FPS used to convert the frame component of SMPTE
|
|
timecodes into fractional seconds (default 24.0).
|
|
"""
|
|
if len(parts) == 2:
|
|
return int(parts[0]) * 60 + float(parts[1])
|
|
elif len(parts) == 3:
|
|
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
|
|
elif len(parts) == 4:
|
|
# SMPTE: HH:MM:SS:FF — convert frames to fractional seconds
|
|
base = int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
|
|
frames = int(parts[3])
|
|
return base + (frames / frame_rate) if frame_rate > 0 else base
|
|
return None
|
|
|
|
|
|
def _raw_markers_to_batch(
|
|
raw_markers: list[dict],
|
|
marker_type: str = "chapter",
|
|
max_label: int | None = None,
|
|
) -> list[dict]:
|
|
"""Convert raw {seconds, text} marker dicts to batch_add_markers format.
|
|
|
|
Shared by import_srt_markers and import_transcript_markers.
|
|
"""
|
|
batch = []
|
|
for m in raw_markers:
|
|
label = m["text"]
|
|
if max_label and len(label) > max_label:
|
|
label = label[:max_label]
|
|
batch.append({
|
|
"timecode": f"{m['seconds']}s",
|
|
"name": label,
|
|
"marker_type": marker_type.upper(),
|
|
})
|
|
return batch
|
|
|
|
|
|
def _extract_subtitle_blocks(text: str, *, strip_vtt_tags: bool = False) -> list[dict]:
|
|
"""Extract timestamp/text pairs from subtitle cue blocks (SRT or VTT).
|
|
|
|
Both SRT and VTT use the same ``start --> end`` cue syntax with
|
|
text lines underneath; only header stripping and tag cleaning differ.
|
|
"""
|
|
markers = []
|
|
blocks = re.split(r'\n\s*\n', text.strip())
|
|
for block in blocks:
|
|
lines = block.strip().split('\n')
|
|
if len(lines) < 2:
|
|
continue
|
|
ts_line = None
|
|
text_lines = []
|
|
for line in lines:
|
|
if '-->' in line:
|
|
ts_line = line
|
|
elif ts_line is not None:
|
|
if strip_vtt_tags:
|
|
line = re.sub(r'<[^>]+>', '', line)
|
|
cleaned = line.strip()
|
|
if cleaned:
|
|
text_lines.append(cleaned)
|
|
if not ts_line or not text_lines:
|
|
continue
|
|
start_str = ts_line.split('-->')[0].strip().replace(',', '.')
|
|
seconds = _parse_timestamp_parts(start_str.split(':'))
|
|
if seconds is not None:
|
|
markers.append({'seconds': seconds, 'text': ' '.join(text_lines)})
|
|
return markers
|
|
|
|
|
|
def parse_srt(text: str) -> list[dict]:
|
|
"""Parse SRT subtitle format into timestamp/text pairs."""
|
|
return _extract_subtitle_blocks(text)
|
|
|
|
|
|
def parse_vtt(text: str) -> list[dict]:
|
|
"""Parse WebVTT subtitle format into timestamp/text pairs."""
|
|
text = re.sub(r'^WEBVTT.*?\n', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'NOTE\n.*?\n\n', '', text, flags=re.DOTALL)
|
|
return _extract_subtitle_blocks(text, strip_vtt_tags=True)
|
|
|
|
|
|
def parse_transcript_timestamps(text: str) -> list[dict]:
|
|
"""Parse timestamped text (YouTube description format) into markers.
|
|
|
|
Supports formats like:
|
|
0:00 Introduction
|
|
00:01:30 Main Topic
|
|
1:05:30 Conclusion
|
|
00:00:00:00 SMPTE timecode
|
|
"""
|
|
markers = []
|
|
for line in text.strip().split('\n'):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
match = re.match(r'^(\d{1,2}:\d{2}(?::\d{2}){0,2})\s+(.+)$', line)
|
|
if match:
|
|
seconds = _parse_timestamp_parts(match.group(1).split(':'))
|
|
if seconds is not None:
|
|
markers.append({'seconds': seconds, 'text': match.group(2).strip()})
|
|
return markers
|
|
|
|
|
|
# ============================================================================
|
|
# MCP RESOURCES — File discovery
|
|
# ============================================================================
|
|
|
|
@server.list_resources()
|
|
async def list_resources() -> list[Resource]:
|
|
"""Expose discovered FCPXML files as MCP resources."""
|
|
files = find_fcpxml_files(PROJECTS_DIR)
|
|
resources = []
|
|
for f in files:
|
|
p = Path(f)
|
|
resources.append(Resource(
|
|
uri=f"file://{f}",
|
|
name=p.stem,
|
|
description=f"FCPXML project: {p.name} ({format_duration(0)})",
|
|
mimeType="application/xml",
|
|
))
|
|
return resources
|
|
|
|
|
|
@server.read_resource()
|
|
async def read_resource(uri: str) -> str:
|
|
"""Read an FCPXML file and return a summary."""
|
|
filepath = str(uri).replace("file://", "")
|
|
try:
|
|
filepath = _validate_filepath(filepath, ('.fcpxml', '.fcpxmld'))
|
|
except (ValueError, FileNotFoundError) as e:
|
|
return str(e)
|
|
|
|
project, tl = _parse_project(filepath)
|
|
if not tl:
|
|
return f"No timelines found in {filepath}"
|
|
|
|
return f"""FCPXML Project: {tl.name}
|
|
Duration: {format_duration(tl.duration.seconds)}
|
|
Resolution: {tl.width}x{tl.height} @ {tl.frame_rate}fps
|
|
Clips: {tl.total_clips}
|
|
Markers: {len(tl.markers)}
|
|
Cuts/min: {tl.cuts_per_minute:.1f}
|
|
Path: {filepath}"""
|
|
|
|
|
|
# ============================================================================
|
|
# MCP PROMPTS — Pre-built workflows
|
|
# ============================================================================
|
|
|
|
@server.list_prompts()
|
|
async def list_prompts() -> list[Prompt]:
|
|
return [
|
|
Prompt(
|
|
name="qc-check",
|
|
description="Run a full quality control check on your timeline — flash frames, gaps, duplicates, and health score",
|
|
arguments=[
|
|
PromptArgument(name="filepath", description="Path to FCPXML file", required=True),
|
|
],
|
|
),
|
|
Prompt(
|
|
name="youtube-chapters",
|
|
description="Extract chapter markers formatted for YouTube descriptions",
|
|
arguments=[
|
|
PromptArgument(name="filepath", description="Path to FCPXML file", required=True),
|
|
],
|
|
),
|
|
Prompt(
|
|
name="rough-cut",
|
|
description="Guided rough cut generation — choose keywords, pacing, and duration",
|
|
arguments=[
|
|
PromptArgument(name="filepath", description="Path to source FCPXML with clips", required=True),
|
|
PromptArgument(name="duration", description="Target duration (e.g., '3m', '90s')", required=True),
|
|
],
|
|
),
|
|
Prompt(
|
|
name="timeline-summary",
|
|
description="Quick overview of a timeline — stats, pacing, and potential issues",
|
|
arguments=[
|
|
PromptArgument(name="filepath", description="Path to FCPXML file", required=True),
|
|
],
|
|
),
|
|
Prompt(
|
|
name="cleanup",
|
|
description="Find and fix common timeline issues — flash frames, gaps, and duplicates",
|
|
arguments=[
|
|
PromptArgument(name="filepath", description="Path to FCPXML file", required=True),
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
@server.get_prompt()
|
|
async def get_prompt(name: str, arguments: dict[str, str] | None = None) -> GetPromptResult:
|
|
args = arguments or {}
|
|
filepath = args.get("filepath", "<path to your .fcpxml file>")
|
|
|
|
if name == "qc-check":
|
|
return GetPromptResult(
|
|
description="Full QC check on timeline",
|
|
messages=[PromptMessage(
|
|
role="user",
|
|
content=TextContent(
|
|
type="text",
|
|
text=f"""Run a complete quality control check on my timeline.
|
|
|
|
File: {filepath}
|
|
|
|
Please:
|
|
1. Use `validate_timeline` to get the health score
|
|
2. Use `detect_flash_frames` to find any ultra-short clips
|
|
3. Use `detect_gaps` to find unintentional gaps
|
|
4. Use `detect_duplicates` to find repeated source clips
|
|
5. Summarize all issues and recommend fixes
|
|
|
|
If there are critical issues, offer to fix them automatically with `fix_flash_frames` and `fill_gaps`."""
|
|
),
|
|
)],
|
|
)
|
|
|
|
elif name == "youtube-chapters":
|
|
return GetPromptResult(
|
|
description="Export YouTube chapter markers",
|
|
messages=[PromptMessage(
|
|
role="user",
|
|
content=TextContent(
|
|
type="text",
|
|
text=f"""Extract chapter markers from my timeline and format them for YouTube.
|
|
|
|
File: {filepath}
|
|
|
|
Please:
|
|
1. Use `list_markers` with format "youtube" to get chapter timestamps
|
|
2. Format the output so I can copy-paste directly into a YouTube description
|
|
3. If there are no chapter markers, suggest good chapter points based on the timeline structure using `analyze_pacing`"""
|
|
),
|
|
)],
|
|
)
|
|
|
|
elif name == "rough-cut":
|
|
duration = args.get("duration", "3m")
|
|
return GetPromptResult(
|
|
description="Guided rough cut generation",
|
|
messages=[PromptMessage(
|
|
role="user",
|
|
content=TextContent(
|
|
type="text",
|
|
text=f"""Help me create a rough cut from my source clips.
|
|
|
|
File: {filepath}
|
|
Target duration: {duration}
|
|
|
|
Please:
|
|
1. Use `list_library_clips` to show me what clips are available
|
|
2. Use `list_keywords` to show me the tags I can filter by
|
|
3. Suggest a structure (segments, pacing) based on what's available
|
|
4. Generate the rough cut with `auto_rough_cut` using my preferences
|
|
5. Show me a summary of what was created"""
|
|
),
|
|
)],
|
|
)
|
|
|
|
elif name == "timeline-summary":
|
|
return GetPromptResult(
|
|
description="Quick timeline overview",
|
|
messages=[PromptMessage(
|
|
role="user",
|
|
content=TextContent(
|
|
type="text",
|
|
text=f"""Give me a quick overview of my timeline.
|
|
|
|
File: {filepath}
|
|
|
|
Please:
|
|
1. Use `analyze_timeline` for stats (duration, resolution, clip count)
|
|
2. Use `analyze_pacing` for pacing metrics and suggestions
|
|
3. Use `list_keywords` to show what tags are in use
|
|
4. Use `list_markers` to show any markers
|
|
5. Give me a brief assessment of the edit"""
|
|
),
|
|
)],
|
|
)
|
|
|
|
elif name == "cleanup":
|
|
return GetPromptResult(
|
|
description="Find and fix timeline issues",
|
|
messages=[PromptMessage(
|
|
role="user",
|
|
content=TextContent(
|
|
type="text",
|
|
text=f"""Help me clean up my timeline by finding and fixing common issues.
|
|
|
|
File: {filepath}
|
|
|
|
Please:
|
|
1. Use `validate_timeline` to get the health score
|
|
2. If there are flash frames, use `fix_flash_frames` to remove them
|
|
3. If there are gaps, use `fill_gaps` to close them
|
|
4. Report what was fixed and the new health score"""
|
|
),
|
|
)],
|
|
)
|
|
|
|
raise ValueError(f"Unknown prompt: {name}")
|
|
|
|
|
|
# ============================================================================
|
|
# TOOL DEFINITIONS
|
|
# ============================================================================
|
|
|
|
@server.list_tools()
|
|
async def list_tools() -> list[Tool]:
|
|
return [
|
|
# ===== READ TOOLS =====
|
|
Tool(
|
|
name="list_projects",
|
|
description="List all FCPXML projects in directory",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"directory": {"type": "string", "description": "Directory to search (default: ~/Movies)"}
|
|
}
|
|
}
|
|
),
|
|
Tool(
|
|
name="analyze_timeline",
|
|
description="Get comprehensive timeline statistics including duration, resolution, clip count, pacing metrics",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string", "description": "Path to FCPXML file"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_clips",
|
|
description="List all clips with timecodes, durations, and metadata",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"limit": {"type": "integer", "description": "Max clips to return"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_markers",
|
|
description="Extract markers (chapter, todo, standard) with timestamps",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"marker_type": {"type": "string", "enum": ["all", "chapter", "todo", "standard", "completed"]},
|
|
"format": {"type": "string", "enum": ["detailed", "youtube", "simple"]}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="find_short_cuts",
|
|
description="Find clips shorter than threshold (flash frame detection)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"threshold_seconds": {"type": "number", "default": 0.5}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="find_long_clips",
|
|
description="Find clips longer than threshold",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"threshold_seconds": {"type": "number", "default": 10.0}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_keywords",
|
|
description="Extract all keywords/tags from project",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="export_edl",
|
|
description="Generate EDL (Edit Decision List) from timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="export_csv",
|
|
description="Export timeline data to CSV format",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"include": {"type": "array", "items": {"type": "string"}}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="analyze_pacing",
|
|
description="Analyze edit pacing with suggestions for improvements",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_library_clips",
|
|
description="List all available clips in the library (source media, not yet on timeline)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}, "description": "Filter by keywords"},
|
|
"limit": {"type": "integer", "description": "Max clips to return"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== QC / VALIDATION TOOLS =====
|
|
Tool(
|
|
name="detect_flash_frames",
|
|
description="Find ultra-short clips (flash frames) that are likely errors, with severity categorization",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"critical_threshold_frames": {"type": "integer", "default": 2, "description": "Frames below this = critical (default: 2)"},
|
|
"warning_threshold_frames": {"type": "integer", "default": 6, "description": "Frames below this = warning (default: 6)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_duplicates",
|
|
description="Find clips using the same source media (potential duplicates)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["same_source", "overlapping_ranges", "identical"], "default": "same_source", "description": "Detection mode"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_gaps",
|
|
description="Find unintentional gaps in the timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"min_gap_frames": {"type": "integer", "default": 1, "description": "Minimum gap size to detect (default: 1 frame)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== WRITE TOOLS =====
|
|
Tool(
|
|
name="add_marker",
|
|
description="Add a marker at a specific timecode",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"timecode": {"type": "string", "description": "Position (00:00:10:00 or 10s)"},
|
|
"name": {"type": "string", "description": "Marker label"},
|
|
"marker_type": {"type": "string", "enum": ["standard", "chapter", "todo", "completed"], "default": "standard"},
|
|
"note": {"type": "string", "description": "Optional note"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath", "timecode", "name"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="batch_add_markers",
|
|
description="Add multiple markers at once, or auto-generate at cuts/intervals",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"markers": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"timecode": {"type": "string"},
|
|
"name": {"type": "string"},
|
|
"marker_type": {"type": "string"},
|
|
"note": {"type": "string"}
|
|
}
|
|
},
|
|
"description": "List of markers to add"
|
|
},
|
|
"auto_at_cuts": {"type": "boolean", "description": "Add marker at every cut"},
|
|
"auto_at_intervals": {"type": "string", "description": "Add markers every N seconds (e.g., '30s')"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="trim_clip",
|
|
description="Trim a clip's in-point and/or out-point",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_id": {"type": "string", "description": "Clip name or ID"},
|
|
"trim_start": {"type": "string", "description": "New in-point or delta (+1s, -10f)"},
|
|
"trim_end": {"type": "string", "description": "New out-point or delta"},
|
|
"ripple": {"type": "boolean", "default": True, "description": "Shift subsequent clips"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_id"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="reorder_clips",
|
|
description="Move clips to a new position in the timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_ids": {"type": "array", "items": {"type": "string"}, "description": "Clips to move"},
|
|
"target_position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_id'"},
|
|
"ripple": {"type": "boolean", "default": True},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_ids", "target_position"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="add_transition",
|
|
description="Add a transition between clips",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_id": {"type": "string", "description": "Clip to add transition to"},
|
|
"position": {"type": "string", "enum": ["start", "end", "both"], "default": "end"},
|
|
"transition_type": {"type": "string", "enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "wipe"], "default": "cross-dissolve"},
|
|
"duration": {"type": "string", "default": "00:00:00:15"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_id"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="change_speed",
|
|
description="Change clip playback speed (slow motion or speed up)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_id": {"type": "string"},
|
|
"speed": {"type": "number", "description": "Speed multiplier (0.5 = half, 2.0 = double)"},
|
|
"preserve_pitch": {"type": "boolean", "default": True},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_id", "speed"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="add_zoom",
|
|
description="Add a smooth ease-in/ease-out punch-in zoom to a clip, animating <adjust-transform>'s scale param via keyframes (100% -> scale -> 100%) entirely within [start, end] (clip-relative seconds, i.e. seconds from the clip's own head). The ease portions each last `ease` seconds; the zoom holds at `scale` in between. Replaces any existing zoom on the same clip rather than stacking.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_id": {"type": "string", "description": "Name/ID of the clip to zoom"},
|
|
"start": {"type": "number", "description": "Clip-relative seconds where the ease-in begins"},
|
|
"end": {"type": "number", "description": "Clip-relative seconds where the ease-out ends (back to 100%)"},
|
|
"scale": {"type": "number", "default": 1.3, "description": "Zoom scale, e.g. 1.3 = 130%"},
|
|
"ease": {"type": "number", "default": 0.3, "description": "Seconds for each of the ease-in/ease-out portions (must fit: 2*ease <= end-start)"},
|
|
"position": {"type": "string", "default": "0 0", "description": "Optional pan offset \"x y\" applied for the duration of the transform"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_id", "start", "end"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="delete_clips",
|
|
description="Delete clips from timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_ids": {"type": "array", "items": {"type": "string"}},
|
|
"ripple": {"type": "boolean", "default": True, "description": "Close gaps after deletion"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_ids"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="split_clip",
|
|
description="Split a clip at specified timecodes",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"clip_id": {"type": "string"},
|
|
"split_points": {"type": "array", "items": {"type": "string"}, "description": "Timecodes to split at"},
|
|
"output_path": {"type": "string"}
|
|
},
|
|
"required": ["filepath", "clip_id", "split_points"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="insert_clip",
|
|
description="Insert a library clip onto the timeline at a specific position",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"asset_id": {"type": "string", "description": "Asset reference ID (e.g., 'r3')"},
|
|
"asset_name": {"type": "string", "description": "Asset name (alternative to asset_id)"},
|
|
"position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_name'"},
|
|
"duration": {"type": "string", "description": "Clip duration (if not using in/out points)"},
|
|
"in_point": {"type": "string", "description": "Source in-point for subclip"},
|
|
"out_point": {"type": "string", "description": "Source out-point for subclip"},
|
|
"ripple": {"type": "boolean", "default": True, "description": "Shift subsequent clips"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath", "position"]
|
|
}
|
|
),
|
|
|
|
# ===== BATCH FIX TOOLS =====
|
|
Tool(
|
|
name="fix_flash_frames",
|
|
description="Automatically fix detected flash frames by extending neighbors or deleting",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["extend_previous", "extend_next", "delete", "auto"], "default": "auto", "description": "How to fix: extend previous/next clip, delete, or auto"},
|
|
"threshold_frames": {"type": "integer", "default": 6, "description": "Frames below this threshold are flash frames"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="rapid_trim",
|
|
description="Batch trim clips to a maximum duration for fast-paced montages",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"max_duration": {"type": "string", "description": "Maximum clip duration (e.g., '2s', '00:00:02:00')"},
|
|
"min_duration": {"type": "string", "description": "Minimum clip duration (optional)"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}, "description": "Only trim clips with these keywords"},
|
|
"trim_from": {"type": "string", "enum": ["start", "end", "center"], "default": "end", "description": "Where to trim from"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath", "max_duration"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="fill_gaps",
|
|
description="Automatically fill gaps in the timeline by extending adjacent clips",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["extend_previous", "extend_next", "delete"], "default": "extend_previous", "description": "How to fill gaps"},
|
|
"max_gap": {"type": "string", "description": "Only fill gaps smaller than this (e.g., '1s')"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="validate_timeline",
|
|
description="Comprehensive timeline health check for flash frames, gaps, duplicates, and issues",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"checks": {"type": "array", "items": {"type": "string", "enum": ["all", "flash_frames", "gaps", "duplicates", "offsets"]}, "default": ["all"], "description": "Which checks to run"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== GENERATION TOOLS =====
|
|
Tool(
|
|
name="auto_rough_cut",
|
|
description="Generate a rough cut from source clips based on keywords, duration, and pacing",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Source FCPXML with clips"},
|
|
"output_path": {"type": "string", "description": "Where to save rough cut"},
|
|
"target_duration": {"type": "string", "description": "Target length (3m, 00:03:00:00)"},
|
|
"pacing": {"type": "string", "enum": ["slow", "medium", "fast", "dynamic"], "default": "medium"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}, "description": "Filter clips by keywords"},
|
|
"segments": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}},
|
|
"duration": {"type": "number"}
|
|
}
|
|
},
|
|
"description": "Segment structure [{name, keywords, duration_seconds}]"
|
|
},
|
|
"priority": {"type": "string", "enum": ["best", "favorites", "longest", "shortest", "random"], "default": "best"},
|
|
"favorites_only": {"type": "boolean", "default": False},
|
|
"add_transitions": {"type": "boolean", "default": False}
|
|
},
|
|
"required": ["filepath", "output_path", "target_duration"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="generate_montage",
|
|
description="Create rapid-fire montages with pacing curves (accelerating, decelerating, pyramid)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Source FCPXML with clips"},
|
|
"output_path": {"type": "string", "description": "Where to save montage"},
|
|
"target_duration": {"type": "string", "description": "Total montage length (e.g., '30s', '00:00:30:00')"},
|
|
"pacing_curve": {"type": "string", "enum": ["accelerating", "decelerating", "pyramid", "constant"], "default": "accelerating", "description": "How clip duration changes over time"},
|
|
"start_duration": {"type": "number", "default": 2.0, "description": "Clip duration at start (seconds)"},
|
|
"end_duration": {"type": "number", "default": 0.5, "description": "Clip duration at end (seconds)"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}, "description": "Filter clips by keywords"},
|
|
"add_transitions": {"type": "boolean", "default": False, "description": "Add quick dissolves"}
|
|
},
|
|
"required": ["filepath", "output_path", "target_duration"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="generate_ab_roll",
|
|
description="Create documentary-style A/B roll edits alternating between main content and cutaways",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Source FCPXML with clips"},
|
|
"output_path": {"type": "string", "description": "Where to save A/B roll edit"},
|
|
"target_duration": {"type": "string", "description": "Total duration (e.g., '3m', '00:03:00:00')"},
|
|
"a_keywords": {"type": "array", "items": {"type": "string"}, "description": "Keywords for A-roll (main content, interviews)"},
|
|
"b_keywords": {"type": "array", "items": {"type": "string"}, "description": "Keywords for B-roll (cutaways, visuals)"},
|
|
"a_duration": {"type": "string", "default": "5s", "description": "Duration of each A-roll segment"},
|
|
"b_duration": {"type": "string", "default": "3s", "description": "Duration of each B-roll cutaway"},
|
|
"start_with": {"type": "string", "enum": ["a", "b"], "default": "a", "description": "Which roll to start with"},
|
|
"add_transitions": {"type": "boolean", "default": True, "description": "Add cross-dissolves"}
|
|
},
|
|
"required": ["filepath", "output_path", "target_duration", "a_keywords", "b_keywords"]
|
|
}
|
|
),
|
|
|
|
# ===== BEAT SYNC TOOLS =====
|
|
Tool(
|
|
name="import_beat_markers",
|
|
description="Import beat markers from external audio analysis (JSON format)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"beats_path": {"type": "string", "description": "Path to beats JSON file"},
|
|
"marker_type": {"type": "string", "enum": ["standard", "chapter"], "default": "standard"},
|
|
"beat_filter": {"type": "string", "enum": ["all", "downbeat", "measure"], "default": "all", "description": "Which beats to import"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _beats suffix)"}
|
|
},
|
|
"required": ["filepath", "beats_path"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="snap_to_beats",
|
|
description="Align cuts to nearest beat markers for music-synced edits",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file with beat markers"},
|
|
"max_shift_frames": {"type": "integer", "default": 6, "description": "Maximum frames to shift a cut"},
|
|
"prefer": {"type": "string", "enum": ["earlier", "later", "nearest"], "default": "nearest", "description": "Which beat to prefer when equidistant"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _synced suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== SUBTITLE / TRANSCRIPT TOOLS =====
|
|
Tool(
|
|
name="import_srt_markers",
|
|
description="Import SRT or VTT subtitles as chapter markers on the timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"srt_path": {"type": "string", "description": "Path to SRT or VTT subtitle file"},
|
|
"mode": {"type": "string", "enum": ["all", "first_per_minute", "scene_changes"], "default": "first_per_minute", "description": "How to create markers: every subtitle, first per minute, or on text changes"},
|
|
"marker_type": {"type": "string", "enum": ["standard", "chapter"], "default": "chapter"},
|
|
"max_label_length": {"type": "integer", "default": 50, "description": "Truncate marker labels to this length"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _subtitled suffix)"}
|
|
},
|
|
"required": ["filepath", "srt_path"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="import_transcript_markers",
|
|
description="Import timestamped transcript (YouTube chapter format) as markers. Supports '0:00 Title' and 'HH:MM:SS Title' formats",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"transcript": {"type": "string", "description": "Timestamped text (one per line: '0:00 Introduction')"},
|
|
"transcript_path": {"type": "string", "description": "Path to text file with timestamps (alternative to inline transcript)"},
|
|
"marker_type": {"type": "string", "enum": ["standard", "chapter"], "default": "chapter"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _chapters suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== CONNECTED CLIPS & COMPOUND CLIPS (v0.5.0) =====
|
|
Tool(
|
|
name="list_connected_clips",
|
|
description="List all connected clips (B-roll, titles, audio) with their lanes and parent clips",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"lane": {"type": "integer", "description": "Filter by lane number (positive=above, negative=below)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="add_connected_clip",
|
|
description="Connect a library clip to an existing timeline clip (B-roll overlay, audio, title)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"parent_clip_id": {"type": "string", "description": "Name/ID of the clip to attach to"},
|
|
"asset_id": {"type": "string", "description": "Asset reference ID"},
|
|
"asset_name": {"type": "string", "description": "Asset name (alternative to asset_id)"},
|
|
"offset": {"type": "string", "default": "0s", "description": "Position relative to parent clip start"},
|
|
"duration": {"type": "string", "description": "Duration (default: full asset)"},
|
|
"lane": {"type": "integer", "default": 1, "description": "Lane number (positive=above, negative=below)"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath", "parent_clip_id"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_compound_clips",
|
|
description="List compound clips (ref-clips) and their nested content",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== ROLES MANAGEMENT (v0.5.0) =====
|
|
Tool(
|
|
name="list_roles",
|
|
description="List all audio/video roles used in the timeline with clip counts",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="assign_role",
|
|
description="Set the audio or video role on a clip (dialogue, music, effects, titles, etc.)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"clip_id": {"type": "string", "description": "Clip name or ID"},
|
|
"audio_role": {"type": "string", "description": "Audio role (e.g., dialogue, music, effects)"},
|
|
"video_role": {"type": "string", "description": "Video role (e.g., video, titles)"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"}
|
|
},
|
|
"required": ["filepath", "clip_id"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="filter_by_role",
|
|
description="List all clips matching a specific audio or video role",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"role": {"type": "string", "description": "Role name to filter by"},
|
|
"role_type": {"type": "string", "enum": ["audio", "video", "any"], "default": "any", "description": "Which role type to search"},
|
|
},
|
|
"required": ["filepath", "role"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="export_role_stems",
|
|
description="Export clip list grouped by role for audio mixing stem planning",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== TIMELINE DIFF (v0.5.0) =====
|
|
Tool(
|
|
name="diff_timelines",
|
|
description="Compare two FCPXML files and report differences in clips, markers, transitions, and format",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath_a": {"type": "string", "description": "Path to first FCPXML file (baseline)"},
|
|
"filepath_b": {"type": "string", "description": "Path to second FCPXML file (comparison)"},
|
|
},
|
|
"required": ["filepath_a", "filepath_b"]
|
|
}
|
|
),
|
|
|
|
# ===== SOCIAL MEDIA REFORMAT (v0.5.0) =====
|
|
Tool(
|
|
name="reformat_timeline",
|
|
description="Create new FCPXML with different resolution/aspect ratio (9:16 for TikTok, 1:1 for Instagram, etc.)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"format": {"type": "string", "enum": ["9:16", "1:1", "4:5", "16:9", "4:3", "custom"], "description": "Target format preset"},
|
|
"width": {"type": "integer", "description": "Custom width (only with format='custom')"},
|
|
"height": {"type": "integer", "description": "Custom height (only with format='custom')"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _reformatted suffix)"}
|
|
},
|
|
"required": ["filepath", "format"]
|
|
}
|
|
),
|
|
|
|
# ===== MEDIA INTELLIGENCE (v0.10.0) =====
|
|
Tool(
|
|
name="detect_media_silence",
|
|
description="Detect REAL silence by analyzing each clip's source audio with ffmpeg silencedetect, mapped into timeline time. Unlike detect_silence_candidates (XML-only heuristics), this reads the actual media files referenced by the timeline. Requires ffmpeg; clips whose media is missing or unreadable are reported, not failed.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"noise_db": {"type": "number", "default": -30.0, "description": "Silence threshold in dBFS, -120 to 0 (default -30)"},
|
|
"min_silence": {"type": "number", "default": 0.5, "description": "Minimum silence duration in seconds to report (default 0.5)"},
|
|
"clip_name": {"type": "string", "description": "Only analyze the clip with this name"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
Tool(
|
|
name="detect_beats",
|
|
description="Detect musical beats and tempo in an audio/video file (librosa beat tracker). Writes a beats JSON next to the media file that plugs directly into import_beat_markers + snap_to_beats for beat-synced editing. Requires the optional [intelligence] extra (librosa); degrades to an install hint without it.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"media_path": {"type": "string", "description": "Path to audio/video file (.wav, .mp3, .m4a, .aac, .aif, .flac, .mov, .mp4)"},
|
|
},
|
|
"required": ["media_path"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="remove_media_silence",
|
|
description="Detect REAL silence in each clip's source audio (ffmpeg) and CUT it out of the timeline with ripple. Clips are split around silence; the silent middles are removed and everything after shifts earlier. Non-destructive: writes a _silence_removed copy. Preview with detect_media_silence first.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"noise_db": {"type": "number", "default": -30.0, "description": "Silence threshold in dBFS, -120 to 0 (default -30)"},
|
|
"min_silence": {"type": "number", "default": 0.5, "description": "Minimum silence duration in seconds to cut (default 0.5)"},
|
|
"padding": {"type": "number", "default": 0.05, "description": "Seconds of silence to keep on each side of a cut so edits breathe (default 0.05, max 5)"},
|
|
"clip_name": {"type": "string", "description": "Only cut silence in the clip with this name"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _silence_removed suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== TRANSCRIPT INTELLIGENCE (v0.13.1) =====
|
|
Tool(
|
|
name="transcribe_media",
|
|
description="Transcribe each clip's source media locally with word-level timestamps (faster-whisper). Writes a _transcript.json next to each media file (reused by edit_by_transcript / remove_filler_words so media is only transcribed once) and optionally an SRT for captions. Requires the optional [transcribe] extra; degrades to an install hint without it.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"clip_name": {"type": "string", "description": "Only transcribe the clip with this name"},
|
|
"model": {"type": "string", "default": "base", "description": "Whisper model size: tiny, base, small, medium, large-v3 (default base; larger = slower + more accurate)"},
|
|
"language": {"type": "string", "description": "ISO language code hint (e.g. 'en'); auto-detected if omitted"},
|
|
"write_srt": {"type": "boolean", "default": False, "description": "Also write a _transcript.srt next to each media file (plugs into import_srt_markers)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="edit_by_transcript",
|
|
description="Text-based editing: 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 and cuts everything else in each matched clip (clips with no matches are left untouched). Uses each media file's _transcript.json (auto-transcribes if missing). Non-destructive: writes a _transcript_edit copy.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"phrases": {"type": "array", "items": {"type": "string"}, "description": "Spoken phrases to match (case/punctuation-insensitive)"},
|
|
"mode": {"type": "string", "enum": ["remove", "keep_only"], "default": "remove", "description": "remove=cut matches out; keep_only=keep only matches"},
|
|
"clip_name": {"type": "string", "description": "Only edit the clip with this name"},
|
|
"model": {"type": "string", "default": "base", "description": "Whisper model size if transcription is needed"},
|
|
"padding": {"type": "number", "default": 0.0, "description": "Seconds to widen each cut on both sides (0-2, default 0)"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _transcript_edit suffix)"},
|
|
},
|
|
"required": ["filepath", "phrases"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="remove_filler_words",
|
|
description="Cut filler words (um, uh, erm...) out of the timeline with ripple, using word-level transcripts of the real source audio. Conservative default filler list — words like 'like' and 'so' are only cut if you pass them explicitly. Uses each media file's _transcript.json (auto-transcribes if missing). Non-destructive: writes a _defillered copy.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"fillers": {"type": "array", "items": {"type": "string"}, "description": "Filler words/phrases to cut (default: um, uh, uhh, umm, erm, ehm, mmm, hmm, mhm)"},
|
|
"clip_name": {"type": "string", "description": "Only clean the clip with this name"},
|
|
"model": {"type": "string", "default": "base", "description": "Whisper model size if transcription is needed"},
|
|
"padding": {"type": "number", "default": 0.02, "description": "Seconds to widen each cut on both sides (0-2, default 0.02)"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _defillered suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="transcript_markers",
|
|
description="Add a marker at the start of every transcribed segment (sentence-level), using each media file's local Whisper transcript. Maps each segment's source-media timestamp to its correct timeline position per clip, so it stays accurate across multiple clips/trims — unlike import_transcript_markers (plain timestamp text) or import_srt_markers (a caption track already synced to the whole export). Uses each media file's _transcript.json (auto-transcribes if missing). Non-destructive: writes a _transcript_markers copy.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"clip_name": {"type": "string", "description": "Only mark the clip with this name"},
|
|
"marker_type": {"type": "string", "default": "chapter", "description": "Marker type: standard, chapter, todo, completed"},
|
|
"max_label_length": {"type": "integer", "default": 50, "description": "Truncate marker labels to this many characters (0 = no truncation)"},
|
|
"model": {"type": "string", "default": "base", "description": "Whisper model size if transcription is needed"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _transcript_markers suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="generate_dynamic_subtitles",
|
|
description="Generate progressive-composition subtitles as real, editable FCPXML title clips (the 'Text'/Basic Text template). Whisper's segments become sentences; each sentence is diagrammed as stacked blocks — supporting words grouped small in a grotesque, the sentence's key word alone and large in a display italic, body lines staggered to opposite edges. One <title> per block: each enters as its own words are spoken and stays on screen, so the sentence assembles itself, and every block clears at the same instant. Set granularity='word' for the older one-title-per-word rhythm. A sentence too tall for the band splits into successive compositions. These are TITLES, not captions: no subtitles role, so they render over the video without enabling caption display. Uses each media file's local Whisper word-level transcript (_transcript.json, auto-transcribes if missing). Non-destructive: writes a _dynamic_subtitles copy.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"clip_name": {"type": "string", "description": "Only caption the clip with this name (default: all spine clips with matched source media)"},
|
|
"model": {"type": "string", "default": "base", "description": "Whisper model size if transcription is needed"},
|
|
"language": {"type": "string", "description": "ISO language code hint (e.g. 'en'); auto-detected if omitted"},
|
|
"band_height": {"type": "number", "default": 0.22, "description": "Fraction of frame height the sentence block may fill before splitting into another block (default 0.22 — about three lines)"},
|
|
"block_center_y": {"type": "number", "default": -167.0, "description": "Vertical centre of the block in canvas points; negative sits below frame centre (default -167, just under centre)"},
|
|
"granularity": {"type": "string", "enum": ["phrase", "word"], "default": "phrase", "description": "'phrase': one title per LINE of the composition, key word set large (the reference look). 'word': one title per word."},
|
|
"emphasis_font": {"type": "string", "default": "Playfair Display", "description": "Family for the key word (phrase mode). Must be installed on the editing Mac; unmeasured families fall back to estimated widths"},
|
|
"emphasis_face": {"type": "string", "default": "Medium Italic", "description": "Face for the key word, e.g. 'Medium Italic' or a script/calligraphic face"},
|
|
"emphasis_size": {"type": "integer", "default": 230, "description": "Key-word size in canvas points, at the 2160x3840 reference frame"},
|
|
"font": {"type": "string", "default": "Helvetica Neue", "description": "Title font family (supporting lines in phrase mode)"},
|
|
"font_size": {"type": "integer", "default": 88, "description": "Supporting-line font size in canvas points, at the 2160x3840 reference frame"},
|
|
"active_color": {"type": "string", "default": "1 1 1 1", "description": "RGBA (0-1, space-separated) for even-indexed lines"},
|
|
"inactive_color": {"type": "string", "default": "0.7 0.7 0.7 1", "description": "RGBA (0-1, space-separated) for odd-indexed lines — alternates with active_color for visual variety between stacked lines"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _dynamic_subtitles suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== SILENCE DETECTION (v0.5.0) =====
|
|
Tool(
|
|
name="detect_silence_candidates",
|
|
description="Detect potential silence/dead air using timeline heuristics (gaps, ultra-short clips, name patterns, duration anomalies)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"min_gap_seconds": {"type": "number", "default": 0.5, "description": "Minimum gap duration to flag"},
|
|
"patterns": {"type": "array", "items": {"type": "string"}, "description": "Name patterns to match (default: gap, silence, room tone)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="remove_silence_candidates",
|
|
description="Remove or mark detected silence candidates from timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["delete", "mark"], "default": "mark", "description": "delete=remove clips/gaps, mark=add red markers"},
|
|
"min_gap_seconds": {"type": "number", "default": 0.5},
|
|
"min_confidence": {"type": "number", "default": 0.7, "description": "Only act on candidates above this confidence"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _silence_cleaned suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== NLE EXPORT (v0.5.0) =====
|
|
Tool(
|
|
name="export_resolve_xml",
|
|
description="Export timeline as DaVinci Resolve compatible FCPXML (simplified v1.9)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"flatten_compounds": {"type": "boolean", "default": True, "description": "Flatten compound clips for compatibility"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _resolve suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="export_fcp7_xml",
|
|
description="Export timeline as FCP7 XML (XMEML) for Premiere Pro, DaVinci Resolve, and Avid compatibility",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _fcp7.xml suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
|
|
# ===== v0.6.0 TOOLS =====
|
|
Tool(
|
|
name="list_effects",
|
|
description="List all available FCP transition effects with slugs and UUIDs",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {},
|
|
}
|
|
),
|
|
Tool(
|
|
name="add_audio",
|
|
description="Add an audio clip or music bed to the timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"parent_clip_id": {"type": "string", "description": "Clip to attach audio to (omit for music bed spanning full timeline)"},
|
|
"asset_id": {"type": "string", "description": "Existing asset reference ID"},
|
|
"src": {"type": "string", "description": "Path to audio file (creates new asset)"},
|
|
"offset": {"type": "string", "description": "Position relative to parent clip start", "default": "0s"},
|
|
"duration": {"type": "string", "description": "Duration of audio clip"},
|
|
"role": {"type": "string", "description": "Audio role (dialogue, music, effects, etc.)", "default": "dialogue"},
|
|
"lane": {"type": "integer", "description": "Lane number (negative = below)", "default": -1},
|
|
"output_path": {"type": "string", "description": "Output path"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="create_compound_clip",
|
|
description="Group spine clips into a compound clip",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"clip_ids": {"type": "array", "items": {"type": "string"}, "description": "Clip IDs to group"},
|
|
"name": {"type": "string", "description": "Name for the compound clip", "default": "Compound Clip"},
|
|
"output_path": {"type": "string", "description": "Output path"},
|
|
},
|
|
"required": ["filepath", "clip_ids"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="flatten_compound_clip",
|
|
description="Flatten a compound clip back into individual clips in the spine",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"ref_clip_id": {"type": "string", "description": "ID of the ref-clip to flatten"},
|
|
"output_path": {"type": "string", "description": "Output path"},
|
|
},
|
|
"required": ["filepath", "ref_clip_id"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_templates",
|
|
description="List available timeline templates with slot definitions",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {},
|
|
}
|
|
),
|
|
Tool(
|
|
name="apply_template",
|
|
description="Fill a timeline template with clips and generate FCPXML",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"template_name": {"type": "string", "description": "Template name (intro_outro, lower_thirds, music_video)"},
|
|
"clips": {"type": "object", "description": "Map of slot_name -> {src, name, duration} or {asset_id, name, duration}"},
|
|
"output_path": {"type": "string", "description": "Output FCPXML path"},
|
|
"fps": {"type": "number", "description": "Frame rate", "default": 24},
|
|
},
|
|
"required": ["template_name", "clips", "output_path"]
|
|
}
|
|
),
|
|
|
|
# ===== v0.8.0 TOOLS =====
|
|
Tool(
|
|
name="relink_media",
|
|
description="Bulk-rewrite media source paths (asset/media-rep src URLs) to relink moved or renamed media folders without opening FCP. Prefix-based: find='/Volumes/OldDrive/Media' replace='/Volumes/NewDrive/Media'. Use dry_run to preview.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file or .fcpxmld bundle"},
|
|
"find": {"type": "string", "description": "Old path prefix to match (plain path or file:// URL)"},
|
|
"replace": {"type": "string", "description": "New path prefix to substitute"},
|
|
"dry_run": {"type": "boolean", "description": "Preview changes without writing", "default": False},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _relinked suffix)"},
|
|
},
|
|
"required": ["filepath", "find", "replace"]
|
|
}
|
|
),
|
|
|
|
# ===== v0.9.0 LIVE MODE (macOS + Final Cut Pro required) =====
|
|
Tool(
|
|
name="push_to_fcp",
|
|
description="LIVE: send an FCPXML file into the running Final Cut Pro with zero clicks (official Open Document Apple event). Creates/targets a library via import-options. Launches FCP if needed. macOS-only; first use triggers an Automation permission prompt. For true zero-click, pass a library_location ending in .fcpbundle (a new path is auto-created); omitting it makes FCP show a modal library picker.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file or .fcpxmld bundle to import"},
|
|
"library_location": {"type": "string", "description": "Target .fcpbundle library path (auto-created if it doesn't exist; the extension is normalized to .fcpbundle). Omit to import into the active library, but note FCP then shows a modal 'Open Library' picker that blocks until answered"},
|
|
"suppress_warnings": {"type": "boolean", "description": "Suppress non-fatal import warning dialogs", "default": True},
|
|
"copy_assets": {"type": "boolean", "description": "Copy media into the library (true) or link in place (false). Omit for FCP default"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_fcp_libraries",
|
|
description="LIVE: enumerate the running Final Cut Pro's open libraries, events, and projects via Apple's read-only scripting dictionary. Refuses to launch FCP unless allow_launch is true. macOS-only.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"allow_launch": {"type": "boolean", "description": "Launch FCP if it isn't running", "default": False},
|
|
},
|
|
}
|
|
),
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# QC DETECTION HELPERS — Pure detection logic, reusable across handlers
|
|
# ============================================================================
|
|
|
|
|
|
def _detect_flash_frames(
|
|
tl: Any, *, critical_threshold: int = 2, warning_threshold: int = 6,
|
|
) -> list:
|
|
"""Find clips shorter than *warning_threshold* frames.
|
|
|
|
Returns a list of ``FlashFrame`` objects sorted by severity. Shared by
|
|
``handle_detect_flash_frames`` and ``handle_validate_timeline`` so the
|
|
detection logic lives in exactly one place.
|
|
"""
|
|
fps = tl.frame_rate
|
|
flash_frames: list[FlashFrame] = []
|
|
for clip in tl.clips:
|
|
duration_frames = int(clip.duration_seconds * fps)
|
|
if duration_frames < warning_threshold:
|
|
severity = (
|
|
FlashFrameSeverity.CRITICAL
|
|
if duration_frames < critical_threshold
|
|
else FlashFrameSeverity.WARNING
|
|
)
|
|
flash_frames.append(FlashFrame(
|
|
clip_name=clip.name, clip_id=clip.name,
|
|
start=clip.start, duration_frames=duration_frames,
|
|
duration_seconds=clip.duration_seconds, severity=severity,
|
|
))
|
|
return flash_frames
|
|
|
|
|
|
def _detect_gaps(tl: Any, *, min_gap_frames: int = 1) -> list:
|
|
"""Find inter-clip gaps of at least *min_gap_frames* length.
|
|
|
|
Returns a list of ``GapInfo`` objects. Shared by ``handle_detect_gaps``
|
|
and ``handle_validate_timeline``.
|
|
"""
|
|
fps = tl.frame_rate
|
|
min_gap_seconds = min_gap_frames / fps
|
|
gaps: list[GapInfo] = []
|
|
sorted_clips = sorted(tl.clips, key=lambda c: c.start.seconds)
|
|
for i in range(len(sorted_clips) - 1):
|
|
current_end = sorted_clips[i].end.seconds
|
|
next_start = sorted_clips[i + 1].start.seconds
|
|
gap_duration = next_start - current_end
|
|
if gap_duration >= min_gap_seconds:
|
|
gaps.append(GapInfo(
|
|
start=Timecode(frames=int(current_end * fps), frame_rate=fps),
|
|
duration_frames=int(gap_duration * fps),
|
|
duration_seconds=gap_duration,
|
|
previous_clip=sorted_clips[i].name,
|
|
next_clip=sorted_clips[i + 1].name,
|
|
))
|
|
return gaps
|
|
|
|
|
|
def _detect_duplicate_groups(tl: Any, *, mode: str = "same_source") -> list:
|
|
"""Group clips that share a source media reference.
|
|
|
|
Returns a list of ``DuplicateGroup`` objects. Shared by
|
|
``handle_detect_duplicates`` and ``handle_validate_timeline``.
|
|
"""
|
|
source_groups: dict[str, list[dict]] = {}
|
|
for clip in tl.clips:
|
|
source_key = clip.media_path or clip.name
|
|
if source_key not in source_groups:
|
|
source_groups[source_key] = []
|
|
source_groups[source_key].append({
|
|
'name': clip.name,
|
|
'start': clip.start.seconds,
|
|
'duration': clip.duration_seconds,
|
|
'source_start': clip.source_start.seconds if clip.source_start else 0,
|
|
'source_duration': clip.duration_seconds,
|
|
'timecode': format_timecode(clip.start),
|
|
})
|
|
|
|
duplicates: list[DuplicateGroup] = []
|
|
for source_key, clips in source_groups.items():
|
|
if len(clips) <= 1:
|
|
continue
|
|
group = DuplicateGroup(
|
|
source_ref=source_key,
|
|
source_name=source_key.split('/')[-1] if '/' in source_key else source_key,
|
|
clips=clips,
|
|
)
|
|
if mode == "same_source":
|
|
duplicates.append(group)
|
|
elif mode == "overlapping_ranges" and group.has_overlapping_ranges:
|
|
duplicates.append(group)
|
|
elif mode == "identical":
|
|
seen_ranges: set[tuple] = set()
|
|
identical_clips = []
|
|
for c in clips:
|
|
range_key = (c['source_start'], c['source_duration'])
|
|
if range_key in seen_ranges:
|
|
identical_clips.append(c)
|
|
seen_ranges.add(range_key)
|
|
if identical_clips:
|
|
group.clips = identical_clips
|
|
duplicates.append(group)
|
|
return duplicates
|
|
|
|
|
|
# ============================================================================
|
|
# TOOL HANDLERS — Each tool gets its own function
|
|
# ============================================================================
|
|
|
|
# ----- READ HANDLERS -----
|
|
|
|
async def handle_list_projects(arguments: dict) -> Sequence[TextContent]:
|
|
directory = arguments.get("directory", PROJECTS_DIR)
|
|
resolved_dir = _validate_directory(
|
|
directory, allowed_root=PROJECTS_DIR if _SANDBOX_ENABLED else None
|
|
)
|
|
files = find_fcpxml_files(resolved_dir)
|
|
if not files:
|
|
return _text_result(f"No FCPXML files found in {directory}")
|
|
return _text_result(f"Found {len(files)} FCPXML file(s):\n" + "\n".join(f" - {f}" for f in files))
|
|
|
|
|
|
async def handle_analyze_timeline(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
durs = [c.duration_seconds for c in tl.clips]
|
|
avg, med, mn, mx = (0, 0, 0, 0) if not durs else (
|
|
sum(durs)/len(durs), sorted(durs)[len(durs)//2], min(durs), max(durs))
|
|
return _text_result(f"""# Timeline Analysis: {tl.name}
|
|
|
|
## Overview
|
|
- **Duration**: {format_duration(tl.duration.seconds)}
|
|
- **Resolution**: {tl.width}x{tl.height} @ {tl.frame_rate}fps
|
|
|
|
## Clip Statistics
|
|
- **Total Clips**: {tl.total_clips}
|
|
- **Total Cuts**: {tl.total_cuts}
|
|
- **Transitions**: {len(tl.transitions)}
|
|
|
|
## Pacing
|
|
- **Average**: {format_duration(avg)}
|
|
- **Median**: {format_duration(med)}
|
|
- **Shortest**: {format_duration(mn)}
|
|
- **Longest**: {format_duration(mx)}
|
|
- **Cuts/Minute**: {tl.cuts_per_minute:.1f}
|
|
|
|
## Markers
|
|
- **Total**: {len(tl.markers)}
|
|
- **Chapters**: {len([m for m in tl.markers if m.marker_type == MarkerType.CHAPTER])}
|
|
""")
|
|
|
|
|
|
async def handle_list_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
limit = arguments.get("limit")
|
|
clips = tl.clips[:limit] if limit else tl.clips
|
|
result = f"# Clips in {tl.name}\n\n| # | Name | Start | Duration | Keywords |\n|---|------|-------|----------|----------|\n"
|
|
for i, c in enumerate(clips, 1):
|
|
kws = ", ".join(k.value for k in c.keywords) if c.keywords else "-"
|
|
result += f"| {i} | {c.name} | {format_timecode(c.start)} | {format_duration(c.duration_seconds)} | {kws} |\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_markers(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
markers = list(tl.markers)
|
|
for clip in tl.clips:
|
|
markers.extend(clip.markers)
|
|
marker_type = arguments.get("marker_type", "all")
|
|
if marker_type != "all":
|
|
markers = [m for m in markers if m.marker_type == MarkerType.from_string(marker_type)]
|
|
markers.sort(key=lambda m: m.start.frames)
|
|
fmt = arguments.get("format", "detailed")
|
|
if fmt == "youtube":
|
|
result = "# YouTube Chapters\n\n" + "\n".join(f"{m.to_youtube_timestamp()} {m.name}" for m in markers)
|
|
elif fmt == "simple":
|
|
result = "\n".join(f"{format_timecode(m.start)} - {m.name}" for m in markers)
|
|
else:
|
|
result = f"# Markers ({len(markers)})\n\n| TC | Name | Type |\n|---|------|------|\n"
|
|
result += "\n".join(f"| {format_timecode(m.start)} | {m.name} | {m.marker_type.value} |" for m in markers)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_find_short_cuts(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
threshold = arguments.get("threshold_seconds", 0.5)
|
|
short = tl.get_clips_shorter_than(threshold)
|
|
if not short:
|
|
return _text_result(f"No clips shorter than {threshold}s")
|
|
return _text_result(_format_clip_table(
|
|
short, f"# Short Clips (< {threshold}s) - {len(short)} found",
|
|
))
|
|
|
|
|
|
async def handle_find_long_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
threshold = arguments.get("threshold_seconds", 10.0)
|
|
long = tl.get_clips_longer_than(threshold)
|
|
if not long:
|
|
return _text_result(f"No clips longer than {threshold}s")
|
|
return _text_result(_format_clip_table(
|
|
long, f"# Long Clips (> {threshold}s) - {len(long)} found",
|
|
))
|
|
|
|
|
|
async def handle_list_keywords(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
keywords = {}
|
|
for clip in tl.clips:
|
|
for kw in clip.keywords:
|
|
keywords.setdefault(kw.value, []).append(clip.name)
|
|
if not keywords:
|
|
return _text_result("No keywords found")
|
|
result = f"# Keywords ({len(keywords)})\n\n"
|
|
for kw, clips in sorted(keywords.items()):
|
|
result += f"**{kw}** ({len(clips)} clips)\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_export_edl(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
edl = f"TITLE: {tl.name}\nFCM: NON-DROP FRAME\n\n"
|
|
for i, c in enumerate(tl.clips, 1):
|
|
edl += f"{i:03d} AX V C {format_timecode(c.source_start)} {format_timecode(c.end)} {format_timecode(c.start)} {format_timecode(c.end)}\n"
|
|
edl += f"* FROM CLIP NAME: {c.name}\n\n"
|
|
return _text_result(f"```edl\n{edl}```")
|
|
|
|
|
|
async def handle_export_csv(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
csv = "Name,Start,End,Duration,Keywords\n"
|
|
for c in tl.clips:
|
|
kws = "|".join(k.value for k in c.keywords)
|
|
csv += f'"{c.name}",{format_timecode(c.start)},{format_timecode(c.end)},{c.duration_seconds:.3f},"{kws}"\n'
|
|
return _text_result(f"```csv\n{csv}```")
|
|
|
|
|
|
async def handle_analyze_pacing(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
if not tl.clips:
|
|
return _text_result("No clips to analyze")
|
|
durs = [c.duration_seconds for c in tl.clips]
|
|
avg = sum(durs) / len(durs)
|
|
q_len = len(durs) // 4 or 1
|
|
segments = [durs[i:i+q_len] for i in range(0, len(durs), q_len)][:4]
|
|
seg_avgs = [sum(s)/len(s) if s else 0 for s in segments]
|
|
suggestions = []
|
|
flash = [c for c in tl.clips if c.duration_seconds < 0.2]
|
|
if flash:
|
|
suggestions.append(f" {len(flash)} potential flash frames (< 0.2s)")
|
|
long = [c for c in tl.clips if c.duration_seconds > 30]
|
|
if long:
|
|
suggestions.append(f" {len(long)} long takes (> 30s) - consider trimming")
|
|
if len(seg_avgs) >= 4 and seg_avgs[3] < seg_avgs[0] * 0.7:
|
|
suggestions.append(" Pacing accelerates toward end - good for building energy")
|
|
elif len(seg_avgs) >= 4 and seg_avgs[3] > seg_avgs[0] * 1.3:
|
|
suggestions.append(" Pacing slows toward end - consider tightening")
|
|
return _text_result(f"""# Pacing Analysis: {tl.name}
|
|
|
|
## Overall
|
|
- **Avg Cut**: {format_duration(avg)}
|
|
- **Cuts/Min**: {tl.cuts_per_minute:.1f}
|
|
|
|
## By Section
|
|
| Q1 | Q2 | Q3 | Q4 |
|
|
|----|----|----|----|
|
|
| {format_duration(seg_avgs[0]) if len(seg_avgs) > 0 else 'N/A'} | {format_duration(seg_avgs[1]) if len(seg_avgs) > 1 else 'N/A'} | {format_duration(seg_avgs[2]) if len(seg_avgs) > 2 else 'N/A'} | {format_duration(seg_avgs[3]) if len(seg_avgs) > 3 else 'N/A'} |
|
|
|
|
## Suggestions
|
|
{_fmt_suggestions(suggestions)}
|
|
""")
|
|
|
|
|
|
async def handle_list_library_clips(arguments: dict) -> Sequence[TextContent]:
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
parser = FCPXMLParser()
|
|
parser.parse_file(filepath)
|
|
keywords = arguments.get("keywords")
|
|
library_clips = parser.get_library_clips(keywords=keywords)
|
|
limit = arguments.get("limit")
|
|
if limit:
|
|
library_clips = library_clips[:limit]
|
|
if not library_clips:
|
|
return _text_result("No library clips found")
|
|
result = f"# Library Clips ({len(library_clips)} available)\n\n"
|
|
result += "| ID | Name | Duration | Has Video | Has Audio |\n"
|
|
result += "|----|------|----------|-----------|----------|\n"
|
|
for c in library_clips:
|
|
result += f"| {c['asset_id']} | {c['name']} | {format_duration(c['duration_seconds'])} | {'Y' if c['has_video'] else 'N'} | {'Y' if c['has_audio'] else 'N'} |\n"
|
|
result += "\n*Use `insert_clip` to add these to your timeline.*"
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- QC / VALIDATION HANDLERS -----
|
|
|
|
async def handle_detect_flash_frames(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
critical_threshold = arguments.get("critical_threshold_frames", 2)
|
|
warning_threshold = arguments.get("warning_threshold_frames", 6)
|
|
|
|
flash_frames = _detect_flash_frames(
|
|
tl, critical_threshold=critical_threshold, warning_threshold=warning_threshold,
|
|
)
|
|
|
|
if not flash_frames:
|
|
return _text_result(f"No flash frames detected (threshold: {warning_threshold} frames)")
|
|
|
|
critical = [f for f in flash_frames if f.severity == FlashFrameSeverity.CRITICAL]
|
|
warnings = [f for f in flash_frames if f.severity == FlashFrameSeverity.WARNING]
|
|
|
|
result = f"""# Flash Frame Detection
|
|
|
|
## Summary
|
|
- **Critical** (< {critical_threshold} frames): {len(critical)} found
|
|
- **Warning** (< {warning_threshold} frames): {len(warnings)} found
|
|
- **Total**: {len(flash_frames)} flash frames
|
|
|
|
## Critical Flash Frames
|
|
"""
|
|
flash_headers = ["Clip", "Timecode", "Frames", "Duration"]
|
|
if critical:
|
|
result += _markdown_table(flash_headers, [
|
|
[f.clip_name, format_timecode(f.start), f"{f.duration_frames}f", format_duration(f.duration_seconds)]
|
|
for f in critical
|
|
]) + "\n"
|
|
else:
|
|
result += "_None_\n"
|
|
|
|
result += "\n## Warning Flash Frames\n"
|
|
if warnings:
|
|
result += _markdown_table(flash_headers, [
|
|
[f.clip_name, format_timecode(f.start), f"{f.duration_frames}f", format_duration(f.duration_seconds)]
|
|
for f in warnings
|
|
]) + "\n"
|
|
else:
|
|
result += "_None_\n"
|
|
|
|
result += "\n*Use `fix_flash_frames` to automatically resolve these issues.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_duplicates(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
mode = arguments.get("mode", "same_source")
|
|
|
|
duplicates = _detect_duplicate_groups(tl, mode=mode)
|
|
|
|
if not duplicates:
|
|
return _text_result(f"No duplicate clips found (mode: {mode})")
|
|
|
|
result = f"""# Duplicate Clip Detection
|
|
|
|
## Summary
|
|
- **Mode**: {mode}
|
|
- **Duplicate Groups**: {len(duplicates)}
|
|
- **Total Duplicate Clips**: {sum(g.count for g in duplicates)}
|
|
|
|
## Duplicate Groups
|
|
"""
|
|
for group in duplicates:
|
|
result += f"\n### {group.source_name} ({group.count} uses)\n"
|
|
result += "| Clip Name | Timeline Position | Duration |\n|-----------|-------------------|----------|\n"
|
|
for c in group.clips:
|
|
result += f"| {c['name']} | {c['timecode']} | {format_duration(c['duration'])} |\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_gaps(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
min_gap_frames = arguments.get("min_gap_frames", 1)
|
|
|
|
gaps = _detect_gaps(tl, min_gap_frames=min_gap_frames)
|
|
|
|
if not gaps:
|
|
return _text_result(f"No gaps detected (minimum: {min_gap_frames} frame(s))")
|
|
|
|
result = f"""# Gap Detection
|
|
|
|
## Summary
|
|
- **Gaps Found**: {len(gaps)}
|
|
- **Total Gap Duration**: {format_duration(sum(g.duration_seconds for g in gaps))}
|
|
- **Minimum Detection**: {min_gap_frames} frame(s)
|
|
|
|
## Gaps
|
|
"""
|
|
result += _markdown_table(
|
|
["Position", "Duration", "Between"],
|
|
[[gap.timecode, f"{gap.duration_frames}f ({format_duration(gap.duration_seconds)})",
|
|
f"{gap.previous_clip} -> {gap.next_clip}"] for gap in gaps],
|
|
) + "\n"
|
|
|
|
result += "\n*Use `fill_gaps` to automatically close these gaps.*"
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- WRITE HANDLERS -----
|
|
|
|
async def handle_add_marker(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
marker_type = MarkerType.from_string(arguments.get("marker_type", "standard"))
|
|
modifier.add_marker_at_timeline(
|
|
timecode=arguments["timecode"], name=arguments["name"],
|
|
marker_type=marker_type, note=arguments.get("note"),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Added marker '{arguments['name']}' at {arguments['timecode']}\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_batch_add_markers(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
markers_added = modifier.batch_add_markers(
|
|
markers=arguments.get("markers", []),
|
|
auto_at_cuts=arguments.get("auto_at_cuts", False),
|
|
auto_at_intervals=arguments.get("auto_at_intervals"),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Added {len(markers_added)} markers\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_trim_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.trim_clip(
|
|
clip_id=arguments["clip_id"],
|
|
trim_start=arguments.get("trim_start"),
|
|
trim_end=arguments.get("trim_end"),
|
|
ripple=arguments.get("ripple", True),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Trimmed clip '{arguments['clip_id']}'\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_reorder_clips(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.reorder_clips(
|
|
clip_ids=arguments["clip_ids"],
|
|
target_position=arguments["target_position"],
|
|
ripple=arguments.get("ripple", True),
|
|
)
|
|
modifier.save(output_path)
|
|
clips_moved = ", ".join(arguments["clip_ids"])
|
|
return _text_result(f"Moved clips [{clips_moved}] to {arguments['target_position']}\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_add_transition(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.add_transition(
|
|
clip_id=arguments["clip_id"],
|
|
position=arguments.get("position", "end"),
|
|
transition_type=arguments.get("transition_type", "cross-dissolve"),
|
|
duration=arguments.get("duration", "00:00:00:15"),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Added {arguments.get('transition_type', 'cross-dissolve')} to '{arguments['clip_id']}'\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_change_speed(arguments: dict) -> Sequence[TextContent]:
|
|
speed = arguments["speed"]
|
|
if not isinstance(speed, (int, float)) or speed <= 0 or speed > 100:
|
|
raise ValueError(
|
|
f"Speed must be a positive number between 0 (exclusive) and 100, got {speed!r}"
|
|
)
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.change_speed(
|
|
clip_id=arguments["clip_id"],
|
|
speed=speed,
|
|
preserve_pitch=arguments.get("preserve_pitch", True),
|
|
)
|
|
modifier.save(output_path)
|
|
speed_desc = f"{speed}x" if speed >= 1 else f"{int(1/speed)}x slow motion"
|
|
return _text_result(f"Changed speed of '{arguments['clip_id']}' to {speed_desc}\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_add_zoom(arguments: dict) -> Sequence[TextContent]:
|
|
start = float(arguments["start"])
|
|
end = float(arguments["end"])
|
|
scale = float(arguments.get("scale", 1.3))
|
|
ease = float(arguments.get("ease", 0.3))
|
|
position = arguments.get("position", "0 0")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.add_zoom(
|
|
clip_id=arguments["clip_id"], start=start, end=end,
|
|
scale=scale, ease=ease, position=position,
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(
|
|
f"# Zoom Added\n\n"
|
|
f"- **Clip**: {arguments['clip_id']}\n"
|
|
f"- **Window**: {start}s → {end}s (clip-relative)\n"
|
|
f"- **Scale**: {int(scale * 100)}%\n"
|
|
f"- **Ease**: {ease}s in/out\n\n"
|
|
f"Saved to: {output_path}"
|
|
)
|
|
|
|
|
|
async def handle_delete_clips(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.delete_clip(
|
|
clip_ids=arguments["clip_ids"],
|
|
ripple=arguments.get("ripple", True),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Deleted {len(arguments['clip_ids'])} clip(s)\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_split_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
new_clips = modifier.split_clip(
|
|
clip_id=arguments["clip_id"],
|
|
split_points=arguments["split_points"],
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result(f"Split '{arguments['clip_id']}' into {len(new_clips)} clips\n\nSaved to: {output_path}")
|
|
|
|
|
|
async def handle_insert_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
new_clip = modifier.insert_clip(
|
|
asset_id=arguments.get("asset_id"),
|
|
asset_name=arguments.get("asset_name"),
|
|
position=arguments["position"],
|
|
duration=arguments.get("duration"),
|
|
in_point=arguments.get("in_point"),
|
|
out_point=arguments.get("out_point"),
|
|
ripple=arguments.get("ripple", True),
|
|
)
|
|
modifier.save(output_path)
|
|
clip_name = new_clip.get('name', 'Unknown')
|
|
pos = arguments["position"]
|
|
return _text_result(f"Inserted '{clip_name}' at position '{pos}'\n\nSaved to: {output_path}")
|
|
|
|
|
|
# ----- BATCH FIX HANDLERS -----
|
|
|
|
async def handle_fix_flash_frames(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_flash_fixed")
|
|
fixed = modifier.fix_flash_frames(
|
|
mode=arguments.get("mode", "auto"),
|
|
threshold_frames=arguments.get("threshold_frames", 6),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
if not fixed:
|
|
return _text_result("No flash frames found to fix.")
|
|
|
|
result = _format_batch_result(
|
|
title="Flash Frames Fixed",
|
|
summary={"Fixed": f"{len(fixed)} flash frames", "Mode": arguments.get('mode', 'auto')},
|
|
headers=["Clip", "Frames", "Action", "Result"],
|
|
rows=[
|
|
[f['clip_name'], f"{f['duration_frames']}f", f['action'], f"Extended: {f.get('extended_clip', 'N/A')}"]
|
|
for f in fixed
|
|
],
|
|
output_path=output_path,
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_rapid_trim(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_rapid_trim")
|
|
trimmed = modifier.rapid_trim(
|
|
max_duration=arguments["max_duration"],
|
|
min_duration=arguments.get("min_duration"),
|
|
keywords=arguments.get("keywords"),
|
|
trim_from=arguments.get("trim_from", "end"),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
if not trimmed:
|
|
return _text_result(f"No clips exceeded {arguments['max_duration']} - nothing trimmed.")
|
|
|
|
total_before = sum(t['original_duration'] for t in trimmed)
|
|
total_after = sum(t['new_duration'] for t in trimmed)
|
|
|
|
result = _format_batch_result(
|
|
title="Rapid Trim Complete",
|
|
summary={
|
|
"Clips Trimmed": str(len(trimmed)),
|
|
"Max Duration": str(arguments['max_duration']),
|
|
"Trim From": arguments.get('trim_from', 'end'),
|
|
"Time Saved": format_duration(total_before - total_after),
|
|
},
|
|
headers=["Clip", "Before", "After"],
|
|
rows=[
|
|
[t['clip_name'], format_duration(t['original_duration']), format_duration(t['new_duration'])]
|
|
for t in trimmed
|
|
],
|
|
output_path=output_path,
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_fill_gaps(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_gaps_filled")
|
|
filled = modifier.fill_gaps(
|
|
mode=arguments.get("mode", "extend_previous"),
|
|
max_gap=arguments.get("max_gap"),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
if not filled:
|
|
return _text_result("No gaps found to fill.")
|
|
|
|
result = _format_batch_result(
|
|
title="Gaps Filled",
|
|
summary={"Gaps Filled": str(len(filled)), "Mode": arguments.get('mode', 'extend_previous')},
|
|
headers=["Position", "Duration", "Action"],
|
|
rows=[[g['timecode'], f"{g['duration_frames']}f", g['action']] for g in filled],
|
|
output_path=output_path,
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_validate_timeline(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
checks = arguments.get("checks", ["all"])
|
|
run_all = "all" in checks
|
|
|
|
issues: list[str] = []
|
|
flash_count = 0
|
|
gap_count = 0
|
|
duplicate_count = 0
|
|
|
|
if run_all or "flash_frames" in checks:
|
|
flashes = _detect_flash_frames(tl)
|
|
flash_count = len(flashes)
|
|
for f in flashes:
|
|
severity = "error" if f.severity == FlashFrameSeverity.CRITICAL else "warning"
|
|
issues.append(
|
|
f"- [{severity.upper()}] Flash frame: {f.clip_name} "
|
|
f"({f.duration_frames}f) at {format_timecode(f.start)}"
|
|
)
|
|
|
|
if run_all or "gaps" in checks:
|
|
detected_gaps = _detect_gaps(tl)
|
|
gap_count = len(detected_gaps)
|
|
for g in detected_gaps:
|
|
issues.append(f"- [WARNING] Gap: {g.duration_frames}f at {g.timecode}")
|
|
|
|
if run_all or "duplicates" in checks:
|
|
dup_groups = _detect_duplicate_groups(tl)
|
|
for group in dup_groups:
|
|
duplicate_count += group.count
|
|
issues.append(
|
|
f"- [INFO] Duplicate source: {group.source_name} ({group.count} uses)"
|
|
)
|
|
|
|
error_weight = 10
|
|
warning_weight = 3
|
|
info_weight = 1
|
|
errors = len([i for i in issues if "[ERROR]" in i])
|
|
warnings = len([i for i in issues if "[WARNING]" in i])
|
|
infos = len([i for i in issues if "[INFO]" in i])
|
|
penalty = (errors * error_weight) + (warnings * warning_weight) + (infos * info_weight)
|
|
health_score = max(0, 100 - penalty)
|
|
|
|
result = f"""# Timeline Validation: {tl.name}
|
|
|
|
## Health Score: {health_score}%
|
|
|
|
## Summary
|
|
| Check | Count | Status |
|
|
|-------|-------|--------|
|
|
| Flash Frames | {flash_count} | {'PASS' if flash_count == 0 else 'FAIL'} |
|
|
| Gaps | {gap_count} | {'PASS' if gap_count == 0 else 'WARN'} |
|
|
| Duplicate Sources | {duplicate_count} | {'PASS' if duplicate_count == 0 else 'INFO'} |
|
|
|
|
## Issues ({len(issues)})
|
|
"""
|
|
if issues:
|
|
result += "\n".join(issues[:20])
|
|
if len(issues) > 20:
|
|
result += f"\n... and {len(issues) - 20} more issues"
|
|
else:
|
|
result += "_No issues found!_"
|
|
|
|
result += "\n\n*Use `fix_flash_frames` and `fill_gaps` to automatically resolve issues.*"
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- GENERATION HANDLERS -----
|
|
|
|
async def handle_auto_rough_cut(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, generator = _setup_generator(arguments, "_roughcut")
|
|
|
|
segments = None
|
|
if arguments.get("segments"):
|
|
segments = [
|
|
SegmentSpec(
|
|
name=s.get("name", "Segment"),
|
|
keywords=s.get("keywords", []),
|
|
duration_seconds=s.get("duration", 0),
|
|
priority=s.get("priority", "best"),
|
|
)
|
|
for s in arguments["segments"]
|
|
]
|
|
result = generator.generate(
|
|
output_path=output_path,
|
|
target_duration=arguments["target_duration"],
|
|
pacing=arguments.get("pacing", "medium"),
|
|
keywords=arguments.get("keywords"),
|
|
segments=segments,
|
|
priority=arguments.get("priority", "best"),
|
|
favorites_only=arguments.get("favorites_only", False),
|
|
add_transitions=arguments.get("add_transitions", False),
|
|
)
|
|
|
|
return _text_result(f"""# Rough Cut Generated
|
|
|
|
## Summary
|
|
- **Clips Used**: {result.clips_used} of {result.clips_available} available
|
|
- **Target Duration**: {format_duration(result.target_duration)}
|
|
- **Actual Duration**: {format_duration(result.actual_duration)}
|
|
- **Average Clip**: {format_duration(result.average_clip_duration)}
|
|
|
|
## Output
|
|
Saved to: `{result.output_path}`
|
|
|
|
**Next step**: Import this FCPXML into Final Cut Pro (File > Import > XML)
|
|
""")
|
|
|
|
|
|
async def handle_generate_montage(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, generator = _setup_generator(arguments, "_montage")
|
|
result = generator.generate_montage(
|
|
output_path=output_path,
|
|
target_duration=arguments["target_duration"],
|
|
pacing_curve=arguments.get("pacing_curve", "accelerating"),
|
|
start_duration=arguments.get("start_duration", 2.0),
|
|
end_duration=arguments.get("end_duration", 0.5),
|
|
keywords=arguments.get("keywords"),
|
|
add_transitions=arguments.get("add_transitions", False),
|
|
)
|
|
|
|
curve_desc = {
|
|
'accelerating': 'slow to fast (builds energy)',
|
|
'decelerating': 'fast to slow (winds down)',
|
|
'pyramid': 'slow to fast to slow (dramatic arc)',
|
|
'constant': 'same duration throughout',
|
|
}
|
|
|
|
return _text_result(f"""# Montage Generated
|
|
|
|
## Summary
|
|
- **Clips Used**: {result['clips_used']} of {result['clips_available']} available
|
|
- **Target Duration**: {format_duration(result['target_duration'])}
|
|
- **Actual Duration**: {format_duration(result['actual_duration'])}
|
|
- **Pacing Curve**: {result['pacing_curve']} - {curve_desc.get(result['pacing_curve'], '')}
|
|
|
|
## Pacing
|
|
- **Start Clip Duration**: {format_duration(result['start_clip_duration'])}
|
|
- **End Clip Duration**: {format_duration(result['end_clip_duration'])}
|
|
|
|
## Output
|
|
Saved to: `{result['output_path']}`
|
|
""")
|
|
|
|
|
|
async def handle_generate_ab_roll(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, generator = _setup_generator(arguments, "_ab_roll")
|
|
result = generator.generate_ab_roll(
|
|
output_path=output_path,
|
|
target_duration=arguments["target_duration"],
|
|
a_keywords=arguments["a_keywords"],
|
|
b_keywords=arguments["b_keywords"],
|
|
a_duration=arguments.get("a_duration", "5s"),
|
|
b_duration=arguments.get("b_duration", "3s"),
|
|
start_with=arguments.get("start_with", "a"),
|
|
add_transitions=arguments.get("add_transitions", True),
|
|
)
|
|
|
|
return _text_result(f"""# A/B Roll Edit Generated
|
|
|
|
## Summary
|
|
- **A-Roll Segments**: {result['a_segments']} (from {result['a_clips_available']} available)
|
|
- **B-Roll Segments**: {result['b_segments']} (from {result['b_clips_available']} available)
|
|
- **Total Clips**: {result['clips_used']}
|
|
|
|
## Timing
|
|
- **Target Duration**: {format_duration(result['target_duration'])}
|
|
- **Actual Duration**: {format_duration(result['actual_duration'])}
|
|
- **A-Roll Duration**: {result['a_duration_setting']} per segment
|
|
- **B-Roll Duration**: {result['b_duration_setting']} per cutaway
|
|
|
|
## Output
|
|
Saved to: `{result['output_path']}`
|
|
|
|
**Next step**: Import this FCPXML into Final Cut Pro (File > Import > XML)
|
|
""")
|
|
|
|
|
|
# ----- BEAT SYNC HANDLERS -----
|
|
|
|
async def handle_import_beat_markers(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_beats")
|
|
beats_path = _validate_filepath(arguments["beats_path"], ('.json',))
|
|
|
|
with open(beats_path, 'r') as f:
|
|
beats_data = json.load(f)
|
|
_check_json_depth(beats_data)
|
|
|
|
beat_times = []
|
|
if isinstance(beats_data, list):
|
|
beat_times = beats_data
|
|
elif isinstance(beats_data, dict):
|
|
beat_times = beats_data.get('beats', beats_data.get('times', beats_data.get('markers', [])))
|
|
|
|
beat_filter = arguments.get("beat_filter", "all")
|
|
if beat_filter == "downbeat" and isinstance(beats_data, dict):
|
|
beat_times = beats_data.get('downbeats', beat_times[::4])
|
|
elif beat_filter == "measure" and isinstance(beats_data, dict):
|
|
beat_times = beats_data.get('measures', beat_times[::4])
|
|
|
|
markers = []
|
|
marker_type = arguments.get("marker_type", "standard")
|
|
for i, beat_time in enumerate(beat_times):
|
|
if isinstance(beat_time, (int, float)):
|
|
markers.append({
|
|
'timecode': f"{beat_time}s",
|
|
'name': f"Beat {i+1}",
|
|
'marker_type': marker_type.upper(),
|
|
})
|
|
elif isinstance(beat_time, dict):
|
|
markers.append({
|
|
'timecode': f"{beat_time.get('time', beat_time.get('position', 0))}s",
|
|
'name': beat_time.get('label', f"Beat {i+1}"),
|
|
'marker_type': marker_type.upper(),
|
|
})
|
|
|
|
modifier = FCPXMLModifier(filepath)
|
|
|
|
# Songs routinely run longer than the edit — beats past the timeline's
|
|
# end are skipped (add_marker_at_timeline would raise on them).
|
|
timeline_end = modifier._timeline_duration().to_seconds()
|
|
in_range = [m for m in markers if float(m['timecode'].rstrip('s')) < timeline_end]
|
|
skipped_count = len(markers) - len(in_range)
|
|
|
|
added = modifier.batch_add_markers(markers=in_range)
|
|
modifier.save(output_path)
|
|
|
|
skipped_note = (
|
|
f"- **Skipped**: {skipped_count} beat(s) beyond the timeline end "
|
|
f"({format_duration(timeline_end)})\n" if skipped_count else ""
|
|
)
|
|
return _text_result(f"""# Beat Markers Imported
|
|
|
|
## Summary
|
|
- **Beats Found**: {len(beat_times)}
|
|
- **Markers Added**: {len(added)}
|
|
{skipped_note}- **Filter**: {beat_filter}
|
|
- **Marker Type**: {marker_type}
|
|
|
|
## Output
|
|
Saved to: `{output_path}`
|
|
|
|
*Use `snap_to_beats` to align your cuts to these markers.*
|
|
""")
|
|
|
|
|
|
async def handle_snap_to_beats(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_synced")
|
|
max_shift = arguments.get("max_shift_frames", 6)
|
|
prefer = arguments.get("prefer", "nearest")
|
|
|
|
parser = FCPXMLParser()
|
|
project = parser.parse_file(filepath)
|
|
if not project.timelines:
|
|
return _no_timeline()
|
|
|
|
tl = project.primary_timeline
|
|
fps = tl.frame_rate
|
|
|
|
markers = list(tl.markers)
|
|
for clip in tl.clips:
|
|
markers.extend(clip.markers)
|
|
|
|
if not markers:
|
|
return _text_result("No markers found. Use `import_beat_markers` first.")
|
|
|
|
marker_times = sorted([m.start.seconds for m in markers])
|
|
|
|
modifier = FCPXMLModifier(filepath)
|
|
spine = modifier._get_spine()
|
|
adjusted_count = 0
|
|
total_shift = 0
|
|
|
|
clips_list = [c for c in spine if c.tag in ('clip', 'asset-clip', 'video', 'ref-clip')]
|
|
|
|
for i, clip in enumerate(clips_list[1:], 1):
|
|
cut_offset = modifier._parse_time(clip.get('offset', '0s'))
|
|
cut_seconds = cut_offset.to_seconds()
|
|
|
|
best_marker = None
|
|
best_distance = float('inf')
|
|
|
|
for marker_time in marker_times:
|
|
distance = abs(marker_time - cut_seconds)
|
|
distance_frames = distance * fps
|
|
|
|
if distance_frames <= max_shift:
|
|
if prefer == "earlier" and marker_time <= cut_seconds:
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_marker = marker_time
|
|
elif prefer == "later" and marker_time >= cut_seconds:
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_marker = marker_time
|
|
elif prefer == "nearest":
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_marker = marker_time
|
|
|
|
if best_marker is not None and best_distance > 0.001:
|
|
shift = best_marker - cut_seconds
|
|
shift_frames = int(shift * fps)
|
|
|
|
prev_clip = clips_list[i - 1]
|
|
prev_dur = modifier._parse_time(prev_clip.get('duration', '0s'))
|
|
new_prev_dur = prev_dur + modifier._parse_time(f"{shift}s")
|
|
prev_clip.set('duration', new_prev_dur.to_fcpxml())
|
|
|
|
new_offset = modifier._parse_time(f"{best_marker}s")
|
|
clip.set('offset', new_offset.to_fcpxml())
|
|
|
|
adjusted_count += 1
|
|
total_shift += abs(shift_frames)
|
|
|
|
modifier.save(output_path)
|
|
avg_shift = total_shift / adjusted_count if adjusted_count > 0 else 0
|
|
|
|
return _text_result(f"""# Cuts Snapped to Beats
|
|
|
|
## Summary
|
|
- **Cuts Adjusted**: {adjusted_count}
|
|
- **Max Shift Allowed**: {max_shift} frames
|
|
- **Preference**: {prefer}
|
|
- **Average Shift**: {avg_shift:.1f} frames
|
|
|
|
## Output
|
|
Saved to: `{output_path}`
|
|
|
|
Your edits are now synced to the beat!
|
|
""")
|
|
|
|
|
|
# ----- SUBTITLE / TRANSCRIPT HANDLERS -----
|
|
|
|
async def handle_import_srt_markers(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_subtitled")
|
|
srt_path = _validate_filepath(arguments["srt_path"], ('.srt', '.vtt'))
|
|
mode = arguments.get("mode", "first_per_minute")
|
|
marker_type = arguments.get("marker_type", "chapter")
|
|
max_label = arguments.get("max_label_length", 50)
|
|
|
|
text = Path(srt_path).read_text(encoding='utf-8')
|
|
|
|
# Detect format and parse
|
|
if srt_path.endswith('.vtt') or text.strip().startswith('WEBVTT'):
|
|
raw_markers = parse_vtt(text)
|
|
fmt_name = "WebVTT"
|
|
else:
|
|
raw_markers = parse_srt(text)
|
|
fmt_name = "SRT"
|
|
|
|
if not raw_markers:
|
|
return _text_result(f"No subtitles found in {srt_path}")
|
|
|
|
# Apply mode filtering
|
|
filtered = []
|
|
if mode == "all":
|
|
filtered = raw_markers
|
|
elif mode == "first_per_minute":
|
|
seen_minutes = set()
|
|
for m in raw_markers:
|
|
minute = int(m['seconds'] // 60)
|
|
if minute not in seen_minutes:
|
|
seen_minutes.add(minute)
|
|
filtered.append(m)
|
|
elif mode == "scene_changes":
|
|
# Group by similar text, take first occurrence of each unique line
|
|
seen_texts = set()
|
|
for m in raw_markers:
|
|
# Normalize: lowercase, strip punctuation
|
|
normalized = re.sub(r'[^\w\s]', '', m['text'].lower()).strip()
|
|
words = normalized.split()[:3] # First 3 words as key
|
|
key = ' '.join(words)
|
|
if key and key not in seen_texts:
|
|
seen_texts.add(key)
|
|
filtered.append(m)
|
|
|
|
markers = _raw_markers_to_batch(filtered, marker_type, max_label=max_label)
|
|
|
|
modifier = FCPXMLModifier(filepath)
|
|
added = modifier.batch_add_markers(markers=markers)
|
|
modifier.save(output_path)
|
|
|
|
return _text_result(f"""# Subtitle Markers Imported
|
|
|
|
## Summary
|
|
- **Format**: {fmt_name}
|
|
- **Subtitles Parsed**: {len(raw_markers)}
|
|
- **Mode**: {mode}
|
|
- **Markers Added**: {len(added)}
|
|
- **Marker Type**: {marker_type}
|
|
|
|
## Output
|
|
Saved to: `{output_path}`
|
|
""")
|
|
|
|
|
|
async def handle_import_transcript_markers(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_chapters")
|
|
marker_type = arguments.get("marker_type", "chapter")
|
|
|
|
# Get transcript text from inline or file
|
|
transcript = arguments.get("transcript")
|
|
transcript_path = arguments.get("transcript_path")
|
|
|
|
if not transcript and not transcript_path:
|
|
return _text_result("Provide either 'transcript' (inline text) or 'transcript_path' (path to file)")
|
|
|
|
if transcript_path:
|
|
# .txt only: the parser below understands "0:00 Title" lines, not real
|
|
# SRT/VTT cue syntax — that's import_srt_markers (parse_srt/parse_vtt).
|
|
transcript_path = _validate_filepath(transcript_path, ('.txt',))
|
|
transcript = Path(transcript_path).read_text(encoding='utf-8')
|
|
|
|
raw_markers = parse_transcript_timestamps(transcript or "")
|
|
|
|
if not raw_markers:
|
|
return _text_result("No timestamps found. Expected format: '0:00 Title' or 'HH:MM:SS Title', one per line.")
|
|
|
|
markers = _raw_markers_to_batch(raw_markers, marker_type)
|
|
|
|
modifier = FCPXMLModifier(filepath)
|
|
added = modifier.batch_add_markers(markers=markers)
|
|
modifier.save(output_path)
|
|
|
|
return _text_result(f"""# Transcript Markers Imported
|
|
|
|
## Summary
|
|
- **Timestamps Found**: {len(raw_markers)}
|
|
- **Markers Added**: {len(added)}
|
|
- **Marker Type**: {marker_type}
|
|
|
|
## Markers
|
|
""" + "\n".join(f"- `{m['timecode']}` {m['name']}" for m in markers) + f"""
|
|
|
|
## Output
|
|
Saved to: `{output_path}`
|
|
""")
|
|
|
|
|
|
# ----- CONNECTED CLIPS & COMPOUND CLIPS HANDLERS (v0.5.0) -----
|
|
|
|
async def handle_list_connected_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
lane_filter = arguments.get("lane")
|
|
clips = tl.connected_clips
|
|
if lane_filter is not None:
|
|
clips = [c for c in clips if c.lane == lane_filter]
|
|
|
|
if not clips:
|
|
return _text_result("No connected clips found in timeline.")
|
|
|
|
result = f"# Connected Clips in {tl.name}\n\n**Total**: {len(clips)}\n\n"
|
|
result += "| # | Name | Lane | Type | Duration | Parent | Role |\n"
|
|
result += "|---|------|------|------|----------|--------|------|\n"
|
|
for i, c in enumerate(clips, 1):
|
|
result += (
|
|
f"| {i} | {c.name} | {c.lane} | {c.clip_type} | "
|
|
f"{format_duration(c.duration_seconds)} | {c.parent_clip_name} | "
|
|
f"{c.role or '-'} |\n"
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_add_connected_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.add_connected_clip(
|
|
parent_clip_id=arguments["parent_clip_id"],
|
|
asset_id=arguments.get("asset_id"),
|
|
asset_name=arguments.get("asset_name"),
|
|
offset=arguments.get("offset", "0s"),
|
|
duration=arguments.get("duration"),
|
|
lane=arguments.get("lane", 1),
|
|
)
|
|
modifier.save(output_path)
|
|
return _text_result((
|
|
f"Connected clip added to '{arguments['parent_clip_id']}' on lane {arguments.get('lane', 1)}\n\n"
|
|
f"Saved to: `{output_path}`"
|
|
))
|
|
|
|
|
|
async def handle_list_compound_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
if not tl.compound_clips:
|
|
return _text_result("No compound clips found in timeline.")
|
|
|
|
result = f"# Compound Clips in {tl.name}\n\n"
|
|
for i, cc in enumerate(tl.compound_clips, 1):
|
|
result += f"### {i}. {cc.name}\n"
|
|
result += f"- **Ref ID**: {cc.ref_id}\n"
|
|
result += f"- **Duration**: {format_duration(cc.duration_seconds)}\n"
|
|
result += f"- **Clips inside**: {len(cc.clips)}\n\n"
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- ROLES HANDLERS (v0.5.0) -----
|
|
|
|
async def handle_list_roles(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
audio_roles: dict[str, int] = {}
|
|
video_roles: dict[str, int] = {}
|
|
|
|
for clip in tl.clips:
|
|
if clip.audio_role:
|
|
audio_roles[clip.audio_role] = audio_roles.get(clip.audio_role, 0) + 1
|
|
if clip.video_role:
|
|
video_roles[clip.video_role] = video_roles.get(clip.video_role, 0) + 1
|
|
|
|
for cc in tl.connected_clips:
|
|
if cc.role:
|
|
# Determine type from clip_type
|
|
if cc.clip_type in ('audio', 'audio-clip'):
|
|
audio_roles[cc.role] = audio_roles.get(cc.role, 0) + 1
|
|
else:
|
|
video_roles[cc.role] = video_roles.get(cc.role, 0) + 1
|
|
|
|
result = f"# Roles in {tl.name}\n\n"
|
|
if audio_roles:
|
|
result += "## Audio Roles\n\n| Role | Clips |\n|------|-------|\n"
|
|
for role, count in sorted(audio_roles.items()):
|
|
result += f"| {role} | {count} |\n"
|
|
else:
|
|
result += "## Audio Roles\n\nNo audio roles assigned.\n"
|
|
|
|
result += "\n"
|
|
if video_roles:
|
|
result += "## Video Roles\n\n| Role | Clips |\n|------|-------|\n"
|
|
for role, count in sorted(video_roles.items()):
|
|
result += f"| {role} | {count} |\n"
|
|
else:
|
|
result += "## Video Roles\n\nNo video roles assigned.\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_assign_role(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments)
|
|
modifier.assign_role(
|
|
clip_id=arguments["clip_id"],
|
|
audio_role=arguments.get("audio_role"),
|
|
video_role=arguments.get("video_role"),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
roles_set = []
|
|
if arguments.get("audio_role"):
|
|
roles_set.append(f"audioRole={arguments['audio_role']}")
|
|
if arguments.get("video_role"):
|
|
roles_set.append(f"videoRole={arguments['video_role']}")
|
|
|
|
return _text_result((
|
|
f"Set {', '.join(roles_set)} on '{arguments['clip_id']}'\n\n"
|
|
f"Saved to: `{output_path}`"
|
|
))
|
|
|
|
|
|
async def handle_filter_by_role(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
role = arguments["role"].lower()
|
|
role_type = arguments.get("role_type", "any")
|
|
matches = []
|
|
|
|
for clip in tl.clips:
|
|
if role_type in ("audio", "any") and clip.audio_role.lower() == role:
|
|
matches.append((clip.name, "audio", clip.audio_role, format_duration(clip.duration_seconds)))
|
|
if role_type in ("video", "any") and clip.video_role.lower() == role:
|
|
matches.append((clip.name, "video", clip.video_role, format_duration(clip.duration_seconds)))
|
|
|
|
if not matches:
|
|
return _text_result(f"No clips found with role '{role}'.")
|
|
|
|
result = f"# Clips with role '{role}'\n\n"
|
|
result += "| Clip | Type | Role | Duration |\n|------|------|------|----------|\n"
|
|
for name, rtype, rval, dur in matches:
|
|
result += f"| {name} | {rtype} | {rval} | {dur} |\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_export_role_stems(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
stems: dict[str, list] = {}
|
|
for clip in tl.clips:
|
|
role = clip.audio_role or "unassigned"
|
|
stems.setdefault(role, []).append(clip)
|
|
|
|
for cc in tl.connected_clips:
|
|
role = cc.role or "unassigned"
|
|
stems.setdefault(role, []).append(cc)
|
|
|
|
result = f"# Audio Stem Plan for {tl.name}\n\n"
|
|
for role, clips in sorted(stems.items()):
|
|
total_dur = sum(c.duration_seconds for c in clips)
|
|
result += f"## {role.title()} ({len(clips)} clips, {format_duration(total_dur)})\n\n"
|
|
for c in clips:
|
|
result += f"- {c.name} ({format_duration(c.duration_seconds)})\n"
|
|
result += "\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- TIMELINE DIFF HANDLER (v0.5.0) -----
|
|
|
|
async def handle_diff_timelines(arguments: dict) -> Sequence[TextContent]:
|
|
filepath_a = _validate_filepath(arguments["filepath_a"], ('.fcpxml', '.fcpxmld'))
|
|
filepath_b = _validate_filepath(arguments["filepath_b"], ('.fcpxml', '.fcpxmld'))
|
|
|
|
diff = compare_timelines(filepath_a, filepath_b)
|
|
|
|
if not diff.has_changes:
|
|
return _text_result((
|
|
f"# Timeline Diff: No Changes\n\n"
|
|
f"**{diff.timeline_a_name}** vs **{diff.timeline_b_name}** are identical."
|
|
))
|
|
|
|
result = (
|
|
f"# Timeline Diff\n\n"
|
|
f"**Baseline**: {diff.timeline_a_name}\n"
|
|
f"**Comparison**: {diff.timeline_b_name}\n"
|
|
f"**Total changes**: {diff.total_changes}\n\n"
|
|
)
|
|
|
|
if diff.format_changes:
|
|
result += "## Format Changes\n\n"
|
|
for change in diff.format_changes:
|
|
result += f"- {change}\n"
|
|
result += "\n"
|
|
|
|
clip_changes = [d for d in diff.clip_diffs if d.action != "unchanged"]
|
|
if clip_changes:
|
|
result += "## Clip Changes\n\n| Action | Clip | Details |\n|--------|------|--------|\n"
|
|
for d in clip_changes:
|
|
result += f"| {d.action.upper()} | {d.clip_name} | {d.details} |\n"
|
|
result += "\n"
|
|
|
|
if diff.marker_diffs:
|
|
result += "## Marker Changes\n\n| Action | Marker | Details |\n|--------|--------|--------|\n"
|
|
for d in diff.marker_diffs:
|
|
result += f"| {d.action.upper()} | {d.marker_name} | {d.details} |\n"
|
|
result += "\n"
|
|
|
|
if diff.transition_diffs:
|
|
result += "## Transition Changes\n\n"
|
|
for change in diff.transition_diffs:
|
|
result += f"- {change}\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- SOCIAL MEDIA REFORMAT HANDLER (v0.5.0) -----
|
|
|
|
async def handle_reformat_timeline(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_reformatted")
|
|
|
|
fmt = arguments["format"]
|
|
if fmt == "custom":
|
|
width = arguments.get("width")
|
|
height = arguments.get("height")
|
|
if not width or not height:
|
|
return _text_result("Custom format requires both 'width' and 'height' parameters.")
|
|
else:
|
|
formats = FCPXMLModifier.SOCIAL_FORMATS
|
|
if fmt not in formats:
|
|
return _text_result(f"Unknown format: {fmt}. Valid: {', '.join(formats.keys())}")
|
|
width, height = formats[fmt]
|
|
|
|
modifier = FCPXMLModifier(filepath)
|
|
modifier.reformat_resolution(width, height)
|
|
modifier.save(output_path)
|
|
|
|
return _text_result((
|
|
f"# Timeline Reformatted\n\n"
|
|
f"- **Format**: {fmt} ({width}x{height})\n"
|
|
f"- **Aspect ratio**: {width}:{height}\n\n"
|
|
f"Saved to: `{output_path}`\n\n"
|
|
f"**Next step**: Import into FCP (File > Import > XML). "
|
|
f"FCP will handle spatial conforming automatically."
|
|
))
|
|
|
|
|
|
# ----- SILENCE DETECTION HANDLERS (v0.5.0) -----
|
|
|
|
async def handle_detect_media_silence(arguments: dict) -> Sequence[TextContent]:
|
|
noise_db = float(arguments.get("noise_db", -30.0))
|
|
min_silence = float(arguments.get("min_silence", 0.5))
|
|
# Same bounds detect_silence() enforces — validated here so a bad request
|
|
# fails before any media file is opened.
|
|
if not (-120.0 <= noise_db <= 0.0):
|
|
raise ValueError(f"noise_db must be between -120 and 0 dB, got {noise_db}")
|
|
if not (0 < min_silence <= 3600):
|
|
raise ValueError(f"min_silence must be between 0 and 3600 seconds, got {min_silence}")
|
|
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
modifier = FCPXMLModifier(filepath)
|
|
clip_filter = arguments.get("clip_name")
|
|
|
|
max_media_probes = 100
|
|
findings: list[tuple[str, float, float]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
probe_cache: dict[str, list | None] = {}
|
|
for el in [el for _, el in modifier._iter_spine_clips()]:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
if media_path not in probe_cache:
|
|
if len(probe_cache) >= max_media_probes:
|
|
skipped.append((name, f"probe cap reached ({max_media_probes} media files)"))
|
|
continue
|
|
probe_cache[media_path] = detect_silence(
|
|
media_path, noise_db=noise_db, min_duration=min_silence
|
|
)
|
|
silences = probe_cache[media_path]
|
|
if silences is None:
|
|
skipped.append((name, "unanalyzable (ffmpeg missing or media unreadable)"))
|
|
continue
|
|
source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
timeline_offset = modifier._parse_time(el.get("offset", "0s")).to_seconds()
|
|
mapped = map_silence_to_timeline(
|
|
silences, source_start, clip_duration, timeline_offset
|
|
)
|
|
findings.extend((name, start, end) for start, end in mapped)
|
|
|
|
total_silence = sum(end - start for _, start, end in findings)
|
|
result = f"""# Media Silence Detection (real audio analysis)
|
|
|
|
## Summary
|
|
- **Threshold**: {noise_db} dB for >= {min_silence}s
|
|
- **Media Files Probed**: {len(probe_cache)}
|
|
- **Silence Spans Found**: {len(findings)} ({format_duration(total_silence)} total)
|
|
"""
|
|
if findings:
|
|
result += "\n## Silence Spans (timeline time)\n"
|
|
result += _markdown_table(
|
|
["Clip", "Start", "End", "Duration"],
|
|
[[name, f"{start:.2f}s", f"{end:.2f}s", f"{end - start:.2f}s"]
|
|
for name, start, end in findings],
|
|
) + "\n"
|
|
result += "\n*To remove: `split_clip` at each boundary, then `delete_clips` with ripple.*"
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n"
|
|
result += _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
if not findings and not skipped:
|
|
result += "\nNo silence detected in any clip's source audio."
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_remove_media_silence(arguments: dict) -> Sequence[TextContent]:
|
|
noise_db = float(arguments.get("noise_db", -30.0))
|
|
min_silence = float(arguments.get("min_silence", 0.5))
|
|
padding = float(arguments.get("padding", 0.05))
|
|
if not (-120.0 <= noise_db <= 0.0):
|
|
raise ValueError(f"noise_db must be between -120 and 0 dB, got {noise_db}")
|
|
if not (0 < min_silence <= 3600):
|
|
raise ValueError(f"min_silence must be between 0 and 3600 seconds, got {min_silence}")
|
|
if not (0 <= padding <= 5):
|
|
raise ValueError(f"padding must be between 0 and 5 seconds, got {padding}")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_silence_removed")
|
|
clip_filter = arguments.get("clip_name")
|
|
to_frame_timevalue = modifier.snap_seconds_to_frame
|
|
|
|
max_media_probes = 100
|
|
cuts_made: list[tuple[str, int, float]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
probe_cache: dict[str, list | None] = {}
|
|
spine_clips = [el for _, el in modifier._iter_spine_clips()]
|
|
for el in spine_clips:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
if media_path not in probe_cache:
|
|
if len(probe_cache) >= max_media_probes:
|
|
skipped.append((name, f"probe cap reached ({max_media_probes} media files)"))
|
|
continue
|
|
probe_cache[media_path] = detect_silence(
|
|
media_path, noise_db=noise_db, min_duration=min_silence
|
|
)
|
|
silences = probe_cache[media_path]
|
|
if silences is None:
|
|
skipped.append((name, "unanalyzable (ffmpeg missing or media unreadable)"))
|
|
continue
|
|
|
|
clip_source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
cut_ranges = []
|
|
for sil_start, sil_end in silences:
|
|
# Source time -> clip-relative, padded so cuts breathe.
|
|
cut_start = max(sil_start, clip_source_start) - clip_source_start + padding
|
|
cut_end = min(sil_end, clip_source_start + clip_duration) - clip_source_start - padding
|
|
if cut_end > cut_start:
|
|
cut_ranges.append((to_frame_timevalue(cut_start), to_frame_timevalue(cut_end)))
|
|
if not cut_ranges:
|
|
continue
|
|
removed = modifier.cut_clip_ranges(el, cut_ranges)
|
|
if removed > TimeValue.zero():
|
|
cuts_made.append((name, len(cut_ranges), removed.to_seconds()))
|
|
|
|
if not cuts_made:
|
|
text = "# Media Silence Removal\n\nNo silence found to remove — file unchanged (nothing saved)."
|
|
if skipped:
|
|
text += "\n\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
)
|
|
return _text_result(text)
|
|
|
|
modifier.remove_trailing_gaps()
|
|
modifier.save(output_path)
|
|
total_removed = sum(seconds for _, _, seconds in cuts_made)
|
|
result = f"""# Media Silence Removal (real audio analysis)
|
|
|
|
## Summary
|
|
- **Threshold**: {noise_db} dB for >= {min_silence}s, padding {padding}s
|
|
- **Clips Cut**: {len(cuts_made)}
|
|
- **Total Removed**: {format_duration(total_removed)}
|
|
|
|
## Cuts
|
|
"""
|
|
result += _markdown_table(
|
|
["Clip", "Silence Spans Cut", "Removed"],
|
|
[[name, str(count), f"{seconds:.2f}s"] for name, count, seconds in cuts_made],
|
|
) + "\n"
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
result += f"\nSaved to: {output_path}\n\n*Preview first next time with `detect_media_silence`. Original file untouched.*"
|
|
return _text_result(result)
|
|
|
|
|
|
AUDIO_MEDIA_EXTENSIONS = (
|
|
'.wav', '.aif', '.aiff', '.mp3', '.m4a', '.aac', '.flac', '.mov', '.mp4',
|
|
)
|
|
|
|
|
|
async def handle_detect_beats(arguments: dict) -> Sequence[TextContent]:
|
|
media_path = _validate_filepath(arguments["media_path"], AUDIO_MEDIA_EXTENSIONS)
|
|
|
|
result = detect_beats(media_path)
|
|
if result is None:
|
|
return _text_result(
|
|
"Beat detection unavailable — librosa is not installed or the file "
|
|
"could not be analyzed.\n\nInstall the optional media-intelligence "
|
|
"extra:\n\n pip install 'fcp-mcp-server[intelligence]'"
|
|
)
|
|
|
|
bpm, beats = result["bpm"], result["beats"]
|
|
beats_data = {
|
|
"source": str(Path(media_path).name),
|
|
"bpm": round(bpm, 2),
|
|
"beats": [round(b, 4) for b in beats],
|
|
"downbeats": [round(b, 4) for b in beats[::4]],
|
|
}
|
|
json_path = _validate_output_path(
|
|
str(Path(media_path).with_name(Path(media_path).stem + "_beats.json")),
|
|
anchor_dir=str(Path(media_path).parent),
|
|
)
|
|
with open(json_path, "w") as f:
|
|
json.dump(beats_data, f, indent=2)
|
|
|
|
preview = beats[:16]
|
|
result_text = f"""# Beat Detection
|
|
|
|
## Summary
|
|
- **Source**: {Path(media_path).name}
|
|
- **Estimated Tempo**: {bpm:.1f} BPM
|
|
- **Beats Detected**: {len(beats)} ({format_duration(beats[-1]) if beats else '0s'} span)
|
|
- **Beats JSON**: {json_path}
|
|
|
|
## First Beats
|
|
"""
|
|
result_text += _markdown_table(
|
|
["#", "Time"],
|
|
[[str(i + 1), f"{b:.3f}s"] for i, b in enumerate(preview)],
|
|
) + "\n"
|
|
result_text += (
|
|
f"\n*Next: `import_beat_markers` with beats_path=\"{json_path}\" to place "
|
|
"markers, then `snap_to_beats` to align your cuts.*"
|
|
)
|
|
return _text_result(result_text)
|
|
|
|
|
|
# ===== TRANSCRIPT INTELLIGENCE (v0.13.1) =====
|
|
|
|
TRANSCRIBE_MAX_MEDIA = 10
|
|
|
|
_TRANSCRIBE_INSTALL_HINT = (
|
|
"\n\nInstall the optional transcription extra:\n\n"
|
|
" pip install 'fcp-mcp-server[transcribe]'\n\n"
|
|
"or run via uvx:\n\n"
|
|
" uvx --from \"fcp-mcp-server[transcribe]\" fcp-mcp-server"
|
|
)
|
|
|
|
|
|
def _transcript_json_path(media_path: str, output_dir: str | None = None) -> 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 instead of next to the source media.
|
|
"""
|
|
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 _load_or_transcribe(
|
|
media_path: str, model: str, language: str | None, output_dir: str | None = None
|
|
) -> tuple[dict | None, str]:
|
|
"""Load a cached ``_transcript.json`` for a media file, else transcribe and cache it.
|
|
|
|
Returns ``(transcript, "")`` or ``(None, reason)``. The cache makes
|
|
transcription a one-time cost per media file across all transcript tools.
|
|
"""
|
|
json_path = _transcript_json_path(media_path, output_dir)
|
|
if json_path.is_file():
|
|
try:
|
|
with open(json_path) as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict) and isinstance(data.get("words"), list):
|
|
return data, ""
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
pass # unreadable cache falls through to re-transcribe
|
|
result = transcribe(media_path, model_size=model, language=language)
|
|
if result is None:
|
|
return None, "untranscribable (faster-whisper not installed or media unreadable)"
|
|
anchor = str(Path(output_dir).expanduser()) if output_dir else str(Path(media_path).parent)
|
|
out_path = _validate_output_path(str(json_path), anchor_dir=anchor)
|
|
with open(out_path, "w") as f:
|
|
json.dump({"source": Path(media_path).name, **result}, f, indent=2)
|
|
return result, ""
|
|
|
|
|
|
def _cut_transcript_spans(modifier, clip_filter, model, language, padding, spans_fn, keep_only=False, output_dir=None):
|
|
"""Shared cut engine for transcript-driven editing.
|
|
|
|
``spans_fn(words) -> [(start, end), ...]`` in source seconds. Spans are
|
|
padded, clamped to each clip's used source window, optionally inverted
|
|
(keep_only), snapped to the frame grid, and cut with ripple.
|
|
"""
|
|
to_frame = modifier.snap_seconds_to_frame
|
|
|
|
cache: dict[str, tuple] = {}
|
|
cuts_made: list[tuple[str, int, float]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
spine_clips = [el for _, el in modifier._iter_spine_clips()]
|
|
for el in spine_clips:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
if media_path not in cache:
|
|
if len(cache) >= TRANSCRIBE_MAX_MEDIA:
|
|
skipped.append((name, f"transcription cap reached ({TRANSCRIBE_MAX_MEDIA} media files)"))
|
|
continue
|
|
cache[media_path] = _load_or_transcribe(media_path, model, language, output_dir)
|
|
data, reason = cache[media_path]
|
|
if data is None:
|
|
skipped.append((name, reason))
|
|
continue
|
|
|
|
clip_source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
window_start = clip_source_start
|
|
window_end = clip_source_start + clip_duration
|
|
|
|
spans = spans_fn(data.get("words", []))
|
|
padded = merge_ranges([(s - padding, e + padding) for s, e in spans])
|
|
clamped = [
|
|
(max(s, window_start), min(e, window_end))
|
|
for s, e in padded
|
|
if min(e, window_end) > max(s, window_start)
|
|
]
|
|
if keep_only:
|
|
if not clamped:
|
|
# Never delete a whole clip just because nothing matched in it.
|
|
skipped.append((name, "no phrase matches — left untouched (keep_only)"))
|
|
continue
|
|
cut_source = invert_ranges(clamped, window_start, window_end)
|
|
else:
|
|
cut_source = clamped
|
|
cut_ranges = [
|
|
(to_frame(s - clip_source_start), to_frame(e - clip_source_start))
|
|
for s, e in cut_source
|
|
]
|
|
cut_ranges = [(a, b) for a, b in cut_ranges if b > a]
|
|
if not cut_ranges:
|
|
continue
|
|
removed = modifier.cut_clip_ranges(el, cut_ranges)
|
|
if removed > TimeValue.zero():
|
|
cuts_made.append((name, len(cut_ranges), removed.to_seconds()))
|
|
return cuts_made, skipped
|
|
|
|
|
|
def _transcript_cut_report(title, summary_lines, cuts_made, skipped, output_path, footer):
|
|
if not cuts_made:
|
|
text = f"# {title}\n\nNo cuts to make — file unchanged (nothing saved)."
|
|
if skipped:
|
|
text += "\n\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
)
|
|
if any("faster-whisper" in reason for _, reason in skipped):
|
|
text += _TRANSCRIBE_INSTALL_HINT
|
|
return _text_result(text)
|
|
total_removed = sum(seconds for _, _, seconds in cuts_made)
|
|
result = f"# {title}\n\n## Summary\n"
|
|
result += "\n".join(summary_lines) + "\n"
|
|
result += f"- **Clips Cut**: {len(cuts_made)}\n- **Total Removed**: {format_duration(total_removed)}\n"
|
|
result += "\n## Cuts\n"
|
|
result += _markdown_table(
|
|
["Clip", "Ranges Cut", "Removed"],
|
|
[[name, str(count), f"{seconds:.2f}s"] for name, count, seconds in cuts_made],
|
|
) + "\n"
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
result += f"\nSaved to: {output_path}\n\n{footer}"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_transcribe_media(arguments: dict) -> Sequence[TextContent]:
|
|
model = arguments.get("model", "base")
|
|
language = arguments.get("language")
|
|
output_dir = arguments.get("output_dir")
|
|
write_srt = bool(arguments.get("write_srt", False))
|
|
_, tl = _require_timeline(arguments["filepath"])
|
|
clip_filter = arguments.get("clip_name")
|
|
|
|
done: dict[str, dict | None] = {}
|
|
skipped: list[tuple[str, str]] = []
|
|
rows: list[list[str]] = []
|
|
srt_paths: list[str] = []
|
|
for clip in tl.clips:
|
|
if clip_filter and clip.name != clip_filter:
|
|
continue
|
|
media_path = media_src_to_path(clip.media_path or "")
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((clip.name, "media file missing"))
|
|
continue
|
|
if media_path in done:
|
|
continue
|
|
if len(done) >= TRANSCRIBE_MAX_MEDIA:
|
|
skipped.append((clip.name, f"transcription cap reached ({TRANSCRIBE_MAX_MEDIA} media files)"))
|
|
continue
|
|
data, reason = _load_or_transcribe(media_path, model, language, output_dir)
|
|
done[media_path] = data
|
|
if data is None:
|
|
skipped.append((clip.name, reason))
|
|
continue
|
|
if write_srt and data.get("segments"):
|
|
srt_name = Path(media_path).stem + "_transcript.srt"
|
|
srt_anchor = str(Path(output_dir).expanduser()) if output_dir else str(Path(media_path).parent)
|
|
srt_path = _validate_output_path(
|
|
str(Path(srt_anchor) / srt_name),
|
|
anchor_dir=srt_anchor,
|
|
)
|
|
with open(srt_path, "w") as f:
|
|
f.write(segments_to_srt(data["segments"]))
|
|
srt_paths.append(srt_path)
|
|
preview = data.get("text", "")[:160]
|
|
rows.append([
|
|
Path(media_path).name,
|
|
data.get("language", "?"),
|
|
str(len(data.get("words", []))),
|
|
format_duration(float(data.get("duration", 0.0))),
|
|
preview + ("…" if len(data.get("text", "")) > 160 else ""),
|
|
])
|
|
|
|
result = f"""# Media Transcription (local Whisper)
|
|
|
|
## Summary
|
|
- **Model**: {model}
|
|
- **Media Files Transcribed**: {len(rows)}
|
|
"""
|
|
if rows:
|
|
result += "\n## Transcripts (saved as _transcript.json next to each media file)\n"
|
|
result += _markdown_table(
|
|
["Media", "Language", "Words", "Duration", "Preview"], rows
|
|
) + "\n"
|
|
result += (
|
|
"\n*Next: `edit_by_transcript` to cut by what was said, or "
|
|
"`remove_filler_words` to clean ums/uhs. Transcripts are cached — "
|
|
"media is only transcribed once.*"
|
|
)
|
|
if srt_paths:
|
|
result += "\n\n## SRT Files\n" + "\n".join(f"- {p}" for p in srt_paths)
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
if not rows and any("faster-whisper" in reason for _, reason in skipped):
|
|
result += _TRANSCRIBE_INSTALL_HINT
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_edit_by_transcript(arguments: dict) -> Sequence[TextContent]:
|
|
phrases = arguments.get("phrases") or []
|
|
if not isinstance(phrases, list) or not all(isinstance(p, str) for p in phrases):
|
|
raise ValueError("phrases must be a list of strings")
|
|
phrases = [p for p in phrases if p.strip()]
|
|
if not phrases:
|
|
raise ValueError("phrases must contain at least one non-empty string")
|
|
mode = arguments.get("mode", "remove")
|
|
if mode not in ("remove", "keep_only"):
|
|
raise ValueError(f"mode must be 'remove' or 'keep_only', got {mode!r}")
|
|
padding = float(arguments.get("padding", 0.0))
|
|
if not (0 <= padding <= 2):
|
|
raise ValueError(f"padding must be between 0 and 2 seconds, got {padding}")
|
|
model = arguments.get("model", "base")
|
|
language = arguments.get("language")
|
|
output_dir = arguments.get("output_dir")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_transcript_edit")
|
|
|
|
def spans_fn(words):
|
|
return merge_ranges(
|
|
[span for phrase in phrases for span in find_phrase_spans(words, phrase)]
|
|
)
|
|
|
|
cuts_made, skipped = _cut_transcript_spans(
|
|
modifier, arguments.get("clip_name"), model, language, padding,
|
|
spans_fn, keep_only=(mode == "keep_only"), output_dir=output_dir,
|
|
)
|
|
if cuts_made:
|
|
modifier.save(output_path)
|
|
verb = "kept only" if mode == "keep_only" else "removed"
|
|
return _transcript_cut_report(
|
|
"Transcript Edit",
|
|
[f"- **Mode**: {mode} ({verb} the matched phrases)",
|
|
f"- **Phrases**: {', '.join(repr(p) for p in phrases)}",
|
|
f"- **Padding**: {padding}s"],
|
|
cuts_made, skipped, output_path,
|
|
"*Transcripts are cached as _transcript.json. Original file untouched.*",
|
|
)
|
|
|
|
|
|
async def handle_remove_filler_words(arguments: dict) -> Sequence[TextContent]:
|
|
fillers = arguments.get("fillers") or list(DEFAULT_FILLERS)
|
|
if not isinstance(fillers, list) or not all(isinstance(f, str) for f in fillers):
|
|
raise ValueError("fillers must be a list of strings")
|
|
padding = float(arguments.get("padding", 0.02))
|
|
if not (0 <= padding <= 2):
|
|
raise ValueError(f"padding must be between 0 and 2 seconds, got {padding}")
|
|
model = arguments.get("model", "base")
|
|
language = arguments.get("language")
|
|
output_dir = arguments.get("output_dir")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_defillered")
|
|
|
|
cuts_made, skipped = _cut_transcript_spans(
|
|
modifier, arguments.get("clip_name"), model, language, padding,
|
|
lambda words: merge_ranges(find_filler_spans(words, fillers)),
|
|
output_dir=output_dir,
|
|
)
|
|
if cuts_made:
|
|
modifier.save(output_path)
|
|
return _transcript_cut_report(
|
|
"Filler Word Removal",
|
|
[f"- **Fillers**: {', '.join(fillers)}", f"- **Padding**: {padding}s"],
|
|
cuts_made, skipped, output_path,
|
|
"*Transcripts are cached as _transcript.json. Original file untouched.*",
|
|
)
|
|
|
|
|
|
async def handle_transcript_markers(arguments: dict) -> Sequence[TextContent]:
|
|
"""Add a marker at the start of each transcribed segment, using each
|
|
media's cached (or freshly transcribed) local Whisper transcript.
|
|
|
|
Unlike ``import_transcript_markers`` (plain "0:00 Title" text) or
|
|
``import_srt_markers`` (a caption track already synced to the whole
|
|
exported video), this maps each segment's SOURCE-media timestamp to its
|
|
TIMELINE position per spine clip — the same source->timeline mapping
|
|
``detect_media_silence`` uses — so it stays correct across multiple
|
|
clips built from different (and differently-trimmed) source files.
|
|
"""
|
|
marker_type = arguments.get("marker_type", "chapter")
|
|
max_label = int(arguments.get("max_label_length", 50))
|
|
model = arguments.get("model", "base")
|
|
language = arguments.get("language")
|
|
output_dir = arguments.get("output_dir")
|
|
clip_filter = arguments.get("clip_name")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_transcript_markers")
|
|
|
|
added: list[tuple[str, float, str]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
spine_clips = [el for _, el in modifier._iter_spine_clips()]
|
|
for el in spine_clips:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
data, reason = _load_or_transcribe(media_path, model, language, output_dir)
|
|
if data is None:
|
|
skipped.append((name, reason))
|
|
continue
|
|
|
|
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 in data.get("segments", []):
|
|
seg_start = float(seg.get("start", 0.0))
|
|
if seg_start < clip_source_start or seg_start >= window_end:
|
|
continue
|
|
label = seg.get("text", "").strip()
|
|
if not label:
|
|
continue
|
|
if max_label and len(label) > max_label:
|
|
label = label[:max_label]
|
|
timeline_seconds = clip_offset + (seg_start - clip_source_start)
|
|
modifier.add_marker_at_timeline(
|
|
timecode=f"{timeline_seconds}s", name=label, marker_type=marker_type,
|
|
)
|
|
added.append((name, seg_start, label))
|
|
|
|
if not added:
|
|
text = "# Transcript Markers\n\nNo segments to mark — file unchanged (nothing saved)."
|
|
if skipped:
|
|
text += "\n\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[n, r] for n, r in skipped]
|
|
)
|
|
return _text_result(text)
|
|
|
|
modifier.save(output_path)
|
|
result = "# Transcript Markers Imported (local Whisper)\n\n## Summary\n"
|
|
result += f"- **Markers Added**: {len(added)}\n- **Marker Type**: {marker_type}\n\n"
|
|
result += _markdown_table(
|
|
["Clip", "Start", "Label"], [[n, f"{s:.2f}s", label] for n, s, label in added]
|
|
)
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[n, r] for n, r in skipped]
|
|
)
|
|
result += f"\n\nSaved to: `{output_path}`\n\n*Transcripts are cached as _transcript.json.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_generate_dynamic_subtitles(arguments: dict) -> Sequence[TextContent]:
|
|
"""Generate per-word subtitle titles laid out as a block per sentence.
|
|
|
|
Whisper's segments become sentences; each word becomes its own positioned
|
|
<title> connected clip, appearing as it is spoken and accumulating on
|
|
screen until the whole block clears at once. No compound clip.
|
|
|
|
Reuses the same SOURCE-media -> TIMELINE mapping as ``transcript_markers``
|
|
(``modifier.source_file_start`` per spine clip) so word timestamps land
|
|
at the correct position even across trimmed/multiple clips.
|
|
"""
|
|
model = arguments.get("model", "base")
|
|
language = arguments.get("language")
|
|
output_dir = arguments.get("output_dir")
|
|
clip_filter = arguments.get("clip_name")
|
|
|
|
body_color = arguments.get("active_color", "1 1 1 1")
|
|
config = DynamicSubtitleConfig(
|
|
style=WordStyle(
|
|
font=arguments.get("font", "Helvetica Neue"),
|
|
font_size=int(arguments.get("font_size", 88)),
|
|
active_color=body_color,
|
|
inactive_color=arguments.get("inactive_color", "0.7 0.7 0.7 1"),
|
|
emphasis_look=WordLook(
|
|
int(arguments.get("emphasis_size", 230)),
|
|
body_color,
|
|
font=arguments.get("emphasis_font", "Playfair Display"),
|
|
face=arguments.get("emphasis_face", "Medium Italic"),
|
|
kerning=0.0,
|
|
),
|
|
body_look=WordLook(
|
|
int(arguments.get("font_size", 88)),
|
|
body_color,
|
|
font=arguments.get("font", "Helvetica Neue"),
|
|
face="Bold",
|
|
kerning=1.2,
|
|
),
|
|
),
|
|
band_height=float(arguments.get("band_height", 0.22)),
|
|
block_center_y=float(arguments.get("block_center_y", -167.0)),
|
|
granularity=arguments.get("granularity", "phrase"),
|
|
)
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_dynamic_subtitles")
|
|
|
|
added: list[tuple[str, int, int]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
spine_clips = [el for _, el in modifier._iter_spine_clips()]
|
|
for el in spine_clips:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
data, reason = _load_or_transcribe(media_path, model, language, output_dir)
|
|
if data is None:
|
|
skipped.append((name, reason))
|
|
continue
|
|
|
|
clip_source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
window_end = clip_source_start + clip_duration
|
|
|
|
clip_words = [
|
|
{
|
|
"word": w.get("word", ""),
|
|
"start": float(w.get("start", 0.0)) - clip_source_start,
|
|
"end": float(w.get("end", 0.0)) - clip_source_start,
|
|
}
|
|
for w in data.get("words", [])
|
|
if clip_source_start <= float(w.get("start", 0.0)) < window_end
|
|
]
|
|
if not clip_words:
|
|
skipped.append((name, "no words in clip's source range"))
|
|
continue
|
|
|
|
# Sentence boundaries, rebased the same way, so each sentence becomes
|
|
# its own block of titles that builds up and then clears together.
|
|
# Overlap rather than containment: a segment straddling the clip's
|
|
# in-point still governs the words that made the cut.
|
|
clip_segments = [
|
|
{
|
|
"start": float(s.get("start", 0.0)) - clip_source_start,
|
|
"end": float(s.get("end", 0.0)) - clip_source_start,
|
|
}
|
|
for s in data.get("segments", [])
|
|
if float(s.get("end", 0.0)) > clip_source_start
|
|
and float(s.get("start", 0.0)) < window_end
|
|
]
|
|
|
|
# Pass the element itself, not `name` — after ripple-cut/silence
|
|
# removal every fragment of an originally-named clip keeps the same
|
|
# `name`, so a name lookup here would resolve every clip in this
|
|
# loop to whichever one `self.clips` last indexed, stacking every
|
|
# clip's captions onto a single wrong spine element instead of each
|
|
# clip's own. See Engine/docs/05_EXPERIENCIAS.md, entry 2026-08-17.
|
|
lines = modifier.generate_dynamic_subtitles(
|
|
el, clip_words, config, segments=clip_segments
|
|
)
|
|
added.append((name, len(lines), len(clip_words)))
|
|
|
|
if not added:
|
|
text = "# Dynamic Subtitles\n\nNo captions generated — file unchanged (nothing saved)."
|
|
if skipped:
|
|
text += "\n\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[n, r] for n, r in skipped]
|
|
)
|
|
return _text_result(text)
|
|
|
|
modifier.save(output_path)
|
|
total_lines = sum(lines for _, lines, _ in added)
|
|
total_words = sum(words for _, _, words in added)
|
|
result = "# Dynamic Subtitles Generated (local Whisper)\n\n## Summary\n"
|
|
result += (
|
|
f"- **Clips Captioned**: {len(added)}\n"
|
|
f"- **Caption Lines (Title Clips)**: {total_lines}\n"
|
|
f"- **Total Words**: {total_words}\n\n"
|
|
)
|
|
result += _markdown_table(
|
|
["Clip", "Caption Lines", "Words"],
|
|
[[n, str(lines), str(words)] for n, lines, words in added],
|
|
)
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[n, r] for n, r in skipped]
|
|
)
|
|
result += f"\n\nSaved to: `{output_path}`\n\n*Transcripts are cached as _transcript.json.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_silence_candidates(arguments: dict) -> Sequence[TextContent]:
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
modifier = FCPXMLModifier(filepath)
|
|
candidates = modifier.detect_silence_candidates(
|
|
min_gap_seconds=arguments.get("min_gap_seconds", 0.5),
|
|
patterns=arguments.get("patterns"),
|
|
)
|
|
|
|
if not candidates:
|
|
return _text_result("No silence candidates detected.")
|
|
|
|
result = f"# Silence Candidates Detected\n\n**Found**: {len(candidates)}\n\n"
|
|
result += "| # | Timecode | Duration | Reason | Confidence | Clip |\n"
|
|
result += "|---|----------|----------|--------|------------|------|\n"
|
|
for i, c in enumerate(candidates, 1):
|
|
result += (
|
|
f"| {i} | {c['start_timecode']} | {format_duration(c['duration_seconds'])} | "
|
|
f"{c['reason']} | {c['confidence']:.0%} | {c.get('clip_name') or '-'} |\n"
|
|
)
|
|
result += (
|
|
"\n**Note**: Detection uses timeline heuristics (gaps, ultra-short clips, name patterns). "
|
|
"Review candidates before removing — some may be intentional."
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_remove_silence_candidates(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_silence_cleaned")
|
|
actions = modifier.remove_silence_candidates(
|
|
mode=arguments.get("mode", "mark"),
|
|
min_gap_seconds=arguments.get("min_gap_seconds", 0.5),
|
|
min_confidence=arguments.get("min_confidence", 0.7),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
if not actions:
|
|
return _text_result("No silence candidates met the confidence threshold.")
|
|
|
|
mode = arguments.get("mode", "mark")
|
|
result = f"# Silence Candidates {'Marked' if mode == 'mark' else 'Removed'}\n\n"
|
|
result += f"**Actions taken**: {len(actions)}\n\n"
|
|
for a in actions:
|
|
result += f"- **{a['action']}** {a.get('clip_name', 'gap')} ({a['reason']})\n"
|
|
result += f"\nSaved to: `{output_path}`"
|
|
return _text_result(result)
|
|
|
|
|
|
# ----- NLE EXPORT HANDLERS (v0.5.0) -----
|
|
|
|
async def handle_export_resolve_xml(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_resolve")
|
|
exporter = DaVinciExporter(filepath)
|
|
exporter.export_simplified_fcpxml(
|
|
output_path,
|
|
flatten_compounds=arguments.get("flatten_compounds", True),
|
|
)
|
|
return _text_result((
|
|
f"# Exported for DaVinci Resolve\n\n"
|
|
f"- **Format**: Simplified FCPXML v1.9\n"
|
|
f"- **Compound clips flattened**: {arguments.get('flatten_compounds', True)}\n\n"
|
|
f"Saved to: `{output_path}`\n\n"
|
|
f"**Next step**: In DaVinci Resolve, go to File > Import > Timeline > Import AAF/EDL/XML"
|
|
))
|
|
|
|
|
|
async def handle_export_fcp7_xml(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path = _resolve_io_paths(arguments, "_fcp7")
|
|
exporter = DaVinciExporter(filepath)
|
|
exporter.export_xmeml(output_path)
|
|
return _text_result((
|
|
f"# Exported as FCP7 XML (XMEML)\n\n"
|
|
f"- **Format**: XMEML v5\n"
|
|
f"- **Compatible with**: Premiere Pro, DaVinci Resolve, Avid Media Composer\n\n"
|
|
f"Saved to: `{output_path}`\n\n"
|
|
f"**Next step**: Import via File > Import in your target NLE"
|
|
))
|
|
|
|
|
|
# ----- v0.6.0 HANDLERS -----
|
|
|
|
async def handle_list_effects(arguments: dict) -> Sequence[TextContent]:
|
|
effects = list_effects()
|
|
lines = ["# Available FCP Transition Effects\n"]
|
|
for eff in effects:
|
|
lines.append(f"- **{eff['slug']}**: {eff['name']} (`{eff['uuid']}`)")
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
async def handle_add_audio(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_audio")
|
|
|
|
parent_clip_id = arguments.get("parent_clip_id")
|
|
if parent_clip_id:
|
|
modifier.add_audio_clip(
|
|
parent_clip_id=parent_clip_id,
|
|
asset_id=arguments.get("asset_id"),
|
|
offset=arguments.get("offset", "0s"),
|
|
duration=arguments.get("duration"),
|
|
role=arguments.get("role", "dialogue"),
|
|
lane=arguments.get("lane", -1),
|
|
src=arguments.get("src"),
|
|
)
|
|
action = f"Added audio clip to '{parent_clip_id}'"
|
|
else:
|
|
modifier.add_music_bed(
|
|
asset_id=arguments.get("asset_id"),
|
|
duration=arguments.get("duration"),
|
|
role=arguments.get("role", "music"),
|
|
src=arguments.get("src"),
|
|
)
|
|
action = "Added music bed spanning full timeline"
|
|
|
|
modifier.save(output_path)
|
|
return _text_result(f"{action}\nSaved to: `{output_path}`")
|
|
|
|
|
|
async def handle_create_compound_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_compound")
|
|
clip_ids = arguments["clip_ids"]
|
|
name = arguments.get("name", "Compound Clip")
|
|
modifier.create_compound_clip(clip_ids, name)
|
|
modifier.save(output_path)
|
|
return _text_result((
|
|
f"Created compound clip '{name}' from {len(clip_ids)} clips.\n"
|
|
f"Saved to: `{output_path}`"
|
|
))
|
|
|
|
|
|
async def handle_flatten_compound_clip(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_flattened")
|
|
ref_clip_id = arguments["ref_clip_id"]
|
|
extracted = modifier.flatten_compound_clip(ref_clip_id)
|
|
modifier.save(output_path)
|
|
return _text_result((
|
|
f"Flattened compound clip '{ref_clip_id}' into {len(extracted)} clips.\n"
|
|
f"Saved to: `{output_path}`"
|
|
))
|
|
|
|
|
|
async def handle_list_templates(arguments: dict) -> Sequence[TextContent]:
|
|
templates = list_templates()
|
|
lines = ["# Available Timeline Templates\n"]
|
|
for tmpl in templates:
|
|
lines.append(f"## {tmpl['name']}")
|
|
lines.append(f"{tmpl['description']}\n")
|
|
lines.append("| Slot | Type | Default Duration | Lane | Required |")
|
|
lines.append("|------|------|-----------------|------|----------|")
|
|
for s in tmpl['slots']:
|
|
lines.append(
|
|
f"| {s['name']} | {s['slot_type']} | {s['default_duration']}s "
|
|
f"| {s['lane']} | {'Yes' if s['required'] else 'No'} |"
|
|
)
|
|
lines.append("")
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
async def handle_apply_template(arguments: dict) -> Sequence[TextContent]:
|
|
template_name = arguments["template_name"]
|
|
clips_raw = arguments["clips"]
|
|
output_path = _validate_output_path(arguments["output_path"], anchor_dir=PROJECTS_DIR)
|
|
fps = arguments.get("fps", 24)
|
|
|
|
# Convert raw clips dict to ClipSpec objects
|
|
clips_map = {}
|
|
for slot_name, spec_data in clips_raw.items():
|
|
if isinstance(spec_data, dict):
|
|
clips_map[slot_name] = ClipSpec(
|
|
asset_id=spec_data.get("asset_id"),
|
|
src=spec_data.get("src"),
|
|
name=spec_data.get("name", slot_name),
|
|
duration=spec_data.get("duration"),
|
|
)
|
|
|
|
result_path = apply_template(template_name, clips_map, output_path, fps)
|
|
return _text_result((
|
|
f"Applied template '{template_name}' with {len(clips_map)} clips.\n"
|
|
f"Saved to: `{result_path}`"
|
|
))
|
|
|
|
|
|
async def handle_relink_media(arguments: dict) -> Sequence[TextContent]:
|
|
dry_run = arguments.get("dry_run", False)
|
|
if dry_run:
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
modifier = FCPXMLModifier(filepath)
|
|
result = modifier.relink_media(
|
|
arguments["find"], arguments["replace"], dry_run=True
|
|
)
|
|
footer = "Dry run — no file written."
|
|
else:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_relinked")
|
|
result = modifier.relink_media(arguments["find"], arguments["replace"])
|
|
saved = modifier.save(output_path)
|
|
footer = f"Saved to: {saved}"
|
|
|
|
if not result["relinked"]:
|
|
return _text_result(
|
|
f"No media paths matched prefix '{arguments['find']}' "
|
|
f"({result['total_assets']} assets scanned). Nothing to relink."
|
|
)
|
|
|
|
lines = [
|
|
f"{'Would relink' if dry_run else 'Relinked'} "
|
|
f"{result['relinked']} media reference(s) "
|
|
f"across {result['total_assets']} asset(s):",
|
|
"",
|
|
]
|
|
missing = 0
|
|
for change in result["changes"]:
|
|
mark = "✓" if change["target_exists"] else "⚠ target missing"
|
|
if not change["target_exists"]:
|
|
missing += 1
|
|
lines.append(f" {change['asset']}: {change['new']} [{mark}]")
|
|
if missing:
|
|
lines.append("")
|
|
lines.append(
|
|
f"⚠ {missing} new path(s) do not exist on this machine — "
|
|
f"FCP will show those clips as missing until the media is present."
|
|
)
|
|
lines.append("")
|
|
lines.append(footer)
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
async def handle_push_to_fcp(arguments: dict) -> Sequence[TextContent]:
|
|
from fcpxml.live import push_to_fcp
|
|
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
|
|
# Flat files get an options-injected sibling copy (never touch the
|
|
# original); the copy path goes through the same write sandbox as
|
|
# every other derived output.
|
|
import_copy = None
|
|
if Path(filepath).suffix.lower() == '.fcpxml':
|
|
anchor = str(Path(filepath).resolve().parent)
|
|
import_copy = _validate_output_path(
|
|
generate_output_path(filepath, "_import"), anchor_dir=anchor
|
|
)
|
|
|
|
result = push_to_fcp(
|
|
filepath,
|
|
library_location=arguments.get("library_location"),
|
|
suppress_warnings=arguments.get("suppress_warnings", True),
|
|
copy_assets=arguments.get("copy_assets"),
|
|
import_copy_path=import_copy,
|
|
)
|
|
lines = [
|
|
f"Sent to Final Cut Pro: {result['sent']}",
|
|
f"FCP {'was launched' if result['launched_fcp'] else 'was already running'} — "
|
|
f"import happens in-app (libraries/events are created or merged per import-options).",
|
|
]
|
|
if arguments.get("library_location"):
|
|
lines.append(f"Target library: {arguments['library_location']}")
|
|
lines.append(
|
|
"Note: Apple offers no programmatic export — to round-trip edits "
|
|
"back, use File > Export XML in FCP."
|
|
)
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
async def handle_list_fcp_libraries(arguments: dict) -> Sequence[TextContent]:
|
|
from fcpxml.live import list_fcp_libraries
|
|
|
|
try:
|
|
libraries = list_fcp_libraries(
|
|
allow_launch=arguments.get("allow_launch", False)
|
|
)
|
|
except RuntimeError as exc:
|
|
return _text_result(str(exc))
|
|
|
|
if not libraries:
|
|
return _text_result("Final Cut Pro is running but reports no open libraries.")
|
|
|
|
lines = [f"Open libraries in Final Cut Pro ({len(libraries)}):", ""]
|
|
for lib in libraries:
|
|
lines.append(f"📚 {lib['name']}")
|
|
for event in lib["events"]:
|
|
lines.append(f" └─ {event['name']}")
|
|
for proj in event["projects"]:
|
|
lines.append(f" • {proj}")
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
# ============================================================================
|
|
# TOOL DISPATCH
|
|
# ============================================================================
|
|
|
|
TOOL_HANDLERS = {
|
|
# Read
|
|
"list_projects": handle_list_projects,
|
|
"analyze_timeline": handle_analyze_timeline,
|
|
"list_clips": handle_list_clips,
|
|
"list_markers": handle_list_markers,
|
|
"find_short_cuts": handle_find_short_cuts,
|
|
"find_long_clips": handle_find_long_clips,
|
|
"list_keywords": handle_list_keywords,
|
|
"export_edl": handle_export_edl,
|
|
"export_csv": handle_export_csv,
|
|
"analyze_pacing": handle_analyze_pacing,
|
|
"list_library_clips": handle_list_library_clips,
|
|
# QC
|
|
"detect_flash_frames": handle_detect_flash_frames,
|
|
"detect_duplicates": handle_detect_duplicates,
|
|
"detect_gaps": handle_detect_gaps,
|
|
# Write
|
|
"add_marker": handle_add_marker,
|
|
"batch_add_markers": handle_batch_add_markers,
|
|
"trim_clip": handle_trim_clip,
|
|
"reorder_clips": handle_reorder_clips,
|
|
"add_transition": handle_add_transition,
|
|
"change_speed": handle_change_speed,
|
|
"add_zoom": handle_add_zoom,
|
|
"delete_clips": handle_delete_clips,
|
|
"split_clip": handle_split_clip,
|
|
"insert_clip": handle_insert_clip,
|
|
# Batch Fix
|
|
"fix_flash_frames": handle_fix_flash_frames,
|
|
"rapid_trim": handle_rapid_trim,
|
|
"fill_gaps": handle_fill_gaps,
|
|
"validate_timeline": handle_validate_timeline,
|
|
# Generation
|
|
"auto_rough_cut": handle_auto_rough_cut,
|
|
"generate_montage": handle_generate_montage,
|
|
"generate_ab_roll": handle_generate_ab_roll,
|
|
# Beat Sync
|
|
"import_beat_markers": handle_import_beat_markers,
|
|
"snap_to_beats": handle_snap_to_beats,
|
|
# SRT / Transcript
|
|
"import_srt_markers": handle_import_srt_markers,
|
|
"import_transcript_markers": handle_import_transcript_markers,
|
|
# Connected Clips & Compound Clips (v0.5.0)
|
|
"list_connected_clips": handle_list_connected_clips,
|
|
"add_connected_clip": handle_add_connected_clip,
|
|
"list_compound_clips": handle_list_compound_clips,
|
|
# Roles (v0.5.0)
|
|
"list_roles": handle_list_roles,
|
|
"assign_role": handle_assign_role,
|
|
"filter_by_role": handle_filter_by_role,
|
|
"export_role_stems": handle_export_role_stems,
|
|
# Timeline Diff (v0.5.0)
|
|
"diff_timelines": handle_diff_timelines,
|
|
# Social Media Reformat (v0.5.0)
|
|
"reformat_timeline": handle_reformat_timeline,
|
|
# Silence Detection (v0.5.0)
|
|
"detect_media_silence": handle_detect_media_silence,
|
|
"remove_media_silence": handle_remove_media_silence,
|
|
"transcribe_media": handle_transcribe_media,
|
|
"edit_by_transcript": handle_edit_by_transcript,
|
|
"remove_filler_words": handle_remove_filler_words,
|
|
"transcript_markers": handle_transcript_markers,
|
|
"generate_dynamic_subtitles": handle_generate_dynamic_subtitles,
|
|
"detect_beats": handle_detect_beats,
|
|
"detect_silence_candidates": handle_detect_silence_candidates,
|
|
"remove_silence_candidates": handle_remove_silence_candidates,
|
|
# NLE Export (v0.5.0)
|
|
"export_resolve_xml": handle_export_resolve_xml,
|
|
"export_fcp7_xml": handle_export_fcp7_xml,
|
|
# v0.6.0
|
|
"list_effects": handle_list_effects,
|
|
"add_audio": handle_add_audio,
|
|
"create_compound_clip": handle_create_compound_clip,
|
|
"flatten_compound_clip": handle_flatten_compound_clip,
|
|
"list_templates": handle_list_templates,
|
|
"apply_template": handle_apply_template,
|
|
# v0.8.0
|
|
"relink_media": handle_relink_media,
|
|
# v0.9.0 — Live mode
|
|
"push_to_fcp": handle_push_to_fcp,
|
|
"list_fcp_libraries": handle_list_fcp_libraries,
|
|
}
|
|
|
|
|
|
@server.call_tool()
|
|
async def call_tool(name: str, arguments: dict[str, Any]) -> Sequence[TextContent]:
|
|
handler = TOOL_HANDLERS.get(name)
|
|
if not handler:
|
|
return _text_result(f"Unknown tool: {name}")
|
|
try:
|
|
return await handler(arguments)
|
|
except _NoTimelineError:
|
|
return _no_timeline()
|
|
except FileNotFoundError as e:
|
|
return _text_result(f"File not found: {e}")
|
|
except ValueError as e:
|
|
return _text_result(f"Validation error: {e}")
|
|
except Exception as e:
|
|
return _text_result(f"Error: {type(e).__name__}")
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN
|
|
# ============================================================================
|
|
|
|
async def main():
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
|
|
|
|
def main_sync():
|
|
"""Synchronous entry point for use as a console script."""
|
|
import asyncio
|
|
asyncio.run(main())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main_sync()
|