chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+885
View File
@@ -0,0 +1,885 @@
# writer.py - FCPXML Write Operations
"""
FCPXML Writer Module
Handles all write operations for Final Cut Pro XML files.
Each function takes parsed FCPXML, performs modifications, and returns valid FCPXML.
"""
from dataclasses import dataclass
from typing import Optional, List, Dict, Union
from xml.etree import ElementTree as ET
from enum import Enum
import copy
# ============================================================================
# DATA MODELS
# ============================================================================
class MarkerType(Enum):
STANDARD = "standard"
INCOMPLETE = "todo"
CHAPTER = "chapter"
COMPLETED = "completed"
class MarkerColor(Enum):
BLUE = 0
CYAN = 1
GREEN = 2
YELLOW = 3
ORANGE = 4
RED = 5
PINK = 6
PURPLE = 7
@dataclass
class TimeValue:
"""Represents FCPXML time format (rational or decimal seconds)"""
numerator: int
denominator: int = 1
@classmethod
def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue':
"""
Convert timecode string to TimeValue
Accepts: 'HH:MM:SS:FF', 'HH:MM:SS;FF' (drop-frame), 'XXs', 'XX.XXs', 'X/Ys'
"""
# Handle FCPXML format (e.g., "30s", "15/30s", "100/1s")
if tc.endswith('s'):
tc = tc[:-1]
if '/' in tc:
num, denom = tc.split('/')
return cls(int(num), int(denom))
else:
# Decimal seconds
seconds = float(tc)
frames = int(seconds * fps)
return cls(frames, int(fps))
# Handle timecode format (HH:MM:SS:FF)
parts = tc.replace(';', ':').split(':')
if len(parts) == 4:
h, m, s, f = map(int, parts)
total_frames = (h * 3600 + m * 60 + s) * int(fps) + f
return cls(total_frames, int(fps))
raise ValueError(f"Invalid timecode format: {tc}")
def to_fcpxml(self) -> str:
"""Convert to FCPXML time string"""
if self.denominator == 1:
return f"{self.numerator}s"
return f"{self.numerator}/{self.denominator}s"
def to_seconds(self) -> float:
"""Convert to decimal seconds"""
return self.numerator / self.denominator
def __add__(self, other: 'TimeValue') -> 'TimeValue':
# Find common denominator
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) + (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def __sub__(self, other: 'TimeValue') -> 'TimeValue':
new_denom = self.denominator * other.denominator
new_num = (self.numerator * other.denominator) - (other.numerator * self.denominator)
return TimeValue(new_num, new_denom).simplify()
def simplify(self) -> 'TimeValue':
"""Reduce fraction to simplest form"""
from math import gcd
divisor = gcd(self.numerator, self.denominator)
return TimeValue(self.numerator // divisor, self.denominator // divisor)
# ============================================================================
# CORE WRITER CLASS
# ============================================================================
class FCPXMLWriter:
"""
Handles all write operations on FCPXML documents.
Usage:
writer = FCPXMLWriter(fcpxml_path)
writer.add_marker(clip_id, timecode, name, marker_type)
writer.save(output_path)
"""
def __init__(self, fcpxml_path: str):
self.tree = ET.parse(fcpxml_path)
self.root = self.tree.getroot()
self.fps = self._detect_fps()
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:
num, denom = frame_dur.replace('s', '').split('/')
return int(denom) / int(num)
return 30.0 # Default
def _build_clip_index(self) -> None:
"""Build index of all clips for fast lookup"""
self.clips: Dict[str, ET.Element] = {}
for i, clip in enumerate(self.root.findall('.//clip')):
clip_id = clip.get('id') or f"clip_{i}"
self.clips[clip_id] = clip
for i, video in enumerate(self.root.findall('.//video')):
vid_id = video.get('id') or f"video_{i}"
self.clips[vid_id] = video
def _get_spine(self) -> ET.Element:
"""Get the primary storyline spine"""
spine = self.root.find('.//spine')
if spine is None:
raise ValueError("No spine found in FCPXML")
return spine
def _recalculate_offsets(self, spine: ET.Element) -> None:
"""Recalculate all clip offsets after modifications"""
current_offset = TimeValue(0)
for child in spine:
if child.tag in ('clip', 'video', 'audio', 'gap', 'transition', 'ref-clip'):
child.set('offset', current_offset.to_fcpxml())
duration_str = child.get('duration', '0s')
duration = TimeValue.from_timecode(duration_str, self.fps)
current_offset = current_offset + duration
def save(self, output_path: str) -> str:
"""Write modified FCPXML to file"""
self.tree.write(output_path, encoding='UTF-8', xml_declaration=True)
return output_path
# ========================================================================
# MARKER OPERATIONS
# ========================================================================
def add_marker(
self,
clip_id: str,
timecode: str,
name: str,
marker_type: MarkerType = MarkerType.STANDARD,
color: Optional[MarkerColor] = None,
note: Optional[str] = None
) -> ET.Element:
"""
Add a marker to a clip.
Args:
clip_id: Target clip identifier
timecode: Position within clip (relative to clip start)
name: Marker label
marker_type: standard, chapter, or todo
color: Optional marker color
note: Optional marker note
Returns:
The created marker element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Convert timecode to FCPXML time
time_value = TimeValue.from_timecode(timecode, self.fps)
# Create marker element
marker = ET.SubElement(clip, marker_type.value)
marker.set('start', time_value.to_fcpxml())
marker.set('duration', f"1/{int(self.fps)}s") # 1 frame duration
marker.set('value', name)
# Add poster offset for chapter markers
if marker_type == MarkerType.CHAPTER:
marker.set('posterOffset', '0s')
# Add color if specified
if color is not None:
color_elem = ET.SubElement(marker, 'marker-color')
color_elem.set('color', str(color.value))
# Add note if specified
if note:
note_elem = ET.SubElement(marker, 'note')
note_elem.text = note
return marker
def batch_add_markers(
self,
markers: List[Dict],
auto_detect: Optional[Dict] = None
) -> List[ET.Element]:
"""
Add multiple markers at once.
Args:
markers: List of marker specs [{clip_id, timecode, name, ...}]
auto_detect: Auto-generate markers (at_cuts, at_keywords, at_intervals)
Returns:
List of created marker elements
"""
created = []
# Handle explicit markers
for m in markers:
marker = self.add_marker(
clip_id=m['clip_id'],
timecode=m['timecode'],
name=m['name'],
marker_type=MarkerType[m.get('marker_type', 'STANDARD').upper()],
color=MarkerColor[m['color'].upper()] if m.get('color') else None,
note=m.get('note')
)
created.append(marker)
# Handle auto-detection
if auto_detect:
if auto_detect.get('at_cuts'):
# Add marker at every cut point
spine = self._get_spine()
for clip in spine.findall('clip'):
offset = clip.get('offset', '0s')
marker = self.add_marker(
clip_id=clip.get('id', 'clip_0'),
timecode='0s', # Start of clip = cut point
name='Cut',
marker_type=MarkerType.STANDARD,
color=MarkerColor.YELLOW
)
created.append(marker)
if auto_detect.get('at_intervals'):
# Add markers at regular intervals
interval = TimeValue.from_timecode(auto_detect['at_intervals'], self.fps)
# Implementation: iterate through timeline at interval steps
pass
return created
# ========================================================================
# TRIM OPERATIONS
# ========================================================================
def trim_clip(
self,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True
) -> ET.Element:
"""
Trim a clip's in-point and/or out-point.
Args:
clip_id: Target clip
trim_start: New in-point or delta ('+1s', '-10f')
trim_end: New out-point or delta
ripple: Whether to shift subsequent clips
Returns:
Modified clip element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current values
current_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
duration_delta = TimeValue(0)
# Handle trim_start
if trim_start:
if trim_start.startswith('+') or trim_start.startswith('-'):
# Delta trim
delta = TimeValue.from_timecode(trim_start.lstrip('+-'), self.fps)
if trim_start.startswith('-'):
new_start = current_start - delta
new_duration = current_duration + delta
else:
new_start = current_start + delta
new_duration = current_duration - delta
else:
# Absolute trim
new_start = TimeValue.from_timecode(trim_start, self.fps)
start_diff = new_start - current_start
new_duration = current_duration - start_diff
clip.set('start', new_start.to_fcpxml())
duration_delta = new_duration - current_duration
current_duration = new_duration
# Handle trim_end
if trim_end:
if trim_end.startswith('+') or trim_end.startswith('-'):
delta = TimeValue.from_timecode(trim_end.lstrip('+-'), self.fps)
if trim_end.startswith('-'):
new_duration = current_duration - delta
else:
new_duration = current_duration + delta
else:
# Absolute end point
end_point = TimeValue.from_timecode(trim_end, self.fps)
new_duration = end_point - current_start
duration_delta = duration_delta + (new_duration - current_duration)
current_duration = new_duration
clip.set('duration', current_duration.to_fcpxml())
# Ripple subsequent clips
if ripple and duration_delta.numerator != 0:
self._ripple_after_clip(clip, duration_delta)
return clip
def _ripple_after_clip(self, clip: ET.Element, delta: TimeValue) -> None:
"""Shift all clips after the given clip by delta"""
spine = self._get_spine()
found_clip = False
for child in spine:
if child == clip:
found_clip = True
continue
if found_clip and child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip'):
current_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = current_offset + delta
child.set('offset', new_offset.to_fcpxml())
# ========================================================================
# REORDER OPERATIONS
# ========================================================================
def reorder_clips(
self,
clip_ids: List[str],
target_position: str,
ripple: bool = True
) -> None:
"""
Move clips to a new position in the timeline.
Args:
clip_ids: Clips to move (maintains relative order)
target_position: 'start', 'end', timecode, or 'after:clip_id'/'before:clip_id'
ripple: Whether to shift other clips
"""
spine = self._get_spine()
# Collect clips to move
clips_to_move = []
for clip_id in clip_ids:
for child in spine:
if child.get('id') == clip_id or child.get('name') == clip_id:
clips_to_move.append(child)
break
if not clips_to_move:
raise ValueError(f"No clips found matching: {clip_ids}")
# Calculate total duration of moving clips
total_duration = TimeValue(0)
for clip in clips_to_move:
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
total_duration = total_duration + dur
# Remove clips from current positions (store for reinsertion)
for clip in clips_to_move:
spine.remove(clip)
# Determine target offset
if target_position == 'start':
target_offset = TimeValue(0)
insert_index = 0
elif target_position == 'end':
# Find end of timeline
last_clip = list(spine)[-1] if len(spine) > 0 else None
if last_clip is not None:
last_offset = TimeValue.from_timecode(last_clip.get('offset', '0s'), self.fps)
last_dur = TimeValue.from_timecode(last_clip.get('duration', '0s'), self.fps)
target_offset = last_offset + last_dur
else:
target_offset = TimeValue(0)
insert_index = len(spine)
elif target_position.startswith('after:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
ref_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
ref_dur = TimeValue.from_timecode(child.get('duration', '0s'), self.fps)
target_offset = ref_offset + ref_dur
insert_index = i + 1
break
elif target_position.startswith('before:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
target_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
insert_index = i
break
else:
# Assume timecode
target_offset = TimeValue.from_timecode(target_position, self.fps)
# Find insert position
insert_index = 0
for i, child in enumerate(spine):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
if child_offset.to_seconds() >= target_offset.to_seconds():
insert_index = i
break
insert_index = i + 1
# Insert clips at new position
current_offset = target_offset
for clip in clips_to_move:
clip.set('offset', current_offset.to_fcpxml())
spine.insert(insert_index, clip)
insert_index += 1
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
current_offset = current_offset + dur
# Recalculate all offsets if ripple
if ripple:
self._recalculate_offsets(spine)
# ========================================================================
# TRANSITION OPERATIONS
# ========================================================================
def add_transition(
self,
clip_id: str,
position: str = 'end',
transition_type: str = 'cross-dissolve',
duration: str = '00:00:00:15'
) -> ET.Element:
"""
Add a transition to a clip.
Args:
clip_id: Target clip
position: 'start', 'end', or 'both'
transition_type: Type of transition
duration: Transition duration
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
trans_duration = TimeValue.from_timecode(duration, self.fps)
# Find clip index in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in primary storyline: {clip_id}")
transitions_added = []
# Map transition type to FCPXML effect name
effect_map = {
'cross-dissolve': 'Cross Dissolve',
'fade-to-black': 'Fade to Color',
'fade-from-black': 'Fade from Color',
'dip-to-color': 'Dip to Color',
'wipe': 'Wipe',
'slide': 'Slide'
}
effect_name = effect_map.get(transition_type, 'Cross Dissolve')
if position in ('end', 'both'):
# Add transition after clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
# Transition starts at clip_end - (duration / 2)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset + clip_dur - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
# Add filter reference
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index + 1, transition)
transitions_added.append(transition)
if position in ('start', 'both'):
# Add transition before clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index, transition)
transitions_added.append(transition)
return transitions_added[0] if len(transitions_added) == 1 else transitions_added
# ========================================================================
# SPEED OPERATIONS
# ========================================================================
def change_speed(
self,
clip_id: str,
speed: float,
ramp: Optional[Dict] = None,
preserve_pitch: bool = True,
frame_blending: str = 'optical-flow'
) -> ET.Element:
"""
Change clip playback speed.
Args:
clip_id: Target clip
speed: Speed multiplier (0.5 = half speed, 2.0 = double)
ramp: Optional speed ramp config {start_speed, end_speed, curve}
preserve_pitch: Maintain audio pitch
frame_blending: Interpolation method
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current duration
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
if ramp:
# Speed ramp - create timeMap with multiple keyframes
timemap = ET.SubElement(clip, 'timeMap')
start_speed = ramp.get('start_speed', 1.0)
end_speed = ramp.get('end_speed', speed)
curve = ramp.get('curve', 'linear')
# Map curve to FCPXML interpolation
interp_map = {
'linear': 'linear',
'ease-in': 'smooth2',
'ease-out': 'smooth2',
'ease-in-out': 'smooth'
}
interp = interp_map.get(curve, 'linear')
# Calculate output duration based on speed changes
# This is simplified - real implementation needs integral calculus
avg_speed = (start_speed + end_speed) / 2
new_duration_seconds = current_duration.to_seconds() / avg_speed
# Create keyframes
# Start point
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# Mid point (speed transition)
mid_time = new_duration_seconds / 2
mid_value = current_duration.to_seconds() / 2 / start_speed
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{mid_time}s")
tp2.set('value', f"{mid_value}s")
tp2.set('interp', interp)
# End point
tp3 = ET.SubElement(timemap, 'timept')
tp3.set('time', f"{new_duration_seconds}s")
tp3.set('value', current_duration.to_fcpxml())
tp3.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
else:
# Constant speed change
timemap = ET.SubElement(clip, 'timeMap')
new_duration_seconds = current_duration.to_seconds() / speed
source_duration = current_duration.to_seconds()
# Start keyframe
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# End keyframe
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{new_duration_seconds}s")
tp2.set('value', f"{source_duration}s")
tp2.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
# Add conform-rate for frame blending
conform = ET.SubElement(clip, 'conform-rate')
conform.set('scaleEnabled', '1')
conform.set('srcFrameRate', str(int(self.fps)))
# Frame blending attribute
blend_map = {
'none': '0',
'frame-blending': '1',
'optical-flow': '2'
}
# Note: Actual FCP attribute name may vary
# Audio pitch preservation
if preserve_pitch:
audio = clip.find('audio')
if audio is not None:
audio.set('preservePitch', '1')
return clip
# ========================================================================
# SPLIT OPERATIONS
# ========================================================================
def split_clip(
self,
clip_id: str,
split_points: List[str],
split_type: str = 'blade'
) -> List[ET.Element]:
"""
Split a clip at specified timecodes.
Args:
clip_id: Clip to split
split_points: Timecodes within the clip to split at
split_type: 'blade' (this clip only) or 'blade-all' (all tracks)
Returns:
List of resulting clip elements
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Find clip in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in spine: {clip_id}")
# Sort split points
split_times = sorted([TimeValue.from_timecode(sp, self.fps) for sp in split_points])
# Get clip properties
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_name = clip.get('name', 'Clip')
clip_ref = clip.get('ref')
# Remove original clip
spine.remove(clip)
# Create new clips
new_clips = []
current_offset = clip_offset
current_start = clip_start
for i, split_time in enumerate(split_times + [clip_duration]):
if i == 0:
segment_duration = split_time
else:
segment_duration = split_time - split_times[i - 1]
# Create new clip element
new_clip = ET.Element('clip')
new_clip.set('name', f"{clip_name}")
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', current_start.to_fcpxml())
new_clip.set('duration', segment_duration.to_fcpxml())
if clip_ref:
new_clip.set('ref', clip_ref)
# Copy other attributes
for attr in ['tcFormat', 'format']:
if clip.get(attr):
new_clip.set(attr, clip.get(attr))
# Insert into spine
spine.insert(clip_index + i, new_clip)
new_clips.append(new_clip)
# Update for next iteration
current_offset = current_offset + segment_duration
current_start = current_start + segment_duration
# Update clip index
for new_clip in new_clips:
self.clips[new_clip.get('id', new_clip.get('name'))] = new_clip
return new_clips
# ========================================================================
# DELETE OPERATIONS
# ========================================================================
def delete_clip(
self,
clip_ids: List[str],
ripple: bool = True
) -> None:
"""
Delete clips from timeline.
Args:
clip_ids: Clips to delete
ripple: If True, shift subsequent clips. If False, leave gaps.
"""
spine = self._get_spine()
for clip_id in clip_ids:
clip = self.clips.get(clip_id)
if clip is None:
continue
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
# Find clip index
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
continue
if ripple:
# Remove clip and shift others
spine.remove(clip)
# Shift subsequent clips
for child in spine[clip_index:]:
if child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip', 'transition'):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = child_offset - clip_duration
child.set('offset', new_offset.to_fcpxml())
else:
# Replace with gap
gap = ET.Element('gap')
gap.set('name', 'Gap')
gap.set('offset', clip_offset.to_fcpxml())
gap.set('duration', clip_duration.to_fcpxml())
spine.remove(clip)
spine.insert(clip_index, gap)
# Remove from index
del self.clips[clip_id]
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def add_marker(
project_path: str,
clip_id: str,
timecode: str,
name: str,
marker_type: str = 'standard',
color: Optional[str] = None,
note: Optional[str] = None,
output_path: Optional[str] = None
) -> str:
"""
Convenience function to add a marker and save.
Returns:
Path to the modified FCPXML
"""
writer = FCPXMLWriter(project_path)
mt = MarkerType[marker_type.upper()]
mc = MarkerColor[color.upper()] if color else None
writer.add_marker(clip_id, timecode, name, mt, mc, note)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def trim_clip(
project_path: str,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to trim a clip and save."""
writer = FCPXMLWriter(project_path)
writer.trim_clip(clip_id, trim_start, trim_end, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def reorder_clips(
project_path: str,
clip_ids: List[str],
target_position: str,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to reorder clips and save."""
writer = FCPXMLWriter(project_path)
writer.reorder_clips(clip_ids, target_position, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
# Additional convenience functions follow same pattern...