"""Timeline comparison engine for FCPXML files. Compares two FCPXML timelines and reports differences in clips, markers, transitions, and format settings. """ from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple from .parser import FCPXMLParser @dataclass class ClipDiff: """Difference record for a single clip.""" action: str # "added", "removed", "moved", "trimmed", "unchanged" clip_name: str details: str = "" old_start: Optional[float] = None new_start: Optional[float] = None old_duration: Optional[float] = None new_duration: Optional[float] = None @dataclass class MarkerDiff: """Difference record for a marker.""" action: str # "added", "removed", "moved" marker_name: str details: str = "" old_position: Optional[float] = None new_position: Optional[float] = None @dataclass class TimelineDiff: """Complete diff between two timelines.""" timeline_a_name: str timeline_b_name: str clip_diffs: List[ClipDiff] = field(default_factory=list) marker_diffs: List[MarkerDiff] = field(default_factory=list) transition_diffs: List[str] = field(default_factory=list) format_changes: List[str] = field(default_factory=list) @property def total_changes(self) -> int: changes = [d for d in self.clip_diffs if d.action != "unchanged"] return len(changes) + len(self.marker_diffs) + len(self.transition_diffs) + len(self.format_changes) @property def has_changes(self) -> bool: return self.total_changes > 0 def _clip_identity(clip) -> Tuple[str, float]: """Build identity key for a clip: (name, source_start rounded to 0.01s).""" source_start = round(clip.source_start.seconds, 2) if clip.source_start else 0.0 return (clip.name, source_start) def compare_timelines(filepath_a: str, filepath_b: str) -> TimelineDiff: """Compare two FCPXML files and return structured diff. Uses clip identity (name + source in-point) to match clips between timelines, then detects moved, trimmed, added, and removed clips. Args: filepath_a: Path to baseline FCPXML filepath_b: Path to comparison FCPXML Returns: TimelineDiff with all detected changes """ parser_a = FCPXMLParser() parser_b = FCPXMLParser() project_a = parser_a.parse_file(filepath_a) project_b = parser_b.parse_file(filepath_b) tl_a = project_a.primary_timeline tl_b = project_b.primary_timeline if tl_a is None or tl_b is None: return TimelineDiff( timeline_a_name=tl_a.name if tl_a else "No timeline", timeline_b_name=tl_b.name if tl_b else "No timeline", ) diff = TimelineDiff( timeline_a_name=tl_a.name, timeline_b_name=tl_b.name, ) # Compare clips _compare_clips(tl_a.clips, tl_b.clips, diff) # Compare markers _compare_markers(tl_a, tl_b, diff) # Compare transitions _compare_transitions(tl_a.transitions, tl_b.transitions, diff) # Compare format _compare_format(tl_a, tl_b, diff) return diff def _compare_clips(clips_a, clips_b, diff: TimelineDiff): """Compare clip sequences between two timelines.""" # Build identity maps map_a: Dict[Tuple[str, float], list] = {} for clip in clips_a: key = _clip_identity(clip) map_a.setdefault(key, []).append(clip) map_b: Dict[Tuple[str, float], list] = {} for clip in clips_b: key = _clip_identity(clip) map_b.setdefault(key, []).append(clip) all_keys = set(map_a.keys()) | set(map_b.keys()) for key in sorted(all_keys, key=lambda k: k[1]): a_clips = map_a.get(key, []) b_clips = map_b.get(key, []) if not a_clips and b_clips: for clip in b_clips: diff.clip_diffs.append(ClipDiff( action="added", clip_name=clip.name, details=f"Added at {clip.start.to_smpte()}", new_start=clip.start.seconds, new_duration=clip.duration_seconds, )) elif a_clips and not b_clips: for clip in a_clips: diff.clip_diffs.append(ClipDiff( action="removed", clip_name=clip.name, details=f"Removed from {clip.start.to_smpte()}", old_start=clip.start.seconds, old_duration=clip.duration_seconds, )) else: # Match clips pairwise for i in range(max(len(a_clips), len(b_clips))): if i >= len(a_clips): diff.clip_diffs.append(ClipDiff( action="added", clip_name=b_clips[i].name, new_start=b_clips[i].start.seconds, new_duration=b_clips[i].duration_seconds, )) elif i >= len(b_clips): diff.clip_diffs.append(ClipDiff( action="removed", clip_name=a_clips[i].name, old_start=a_clips[i].start.seconds, old_duration=a_clips[i].duration_seconds, )) else: a, b = a_clips[i], b_clips[i] moved = abs(a.start.seconds - b.start.seconds) > 0.04 trimmed = abs(a.duration_seconds - b.duration_seconds) > 0.04 if moved and trimmed: diff.clip_diffs.append(ClipDiff( action="moved", clip_name=a.name, details=( f"Moved {a.start.to_smpte()} -> {b.start.to_smpte()}, " f"trimmed {a.duration_seconds:.2f}s -> {b.duration_seconds:.2f}s" ), old_start=a.start.seconds, new_start=b.start.seconds, old_duration=a.duration_seconds, new_duration=b.duration_seconds, )) elif moved: diff.clip_diffs.append(ClipDiff( action="moved", clip_name=a.name, details=f"Moved {a.start.to_smpte()} -> {b.start.to_smpte()}", old_start=a.start.seconds, new_start=b.start.seconds, old_duration=a.duration_seconds, new_duration=b.duration_seconds, )) elif trimmed: diff.clip_diffs.append(ClipDiff( action="trimmed", clip_name=a.name, details=f"Duration {a.duration_seconds:.2f}s -> {b.duration_seconds:.2f}s", old_start=a.start.seconds, new_start=b.start.seconds, old_duration=a.duration_seconds, new_duration=b.duration_seconds, )) def _compare_markers(tl_a, tl_b, diff: TimelineDiff): """Compare markers between two timelines.""" # Collect all markers including clip-level markers markers_a = {} for m in tl_a.markers: markers_a[m.name] = m.start.seconds for clip in tl_a.clips: for m in clip.markers: markers_a[m.name] = clip.start.seconds + m.start.seconds markers_b = {} for m in tl_b.markers: markers_b[m.name] = m.start.seconds for clip in tl_b.clips: for m in clip.markers: markers_b[m.name] = clip.start.seconds + m.start.seconds all_names = set(markers_a.keys()) | set(markers_b.keys()) for name in sorted(all_names): if name in markers_a and name not in markers_b: diff.marker_diffs.append(MarkerDiff( action="removed", marker_name=name, old_position=markers_a[name], )) elif name not in markers_a and name in markers_b: diff.marker_diffs.append(MarkerDiff( action="added", marker_name=name, new_position=markers_b[name], )) elif abs(markers_a[name] - markers_b[name]) > 1.0: diff.marker_diffs.append(MarkerDiff( action="moved", marker_name=name, details=f"Position {markers_a[name]:.2f}s -> {markers_b[name]:.2f}s", old_position=markers_a[name], new_position=markers_b[name], )) def _compare_transitions(trans_a, trans_b, diff: TimelineDiff): """Compare transitions between timelines.""" count_a = len(trans_a) count_b = len(trans_b) if count_a != count_b: diff.transition_diffs.append( f"Transition count changed: {count_a} -> {count_b}" ) for i in range(min(count_a, count_b)): a, b = trans_a[i], trans_b[i] if a.name != b.name: diff.transition_diffs.append( f"Transition {i + 1}: {a.name} -> {b.name}" ) if abs(a.duration.seconds - b.duration.seconds) > 0.04: diff.transition_diffs.append( f"Transition {i + 1} duration: {a.duration.seconds:.2f}s -> {b.duration.seconds:.2f}s" ) def _compare_format(tl_a, tl_b, diff: TimelineDiff): """Compare format settings (resolution, frame rate).""" if tl_a.width != tl_b.width or tl_a.height != tl_b.height: diff.format_changes.append( f"Resolution: {tl_a.width}x{tl_a.height} -> {tl_b.width}x{tl_b.height}" ) if abs(tl_a.frame_rate - tl_b.frame_rate) > 0.01: diff.format_changes.append( f"Frame rate: {tl_a.frame_rate:.3f} -> {tl_b.frame_rate:.3f}" )