# 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...