chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+273
@@ -0,0 +1,273 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user