Files
gart/code/fcpxml/diff.py
T

270 lines
9.8 KiB
Python
Executable File

"""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}"
)