274 lines
10 KiB
Python
Executable File
274 lines
10 KiB
Python
Executable File
"""Live mode v1 — officially-supported control of a running Final Cut Pro.
|
|
|
|
This module is the first piece of the dual-mode (XML + Live) architecture
|
|
(see docs/CAPABILITY-AUDIT-2026-06.md). Everything here rides Apple's
|
|
sanctioned surfaces only — no injection, no private APIs, no accessibility
|
|
scripting:
|
|
|
|
- **push_to_fcp** — programmatic FCPXML *import* via the Open Document
|
|
Apple event, Apple's documented zero-click ingestion path. Behavior is
|
|
steered by an ``<import-options>`` element injected into the document
|
|
(library location, suppress warnings, copy assets).
|
|
|
|
Live-verified findings (FCP 12.2, 2026-06-11) that shape the contract:
|
|
|
|
1. **Truly zero-click requires a ``.fcpbundle`` library location.** Given
|
|
a new ``.fcpbundle`` path, FCP silently creates the library + a dated
|
|
event and imports. With NO location, or a ``.fcplibrary``/bare path,
|
|
FCP raises a modal "Open Library" picker — a *required choice* that
|
|
``suppress warnings`` does not cover — and the Apple event blocks until
|
|
a human answers. ``inject_import_options`` therefore normalises the
|
|
location to ``.fcpbundle``.
|
|
2. **Media-identity collisions** — importing a project whose media already
|
|
exists in the target library fails with "the media already exists with a
|
|
unique identifier". Push into a fresh library, or reuse the exact asset
|
|
IDs FCP already holds.
|
|
- **list_fcp_libraries** — FCP 12's AppleScript dictionary is read-only
|
|
library inspection (suite ``com.apple.FinalCut.library.inspection``);
|
|
we use it to enumerate open libraries → events → projects.
|
|
|
|
The asymmetry is structural: import is scriptable, but Apple offers NO
|
|
programmatic export — reading back the user's current timeline still
|
|
requires a manual File > Export XML. Live mode therefore *pushes*;
|
|
round-trips come back through the XML tools.
|
|
|
|
macOS notes: ``osascript`` targeting Final Cut Pro requires the host
|
|
process to hold an Apple Events automation grant (System Settings →
|
|
Privacy & Security → Automation) — the first call triggers the consent
|
|
prompt. ``tell application "Final Cut Pro"`` launches FCP if it is not
|
|
already running; ``list_fcp_libraries`` checks first and declines to
|
|
launch, while ``push_to_fcp`` launching FCP is the point.
|
|
"""
|
|
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
_OSASCRIPT_TIMEOUT_SECONDS = 120 # FCP cold-launch + import can be slow
|
|
_FCP_BUNDLE_ID = "com.apple.FinalCut"
|
|
|
|
# Field separators for AppleScript list output — ASCII unit/record
|
|
# separators cannot appear in user-facing library/project names.
|
|
_FIELD_SEP = "\x1f"
|
|
_RECORD_SEP = "\x1e"
|
|
|
|
|
|
def _applescript_quote(value: str) -> str:
|
|
"""Escape a string for embedding in a double-quoted AppleScript literal."""
|
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
def _run_osascript(script: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
["osascript", "-e", script],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_OSASCRIPT_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
|
|
def fcp_is_running() -> bool:
|
|
"""True when a Final Cut Pro process is active (no launch side-effect)."""
|
|
proc = subprocess.run(
|
|
["pgrep", "-x", "Final Cut Pro"], capture_output=True, text=True
|
|
)
|
|
return proc.returncode == 0
|
|
|
|
|
|
def inject_import_options(
|
|
fcpxml_path: str,
|
|
output_path: str,
|
|
library_location: Optional[str] = None,
|
|
suppress_warnings: bool = True,
|
|
copy_assets: Optional[bool] = None,
|
|
) -> str:
|
|
"""Write a copy of *fcpxml_path* with an ``<import-options>`` element.
|
|
|
|
The DTD requires ``import-options`` as the FIRST child of ``<fcpxml>``
|
|
(``<!ELEMENT fcpxml (import-options?, resources?, ...)>``). Any
|
|
existing import-options element is replaced.
|
|
|
|
Args:
|
|
fcpxml_path: Source ``.fcpxml`` document.
|
|
output_path: Where to write the import-ready copy.
|
|
library_location: Path or ``file://`` URL of the target library
|
|
(FCP creates the library if none exists there).
|
|
suppress_warnings: Suppress non-fatal import warning dialogs.
|
|
copy_assets: True = copy media into the library, False = link
|
|
in place, None = let FCP use its default.
|
|
|
|
Returns:
|
|
*output_path*.
|
|
"""
|
|
from .safe_xml import safe_parse
|
|
from .writer import write_fcpxml
|
|
|
|
tree = safe_parse(fcpxml_path)
|
|
root = tree.getroot()
|
|
|
|
for stale in root.findall('import-options'):
|
|
root.remove(stale)
|
|
|
|
options = ET.Element('import-options')
|
|
if library_location:
|
|
loc = library_location
|
|
if not loc.startswith('file://'):
|
|
from urllib.parse import quote
|
|
resolved = Path(loc).expanduser()
|
|
# Live-verified on FCP 12.2: FCP auto-creates a library ONLY when
|
|
# the location carries the .fcpbundle extension. A bare directory
|
|
# or a .fcplibrary path triggers the modal "Open Library" picker
|
|
# (a required choice that `suppress warnings` does NOT dismiss),
|
|
# which blocks the Apple event. Normalise to .fcpbundle.
|
|
if resolved.suffix.lower() != '.fcpbundle':
|
|
resolved = resolved.with_suffix('.fcpbundle')
|
|
loc = 'file://' + quote(str(resolved.resolve()))
|
|
ET.SubElement(options, 'option', key='library location', value=loc)
|
|
ET.SubElement(
|
|
options, 'option',
|
|
key='suppress warnings', value='1' if suppress_warnings else '0',
|
|
)
|
|
if copy_assets is not None:
|
|
ET.SubElement(
|
|
options, 'option',
|
|
key='copy assets', value='1' if copy_assets else '0',
|
|
)
|
|
root.insert(0, options)
|
|
|
|
write_fcpxml(root, output_path)
|
|
return output_path
|
|
|
|
|
|
def push_to_fcp(
|
|
fcpxml_path: str,
|
|
library_location: Optional[str] = None,
|
|
suppress_warnings: bool = True,
|
|
copy_assets: Optional[bool] = None,
|
|
import_copy_path: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Send an FCPXML document to Final Cut Pro via the Open Document event.
|
|
|
|
This is Apple's documented programmatic-import path: FCP ingests the
|
|
document without any clicks, creating libraries/events as directed by
|
|
``<import-options>``. Launches FCP when it isn't running.
|
|
|
|
Args:
|
|
fcpxml_path: ``.fcpxml`` file or ``.fcpxmld`` bundle to import.
|
|
library_location: Target library path/URL (created if absent).
|
|
suppress_warnings: Suppress non-fatal import warning dialogs.
|
|
copy_assets: Copy media into the library vs. link in place.
|
|
import_copy_path: Where to write the options-injected copy for
|
|
flat files (defaults handled by the caller; required when
|
|
options are used on a flat file).
|
|
|
|
Returns:
|
|
Dict with ``sent`` (path actually opened), ``launched_fcp``
|
|
(whether FCP was started by this call), and ``stdout``.
|
|
|
|
Raises:
|
|
RuntimeError: When osascript fails (most commonly a missing
|
|
Automation permission grant for the host process).
|
|
"""
|
|
path = Path(fcpxml_path)
|
|
send_path = path
|
|
|
|
# Bundles: open directly (option injection inside a copied bundle is
|
|
# a v0.10 refinement); flat files get an import-ready copy so the
|
|
# user's original is never touched.
|
|
if path.suffix.lower() != '.fcpxmld' and import_copy_path:
|
|
send_path = Path(
|
|
inject_import_options(
|
|
str(path),
|
|
import_copy_path,
|
|
library_location=library_location,
|
|
suppress_warnings=suppress_warnings,
|
|
copy_assets=copy_assets,
|
|
)
|
|
)
|
|
|
|
was_running = fcp_is_running()
|
|
script = (
|
|
'tell application "Final Cut Pro"\n'
|
|
'activate\n'
|
|
f'open POSIX file "{_applescript_quote(str(send_path.resolve()))}"\n'
|
|
'end tell'
|
|
)
|
|
proc = _run_osascript(script)
|
|
if proc.returncode != 0:
|
|
stderr = proc.stderr.strip()
|
|
hint = ""
|
|
if "-1743" in stderr or "not allowed" in stderr.lower():
|
|
hint = (
|
|
" — grant Automation permission: System Settings → "
|
|
"Privacy & Security → Automation → allow your terminal/MCP "
|
|
"host to control Final Cut Pro, then retry"
|
|
)
|
|
raise RuntimeError(f"osascript failed: {stderr}{hint}")
|
|
|
|
return {
|
|
"sent": str(send_path),
|
|
"launched_fcp": not was_running,
|
|
"stdout": proc.stdout.strip(),
|
|
}
|
|
|
|
|
|
def list_fcp_libraries(allow_launch: bool = False) -> List[Dict[str, Any]]:
|
|
"""Enumerate open libraries → events → projects via AppleScript.
|
|
|
|
Uses FCP 12's read-only scripting dictionary. By default this
|
|
refuses to launch FCP (``tell application`` would start it);
|
|
pass ``allow_launch=True`` to override.
|
|
|
|
Returns:
|
|
List of ``{name, events: [{name, projects: [str, ...]}, ...]}``.
|
|
|
|
Raises:
|
|
RuntimeError: FCP not running (and *allow_launch* False), or
|
|
osascript failure.
|
|
"""
|
|
if not allow_launch and not fcp_is_running():
|
|
raise RuntimeError(
|
|
"Final Cut Pro is not running (pass allow_launch=true to start it)"
|
|
)
|
|
|
|
script = (
|
|
'set fieldSep to (ASCII character 31)\n'
|
|
'set recSep to (ASCII character 30)\n'
|
|
'set out to ""\n'
|
|
'tell application "Final Cut Pro"\n'
|
|
' repeat with lib in libraries\n'
|
|
' set libName to name of lib\n'
|
|
' repeat with evt in (events of lib)\n'
|
|
' set evtName to name of evt\n'
|
|
' set projNames to ""\n'
|
|
' repeat with proj in (projects of evt)\n'
|
|
' set projNames to projNames & (name of proj) & fieldSep\n'
|
|
' end repeat\n'
|
|
' set out to out & libName & fieldSep & evtName & fieldSep '
|
|
'& projNames & recSep\n'
|
|
' end repeat\n'
|
|
' if (count of events of lib) is 0 then\n'
|
|
' set out to out & libName & fieldSep & recSep\n'
|
|
' end if\n'
|
|
' end repeat\n'
|
|
'end tell\n'
|
|
'return out'
|
|
)
|
|
proc = _run_osascript(script)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"osascript failed: {proc.stderr.strip()}")
|
|
|
|
libraries: Dict[str, Dict[str, Any]] = {}
|
|
for record in proc.stdout.strip().split(_RECORD_SEP):
|
|
record = record.strip('\n')
|
|
if not record:
|
|
continue
|
|
fields = record.split(_FIELD_SEP)
|
|
lib_name = fields[0]
|
|
lib = libraries.setdefault(lib_name, {"name": lib_name, "events": []})
|
|
if len(fields) >= 2 and fields[1]:
|
|
projects = [p for p in fields[2:] if p]
|
|
lib["events"].append({"name": fields[1], "projects": projects})
|
|
return list(libraries.values())
|