chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+284
@@ -0,0 +1,284 @@
|
||||
"""Tests for fcpxml/diff.py — timeline comparison engine.
|
||||
|
||||
Covers: moved clips, transition diffs, marker removal/movement,
|
||||
duplicate clip identity, simultaneous move+trim, empty timelines.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from fcpxml.diff import (
|
||||
ClipDiff,
|
||||
MarkerDiff,
|
||||
TimelineDiff,
|
||||
_clip_identity,
|
||||
compare_timelines,
|
||||
)
|
||||
|
||||
BASE_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Clip_A" src="file:///a.mov" start="0s" duration="240/24s"/>
|
||||
<asset id="r3" name="Clip_B" src="file:///b.mov" start="0s" duration="240/24s"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Diff Test">
|
||||
<sequence format="r1" duration="480/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s"/>
|
||||
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
|
||||
def _tmp(xml: str) -> str:
|
||||
fd, path = tempfile.mkstemp(suffix='.fcpxml')
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(xml)
|
||||
return path
|
||||
|
||||
|
||||
class TestMovedClips:
|
||||
"""Clips that change timeline position without duration change.
|
||||
|
||||
Parser computes clip.start from cumulative spine position, not XML offset.
|
||||
Insert a gap to shift downstream clips' computed timeline positions.
|
||||
"""
|
||||
|
||||
def test_moved_clip_detected(self):
|
||||
"""Gap insertion shifts Clip_B's position — detected as 'moved'."""
|
||||
# Insert a 5-second gap before Clip_B, pushing it from 10s to 15s
|
||||
xml_b = BASE_XML.replace(
|
||||
'<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s"/>',
|
||||
'<gap offset="240/24s" duration="120/24s"/>\n'
|
||||
' <asset-clip ref="r3" offset="360/24s" name="Clip_B" start="0s" duration="240/24s"/>',
|
||||
).replace('duration="480/24s"', 'duration="600/24s"')
|
||||
a, b = _tmp(BASE_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
moved = [d for d in diff.clip_diffs if d.action == "moved"]
|
||||
assert len(moved) >= 1
|
||||
assert moved[0].clip_name == "Clip_B"
|
||||
assert moved[0].old_start is not None
|
||||
assert moved[0].new_start is not None
|
||||
assert abs(moved[0].new_start - moved[0].old_start) > 0.04
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
def test_identical_position_is_unchanged(self):
|
||||
"""Same spine structure should produce no moved clips."""
|
||||
a, b = _tmp(BASE_XML), _tmp(BASE_XML)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
moved = [d for d in diff.clip_diffs if d.action == "moved"]
|
||||
assert len(moved) == 0
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestMovedAndTrimmed:
|
||||
"""Clip simultaneously moved and trimmed — reports as 'moved' with trim detail."""
|
||||
|
||||
def test_simultaneous_move_and_trim(self):
|
||||
"""Gap insertion + duration change = moved AND trimmed."""
|
||||
# Insert gap (moves Clip_B) AND shorten it (trims Clip_B)
|
||||
xml_b = BASE_XML.replace(
|
||||
'<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s"/>',
|
||||
'<gap offset="240/24s" duration="120/24s"/>\n'
|
||||
' <asset-clip ref="r3" offset="360/24s" name="Clip_B" start="0s" duration="120/24s"/>',
|
||||
).replace('duration="480/24s"', 'duration="600/24s"')
|
||||
a, b = _tmp(BASE_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
moved = [d for d in diff.clip_diffs if d.action == "moved"]
|
||||
assert len(moved) >= 1
|
||||
m = moved[0]
|
||||
assert "trimmed" in m.details.lower()
|
||||
assert m.old_duration != m.new_duration
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestTransitionDiffs:
|
||||
"""Transition comparison — count changes, name/duration changes."""
|
||||
|
||||
TRANS_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Clip_A" src="file:///a.mov" start="0s" duration="240/24s"/>
|
||||
<asset id="r3" name="Clip_B" src="file:///b.mov" start="0s" duration="240/24s"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Trans Test">
|
||||
<sequence format="r1" duration="480/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s"/>
|
||||
<transition name="Cross Dissolve" offset="228/24s" duration="24/24s"/>
|
||||
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
def test_transition_added(self):
|
||||
"""Timeline with no transitions vs one transition."""
|
||||
a, b = _tmp(BASE_XML), _tmp(self.TRANS_XML)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
assert len(diff.transition_diffs) >= 1
|
||||
assert "count" in diff.transition_diffs[0].lower()
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
def test_transition_removed(self):
|
||||
a, b = _tmp(self.TRANS_XML), _tmp(BASE_XML)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
assert len(diff.transition_diffs) >= 1
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
def test_transition_name_change(self):
|
||||
xml_b = self.TRANS_XML.replace('Cross Dissolve', 'Fade to Black')
|
||||
a, b = _tmp(self.TRANS_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
name_changes = [d for d in diff.transition_diffs if 'Cross Dissolve' in d]
|
||||
assert len(name_changes) >= 1
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestMarkerDiffs:
|
||||
"""Marker removal and position movement detection."""
|
||||
|
||||
MARKER_XML = BASE_XML.replace(
|
||||
'name="Clip_A" start="0s" duration="240/24s"/>',
|
||||
'name="Clip_A" start="0s" duration="240/24s">'
|
||||
'<marker start="48/24s" duration="1/24s" value="Take 1"/>'
|
||||
'<marker start="120/24s" duration="1/24s" value="Take 2"/>'
|
||||
'</asset-clip>'
|
||||
).replace(
|
||||
'<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s">',
|
||||
'<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s">',
|
||||
)
|
||||
|
||||
def test_marker_removed(self):
|
||||
xml_b = self.MARKER_XML.replace(
|
||||
'<marker start="48/24s" duration="1/24s" value="Take 1"/>', ''
|
||||
)
|
||||
a, b = _tmp(self.MARKER_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
removed = [d for d in diff.marker_diffs if d.action == "removed"]
|
||||
assert len(removed) >= 1
|
||||
assert any(d.marker_name == "Take 1" for d in removed)
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
def test_marker_moved(self):
|
||||
"""Marker position change >1.0s should report 'moved'."""
|
||||
xml_b = self.MARKER_XML.replace(
|
||||
'<marker start="48/24s" duration="1/24s" value="Take 1"/>',
|
||||
'<marker start="120/24s" duration="1/24s" value="Take 1"/>',
|
||||
)
|
||||
a, b = _tmp(self.MARKER_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
moved = [d for d in diff.marker_diffs if d.action == "moved"]
|
||||
assert len(moved) >= 1
|
||||
m = moved[0]
|
||||
assert m.marker_name == "Take 1"
|
||||
assert m.old_position is not None
|
||||
assert m.new_position is not None
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestFrameRateDiff:
|
||||
"""Frame rate changes between timelines."""
|
||||
|
||||
def test_frame_rate_change_detected(self):
|
||||
xml_b = BASE_XML.replace('frameDuration="1/24s"', 'frameDuration="1/30s"')
|
||||
a, b = _tmp(BASE_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
fps_changes = [c for c in diff.format_changes if "Frame rate" in c]
|
||||
assert len(fps_changes) >= 1
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestClipIdentity:
|
||||
"""_clip_identity uses (name, source_start) for matching.
|
||||
|
||||
Parser produces Timecode objects (which have .seconds), so tests
|
||||
must use Timecode — not raw TimeValue — to match real behavior.
|
||||
"""
|
||||
|
||||
def test_identity_with_source_start(self):
|
||||
from fcpxml.models import Clip, Timecode
|
||||
clip = Clip(
|
||||
name="Interview",
|
||||
start=Timecode(frames=0, frame_rate=24),
|
||||
duration=Timecode(frames=240, frame_rate=24),
|
||||
source_start=Timecode(frames=240, frame_rate=24), # 10 seconds
|
||||
)
|
||||
key = _clip_identity(clip)
|
||||
assert key == ("Interview", 10.0)
|
||||
|
||||
def test_identity_no_source_start(self):
|
||||
from fcpxml.models import Clip, Timecode
|
||||
clip = Clip(
|
||||
name="Test",
|
||||
start=Timecode(frames=0, frame_rate=24),
|
||||
duration=Timecode(frames=100, frame_rate=24),
|
||||
source_start=None,
|
||||
)
|
||||
key = _clip_identity(clip)
|
||||
assert key == ("Test", 0.0)
|
||||
|
||||
|
||||
class TestTotalChangesProperty:
|
||||
"""TimelineDiff.total_changes excludes 'unchanged' clips."""
|
||||
|
||||
def test_unchanged_clips_not_counted(self):
|
||||
diff = TimelineDiff(
|
||||
timeline_a_name="A", timeline_b_name="B",
|
||||
clip_diffs=[
|
||||
ClipDiff(action="unchanged", clip_name="X"),
|
||||
ClipDiff(action="moved", clip_name="Y"),
|
||||
],
|
||||
)
|
||||
assert diff.total_changes == 1
|
||||
|
||||
def test_all_change_types_summed(self):
|
||||
diff = TimelineDiff(
|
||||
timeline_a_name="A", timeline_b_name="B",
|
||||
clip_diffs=[ClipDiff(action="added", clip_name="X")],
|
||||
marker_diffs=[MarkerDiff(action="removed", marker_name="M")],
|
||||
transition_diffs=["count changed"],
|
||||
format_changes=["resolution changed"],
|
||||
)
|
||||
assert diff.total_changes == 4
|
||||
Reference in New Issue
Block a user