Object-tracker/tracking-shape (dado de rastreamento de objeto preservado do asset original) mantinha o mesmo id em cada deepcopy feito por split_clip/cut_clip_ranges, e o FCP acabava rejeitando o arquivo com "ID tr1 already defined" depois de vários cortes. Mesmo mecanismo do bug já corrigido para text-style-def, agora coberto também para tracking-shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
726 lines
30 KiB
Python
726 lines
30 KiB
Python
"""Núcleo do FCPXMLModifier: carga, índices, navegação na spine e save.
|
|
|
|
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
|
|
"""
|
|
|
|
import xml.etree.ElementTree as ET
|
|
from fractions import Fraction
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from ..models import (
|
|
TimeValue,
|
|
)
|
|
from .document import write_fcpxml
|
|
from .helpers import CLIP_TAGS
|
|
|
|
|
|
class ModifierCore:
|
|
"""Load an existing FCPXML file, apply edits, and save.
|
|
|
|
This is the primary editing interface used by every MCP server write-tool
|
|
handler. It wraps an ElementTree parsed from disk and maintains three
|
|
in-memory indices so that clip/asset lookups are fast.
|
|
|
|
Index design
|
|
------------
|
|
``clips`` : ``Dict[str, ET.Element]``
|
|
Every ``<clip>``, ``<asset-clip>``, and ``<video>`` element keyed by
|
|
its ``id`` attribute, falling back to ``name``, then a generated key.
|
|
**Gotcha**: duplicate clip names (e.g. multiple "Interview_A") mean
|
|
only the *last* element indexed under that name is accessible. Use
|
|
unique ``id`` attributes when possible.
|
|
|
|
``resources`` : ``Dict[str, Dict[str, Any]]``
|
|
Every ``<asset>`` element keyed by ``id``, with pre-extracted ``name``,
|
|
``src``, ``start``, ``duration``, and a reference to the raw element.
|
|
|
|
``formats`` : ``Dict[str, Dict[str, Any]]``
|
|
Every ``<format>`` element keyed by ``id``.
|
|
|
|
Editing model
|
|
-------------
|
|
1. Look up the target clip via ``_require_clip`` / ``_require_spine_clip``.
|
|
2. Mutate the clip's XML attributes (``start``, ``duration``, ``offset``).
|
|
3. If the edit changes duration, ripple subsequent spine siblings via
|
|
``_ripple_from_index`` so downstream offsets stay contiguous.
|
|
4. Call ``save()`` to serialise the modified tree back to disk.
|
|
|
|
Example::
|
|
|
|
modifier = FCPXMLModifier("project.fcpxml")
|
|
modifier.add_marker("clip_0", "00:00:10:00", "Review", MarkerType.INCOMPLETE)
|
|
modifier.trim_clip("clip_1", trim_end="-2s")
|
|
modifier.save("project_modified.fcpxml")
|
|
|
|
Attributes:
|
|
path (Path): Filesystem path to the source FCPXML file.
|
|
tree (ET.ElementTree): Parsed XML tree (mutated in-place by edits).
|
|
root (ET.Element): Root ``<fcpxml>`` element.
|
|
fps (float): Detected frame rate from the first ``<format>`` resource.
|
|
clips (Dict[str, ET.Element]): Clip index — see *Index design* above.
|
|
resources (Dict[str, Dict]): Asset index.
|
|
formats (Dict[str, Dict]): Format index.
|
|
"""
|
|
|
|
def __init__(self, fcpxml_path: str):
|
|
"""Load *fcpxml_path*, parse its XML, and build lookup indices.
|
|
|
|
The constructor eagerly builds all three indices (clips, resources,
|
|
formats) and detects the project frame rate. After construction the
|
|
modifier is ready for any editing operation.
|
|
|
|
Args:
|
|
fcpxml_path: Absolute or relative path to an ``.fcpxml`` file or
|
|
an ``.fcpxmld`` bundle (a directory wrapping ``Info.fcpxml``
|
|
plus sidecar data files for object tracking / Cinematic mode).
|
|
|
|
Raises:
|
|
FileNotFoundError: If *fcpxml_path* does not exist.
|
|
ET.ParseError: If the file is not valid XML.
|
|
ValueError: If no ``<spine>`` is found (checked lazily on first edit).
|
|
"""
|
|
path = Path(fcpxml_path)
|
|
self.bundle_dir: Optional[Path] = None
|
|
if path.suffix.lower() == '.fcpxmld':
|
|
self.bundle_dir = path
|
|
inner = path / 'Info.fcpxml'
|
|
if not inner.exists():
|
|
raise FileNotFoundError(
|
|
f"Info.fcpxml not found in bundle: {fcpxml_path}"
|
|
)
|
|
fcpxml_path = str(inner)
|
|
self.path = Path(fcpxml_path)
|
|
from ..safe_xml import safe_parse
|
|
self.tree = safe_parse(fcpxml_path)
|
|
self.root = self.tree.getroot()
|
|
self.fps = self._detect_fps()
|
|
# Lazily filled on the first generated title; see _unique_text_style_id.
|
|
self._text_style_ids: Optional[set] = None
|
|
# Lazily filled on the first clip split/cut; see _unique_tracking_shape_id.
|
|
self._tracking_shape_ids: Optional[set] = None
|
|
self._build_resource_index()
|
|
self._build_clip_index()
|
|
|
|
def _detect_fps(self) -> float:
|
|
"""Extract frame rate from format resource."""
|
|
for fmt in self.root.findall('.//format'):
|
|
frame_dur = fmt.get('frameDuration', '1/30s')
|
|
if '/' in frame_dur:
|
|
parts = frame_dur.replace('s', '').split('/', 1)
|
|
num, denom = int(parts[0]), int(parts[1])
|
|
if num <= 0:
|
|
return 30.0
|
|
return denom / num
|
|
return 30.0
|
|
|
|
def frame_duration_fraction(self):
|
|
"""Exact ``frameDuration`` as a Fraction (e.g. 1001/24000 at 23.976fps).
|
|
|
|
Unlike ``_detect_fps()`` (a float, lossy for NTSC rates), this is
|
|
exact — use it wherever a cut boundary is snapped to the frame grid,
|
|
so 23.976/29.97/59.94 timebases don't drift off-grid the way a
|
|
hardcoded tick base like 2400 does.
|
|
"""
|
|
|
|
for fmt in self.root.findall('.//format'):
|
|
raw = fmt.get('frameDuration', '')
|
|
if raw.endswith('s') and '/' in raw:
|
|
n, d = raw[:-1].split('/', 1)
|
|
fd = Fraction(int(n), int(d))
|
|
if fd > 0:
|
|
return fd
|
|
return Fraction(1, 30)
|
|
|
|
def frame_size(self) -> 'Tuple[float, float]':
|
|
"""The sequence's frame size in pixels, as ``(width, height)``.
|
|
|
|
Reads the sequence's own ``<format>`` when it references one, since a
|
|
document may carry several (an asset's source format need not match
|
|
the timeline's). Falls back to the first format that declares a size,
|
|
then to 1920x1080.
|
|
"""
|
|
formats = {f.get('id'): f for f in self.root.findall('.//format')}
|
|
candidates = []
|
|
seq = self.root.find('.//sequence')
|
|
if seq is not None and formats.get(seq.get('format')) is not None:
|
|
candidates.append(formats[seq.get('format')])
|
|
candidates.extend(formats.values())
|
|
|
|
for fmt in candidates:
|
|
try:
|
|
width = float(fmt.get('width') or 0)
|
|
height = float(fmt.get('height') or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if width > 0 and height > 0:
|
|
return width, height
|
|
return 1920.0, 1080.0
|
|
|
|
def frame_width(self) -> float:
|
|
"""The sequence's frame width in pixels."""
|
|
return self.frame_size()[0]
|
|
|
|
def frame_height(self) -> float:
|
|
"""The sequence's frame height in pixels."""
|
|
return self.frame_size()[1]
|
|
|
|
def snap_seconds_to_frame(self, seconds: float) -> 'TimeValue':
|
|
"""Round *seconds* to the nearest exact frame boundary as a TimeValue."""
|
|
fd = self.frame_duration_fraction()
|
|
frames = round(seconds / float(fd))
|
|
snapped = fd * frames
|
|
return TimeValue(snapped.numerator, snapped.denominator)
|
|
|
|
def snap_spine_times_to_frames(self) -> None:
|
|
"""Snap primary-storyline offsets and durations to sequence frames.
|
|
|
|
Final Cut rejects otherwise valid XML when ripple edits leave a clip
|
|
boundary between frames. Use the exact ``frameDuration`` fraction,
|
|
rather than a float FPS, to preserve 23.976/29.97 timebases.
|
|
"""
|
|
|
|
frame_duration = None
|
|
for fmt in self.root.findall('.//format'):
|
|
raw = fmt.get('frameDuration', '')
|
|
if raw.endswith('s') and '/' in raw:
|
|
n, d = raw[:-1].split('/', 1)
|
|
frame_duration = Fraction(int(n), int(d))
|
|
break
|
|
if frame_duration is None or frame_duration <= 0:
|
|
return
|
|
|
|
for element in self.root.findall('.//spine/*'):
|
|
for attr in ('offset', 'duration'):
|
|
raw = element.get(attr)
|
|
if not raw or not raw.endswith('s'):
|
|
continue
|
|
value = raw[:-1]
|
|
if '/' in value:
|
|
n, d = value.split('/', 1)
|
|
seconds = Fraction(int(n), int(d))
|
|
else:
|
|
seconds = Fraction(value)
|
|
frames = int(round(float(seconds / frame_duration)))
|
|
snapped = frame_duration * frames
|
|
element.set(attr, f'{snapped.numerator}/{snapped.denominator}s')
|
|
|
|
def _build_resource_index(self) -> None:
|
|
"""Build ``self.resources`` and ``self.formats`` from ``<asset>``/``<format>`` elements.
|
|
|
|
Called once during ``__init__``. Each asset entry stores the raw
|
|
element plus pre-extracted metadata so callers don't need to
|
|
re-parse attributes on every access.
|
|
"""
|
|
self.resources: Dict[str, Dict[str, Any]] = {}
|
|
self.formats: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for asset in self.root.findall('.//asset'):
|
|
asset_id = asset.get('id', '')
|
|
self.resources[asset_id] = {
|
|
'id': asset_id,
|
|
'name': asset.get('name', ''),
|
|
'src': asset.get('src', '') or (asset.find('media-rep').get('src', '') if asset.find('media-rep') is not None else ''),
|
|
'start': asset.get('start', '0s'),
|
|
'duration': asset.get('duration', '0s'),
|
|
'element': asset
|
|
}
|
|
|
|
for fmt in self.root.findall('.//format'):
|
|
fmt_id = fmt.get('id', '')
|
|
self.formats[fmt_id] = {
|
|
'id': fmt_id,
|
|
'name': fmt.get('name', ''),
|
|
'element': fmt
|
|
}
|
|
|
|
def _index_elements(self, tag: str, fallback_prefix: str) -> None:
|
|
"""Index XML elements of *tag* into ``self.clips`` by id/name.
|
|
|
|
Each element is keyed by its ``id`` attribute, falling back to
|
|
``name``, then a generated ``{fallback_prefix}_{i}`` key. This
|
|
replaces three near-identical loops that only differed in the tag
|
|
name and fallback prefix.
|
|
"""
|
|
for i, elem in enumerate(self.root.findall(f'.//{tag}')):
|
|
key = elem.get('id') or elem.get('name') or f"{fallback_prefix}_{i}"
|
|
self.clips[key] = elem
|
|
|
|
def _build_clip_index(self) -> None:
|
|
"""Build ``self.clips`` index from all clip-type elements.
|
|
|
|
Indexes ``<clip>``, ``<asset-clip>``, and ``<video>`` tags. Keys are
|
|
resolved by ``_index_elements`` (``id`` → ``name`` → generated).
|
|
|
|
.. warning::
|
|
Duplicate names cause last-one-wins overwrites. If your project
|
|
has multiple clips named "Interview_A", only the last one parsed
|
|
will be reachable by name. Prefer unique ``id`` attributes.
|
|
"""
|
|
self.clips: Dict[str, ET.Element] = {}
|
|
for tag, prefix in (('clip', 'clip'), ('asset-clip', 'asset_clip'), ('video', 'video')):
|
|
self._index_elements(tag, prefix)
|
|
|
|
def _get_spine(self) -> ET.Element:
|
|
"""Get the primary storyline spine.
|
|
|
|
Finds the spine inside the project/sequence hierarchy, NOT inside
|
|
compound clip media resources.
|
|
"""
|
|
# Prefer the main timeline spine (under project/sequence)
|
|
spine = self.root.find('.//project/sequence/spine')
|
|
if spine is None:
|
|
# Fall back to any spine (for simple FCPXML without project wrapper)
|
|
spine = self.root.find('.//spine')
|
|
if spine is None:
|
|
raise ValueError("No spine found in FCPXML")
|
|
return spine
|
|
|
|
def _iter_spine_clips(self) -> list[tuple[int, ET.Element]]:
|
|
"""Return an indexed list of clip-type elements in the primary spine.
|
|
|
|
Filters out gaps, transitions, and other non-clip elements, returning
|
|
only ``(index_in_spine, element)`` pairs where the tag is in
|
|
``CLIP_TAGS``. The index is the element's position among *all* spine
|
|
children (not just clips), so it stays valid for insertion/removal.
|
|
"""
|
|
spine = self._get_spine()
|
|
return [
|
|
(i, child)
|
|
for i, child in enumerate(spine.findall('*'))
|
|
if child.tag in CLIP_TAGS
|
|
]
|
|
|
|
def _find_spine_clip_at_seconds(self, target_seconds: float) -> tuple[ET.Element, float]:
|
|
"""Find the spine clip containing *target_seconds* and return it with the relative offset.
|
|
|
|
Returns:
|
|
``(clip_element, relative_seconds)`` — the clip and the time
|
|
within that clip corresponding to *target_seconds*.
|
|
|
|
Raises:
|
|
ValueError: If no clip spans the requested position.
|
|
"""
|
|
spine = self._get_spine()
|
|
for child in spine.findall('*'):
|
|
if child.tag not in CLIP_TAGS:
|
|
continue
|
|
offset = self._parse_time(child.get('offset', '0s')).to_seconds()
|
|
dur = self._parse_time(child.get('duration', '0s')).to_seconds()
|
|
if offset <= target_seconds < offset + dur:
|
|
return child, target_seconds - offset
|
|
raise ValueError(f"No spine clip at position {target_seconds:.3f}s")
|
|
|
|
def _parse_time(self, tc: str) -> TimeValue:
|
|
"""Parse a timecode string to TimeValue."""
|
|
return TimeValue.from_timecode(tc, self.fps)
|
|
|
|
def _get_clip_times(
|
|
self, clip: ET.Element
|
|
) -> tuple:
|
|
"""Return (start, duration, offset) TimeValues for a clip element."""
|
|
return (
|
|
self._parse_time(clip.get('start', '0s')),
|
|
self._parse_time(clip.get('duration', '0s')),
|
|
self._parse_time(clip.get('offset', '0s')),
|
|
)
|
|
|
|
def source_file_start(self, clip: ET.Element) -> 'TimeValue':
|
|
"""Return a clip's in-point measured from the head of its media file.
|
|
|
|
FCPXML ``start`` on an asset-clip is a source *timecode*, and the
|
|
asset's own ``start`` is the timecode of the source media's first
|
|
frame. Media analysis (ffmpeg silencedetect, Whisper) reports
|
|
file-relative time, so subtract the asset's start timecode to land
|
|
both on the same origin. When the asset starts at 0s (the common
|
|
case, and every test fixture) this is a no-op.
|
|
"""
|
|
ref = clip.get('ref', '')
|
|
asset = self.resources.get(ref, {})
|
|
asset_start = self._parse_time(asset.get('start', '0s'))
|
|
clip_start = self._parse_time(clip.get('start', '0s'))
|
|
return clip_start - asset_start
|
|
|
|
def _resolve_clip_duration(
|
|
self,
|
|
asset: dict,
|
|
duration: Optional[str] = None,
|
|
in_point: Optional[str] = None,
|
|
out_point: Optional[str] = None,
|
|
) -> tuple['TimeValue', 'TimeValue']:
|
|
"""Compute clip duration and source start from optional overrides.
|
|
|
|
Centralises the three-way fallback logic shared by insert_clip,
|
|
add_connected_clip, and add_audio_clip:
|
|
|
|
1. If *in_point* and *out_point* are given → subclip range.
|
|
2. Else if *duration* is given → explicit duration, source start = 0.
|
|
3. Else → full asset duration, source start = 0.
|
|
|
|
Returns:
|
|
``(clip_duration, source_start)`` TimeValue pair.
|
|
"""
|
|
if in_point and out_point:
|
|
in_time = self._parse_time(in_point)
|
|
out_time = self._parse_time(out_point)
|
|
return out_time - in_time, in_time
|
|
if duration:
|
|
return self._parse_time(duration), TimeValue.zero()
|
|
return self._parse_time(asset.get('duration', '0s')), TimeValue.zero()
|
|
|
|
def _make_asset_clip(
|
|
self,
|
|
asset_id: str,
|
|
name: str,
|
|
offset: 'TimeValue',
|
|
start: 'TimeValue',
|
|
duration: 'TimeValue',
|
|
*,
|
|
parent: Optional[ET.Element] = None,
|
|
**extra_attrs: str,
|
|
) -> ET.Element:
|
|
"""Build an ``<asset-clip>`` element with standard attributes.
|
|
|
|
Centralises the repeated element creation shared by insert_clip,
|
|
add_connected_clip, and add_audio_clip. Each caller can pass
|
|
additional attributes (``lane``, ``audioRole``, ``format``) via
|
|
*extra_attrs*.
|
|
|
|
Args:
|
|
asset_id: Resource reference (e.g. ``'r3'``).
|
|
name: Human-readable clip name.
|
|
offset: Timeline offset (or offset within parent for connected clips).
|
|
start: Source media start point.
|
|
duration: Clip duration.
|
|
parent: If given, create the element as a SubElement of *parent*;
|
|
otherwise create a detached Element.
|
|
**extra_attrs: Additional XML attributes (``lane``, ``audioRole``).
|
|
|
|
Returns:
|
|
The new ``<asset-clip>`` Element.
|
|
"""
|
|
if parent is not None:
|
|
elem = ET.SubElement(parent, 'asset-clip')
|
|
else:
|
|
elem = ET.Element('asset-clip')
|
|
elem.set('ref', asset_id)
|
|
elem.set('offset', offset.to_fcpxml())
|
|
elem.set('name', name)
|
|
elem.set('start', start.to_fcpxml())
|
|
elem.set('duration', duration.to_fcpxml())
|
|
for attr, val in extra_attrs.items():
|
|
elem.set(attr, val)
|
|
return elem
|
|
|
|
def _require_clip(self, clip_id: 'str | ET.Element') -> ET.Element:
|
|
"""Look up a clip by ID/name, raising if not found.
|
|
|
|
Centralises the get-or-raise pattern used by every clip-mutating
|
|
method so the error message stays consistent and future
|
|
enhancements (fuzzy matching, suggestions) only need one site.
|
|
|
|
An Element is returned as-is. That matters after ``split_clip`` or
|
|
``cut_clip_ranges``: the resulting pieces all carry the *same* name,
|
|
so a name lookup would always resolve to the first one and silently
|
|
put the edit on the wrong piece. Callers holding the exact element
|
|
pass it directly.
|
|
"""
|
|
if isinstance(clip_id, ET.Element):
|
|
return clip_id
|
|
clip = self.clips.get(clip_id)
|
|
if clip is None:
|
|
raise ValueError(f"Clip not found: {clip_id}")
|
|
return clip
|
|
|
|
def _require_spine_clip(self, clip_id: str) -> tuple[ET.Element, ET.Element, int]:
|
|
"""Look up a clip and verify it lives in the primary spine.
|
|
|
|
Returns:
|
|
``(spine, clip, index_in_spine)`` tuple.
|
|
|
|
Raises:
|
|
ValueError: If the clip doesn't exist or isn't in the spine.
|
|
"""
|
|
clip = self._require_clip(clip_id)
|
|
spine = self._get_spine()
|
|
clip_index = self._find_clip_index(spine, clip)
|
|
if clip_index is None:
|
|
raise ValueError(f"Clip not in spine: {clip_id}")
|
|
return spine, clip, clip_index
|
|
|
|
def _find_clip_index(self, spine: ET.Element, clip: ET.Element) -> int | None:
|
|
"""Find the index of a clip in the spine. Returns None if not found."""
|
|
for i, child in enumerate(spine):
|
|
if child == clip:
|
|
return i
|
|
return None
|
|
|
|
@staticmethod
|
|
def _find_neighbor_clip(
|
|
spine_list: list, index: int, direction: str
|
|
) -> Optional[ET.Element]:
|
|
"""Find the nearest non-gap clip before or after *index* in *spine_list*.
|
|
|
|
Args:
|
|
spine_list: Materialised list of spine children.
|
|
index: Position to search from (exclusive).
|
|
direction: ``'prev'`` to search backward, ``'next'`` to search forward.
|
|
|
|
Returns:
|
|
The first clip-type element found, or ``None``.
|
|
"""
|
|
if direction == 'prev':
|
|
for j in range(index - 1, -1, -1):
|
|
if spine_list[j].tag in CLIP_TAGS:
|
|
return spine_list[j]
|
|
else:
|
|
for j in range(index + 1, len(spine_list)):
|
|
if spine_list[j].tag in CLIP_TAGS:
|
|
return spine_list[j]
|
|
return None
|
|
|
|
def _resolve_asset(
|
|
self, asset_id: Optional[str], asset_name: Optional[str]
|
|
) -> tuple:
|
|
"""Look up an asset by ID or name from ``self.resources``.
|
|
|
|
Returns:
|
|
``(asset_dict, resolved_asset_id)`` tuple.
|
|
|
|
Raises:
|
|
ValueError: If neither ID nor name matches a known asset.
|
|
"""
|
|
if asset_id and asset_id in self.resources:
|
|
return self.resources[asset_id], asset_id
|
|
if asset_name:
|
|
for res_id, res_data in self.resources.items():
|
|
if res_data.get('name') == asset_name:
|
|
return res_data, res_id
|
|
raise ValueError(f"Asset not found: {asset_id or asset_name}")
|
|
|
|
@staticmethod
|
|
def _unique_resource_id(resources: ET.Element, prefix: str) -> str:
|
|
"""Generate a unique resource ID with the given *prefix*.
|
|
|
|
Starts with ``prefix`` (e.g. ``'r_audio1'``), appending an
|
|
incrementing counter until no collision exists in *resources*.
|
|
"""
|
|
existing_ids = {el.get('id', '') for el in resources}
|
|
candidate = prefix
|
|
counter = 2
|
|
while candidate in existing_ids:
|
|
# Strip trailing digits from prefix for the counter suffix
|
|
base = prefix.rstrip('0123456789')
|
|
candidate = f'{base}{counter}'
|
|
counter += 1
|
|
return candidate
|
|
|
|
def _find_spine_element_at_timecode(
|
|
self, spine: ET.Element, target_tc: str, *, require_clip: bool = False
|
|
) -> Optional[ET.Element]:
|
|
"""Find the first spine child whose offset matches *target_tc*.
|
|
|
|
Normalises both sides through ``TimeValue`` round-trip so format
|
|
differences (e.g. ``"3600/2400s"`` vs ``"1800/1200s"``) don't
|
|
cause false negatives.
|
|
|
|
Args:
|
|
spine: The ``<spine>`` element to search.
|
|
target_tc: Timecode string to match against each child's offset.
|
|
require_clip: If True, skip non-clip elements (gaps, etc.).
|
|
"""
|
|
for child in spine:
|
|
offset_str = child.get('offset', '0s')
|
|
tc = TimeValue.from_timecode(offset_str, self.fps).to_timecode(self.fps)
|
|
if tc == target_tc:
|
|
if require_clip and child.tag not in CLIP_TAGS:
|
|
continue
|
|
return child
|
|
return None
|
|
|
|
def _absorb_into_neighbor(
|
|
self,
|
|
spine: ET.Element,
|
|
element: ET.Element,
|
|
direction: str,
|
|
) -> Optional[ET.Element]:
|
|
"""Extend a neighbor clip to absorb *element*'s duration, then remove *element*.
|
|
|
|
Shared by ``fix_flash_frames`` (absorbing flash-frame clips) and
|
|
``fill_gaps`` (absorbing gap elements). Both operations find the
|
|
nearest clip in *direction*, grow it by the absorbed element's
|
|
duration, and remove the absorbed element from the spine.
|
|
|
|
When extending backward (``direction='next'``), the neighbor's
|
|
source in-point is also pulled earlier so the extra frames come
|
|
from before the original cut, not after.
|
|
|
|
Does **not** call ``_recalculate_offsets`` — callers decide when to
|
|
recalculate (per-iteration vs. once at the end).
|
|
|
|
Args:
|
|
spine: The primary storyline ``<spine>`` element.
|
|
element: The clip or gap to absorb (will be removed).
|
|
direction: ``'prev'`` to extend the previous clip forward,
|
|
``'next'`` to extend the next clip backward.
|
|
|
|
Returns:
|
|
The neighbor clip that absorbed the duration, or ``None`` if
|
|
no suitable neighbor exists.
|
|
"""
|
|
spine_list = list(spine)
|
|
element_index = spine_list.index(element)
|
|
neighbor = self._find_neighbor_clip(spine_list, element_index, direction)
|
|
if neighbor is None:
|
|
return None
|
|
|
|
absorbed_dur = self._parse_time(element.get('duration', '0s'))
|
|
neighbor_dur = self._parse_time(neighbor.get('duration', '0s'))
|
|
|
|
if direction == 'next':
|
|
neighbor_start = self._parse_time(neighbor.get('start', '0s'))
|
|
new_start = neighbor_start - absorbed_dur
|
|
if new_start >= TimeValue.zero():
|
|
neighbor.set('start', new_start.to_fcpxml())
|
|
neighbor.set('duration', (neighbor_dur + absorbed_dur).to_fcpxml())
|
|
else:
|
|
# Can't shift start negative — only extend by what's available
|
|
available = neighbor_start
|
|
neighbor.set('start', TimeValue(0, 1).to_fcpxml())
|
|
neighbor.set('duration', (neighbor_dur + available).to_fcpxml())
|
|
else:
|
|
neighbor.set('duration', (neighbor_dur + absorbed_dur).to_fcpxml())
|
|
spine.remove(element)
|
|
return neighbor
|
|
|
|
def _resolve_insert_position(
|
|
self, position: str, spine_children: list
|
|
) -> tuple:
|
|
"""Translate a human-friendly position spec into (target_offset, insert_index).
|
|
|
|
Supported formats:
|
|
``'start'`` — beginning of spine
|
|
``'end'`` — after last element
|
|
``'after:clip_id'`` — after the named clip
|
|
``'before:clip_id'``— before the named clip
|
|
*timecode* — absolute timeline position
|
|
|
|
Returns:
|
|
``(TimeValue, int)`` — the offset and child-index for spine insertion.
|
|
"""
|
|
if position == 'start':
|
|
return TimeValue.zero(), 0
|
|
|
|
if position == 'end':
|
|
if spine_children:
|
|
last = spine_children[-1]
|
|
last_offset = self._parse_time(last.get('offset', '0s'))
|
|
last_dur = self._parse_time(last.get('duration', '0s'))
|
|
return last_offset + last_dur, len(spine_children)
|
|
return TimeValue.zero(), len(spine_children)
|
|
|
|
if position.startswith('after:') or position.startswith('before:'):
|
|
is_after = position.startswith('after:')
|
|
ref_id = position.split(':', 1)[1]
|
|
ref_clip = self.clips.get(ref_id)
|
|
if ref_clip is None or ref_clip not in spine_children:
|
|
raise ValueError(f"Reference clip not found: {ref_id}")
|
|
idx = spine_children.index(ref_clip)
|
|
ref_offset = self._parse_time(ref_clip.get('offset', '0s'))
|
|
if is_after:
|
|
ref_dur = self._parse_time(ref_clip.get('duration', '0s'))
|
|
return ref_offset + ref_dur, idx + 1
|
|
return ref_offset, idx
|
|
|
|
# Assume timecode
|
|
target_offset = self._parse_time(position)
|
|
insert_index = 0
|
|
for i, child in enumerate(spine_children):
|
|
child_offset = self._parse_time(child.get('offset', '0s'))
|
|
if child_offset >= target_offset:
|
|
insert_index = i
|
|
break
|
|
insert_index = i + 1
|
|
return target_offset, insert_index
|
|
|
|
def _make_transition_element(
|
|
self,
|
|
effect_name: str,
|
|
trans_offset: 'TimeValue',
|
|
trans_duration: 'TimeValue',
|
|
effect_ref_id: str | None,
|
|
) -> ET.Element:
|
|
"""Build a <transition> element with optional filter-video child."""
|
|
transition = ET.Element('transition')
|
|
transition.set('name', effect_name)
|
|
transition.set('offset', trans_offset.to_fcpxml())
|
|
transition.set('duration', trans_duration.to_fcpxml())
|
|
if effect_ref_id:
|
|
fv = ET.SubElement(transition, 'filter-video')
|
|
fv.set('ref', effect_ref_id)
|
|
fv.set('name', effect_name)
|
|
return transition
|
|
|
|
def save(self, output_path: Optional[str] = None) -> str:
|
|
"""Serialise the modified XML tree to disk.
|
|
|
|
When the destination ends in ``.fcpxmld`` a bundle directory is
|
|
created and the XML lands in ``Info.fcpxml`` inside it. If the
|
|
source was also a bundle, every sidecar file (object-tracking /
|
|
Cinematic-mode ``dataLocator`` payloads — anything that isn't
|
|
``Info.fcpxml``) is copied across so the round-trip is lossless.
|
|
Writing a bundle source to a flat ``.fcpxml`` destination drops
|
|
those sidecars by definition.
|
|
|
|
Args:
|
|
output_path: Destination ``.fcpxml`` file or ``.fcpxmld``
|
|
bundle path. Defaults to overwriting the original
|
|
file/bundle loaded in ``__init__``.
|
|
|
|
Returns:
|
|
The absolute path written to (the bundle path when writing
|
|
a bundle, not the inner ``Info.fcpxml``).
|
|
"""
|
|
if output_path is None:
|
|
out = self.bundle_dir if self.bundle_dir is not None else self.path
|
|
else:
|
|
out = Path(output_path)
|
|
|
|
# Every write path goes through here, so snapping here (rather than
|
|
# in each handler) guarantees ripple edits never leave a spine clip
|
|
# off the frame grid — see snap_spine_times_to_frames() docstring.
|
|
# No-op (each value already equals its own snapped form) on content
|
|
# that was already frame-aligned.
|
|
self.snap_spine_times_to_frames()
|
|
|
|
if out.suffix.lower() == '.fcpxmld':
|
|
out.mkdir(exist_ok=True)
|
|
if (
|
|
self.bundle_dir is not None
|
|
and self.bundle_dir.resolve() != out.resolve()
|
|
):
|
|
self._copy_bundle_sidecars(self.bundle_dir, out)
|
|
write_fcpxml(self.root, str(out / 'Info.fcpxml'), fps=self.fps)
|
|
return str(out)
|
|
|
|
return write_fcpxml(self.root, str(out), fps=self.fps)
|
|
|
|
@staticmethod
|
|
def _copy_bundle_sidecars(src_bundle: Path, dst_bundle: Path) -> None:
|
|
"""Copy every sidecar entry of *src_bundle* into *dst_bundle*.
|
|
|
|
Sidecars are all bundle members except ``Info.fcpxml`` itself —
|
|
e.g. the external data files that ``locator``/``dataLocator``
|
|
elements reference for object tracking and Cinematic mode.
|
|
"""
|
|
import shutil
|
|
for entry in src_bundle.iterdir():
|
|
if entry.name == 'Info.fcpxml':
|
|
continue
|
|
target = dst_bundle / entry.name
|
|
if entry.is_dir():
|
|
shutil.copytree(entry, target, dirs_exist_ok=True)
|
|
else:
|
|
shutil.copy2(entry, target)
|
|
|