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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -0,0 +1,723 @@
|
||||
"""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)
|
||||
|
||||
Reference in New Issue
Block a user