Files
gart/code/fcpxml/writer/core.py
T
João HenriqueandClaude Opus 5 4f5cf94443 refactor: writer.py vira pacote, um módulo por assunto
O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.

Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.

Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.

    core      723   carga, índices, navegação na spine, save
    titles    600   títulos e legendas dinâmicas
    cut       333   dividir, cortar faixas, apagar
    speed     297   velocidade e zoom
    (+ 20 módulos menores)

Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).

Lint zerado, 1441 testes passando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:38:49 -04:00

724 lines
29 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
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)