chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Tests for FCP MCP Server."""
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
"""Tests for .fcpxmld bundle support and FCPXML 1.13/1.14 tolerance.
|
||||
|
||||
A .fcpxmld bundle is a directory wrapping Info.fcpxml plus sidecar data
|
||||
files (object-tracking / Cinematic-mode payloads referenced by
|
||||
locator/dataLocator elements). These tests cover: parsing bundles,
|
||||
modifying them with sidecar-preserving round-trips, validation-layer
|
||||
acceptance, and lossless handling of elements introduced in FCPXML
|
||||
1.12-1.14.
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import server
|
||||
from fcpxml.parser import FCPXMLParser
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
# FCP 12-era export: version 1.14, with 1.13 elements the parser does not
|
||||
# model (hidden-clip-marker, adjust-stereo-3D) and a tracking sidecar
|
||||
# reference, to prove unknown-element tolerance + preservation.
|
||||
FCP12_XML = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<fcpxml version="1.14"><resources>'
|
||||
'<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>'
|
||||
'<asset id="r2" name="A" start="0s" duration="100s">'
|
||||
'<media-rep kind="original-media" src="file:///a.mov"/>'
|
||||
'</asset>'
|
||||
'<locator id="loc1" url="tracking/shot1.dat"/>'
|
||||
'</resources>'
|
||||
'<library location="file:///lib.fcpbundle/"><event name="Evt">'
|
||||
'<project name="FCP12 Test">'
|
||||
'<sequence format="r1" duration="240/24s"><spine>'
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1">'
|
||||
'<hidden-clip-marker start="24/24s" duration="1/24s"/>'
|
||||
'<adjust-stereo-3D convergence="0.5"/>'
|
||||
'</asset-clip>'
|
||||
'<asset-clip ref="r2" offset="120/24s" name="B" start="0s" duration="120/24s" format="r1"/>'
|
||||
'</spine></sequence></project>'
|
||||
'</event></library></fcpxml>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle(tmp_path):
|
||||
"""Create a synthetic .fcpxmld bundle with sidecars."""
|
||||
b = tmp_path / "project.fcpxmld"
|
||||
b.mkdir()
|
||||
(b / "Info.fcpxml").write_text(FCP12_XML)
|
||||
tracking = b / "tracking"
|
||||
tracking.mkdir()
|
||||
(tracking / "shot1.dat").write_bytes(b"\x00\x01tracking-data")
|
||||
(b / "CurrentVersion.plist").write_text("<plist/>")
|
||||
return b
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Parsing
|
||||
# ============================================================
|
||||
|
||||
def test_parse_bundle(bundle):
|
||||
project = FCPXMLParser().parse_file(str(bundle))
|
||||
assert project.fcpxml_version == "1.14"
|
||||
tl = project.primary_timeline
|
||||
assert tl.name == "FCP12 Test"
|
||||
assert len(tl.clips) == 2
|
||||
|
||||
|
||||
def test_parse_v14_unknown_elements_ignored(bundle):
|
||||
"""1.13/1.14 elements the model doesn't know are skipped, not fatal."""
|
||||
tl = FCPXMLParser().parse_file(str(bundle)).primary_timeline
|
||||
# hidden-clip-marker must not surface as a regular marker
|
||||
assert all(m.name != "" or True for m in tl.markers)
|
||||
assert len(tl.clips) == 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Validation layer
|
||||
# ============================================================
|
||||
|
||||
def test_validate_filepath_accepts_bundle(bundle):
|
||||
resolved = server._validate_filepath(str(bundle), ('.fcpxml', '.fcpxmld'))
|
||||
assert resolved.endswith(".fcpxmld")
|
||||
|
||||
|
||||
def test_validate_filepath_rejects_bundle_without_info(tmp_path):
|
||||
fake = tmp_path / "empty.fcpxmld"
|
||||
fake.mkdir()
|
||||
with pytest.raises(ValueError, match="no Info.fcpxml"):
|
||||
server._validate_filepath(str(fake), ('.fcpxml', '.fcpxmld'))
|
||||
|
||||
|
||||
def test_validate_filepath_rejects_plain_directory(tmp_path):
|
||||
plain = tmp_path / "somedir"
|
||||
plain.mkdir()
|
||||
with pytest.raises(ValueError, match="Not a regular file"):
|
||||
server._validate_filepath(str(plain))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Modifier round-trip
|
||||
# ============================================================
|
||||
|
||||
def test_modifier_loads_bundle(bundle):
|
||||
modifier = FCPXMLModifier(str(bundle))
|
||||
assert modifier.bundle_dir == bundle
|
||||
assert modifier.root.get("version") == "1.14"
|
||||
|
||||
|
||||
def test_modifier_bundle_roundtrip_preserves_sidecars(bundle, tmp_path):
|
||||
modifier = FCPXMLModifier(str(bundle))
|
||||
modifier.trim_clip("A", trim_end="-24/24s")
|
||||
out = tmp_path / "project_modified.fcpxmld"
|
||||
written = modifier.save(str(out))
|
||||
|
||||
assert written == str(out)
|
||||
assert (out / "Info.fcpxml").is_file()
|
||||
# Sidecars copied byte-for-byte
|
||||
assert (out / "tracking" / "shot1.dat").read_bytes() == b"\x00\x01tracking-data"
|
||||
assert (out / "CurrentVersion.plist").is_file()
|
||||
|
||||
|
||||
def test_modifier_bundle_roundtrip_preserves_unknown_elements(bundle, tmp_path):
|
||||
modifier = FCPXMLModifier(str(bundle))
|
||||
modifier.add_marker("B", "00:00:01:00", "note")
|
||||
out = tmp_path / "project_modified.fcpxmld"
|
||||
modifier.save(str(out))
|
||||
|
||||
written_xml = (out / "Info.fcpxml").read_text()
|
||||
root = ET.fromstring(written_xml)
|
||||
assert root.get("version") == "1.14" # source version kept, not rewritten
|
||||
assert root.find(".//hidden-clip-marker") is not None
|
||||
assert root.find(".//adjust-stereo-3D") is not None
|
||||
assert root.find(".//locator") is not None
|
||||
|
||||
|
||||
def test_modifier_bundle_to_flat_fcpxml(bundle, tmp_path):
|
||||
"""Explicit flat output works (sidecars dropped by definition)."""
|
||||
modifier = FCPXMLModifier(str(bundle))
|
||||
out = tmp_path / "flat.fcpxml"
|
||||
written = modifier.save(str(out))
|
||||
assert written == str(out)
|
||||
assert out.is_file()
|
||||
assert ET.parse(out).getroot().get("version") == "1.14"
|
||||
|
||||
|
||||
def test_modifier_default_save_overwrites_bundle(bundle):
|
||||
modifier = FCPXMLModifier(str(bundle))
|
||||
modifier.add_marker("A", "00:00:01:00", "in-place")
|
||||
written = modifier.save()
|
||||
assert written == str(bundle)
|
||||
assert "in-place" in (bundle / "Info.fcpxml").read_text()
|
||||
# Sidecars untouched
|
||||
assert (bundle / "tracking" / "shot1.dat").read_bytes() == b"\x00\x01tracking-data"
|
||||
|
||||
|
||||
def test_flat_source_to_bundle_output(tmp_path):
|
||||
flat = tmp_path / "flat.fcpxml"
|
||||
flat.write_text(FCP12_XML)
|
||||
modifier = FCPXMLModifier(str(flat))
|
||||
out = tmp_path / "out.fcpxmld"
|
||||
written = modifier.save(str(out))
|
||||
assert written == str(out)
|
||||
assert (out / "Info.fcpxml").is_file()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Server I/O plumbing
|
||||
# ============================================================
|
||||
|
||||
def test_resolve_io_paths_bundle_default_output(bundle):
|
||||
filepath, output_path = server._resolve_io_paths({"filepath": str(bundle)})
|
||||
assert filepath.endswith("project.fcpxmld")
|
||||
assert output_path.endswith("project_modified.fcpxmld")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for fcpxml/diarize.py — speaker diarization (WHISPERX-inspired).
|
||||
|
||||
The pure assignment helpers are fully testable without pyannote; the
|
||||
diarize()/diarization_capability() integration path degrades gracefully when
|
||||
pyannote or a token is absent.
|
||||
"""
|
||||
|
||||
|
||||
from fcpxml.diarize import (
|
||||
DEFAULT_SPEAKER,
|
||||
assign_speakers,
|
||||
build_speakers,
|
||||
diarization_capability,
|
||||
diarize,
|
||||
)
|
||||
|
||||
|
||||
def test_capability_without_token_is_false():
|
||||
ok, _msg = diarization_capability("")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_capability_without_pyannote_is_false(monkeypatch):
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def block(name, *args, **kwargs):
|
||||
if name.startswith("pyannote"):
|
||||
raise ImportError("blocked for test")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", block)
|
||||
ok, msg = diarization_capability("hf_xyz")
|
||||
assert ok is False
|
||||
assert "pyannote" in msg.lower()
|
||||
|
||||
|
||||
def test_diarize_without_token_returns_none():
|
||||
assert diarize("ignored.wav", None) is None
|
||||
assert diarize("ignored.wav", "") is None
|
||||
|
||||
|
||||
def test_assign_speakers_no_tracks_uses_default():
|
||||
segments = [{"start": 0.0, "end": 1.0}, {"start": 2.0, "end": 3.0}]
|
||||
words = [{"start": 0.2, "end": 0.4}]
|
||||
segs, ws = assign_speakers(segments, words, None)
|
||||
assert all(s["speaker_id"] == DEFAULT_SPEAKER for s in segs)
|
||||
assert ws[0]["speaker_id"] == DEFAULT_SPEAKER
|
||||
|
||||
|
||||
def test_assign_speakers_overlap_mapping():
|
||||
tracks = [(0.0, 2.0, "SPEAKER_A"), (2.0, 4.0, "SPEAKER_B")]
|
||||
segments = [{"start": 0.5, "end": 1.5}, {"start": 2.5, "end": 3.5}]
|
||||
words = [{"start": 0.9, "end": 1.0}, {"start": 3.0, "end": 3.1}]
|
||||
segs, ws = assign_speakers(segments, words, tracks)
|
||||
assert segs[0]["speaker_id"] == "SPEAKER_00"
|
||||
assert segs[1]["speaker_id"] == "SPEAKER_01"
|
||||
assert ws[0]["speaker_id"] == "SPEAKER_00"
|
||||
assert ws[1]["speaker_id"] == "SPEAKER_01"
|
||||
|
||||
|
||||
def test_assign_speakers_does_not_mutate_input():
|
||||
tracks = [(0.0, 2.0, "SPEAKER_A")]
|
||||
segments = [{"start": 0.5, "end": 1.5}]
|
||||
words = [{"start": 0.9, "end": 1.0}]
|
||||
assign_speakers(segments, words, tracks)
|
||||
assert "speaker_id" not in segments[0]
|
||||
assert "speaker_id" not in words[0]
|
||||
|
||||
|
||||
def test_build_speakers_dedupes_and_orders():
|
||||
segments = [
|
||||
{"speaker_id": "SPEAKER_01"},
|
||||
{"speaker_id": "SPEAKER_00"},
|
||||
{"speaker_id": "SPEAKER_01"},
|
||||
]
|
||||
speakers = build_speakers(segments)
|
||||
assert speakers == [
|
||||
{"id": "SPEAKER_01", "name": "Speaker 1"},
|
||||
{"id": "SPEAKER_00", "name": "Speaker 2"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_speakers_empty():
|
||||
assert build_speakers([]) == []
|
||||
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
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
"""Diversity-picked tests targeting untested boundaries across modules.
|
||||
|
||||
Covers: diff threshold boundaries (0.04s clip move, 1.0s marker move),
|
||||
MontageConfig pacing curves at inflection points, Timeline model edge
|
||||
cases (zero duration, empty clips), DuplicateGroup overlap detection,
|
||||
ValidationResult aggregation, export lane-to-track mapping fidelity,
|
||||
and TimelineDiff empty-state properties.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.diff import ClipDiff, TimelineDiff, compare_timelines
|
||||
from fcpxml.models import (
|
||||
Clip,
|
||||
DuplicateGroup,
|
||||
MontageConfig,
|
||||
PacingCurve,
|
||||
Timecode,
|
||||
Timeline,
|
||||
ValidationIssue,
|
||||
ValidationIssueType,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Diff threshold boundaries — 0.04s is the move/trim detection threshold
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _tmp(xml: str) -> str:
|
||||
fd, path = tempfile.mkstemp(suffix=".fcpxml")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(xml)
|
||||
return path
|
||||
|
||||
|
||||
# Clip_A = 10s, Clip_B = 10s
|
||||
THRESHOLD_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="240/24s"/>
|
||||
<asset id="r3" name="B" src="file:///b.mov" start="0s" duration="240/24s"/>
|
||||
</resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="480/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s"/>
|
||||
<asset-clip ref="r3" offset="240/24s" name="B" start="0s" duration="240/24s"/>
|
||||
</spine></sequence></project></event></library>
|
||||
</fcpxml>"""
|
||||
|
||||
|
||||
class TestDiffMoveThreshold:
|
||||
"""Clip move detection uses 0.04s threshold — test both sides."""
|
||||
|
||||
def test_below_threshold_not_detected(self):
|
||||
"""1-frame shift at 24fps (0.042s) is right at threshold — but a sub-frame
|
||||
shift of 0.03s should NOT be detected as a move."""
|
||||
# This is tested indirectly: identical files produce no moves
|
||||
a = _tmp(THRESHOLD_XML)
|
||||
b = _tmp(THRESHOLD_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)
|
||||
|
||||
def test_trimmed_only_detected_as_trim(self):
|
||||
"""Duration change without position change should be 'trimmed' not 'moved'."""
|
||||
xml_b = THRESHOLD_XML.replace(
|
||||
'name="B" start="0s" duration="240/24s"/>',
|
||||
'name="B" start="0s" duration="120/24s"/>',
|
||||
)
|
||||
xml_b = xml_b.replace('duration="480/24s"', 'duration="360/24s"')
|
||||
a, b = _tmp(THRESHOLD_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"]
|
||||
moved = [d for d in diff.clip_diffs if d.action == "moved"]
|
||||
assert len(trimmed) >= 1, "Duration-only change should be 'trimmed'"
|
||||
assert trimmed[0].clip_name == "B"
|
||||
assert len(moved) == 0, "No position change, so no 'moved'"
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestDiffMarkerThreshold:
|
||||
"""Marker movement uses 1.0s threshold — sub-second moves are ignored."""
|
||||
|
||||
MARKER_XML = THRESHOLD_XML.replace(
|
||||
'name="A" start="0s" duration="240/24s"/>',
|
||||
'name="A" start="0s" duration="240/24s">'
|
||||
'<marker start="48/24s" duration="1/24s" value="Mark"/>'
|
||||
"</asset-clip>",
|
||||
)
|
||||
|
||||
def test_marker_sub_second_move_ignored(self):
|
||||
"""Moving marker by 12 frames at 24fps (0.5s) should NOT be reported."""
|
||||
xml_b = self.MARKER_XML.replace('start="48/24s"', 'start="60/24s"')
|
||||
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) == 0, "0.5s move is under 1.0s threshold"
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
def test_marker_over_threshold_detected(self):
|
||||
"""Moving marker by 48 frames at 24fps (2.0s) SHOULD be reported."""
|
||||
xml_b = self.MARKER_XML.replace('start="48/24s"', 'start="96/24s"')
|
||||
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
|
||||
assert moved[0].marker_name == "Mark"
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
class TestTimelineDiffEmptyState:
|
||||
"""TimelineDiff properties when no changes exist."""
|
||||
|
||||
def test_empty_diff_has_no_changes(self):
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
assert diff.total_changes == 0
|
||||
assert diff.has_changes is False
|
||||
|
||||
def test_only_unchanged_clips_means_no_changes(self):
|
||||
diff = TimelineDiff(
|
||||
timeline_a_name="A",
|
||||
timeline_b_name="B",
|
||||
clip_diffs=[
|
||||
ClipDiff(action="unchanged", clip_name="X"),
|
||||
ClipDiff(action="unchanged", clip_name="Y"),
|
||||
],
|
||||
)
|
||||
assert diff.total_changes == 0
|
||||
assert diff.has_changes is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MontageConfig pacing curve inflection points
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestMontageConfigCurves:
|
||||
"""Test pacing curve math at boundary and inflection positions."""
|
||||
|
||||
def test_pyramid_at_midpoint(self):
|
||||
"""PYRAMID inflection at 0.5 should produce end_duration (fastest)."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.PYRAMID,
|
||||
start_duration=4.0,
|
||||
end_duration=1.0,
|
||||
)
|
||||
# At position=0.5: first-half formula yields end_duration
|
||||
dur = cfg.get_duration_at_position(0.5)
|
||||
assert dur == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
def test_pyramid_at_zero(self):
|
||||
"""PYRAMID at start should produce start_duration."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60, pacing_curve=PacingCurve.PYRAMID,
|
||||
start_duration=4.0, end_duration=1.0,
|
||||
)
|
||||
assert cfg.get_duration_at_position(0.0) == pytest.approx(4.0, abs=0.01)
|
||||
|
||||
def test_pyramid_at_one(self):
|
||||
"""PYRAMID at end should return to start_duration."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60, pacing_curve=PacingCurve.PYRAMID,
|
||||
start_duration=4.0, end_duration=1.0,
|
||||
)
|
||||
assert cfg.get_duration_at_position(1.0) == pytest.approx(4.0, abs=0.01)
|
||||
|
||||
def test_constant_ignores_position(self):
|
||||
"""CONSTANT curve returns same duration regardless of position."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=30, pacing_curve=PacingCurve.CONSTANT,
|
||||
start_duration=3.0, end_duration=1.0,
|
||||
)
|
||||
assert cfg.get_duration_at_position(0.0) == cfg.get_duration_at_position(1.0)
|
||||
|
||||
def test_accelerating_monotonically_decreases(self):
|
||||
"""ACCELERATING: clip durations should decrease from start to end."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60, pacing_curve=PacingCurve.ACCELERATING,
|
||||
start_duration=5.0, end_duration=0.5, min_duration=0.2,
|
||||
)
|
||||
durations = [cfg.get_duration_at_position(p) for p in [0.0, 0.25, 0.5, 0.75, 1.0]]
|
||||
for i in range(len(durations) - 1):
|
||||
assert durations[i] >= durations[i + 1], f"Not monotonic at index {i}"
|
||||
|
||||
def test_clamping_respects_min_max(self):
|
||||
"""Duration should never exceed max or drop below min."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60, pacing_curve=PacingCurve.ACCELERATING,
|
||||
start_duration=10.0, end_duration=0.1,
|
||||
min_duration=0.5, max_duration=5.0,
|
||||
)
|
||||
for p in [0.0, 0.5, 1.0]:
|
||||
dur = cfg.get_duration_at_position(p)
|
||||
assert dur >= cfg.min_duration
|
||||
assert dur <= cfg.max_duration
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Timeline model edge cases
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestTimelineModelEdges:
|
||||
"""Timeline properties when clips are empty or duration is degenerate."""
|
||||
|
||||
def _make_timeline(self, clips=None, duration_frames=0, fps=24.0):
|
||||
return Timeline(
|
||||
name="Test",
|
||||
duration=Timecode(frames=duration_frames, frame_rate=fps),
|
||||
frame_rate=fps,
|
||||
clips=clips or [],
|
||||
)
|
||||
|
||||
def test_cuts_per_minute_zero_duration(self):
|
||||
"""Zero-duration timeline should return 0.0 CPM, not ZeroDivisionError."""
|
||||
tl = self._make_timeline(duration_frames=0)
|
||||
assert tl.cuts_per_minute == 0.0
|
||||
|
||||
def test_average_clip_duration_empty(self):
|
||||
assert self._make_timeline().average_clip_duration == 0.0
|
||||
|
||||
def test_get_clip_at_exact_boundary(self):
|
||||
"""get_clip_at uses start <= tc < end — clip end is exclusive."""
|
||||
clip = Clip(
|
||||
name="X",
|
||||
start=Timecode(frames=0, frame_rate=24),
|
||||
duration=Timecode(frames=48, frame_rate=24),
|
||||
)
|
||||
tl = self._make_timeline(clips=[clip], duration_frames=48)
|
||||
assert tl.get_clip_at(0.0) is clip # At start
|
||||
assert tl.get_clip_at(1.99) is clip # Just before end
|
||||
assert tl.get_clip_at(2.0) is None # Exactly at end (exclusive)
|
||||
|
||||
def test_total_cuts_single_clip(self):
|
||||
clip = Clip("X", Timecode(0, 24), Timecode(24, 24))
|
||||
tl = self._make_timeline(clips=[clip], duration_frames=24)
|
||||
assert tl.total_cuts == 0 # 1 clip = 0 cuts
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DuplicateGroup overlap detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestDuplicateGroupOverlap:
|
||||
"""has_overlapping_ranges detects source range collisions."""
|
||||
|
||||
def test_non_overlapping(self):
|
||||
grp = DuplicateGroup(
|
||||
source_ref="r1", source_name="Clip",
|
||||
clips=[
|
||||
{"source_start": 0, "source_duration": 5},
|
||||
{"source_start": 5, "source_duration": 5},
|
||||
],
|
||||
)
|
||||
assert grp.has_overlapping_ranges is False
|
||||
|
||||
def test_overlapping(self):
|
||||
grp = DuplicateGroup(
|
||||
source_ref="r1", source_name="Clip",
|
||||
clips=[
|
||||
{"source_start": 0, "source_duration": 10},
|
||||
{"source_start": 5, "source_duration": 10},
|
||||
],
|
||||
)
|
||||
assert grp.has_overlapping_ranges is True
|
||||
|
||||
def test_empty_clips(self):
|
||||
grp = DuplicateGroup(source_ref="r1", source_name="Clip", clips=[])
|
||||
assert grp.has_overlapping_ranges is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ValidationResult aggregation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestValidationResultAggregation:
|
||||
"""summary() and count properties."""
|
||||
|
||||
def test_mixed_severities(self):
|
||||
result = ValidationResult(
|
||||
is_valid=False, health_score=60,
|
||||
issues=[
|
||||
ValidationIssue(ValidationIssueType.FLASH_FRAME, "error", "bad"),
|
||||
ValidationIssue(ValidationIssueType.GAP, "warning", "gap found"),
|
||||
ValidationIssue(ValidationIssueType.GAP, "warning", "another gap"),
|
||||
ValidationIssue(ValidationIssueType.DUPLICATE, "error", "dupe"),
|
||||
],
|
||||
)
|
||||
assert result.error_count == 2
|
||||
assert result.warning_count == 2
|
||||
summary = result.summary()
|
||||
assert "60%" in summary
|
||||
assert "Errors: 2" in summary
|
||||
|
||||
def test_perfect_health(self):
|
||||
result = ValidationResult(is_valid=True, health_score=100)
|
||||
assert result.error_count == 0
|
||||
assert result.warning_count == 0
|
||||
assert "100%" in result.summary()
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
"""Validate generated FCPXML against Apple's official DTDs (v0.8.0).
|
||||
|
||||
These tests use the DTDs shipped inside the local Final Cut Pro app
|
||||
bundle — the only authoritative FCPXML spec (Apple's online docs stopped
|
||||
at 1.10). On machines without FCP (CI runners), the whole module skips.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.dtd import (
|
||||
available_dtd_versions,
|
||||
find_apple_dtd,
|
||||
validate_against_dtd,
|
||||
)
|
||||
from fcpxml.templates import ClipSpec, apply_template
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
HAVE_DTDS = bool(available_dtd_versions()) and shutil.which("xmllint")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HAVE_DTDS,
|
||||
reason="Apple FCPXML DTDs not available (Final Cut Pro not installed) "
|
||||
"or xmllint missing",
|
||||
)
|
||||
|
||||
|
||||
def test_dtds_present_through_1_13():
|
||||
versions = available_dtd_versions()
|
||||
assert "1.11" in versions
|
||||
assert "1.13" in versions
|
||||
|
||||
|
||||
def test_find_apple_dtd_rejects_garbage():
|
||||
assert find_apple_dtd("../../etc/passwd") is None
|
||||
assert find_apple_dtd("99.99") is None
|
||||
|
||||
|
||||
def test_template_output_is_dtd_valid(tmp_path):
|
||||
clips = {
|
||||
"main_content": ClipSpec(src="/test/main.mov", name="Main", duration=10.0),
|
||||
}
|
||||
out = str(tmp_path / "template.fcpxml")
|
||||
apply_template("lower_thirds", clips, out)
|
||||
ok, detail = validate_against_dtd(out)
|
||||
assert ok is True, detail
|
||||
|
||||
|
||||
# DTD-valid 1.13 fixture (asset uses the required media-rep form;
|
||||
# markers live inside clips, never at sequence level).
|
||||
VALID_113 = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<fcpxml version="1.13"><resources>'
|
||||
'<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" '
|
||||
'width="1920" height="1080"/>'
|
||||
'<asset id="r2" name="A" start="0s" duration="100s" hasVideo="1" format="r1">'
|
||||
'<media-rep kind="original-media" src="file:///a.mov"/>'
|
||||
'</asset>'
|
||||
'</resources>'
|
||||
'<library><event name="Evt"><project name="DTD Test">'
|
||||
'<sequence format="r1" duration="240/24s" tcStart="0s"><spine>'
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s"/>'
|
||||
'<asset-clip ref="r2" offset="120/24s" name="B" start="0s" duration="120/24s"/>'
|
||||
'</spine></sequence></project></event></library></fcpxml>'
|
||||
)
|
||||
|
||||
|
||||
def test_modifier_roundtrip_stays_dtd_valid(tmp_path):
|
||||
src = tmp_path / "valid.fcpxml"
|
||||
src.write_text(VALID_113)
|
||||
ok, detail = validate_against_dtd(str(src))
|
||||
assert ok is True, f"fixture itself invalid: {detail}"
|
||||
|
||||
out = str(tmp_path / "valid_modified.fcpxml")
|
||||
modifier = FCPXMLModifier(str(src))
|
||||
modifier.add_marker("A", "00:00:01:00", "dtd-check")
|
||||
modifier.trim_clip("B", trim_end="-24/24s")
|
||||
modifier.save(out)
|
||||
ok, detail = validate_against_dtd(out)
|
||||
assert ok is True, detail
|
||||
|
||||
|
||||
def test_repo_sample_fixture_known_nonconformant():
|
||||
"""examples/sample.fcpxml predates media-rep and uses sequence-level
|
||||
chapter markers — Apple's 1.11 DTD rejects both. Documented here so
|
||||
the day it gets fixed, this test flips and forces a fixture review.
|
||||
"""
|
||||
ok, detail = validate_against_dtd(str(SAMPLE))
|
||||
assert ok is False
|
||||
assert "media-rep" in detail or "asset" in detail
|
||||
|
||||
|
||||
def test_invalid_xml_fails_dtd(tmp_path):
|
||||
bad = tmp_path / "bad.fcpxml"
|
||||
bad.write_text(
|
||||
'<?xml version="1.0"?>\n'
|
||||
'<fcpxml version="1.13"><made-up-element/></fcpxml>'
|
||||
)
|
||||
ok, detail = validate_against_dtd(str(bad))
|
||||
assert ok is False
|
||||
assert detail
|
||||
|
||||
|
||||
def test_validate_missing_file_unavailable():
|
||||
ok, detail = validate_against_dtd("/nonexistent/file.fcpxml")
|
||||
assert ok is None
|
||||
assert "not found" in detail.lower()
|
||||
|
||||
|
||||
def test_generated_titles_stay_dtd_valid(tmp_path):
|
||||
"""Regression for 2026-08-17: text-style ids derived from the caption
|
||||
text (spaces, accents, leading digits) are not valid XML Names, and
|
||||
xmllint rejected the document with "Syntax of value for attribute id of
|
||||
text-style-def is not valid" — FCP refused to import the file.
|
||||
"""
|
||||
src = tmp_path / "titles.fcpxml"
|
||||
src.write_text(VALID_113)
|
||||
|
||||
modifier = FCPXMLModifier(str(src))
|
||||
modifier.add_text_title("A", "3 coisas que você precisa saber", duration="24/24s")
|
||||
modifier.generate_dynamic_subtitles(
|
||||
"B",
|
||||
[
|
||||
{"word": "Olá", "start": 0.1, "end": 0.4},
|
||||
{"word": "mundo!", "start": 0.5, "end": 0.9},
|
||||
],
|
||||
)
|
||||
|
||||
out = str(tmp_path / "titles_out.fcpxml")
|
||||
modifier.save(out)
|
||||
ok, detail = validate_against_dtd(out)
|
||||
assert ok is True, detail
|
||||
@@ -0,0 +1,764 @@
|
||||
"""Tests for cascading progressive-reveal subtitle generation.
|
||||
|
||||
Covers FCPXMLModifier.generate_dynamic_subtitles(): one standalone <title>
|
||||
("Essencial - Título"/Essential Title template) per word by default, cycling
|
||||
round-robin across config.lane_count lanes (no compound clip, no <gap>
|
||||
anchoring — both were rejected by a real Final Cut Pro import in earlier
|
||||
iterations), same-lane non-overlap, minimum one-frame durations, effect
|
||||
uid/param fidelity to the FCP exports the user produced by hand, absence of
|
||||
any caption role, and round-trip through the parser.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import DynamicSubtitleConfig, TimeValue, WordStyle
|
||||
from fcpxml.parser import parse_fcpxml
|
||||
from fcpxml.text_layout import POINT_SCALE, REFERENCE_CANVAS_HEIGHT, ink_extent
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
# The earlier rhythm: one title per WORD. The default is now the
|
||||
# progressive composition (one title per LINE), covered in
|
||||
# TestProgressiveComposition.
|
||||
WORD_MODE = DynamicSubtitleConfig(granularity="word")
|
||||
|
||||
WORDS = [
|
||||
{"word": "Hello", "start": 0.0, "end": 0.4},
|
||||
{"word": "there", "start": 0.4, "end": 0.8},
|
||||
{"word": "friend", "start": 0.8, "end": 1.3},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_fcpxml():
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
shutil.copy(SAMPLE, f.name)
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestGenerateDynamicSubtitles:
|
||||
def test_one_title_per_word_no_compound_clip(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
# Default max_words_per_line=1 -> one title per word.
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)
|
||||
assert len(titles) == len(WORDS)
|
||||
assert all(t.tag == "title" for t in titles)
|
||||
# No <media>/<ref-clip> compound-clip machinery at all.
|
||||
resources = modifier.root.find(".//resources")
|
||||
assert resources.findall("media") == []
|
||||
assert modifier.root.findall(".//ref-clip") == []
|
||||
|
||||
def test_title_attached_directly_to_parent_clip(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
parent = modifier._require_clip("Interview_A")
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
assert titles[0] in list(parent)
|
||||
|
||||
def test_effect_uid_matches_real_fcp_export(self, temp_fcpxml):
|
||||
"""An incorrect/guessed effect uid makes FCP silently drop every
|
||||
connected clip that references it during import — no error, the
|
||||
clips just never appear in the timeline or role list. This uid comes
|
||||
verbatim from an export the user made by hand in Final Cut and
|
||||
exported back out ("teste.fcpxmld" and "posição.fcpxmld", effect r3
|
||||
"Text"), not fabricated."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
|
||||
resources = modifier.root.find(".//resources")
|
||||
effect = next(
|
||||
e for e in resources.findall("effect") if e.get("name") == "Text"
|
||||
)
|
||||
assert effect.get("uid") == (
|
||||
".../Titles.localized/Basic Text.localized/"
|
||||
"Text.localized/Text.moti"
|
||||
)
|
||||
|
||||
def test_titles_carry_no_caption_role(self, temp_fcpxml):
|
||||
"""Regression: dynamic subtitles are animated TITLES, not captions.
|
||||
|
||||
A role="subtitles.*" makes Final Cut route them to the captions lane,
|
||||
which is not drawn over the video unless caption display is enabled —
|
||||
so the import succeeds and nothing ever appears. No generated title
|
||||
may carry a role attribute.
|
||||
"""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
assert titles
|
||||
assert all(t.get("role") is None for t in titles)
|
||||
|
||||
static = modifier.generate_dynamic_subtitles(
|
||||
"Broll_City", WORDS, DynamicSubtitleConfig(animated=False)
|
||||
)
|
||||
assert all(t.get("role") is None for t in static)
|
||||
|
||||
def test_title_has_start_attribute_and_layout_param_block(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
title = modifier.generate_dynamic_subtitles("Interview_A", WORDS)[0]
|
||||
assert title.get("start") == "86486400/24000s"
|
||||
|
||||
param_names = [p.get("name") for p in title.findall("param")]
|
||||
assert param_names == [
|
||||
"Position", "Layout Method", "Left Margin", "Right Margin",
|
||||
"Top Margin", "Bottom Margin", "Alignment", "Line Spacing",
|
||||
"Auto-Shrink", "Alignment", "Opacity", "Speed", "Custom Speed",
|
||||
"Apply Speed",
|
||||
]
|
||||
# The "Custom Speed" param carries the template's own keyframe
|
||||
# animation (opacity reveal) — absolute nominal times, constant
|
||||
# across every instance, so they replay verbatim.
|
||||
anims = title.findall(".//keyframeAnimation")
|
||||
assert len(anims) == 1
|
||||
|
||||
def test_word_text_and_style(self, temp_fcpxml):
|
||||
"""Font and face come from the sentence rhythm, whose first entry is
|
||||
the reference export's "Toda": Helvetica Light at 170. The size scales
|
||||
to the frame — the fixture is 1920x1080, a quarter the height of the
|
||||
2160x3840 timeline the rhythm was calibrated on."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
title = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)[0]
|
||||
run = title.find("text/text-style")
|
||||
assert run.text == "Hello"
|
||||
style_def = title.find(f"text-style-def[@id='{run.get('ref')}']/text-style")
|
||||
first = WordStyle().rhythm[0]
|
||||
assert style_def.get("font") == first.font
|
||||
assert style_def.get("fontFace") == first.face
|
||||
|
||||
scale = (1080 * POINT_SCALE) / REFERENCE_CANVAS_HEIGHT
|
||||
assert style_def.get("fontSize") == str(round(first.font_size * scale))
|
||||
|
||||
def test_font_size_scales_with_the_frame(self, temp_fcpxml):
|
||||
"""A vertical 2160x3840 timeline must get the reference sizes back
|
||||
unscaled — that is the format the rhythm was calibrated against."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
fmt = modifier.root.find(".//format")
|
||||
fmt.set("width", "2160")
|
||||
fmt.set("height", "3840")
|
||||
title = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)[0]
|
||||
run = title.find("text/text-style")
|
||||
style_def = title.find(f"text-style-def[@id='{run.get('ref')}']/text-style")
|
||||
assert style_def.get("fontSize") == str(WordStyle().rhythm[0].font_size)
|
||||
|
||||
def test_position_is_keyframed_constant_hold(self, temp_fcpxml):
|
||||
"""Regression (2026-08-17): position is a STATIC param in the "Text"
|
||||
template. The user's exports ("posição.fcpxmld") write the
|
||||
hand-placed words' coordinates as a plain `value="x y"` with no
|
||||
keyframeAnimation. Wrapping it in keyframes made FCP ignore the param
|
||||
and fall back to the template default, stacking every word at the
|
||||
same spot. So the position param must carry a static value — never
|
||||
keyframes — and each word must get its own."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
|
||||
for t in titles:
|
||||
param = next(
|
||||
p for p in t.findall("param")
|
||||
if p.get("key") == FCPXMLModifier._TEXT_POSITION_KEY
|
||||
)
|
||||
# Static value, no keyframeAnimation.
|
||||
assert param.get("value") is not None
|
||||
assert param.find("keyframeAnimation") is None
|
||||
|
||||
def test_each_word_is_positioned_distinctly(self, temp_fcpxml):
|
||||
"""Every word carries its own place in the block, so the template's
|
||||
fixed centre never leaves them stacked on one another."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)
|
||||
positions = [
|
||||
next(
|
||||
p.get("value")
|
||||
for p in t.findall("param")
|
||||
if p.get("key") == FCPXMLModifier._TEXT_POSITION_KEY
|
||||
)
|
||||
for t in titles
|
||||
]
|
||||
assert len(positions) == len(WORDS)
|
||||
assert len(set(positions)) == len(WORDS), "words must not share a spot"
|
||||
|
||||
def test_no_adjust_transform_on_text_title(self, temp_fcpxml):
|
||||
"""The "Text" template positions via its Position param, not
|
||||
<adjust-transform> — FCP's own export carries no adjust-transform."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
assert all(t.find("adjust-transform") is None for t in titles)
|
||||
|
||||
def test_each_word_of_a_block_gets_its_own_lane(self, temp_fcpxml):
|
||||
"""Words of one sentence are all on screen together, so none may share
|
||||
a lane — sharing one would make Final Cut reject the overlap."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)
|
||||
assert [t.get("lane") for t in titles] == ["1", "2", "3"]
|
||||
|
||||
def test_lanes_restart_with_each_block(self, temp_fcpxml):
|
||||
"""The previous block has cleared by the time the next one starts, so
|
||||
its lanes are free to reuse."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
words, segments = [], []
|
||||
for s in range(3):
|
||||
base = s * 2.0
|
||||
segments.append({"start": base, "end": base + 1.5})
|
||||
words += [
|
||||
{
|
||||
"word": f"s{s}w{i}",
|
||||
"start": base + i * 0.4,
|
||||
"end": base + i * 0.4 + 0.2,
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
titles = modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", words, segments=segments
|
||||
)
|
||||
assert [t.get("lane") for t in titles] == ["1", "2", "3"] * 3
|
||||
|
||||
def test_same_lane_titles_never_overlap_in_time(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
words, segments = [], []
|
||||
for s in range(3):
|
||||
base = s * 2.0
|
||||
segments.append({"start": base, "end": base + 1.5})
|
||||
words += [
|
||||
{
|
||||
"word": f"s{s}w{i}",
|
||||
"start": base + i * 0.4,
|
||||
"end": base + i * 0.4 + 0.2,
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
titles = modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", words, segments=segments
|
||||
)
|
||||
|
||||
by_lane: dict[str, list] = {}
|
||||
for t in titles:
|
||||
by_lane.setdefault(t.get("lane"), []).append(t)
|
||||
|
||||
for lane_titles in by_lane.values():
|
||||
spans = sorted(
|
||||
(
|
||||
modifier._parse_time(t.get("offset")),
|
||||
modifier._parse_time(t.get("offset")) + modifier._parse_time(t.get("duration")),
|
||||
)
|
||||
for t in lane_titles
|
||||
)
|
||||
for (_, end_a), (start_b, _) in zip(spans, spans[1:]):
|
||||
assert end_a <= start_b
|
||||
|
||||
def test_words_of_a_block_accumulate_on_screen(self, temp_fcpxml):
|
||||
"""Each word appears later than the last but they stay up together —
|
||||
that is the sentence building up in front of the viewer."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
offsets = [modifier._parse_time(t.get("offset")) for t in titles]
|
||||
assert offsets == sorted(offsets)
|
||||
assert offsets[0] < offsets[-1], "words must not all start at once"
|
||||
first_end = offsets[0] + modifier._parse_time(titles[0].get("duration"))
|
||||
assert first_end > offsets[-1], "the first word must outlast the last"
|
||||
|
||||
def test_every_word_of_a_block_clears_at_the_same_instant(self, temp_fcpxml):
|
||||
"""The whole sentence vanishes at once, rather than word by word."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
ends = {
|
||||
(
|
||||
modifier._parse_time(t.get("offset"))
|
||||
+ modifier._parse_time(t.get("duration"))
|
||||
).to_fcpxml()
|
||||
for t in titles
|
||||
}
|
||||
assert len(ends) == 1, f"expected one shared end, got {ends}"
|
||||
|
||||
def test_color_follows_the_style_rhythm(self, temp_fcpxml):
|
||||
"""Colour comes from the word's place in the sentence's rhythm, which
|
||||
reproduces the palette of the user's calibration export."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)
|
||||
|
||||
def color_of(title):
|
||||
run = title.find("text/text-style")
|
||||
style_def = title.find(f"text-style-def[@id='{run.get('ref')}']/text-style")
|
||||
return style_def.get("fontColor")
|
||||
|
||||
rhythm = WordStyle().rhythm
|
||||
assert [color_of(t) for t in titles] == [
|
||||
rhythm[i].color for i in range(len(WORDS))
|
||||
]
|
||||
|
||||
def test_effect_resource_created_once_and_deduped(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
modifier.generate_dynamic_subtitles("Broll_City", WORDS)
|
||||
|
||||
resources = modifier.root.find(".//resources")
|
||||
title_effects = [
|
||||
e for e in resources.findall("effect") if e.get("name") == "Text"
|
||||
]
|
||||
assert len(title_effects) == 1
|
||||
|
||||
def test_no_duplicate_ids_across_multiple_calls_on_same_modifier(self, temp_fcpxml):
|
||||
"""Regression test: the handler calls generate_dynamic_subtitles once
|
||||
per spine clip on the SAME FCPXMLModifier instance. Every <title>
|
||||
name and <text-style-def> id must stay unique document-wide, or FCP
|
||||
rejects the whole import with 'ID ... already defined'."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
modifier.generate_dynamic_subtitles("Broll_City", WORDS)
|
||||
modifier.generate_dynamic_subtitles("Broll_Studio", WORDS)
|
||||
|
||||
all_title_names = [t.get("name") for t in modifier.root.findall(".//title")]
|
||||
all_style_def_ids = [sd.get("id") for sd in modifier.root.findall(".//text-style-def")]
|
||||
|
||||
assert len(all_title_names) == len(set(all_title_names))
|
||||
assert len(all_style_def_ids) == len(set(all_style_def_ids))
|
||||
|
||||
def test_element_param_bypasses_ambiguous_duplicate_name_lookup(self, temp_fcpxml):
|
||||
"""Regression test for 2026-08-17: after ripple-cut/silence-removal,
|
||||
every fragment of an originally-named clip keeps the same `name`.
|
||||
Passing that (now-ambiguous) name resolves via `self.clips` — keyed
|
||||
by name — to whichever clip was indexed LAST, silently attaching
|
||||
every clip's captions to one wrong spine element. Passing the
|
||||
`ET.Element` itself (what the real handler in server.py does) must
|
||||
attach to the exact clip intended, regardless of duplicate names."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
interview_el = modifier._require_clip("Interview_A")
|
||||
broll_el = modifier._require_clip("Broll_City")
|
||||
# Simulate the real-world collision: both clips now share one name.
|
||||
broll_el.set("name", "Interview_A")
|
||||
modifier._build_clip_index()
|
||||
|
||||
titles_a = modifier.generate_dynamic_subtitles(interview_el, WORDS)
|
||||
titles_b = modifier.generate_dynamic_subtitles(
|
||||
broll_el, [{"word": "elsewhere", "start": 0.5, "end": 0.9}]
|
||||
)
|
||||
|
||||
assert titles_a and all(t in list(interview_el) for t in titles_a)
|
||||
assert titles_b and all(t in list(broll_el) for t in titles_b)
|
||||
# The two clips' captions must not have landed on the same element.
|
||||
assert list(interview_el.findall("title")) != list(broll_el.findall("title"))
|
||||
|
||||
def test_no_zero_duration_titles_for_tight_word_timing(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
tight_words = [
|
||||
{"word": "um", "start": 0.0, "end": 0.0},
|
||||
{"word": "dois", "start": 0.0, "end": 0.0},
|
||||
]
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", tight_words)
|
||||
for t in titles:
|
||||
assert modifier._parse_time(t.get("duration")) > TimeValue.zero()
|
||||
|
||||
def test_one_title_per_word_not_per_line(self, temp_fcpxml):
|
||||
"""Words are never merged into a single title — each is placed
|
||||
separately so it can appear on its own beat."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
long_words = WORDS + [{"word": "again", "start": 1.3, "end": 1.6}]
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", long_words, WORD_MODE)
|
||||
|
||||
assert len(titles) == len(long_words)
|
||||
assert [t.find("text/text-style").text for t in titles] == [
|
||||
w["word"] for w in long_words
|
||||
]
|
||||
|
||||
def test_segments_split_words_into_separate_blocks(self, temp_fcpxml):
|
||||
"""Two Whisper segments must produce two blocks that clear
|
||||
independently, rather than one run-on block."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
words = [
|
||||
{"word": "um", "start": 0.0, "end": 0.4},
|
||||
{"word": "dois", "start": 0.5, "end": 0.9},
|
||||
{"word": "tres", "start": 5.0, "end": 5.4},
|
||||
]
|
||||
segments = [{"start": 0.0, "end": 1.0}, {"start": 5.0, "end": 6.0}]
|
||||
titles = modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", words, segments=segments
|
||||
)
|
||||
|
||||
def end_of(t):
|
||||
return (
|
||||
modifier._parse_time(t.get("offset"))
|
||||
+ modifier._parse_time(t.get("duration"))
|
||||
)
|
||||
|
||||
assert end_of(titles[0]) == end_of(titles[1]), "block 1 clears together"
|
||||
assert end_of(titles[2]) != end_of(titles[0]), "block 2 is independent"
|
||||
|
||||
def test_long_sentence_splits_into_sub_blocks(self, temp_fcpxml):
|
||||
"""A sentence taller than the band is broken up rather than spilling
|
||||
off screen — so no block exceeds the lanes its words need."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
words = [
|
||||
{"word": f"palavra{i}", "start": float(i) * 0.2, "end": float(i) * 0.2 + 0.15}
|
||||
for i in range(30)
|
||||
]
|
||||
segments = [{"start": 0.0, "end": 30.0}]
|
||||
titles = modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", words, WORD_MODE, segments=segments
|
||||
)
|
||||
assert len(titles) == len(words)
|
||||
# More than one distinct clear-time means the sentence was split.
|
||||
ends = {
|
||||
(
|
||||
modifier._parse_time(t.get("offset"))
|
||||
+ modifier._parse_time(t.get("duration"))
|
||||
).to_fcpxml()
|
||||
for t in titles
|
||||
}
|
||||
assert len(ends) > 1
|
||||
|
||||
def test_empty_words_returns_empty_list(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
assert modifier.generate_dynamic_subtitles("Interview_A", []) == []
|
||||
|
||||
def test_missing_clip_raises(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
with pytest.raises(ValueError):
|
||||
modifier.generate_dynamic_subtitles("NoSuchClip", WORDS)
|
||||
|
||||
def test_round_trip_through_writer_and_parser(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)
|
||||
out_path = str(Path(temp_fcpxml).with_suffix(".out.fcpxml"))
|
||||
try:
|
||||
modifier.save(out_path)
|
||||
tree = ET.parse(out_path)
|
||||
titles = tree.getroot().findall(".//title")
|
||||
assert len(titles) == len(WORDS)
|
||||
|
||||
parsed = parse_fcpxml(out_path)
|
||||
assert parsed is not None
|
||||
finally:
|
||||
Path(out_path).unlink(missing_ok=True)
|
||||
|
||||
def test_animated_offset_uses_source_media_coordinates(self, temp_fcpxml):
|
||||
"""Regression: anchored titles are positioned in SOURCE media
|
||||
coordinates — FCP's own output ("exemplo de arquivos.fcpxmld") writes
|
||||
offset = parent clip's `start` + timeline-relative. Writing a plain
|
||||
relative offset drops the title to ~0s of the media, before the clip's
|
||||
in-point, so FCP never shows it ("legendas fora"). The default
|
||||
animated=True must add the clip's source `start` to every offset."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
parent = modifier._require_clip("Interview_A")
|
||||
parent.set("start", "226220995/24000s")
|
||||
titles = modifier.generate_dynamic_subtitles(parent, WORDS)
|
||||
|
||||
clip_start = modifier._parse_time("226220995/24000s")
|
||||
titles = sorted(titles, key=lambda t: modifier._parse_time(t.get("offset")))
|
||||
for t in titles:
|
||||
offset = modifier._parse_time(t.get("offset"))
|
||||
# offset must be >= clip source start (== for the first line at
|
||||
# relative 0.0, > for later lines) — never a small relative value
|
||||
# that FCP would read as ~0s of the media.
|
||||
assert offset >= clip_start
|
||||
# Relative spacing between lines is preserved (offset - clip_start).
|
||||
starts = [modifier._parse_time(t.get("offset")) - clip_start for t in titles]
|
||||
assert starts == sorted(starts)
|
||||
assert starts[0].to_seconds() >= 0.0
|
||||
|
||||
def test_effect_is_basic_text_template(self, temp_fcpxml):
|
||||
"""Dynamic subtitles must resolve the "Text" (Basic Text) effect uid
|
||||
and produce roleless titles — the structure of FCP's own export, and
|
||||
the only template we have verified renders in Final Cut."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
|
||||
resources = modifier.root.find(".//resources")
|
||||
effect = next(
|
||||
e for e in resources.findall("effect") if e.get("name") == "Text"
|
||||
)
|
||||
assert effect.get("uid") == (
|
||||
".../Titles.localized/Basic Text.localized/"
|
||||
"Text.localized/Text.moti"
|
||||
)
|
||||
for t in modifier.root.findall(".//title"):
|
||||
assert t.get("ref") == effect.get("id")
|
||||
assert t.get("role") is None
|
||||
assert t.get("start") == "86486400/24000s"
|
||||
|
||||
def test_all_configs_use_the_one_text_template(self, temp_fcpxml):
|
||||
"""animated True/False now both resolve to the single "Text" template —
|
||||
the earlier Essential Title / Título Básico templates never rendered,
|
||||
so they were retired."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", WORDS, DynamicSubtitleConfig(animated=False)
|
||||
)
|
||||
|
||||
resources = modifier.root.find(".//resources")
|
||||
effects = {e.get("name") for e in resources.findall("effect")}
|
||||
assert effects == {"Text"}
|
||||
|
||||
|
||||
class TestClipBoundaryClamping:
|
||||
"""A connected title is not trimmed by its parent clip's out-point: FCP
|
||||
keeps drawing it over the following clip. Words whose Whisper end (or
|
||||
start) runs past the cut must therefore be clamped, or the last block of
|
||||
one clip overlaps the first block of the next one on screen."""
|
||||
|
||||
def _parent_and_titles(self, path, words):
|
||||
modifier = FCPXMLModifier(path)
|
||||
parent = modifier._require_clip("Interview_A")
|
||||
titles = modifier.generate_dynamic_subtitles(parent, words)
|
||||
limit = modifier._parse_time(parent.get("duration")).to_seconds()
|
||||
origin = modifier._parse_time(parent.get("start", "0s")).to_seconds()
|
||||
spans = [
|
||||
(
|
||||
modifier._parse_time(t.get("offset")).to_seconds() - origin,
|
||||
modifier._parse_time(t.get("duration")).to_seconds(),
|
||||
)
|
||||
for t in titles
|
||||
]
|
||||
return limit, spans
|
||||
|
||||
def test_last_word_end_beyond_clip_is_clamped(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
limit = modifier._parse_time(
|
||||
modifier._require_clip("Interview_A").get("duration")
|
||||
).to_seconds()
|
||||
words = [
|
||||
{"word": "fim", "start": limit - 0.5, "end": limit + 5.0},
|
||||
]
|
||||
clip_limit, spans = self._parent_and_titles(temp_fcpxml, words)
|
||||
for offset, duration in spans:
|
||||
assert offset + duration <= clip_limit + 1e-6
|
||||
|
||||
def test_word_starting_past_clip_end_is_dropped(self, temp_fcpxml):
|
||||
"""It could only ever be seen over the NEXT clip's captions."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
limit = modifier._parse_time(
|
||||
modifier._require_clip("Interview_A").get("duration")
|
||||
).to_seconds()
|
||||
words = [
|
||||
{"word": "ok", "start": 0.2, "end": 0.6},
|
||||
{"word": "tarde", "start": limit + 2.0, "end": limit + 3.0},
|
||||
]
|
||||
clip_limit, spans = self._parent_and_titles(temp_fcpxml, words)
|
||||
assert len(spans) == 1
|
||||
for offset, duration in spans:
|
||||
assert offset < clip_limit
|
||||
assert offset + duration <= clip_limit + 1e-6
|
||||
|
||||
|
||||
class TestTextStyleIds:
|
||||
"""Regression for 2026-08-17: <text-style-def id> is DTD type ID, so it
|
||||
must be a valid XML Name. Deriving it from the title name (built from the
|
||||
caption text) emitted ids with spaces, accents and leading digits, and
|
||||
xmllint rejected the whole document — "Syntax of value for attribute id
|
||||
of text-style-def is not valid" — so FCP refused the import.
|
||||
"""
|
||||
|
||||
XML_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
def _style_ids(self, modifier):
|
||||
return [sd.get("id") for sd in modifier.root.findall(".//text-style-def")]
|
||||
|
||||
def test_text_title_id_is_a_valid_xml_name(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.add_text_title(
|
||||
"Interview_A", "3 coisas que você precisa saber", duration="1s"
|
||||
)
|
||||
ids = self._style_ids(modifier)
|
||||
assert ids and all(self.XML_NAME.match(i) for i in ids), ids
|
||||
|
||||
def test_style_id_matches_its_run_ref(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
title = modifier.add_text_title("Interview_A", "Olá, mundo!", duration="1s")
|
||||
run = title.find("text/text-style")
|
||||
assert run.get("ref") == title.find("text-style-def").get("id")
|
||||
|
||||
def test_repeated_titles_get_unique_ids(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
for _ in range(3):
|
||||
modifier.add_text_title("Interview_A", "Olá", duration="1s")
|
||||
ids = self._style_ids(modifier)
|
||||
assert len(ids) == len(set(ids)) == 3
|
||||
|
||||
def test_id_survives_text_with_no_ascii_characters(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
modifier.add_text_title("Interview_A", "日本語", duration="1s")
|
||||
ids = self._style_ids(modifier)
|
||||
assert ids and all(self.XML_NAME.match(i) for i in ids), ids
|
||||
|
||||
def test_dynamic_subtitle_ids_are_valid_xml_names(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS)
|
||||
ids = self._style_ids(modifier)
|
||||
assert len(ids) == len(titles)
|
||||
assert all(self.XML_NAME.match(i) for i in ids), ids
|
||||
|
||||
|
||||
class TestProgressiveComposition:
|
||||
"""The default look (reference reel the user sent, 2026-08-17):
|
||||
|
||||
[ que vão ]
|
||||
[ melhorar ]
|
||||
[ sua legenda ]
|
||||
|
||||
One title per LINE, not per word. Supporting words are grouped and set
|
||||
small in a grotesque; the sentence's key word is set large in a display
|
||||
italic on a line of its own. Each block appears as its own words are
|
||||
spoken and stays on screen, so the sentence assembles itself, and every
|
||||
block of a composition clears at the same instant.
|
||||
"""
|
||||
|
||||
PHRASE = "que vão melhorar sua legenda".split()
|
||||
|
||||
def _words(self, texts=None, step=0.5):
|
||||
texts = texts or self.PHRASE
|
||||
return [
|
||||
{"word": w, "start": i * step, "end": i * step + step * 0.9}
|
||||
for i, w in enumerate(texts)
|
||||
]
|
||||
|
||||
def _titles(self, temp_fcpxml, words=None):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
fmt = modifier.root.find(".//format")
|
||||
fmt.set("width", "2160")
|
||||
fmt.set("height", "3840")
|
||||
words = words or self._words()
|
||||
segments = [{"start": 0.0, "end": words[-1]["end"]}]
|
||||
return modifier, modifier.generate_dynamic_subtitles(
|
||||
"Interview_A", words, segments=segments
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _style(title):
|
||||
run = title.find("text/text-style")
|
||||
return title.find(f"text-style-def[@id='{run.get('ref')}']/text-style")
|
||||
|
||||
@staticmethod
|
||||
def _position(title):
|
||||
param = title.find(f"param[@key='{FCPXMLModifier._TEXT_POSITION_KEY}']")
|
||||
x, y = param.get("value").split()
|
||||
return float(x), float(y)
|
||||
|
||||
def test_one_title_per_line_grouping_supporting_words(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
assert [t.find("text/text-style").text for t in titles] == [
|
||||
"que vão",
|
||||
"melhorar",
|
||||
"sua legenda",
|
||||
]
|
||||
|
||||
def test_key_word_is_set_large_in_the_display_italic(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
emphasis, body = self._style(titles[1]), self._style(titles[0])
|
||||
assert emphasis.get("font") == "Playfair Display"
|
||||
assert "Italic" in emphasis.get("fontFace")
|
||||
assert body.get("font") == "Helvetica Neue"
|
||||
assert int(emphasis.get("fontSize")) > 2 * int(body.get("fontSize"))
|
||||
|
||||
def test_every_line_is_white(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
assert {self._style(t).get("fontColor") for t in titles} == {"1 1 1 1"}
|
||||
|
||||
def test_lines_stack_downward_and_stagger_around_the_key_word(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
(x_top, y_top), (x_mid, y_mid), (x_low, y_low) = map(self._position, titles)
|
||||
assert y_top > y_mid > y_low, "lines stack top to bottom"
|
||||
assert x_mid == 0, "the key word stays centred"
|
||||
assert x_top < 0 < x_low, "supporting lines hang off opposite edges"
|
||||
|
||||
def test_blocks_enter_as_spoken_and_clear_together(self, temp_fcpxml):
|
||||
modifier, titles = self._titles(temp_fcpxml)
|
||||
starts = [modifier._parse_time(t.get("offset")) for t in titles]
|
||||
ends = [
|
||||
s + modifier._parse_time(t.get("duration"))
|
||||
for s, t in zip(starts, titles)
|
||||
]
|
||||
assert starts == sorted(starts) and starts[0] < starts[-1]
|
||||
assert len({e.to_fcpxml() for e in ends}) == 1, "the composition clears as one"
|
||||
|
||||
def test_lines_never_overlap_on_screen(self, temp_fcpxml):
|
||||
"""Measured on the REAL ink each line contains — the accents and
|
||||
descenders of a display italic reach far past its cap height, and
|
||||
that is the pair that touches if the stack is spaced nominally."""
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
boxes = []
|
||||
for t in titles:
|
||||
_, y = self._position(t)
|
||||
style = self._style(t)
|
||||
top, bottom = ink_extent(
|
||||
t.find("text/text-style").text,
|
||||
float(style.get("fontSize")),
|
||||
font=style.get("font"),
|
||||
face=style.get("fontFace"),
|
||||
)
|
||||
boxes.append((y + bottom, y + top))
|
||||
for (_, top_above), (bottom_below, _) in zip(boxes, boxes[1:]):
|
||||
assert bottom_below < top_above # ordered top to bottom
|
||||
assert top_above <= boxes[0][1]
|
||||
for i, (lo_a, hi_a) in enumerate(boxes):
|
||||
for lo_b, hi_b in boxes[i + 1:]:
|
||||
assert hi_b <= lo_a, "rendered ink must not intersect"
|
||||
|
||||
@pytest.mark.parametrize("phrase", [
|
||||
"que vão melhorar sua legenda",
|
||||
"o segredo é começar antes de estar pronto",
|
||||
"ninguém ganha dinheiro gravando vídeo tremido à noite",
|
||||
"ÇÃO PAGA jjj ggg ppp qqq yyy ÁÊÍÕÚ",
|
||||
"a b c d e f g h i j k l",
|
||||
])
|
||||
def test_ink_never_overlaps_for_any_phrase(self, temp_fcpxml, phrase):
|
||||
"""The guarantee has to hold for accents stacked over descenders,
|
||||
for all-caps, and for a run of one-letter words — not just for the
|
||||
reference sentence."""
|
||||
modifier, titles = self._titles(temp_fcpxml, self._words(phrase.split()))
|
||||
# Only lines that share the screen can collide. Every block of one
|
||||
# composition clears at the same instant, and the next composition
|
||||
# starts there, so the clear time groups what is on screen together.
|
||||
onscreen = {}
|
||||
for t in titles:
|
||||
_, y = self._position(t)
|
||||
style = self._style(t)
|
||||
top, bottom = ink_extent(
|
||||
t.find("text/text-style").text,
|
||||
float(style.get("fontSize")),
|
||||
font=style.get("font"),
|
||||
face=style.get("fontFace"),
|
||||
)
|
||||
clear = (
|
||||
modifier._parse_time(t.get("offset"))
|
||||
+ modifier._parse_time(t.get("duration"))
|
||||
).to_fcpxml()
|
||||
onscreen.setdefault(clear, []).append((y + bottom, y + top))
|
||||
|
||||
assert onscreen, phrase
|
||||
for boxes in onscreen.values():
|
||||
for i, (lo_a, hi_a) in enumerate(boxes):
|
||||
for lo_b, hi_b in boxes[i + 1:]:
|
||||
assert hi_b <= lo_a or lo_b >= hi_a, phrase
|
||||
|
||||
def test_function_words_are_never_the_emphasis(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml, self._words("e o que importa é constância".split()))
|
||||
big = max(titles, key=lambda t: int(self._style(t).get("fontSize")))
|
||||
assert big.find("text/text-style").text == "constância"
|
||||
|
||||
def test_each_line_gets_its_own_lane(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml)
|
||||
assert [t.get("lane") for t in titles] == ["1", "2", "3"]
|
||||
|
||||
def test_single_word_sentence_still_composes(self, temp_fcpxml):
|
||||
_, titles = self._titles(temp_fcpxml, self._words(["chega"]))
|
||||
assert len(titles) == 1
|
||||
assert self._style(titles[0]).get("font") == "Playfair Display"
|
||||
|
||||
def test_long_sentence_splits_into_successive_compositions(self, temp_fcpxml):
|
||||
words = self._words([f"palavra{i}" for i in range(24)], step=0.3)
|
||||
modifier, titles = self._titles(temp_fcpxml, words)
|
||||
ends = {
|
||||
(
|
||||
modifier._parse_time(t.get("offset"))
|
||||
+ modifier._parse_time(t.get("duration"))
|
||||
).to_fcpxml()
|
||||
for t in titles
|
||||
}
|
||||
assert len(ends) > 1, "a long sentence must break into several compositions"
|
||||
spoken = " ".join(w["word"] for w in words)
|
||||
emitted = " ".join(t.find("text/text-style").text for t in titles)
|
||||
assert emitted == spoken, "no spoken word may be dropped or reordered"
|
||||
Executable
+617
@@ -0,0 +1,617 @@
|
||||
"""Edge-case tests targeting real production failure modes.
|
||||
|
||||
Covers boundary conditions in TimeValue arithmetic, clip index collisions
|
||||
from duplicate names, split_clip boundary behavior, diff identity rounding,
|
||||
and to_fcpxml round-trip fidelity for non-standard timebases.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.diff import ClipDiff, TimelineDiff, _clip_identity
|
||||
from fcpxml.models import Clip, Timecode, TimeValue
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
# ============================================================================
|
||||
# TimeValue boundary arithmetic
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestTimeValueBoundaries:
|
||||
"""Catch real bugs in rational time math at the edges."""
|
||||
|
||||
def test_subtraction_produces_negative_time(self):
|
||||
"""Negative results from subtraction must not silently corrupt offsets."""
|
||||
a = TimeValue(10, 30)
|
||||
b = TimeValue(20, 30)
|
||||
result = a - b
|
||||
assert result.numerator == -10
|
||||
assert result.to_seconds() < 0, "Negative time should report negative seconds"
|
||||
|
||||
def test_zero_denominator_rejected_at_construction(self):
|
||||
"""Zero denominator must be caught at construction, not silently propagated."""
|
||||
with pytest.raises(ValueError, match="denominator cannot be zero"):
|
||||
TimeValue(100, 0)
|
||||
|
||||
def test_zero_denominator_rejected_even_with_zero_numerator(self):
|
||||
"""TimeValue(0, 0) is still invalid — use TimeValue(0, 1) for zero time."""
|
||||
with pytest.raises(ValueError, match="denominator cannot be zero"):
|
||||
TimeValue(0, 0)
|
||||
|
||||
def test_snap_to_frame_rejects_zero_fps(self):
|
||||
"""snap_to_frame(0) would cause ZeroDivisionError without the guard."""
|
||||
tv = TimeValue(100, 2400)
|
||||
with pytest.raises(ValueError, match="fps must be positive"):
|
||||
tv.snap_to_frame(0)
|
||||
|
||||
def test_snap_to_frame_rejects_negative_fps(self):
|
||||
tv = TimeValue(100, 2400)
|
||||
with pytest.raises(ValueError, match="fps must be positive"):
|
||||
tv.snap_to_frame(-24)
|
||||
|
||||
def test_multiply_by_zero_yields_zero_time(self):
|
||||
tv = TimeValue(90, 30)
|
||||
result = tv * 0
|
||||
assert result.to_seconds() == 0.0
|
||||
|
||||
def test_divide_by_zero_raises(self):
|
||||
"""Division by zero scalar must raise ZeroDivisionError, not corrupt silently."""
|
||||
tv = TimeValue(90, 30)
|
||||
with pytest.raises(ZeroDivisionError, match="Cannot divide TimeValue by zero"):
|
||||
tv / 0
|
||||
|
||||
def test_from_timecode_zero_denominator_rational(self):
|
||||
"""Parsing '10/0s' must raise, not silently produce bad TimeValue."""
|
||||
with pytest.raises(ValueError, match="Zero denominator"):
|
||||
TimeValue.from_timecode("10/0s")
|
||||
|
||||
def test_to_fcpxml_preserves_non_standard_denominator(self):
|
||||
"""Non-standard denominators (e.g., 7) fall back to unsimplified form."""
|
||||
tv = TimeValue(14, 7) # 2 seconds, but 7 isn't a standard timebase
|
||||
result = tv.to_fcpxml()
|
||||
assert result == "2s" # Simplifies to 2/1, denominator=1 → "2s"
|
||||
|
||||
def test_to_fcpxml_non_standard_no_simplify_to_whole(self):
|
||||
"""Non-standard denominator that doesn't simplify to 1 keeps original."""
|
||||
tv = TimeValue(10, 7) # 10/7 ≈ 1.43s, can't simplify to standard
|
||||
result = tv.to_fcpxml()
|
||||
assert result == "10/7s" # Falls back to unsimplified
|
||||
|
||||
def test_simplify_negative_numerator(self):
|
||||
"""Simplify handles negative numerators correctly via abs()."""
|
||||
tv = TimeValue(-60, 30)
|
||||
simplified = tv.simplify()
|
||||
assert simplified.numerator == -2
|
||||
assert simplified.denominator == 1
|
||||
|
||||
def test_equality_exact_rational(self):
|
||||
"""Equivalent fractions are equal via cross-multiplication."""
|
||||
a = TimeValue(600, 2400) # 0.25s
|
||||
b = TimeValue(300, 1200) # 0.25s — same value, different representation
|
||||
assert a == b
|
||||
|
||||
def test_equality_different_values(self):
|
||||
"""Genuinely different rational values are NOT equal."""
|
||||
a = TimeValue(30000, 30000) # Exactly 1.0s
|
||||
b = TimeValue(10000, 10001) # 0.99990001s — different value
|
||||
assert a != b
|
||||
|
||||
def test_equality_beyond_tolerance(self):
|
||||
"""TimeValues differing by any amount are NOT equal."""
|
||||
a = TimeValue(1000, 1000) # 1.0s
|
||||
b = TimeValue(999, 1000) # 0.999s
|
||||
assert a != b
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Timecode edge cases
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestTimecodeEdges:
|
||||
"""Edge cases in legacy Timecode wrapper."""
|
||||
|
||||
def test_zero_frame_rate_seconds(self):
|
||||
"""Timecode with frame_rate=0 would crash on .seconds — document behavior."""
|
||||
tc = Timecode(frames=100, frame_rate=0)
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
_ = tc.seconds
|
||||
|
||||
def test_negative_frames_smpte(self):
|
||||
"""Negative frame count should not crash to_smpte — just produce odd output."""
|
||||
tc = Timecode(frames=-1, frame_rate=24.0)
|
||||
# Should not raise; output may be unusual but shouldn't crash
|
||||
result = tc.to_smpte()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Clip index collision (duplicate names)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
DUPLICATE_NAMES_FCPXML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Interview" src="file:///a.mov" start="0s" duration="100s" format="r1"/>
|
||||
</resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="360/24s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Interview" start="0s" duration="120/24s" format="r1"/>
|
||||
<asset-clip ref="r2" offset="120/24s" name="Interview" start="30s" duration="120/24s" format="r1"/>
|
||||
<asset-clip ref="r2" offset="240/24s" name="Interview" start="60s" duration="120/24s" format="r1"/>
|
||||
</spine>
|
||||
</sequence></project></event></library></fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestClipIndexCollision:
|
||||
"""When multiple clips share a name, only the LAST one is indexed.
|
||||
|
||||
This is a known limitation documented in MEMORY.md — these tests pin
|
||||
the behavior so regressions are caught if the indexing strategy changes.
|
||||
"""
|
||||
|
||||
def test_duplicate_name_last_wins(self):
|
||||
"""_build_clip_index stores last occurrence for duplicate names."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(DUPLICATE_NAMES_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
clip = mod.clips.get("Interview")
|
||||
assert clip is not None
|
||||
# Last clip starts at source offset 60s
|
||||
assert clip.get("start") == "60s"
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_add_marker_hits_last_duplicate(self):
|
||||
"""add_marker on duplicate name affects last indexed clip, not first."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(DUPLICATE_NAMES_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
result = mod.add_marker("Interview", "1/24s", "Test Marker")
|
||||
assert result is not None
|
||||
# Marker should be on the LAST Interview clip (start=60s)
|
||||
clip = mod.clips["Interview"]
|
||||
markers = [c for c in clip if c.tag == "marker"]
|
||||
assert len(markers) == 1
|
||||
assert markers[0].get("value") == "Test Marker"
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# split_clip boundary behavior
|
||||
# ============================================================================
|
||||
|
||||
|
||||
SPLIT_FCPXML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="100s" format="r1"/>
|
||||
</resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="120/24s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1"/>
|
||||
</spine>
|
||||
</sequence></project></event></library></fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestSplitClipBoundaries:
|
||||
"""split_clip at edge positions — verify zero-duration segments are skipped."""
|
||||
|
||||
def test_split_at_start_skips_zero_duration_segment(self):
|
||||
"""Splitting at offset=0 should skip the zero-duration first segment."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(SPLIT_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
# Split at the very beginning (0 frames into clip)
|
||||
result = mod.split_clip("A", ["0/24s"])
|
||||
# Zero-duration segment should be skipped → only 1 clip remains
|
||||
assert len(result) == 1
|
||||
# Duration preserved (may be simplified by to_fcpxml)
|
||||
dur_seconds = TimeValue.from_timecode(result[0].get("duration")).to_seconds()
|
||||
assert dur_seconds == pytest.approx(5.0, abs=0.05)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_split_at_end_skips_zero_duration_tail(self):
|
||||
"""Splitting at the clip's full duration produces no zero-duration tail."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(SPLIT_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
result = mod.split_clip("A", ["120/24s"])
|
||||
# Full duration split → first segment = full clip, tail = 0 → skipped
|
||||
assert len(result) == 1
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_split_midpoint_produces_two_clips(self):
|
||||
"""Normal midpoint split produces exactly two clips with correct offsets."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(SPLIT_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
result = mod.split_clip("A", ["60/24s"])
|
||||
assert len(result) == 2
|
||||
assert result[0].get("duration") == "60/24s"
|
||||
assert result[1].get("duration") == "60/24s"
|
||||
# Offsets must be contiguous
|
||||
assert result[0].get("offset") == "0s"
|
||||
assert result[1].get("offset") == "60/24s"
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Split clip child-element filtering (markers, keywords)
|
||||
# ============================================================================
|
||||
|
||||
SPLIT_MARKER_FCPXML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="100s" format="r1"/>
|
||||
</resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="120/24s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="A" start="10s" duration="120/24s" format="r1">
|
||||
<marker start="11s" duration="1/24s" value="Early mark"/>
|
||||
<marker start="13s" duration="1/24s" value="Late mark"/>
|
||||
<keyword start="10s" duration="120/24s" value="Interview"/>
|
||||
</asset-clip>
|
||||
</spine>
|
||||
</sequence></project></event></library></fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestSplitClipChildFiltering:
|
||||
"""split_clip must filter markers/keywords so they only appear on the
|
||||
segment whose source range actually contains them."""
|
||||
|
||||
def test_markers_land_on_correct_segment(self):
|
||||
"""Markers must appear only on the segment whose source range covers them."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(SPLIT_MARKER_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
# Clip starts at source 10s, duration 120/24s = 5s → source range [10s, 15s)
|
||||
# Split at 60/24s = 2.5s into clip → source split at 12.5s
|
||||
# Segment 0: source [10s, 12.5s) — "Early mark" at 11s ✓, "Late mark" at 13s ✗
|
||||
# Segment 1: source [12.5s, 15s) — "Early mark" at 11s ✗, "Late mark" at 13s ✓
|
||||
result = mod.split_clip("A", ["60/24s"])
|
||||
assert len(result) == 2
|
||||
|
||||
seg0_markers = [c for c in result[0] if c.tag == 'marker']
|
||||
seg1_markers = [c for c in result[1] if c.tag == 'marker']
|
||||
|
||||
assert len(seg0_markers) == 1
|
||||
assert seg0_markers[0].get('value') == 'Early mark'
|
||||
|
||||
assert len(seg1_markers) == 1
|
||||
assert seg1_markers[0].get('value') == 'Late mark'
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_keyword_clamped_to_segment_boundaries(self):
|
||||
"""Keywords spanning the full clip get clamped to each segment's range."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(SPLIT_MARKER_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
result = mod.split_clip("A", ["60/24s"])
|
||||
assert len(result) == 2
|
||||
|
||||
seg0_kw = [c for c in result[0] if c.tag == 'keyword']
|
||||
seg1_kw = [c for c in result[1] if c.tag == 'keyword']
|
||||
|
||||
# Both segments should have the keyword (it spans the whole clip)
|
||||
assert len(seg0_kw) == 1
|
||||
assert len(seg1_kw) == 1
|
||||
|
||||
# Segment 0: keyword clamped to [10s, 12.5s) → duration = 60/24s
|
||||
seg0_dur = TimeValue.from_timecode(seg0_kw[0].get('duration'))
|
||||
assert seg0_dur.to_seconds() == pytest.approx(2.5, abs=0.05)
|
||||
|
||||
# Segment 1: keyword clamped to [12.5s, 15s) → duration = 60/24s
|
||||
seg1_dur = TimeValue.from_timecode(seg1_kw[0].get('duration'))
|
||||
assert seg1_dur.to_seconds() == pytest.approx(2.5, abs=0.05)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_marker_outside_all_segments_removed(self):
|
||||
"""A marker exactly at the segment boundary end is excluded."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
# Marker at source 15s, clip source range [10s, 15s) — at boundary = excluded
|
||||
xml = SPLIT_MARKER_FCPXML.replace(
|
||||
'<marker start="13s"', '<marker start="15s"'
|
||||
)
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
result = mod.split_clip("A", ["60/24s"])
|
||||
# Marker at 15s is at clip end, should not appear on any segment
|
||||
all_markers = []
|
||||
for seg in result:
|
||||
all_markers.extend(c for c in seg if c.tag == 'marker')
|
||||
# Only "Early mark" at 11s should survive
|
||||
assert len(all_markers) == 1
|
||||
assert all_markers[0].get('value') == 'Early mark'
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Diff identity rounding
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestDiffIdentityRounding:
|
||||
"""_clip_identity rounds source_start to 0.01s — collisions are possible."""
|
||||
|
||||
def _make_clip(self, name: str, source_start_seconds: float) -> Clip:
|
||||
return Clip(
|
||||
name=name,
|
||||
start=Timecode(frames=0, frame_rate=24),
|
||||
duration=Timecode(frames=120, frame_rate=24),
|
||||
source_start=Timecode(
|
||||
frames=int(round(source_start_seconds * 24)),
|
||||
frame_rate=24,
|
||||
),
|
||||
)
|
||||
|
||||
def test_same_name_different_source_start_distinct(self):
|
||||
"""Clips one full frame apart (0.042s at 24fps) are distinct identities."""
|
||||
a = self._make_clip("Shot", 1.00)
|
||||
b = self._make_clip("Shot", 1.05) # 1.05*24=25.2→25 frames→1.04s
|
||||
assert _clip_identity(a) != _clip_identity(b)
|
||||
|
||||
def test_rounding_collision_within_half_cent(self):
|
||||
"""Clips 0.004s apart may collide after rounding to 0.01s."""
|
||||
a = self._make_clip("Shot", 1.004)
|
||||
b = self._make_clip("Shot", 1.006)
|
||||
# Both round to 1.00 or 1.01 depending on frame quantization at 24fps
|
||||
id_a = _clip_identity(a)
|
||||
id_b = _clip_identity(b)
|
||||
# At 24fps: 1.004*24=24.096→24 frames→1.0s, 1.006*24=24.144→24 frames→1.0s
|
||||
# Both map to source_start=1.0 → collision
|
||||
assert id_a == id_b, "Rounding collision is expected at sub-frame precision"
|
||||
|
||||
def test_timeline_diff_total_changes_excludes_unchanged(self):
|
||||
"""total_changes property skips 'unchanged' diffs."""
|
||||
diff = TimelineDiff(
|
||||
timeline_a_name="A",
|
||||
timeline_b_name="B",
|
||||
clip_diffs=[
|
||||
ClipDiff(action="unchanged", clip_name="C1"),
|
||||
ClipDiff(action="moved", clip_name="C2"),
|
||||
ClipDiff(action="added", clip_name="C3"),
|
||||
],
|
||||
)
|
||||
assert diff.total_changes == 2
|
||||
assert diff.has_changes is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# _filter_children_for_segment (direct unit tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestFilterChildrenForSegment:
|
||||
"""Direct unit tests for _filter_children_for_segment in isolation."""
|
||||
|
||||
@staticmethod
|
||||
def _make_clip_with_children(*children_specs):
|
||||
"""Build a minimal <asset-clip>. Each spec is (tag, attrs_dict)."""
|
||||
clip = ET.Element('asset-clip')
|
||||
clip.set('start', '0s')
|
||||
clip.set('duration', '240/24s') # 10s
|
||||
for tag, attrs in children_specs:
|
||||
child = ET.SubElement(clip, tag)
|
||||
for k, v in attrs.items():
|
||||
child.set(k, v)
|
||||
return clip
|
||||
|
||||
def test_chapter_marker_filtered_same_as_regular_marker(self):
|
||||
"""chapter-marker elements must be filtered identically to markers."""
|
||||
clip = self._make_clip_with_children(
|
||||
('chapter-marker', {'start': '1s', 'duration': '1/24s', 'value': 'Ch1'}),
|
||||
('chapter-marker', {'start': '8s', 'duration': '1/24s', 'value': 'Ch2'}),
|
||||
)
|
||||
seg_start = TimeValue(0, 1) # 0s
|
||||
seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s)
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
remaining = [c for c in clip if c.tag == 'chapter-marker']
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].get('value') == 'Ch1'
|
||||
|
||||
def test_keyword_completely_before_segment_removed(self):
|
||||
"""Keyword ending before segment start is removed entirely."""
|
||||
clip = self._make_clip_with_children(
|
||||
('keyword', {'start': '0s', 'duration': '48/24s', 'value': 'Early'}), # 0-2s
|
||||
)
|
||||
seg_start = TimeValue(120, 24) # 5s
|
||||
seg_dur = TimeValue(120, 24) # 5s → segment [5s, 10s)
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
assert len([c for c in clip if c.tag == 'keyword']) == 0
|
||||
|
||||
def test_keyword_completely_after_segment_removed(self):
|
||||
"""Keyword starting at or after segment end is removed."""
|
||||
clip = self._make_clip_with_children(
|
||||
('keyword', {'start': '6s', 'duration': '48/24s', 'value': 'Late'}), # 6-8s
|
||||
)
|
||||
seg_start = TimeValue(0, 1) # 0s
|
||||
seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s)
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
assert len([c for c in clip if c.tag == 'keyword']) == 0
|
||||
|
||||
def test_keyword_zero_duration_inside_segment_kept(self):
|
||||
"""Zero-duration keyword at a point inside segment survives filter."""
|
||||
clip = self._make_clip_with_children(
|
||||
('keyword', {'start': '3s', 'duration': '0s', 'value': 'Point'}),
|
||||
)
|
||||
seg_start = TimeValue(0, 1)
|
||||
seg_dur = TimeValue(120, 24) # 5s
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
remaining = [c for c in clip if c.tag == 'keyword']
|
||||
assert len(remaining) == 1
|
||||
|
||||
def test_keyword_zero_duration_at_segment_end_removed(self):
|
||||
"""Zero-duration keyword exactly at segment end boundary is removed."""
|
||||
clip = self._make_clip_with_children(
|
||||
('keyword', {'start': '5s', 'duration': '0s', 'value': 'Boundary'}),
|
||||
)
|
||||
seg_start = TimeValue(0, 1)
|
||||
seg_dur = TimeValue(120, 24) # 5s → segment [0s, 5s)
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
# kw_start(5s) >= seg_end(5s) → removed
|
||||
assert len([c for c in clip if c.tag == 'keyword']) == 0
|
||||
|
||||
def test_non_marker_children_preserved(self):
|
||||
"""Elements other than marker/keyword/chapter-marker are untouched."""
|
||||
clip = self._make_clip_with_children(
|
||||
('conform-rate', {'scaleEnabled': '1'}),
|
||||
('marker', {'start': '99s', 'duration': '1/24s', 'value': 'Outside'}),
|
||||
)
|
||||
seg_start = TimeValue(0, 1)
|
||||
seg_dur = TimeValue(24, 24) # 1s
|
||||
FCPXMLModifier._filter_children_for_segment(clip, seg_start, seg_dur)
|
||||
|
||||
# Marker at 99s removed, but conform-rate must survive
|
||||
assert len([c for c in clip if c.tag == 'conform-rate']) == 1
|
||||
assert len([c for c in clip if c.tag == 'marker']) == 0
|
||||
|
||||
def test_keyword_partial_overlap_clamped_to_segment(self):
|
||||
"""Keyword [2s,8s) in segment [5s,10s) → clamped to [5s,8s)."""
|
||||
clip = self._make_clip_with_children(
|
||||
('keyword', {'start': '2s', 'duration': '6s', 'value': 'Wide'}),
|
||||
)
|
||||
FCPXMLModifier._filter_children_for_segment(
|
||||
clip, TimeValue(120, 24), TimeValue(120, 24)) # seg [5s, 10s)
|
||||
|
||||
kw = [c for c in clip if c.tag == 'keyword'][0]
|
||||
assert TimeValue.from_timecode(kw.get('start')).to_seconds() == pytest.approx(5.0, abs=0.01)
|
||||
assert TimeValue.from_timecode(kw.get('duration')).to_seconds() == pytest.approx(3.0, abs=0.01)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Multi-point split with distributed children
|
||||
# ============================================================================
|
||||
|
||||
|
||||
MULTI_SPLIT_FCPXML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="100s" format="r1"/>
|
||||
</resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="240/24s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">
|
||||
<marker start="1s" duration="1/24s" value="M1"/>
|
||||
<marker start="4s" duration="1/24s" value="M2"/>
|
||||
<marker start="8s" duration="1/24s" value="M3"/>
|
||||
<keyword start="0s" duration="10s" value="FullSpan"/>
|
||||
</asset-clip>
|
||||
</spine>
|
||||
</sequence></project></event></library></fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestMultiPointSplit:
|
||||
"""Splitting at 3+ points: marker distribution + keyword clamping."""
|
||||
|
||||
def test_three_way_split_markers_and_keywords(self):
|
||||
"""Each marker lands on exactly one segment; keyword spans all three."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(MULTI_SPLIT_FCPXML)
|
||||
f.flush()
|
||||
try:
|
||||
mod = FCPXMLModifier(f.name)
|
||||
# Split at 3s and 6s into a 10s clip → [0-3), [3-6), [6-10)
|
||||
result = mod.split_clip("A", ["72/24s", "144/24s"])
|
||||
assert len(result) == 3
|
||||
|
||||
# Markers: M1@1s→seg0, M2@4s→seg1, M3@8s→seg2
|
||||
markers = [[c.get('value') for c in s if c.tag == 'marker'] for s in result]
|
||||
assert markers[0] == ['M1']
|
||||
assert markers[1] == ['M2']
|
||||
assert markers[2] == ['M3']
|
||||
|
||||
# Full-span keyword appears on all segments, durations sum to 10s
|
||||
total_kw_dur = sum(
|
||||
TimeValue.from_timecode(
|
||||
[c for c in seg if c.tag == 'keyword'][0].get('duration')
|
||||
).to_seconds()
|
||||
for seg in result
|
||||
)
|
||||
assert total_kw_dur == pytest.approx(10.0, abs=0.05)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TimeValue division edge cases
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestTimeValueDivision:
|
||||
"""Division edge cases for speed-change and time arithmetic."""
|
||||
|
||||
def test_negative_scalar_flips_playback_direction(self):
|
||||
"""Dividing by negative scalar normalizes sign onto numerator."""
|
||||
tv = TimeValue(100, 30)
|
||||
result = tv / -2
|
||||
# Denominator normalized to positive; sign moves to numerator
|
||||
assert result.numerator == -100
|
||||
assert result.denominator == 60
|
||||
# Seconds should be negative (reverse playback)
|
||||
assert result.to_seconds() < 0
|
||||
|
||||
def test_very_small_scalar_rounds_denom_to_zero_raises(self):
|
||||
"""When scalar is so small that denom * scalar rounds to 0, raise."""
|
||||
# 24 * 0.01 = 0.24 → rounds to 0 → ZeroDivisionError
|
||||
with pytest.raises(ZeroDivisionError, match="rounds denominator.*to zero"):
|
||||
TimeValue(100, 24) / 0.01
|
||||
|
||||
def test_division_mul_roundtrip(self):
|
||||
"""(tv / k) * k ≈ tv for non-pathological scalars."""
|
||||
tv = TimeValue(120, 24)
|
||||
k = 3.0
|
||||
result = (tv / k) * k
|
||||
assert result.to_seconds() == pytest.approx(tv.to_seconds(), abs=0.01)
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
"""Tests for fcpxml/export.py — DaVinci Resolve and XMEML export.
|
||||
|
||||
Covers: attribute stripping, compound clip flattening, audio track
|
||||
generation, lane mapping, file path handling, no-timeline error,
|
||||
doctype injection, NTSC detection.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.export import DaVinciExporter
|
||||
from fcpxml.safe_xml import serialize_xml
|
||||
|
||||
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:///Media/a.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
||||
<asset id="r3" name="Clip_B" src="file:///Media/b.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Export Test" modDate="2025-01-15 10:30:00 -0500">
|
||||
<sequence format="r1" duration="480/24s" tcStart="0s" tcFormat="NDF" audioLayout="stereo" audioRate="48k">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s" format="r1"/>
|
||||
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s" format="r1"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
CONNECTED_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="Main" src="file:///Media/main.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
||||
<asset id="r3" name="Broll" src="file:///Media/broll.mov" start="0s" duration="120/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
||||
<asset id="r4" name="VO" src="file:///Media/vo.mov" start="0s" duration="120/24s" hasVideo="0" hasAudio="1" format="r1"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Connected Test">
|
||||
<sequence format="r1" duration="240/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Main" start="0s" duration="240/24s" format="r1" lane="0">
|
||||
<asset-clip ref="r3" lane="1" offset="48/24s" name="Broll" start="0s" duration="120/24s"/>
|
||||
<asset-clip ref="r4" lane="-1" offset="0s" name="VO" start="0s" duration="120/24s"/>
|
||||
</asset-clip>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
COMPOUND_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" hasVideo="1" hasAudio="1" format="r1"/>
|
||||
<media id="r5" name="Compound">
|
||||
<sequence format="r1" duration="240/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</media>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Compound Test">
|
||||
<sequence format="r1" duration="240/24s">
|
||||
<spine>
|
||||
<ref-clip ref="r5" offset="0s" name="Compound" duration="240/24s" srcEnable="all"/>
|
||||
</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
|
||||
|
||||
|
||||
def _out(suffix='.fcpxml') -> str:
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
class TestAttributeStripping:
|
||||
"""Simplified FCPXML should strip Resolve-incompatible attributes."""
|
||||
|
||||
def test_moddate_removed(self):
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out)
|
||||
tree = ET.parse(out)
|
||||
for elem in tree.iter():
|
||||
assert 'modDate' not in elem.attrib
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
def test_version_downgraded_to_1_9(self):
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out)
|
||||
tree = ET.parse(out)
|
||||
assert tree.getroot().get('version') == '1.9'
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
def test_colorprocessing_stripped(self):
|
||||
xml = BASE_XML.replace('<fcpxml version="1.11">', '<fcpxml version="1.11" colorProcessing="wide">')
|
||||
src = _tmp(xml)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out)
|
||||
tree = ET.parse(out)
|
||||
assert 'colorProcessing' not in tree.getroot().attrib
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestCompoundClipFlattening:
|
||||
"""ref-clip → asset-clip conversion when flatten_compounds=True."""
|
||||
|
||||
def test_ref_clip_becomes_asset_clip(self):
|
||||
src = _tmp(COMPOUND_XML)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out, flatten_compounds=True)
|
||||
tree = ET.parse(out)
|
||||
ref_clips = tree.findall('.//ref-clip')
|
||||
asset_clips = tree.findall('.//asset-clip')
|
||||
assert len(ref_clips) == 0, "ref-clip should be converted"
|
||||
assert len(asset_clips) >= 1
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
def test_flatten_strips_compound_attrs(self):
|
||||
"""srcEnable and other compound-specific attrs should be removed."""
|
||||
src = _tmp(COMPOUND_XML)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out, flatten_compounds=True)
|
||||
tree = ET.parse(out)
|
||||
for clip in tree.findall('.//asset-clip'):
|
||||
assert 'srcEnable' not in clip.attrib
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
def test_no_flatten_preserves_ref_clip(self):
|
||||
src = _tmp(COMPOUND_XML)
|
||||
out = _out()
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_simplified_fcpxml(out, flatten_compounds=False)
|
||||
tree = ET.parse(out)
|
||||
ref_clips = tree.findall('.//ref-clip')
|
||||
assert len(ref_clips) >= 1, "ref-clip should be preserved"
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestXmemlAudioTracks:
|
||||
"""Audio tracks from connected clips with negative lanes."""
|
||||
|
||||
def test_audio_track_generated_from_negative_lane(self):
|
||||
src = _tmp(CONNECTED_XML)
|
||||
out = _out('.xml')
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_xmeml(out)
|
||||
tree = ET.parse(out)
|
||||
audio = tree.find('.//audio')
|
||||
assert audio is not None
|
||||
audio_tracks = audio.findall('track')
|
||||
assert len(audio_tracks) >= 1
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
def test_primary_storyline_audio_fallback(self):
|
||||
"""When no connected audio clips, primary storyline creates audio track."""
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out('.xml')
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_xmeml(out)
|
||||
tree = ET.parse(out)
|
||||
audio = tree.find('.//audio')
|
||||
tracks = audio.findall('track')
|
||||
assert len(tracks) >= 1
|
||||
clipitems = tracks[0].findall('clipitem')
|
||||
assert len(clipitems) == 2 # Both primary clips have audio
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestXmemlFilePathHandling:
|
||||
"""Clipitem file/pathurl elements from media_path."""
|
||||
|
||||
def test_clipitem_has_file_pathurl(self):
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out('.xml')
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_xmeml(out)
|
||||
tree = ET.parse(out)
|
||||
clipitems = tree.findall('.//clipitem')
|
||||
found_path = False
|
||||
for ci in clipitems:
|
||||
file_elem = ci.find('file')
|
||||
if file_elem is not None:
|
||||
pathurl = file_elem.find('pathurl')
|
||||
if pathurl is not None and pathurl.text:
|
||||
found_path = True
|
||||
break
|
||||
assert found_path, "At least one clipitem should have file/pathurl"
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestNoTimelineError:
|
||||
"""Export should fail cleanly when FCPXML has no timeline."""
|
||||
|
||||
NO_TL_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"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Empty"/>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
def test_xmeml_raises_on_no_timeline(self):
|
||||
src = _tmp(self.NO_TL_XML)
|
||||
out = _out('.xml')
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
with pytest.raises(ValueError, match="No timeline"):
|
||||
exp.export_xmeml(out)
|
||||
finally:
|
||||
os.unlink(src)
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestDoctypeInjection:
|
||||
"""serialize_xml should inject DOCTYPE correctly."""
|
||||
|
||||
def test_doctype_inserted(self):
|
||||
root = ET.Element('fcpxml')
|
||||
root.set('version', '1.9')
|
||||
out = _out()
|
||||
try:
|
||||
serialize_xml(root, out, '<!DOCTYPE fcpxml>')
|
||||
with open(out) as f:
|
||||
content = f.read()
|
||||
assert '<!DOCTYPE fcpxml>' in content
|
||||
assert '<?xml version="1.0" encoding="UTF-8"?>' in content
|
||||
finally:
|
||||
os.unlink(out)
|
||||
|
||||
def test_no_doctype(self):
|
||||
root = ET.Element('test')
|
||||
out = _out()
|
||||
try:
|
||||
serialize_xml(root, out, '')
|
||||
with open(out) as f:
|
||||
content = f.read()
|
||||
assert '<!DOCTYPE' not in content
|
||||
finally:
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
class TestNtscDetection:
|
||||
"""NTSC flag set correctly for various frame rates."""
|
||||
|
||||
def test_24fps_not_ntsc(self):
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out('.xml')
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_xmeml(out)
|
||||
tree = ET.parse(out)
|
||||
ntsc = tree.find('.//rate/ntsc')
|
||||
assert ntsc.text == 'FALSE'
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
Executable
+579
@@ -0,0 +1,579 @@
|
||||
"""Tests for FCPXMLWriter — FCPXML generation from Python objects."""
|
||||
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import (
|
||||
Clip,
|
||||
Keyword,
|
||||
Marker,
|
||||
MarkerType,
|
||||
Project,
|
||||
Timecode,
|
||||
Timeline,
|
||||
)
|
||||
from fcpxml.writer import FCPXMLWriter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fps():
|
||||
return 24.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writer():
|
||||
return FCPXMLWriter(version="1.11")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_clip(fps):
|
||||
"""A single clip at 0 frames, 48 frames (2s) duration."""
|
||||
return Clip(
|
||||
name="Interview_A",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/interview_a.mov",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_timeline(fps, basic_clip):
|
||||
"""Timeline with one clip, 2 seconds long."""
|
||||
return Timeline(
|
||||
name="Main Timeline",
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps,
|
||||
width=1920,
|
||||
height=1080,
|
||||
clips=[basic_clip],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_project(basic_timeline):
|
||||
return Project(
|
||||
name="Test Project",
|
||||
timelines=[basic_timeline],
|
||||
fcpxml_version="1.11",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Resource ID counter
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_next_resource_id_increments(writer):
|
||||
"""Resource IDs should increment: r1, r2, r3, …"""
|
||||
assert writer._next_resource_id() == "r1"
|
||||
assert writer._next_resource_id() == "r2"
|
||||
assert writer._next_resource_id() == "r3"
|
||||
|
||||
|
||||
def test_generate_uid_unique(writer):
|
||||
"""Each UID should be unique."""
|
||||
uids = {writer._generate_uid() for _ in range(50)}
|
||||
assert len(uids) == 50
|
||||
|
||||
|
||||
def test_generate_uid_uppercase(writer):
|
||||
"""UIDs should be uppercase hex."""
|
||||
uid = writer._generate_uid()
|
||||
assert uid == uid.upper()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Timecode → rational string
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_tc_to_rational_basic(writer, fps):
|
||||
"""48 frames at 24fps → '48/24s'."""
|
||||
tc = Timecode(frames=48, frame_rate=fps)
|
||||
assert writer._tc_to_rational(tc) == "48/24s"
|
||||
|
||||
|
||||
def test_tc_to_rational_zero(writer, fps):
|
||||
"""0 frames → '0/24s'."""
|
||||
tc = Timecode(frames=0, frame_rate=fps)
|
||||
assert writer._tc_to_rational(tc) == "0/24s"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# write_project — file output
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_write_project_creates_file(writer, basic_project):
|
||||
"""write_project should create a file on disk."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
writer.write_project(basic_project, path)
|
||||
assert Path(path).exists()
|
||||
assert Path(path).stat().st_size > 0
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_write_project_xml_declaration(writer, basic_project):
|
||||
"""Output should start with XML declaration and DOCTYPE."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
writer.write_project(basic_project, path)
|
||||
content = Path(path).read_text()
|
||||
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
assert "<!DOCTYPE fcpxml>" in content
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_write_project_valid_xml(writer, basic_project):
|
||||
"""Output should be parseable XML."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
writer.write_project(basic_project, path)
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
assert root.tag == "fcpxml"
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_fcpxml — structure tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_build_fcpxml_root_version(writer, basic_project):
|
||||
"""Root element should have the correct version."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
assert root.get("version") == "1.11"
|
||||
|
||||
|
||||
def test_build_fcpxml_has_resources(writer, basic_project):
|
||||
"""FCPXML should contain a <resources> element."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
resources = root.find("resources")
|
||||
assert resources is not None
|
||||
|
||||
|
||||
def test_build_fcpxml_has_format_resource(writer, basic_project):
|
||||
"""Resources should include a format element matching timeline specs."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
fmt = root.find(".//format")
|
||||
assert fmt is not None
|
||||
assert fmt.get("width") == "1920"
|
||||
assert fmt.get("height") == "1080"
|
||||
assert fmt.get("frameDuration") == "1/24s"
|
||||
|
||||
|
||||
def test_build_fcpxml_has_library(writer, basic_project):
|
||||
"""FCPXML should contain a <library> element."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
library = root.find("library")
|
||||
assert library is not None
|
||||
assert "Test Project" in library.get("location", "")
|
||||
|
||||
|
||||
def test_build_fcpxml_has_event(writer, basic_project):
|
||||
"""Library should contain an <event> with the project name."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
event = root.find(".//event")
|
||||
assert event is not None
|
||||
assert event.get("name") == "Test Project"
|
||||
assert event.get("uid") is not None
|
||||
|
||||
|
||||
def test_build_fcpxml_has_project_element(writer, basic_project):
|
||||
"""Event should contain a <project> element."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
proj = root.find(".//project")
|
||||
assert proj is not None
|
||||
assert proj.get("name") == "Main Timeline"
|
||||
|
||||
|
||||
def test_build_fcpxml_has_sequence(writer, basic_project):
|
||||
"""Project should contain a <sequence> element."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
seq = root.find(".//sequence")
|
||||
assert seq is not None
|
||||
assert seq.get("tcFormat") == "NDF"
|
||||
assert seq.get("audioLayout") == "stereo"
|
||||
assert seq.get("audioRate") == "48k"
|
||||
|
||||
|
||||
def test_build_fcpxml_has_spine(writer, basic_project):
|
||||
"""Sequence should contain a <spine> element."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
spine = root.find(".//spine")
|
||||
assert spine is not None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Clip generation
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_clip_in_spine(writer, basic_project):
|
||||
"""Spine should contain the clip as an <asset-clip>."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
clips = root.findall(".//asset-clip")
|
||||
assert len(clips) == 1
|
||||
assert clips[0].get("name") == "Interview_A"
|
||||
|
||||
|
||||
def test_clip_has_ref(writer, basic_project):
|
||||
"""Asset-clip should reference an asset resource."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
clip = root.find(".//asset-clip")
|
||||
ref = clip.get("ref")
|
||||
# Verify the referenced asset exists
|
||||
asset = root.find(f".//asset[@id='{ref}']")
|
||||
assert asset is not None
|
||||
assert asset.get("name") == "Interview_A"
|
||||
|
||||
|
||||
def test_clip_duration(writer, basic_project):
|
||||
"""Clip should have correct duration in rational format."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
clip = root.find(".//asset-clip")
|
||||
assert clip.get("duration") == "48/24s"
|
||||
|
||||
|
||||
def test_clip_offset(writer, basic_project):
|
||||
"""First clip offset should be its start timecode in rational format."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
clip = root.find(".//asset-clip")
|
||||
assert clip.get("offset") == "0/24s"
|
||||
|
||||
|
||||
def test_asset_has_media_path(writer, basic_project):
|
||||
"""Asset resource should include the source media path."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
asset = root.find(".//asset")
|
||||
assert asset.get("src") == "file:///media/interview_a.mov"
|
||||
|
||||
|
||||
def test_asset_has_video_audio_flags(writer, basic_project):
|
||||
"""Asset should declare video and audio presence."""
|
||||
root = writer._build_fcpxml(basic_project)
|
||||
asset = root.find(".//asset")
|
||||
assert asset.get("hasVideo") == "1"
|
||||
assert asset.get("hasAudio") == "1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Multiple clips / shared media
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_multiple_clips(writer, fps):
|
||||
"""Multiple clips should all appear in the spine."""
|
||||
clips = [
|
||||
Clip(name=f"Clip_{i}", start=Timecode(frames=i * 48, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path=f"file:///media/clip_{i}.mov")
|
||||
for i in range(3)
|
||||
]
|
||||
tl = Timeline(name="Multi", duration=Timecode(frames=144, frame_rate=fps),
|
||||
frame_rate=fps, clips=clips)
|
||||
proj = Project(name="Multi", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
assert len(root.findall(".//asset-clip")) == 3
|
||||
assert len(root.findall(".//asset")) == 3
|
||||
|
||||
|
||||
def test_shared_media_single_asset(writer, fps):
|
||||
"""Two clips from the same media should share one asset resource."""
|
||||
shared_path = "file:///media/shared.mov"
|
||||
clips = [
|
||||
Clip(name="PartA", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=24, frame_rate=fps), media_path=shared_path),
|
||||
Clip(name="PartB", start=Timecode(frames=24, frame_rate=fps),
|
||||
duration=Timecode(frames=24, frame_rate=fps), media_path=shared_path),
|
||||
]
|
||||
tl = Timeline(name="Shared", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=clips)
|
||||
proj = Project(name="Shared", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
assets = root.findall(".//asset")
|
||||
assert len(assets) == 1 # deduplicated
|
||||
asset_clips = root.findall(".//asset-clip")
|
||||
assert len(asset_clips) == 2
|
||||
# Both clips reference the same asset
|
||||
refs = {ac.get("ref") for ac in asset_clips}
|
||||
assert len(refs) == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Markers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_clip_with_marker(writer, fps):
|
||||
"""A clip marker should appear as a child of the asset-clip element."""
|
||||
clip = Clip(
|
||||
name="Marked",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/marked.mov",
|
||||
markers=[Marker(name="Hit", start=Timecode(frames=12, frame_rate=fps))],
|
||||
)
|
||||
tl = Timeline(name="M", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="M", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
marker = root.find(".//asset-clip/marker")
|
||||
assert marker is not None
|
||||
assert marker.get("value") == "Hit"
|
||||
assert marker.get("start") == "12/24s"
|
||||
|
||||
|
||||
def test_chapter_marker(writer, fps):
|
||||
"""Chapter markers should use <chapter-marker> tag."""
|
||||
clip = Clip(
|
||||
name="Chaptered",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/ch.mov",
|
||||
markers=[Marker(
|
||||
name="Chapter 1", start=Timecode(frames=0, frame_rate=fps),
|
||||
marker_type=MarkerType.CHAPTER,
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="C", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="C", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
ch = root.find(".//chapter-marker")
|
||||
assert ch is not None
|
||||
assert ch.get("value") == "Chapter 1"
|
||||
|
||||
|
||||
def test_marker_with_note(writer, fps):
|
||||
"""Standard markers should include the note attribute when set."""
|
||||
clip = Clip(
|
||||
name="Noted",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/noted.mov",
|
||||
markers=[Marker(
|
||||
name="Review", start=Timecode(frames=6, frame_rate=fps),
|
||||
note="Fix audio",
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="N", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="N", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
marker = root.find(".//marker")
|
||||
assert marker.get("note") == "Fix audio"
|
||||
|
||||
|
||||
def test_chapter_marker_no_note(writer, fps):
|
||||
"""Chapter markers should NOT include a note attribute even if set."""
|
||||
clip = Clip(
|
||||
name="ChNoted",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/chnoted.mov",
|
||||
markers=[Marker(
|
||||
name="Ch", start=Timecode(frames=0, frame_rate=fps),
|
||||
marker_type=MarkerType.CHAPTER, note="ignored",
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="CN", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="CN", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
ch = root.find(".//chapter-marker")
|
||||
# chapter-marker check: the code only writes note for non-chapter tags
|
||||
assert ch.get("note") is None
|
||||
|
||||
|
||||
def test_todo_marker_completed_attr(writer, fps):
|
||||
"""INCOMPLETE markers must emit completed='0' — the only way FCP recognises them as open tasks."""
|
||||
clip = Clip(
|
||||
name="Todo", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/todo.mov",
|
||||
markers=[Marker(
|
||||
name="Fix color", start=Timecode(frames=6, frame_rate=fps),
|
||||
marker_type=MarkerType.INCOMPLETE,
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="T", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
root = writer._build_fcpxml(Project(name="T", timelines=[tl]))
|
||||
marker = root.find(".//asset-clip/marker")
|
||||
assert marker is not None
|
||||
assert marker.get("completed") == "0", "INCOMPLETE marker must have completed='0'"
|
||||
assert marker.get("value") == "Fix color"
|
||||
|
||||
|
||||
def test_completed_marker_completed_attr(writer, fps):
|
||||
"""COMPLETED markers must emit completed='1' to signal done state in FCP."""
|
||||
clip = Clip(
|
||||
name="Done", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/done.mov",
|
||||
markers=[Marker(
|
||||
name="Approved", start=Timecode(frames=6, frame_rate=fps),
|
||||
marker_type=MarkerType.COMPLETED,
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="D", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
root = writer._build_fcpxml(Project(name="D", timelines=[tl]))
|
||||
marker = root.find(".//asset-clip/marker")
|
||||
assert marker is not None
|
||||
assert marker.get("completed") == "1", "COMPLETED marker must have completed='1'"
|
||||
|
||||
|
||||
def test_standard_marker_no_completed_attr(writer, fps):
|
||||
"""STANDARD markers must NOT emit a completed attribute — it would confuse FCP."""
|
||||
clip = Clip(
|
||||
name="Std", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/std.mov",
|
||||
markers=[Marker(
|
||||
name="Note", start=Timecode(frames=6, frame_rate=fps),
|
||||
marker_type=MarkerType.STANDARD,
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="S", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
root = writer._build_fcpxml(Project(name="S", timelines=[tl]))
|
||||
marker = root.find(".//asset-clip/marker")
|
||||
assert marker is not None
|
||||
assert marker.get("completed") is None, "STANDARD marker must not have completed attr"
|
||||
|
||||
|
||||
def test_all_marker_types_on_one_clip(writer, fps):
|
||||
"""All four marker types on one clip — each gets correct tag and attributes."""
|
||||
markers = [
|
||||
Marker(name="Std", start=Timecode(frames=0, frame_rate=fps),
|
||||
marker_type=MarkerType.STANDARD),
|
||||
Marker(name="Task", start=Timecode(frames=6, frame_rate=fps),
|
||||
marker_type=MarkerType.INCOMPLETE),
|
||||
Marker(name="Done", start=Timecode(frames=12, frame_rate=fps),
|
||||
marker_type=MarkerType.COMPLETED),
|
||||
Marker(name="Ch1", start=Timecode(frames=18, frame_rate=fps),
|
||||
marker_type=MarkerType.CHAPTER),
|
||||
]
|
||||
clip = Clip(
|
||||
name="Multi", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/multi.mov", markers=markers,
|
||||
)
|
||||
tl = Timeline(name="A", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
root = writer._build_fcpxml(Project(name="A", timelines=[tl]))
|
||||
ac = root.find(".//asset-clip")
|
||||
plain_markers = ac.findall("marker")
|
||||
chapter_markers = ac.findall("chapter-marker")
|
||||
# 3 <marker> elements (standard + todo + completed), 1 <chapter-marker>
|
||||
assert len(plain_markers) == 3
|
||||
assert len(chapter_markers) == 1
|
||||
by_name = {m.get("value"): m for m in plain_markers}
|
||||
assert by_name["Std"].get("completed") is None
|
||||
assert by_name["Task"].get("completed") == "0"
|
||||
assert by_name["Done"].get("completed") == "1"
|
||||
assert chapter_markers[0].get("value") == "Ch1"
|
||||
|
||||
|
||||
def test_timeline_level_markers(writer, fps):
|
||||
"""Timeline-level markers should be children of the sequence element."""
|
||||
clip = Clip(
|
||||
name="C1", start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/c1.mov",
|
||||
)
|
||||
tl = Timeline(
|
||||
name="TL", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip],
|
||||
markers=[Marker(name="Section", start=Timecode(frames=24, frame_rate=fps))],
|
||||
)
|
||||
proj = Project(name="TL", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
seq = root.find(".//sequence")
|
||||
seq_markers = seq.findall("marker")
|
||||
assert len(seq_markers) == 1
|
||||
assert seq_markers[0].get("value") == "Section"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Keywords
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_clip_with_keyword(writer, fps):
|
||||
"""Keywords should appear as <keyword> children of the asset-clip."""
|
||||
clip = Clip(
|
||||
name="Tagged",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/tagged.mov",
|
||||
keywords=[Keyword(value="interview")],
|
||||
)
|
||||
tl = Timeline(name="K", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="K", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
kw = root.find(".//asset-clip/keyword")
|
||||
assert kw is not None
|
||||
assert kw.get("value") == "interview"
|
||||
|
||||
|
||||
def test_keyword_with_range(writer, fps):
|
||||
"""Keywords with start/duration should include those attributes."""
|
||||
clip = Clip(
|
||||
name="Ranged",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=48, frame_rate=fps),
|
||||
media_path="file:///media/ranged.mov",
|
||||
keywords=[Keyword(
|
||||
value="broll",
|
||||
start=Timecode(frames=0, frame_rate=fps),
|
||||
duration=Timecode(frames=24, frame_rate=fps),
|
||||
)],
|
||||
)
|
||||
tl = Timeline(name="KR", duration=Timecode(frames=48, frame_rate=fps),
|
||||
frame_rate=fps, clips=[clip])
|
||||
proj = Project(name="KR", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
kw = root.find(".//keyword")
|
||||
assert kw.get("start") == "0/24s"
|
||||
assert kw.get("duration") == "24/24s"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Empty / edge-case projects
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_empty_project_no_timelines(writer):
|
||||
"""A project with no timelines should still produce valid FCPXML."""
|
||||
proj = Project(name="Empty")
|
||||
root = writer._build_fcpxml(proj)
|
||||
assert root.tag == "fcpxml"
|
||||
assert root.find("library") is not None
|
||||
# No format resource needed
|
||||
assert root.find(".//sequence") is None
|
||||
|
||||
|
||||
def test_empty_timeline_no_clips(writer, fps):
|
||||
"""A timeline with zero clips should produce a spine with no children."""
|
||||
tl = Timeline(name="Blank", duration=Timecode(frames=0, frame_rate=fps),
|
||||
frame_rate=fps, clips=[])
|
||||
proj = Project(name="Blank", timelines=[tl])
|
||||
root = writer._build_fcpxml(proj)
|
||||
spine = root.find(".//spine")
|
||||
assert spine is not None
|
||||
assert len(list(spine)) == 0
|
||||
Executable
+876
@@ -0,0 +1,876 @@
|
||||
"""Tests for v0.5.0 features: connected clips, roles, diff, reformat, silence, export."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.diff import ClipDiff, MarkerDiff, TimelineDiff, compare_timelines
|
||||
from fcpxml.export import DaVinciExporter
|
||||
from fcpxml.models import (
|
||||
Clip,
|
||||
CompoundClip,
|
||||
ConnectedClip,
|
||||
SilenceCandidate,
|
||||
Timecode,
|
||||
Timeline,
|
||||
)
|
||||
from fcpxml.parser import FCPXMLParser
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
# ============================================================================
|
||||
# TEST FIXTURES
|
||||
# ============================================================================
|
||||
|
||||
SAMPLE = os.path.join(os.path.dirname(__file__), '..', 'examples', 'sample.fcpxml')
|
||||
|
||||
CONNECTED_CLIPS_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="Interview" src="file:///interview.mov" start="0s" duration="2400/24s" hasVideo="1" hasAudio="1"/>
|
||||
<asset id="r3" name="BRoll_City" src="file:///broll.mov" start="0s" duration="1200/24s" hasVideo="1" hasAudio="1"/>
|
||||
<asset id="r4" name="Music_BG" src="file:///music.mp3" start="0s" duration="4800/24s" hasVideo="0" hasAudio="1"/>
|
||||
<asset id="r5" name="Title_Intro" src="" start="0s" duration="120/24s" hasVideo="1" hasAudio="0"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test Event">
|
||||
<project name="Connected Clips Test">
|
||||
<sequence format="r1" duration="4800/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Interview" start="0s" duration="2400/24s" audioRole="dialogue">
|
||||
<marker start="240/24s" duration="1/24s" value="Good take"/>
|
||||
<keyword value="interview" start="0s" duration="2400/24s"/>
|
||||
<asset-clip ref="r3" lane="1" offset="240/24s" name="BRoll_City" start="0s" duration="480/24s" videoRole="b-roll"/>
|
||||
<asset-clip ref="r4" lane="-1" offset="0s" name="Music_BG" start="0s" duration="2400/24s" audioRole="music"/>
|
||||
</asset-clip>
|
||||
<gap offset="2400/24s" duration="120/24s">
|
||||
<asset-clip ref="r5" lane="1" offset="2400/24s" name="Title_Intro" start="0s" duration="120/24s"/>
|
||||
</gap>
|
||||
<asset-clip ref="r2" offset="2520/24s" name="Interview_2" start="2400/24s" duration="2280/24s" audioRole="dialogue" videoRole="video">
|
||||
<storyline lane="2">
|
||||
<asset-clip ref="r3" offset="0s" name="BRoll_Street" start="480/24s" duration="240/24s"/>
|
||||
</storyline>
|
||||
</asset-clip>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
|
||||
ROLES_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"/>
|
||||
<asset id="r4" name="Clip_C" src="file:///c.mov" start="0s" duration="240/24s"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Roles Test">
|
||||
<sequence format="r1" duration="720/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s" audioRole="dialogue"/>
|
||||
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s" audioRole="music"/>
|
||||
<asset-clip ref="r4" offset="480/24s" name="Clip_C" start="0s" duration="240/24s" audioRole="dialogue" videoRole="titles"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
|
||||
SILENCE_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p30" frameDuration="1/30s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Clip_Main" src="file:///main.mov" start="0s" duration="300/30s"/>
|
||||
<asset id="r3" name="Silence_Fill" src="file:///silence.mov" start="0s" duration="60/30s"/>
|
||||
<asset id="r4" name="Short" src="file:///short.mov" start="0s" duration="10/30s"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="Silence Test">
|
||||
<sequence format="r1" duration="670/30s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Clip_Main" start="0s" duration="300/30s"/>
|
||||
<gap offset="300/30s" duration="60/30s"/>
|
||||
<asset-clip ref="r3" offset="360/30s" name="Silence_Fill" start="0s" duration="60/30s"/>
|
||||
<asset-clip ref="r4" offset="420/30s" name="Short" start="0s" duration="10/30s"/>
|
||||
<asset-clip ref="r2" offset="430/30s" name="Clip_Main_2" start="0s" duration="240/30s"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
|
||||
|
||||
def _write_temp_fcpxml(xml_str: str) -> str:
|
||||
"""Write XML string to a temp file and return path."""
|
||||
fd, path = tempfile.mkstemp(suffix='.fcpxml')
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(xml_str)
|
||||
return path
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTED CLIPS TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestConnectedClips:
|
||||
|
||||
def test_parse_connected_clips_from_spine(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
assert tl is not None
|
||||
assert len(tl.connected_clips) > 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_connected_clip_has_lane(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
broll = [c for c in tl.connected_clips if c.name == "BRoll_City"]
|
||||
assert len(broll) == 1
|
||||
assert broll[0].lane == 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_connected_clip_negative_lane(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
music = [c for c in tl.connected_clips if c.name == "Music_BG"]
|
||||
assert len(music) == 1
|
||||
assert music[0].lane == -1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_connected_clips_on_parent_clip(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
interview = tl.clips[0]
|
||||
assert len(interview.connected_clips) == 2 # BRoll + Music
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_gap_connected_clips(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
title = [c for c in tl.connected_clips if c.name == "Title_Intro"]
|
||||
assert len(title) == 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_secondary_storyline_clips(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
street = [c for c in tl.connected_clips if c.name == "BRoll_Street"]
|
||||
assert len(street) == 1
|
||||
assert street[0].lane == 2
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_connected_clip_role(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
music = [c for c in tl.connected_clips if c.name == "Music_BG"]
|
||||
assert music[0].role == "music"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_connected_clip_parent_name(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
broll = [c for c in tl.connected_clips if c.name == "BRoll_City"]
|
||||
assert broll[0].parent_clip_name == "Interview"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_total_connected_clips_count(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
# BRoll_City, Music_BG on Interview
|
||||
# Title_Intro on gap
|
||||
# BRoll_Street in storyline on Interview_2
|
||||
assert len(tl.connected_clips) == 4
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_add_connected_clip(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
out = path.replace('.fcpxml', '_modified.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.add_connected_clip(
|
||||
parent_clip_id="Interview",
|
||||
asset_id="r3",
|
||||
offset="480/24s",
|
||||
duration="240/24s",
|
||||
lane=1,
|
||||
)
|
||||
modifier.save(out)
|
||||
|
||||
# Verify the connected clip was added
|
||||
tree = ET.parse(out)
|
||||
interview = tree.find('.//asset-clip[@name="Interview"]')
|
||||
connected = [c for c in interview if c.get('lane') == '1']
|
||||
assert len(connected) == 2 # Original BRoll_City + new one
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_connected_clip_model_duration_seconds(self):
|
||||
cc = ConnectedClip(
|
||||
name="Test", start=Timecode(0, 24.0),
|
||||
duration=Timecode(48, 24.0), lane=1,
|
||||
)
|
||||
assert cc.duration_seconds == 2.0
|
||||
|
||||
def test_chapter_markers_on_connected_clips(self):
|
||||
"""Chapter markers on connected clips must be parsed — not silently dropped."""
|
||||
from fcpxml.models import MarkerType
|
||||
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.11">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Main" src="main.mov" start="0s" duration="240/24s"/>
|
||||
<asset id="r3" name="Overlay" src="overlay.mov" start="0s" duration="120/24s"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="ChapterOnConnected">
|
||||
<sequence format="r1" duration="240/24s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Main" start="0s" duration="240/24s">
|
||||
<asset-clip ref="r3" lane="1" offset="24/24s" name="Overlay"
|
||||
start="0s" duration="120/24s">
|
||||
<chapter-marker start="0s" duration="1/24s" value="CH1"/>
|
||||
<marker start="48/24s" duration="1/24s" value="Note"/>
|
||||
</asset-clip>
|
||||
</asset-clip>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>"""
|
||||
path = _write_temp_fcpxml(xml)
|
||||
try:
|
||||
project = FCPXMLParser().parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
overlay = [c for c in tl.connected_clips if c.name == "Overlay"]
|
||||
assert len(overlay) == 1
|
||||
markers = overlay[0].markers
|
||||
assert len(markers) == 2
|
||||
by_name = {m.name: m.marker_type for m in markers}
|
||||
assert by_name["CH1"] == MarkerType.CHAPTER
|
||||
assert by_name["Note"] == MarkerType.STANDARD
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_compound_clip_model(self):
|
||||
cc = CompoundClip(
|
||||
name="Nested", ref_id="r5",
|
||||
duration=Timecode(120, 24.0),
|
||||
start=Timecode(0, 24.0),
|
||||
)
|
||||
assert cc.duration_seconds == 5.0
|
||||
assert cc.ref_id == "r5"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROLES TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestRoles:
|
||||
|
||||
def test_parse_audio_role(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
clip_a = tl.clips[0]
|
||||
assert clip_a.audio_role == "dialogue"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_parse_video_role(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
clip_c = tl.clips[2]
|
||||
assert clip_c.video_role == "titles"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_parse_no_role_is_empty(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
clip_a = tl.clips[0]
|
||||
assert clip_a.video_role == ""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_assign_role(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_role.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.assign_role("Clip_A", audio_role="effects")
|
||||
modifier.save(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
clip = tree.find('.//asset-clip[@name="Clip_A"]')
|
||||
assert clip.get('audioRole') == 'effects'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_assign_video_role(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_role.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.assign_role("Clip_B", video_role="titles")
|
||||
modifier.save(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
clip = tree.find('.//asset-clip[@name="Clip_B"]')
|
||||
assert clip.get('videoRole') == 'titles'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_assign_role_invalid_clip(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
with pytest.raises(ValueError, match="Clip not found"):
|
||||
modifier.assign_role("Nonexistent", audio_role="music")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_clip_role_fields_default_empty(self):
|
||||
clip = Clip(
|
||||
name="Test", start=Timecode(0, 24.0),
|
||||
duration=Timecode(24, 24.0),
|
||||
)
|
||||
assert clip.audio_role == ""
|
||||
assert clip.video_role == ""
|
||||
|
||||
def test_connected_clip_role_parsed(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
try:
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(path)
|
||||
tl = project.primary_timeline
|
||||
broll = [c for c in tl.connected_clips if c.name == "BRoll_City"]
|
||||
assert broll[0].role == "b-roll"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TIMELINE DIFF TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestTimelineDiff:
|
||||
|
||||
def test_identical_timelines_no_changes(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
try:
|
||||
diff = compare_timelines(path, path)
|
||||
assert not diff.has_changes
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_added_clip_detected(self):
|
||||
xml_a = ROLES_XML
|
||||
xml_b = ROLES_XML.replace('duration="720/24s"', 'duration="960/24s"')
|
||||
# Add a new clip to xml_b
|
||||
xml_b = xml_b.replace(
|
||||
'</spine>',
|
||||
'<asset-clip ref="r2" offset="720/24s" name="Clip_D" start="0s" duration="240/24s"/>\n</spine>'
|
||||
)
|
||||
path_a = _write_temp_fcpxml(xml_a)
|
||||
path_b = _write_temp_fcpxml(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(path_a, path_b)
|
||||
added = [d for d in diff.clip_diffs if d.action == "added"]
|
||||
assert len(added) >= 1
|
||||
assert any(d.clip_name == "Clip_D" for d in added)
|
||||
finally:
|
||||
os.unlink(path_a)
|
||||
os.unlink(path_b)
|
||||
|
||||
def test_removed_clip_detected(self):
|
||||
xml_a = ROLES_XML
|
||||
# Remove Clip_C from xml_b
|
||||
xml_b = xml_a.replace(
|
||||
'<asset-clip ref="r4" offset="480/24s" name="Clip_C" start="0s" duration="240/24s" audioRole="dialogue" videoRole="titles"/>',
|
||||
''
|
||||
)
|
||||
path_a = _write_temp_fcpxml(xml_a)
|
||||
path_b = _write_temp_fcpxml(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(path_a, path_b)
|
||||
removed = [d for d in diff.clip_diffs if d.action == "removed"]
|
||||
assert len(removed) >= 1
|
||||
assert any(d.clip_name == "Clip_C" for d in removed)
|
||||
finally:
|
||||
os.unlink(path_a)
|
||||
os.unlink(path_b)
|
||||
|
||||
def test_trimmed_clip_detected(self):
|
||||
xml_a = ROLES_XML
|
||||
xml_b = xml_a.replace(
|
||||
'name="Clip_A" start="0s" duration="240/24s"',
|
||||
'name="Clip_A" start="0s" duration="120/24s"'
|
||||
)
|
||||
path_a = _write_temp_fcpxml(xml_a)
|
||||
path_b = _write_temp_fcpxml(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(path_a, path_b)
|
||||
trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"]
|
||||
assert len(trimmed) >= 1
|
||||
finally:
|
||||
os.unlink(path_a)
|
||||
os.unlink(path_b)
|
||||
|
||||
def test_marker_added_detected(self):
|
||||
xml_a = ROLES_XML
|
||||
# Replace the self-closing Clip_A tag with one containing a marker
|
||||
xml_b = xml_a.replace(
|
||||
'<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s" audioRole="dialogue"/>',
|
||||
'<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s" audioRole="dialogue">'
|
||||
'<marker start="0s" duration="1/24s" value="New Marker"/>'
|
||||
'</asset-clip>'
|
||||
)
|
||||
path_a = _write_temp_fcpxml(xml_a)
|
||||
path_b = _write_temp_fcpxml(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(path_a, path_b)
|
||||
marker_added = [d for d in diff.marker_diffs if d.action == "added"]
|
||||
assert len(marker_added) >= 1
|
||||
finally:
|
||||
os.unlink(path_a)
|
||||
os.unlink(path_b)
|
||||
|
||||
def test_format_change_detected(self):
|
||||
xml_a = ROLES_XML
|
||||
xml_b = xml_a.replace('width="1920" height="1080"', 'width="1080" height="1920"')
|
||||
path_a = _write_temp_fcpxml(xml_a)
|
||||
path_b = _write_temp_fcpxml(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(path_a, path_b)
|
||||
assert len(diff.format_changes) >= 1
|
||||
assert "Resolution" in diff.format_changes[0]
|
||||
finally:
|
||||
os.unlink(path_a)
|
||||
os.unlink(path_b)
|
||||
|
||||
def test_diff_result_total_changes(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")],
|
||||
)
|
||||
assert diff.total_changes == 2
|
||||
|
||||
def test_diff_result_no_changes(self):
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
assert not diff.has_changes
|
||||
assert diff.total_changes == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SOCIAL MEDIA REFORMAT TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestReformat:
|
||||
|
||||
def test_reformat_9_16(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_reformat.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.reformat_resolution(1080, 1920)
|
||||
modifier.save(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
fmt = tree.find('.//format')
|
||||
assert fmt.get('width') == '1080'
|
||||
assert fmt.get('height') == '1920'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_reformat_1_1(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_reformat.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.reformat_resolution(1080, 1080)
|
||||
modifier.save(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
fmt = tree.find('.//format')
|
||||
assert fmt.get('width') == '1080'
|
||||
assert fmt.get('height') == '1080'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_reformat_preserves_clips(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_reformat.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.reformat_resolution(1080, 1350)
|
||||
modifier.save(out)
|
||||
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(out)
|
||||
tl = project.primary_timeline
|
||||
assert len(tl.clips) == 3 # Same clip count
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_reformat_preserves_frame_rate(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_reformat.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
modifier.reformat_resolution(1080, 1920)
|
||||
modifier.save(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
fmt = tree.find('.//format')
|
||||
assert 'frameDuration' in fmt.attrib
|
||||
assert fmt.get('frameDuration') == '1/24s'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_social_formats_dict(self):
|
||||
formats = FCPXMLModifier.SOCIAL_FORMATS
|
||||
assert formats["9:16"] == (1080, 1920)
|
||||
assert formats["1:1"] == (1080, 1080)
|
||||
assert formats["4:5"] == (1080, 1350)
|
||||
assert formats["16:9"] == (1920, 1080)
|
||||
assert formats["4:3"] == (1440, 1080)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SILENCE DETECTION TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestSilenceDetection:
|
||||
|
||||
def test_detect_gap_as_silence(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
candidates = modifier.detect_silence_candidates(min_gap_seconds=0.5)
|
||||
gaps = [c for c in candidates if c['reason'] == 'gap']
|
||||
assert len(gaps) >= 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_detect_name_match(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
candidates = modifier.detect_silence_candidates()
|
||||
name_matches = [c for c in candidates if c['reason'] == 'name_match']
|
||||
assert len(name_matches) >= 1
|
||||
assert any('Silence' in (c.get('clip_name') or '') for c in name_matches)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_detect_ultra_short(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
candidates = modifier.detect_silence_candidates()
|
||||
ultra_short = [c for c in candidates if c['reason'] == 'ultra_short']
|
||||
assert len(ultra_short) >= 1
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_confidence_filtering(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
all_candidates = modifier.detect_silence_candidates()
|
||||
high_conf = [c for c in all_candidates if c['confidence'] >= 0.8]
|
||||
assert len(high_conf) > 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_remove_silence_mark_mode(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
out = path.replace('.fcpxml', '_cleaned.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
actions = modifier.remove_silence_candidates(mode="mark", min_confidence=0.7)
|
||||
modifier.save(out)
|
||||
assert len(actions) > 0
|
||||
assert all(a['action'] == 'marked' for a in actions)
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_remove_silence_delete_mode(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
out = path.replace('.fcpxml', '_cleaned.fcpxml')
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
actions = modifier.remove_silence_candidates(mode="delete", min_confidence=0.8)
|
||||
modifier.save(out)
|
||||
# Should have deleted something
|
||||
assert len(actions) > 0
|
||||
assert all(a['action'] == 'deleted' for a in actions)
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_custom_patterns(self):
|
||||
path = _write_temp_fcpxml(SILENCE_XML)
|
||||
try:
|
||||
modifier = FCPXMLModifier(path)
|
||||
candidates = modifier.detect_silence_candidates(patterns=["nonexistent_pattern"])
|
||||
name_matches = [c for c in candidates if c['reason'] == 'name_match']
|
||||
assert len(name_matches) == 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_silence_candidate_model(self):
|
||||
sc = SilenceCandidate(
|
||||
start_timecode="00:00:05:00",
|
||||
duration_seconds=2.0,
|
||||
reason="gap",
|
||||
confidence=0.9,
|
||||
)
|
||||
assert sc.confidence == 0.9
|
||||
assert sc.clip_name is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EXPORT TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestExport:
|
||||
|
||||
def test_export_resolve_xml_version(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_resolve.fcpxml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_simplified_fcpxml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
root = tree.getroot()
|
||||
assert root.get('version') == '1.9'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_resolve_preserves_clips(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_resolve.fcpxml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_simplified_fcpxml(out)
|
||||
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(out)
|
||||
tl = project.primary_timeline
|
||||
assert len(tl.clips) == 3
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_xmeml_structure(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_fcp7.xml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_xmeml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
root = tree.getroot()
|
||||
assert root.tag == 'xmeml'
|
||||
assert root.get('version') == '5'
|
||||
|
||||
sequence = root.find('sequence')
|
||||
assert sequence is not None
|
||||
assert sequence.find('name') is not None
|
||||
assert sequence.find('media') is not None
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_xmeml_has_video_track(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_fcp7.xml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_xmeml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
video_tracks = tree.findall('.//media/video/track')
|
||||
assert len(video_tracks) >= 1
|
||||
|
||||
clipitems = video_tracks[0].findall('clipitem')
|
||||
assert len(clipitems) == 3 # 3 clips from primary storyline
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_xmeml_clipitem_has_timing(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_fcp7.xml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_xmeml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
clipitem = tree.find('.//clipitem')
|
||||
assert clipitem.find('name') is not None
|
||||
assert clipitem.find('duration') is not None
|
||||
assert clipitem.find('start') is not None
|
||||
assert clipitem.find('end') is not None
|
||||
assert clipitem.find('in') is not None
|
||||
assert clipitem.find('out') is not None
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_xmeml_connected_clips_to_tracks(self):
|
||||
path = _write_temp_fcpxml(CONNECTED_CLIPS_XML)
|
||||
out = path.replace('.fcpxml', '_fcp7.xml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_xmeml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
video_tracks = tree.findall('.//media/video/track')
|
||||
# Track 0 = primary, tracks 1+ = connected video clips
|
||||
assert len(video_tracks) >= 2
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
def test_export_xmeml_rate_element(self):
|
||||
path = _write_temp_fcpxml(ROLES_XML)
|
||||
out = path.replace('.fcpxml', '_fcp7.xml')
|
||||
try:
|
||||
exporter = DaVinciExporter(path)
|
||||
exporter.export_xmeml(out)
|
||||
|
||||
tree = ET.parse(out)
|
||||
rate = tree.find('.//sequence/rate')
|
||||
assert rate is not None
|
||||
assert rate.find('timebase').text == '24'
|
||||
assert rate.find('ntsc').text == 'FALSE'
|
||||
finally:
|
||||
for f in (path, out):
|
||||
if os.path.exists(f):
|
||||
os.unlink(f)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BACKWARD COMPATIBILITY TESTS
|
||||
# ============================================================================
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
|
||||
def test_existing_sample_still_parses(self):
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(SAMPLE)
|
||||
tl = project.primary_timeline
|
||||
assert tl is not None
|
||||
assert len(tl.clips) > 0
|
||||
|
||||
def test_existing_sample_no_connected_clips(self):
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(SAMPLE)
|
||||
tl = project.primary_timeline
|
||||
assert len(tl.connected_clips) == 0
|
||||
|
||||
def test_clip_without_roles_has_empty_strings(self):
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(SAMPLE)
|
||||
tl = project.primary_timeline
|
||||
for clip in tl.clips:
|
||||
assert isinstance(clip.audio_role, str)
|
||||
assert isinstance(clip.video_role, str)
|
||||
|
||||
def test_timeline_new_fields_default_empty(self):
|
||||
tl = Timeline(
|
||||
name="Test",
|
||||
duration=Timecode(100, 24.0),
|
||||
)
|
||||
assert tl.connected_clips == []
|
||||
assert tl.compound_clips == []
|
||||
Executable
+1046
File diff suppressed because it is too large
Load Diff
Executable
+202
@@ -0,0 +1,202 @@
|
||||
"""Tests for Live mode v1 (v0.9.0) — push_to_fcp / list_fcp_libraries.
|
||||
|
||||
osascript and pgrep are mocked throughout: unit tests must never launch
|
||||
Final Cut Pro. The import-options injection path is real (and
|
||||
DTD-checked in test_dtd_validation.py's environment when available).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import server
|
||||
from fcpxml import live
|
||||
from fcpxml.live import inject_import_options, list_fcp_libraries, push_to_fcp
|
||||
|
||||
SIMPLE_XML = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<fcpxml version="1.13"><resources>'
|
||||
'<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>'
|
||||
'</resources>'
|
||||
'<library><event name="Evt"><project name="Live Test">'
|
||||
'<sequence format="r1" duration="240/24s"><spine>'
|
||||
'<gap name="Gap" offset="0s" duration="240/24s"/>'
|
||||
'</spine></sequence></project></event></library></fcpxml>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_file(tmp_path):
|
||||
f = tmp_path / "live.fcpxml"
|
||||
f.write_text(SIMPLE_XML)
|
||||
return f
|
||||
|
||||
|
||||
def _fake_proc(returncode=0, stdout="", stderr=""):
|
||||
return subprocess.CompletedProcess(
|
||||
args=["osascript"], returncode=returncode, stdout=stdout, stderr=stderr
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# inject_import_options
|
||||
# ============================================================
|
||||
|
||||
def test_inject_import_options_first_child(project_file, tmp_path):
|
||||
out = tmp_path / "live_import.fcpxml"
|
||||
inject_import_options(
|
||||
str(project_file), str(out),
|
||||
library_location="/Users/me/Movies/Test.fcplibrary",
|
||||
suppress_warnings=True,
|
||||
copy_assets=False,
|
||||
)
|
||||
root = ET.parse(out).getroot()
|
||||
first = list(root)[0]
|
||||
assert first.tag == "import-options"
|
||||
opts = {o.get("key"): o.get("value") for o in first.findall("option")}
|
||||
# location is normalized to .fcpbundle so FCP auto-creates instead of
|
||||
# raising the modal "Open Library" picker (live-verified on FCP 12.2)
|
||||
assert opts["library location"].startswith("file:///Users/me/Movies/")
|
||||
assert opts["library location"].endswith("Test.fcpbundle")
|
||||
assert opts["suppress warnings"] == "1"
|
||||
assert opts["copy assets"] == "0"
|
||||
|
||||
|
||||
def test_inject_normalizes_to_fcpbundle(project_file, tmp_path):
|
||||
out = tmp_path / "n.fcpxml"
|
||||
inject_import_options(
|
||||
str(project_file), str(out), library_location="/tmp/Bare/MyLib"
|
||||
)
|
||||
loc = ET.parse(out).getroot().find(
|
||||
"import-options/option[@key='library location']"
|
||||
).get("value")
|
||||
assert loc.endswith("MyLib.fcpbundle")
|
||||
|
||||
|
||||
def test_inject_import_options_replaces_existing(project_file, tmp_path):
|
||||
out1 = tmp_path / "a.fcpxml"
|
||||
inject_import_options(str(project_file), str(out1), suppress_warnings=False)
|
||||
out2 = tmp_path / "b.fcpxml"
|
||||
inject_import_options(str(out1), str(out2), suppress_warnings=True)
|
||||
root = ET.parse(out2).getroot()
|
||||
assert len(root.findall("import-options")) == 1
|
||||
|
||||
|
||||
def test_inject_percent_encodes_spaces(project_file, tmp_path):
|
||||
out = tmp_path / "c.fcpxml"
|
||||
inject_import_options(
|
||||
str(project_file), str(out),
|
||||
library_location="/Users/me/My Movies/Lib.fcplibrary",
|
||||
)
|
||||
root = ET.parse(out).getroot()
|
||||
loc = root.find("import-options/option[@key='library location']").get("value")
|
||||
assert "%20" in loc and " " not in loc
|
||||
|
||||
|
||||
# ============================================================
|
||||
# push_to_fcp (osascript mocked)
|
||||
# ============================================================
|
||||
|
||||
def test_push_sends_options_copy(project_file, tmp_path, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(script):
|
||||
calls.append(script)
|
||||
return _fake_proc()
|
||||
|
||||
monkeypatch.setattr(live, "_run_osascript", fake_run)
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: True)
|
||||
|
||||
copy_path = str(tmp_path / "live_import.fcpxml")
|
||||
result = push_to_fcp(
|
||||
str(project_file),
|
||||
library_location="/tmp/Lib.fcplibrary",
|
||||
import_copy_path=copy_path,
|
||||
)
|
||||
assert result["sent"] == copy_path
|
||||
assert result["launched_fcp"] is False
|
||||
assert 'tell application "Final Cut Pro"' in calls[0]
|
||||
assert "live_import.fcpxml" in calls[0]
|
||||
|
||||
|
||||
def test_push_reports_launch(project_file, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(live, "_run_osascript", lambda s: _fake_proc())
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: False)
|
||||
result = push_to_fcp(
|
||||
str(project_file), import_copy_path=str(tmp_path / "x.fcpxml")
|
||||
)
|
||||
assert result["launched_fcp"] is True
|
||||
|
||||
|
||||
def test_push_permission_error_hint(project_file, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
live, "_run_osascript",
|
||||
lambda s: _fake_proc(returncode=1, stderr="Not authorized (-1743)"),
|
||||
)
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: True)
|
||||
with pytest.raises(RuntimeError, match="Automation permission"):
|
||||
push_to_fcp(str(project_file), import_copy_path=str(tmp_path / "x.fcpxml"))
|
||||
|
||||
|
||||
def test_push_escapes_applescript_quotes(tmp_path, monkeypatch):
|
||||
evil = tmp_path / 'a"b.fcpxml'
|
||||
evil.write_text(SIMPLE_XML)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
live, "_run_osascript",
|
||||
lambda s: captured.setdefault("script", s) and _fake_proc() or _fake_proc(),
|
||||
)
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: True)
|
||||
push_to_fcp(str(evil))
|
||||
assert '\\"' in captured["script"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# list_fcp_libraries (osascript mocked)
|
||||
# ============================================================
|
||||
|
||||
def test_list_refuses_to_launch(monkeypatch):
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: False)
|
||||
with pytest.raises(RuntimeError, match="not running"):
|
||||
list_fcp_libraries()
|
||||
|
||||
|
||||
def test_list_parses_records(monkeypatch):
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: True)
|
||||
payload = (
|
||||
"LibA\x1fEvt1\x1fProj1\x1fProj2\x1f\x1e"
|
||||
"LibA\x1fEvt2\x1f\x1e"
|
||||
"LibB\x1f\x1e"
|
||||
)
|
||||
monkeypatch.setattr(live, "_run_osascript", lambda s: _fake_proc(stdout=payload))
|
||||
libs = list_fcp_libraries()
|
||||
by_name = {lib["name"]: lib for lib in libs}
|
||||
assert by_name["LibA"]["events"][0]["projects"] == ["Proj1", "Proj2"]
|
||||
assert by_name["LibA"]["events"][1]["name"] == "Evt2"
|
||||
assert by_name["LibB"]["events"] == []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP handlers
|
||||
# ============================================================
|
||||
|
||||
async def test_handler_push(project_file, monkeypatch):
|
||||
monkeypatch.setattr(live, "_run_osascript", lambda s: _fake_proc())
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: True)
|
||||
result = await server.handle_push_to_fcp({"filepath": str(project_file)})
|
||||
text = result[0].text
|
||||
assert "Sent to Final Cut Pro" in text
|
||||
assert "_import" in text # options copy, original untouched
|
||||
assert "Export XML" in text # read-back asymmetry stated
|
||||
|
||||
|
||||
async def test_handler_list_not_running(monkeypatch):
|
||||
monkeypatch.setattr(live, "fcp_is_running", lambda: False)
|
||||
result = await server.handle_list_fcp_libraries({})
|
||||
assert "not running" in result[0].text
|
||||
|
||||
|
||||
def test_live_tools_registered():
|
||||
assert "push_to_fcp" in server.TOOL_HANDLERS
|
||||
assert "list_fcp_libraries" in server.TOOL_HANDLERS
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
"""Tests for marker pipeline gaps: build_marker_element, batch auto-modes,
|
||||
clip index duplicate-name behavior, and write_fcpxml output format.
|
||||
|
||||
These cover integration seams between models → writer → parser that existed
|
||||
without direct test coverage despite each component being individually tested.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import MarkerType
|
||||
from fcpxml.writer import (
|
||||
FCPXMLModifier,
|
||||
build_marker_element,
|
||||
write_fcpxml,
|
||||
)
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_fcpxml():
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
shutil.copy(SAMPLE, f.name)
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# build_marker_element — the shared builder
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestBuildMarkerElement:
|
||||
"""Direct tests for the centralised marker XML element factory."""
|
||||
|
||||
def test_standard_creates_marker_tag(self):
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(parent, MarkerType.STANDARD, "12/24s", "1/24s", "Note")
|
||||
assert elem.tag == "marker"
|
||||
assert elem.get("completed") is None
|
||||
|
||||
def test_todo_sets_completed_zero(self):
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(parent, MarkerType.INCOMPLETE, "0s", "1/24s", "Fix")
|
||||
assert elem.tag == "marker"
|
||||
assert elem.get("completed") == "0"
|
||||
|
||||
def test_completed_sets_completed_one(self):
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(parent, MarkerType.COMPLETED, "0s", "1/24s", "Done")
|
||||
assert elem.get("completed") == "1"
|
||||
|
||||
def test_chapter_creates_chapter_marker_tag(self):
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(parent, MarkerType.CHAPTER, "0s", "1/24s", "Ch1")
|
||||
assert elem.tag == "chapter-marker"
|
||||
assert elem.get("posterOffset") == "0s"
|
||||
|
||||
def test_chapter_ignores_note(self):
|
||||
"""Chapter markers must NOT carry note attrs — FCP spec forbids it."""
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(
|
||||
parent, MarkerType.CHAPTER, "0s", "1/24s", "Ch", note="ignored"
|
||||
)
|
||||
assert elem.get("note") is None
|
||||
|
||||
def test_standard_with_note(self):
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(
|
||||
parent, MarkerType.STANDARD, "0s", "1/24s", "Review", note="Check audio"
|
||||
)
|
||||
assert elem.get("note") == "Check audio"
|
||||
|
||||
def test_sanitizes_marker_name(self):
|
||||
"""Null bytes in marker names must be stripped before writing."""
|
||||
parent = ET.Element("clip")
|
||||
elem = build_marker_element(parent, MarkerType.STANDARD, "0s", "1/24s", "bad\x00name")
|
||||
assert "\x00" not in elem.get("value")
|
||||
assert elem.get("value") == "badname"
|
||||
|
||||
def test_element_appended_to_parent(self):
|
||||
parent = ET.Element("clip")
|
||||
build_marker_element(parent, MarkerType.STANDARD, "0s", "1/24s", "M")
|
||||
assert len(list(parent)) == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# batch_add_markers — auto_at_cuts
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestBatchAutoAtCuts:
|
||||
"""batch_add_markers(auto_at_cuts=True) iterates spine clips and marks cuts.
|
||||
|
||||
Previously bugged: auto_at_cuts called add_marker_at_timeline which searched
|
||||
the name-indexed clip dict (last-one-wins for duplicates). Now fixed to add
|
||||
markers directly to each spine clip element.
|
||||
"""
|
||||
|
||||
def test_auto_at_cuts_works_with_duplicate_names(self, temp_fcpxml):
|
||||
"""Duplicate clip names in the spine no longer cause ValueError."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
created = modifier.batch_add_markers(markers=[], auto_at_cuts=True)
|
||||
# sample.fcpxml has 9 spine clips — each should get a cut marker
|
||||
assert len(created) == 9
|
||||
assert all(m.tag == 'marker' for m in created)
|
||||
assert all('Cut' in m.get('value', '') for m in created)
|
||||
|
||||
def test_auto_at_cuts_on_unique_timeline(self, tmp_path):
|
||||
"""Works correctly when all clip names are unique."""
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.11"><resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="a.mov" start="0s" duration="100s"/>
|
||||
<asset id="r3" name="B" src="b.mov" start="0s" duration="100s"/>
|
||||
</resources><library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="240/24s"><spine>
|
||||
<asset-clip ref="r2" offset="0s" name="ClipA" start="0s" duration="120/24s" format="r1"/>
|
||||
<asset-clip ref="r3" offset="120/24s" name="ClipB" start="0s" duration="120/24s" format="r1"/>
|
||||
</spine></sequence></project></event></library></fcpxml>"""
|
||||
p = tmp_path / "unique.fcpxml"
|
||||
p.write_text(xml)
|
||||
modifier = FCPXMLModifier(str(p))
|
||||
created = modifier.batch_add_markers(markers=[], auto_at_cuts=True)
|
||||
assert len(created) == 2
|
||||
assert all(m.get("completed") is None for m in created)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# batch_add_markers — auto_at_intervals
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestBatchAutoAtIntervals:
|
||||
|
||||
def test_interval_creates_markers_on_unique_timeline(self, tmp_path):
|
||||
"""Interval markers work when all clip names are unique (no index collisions)."""
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.11"><resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="a.mov" start="0s" duration="100s"/>
|
||||
</resources><library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="720/24s"><spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Long" start="0s" duration="720/24s" format="r1"/>
|
||||
</spine></sequence></project></event></library></fcpxml>"""
|
||||
p = tmp_path / "interval.fcpxml"
|
||||
p.write_text(xml)
|
||||
modifier = FCPXMLModifier(str(p))
|
||||
# 30s timeline, 10s intervals → 2 markers (at 10s and 20s)
|
||||
created = modifier.batch_add_markers(
|
||||
markers=[], auto_at_intervals="00:00:10:00"
|
||||
)
|
||||
assert len(created) == 2
|
||||
assert all(m.get("value", "").startswith("Marker ") for m in created)
|
||||
|
||||
def test_interval_works_with_duplicate_clip_names(self, tmp_path):
|
||||
"""Interval markers must not silently drop on duplicate-named clips.
|
||||
|
||||
Previously, auto_at_intervals used add_marker_at_timeline which
|
||||
searches the name-indexed clip dict (last-one-wins). Markers landing
|
||||
on earlier duplicate-named clips were silently lost.
|
||||
"""
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.11"><resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="A" src="a.mov" start="0s" duration="100s"/>
|
||||
</resources><library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="480/24s"><spine>
|
||||
<asset-clip ref="r2" offset="0s" name="Interview" start="0s" duration="240/24s" format="r1"/>
|
||||
<asset-clip ref="r2" offset="240/24s" name="Interview" start="0s" duration="240/24s" format="r1"/>
|
||||
</spine></sequence></project></event></library></fcpxml>"""
|
||||
p = tmp_path / "dup_interval.fcpxml"
|
||||
p.write_text(xml)
|
||||
modifier = FCPXMLModifier(str(p))
|
||||
# 20s timeline, 5s intervals → 3 markers (at 5s, 10s, 15s)
|
||||
# Clip 1 covers 0–10s, Clip 2 covers 10–20s (both named "Interview")
|
||||
created = modifier.batch_add_markers(
|
||||
markers=[], auto_at_intervals="00:00:05:00"
|
||||
)
|
||||
assert len(created) == 3
|
||||
assert all(m.get("value", "").startswith("Marker ") for m in created)
|
||||
|
||||
def test_interval_past_timeline_creates_nothing(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
# 999s interval on a ~54s timeline → zero markers
|
||||
created = modifier.batch_add_markers(
|
||||
markers=[], auto_at_intervals="00:16:00:00"
|
||||
)
|
||||
assert len(created) == 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_clip_index — duplicate name behavior
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestClipIndexDuplicateNames:
|
||||
"""The clip index uses name as key — duplicates mean last-one-wins."""
|
||||
|
||||
def test_last_duplicate_wins(self, temp_fcpxml):
|
||||
"""sample.fcpxml has multiple Interview_A clips; only the last is indexed."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
clip = modifier.clips.get("Interview_A")
|
||||
assert clip is not None
|
||||
# The last Interview_A in the spine is at offset 1122/24s
|
||||
assert clip.get("offset") == "1122/24s"
|
||||
|
||||
def test_all_unique_names_indexed(self, temp_fcpxml):
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
# sample has: Interview_A (×3), Broll_City (×3), Broll_Studio (×3)
|
||||
# But only 3 unique names survive indexing
|
||||
assert "Interview_A" in modifier.clips
|
||||
assert "Broll_City" in modifier.clips
|
||||
assert "Broll_Studio" in modifier.clips
|
||||
|
||||
|
||||
# ============================================================
|
||||
# write_fcpxml — output format validation
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWriteFcpxmlFormat:
|
||||
"""write_fcpxml must produce FCP-importable XML with correct headers."""
|
||||
|
||||
def test_xml_declaration_and_doctype(self, tmp_path):
|
||||
root = ET.Element("fcpxml")
|
||||
root.set("version", "1.11")
|
||||
out = str(tmp_path / "out.fcpxml")
|
||||
write_fcpxml(root, out)
|
||||
content = Path(out).read_text()
|
||||
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
assert "<!DOCTYPE fcpxml>" in content
|
||||
|
||||
def test_no_blank_lines(self, tmp_path):
|
||||
"""Blank lines in pretty-printed XML confuse some FCP importers."""
|
||||
root = ET.Element("fcpxml")
|
||||
ET.SubElement(root, "resources")
|
||||
out = str(tmp_path / "blanks.fcpxml")
|
||||
write_fcpxml(root, out)
|
||||
content = Path(out).read_text()
|
||||
for line in content.split("\n"):
|
||||
assert line.strip() != "" or line == content.split("\n")[-1]
|
||||
|
||||
def test_roundtrip_parseable(self, tmp_path):
|
||||
"""Output of write_fcpxml must be parseable by ET.parse."""
|
||||
root = ET.Element("fcpxml")
|
||||
root.set("version", "1.11")
|
||||
res = ET.SubElement(root, "resources")
|
||||
ET.SubElement(res, "format", id="r1", frameDuration="1/24s",
|
||||
width="1920", height="1080")
|
||||
out = str(tmp_path / "rt.fcpxml")
|
||||
write_fcpxml(root, out)
|
||||
tree = ET.parse(out)
|
||||
assert tree.getroot().tag == "fcpxml"
|
||||
Executable
+586
@@ -0,0 +1,586 @@
|
||||
"""Tests for fcpxml/media_intel.py — real media analysis (v0.10 media intelligence).
|
||||
|
||||
Parser and mapping tests are pure and run everywhere; integration tests that
|
||||
invoke ffmpeg skip automatically on machines without it (CI installs it).
|
||||
"""
|
||||
|
||||
import math
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.media_intel import (
|
||||
detect_silence,
|
||||
map_silence_to_timeline,
|
||||
parse_silencedetect_output,
|
||||
)
|
||||
|
||||
FFMPEG = shutil.which("ffmpeg") is not None
|
||||
|
||||
SAMPLE_STDERR = """\
|
||||
Input #0, wav, from 'test.wav':
|
||||
Duration: 00:00:04.00, bitrate: 1536 kb/s
|
||||
[silencedetect @ 0x600002f30000] silence_start: 1.0
|
||||
[silencedetect @ 0x600002f30000] silence_end: 3.0 | silence_duration: 2.0
|
||||
size=N/A time=00:00:04.00 bitrate=N/A speed= 500x
|
||||
"""
|
||||
|
||||
MULTI_STDERR = """\
|
||||
[silencedetect @ 0x1] silence_start: 0.5
|
||||
[silencedetect @ 0x1] silence_end: 1.25 | silence_duration: 0.75
|
||||
[silencedetect @ 0x1] silence_start: 7.099979
|
||||
[silencedetect @ 0x1] silence_end: 9.5 | silence_duration: 2.400021
|
||||
"""
|
||||
|
||||
UNTERMINATED_STDERR = """\
|
||||
[silencedetect @ 0x1] silence_start: 2.0
|
||||
"""
|
||||
|
||||
|
||||
class TestParseSilencedetectOutput:
|
||||
def test_parses_single_silence_range(self):
|
||||
assert parse_silencedetect_output(SAMPLE_STDERR) == [(1.0, 3.0)]
|
||||
|
||||
def test_parses_multiple_ranges(self):
|
||||
result = parse_silencedetect_output(MULTI_STDERR)
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0.5, 1.25)
|
||||
assert result[1] == pytest.approx((7.099979, 9.5))
|
||||
|
||||
def test_unterminated_silence_closed_at_total_duration(self):
|
||||
result = parse_silencedetect_output(UNTERMINATED_STDERR, total_duration=5.0)
|
||||
assert result == [(2.0, 5.0)]
|
||||
|
||||
def test_unterminated_silence_without_duration_dropped(self):
|
||||
assert parse_silencedetect_output(UNTERMINATED_STDERR) == []
|
||||
|
||||
def test_empty_and_unrelated_input(self):
|
||||
assert parse_silencedetect_output("") == []
|
||||
assert parse_silencedetect_output("frame= 100 fps=25\n") == []
|
||||
|
||||
|
||||
class TestMapSilenceToTimeline:
|
||||
"""Silence ranges are in SOURCE seconds; clips use [source_start,
|
||||
source_start + duration) of the source and sit at timeline_offset."""
|
||||
|
||||
def test_silence_inside_used_range_is_mapped(self):
|
||||
# Clip uses source 10..20s, sits at timeline 100s.
|
||||
result = map_silence_to_timeline(
|
||||
[(12.0, 15.0)], source_start=10.0, clip_duration=10.0, timeline_offset=100.0
|
||||
)
|
||||
assert result == [(102.0, 105.0)]
|
||||
|
||||
def test_silence_overlapping_range_edges_is_clamped(self):
|
||||
result = map_silence_to_timeline(
|
||||
[(8.0, 12.0), (18.0, 25.0)],
|
||||
source_start=10.0, clip_duration=10.0, timeline_offset=100.0,
|
||||
)
|
||||
assert result == [(100.0, 102.0), (108.0, 110.0)]
|
||||
|
||||
def test_silence_outside_used_range_is_excluded(self):
|
||||
result = map_silence_to_timeline(
|
||||
[(0.0, 5.0), (30.0, 40.0)],
|
||||
source_start=10.0, clip_duration=10.0, timeline_offset=100.0,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestDetectSilenceBounds:
|
||||
def test_rejects_positive_noise_db(self):
|
||||
with pytest.raises(ValueError):
|
||||
detect_silence("/tmp/x.wav", noise_db=5.0)
|
||||
|
||||
def test_rejects_extreme_negative_noise_db(self):
|
||||
with pytest.raises(ValueError):
|
||||
detect_silence("/tmp/x.wav", noise_db=-200.0)
|
||||
|
||||
def test_rejects_nonpositive_min_duration(self):
|
||||
with pytest.raises(ValueError):
|
||||
detect_silence("/tmp/x.wav", min_duration=0)
|
||||
|
||||
def test_rejects_huge_min_duration(self):
|
||||
with pytest.raises(ValueError):
|
||||
detect_silence("/tmp/x.wav", min_duration=4000)
|
||||
|
||||
def test_missing_file_returns_none(self):
|
||||
assert detect_silence("/nonexistent/path/audio.wav") is None
|
||||
|
||||
|
||||
def _write_tone_silence_tone_wav(path: str, rate: int = 48000) -> None:
|
||||
"""1s 440Hz tone, 2s silence, 1s 440Hz tone."""
|
||||
def tone(seconds: float) -> bytes:
|
||||
n = int(seconds * rate)
|
||||
return b"".join(
|
||||
struct.pack("<h", int(20000 * math.sin(2 * math.pi * 440 * i / rate)))
|
||||
for i in range(n)
|
||||
)
|
||||
|
||||
def silence(seconds: float) -> bytes:
|
||||
return b"\x00\x00" * int(seconds * rate)
|
||||
|
||||
with wave.open(path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(rate)
|
||||
wf.writeframes(tone(1.0) + silence(2.0) + tone(1.0))
|
||||
|
||||
|
||||
PROJECT_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.13">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="interview" start="0s" duration="4s" hasVideo="0" hasAudio="1"
|
||||
audioSources="1" audioChannels="1" audioRate="48000">
|
||||
<media-rep kind="original-media" src="{src}"/>
|
||||
</asset>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="SilenceTest">
|
||||
<sequence format="r1" duration="4s" tcStart="0s">
|
||||
<spine>
|
||||
<gap name="Gap" offset="0s" start="0s" duration="10s"/>
|
||||
<asset-clip ref="r2" offset="10s" name="interview" start="0s" duration="4s" audioRole="dialogue"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestDetectMediaSilenceHandler:
|
||||
"""The detect_media_silence MCP tool: probes each clip's real source
|
||||
audio and reports silence mapped into timeline time."""
|
||||
|
||||
def _write_project(self, tmp_path: Path, media_src: str) -> str:
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src=media_src))
|
||||
return str(fcpxml_path)
|
||||
|
||||
@pytest.mark.skipif(not FFMPEG, reason="ffmpeg not installed")
|
||||
async def test_reports_timeline_mapped_silence(self, tmp_path):
|
||||
from server import handle_detect_media_silence
|
||||
|
||||
wav_path = tmp_path / "interview.wav"
|
||||
_write_tone_silence_tone_wav(str(wav_path))
|
||||
filepath = self._write_project(tmp_path, f"file://{wav_path}")
|
||||
|
||||
result = await handle_detect_media_silence(
|
||||
{"filepath": filepath, "noise_db": -40.0, "min_silence": 0.5}
|
||||
)
|
||||
text = result[0].text
|
||||
# WAV is silent from 1s to 3s in source; clip sits at timeline 10s.
|
||||
assert "interview" in text
|
||||
assert "11.0" in text
|
||||
assert "13.0" in text
|
||||
|
||||
async def test_missing_media_is_reported_not_crashed(self, tmp_path):
|
||||
from server import handle_detect_media_silence
|
||||
|
||||
filepath = self._write_project(tmp_path, "file:///nonexistent/interview.wav")
|
||||
result = await handle_detect_media_silence({"filepath": filepath})
|
||||
text = result[0].text
|
||||
assert "interview" in text
|
||||
assert "skipped" in text.lower() or "missing" in text.lower()
|
||||
|
||||
async def test_rejects_out_of_bounds_noise_db(self, tmp_path):
|
||||
from server import handle_detect_media_silence
|
||||
|
||||
filepath = self._write_project(tmp_path, "file:///nonexistent/interview.wav")
|
||||
with pytest.raises(ValueError):
|
||||
await handle_detect_media_silence({"filepath": filepath, "noise_db": 40.0})
|
||||
|
||||
|
||||
TWO_CLIP_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.13">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="interview" start="0s" duration="10s" hasVideo="0" hasAudio="1">
|
||||
<media-rep kind="original-media" src="file:///media/interview.wav"/>
|
||||
</asset>
|
||||
<asset id="r3" name="broll" start="0s" duration="10s" hasVideo="1" hasAudio="1">
|
||||
<media-rep kind="original-media" src="file:///media/broll.mov"/>
|
||||
</asset>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="CutTest">
|
||||
<sequence format="r1" duration="6s" tcStart="0s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="interview" start="0s" duration="4s">
|
||||
<marker start="0.5s" duration="1/24s" value="Keep"/>
|
||||
<marker start="2s" duration="1/24s" value="Drop"/>
|
||||
</asset-clip>
|
||||
<asset-clip ref="r3" offset="4s" name="broll" start="0s" duration="2s"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
class TestCutClipRanges:
|
||||
"""FCPXMLModifier.cut_clip_ranges — rebuild a clip without the given
|
||||
clip-relative ranges, rippling everything after it."""
|
||||
|
||||
def _make_modifier(self, tmp_path: Path):
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
fcpxml_path = tmp_path / "cut.fcpxml"
|
||||
fcpxml_path.write_text(TWO_CLIP_XML)
|
||||
return FCPXMLModifier(str(fcpxml_path))
|
||||
|
||||
def _spine_clips(self, mod):
|
||||
spine = mod.root.find(".//spine")
|
||||
return [el for el in spine if el.tag == "asset-clip"]
|
||||
|
||||
def test_middle_cut_splits_clip_and_ripples(self, tmp_path):
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
removed = mod.cut_clip_ranges(clip, [(TimeValue(1, 1), TimeValue(3, 1))])
|
||||
|
||||
assert removed.to_seconds() == pytest.approx(2.0)
|
||||
clips = self._spine_clips(mod)
|
||||
assert len(clips) == 3 # interview x2 + broll
|
||||
seg1, seg2, broll = clips
|
||||
assert seg1.get("offset") == "0s"
|
||||
assert mod._parse_time(seg1.get("duration")).to_seconds() == pytest.approx(1.0)
|
||||
assert mod._parse_time(seg2.get("offset")).to_seconds() == pytest.approx(1.0)
|
||||
assert mod._parse_time(seg2.get("start")).to_seconds() == pytest.approx(3.0)
|
||||
assert mod._parse_time(seg2.get("duration")).to_seconds() == pytest.approx(1.0)
|
||||
# broll rippled 4s -> 2s
|
||||
assert mod._parse_time(broll.get("offset")).to_seconds() == pytest.approx(2.0)
|
||||
|
||||
def test_head_cut_trims_in_place(self, tmp_path):
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
removed = mod.cut_clip_ranges(clip, [(TimeValue(0, 1), TimeValue(1, 1))])
|
||||
|
||||
assert removed.to_seconds() == pytest.approx(1.0)
|
||||
clips = self._spine_clips(mod)
|
||||
assert len(clips) == 2
|
||||
seg, broll = clips
|
||||
assert seg.get("offset") == "0s"
|
||||
assert mod._parse_time(seg.get("start")).to_seconds() == pytest.approx(1.0)
|
||||
assert mod._parse_time(seg.get("duration")).to_seconds() == pytest.approx(3.0)
|
||||
assert mod._parse_time(broll.get("offset")).to_seconds() == pytest.approx(3.0)
|
||||
|
||||
def test_full_span_cut_removes_clip(self, tmp_path):
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
removed = mod.cut_clip_ranges(clip, [(TimeValue(0, 1), TimeValue(4, 1))])
|
||||
|
||||
assert removed.to_seconds() == pytest.approx(4.0)
|
||||
clips = self._spine_clips(mod)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].get("name") == "broll"
|
||||
assert clips[0].get("offset") == "0s"
|
||||
|
||||
def test_overlapping_ranges_are_merged_and_clamped(self, tmp_path):
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
removed = mod.cut_clip_ranges(clip, [
|
||||
(TimeValue(2, 1), TimeValue(9, 1)), # clamped to 4s
|
||||
(TimeValue(1, 1), TimeValue(5, 2)), # 1..2.5 overlaps
|
||||
(TimeValue(-1, 1), TimeValue(1, 2)), # -1..0.5 clamped to 0..0.5
|
||||
])
|
||||
# merged cuts: 0..0.5 and 1..4 -> removed 3.5, kept 0.5..1
|
||||
assert removed.to_seconds() == pytest.approx(3.5)
|
||||
clips = self._spine_clips(mod)
|
||||
seg = clips[0]
|
||||
assert mod._parse_time(seg.get("start")).to_seconds() == pytest.approx(0.5)
|
||||
assert mod._parse_time(seg.get("duration")).to_seconds() == pytest.approx(0.5)
|
||||
|
||||
def test_edge_slivers_are_absorbed_not_emitted_as_micro_clips(self, tmp_path):
|
||||
"""A keep segment under ~2 frames right at the clip's own start/end
|
||||
(leftover cut padding with no kept audio to sit next to) must not
|
||||
become its own near-invisible clip — it should widen the adjacent
|
||||
real segment instead."""
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
# 4s clip; cut 0.02..1 and 3..3.98 -> would-be keeps: [0..0.02]
|
||||
# (1 frame sliver), [1..3] (real), [3.98..4] (1 frame sliver).
|
||||
removed = mod.cut_clip_ranges(clip, [
|
||||
(TimeValue(1, 50), TimeValue(1, 1)),
|
||||
(TimeValue(3, 1), TimeValue(199, 50)),
|
||||
])
|
||||
|
||||
clips = self._spine_clips(mod)
|
||||
assert len(clips) == 2 # interview (merged, no sliver) + broll
|
||||
seg = clips[0]
|
||||
assert seg.get("offset") == "0s"
|
||||
assert mod._parse_time(seg.get("start")).to_seconds() == pytest.approx(0.0)
|
||||
assert mod._parse_time(seg.get("duration")).to_seconds() == pytest.approx(4.0)
|
||||
assert removed.to_seconds() == pytest.approx(0.0)
|
||||
|
||||
def test_markers_follow_their_segment(self, tmp_path):
|
||||
from fcpxml.models import TimeValue
|
||||
|
||||
mod = self._make_modifier(tmp_path)
|
||||
clip = self._spine_clips(mod)[0]
|
||||
mod.cut_clip_ranges(clip, [(TimeValue(1, 1), TimeValue(3, 1))])
|
||||
|
||||
clips = self._spine_clips(mod)
|
||||
seg1_markers = [m.get("value") for m in clips[0].findall("marker")]
|
||||
seg2_markers = [m.get("value") for m in clips[1].findall("marker")]
|
||||
assert seg1_markers == ["Keep"] # 0.5s is in kept 0..1
|
||||
assert seg2_markers == [] # 2s was inside the cut
|
||||
|
||||
|
||||
class TestRemoveMediaSilenceHandler:
|
||||
"""The remove_media_silence MCP tool: detects real silence and cuts it
|
||||
out of the timeline with ripple."""
|
||||
|
||||
def _write_project(self, tmp_path: Path, media_src: str) -> str:
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src=media_src))
|
||||
return str(fcpxml_path)
|
||||
|
||||
@pytest.mark.skipif(not FFMPEG, reason="ffmpeg not installed")
|
||||
async def test_cuts_silence_and_saves_output(self, tmp_path):
|
||||
from fcpxml.parser import FCPXMLParser
|
||||
from server import handle_remove_media_silence
|
||||
|
||||
wav_path = tmp_path / "interview.wav"
|
||||
_write_tone_silence_tone_wav(str(wav_path))
|
||||
filepath = self._write_project(tmp_path, f"file://{wav_path}")
|
||||
|
||||
result = await handle_remove_media_silence(
|
||||
{"filepath": filepath, "noise_db": -40.0, "min_silence": 0.5, "padding": 0}
|
||||
)
|
||||
text = result[0].text
|
||||
assert "_silence_removed" in text
|
||||
|
||||
out = str(tmp_path / "project_silence_removed.fcpxml")
|
||||
project = FCPXMLParser().parse_file(out)
|
||||
segments = [c for c in project.primary_timeline.clips if c.name == "interview"]
|
||||
# tone(1s) silence(2s) tone(1s) -> two 1s segments
|
||||
assert len(segments) == 2
|
||||
assert segments[0].duration.seconds == pytest.approx(1.0, abs=0.05)
|
||||
assert segments[1].duration.seconds == pytest.approx(1.0, abs=0.05)
|
||||
assert segments[1].source_start.seconds == pytest.approx(3.0, abs=0.05)
|
||||
|
||||
async def test_missing_media_makes_no_changes(self, tmp_path):
|
||||
from server import handle_remove_media_silence
|
||||
|
||||
filepath = self._write_project(tmp_path, "file:///nonexistent/interview.wav")
|
||||
result = await handle_remove_media_silence({"filepath": filepath})
|
||||
text = result[0].text
|
||||
assert "skipped" in text.lower() or "missing" in text.lower()
|
||||
assert not (tmp_path / "project_silence_removed.fcpxml").exists()
|
||||
|
||||
async def test_rejects_out_of_bounds_padding(self, tmp_path):
|
||||
from server import handle_remove_media_silence
|
||||
|
||||
filepath = self._write_project(tmp_path, "file:///nonexistent/interview.wav")
|
||||
with pytest.raises(ValueError):
|
||||
await handle_remove_media_silence({"filepath": filepath, "padding": -1})
|
||||
|
||||
@pytest.mark.skipif(not FFMPEG, reason="ffmpeg not installed")
|
||||
async def test_ntsc_rate_output_stays_frame_aligned(self, tmp_path):
|
||||
"""23.976fps (frameDuration=1001/24000s) must not drift off-grid.
|
||||
|
||||
A hardcoded 2400-tick snap (2400/23.976 rounds to 100, i.e. 1/24s)
|
||||
is close to but not equal to the true 1001/24000s frame — Final Cut
|
||||
then rejects the result as "not on an edit frame boundary".
|
||||
"""
|
||||
from fractions import Fraction
|
||||
|
||||
from fcpxml.safe_xml import safe_parse
|
||||
from server import handle_remove_media_silence
|
||||
|
||||
ntsc_xml = PROJECT_XML.replace(
|
||||
'frameDuration="1/24s"', 'frameDuration="1001/24000s"'
|
||||
)
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
wav_path = tmp_path / "interview.wav"
|
||||
_write_tone_silence_tone_wav(str(wav_path))
|
||||
fcpxml_path.write_text(ntsc_xml.format(src=f"file://{wav_path}"))
|
||||
|
||||
result = await handle_remove_media_silence(
|
||||
{"filepath": str(fcpxml_path), "noise_db": -40.0, "min_silence": 0.5, "padding": 0}
|
||||
)
|
||||
assert "_silence_removed" in result[0].text
|
||||
|
||||
out = tmp_path / "project_silence_removed.fcpxml"
|
||||
root = safe_parse(str(out)).getroot()
|
||||
frame_duration = Fraction(1001, 24000)
|
||||
spine_clips = root.findall(".//spine/*")
|
||||
assert spine_clips, "expected at least one clip after the cut"
|
||||
for el in spine_clips:
|
||||
for attr in ("offset", "duration"):
|
||||
raw = el.get(attr)
|
||||
if not raw or not raw.endswith("s"):
|
||||
continue
|
||||
value = raw[:-1]
|
||||
seconds = Fraction(*map(int, value.split("/"))) if "/" in value else Fraction(value)
|
||||
frames = seconds / frame_duration
|
||||
assert frames.denominator == 1, (
|
||||
f"{el.tag}@{attr}={raw!r} is not an exact multiple of "
|
||||
f"frameDuration={frame_duration} (23.976fps)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import librosa # noqa: F401
|
||||
LIBROSA = True
|
||||
except ImportError:
|
||||
LIBROSA = False
|
||||
|
||||
|
||||
def _write_click_track_wav(path: str, bpm: float = 120.0, seconds: float = 8.0,
|
||||
rate: int = 22050) -> None:
|
||||
"""Clicks (10ms 1kHz bursts) on the beat grid, silence between."""
|
||||
interval = 60.0 / bpm
|
||||
total = int(seconds * rate)
|
||||
samples = [0] * total
|
||||
burst = int(0.010 * rate)
|
||||
t = 0.0
|
||||
while t < seconds:
|
||||
start = int(t * rate)
|
||||
for i in range(burst):
|
||||
if start + i < total:
|
||||
samples[start + i] = int(28000 * math.sin(2 * math.pi * 1000 * i / rate))
|
||||
t += interval
|
||||
with wave.open(path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(rate)
|
||||
wf.writeframes(b"".join(struct.pack("<h", s) for s in samples))
|
||||
|
||||
|
||||
class TestDetectBeats:
|
||||
def test_missing_file_returns_none(self):
|
||||
from fcpxml.media_intel import detect_beats
|
||||
|
||||
assert detect_beats("/nonexistent/song.wav") is None
|
||||
|
||||
@pytest.mark.skipif(not LIBROSA, reason="librosa not installed")
|
||||
def test_detects_click_track_grid(self, tmp_path):
|
||||
from fcpxml.media_intel import detect_beats
|
||||
|
||||
wav = tmp_path / "click.wav"
|
||||
_write_click_track_wav(str(wav), bpm=120.0, seconds=8.0)
|
||||
result = detect_beats(str(wav))
|
||||
assert result is not None
|
||||
beats = result["beats"]
|
||||
assert result["bpm"] > 0
|
||||
assert len(beats) >= 10
|
||||
# median inter-beat interval must sit on the 0.5s grid
|
||||
intervals = sorted(b - a for a, b in zip(beats, beats[1:]))
|
||||
median = intervals[len(intervals) // 2]
|
||||
assert median == pytest.approx(0.5, abs=0.06)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not LIBROSA, reason="librosa not installed")
|
||||
class TestDetectBeatsHandler:
|
||||
async def test_writes_beats_json_and_reports(self, tmp_path):
|
||||
import json as jsonlib
|
||||
|
||||
from server import handle_detect_beats
|
||||
|
||||
wav = tmp_path / "song.wav"
|
||||
_write_click_track_wav(str(wav), bpm=120.0, seconds=8.0)
|
||||
result = await handle_detect_beats({"media_path": str(wav)})
|
||||
text = result[0].text
|
||||
assert "BPM" in text
|
||||
json_path = tmp_path / "song_beats.json"
|
||||
assert str(json_path) in text
|
||||
data = jsonlib.loads(json_path.read_text())
|
||||
assert len(data["beats"]) >= 10
|
||||
assert data["bpm"] > 0
|
||||
assert data["downbeats"] == data["beats"][::4]
|
||||
|
||||
async def test_rejects_disallowed_extension(self, tmp_path):
|
||||
from server import handle_detect_beats
|
||||
|
||||
bad = tmp_path / "song.txt"
|
||||
bad.write_text("not audio")
|
||||
with pytest.raises(ValueError):
|
||||
await handle_detect_beats({"media_path": str(bad)})
|
||||
|
||||
async def test_reports_when_librosa_unavailable(self, tmp_path, monkeypatch):
|
||||
import server as server_mod
|
||||
from server import handle_detect_beats
|
||||
|
||||
wav = tmp_path / "song.wav"
|
||||
_write_click_track_wav(str(wav), seconds=2.0)
|
||||
monkeypatch.setattr(server_mod, "detect_beats", lambda *a, **k: None)
|
||||
result = await handle_detect_beats({"media_path": str(wav)})
|
||||
assert "librosa" in result[0].text.lower()
|
||||
|
||||
|
||||
class TestImportBeatMarkersBeyondTimeline:
|
||||
"""detect_beats output routinely spans longer than the edit (songs are
|
||||
longer than timelines) — import must skip out-of-range beats, not crash."""
|
||||
|
||||
async def test_out_of_range_beats_are_skipped_not_fatal(self, tmp_path):
|
||||
import json as jsonlib
|
||||
|
||||
from server import handle_import_beat_markers
|
||||
|
||||
proj = tmp_path / "edit.fcpxml"
|
||||
proj.write_text(TWO_CLIP_XML) # timeline is 6s total
|
||||
beats = tmp_path / "beats.json"
|
||||
beats.write_text(jsonlib.dumps({"beats": [0.5, 2.0, 5.5, 7.0, 9.5]}))
|
||||
|
||||
result = await handle_import_beat_markers(
|
||||
{"filepath": str(proj), "beats_path": str(beats)}
|
||||
)
|
||||
text = result[0].text
|
||||
assert "3" in text # 3 in-range markers placed
|
||||
assert "skipped" in text.lower()
|
||||
assert "2" in text # 2 beyond-timeline beats skipped
|
||||
|
||||
|
||||
@pytest.mark.skipif(not FFMPEG, reason="ffmpeg not installed")
|
||||
class TestDetectSilenceIntegration:
|
||||
def test_detects_silence_in_real_wav(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
wav_path = f.name
|
||||
try:
|
||||
_write_tone_silence_tone_wav(wav_path)
|
||||
result = detect_silence(wav_path, noise_db=-40.0, min_duration=0.5)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
start, end = result[0]
|
||||
assert start == pytest.approx(1.0, abs=0.1)
|
||||
assert end == pytest.approx(3.0, abs=0.1)
|
||||
finally:
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
|
||||
def test_fully_silent_wav_is_one_range(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
wav_path = f.name
|
||||
try:
|
||||
with wave.open(wav_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(48000)
|
||||
wf.writeframes(b"\x00\x00" * 48000 * 3)
|
||||
result = detect_silence(wav_path, noise_db=-40.0, min_duration=0.5)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
start, end = result[0]
|
||||
assert start == pytest.approx(0.0, abs=0.1)
|
||||
assert end == pytest.approx(3.0, abs=0.1)
|
||||
finally:
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
Executable
+841
@@ -0,0 +1,841 @@
|
||||
"""Tests for FCPXML data models — TimeValue, Timecode, Timeline, and helpers.
|
||||
|
||||
Covers the core time math and model properties that underpin all 34 tools.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import (
|
||||
Clip,
|
||||
FlashFrame,
|
||||
FlashFrameSeverity,
|
||||
GapInfo,
|
||||
Keyword,
|
||||
Marker,
|
||||
MarkerType,
|
||||
PacingConfig,
|
||||
Project,
|
||||
Timecode,
|
||||
Timeline,
|
||||
TimeValue,
|
||||
ValidationIssue,
|
||||
ValidationIssueType,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeValueCreation:
|
||||
"""Test TimeValue construction from various input formats."""
|
||||
|
||||
def test_from_rational_string(self):
|
||||
tv = TimeValue.from_timecode("90/30s")
|
||||
assert tv.numerator == 90 and tv.denominator == 30
|
||||
|
||||
def test_from_seconds_string(self):
|
||||
assert TimeValue.from_timecode("5s", fps=24.0).to_seconds() == pytest.approx(5.0, abs=0.01)
|
||||
|
||||
def test_from_seconds_float(self):
|
||||
assert TimeValue.from_seconds(2.5, fps=24.0).to_seconds() == pytest.approx(2.5, abs=0.05)
|
||||
|
||||
def test_from_timecode_hhmmssff(self):
|
||||
assert TimeValue.from_timecode("00:01:30:15", fps=30.0).to_seconds() == pytest.approx(90.5, abs=0.05)
|
||||
|
||||
def test_from_timecode_hhmmss(self):
|
||||
assert TimeValue.from_timecode("00:02:00", fps=24.0).to_seconds() == pytest.approx(120.0, abs=0.05)
|
||||
|
||||
def test_from_frames_string(self):
|
||||
assert TimeValue.from_timecode("48f", fps=24.0).to_seconds() == pytest.approx(2.0, abs=0.05)
|
||||
|
||||
def test_from_plain_number(self):
|
||||
assert TimeValue.from_timecode("3.5", fps=24.0).to_seconds() == pytest.approx(3.5, abs=0.05)
|
||||
|
||||
def test_from_empty_string(self):
|
||||
assert TimeValue.from_timecode("") == TimeValue.zero()
|
||||
|
||||
def test_zero(self):
|
||||
tv = TimeValue.zero()
|
||||
assert tv.numerator == 0 and tv.to_seconds() == 0.0
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid timecode format"):
|
||||
TimeValue.from_timecode("not_a_timecode")
|
||||
|
||||
def test_drop_frame_separator(self):
|
||||
assert TimeValue.from_timecode("00:00:10;15", fps=30.0).to_seconds() == pytest.approx(10.5, abs=0.05)
|
||||
|
||||
def test_from_seconds_string_ntsc_rate_exact(self):
|
||||
"""23.976fps (24000/1001): int(fps) truncation used to corrupt the
|
||||
value by ~1.04x (317.99s -> 331.48s) because the numerator was
|
||||
computed with the real fps but the denominator was hardcoded to
|
||||
int(fps)=23. The reconstructed value must round-trip exactly."""
|
||||
fps = 24000 / 1001
|
||||
tv = TimeValue.from_timecode("317.9857083333334s", fps=fps)
|
||||
# Half a frame (~0.0417s) of quantization slack is expected/correct;
|
||||
# the bug inflated the value by ~1.04x (to ~331.48s), which this
|
||||
# tolerance would never mask.
|
||||
assert tv.to_seconds() == pytest.approx(317.9857083333334, abs=0.03)
|
||||
assert tv.denominator == 24000
|
||||
|
||||
|
||||
class TestTimeValueConversions:
|
||||
|
||||
def test_to_fcpxml(self):
|
||||
assert TimeValue(3, 1).to_fcpxml() == "3s"
|
||||
assert TimeValue(90, 30).to_fcpxml() == "3s" # simplifies
|
||||
assert TimeValue(75, 30).to_fcpxml() == "75/30s" # keeps standard timebase denom
|
||||
|
||||
def test_to_seconds(self):
|
||||
assert TimeValue(72, 24).to_seconds() == pytest.approx(3.0)
|
||||
with pytest.raises(ValueError, match="denominator cannot be zero"):
|
||||
TimeValue(5, 0) # zero denominator rejected at construction
|
||||
|
||||
def test_to_timecode(self):
|
||||
assert TimeValue(2700, 30).to_timecode(fps=30.0) == "00:01:30:00"
|
||||
assert TimeValue(2715, 30).to_timecode(fps=30.0) == "00:01:30:15"
|
||||
|
||||
def test_to_frames(self):
|
||||
assert TimeValue(3, 1).to_frames(fps=24.0) == 72
|
||||
|
||||
|
||||
class TestTimeValueArithmetic:
|
||||
|
||||
def test_add(self):
|
||||
assert (TimeValue(72, 24) + TimeValue(48, 24)).to_seconds() == pytest.approx(5.0)
|
||||
|
||||
def test_sub(self):
|
||||
assert (TimeValue(120, 24) - TimeValue(72, 24)).to_seconds() == pytest.approx(2.0)
|
||||
|
||||
def test_add_different_denominators(self):
|
||||
assert (TimeValue(72, 24) + TimeValue(150, 30)).to_seconds() == pytest.approx(8.0)
|
||||
|
||||
def test_mul(self):
|
||||
assert (TimeValue(72, 24) * 2).to_seconds() == pytest.approx(6.0)
|
||||
|
||||
def test_div(self):
|
||||
assert (TimeValue(72, 24) / 3).to_seconds() == pytest.approx(1.0)
|
||||
|
||||
def test_simplify(self):
|
||||
s = TimeValue(120, 48).simplify()
|
||||
assert s.numerator == 5 and s.denominator == 2
|
||||
|
||||
def test_simplify_zero(self):
|
||||
s = TimeValue(0, 48).simplify()
|
||||
assert s.numerator == 0 and s.denominator == 1
|
||||
|
||||
|
||||
class TestTimeValueComparisons:
|
||||
|
||||
def test_ordering(self):
|
||||
small, big = TimeValue(48, 24), TimeValue(72, 24)
|
||||
assert small < big and small <= big
|
||||
assert big > small and big >= small
|
||||
|
||||
def test_equality(self):
|
||||
assert TimeValue(72, 24) == TimeValue(72, 24)
|
||||
assert TimeValue(3, 1) == TimeValue(72, 24) # cross-denominator
|
||||
assert TimeValue(72, 24) != "not a timevalue"
|
||||
|
||||
def test_repr(self):
|
||||
r = repr(TimeValue(72, 24))
|
||||
assert "72" in r and "24" in r and "3.000s" in r
|
||||
|
||||
|
||||
class TestTimeline:
|
||||
|
||||
@staticmethod
|
||||
def _make(durations, fps=24.0):
|
||||
clips, offset = [], 0.0
|
||||
for i, dur in enumerate(durations):
|
||||
clips.append(Clip(
|
||||
name=f"clip_{i}",
|
||||
start=Timecode(frames=int(offset * fps), frame_rate=fps),
|
||||
duration=Timecode(frames=int(dur * fps), frame_rate=fps),
|
||||
))
|
||||
offset += dur
|
||||
return Timeline(
|
||||
name="test",
|
||||
duration=Timecode(frames=int(offset * fps), frame_rate=fps),
|
||||
frame_rate=fps, clips=clips,
|
||||
)
|
||||
|
||||
def test_total_clips(self):
|
||||
assert self._make([2, 3, 1]).total_clips == 3
|
||||
|
||||
def test_total_cuts(self):
|
||||
assert self._make([2, 3, 1]).total_cuts == 2
|
||||
assert self._make([]).total_cuts == 0
|
||||
|
||||
def test_avg_duration(self):
|
||||
assert self._make([2, 4, 6]).average_clip_duration == pytest.approx(4.0, abs=0.1)
|
||||
assert self._make([]).average_clip_duration == 0.0
|
||||
|
||||
def test_cuts_per_minute(self):
|
||||
assert self._make([2, 2, 2]).cuts_per_minute == pytest.approx(20.0, abs=0.5)
|
||||
|
||||
def test_shorter_than(self):
|
||||
assert len(self._make([0.2, 5, 0.3, 2]).get_clips_shorter_than(0.5)) == 2
|
||||
|
||||
def test_longer_than(self):
|
||||
assert len(self._make([1, 10, 2, 15]).get_clips_longer_than(5)) == 2
|
||||
|
||||
def test_clip_at(self):
|
||||
clip = self._make([2, 3, 1]).get_clip_at(3.5)
|
||||
assert clip is not None and clip.name == "clip_1"
|
||||
assert self._make([2, 3]).get_clip_at(999) is None
|
||||
|
||||
def test_by_keyword(self):
|
||||
tl = self._make([2, 3])
|
||||
tl.clips[0].keywords = [Keyword(value="interview")]
|
||||
assert len(tl.get_clips_by_keyword("interview")) == 1
|
||||
|
||||
|
||||
class TestProject:
|
||||
|
||||
def test_primary_timeline(self):
|
||||
tl = Timeline(name="main", duration=Timecode(frames=0, frame_rate=24.0))
|
||||
assert Project(name="test", timelines=[tl]).primary_timeline is tl
|
||||
|
||||
def test_primary_timeline_empty(self):
|
||||
assert Project(name="empty").primary_timeline is None
|
||||
|
||||
|
||||
class TestMarker:
|
||||
|
||||
def test_youtube_timestamp_minutes(self):
|
||||
m = Marker(name="Ch", start=Timecode(frames=2160, frame_rate=24.0))
|
||||
assert m.to_youtube_timestamp() == "1:30"
|
||||
|
||||
def test_youtube_timestamp_hours(self):
|
||||
m = Marker(name="Act", start=Timecode(frames=24 * 3700, frame_rate=24.0))
|
||||
assert m.to_youtube_timestamp().count(":") == 2
|
||||
|
||||
|
||||
class TestTimecode:
|
||||
"""Test Timecode — the frame-based time representation used by the parser."""
|
||||
|
||||
def test_from_rational_fraction(self):
|
||||
tc = Timecode.from_rational("3600/24s", frame_rate=24.0)
|
||||
assert tc.frames == 3600
|
||||
assert tc.seconds == pytest.approx(150.0)
|
||||
|
||||
def test_from_rational_whole_seconds(self):
|
||||
tc = Timecode.from_rational("5s", frame_rate=30.0)
|
||||
assert tc.frames == 150
|
||||
assert tc.seconds == pytest.approx(5.0)
|
||||
|
||||
def test_from_rational_plain_number(self):
|
||||
tc = Timecode.from_rational("2.5", frame_rate=24.0)
|
||||
assert tc.frames == 60
|
||||
assert tc.seconds == pytest.approx(2.5)
|
||||
|
||||
def test_from_rational_empty_string(self):
|
||||
tc = Timecode.from_rational("", frame_rate=24.0)
|
||||
assert tc.frames == 0
|
||||
|
||||
def test_to_smpte_basic(self):
|
||||
tc = Timecode(frames=2700, frame_rate=30.0)
|
||||
assert tc.to_smpte() == "00:01:30:00"
|
||||
|
||||
def test_to_smpte_with_frames(self):
|
||||
tc = Timecode(frames=2715, frame_rate=30.0)
|
||||
assert tc.to_smpte() == "00:01:30:15"
|
||||
|
||||
def test_to_smpte_drop_frame_separator(self):
|
||||
tc = Timecode(frames=48, frame_rate=24.0, drop_frame=True)
|
||||
assert ";" in tc.to_smpte()
|
||||
|
||||
def test_to_smpte_hours(self):
|
||||
tc = Timecode(frames=24 * 3661, frame_rate=24.0)
|
||||
smpte = tc.to_smpte()
|
||||
assert smpte.startswith("01:01:01")
|
||||
|
||||
def test_to_rational(self):
|
||||
tc = Timecode(frames=72, frame_rate=24.0)
|
||||
assert tc.to_rational() == "72/24s"
|
||||
|
||||
def test_to_time_value(self):
|
||||
tc = Timecode(frames=72, frame_rate=24.0)
|
||||
tv = tc.to_time_value()
|
||||
assert tv.numerator == 72
|
||||
assert tv.denominator == 24
|
||||
assert tv.to_seconds() == pytest.approx(3.0)
|
||||
|
||||
def test_total_frames_alias(self):
|
||||
tc = Timecode(frames=100, frame_rate=30.0)
|
||||
assert tc.total_frames == 100
|
||||
|
||||
|
||||
class TestClipProperties:
|
||||
"""Test Clip computed properties."""
|
||||
|
||||
def test_end_timecode(self):
|
||||
clip = Clip(
|
||||
name="test",
|
||||
start=Timecode(frames=48, frame_rate=24.0),
|
||||
duration=Timecode(frames=72, frame_rate=24.0),
|
||||
)
|
||||
assert clip.end.frames == 120
|
||||
assert clip.end.frame_rate == 24.0
|
||||
|
||||
def test_duration_seconds(self):
|
||||
clip = Clip(
|
||||
name="test",
|
||||
start=Timecode(frames=0, frame_rate=24.0),
|
||||
duration=Timecode(frames=72, frame_rate=24.0),
|
||||
)
|
||||
assert clip.duration_seconds == pytest.approx(3.0)
|
||||
|
||||
def test_keyword_values(self):
|
||||
clip = Clip(
|
||||
name="test",
|
||||
start=Timecode(frames=0, frame_rate=24.0),
|
||||
duration=Timecode(frames=24, frame_rate=24.0),
|
||||
keywords=[Keyword(value="interview"), Keyword(value="broll")],
|
||||
)
|
||||
assert clip.keyword_values == ["interview", "broll"]
|
||||
|
||||
def test_keyword_values_empty(self):
|
||||
clip = Clip(
|
||||
name="test",
|
||||
start=Timecode(frames=0, frame_rate=24.0),
|
||||
duration=Timecode(frames=24, frame_rate=24.0),
|
||||
)
|
||||
assert clip.keyword_values == []
|
||||
|
||||
|
||||
class TestFlashFrame:
|
||||
|
||||
def test_is_critical(self):
|
||||
ff = FlashFrame(
|
||||
clip_name="bad_clip", clip_id="c1",
|
||||
start=Timecode(frames=0, frame_rate=24.0),
|
||||
duration_frames=1, duration_seconds=0.04,
|
||||
severity=FlashFrameSeverity.CRITICAL,
|
||||
)
|
||||
assert ff.is_critical is True
|
||||
|
||||
def test_is_not_critical(self):
|
||||
ff = FlashFrame(
|
||||
clip_name="short_clip", clip_id="c2",
|
||||
start=Timecode(frames=0, frame_rate=24.0),
|
||||
duration_frames=4, duration_seconds=0.17,
|
||||
severity=FlashFrameSeverity.WARNING,
|
||||
)
|
||||
assert ff.is_critical is False
|
||||
|
||||
|
||||
class TestGapInfo:
|
||||
|
||||
def test_timecode_property(self):
|
||||
gap = GapInfo(
|
||||
start=Timecode(frames=2700, frame_rate=30.0),
|
||||
duration_frames=30, duration_seconds=1.0,
|
||||
previous_clip="Clip_A", next_clip="Clip_B",
|
||||
)
|
||||
assert gap.timecode == "00:01:30:00"
|
||||
|
||||
|
||||
class TestValidationResult:
|
||||
|
||||
def test_summary_format(self):
|
||||
result = ValidationResult(
|
||||
is_valid=False, health_score=72,
|
||||
issues=[
|
||||
ValidationIssue(issue_type=ValidationIssueType.FLASH_FRAME, severity="error", message="e"),
|
||||
ValidationIssue(issue_type=ValidationIssueType.GAP, severity="warning", message="w"),
|
||||
],
|
||||
flash_frames=[
|
||||
FlashFrame("c", "c1", Timecode(0, 24.0), 1, 0.04, FlashFrameSeverity.CRITICAL),
|
||||
],
|
||||
gaps=[
|
||||
GapInfo(Timecode(0, 24.0), 10, 0.4),
|
||||
],
|
||||
)
|
||||
s = result.summary()
|
||||
assert "72%" in s
|
||||
assert "Errors: 1" in s
|
||||
assert "Warnings: 1" in s
|
||||
assert "Flash frames: 1" in s
|
||||
assert "Gaps: 1" in s
|
||||
|
||||
|
||||
class TestMarkerType:
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("todo", MarkerType.INCOMPLETE), # enum value, not an action item
|
||||
("TODO", MarkerType.INCOMPLETE), # enum value, not an action item # noqa
|
||||
("completed", MarkerType.COMPLETED),
|
||||
("COMPLETED", MarkerType.COMPLETED),
|
||||
("standard", MarkerType.STANDARD),
|
||||
("chapter", MarkerType.CHAPTER),
|
||||
])
|
||||
def test_from_string_current_values(self, value, expected):
|
||||
assert MarkerType.from_string(value) == expected
|
||||
|
||||
@pytest.mark.parametrize("alias,expected", [
|
||||
("todo-marker", MarkerType.INCOMPLETE),
|
||||
("completed-marker", MarkerType.COMPLETED),
|
||||
("chapter-marker", MarkerType.CHAPTER),
|
||||
])
|
||||
def test_from_string_legacy_aliases(self, alias, expected):
|
||||
"""Legacy spec values (e.g. 'todo-marker') resolve to current enum."""
|
||||
assert MarkerType.from_string(alias) == expected
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("Todo", MarkerType.INCOMPLETE),
|
||||
("tOdO", MarkerType.INCOMPLETE),
|
||||
("Completed", MarkerType.COMPLETED),
|
||||
("cOMPLETED", MarkerType.COMPLETED),
|
||||
("Standard", MarkerType.STANDARD),
|
||||
("CHAPTER", MarkerType.CHAPTER),
|
||||
])
|
||||
def test_from_string_mixed_case(self, value, expected):
|
||||
"""Case should never matter — FCP specs are inconsistent about casing."""
|
||||
assert MarkerType.from_string(value) == expected
|
||||
|
||||
@pytest.mark.parametrize("alias,expected", [
|
||||
(" todo-marker ", MarkerType.INCOMPLETE),
|
||||
(" completed-marker ", MarkerType.COMPLETED),
|
||||
("\tchapter-marker\t", MarkerType.CHAPTER),
|
||||
])
|
||||
def test_from_string_legacy_aliases_with_whitespace(self, alias, expected):
|
||||
"""Whitespace around legacy aliases must be stripped before matching."""
|
||||
assert MarkerType.from_string(alias) == expected
|
||||
|
||||
def test_enum_values_are_lowercase(self):
|
||||
"""Enum .value must be lowercase strings — they're used as dict keys."""
|
||||
assert MarkerType.INCOMPLETE.value == "todo"
|
||||
assert MarkerType.COMPLETED.value == "completed"
|
||||
assert MarkerType.STANDARD.value == "standard"
|
||||
assert MarkerType.CHAPTER.value == "chapter"
|
||||
|
||||
def test_from_string_invalid_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid marker type"):
|
||||
MarkerType.from_string("nonexistent")
|
||||
|
||||
def test_xml_tag_chapter_vs_marker(self):
|
||||
assert MarkerType.CHAPTER.xml_tag == "chapter-marker"
|
||||
assert MarkerType.INCOMPLETE.xml_tag == "marker"
|
||||
assert MarkerType.COMPLETED.xml_tag == "marker"
|
||||
assert MarkerType.STANDARD.xml_tag == "marker"
|
||||
|
||||
|
||||
class TestMarkerTypeXmlContract:
|
||||
"""Tests for MarkerType.from_xml_element and xml_attrs — the unified
|
||||
serialization contract that both parser and writer depend on."""
|
||||
|
||||
def test_from_xml_element_chapter(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('chapter-marker')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.CHAPTER
|
||||
|
||||
def test_from_xml_element_todo(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', '0')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.INCOMPLETE
|
||||
|
||||
def test_from_xml_element_completed(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', '1')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.COMPLETED
|
||||
|
||||
def test_from_xml_element_standard(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD
|
||||
|
||||
def test_from_xml_element_rejects_malformed_completed(self):
|
||||
"""Non-standard completed values (e.g. 'true') must fall to STANDARD."""
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', 'true')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD
|
||||
|
||||
def test_from_xml_element_whitespace_padded_zero_is_standard(self):
|
||||
"""Whitespace around '0' must NOT match INCOMPLETE — strict exact matching."""
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', ' 0 ')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD
|
||||
|
||||
def test_from_xml_element_whitespace_padded_one_is_standard(self):
|
||||
"""Whitespace around '1' must NOT match COMPLETED — strict exact matching."""
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', ' 1 ')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD
|
||||
|
||||
def test_from_xml_element_empty_completed_is_standard(self):
|
||||
"""An empty completed='' attribute must fall to STANDARD, not INCOMPLETE."""
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', '')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD
|
||||
|
||||
def test_from_xml_element_chapter_ignores_completed(self):
|
||||
"""A chapter-marker tag takes priority over any completed attribute."""
|
||||
import xml.etree.ElementTree as ET
|
||||
elem = ET.Element('chapter-marker')
|
||||
elem.set('completed', '0')
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.CHAPTER
|
||||
|
||||
def test_xml_attrs_todo(self):
|
||||
assert MarkerType.INCOMPLETE.xml_attrs == {'completed': '0'}
|
||||
|
||||
def test_xml_attrs_completed(self):
|
||||
assert MarkerType.COMPLETED.xml_attrs == {'completed': '1'}
|
||||
|
||||
def test_xml_attrs_chapter(self):
|
||||
assert MarkerType.CHAPTER.xml_attrs == {'posterOffset': '0s'}
|
||||
|
||||
def test_xml_attrs_standard_empty(self):
|
||||
assert MarkerType.STANDARD.xml_attrs == {}
|
||||
|
||||
def test_roundtrip_symmetry(self):
|
||||
"""from_xml_element(write(type)) == type for all marker types."""
|
||||
import xml.etree.ElementTree as ET
|
||||
for mt in MarkerType:
|
||||
elem = ET.Element(mt.xml_tag)
|
||||
for attr, val in mt.xml_attrs.items():
|
||||
elem.set(attr, val)
|
||||
assert MarkerType.from_xml_element(elem) == mt
|
||||
|
||||
|
||||
class TestTimeValueSnapToFrame:
|
||||
"""Tests for TimeValue.snap_to_frame — 2400-tick frame boundary snapping."""
|
||||
|
||||
def test_snap_exact_frame_is_noop(self):
|
||||
"""A value already on a frame boundary stays unchanged."""
|
||||
tv = TimeValue(100, 2400) # exactly 1 frame at 24fps (2400/24=100 ticks)
|
||||
snapped = tv.snap_to_frame(24)
|
||||
assert snapped.to_seconds() == pytest.approx(tv.to_seconds(), abs=1e-6)
|
||||
|
||||
def test_snap_between_frames_rounds_nearest(self):
|
||||
"""A value between frames snaps to the nearest one."""
|
||||
# 1.5 frames at 24fps = 150 ticks. Should snap to 100 or 200.
|
||||
tv = TimeValue(150, 2400)
|
||||
snapped = tv.snap_to_frame(24)
|
||||
assert snapped.denominator == 2400
|
||||
assert snapped.numerator in (100, 200)
|
||||
|
||||
def test_snap_30fps_frame_boundaries(self):
|
||||
"""30fps: each frame = 80 ticks. 85 ticks should snap to 80."""
|
||||
tv = TimeValue(85, 2400)
|
||||
snapped = tv.snap_to_frame(30)
|
||||
assert snapped.numerator == 80
|
||||
assert snapped.denominator == 2400
|
||||
|
||||
def test_snap_preserves_zero(self):
|
||||
tv = TimeValue(0, 24).snap_to_frame(24)
|
||||
assert tv.to_seconds() == 0.0
|
||||
|
||||
def test_snap_large_value_stays_aligned(self):
|
||||
"""10 seconds at 24fps should remain exactly 10 seconds."""
|
||||
tv = TimeValue(240, 24) # 10s
|
||||
snapped = tv.snap_to_frame(24)
|
||||
assert snapped.to_seconds() == pytest.approx(10.0, abs=1e-6)
|
||||
|
||||
|
||||
class TestTimeValueStandardTimebase:
|
||||
"""Tests for TimeValue.is_standard_timebase — FCP DTD denominator checks."""
|
||||
|
||||
@pytest.mark.parametrize("denom", [1, 24, 30, 60, 2400])
|
||||
def test_standard_denominators_accepted(self, denom):
|
||||
assert TimeValue(denom, denom).is_standard_timebase() is True
|
||||
|
||||
def test_non_standard_denominator_rejected(self):
|
||||
"""Denominators like 7 are not in any FCP timebase."""
|
||||
assert TimeValue(7, 7).is_standard_timebase() is True # simplifies to 1/1
|
||||
assert TimeValue(3, 7).is_standard_timebase() is False
|
||||
|
||||
def test_simplification_reveals_standard(self):
|
||||
"""72/24 simplifies to 3/1 — denominator 1 is standard."""
|
||||
assert TimeValue(72, 24).is_standard_timebase() is True
|
||||
|
||||
def test_simplification_reveals_non_standard(self):
|
||||
"""15/7 doesn't simplify to a standard timebase."""
|
||||
assert TimeValue(15, 7).is_standard_timebase() is False
|
||||
|
||||
|
||||
class TestTimeValueToFcpxmlEdgeCases:
|
||||
"""Tests for to_fcpxml fallback paths that prevent FCP DTD rejection."""
|
||||
|
||||
def test_whole_seconds_simplify(self):
|
||||
"""72/24 = 3 whole seconds → '3s'."""
|
||||
assert TimeValue(72, 24).to_fcpxml() == "3s"
|
||||
|
||||
def test_standard_timebase_simplifies(self):
|
||||
"""75/30 simplifies to 5/2 — but 2 is not standard, so stays '75/30s'."""
|
||||
result = TimeValue(75, 30).to_fcpxml()
|
||||
# Should NOT produce "5/2s" since 2 isn't a standard timebase
|
||||
assert "/2s" not in result or "75/30s" == result
|
||||
|
||||
def test_non_standard_denom_falls_back_to_original(self):
|
||||
"""If simplification yields a non-standard denom, keep the original."""
|
||||
# 7 ticks at denom 21 → simplifies to 1/3. 3 is non-standard.
|
||||
# Should fall back to "7/21s"
|
||||
tv = TimeValue(7, 21)
|
||||
result = tv.to_fcpxml()
|
||||
assert result == "7/21s"
|
||||
|
||||
def test_zero_is_always_zero_seconds(self):
|
||||
assert TimeValue(0, 2400).to_fcpxml() == "0s"
|
||||
|
||||
|
||||
class TestTimeValueArithmeticEdgeCases:
|
||||
"""Edge cases in TimeValue arithmetic that could produce incorrect frame values."""
|
||||
|
||||
def test_sub_negative_result(self):
|
||||
"""Subtraction producing negative time — used for offset calculations."""
|
||||
result = TimeValue(24, 24) - TimeValue(72, 24)
|
||||
assert result.to_seconds() == pytest.approx(-2.0)
|
||||
|
||||
def test_mul_by_zero(self):
|
||||
assert (TimeValue(72, 24) * 0).to_seconds() == 0.0
|
||||
|
||||
def test_div_preserves_value(self):
|
||||
"""Division should maintain the time value, not produce rounding drift."""
|
||||
tv = TimeValue(72, 24) # 3 seconds
|
||||
halved = tv / 2
|
||||
assert halved.to_seconds() == pytest.approx(1.5, abs=1e-6)
|
||||
|
||||
def test_div_fractional_scalar_rounds_not_truncates(self):
|
||||
"""Division by fractional scalar must round denominator, not truncate.
|
||||
|
||||
int(2400 * 0.3333...) = 799 (truncation bug), round() = 800 (correct).
|
||||
This was the root cause of silent time drift in speed-change operations.
|
||||
"""
|
||||
tv = TimeValue(100, 2400)
|
||||
result = tv / (1 / 3) # divide by 0.333... → multiply duration by 3
|
||||
assert result.denominator == 800 # round(2400 * 0.333...) = 800
|
||||
assert result.to_seconds() == pytest.approx(0.125, abs=1e-6)
|
||||
|
||||
def test_div_by_zero_raises_not_corrupts(self):
|
||||
"""Division by zero must raise, not create zombie TimeValue(n, 0)."""
|
||||
tv = TimeValue(100, 2400)
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
tv / 0
|
||||
|
||||
def test_div_rounding_to_zero_raises(self):
|
||||
"""Small scalars that round denominator to zero must raise, not corrupt."""
|
||||
tv = TimeValue(1, 1)
|
||||
with pytest.raises(ZeroDivisionError, match="rounds denominator"):
|
||||
tv / 0.3 # round(1 * 0.3) = 0
|
||||
|
||||
def test_div_mul_roundtrip(self):
|
||||
"""Multiply then divide should approximately restore original value."""
|
||||
tv = TimeValue(72, 24) # 3 seconds
|
||||
scaled = tv * 2.5
|
||||
restored = scaled / 2.5
|
||||
assert restored.to_seconds() == pytest.approx(tv.to_seconds(), abs=0.01)
|
||||
|
||||
def test_add_mismatched_timebases_lcm(self):
|
||||
"""24fps + 30fps values should use LCM denominator, not multiply."""
|
||||
a = TimeValue(24, 24) # 1 second
|
||||
b = TimeValue(30, 30) # 1 second
|
||||
result = a + b
|
||||
assert result.to_seconds() == pytest.approx(2.0)
|
||||
# LCM(24,30)=120, not 24*30=720
|
||||
assert result.denominator <= 720 # at minimum, shouldn't explode
|
||||
|
||||
def test_equality_across_timebases(self):
|
||||
"""1 second expressed in different timebases must be equal."""
|
||||
assert TimeValue(24, 24) == TimeValue(30, 30) == TimeValue(2400, 2400)
|
||||
|
||||
def test_inequality_near_boundary(self):
|
||||
"""Integer-exact comparison: different rational values are never equal."""
|
||||
a = TimeValue(24000, 24000) # exactly 1s
|
||||
b = TimeValue(24001, 24000) # 1.0000417s
|
||||
# Cross-multiplication gives exact comparison — no epsilon
|
||||
assert a != b # genuinely different values
|
||||
|
||||
def test_mul_fractional_scalar_rounds_correctly(self):
|
||||
"""Multiplying by 0.5 must round, not truncate — avoids half-tick loss."""
|
||||
# round(101 * 0.5) = 50 vs int(101 * 0.5) = 50 — same here, but
|
||||
# round is correct for odd numerators like 5 * 1.5:
|
||||
tv = TimeValue(5, 24)
|
||||
result = tv * 1.5
|
||||
# round(7.5) = 8, int(7.5) = 7 — round gives closer-to-correct result
|
||||
assert result.numerator == 8
|
||||
assert result.to_seconds() == pytest.approx(8 / 24, abs=1e-6)
|
||||
|
||||
def test_hash_equal_values_same_hash(self):
|
||||
"""Equal TimeValues must produce the same hash (dict/set contract)."""
|
||||
a = TimeValue(24, 24)
|
||||
b = TimeValue(30, 30)
|
||||
assert a == b
|
||||
assert hash(a) == hash(b)
|
||||
|
||||
def test_hash_usable_in_set(self):
|
||||
"""TimeValues must be usable as set elements."""
|
||||
s = {TimeValue(24, 24), TimeValue(30, 30), TimeValue(72, 24)}
|
||||
# 24/24 == 30/30 (both 1s), so set should have 2 elements
|
||||
assert len(s) == 2
|
||||
|
||||
def test_hash_usable_as_dict_key(self):
|
||||
"""TimeValues must be usable as dict keys."""
|
||||
d = {TimeValue(24, 24): "one_second"}
|
||||
assert d[TimeValue(30, 30)] == "one_second"
|
||||
|
||||
|
||||
class TestMarkerTypeAliasSemantics:
|
||||
"""MarkerType enum alias: TODO → INCOMPLETE (Python enum identity, not action items)."""
|
||||
|
||||
def test_todo_alias_resolves_to_incomplete(self):
|
||||
"""All alias properties match — identity, value, xml_tag, xml_attrs."""
|
||||
assert MarkerType.INCOMPLETE is MarkerType.TODO # enum alias check # noqa
|
||||
assert MarkerType.INCOMPLETE.value == "todo"
|
||||
assert MarkerType.INCOMPLETE.xml_tag == "marker"
|
||||
assert MarkerType.INCOMPLETE.xml_attrs == {'completed': '0'}
|
||||
|
||||
def test_from_string_returns_canonical(self):
|
||||
"""from_string('todo') returns the canonical INCOMPLETE member."""
|
||||
assert MarkerType.from_string("todo") is MarkerType.INCOMPLETE
|
||||
|
||||
def test_from_xml_element_numeric_completed_values(self):
|
||||
"""Only exact '0' and '1' are recognised — '2', '-1', '00' are STANDARD."""
|
||||
import xml.etree.ElementTree as ET
|
||||
for bad_val in ('2', '-1', '00', '01', '10'):
|
||||
elem = ET.Element('marker')
|
||||
elem.set('completed', bad_val)
|
||||
assert MarkerType.from_xml_element(elem) == MarkerType.STANDARD, (
|
||||
f"completed='{bad_val}' should be STANDARD, not a task marker"
|
||||
)
|
||||
|
||||
|
||||
class TestTimecodeEdgeCases:
|
||||
"""Edge cases in Timecode that could cause silent bugs in the parser."""
|
||||
|
||||
def test_zero_frames_smpte(self):
|
||||
assert Timecode(frames=0, frame_rate=24.0).to_smpte() == "00:00:00:00"
|
||||
|
||||
def test_one_frame_smpte(self):
|
||||
assert Timecode(frames=1, frame_rate=24.0).to_smpte() == "00:00:00:01"
|
||||
|
||||
def test_exactly_one_hour(self):
|
||||
tc = Timecode(frames=24 * 3600, frame_rate=24.0)
|
||||
assert tc.to_smpte() == "01:00:00:00"
|
||||
assert tc.seconds == pytest.approx(3600.0)
|
||||
|
||||
def test_to_time_value_roundtrip(self):
|
||||
"""Timecode → TimeValue → seconds should be lossless."""
|
||||
tc = Timecode(frames=2715, frame_rate=30.0)
|
||||
tv = tc.to_time_value()
|
||||
assert tv.to_seconds() == pytest.approx(tc.seconds, abs=1e-6)
|
||||
|
||||
|
||||
class TestTimeValueNegativeDenominator:
|
||||
"""Negative denominators must be normalized to preserve hash/eq/ordering."""
|
||||
|
||||
def test_negative_denom_normalized_at_construction(self):
|
||||
tv = TimeValue(1, -2)
|
||||
assert tv.numerator == -1
|
||||
assert tv.denominator == 2
|
||||
|
||||
def test_negative_denom_both_negative(self):
|
||||
tv = TimeValue(-3, -4)
|
||||
assert tv.numerator == 3
|
||||
assert tv.denominator == 4
|
||||
|
||||
def test_hash_contract_negative_denom(self):
|
||||
"""Equal values must produce identical hashes (Python data model)."""
|
||||
a = TimeValue(-1, 1)
|
||||
b = TimeValue(1, -1)
|
||||
assert a == b
|
||||
assert hash(a) == hash(b)
|
||||
|
||||
def test_set_dedup_negative_denom(self):
|
||||
s = {TimeValue(-1, 1), TimeValue(1, -1)}
|
||||
assert len(s) == 1
|
||||
|
||||
def test_lt_negative_denom(self):
|
||||
assert TimeValue(1, -2) < TimeValue(1, 2) # -0.5 < 0.5
|
||||
assert not (TimeValue(1, 2) < TimeValue(1, -2))
|
||||
|
||||
def test_division_by_negative_normalizes(self):
|
||||
tv = TimeValue(100, 2400) / -2.0
|
||||
assert tv.denominator > 0
|
||||
assert tv.to_seconds() == pytest.approx(-100 / 4800, abs=1e-9)
|
||||
|
||||
def test_to_fcpxml_negative_denom(self):
|
||||
"""Normalized negative should serialize cleanly."""
|
||||
tv = TimeValue(1, -2400)
|
||||
assert tv.to_fcpxml() == "-1/2400s"
|
||||
|
||||
|
||||
class TestTimeValueCrossMultiplicationEdgeCases:
|
||||
"""Verify integer-exact comparison survives edge cases that broke float-based comparison."""
|
||||
|
||||
def test_eq_non_timevalue_returns_false(self):
|
||||
"""__eq__ with non-TimeValue must return False, not raise."""
|
||||
assert TimeValue(1, 1) != 1
|
||||
assert TimeValue(1, 1) != 1.0
|
||||
assert TimeValue(1, 1) != "1/1s"
|
||||
assert TimeValue(0, 1) != None # noqa: E711
|
||||
|
||||
def test_total_ordering_le_gt_ge(self):
|
||||
"""@total_ordering derives __le__/__gt__/__ge__ from __lt__+__eq__.
|
||||
If the decorator were removed, these would raise TypeError."""
|
||||
a, b = TimeValue(1, 3), TimeValue(2, 3)
|
||||
assert a <= b
|
||||
assert a <= TimeValue(1, 3) # equal case
|
||||
assert b > a
|
||||
assert b >= a
|
||||
assert b >= TimeValue(2, 3) # equal case
|
||||
|
||||
def test_large_numerator_cross_multiply(self):
|
||||
"""Python arbitrary precision means cross-multiplication never overflows,
|
||||
but verify the comparison is actually correct with big numbers."""
|
||||
big = TimeValue(2**60, 1)
|
||||
small = TimeValue(1, 2**60)
|
||||
assert small < big
|
||||
assert big > small
|
||||
assert big != small
|
||||
|
||||
def test_hash_contract_different_denominators(self):
|
||||
"""Equivalent fractions must hash identically for dict/set correctness."""
|
||||
a = TimeValue(100, 2400)
|
||||
b = TimeValue(50, 1200)
|
||||
c = TimeValue(1, 24)
|
||||
assert a == b == c
|
||||
assert hash(a) == hash(b) == hash(c)
|
||||
assert len({a, b, c}) == 1
|
||||
|
||||
def test_zero_numerator_negative_denom(self):
|
||||
"""Zero with negative denominator normalizes cleanly."""
|
||||
tv = TimeValue(0, -1)
|
||||
assert tv.numerator == 0
|
||||
assert tv.denominator == 1
|
||||
assert tv == TimeValue(0, 1)
|
||||
assert hash(tv) == hash(TimeValue(0, 1))
|
||||
|
||||
def test_comparison_transitivity(self):
|
||||
"""If a < b and b < c then a < c — cross-multiplication must be transitive."""
|
||||
a = TimeValue(1, 7)
|
||||
b = TimeValue(1, 5)
|
||||
c = TimeValue(1, 3)
|
||||
assert a < b < c
|
||||
assert a < c # transitive
|
||||
|
||||
def test_sorted_rational_sequence(self):
|
||||
"""Sort a sequence of TimeValues and verify order matches float values."""
|
||||
vals = [TimeValue(n, 24) for n in [72, 12, 48, 24, 36]]
|
||||
sorted_vals = sorted(vals)
|
||||
sorted_seconds = [v.to_seconds() for v in sorted_vals]
|
||||
assert sorted_seconds == sorted(sorted_seconds)
|
||||
|
||||
def test_simplify_preserves_positive_denom(self):
|
||||
"""After simplify(), denominator must remain positive (regression guard)."""
|
||||
tv = TimeValue(-600, 2400).simplify()
|
||||
assert tv.denominator > 0
|
||||
assert tv.to_seconds() == pytest.approx(-0.25, abs=1e-9)
|
||||
|
||||
|
||||
class TestPacingConfig:
|
||||
|
||||
def test_pacing_ranges(self):
|
||||
assert PacingConfig(pacing="slow").get_duration_range() == (5.0, 10.0)
|
||||
assert PacingConfig(pacing="fast").get_duration_range() == (0.5, 2.0)
|
||||
assert PacingConfig(pacing="unknown").get_duration_range() == (2.0, 5.0)
|
||||
Executable
+384
@@ -0,0 +1,384 @@
|
||||
"""Tests for FCPXML parser."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import MarkerType
|
||||
from fcpxml.parser import FCPXMLParser, parse_fcpxml
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
|
||||
def _fcpxml(spine_content="", resources_extra="", project_name="Test",
|
||||
frame_dur="1/24s", seq_dur="240/24s"):
|
||||
"""Build a minimal FCPXML string for testing."""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<fcpxml version="1.11"><resources>'
|
||||
f'<format id="r1" frameDuration="{frame_dur}" width="1920" height="1080"/>'
|
||||
f'{resources_extra}</resources>'
|
||||
f'<library location="file:///lib.fcpbundle/"><event name="Evt">'
|
||||
f'<project name="{project_name}">'
|
||||
f'<sequence format="r1" duration="{seq_dur}"><spine>'
|
||||
f'{spine_content}</spine></sequence></project>'
|
||||
f'</event></library></fcpxml>'
|
||||
)
|
||||
|
||||
|
||||
ASSET_R2 = '<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="100s"/>'
|
||||
CLIP_A = '<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1"/>'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# File-based Parsing (existing)
|
||||
# ============================================================
|
||||
|
||||
def test_parse_sample():
|
||||
project = FCPXMLParser().parse_file(str(SAMPLE))
|
||||
assert project.name == "Music Video Edit"
|
||||
assert len(project.timelines) == 1
|
||||
|
||||
|
||||
def test_timeline_props():
|
||||
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
|
||||
assert tl.frame_rate == 24.0
|
||||
assert tl.width == 1920
|
||||
|
||||
|
||||
def test_clips_parsed():
|
||||
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
|
||||
assert len(tl.clips) > 0
|
||||
assert tl.clips[0].name == "Interview_A"
|
||||
|
||||
|
||||
def test_markers_parsed():
|
||||
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
|
||||
assert len([m for m in tl.markers if m.marker_type == MarkerType.CHAPTER]) == 4
|
||||
|
||||
|
||||
def test_short_clip_detection():
|
||||
assert len(FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
|
||||
.get_clips_shorter_than(0.5)) >= 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Library Clips
|
||||
# ============================================================
|
||||
|
||||
def test_get_library_clips_returns_assets():
|
||||
parser = FCPXMLParser()
|
||||
parser.parse_file(str(SAMPLE))
|
||||
assert len(parser.get_library_clips()) == 3
|
||||
|
||||
|
||||
def test_get_library_clips_includes_metadata():
|
||||
parser = FCPXMLParser()
|
||||
parser.parse_file(str(SAMPLE))
|
||||
interview = next(c for c in parser.get_library_clips() if c['name'] == 'Interview_A')
|
||||
assert interview['asset_id'] == 'r2'
|
||||
assert interview['duration_seconds'] == 300.0
|
||||
assert interview['src'] is not None
|
||||
|
||||
|
||||
def test_get_library_clips_filter_by_keyword():
|
||||
parser = FCPXMLParser()
|
||||
parser.parse_file(str(SAMPLE))
|
||||
assert len(parser.get_library_clips(keywords=['NonExistent'])) == 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# parse_string
|
||||
# ============================================================
|
||||
|
||||
def test_parse_string_basic():
|
||||
xml = _fcpxml(CLIP_A, ASSET_R2, project_name="MyProject")
|
||||
project = FCPXMLParser().parse_string(xml)
|
||||
assert project.name == "MyProject"
|
||||
assert len(project.timelines) == 1
|
||||
|
||||
|
||||
def test_parse_string_clip_properties():
|
||||
xml = _fcpxml(CLIP_A, ASSET_R2)
|
||||
clip = FCPXMLParser().parse_string(xml).primary_timeline.clips[0]
|
||||
assert clip.name == "A"
|
||||
assert clip.duration.seconds == pytest.approx(5.0, abs=0.01)
|
||||
assert clip.media_path == "file:///a.mov"
|
||||
|
||||
|
||||
def test_parse_string_matches_file():
|
||||
from_file = FCPXMLParser().parse_file(str(SAMPLE))
|
||||
from_string = FCPXMLParser().parse_string(SAMPLE.read_text())
|
||||
assert from_file.name == from_string.name
|
||||
assert len(from_file.timelines[0].clips) == len(from_string.timelines[0].clips)
|
||||
|
||||
|
||||
def test_parse_string_30fps():
|
||||
xml = _fcpxml(
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="300/30s" format="r1"/>',
|
||||
'<asset id="r2" name="A" src="a.mov" start="0s" duration="60s"/>',
|
||||
frame_dur="1/30s", seq_dur="300/30s",
|
||||
)
|
||||
assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == 30.0
|
||||
|
||||
|
||||
def test_parse_string_2997fps():
|
||||
xml = _fcpxml(
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="30000/1001s" format="r1"/>',
|
||||
'<asset id="r2" name="A" src="a.mov" start="0s" duration="60s"/>',
|
||||
frame_dur="1001/30000s", seq_dur="30000/1001s",
|
||||
)
|
||||
assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == pytest.approx(29.97, abs=0.01)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Markers
|
||||
# ============================================================
|
||||
|
||||
def test_standard_marker():
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<marker start="24/24s" duration="1/24s" value="Mark1"/></asset-clip>')
|
||||
clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0]
|
||||
assert clip.markers[0].marker_type == MarkerType.STANDARD
|
||||
assert clip.markers[0].name == "Mark1"
|
||||
|
||||
|
||||
def test_todo_marker():
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<marker start="24/24s" duration="1/24s" value="Fix" completed="0"/></asset-clip>')
|
||||
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
|
||||
assert m.marker_type == MarkerType.INCOMPLETE
|
||||
|
||||
|
||||
def test_completed_marker():
|
||||
"""Marker with completed='1' should be COMPLETED, not incomplete."""
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<marker start="24/24s" duration="1/24s" value="Done" completed="1"/></asset-clip>')
|
||||
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
|
||||
assert m.marker_type == MarkerType.COMPLETED
|
||||
|
||||
|
||||
def test_multiple_marker_types_on_one_clip():
|
||||
"""All four marker types coexist on a single clip without cross-contamination."""
|
||||
clip_xml = (
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<marker start="6/24s" duration="1/24s" value="Note"/>'
|
||||
'<marker start="12/24s" duration="1/24s" value="Task" completed="0"/>'
|
||||
'<marker start="18/24s" duration="1/24s" value="Done" completed="1"/>'
|
||||
'<chapter-marker start="24/24s" duration="1/24s" value="Ch1"/>'
|
||||
'</asset-clip>'
|
||||
)
|
||||
clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0]
|
||||
by_name = {m.name: m.marker_type for m in clip.markers}
|
||||
assert by_name["Note"] == MarkerType.STANDARD
|
||||
assert by_name["Task"] == MarkerType.INCOMPLETE
|
||||
assert by_name["Done"] == MarkerType.COMPLETED
|
||||
assert by_name["Ch1"] == MarkerType.CHAPTER
|
||||
|
||||
|
||||
def test_marker_without_completed_is_standard():
|
||||
"""A plain <marker> with no completed attribute must parse as STANDARD, not incomplete."""
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<marker start="6/24s" duration="1/24s" value="Plain"/></asset-clip>')
|
||||
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
|
||||
assert m.marker_type == MarkerType.STANDARD
|
||||
assert m.name == "Plain"
|
||||
|
||||
|
||||
# The strict exact-match tests below verify that only completed='0' and
|
||||
# completed='1' (no whitespace, no variants) produce non-STANDARD types.
|
||||
|
||||
@pytest.mark.parametrize("completed_val,label", [
|
||||
(" 0 ", "space-padded zero"),
|
||||
(" 1 ", "space-padded one"),
|
||||
("", "empty string"),
|
||||
("\n0\n", "newline-padded zero"),
|
||||
("\n1\n", "newline-padded one"),
|
||||
("\t0\t", "tab-padded zero"),
|
||||
("\r\n1\r\n", "crlf-padded one"),
|
||||
("00", "double-zero"),
|
||||
("true", "boolean-string true"),
|
||||
])
|
||||
def test_non_exact_completed_values_are_standard(completed_val, label):
|
||||
"""completed attribute must be exactly '0' or '1' — anything else is STANDARD."""
|
||||
clip_xml = (
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
f'<marker start="24/24s" duration="1/24s" value="{label}" completed="{completed_val}"/>'
|
||||
'</asset-clip>'
|
||||
)
|
||||
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
|
||||
assert m.marker_type == MarkerType.STANDARD, (
|
||||
f"completed='{completed_val}' ({label}) should be STANDARD, got {m.marker_type}"
|
||||
)
|
||||
|
||||
|
||||
def test_chapter_marker_ignores_completed_attribute():
|
||||
"""A <chapter-marker> with completed='0' must still parse as CHAPTER, not as a to-do."""
|
||||
clip_xml = (
|
||||
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
|
||||
'<chapter-marker start="24/24s" duration="1/24s" value="Ch" completed="0"/>'
|
||||
'</asset-clip>'
|
||||
)
|
||||
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
|
||||
assert m.marker_type == MarkerType.CHAPTER
|
||||
|
||||
|
||||
def test_chapter_markers_on_sequence():
|
||||
# Chapter markers are children of sequence (parsed via findall .//chapter-marker)
|
||||
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
|
||||
chapters = [m for m in tl.markers if m.marker_type == MarkerType.CHAPTER]
|
||||
assert len(chapters) == 4
|
||||
assert chapters[0].name == "Intro"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Keywords
|
||||
# ============================================================
|
||||
|
||||
def test_keyword_with_range():
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1">'
|
||||
'<keyword start="0s" duration="48/24s" value="Interview"/></asset-clip>')
|
||||
kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0]
|
||||
assert kw.start is not None
|
||||
assert kw.duration.seconds == pytest.approx(2.0, abs=0.01)
|
||||
|
||||
|
||||
def test_keyword_without_range():
|
||||
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1">'
|
||||
'<keyword value="B-Roll"/></asset-clip>')
|
||||
kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0]
|
||||
assert kw.start is None
|
||||
assert kw.duration is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Transitions & Gaps
|
||||
# ============================================================
|
||||
|
||||
def test_transition_parsed():
|
||||
spine = (CLIP_A
|
||||
+ '<transition name="Cross Dissolve" offset="120/24s" duration="24/24s"/>'
|
||||
+ '<asset-clip ref="r2" offset="120/24s" name="B" start="10s" duration="120/24s" format="r1"/>')
|
||||
tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2)).primary_timeline
|
||||
assert len(tl.transitions) == 1
|
||||
assert tl.transitions[0].name == "Cross Dissolve"
|
||||
assert len(tl.clips) == 2
|
||||
|
||||
|
||||
def test_gap_advances_offset():
|
||||
spine = (CLIP_A
|
||||
+ '<gap offset="120/24s" duration="48/24s"/>'
|
||||
+ '<asset-clip ref="r2" offset="168/24s" name="B" start="10s" duration="72/24s" format="r1"/>')
|
||||
tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2, seq_dur="240/24s")).primary_timeline
|
||||
assert len(tl.clips) == 2
|
||||
assert tl.clips[1].start.frames == 168 # 120 clip + 48 gap
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Error Handling
|
||||
# ============================================================
|
||||
|
||||
def test_parse_file_not_found():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
FCPXMLParser().parse_file("/nonexistent/path.fcpxml")
|
||||
|
||||
|
||||
def test_parse_string_invalid_xml():
|
||||
with pytest.raises(Exception):
|
||||
FCPXMLParser().parse_string("<not valid xml")
|
||||
|
||||
|
||||
def test_parse_empty_sequence():
|
||||
xml = ('<?xml version="1.0" encoding="UTF-8"?><fcpxml version="1.11"><resources>'
|
||||
'<format id="r1" frameDuration="1/24s" width="1920" height="1080"/></resources>'
|
||||
'<library location="file:///x/"><event name="E"><project name="Empty">'
|
||||
'<sequence format="r1" duration="0s"/></project></event></library></fcpxml>')
|
||||
project = FCPXMLParser().parse_string(xml)
|
||||
assert project.name == "Empty"
|
||||
assert len(project.primary_timeline.clips) == 0
|
||||
|
||||
|
||||
def test_parse_no_project():
|
||||
xml = '<?xml version="1.0" encoding="UTF-8"?><fcpxml version="1.11"><resources/></fcpxml>'
|
||||
project = FCPXMLParser().parse_string(xml)
|
||||
assert project.name == "Untitled"
|
||||
assert len(project.timelines) == 0
|
||||
|
||||
|
||||
def test_fcpxmld_missing_info():
|
||||
with tempfile.TemporaryDirectory(suffix=".fcpxmld") as tmpdir:
|
||||
with pytest.raises(FileNotFoundError, match="Info.fcpxml not found"):
|
||||
FCPXMLParser().parse_file(tmpdir)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers & Convenience
|
||||
# ============================================================
|
||||
|
||||
def test_duration_to_seconds_rational():
|
||||
assert FCPXMLParser()._parse_duration_to_seconds("150/30s") == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_duration_to_seconds_plain():
|
||||
assert FCPXMLParser()._parse_duration_to_seconds("10s") == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_duration_to_seconds_bare_number():
|
||||
assert FCPXMLParser()._parse_duration_to_seconds("5") == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_duration_to_seconds_zero_denominator():
|
||||
"""Zero-denominator rational returns 0.0 instead of crashing."""
|
||||
assert FCPXMLParser()._parse_duration_to_seconds("10/0s") == 0.0
|
||||
|
||||
|
||||
def test_duration_to_seconds_multiple_slashes():
|
||||
"""Malformed rational with extra slashes returns 0.0 (unparseable)."""
|
||||
# "10/20/30s" → int("20/30") raises ValueError inside TimeValue.from_timecode,
|
||||
# which _parse_duration_to_seconds catches and returns 0.0
|
||||
assert FCPXMLParser()._parse_duration_to_seconds("10/20/30s") == 0.0
|
||||
|
||||
|
||||
def test_parse_fcpxml_convenience():
|
||||
project = parse_fcpxml(str(SAMPLE))
|
||||
assert project.name == "Music Video Edit"
|
||||
assert len(project.timelines[0].clips) == 9
|
||||
|
||||
|
||||
def test_version_preserved():
|
||||
xml = _fcpxml(CLIP_A, ASSET_R2).replace('version="1.11"', 'version="1.10"')
|
||||
assert FCPXMLParser().parse_string(xml).fcpxml_version == "1.10"
|
||||
|
||||
|
||||
def test_default_version():
|
||||
assert FCPXMLParser().parse_string('<fcpxml><resources/></fcpxml>').fcpxml_version == "1.11"
|
||||
|
||||
|
||||
def test_asset_media_rep_fallback():
|
||||
"""Asset with no direct src should fall back to media-rep child src."""
|
||||
asset_xml = (
|
||||
'<asset id="r2" name="MediaRep" start="0s" duration="100s">'
|
||||
'<media-rep src="file:///video.mov" kind="original-media"/>'
|
||||
'</asset>'
|
||||
)
|
||||
xml = _fcpxml(CLIP_A.replace('ref="r2"', 'ref="r2"'), asset_xml)
|
||||
parser = FCPXMLParser()
|
||||
parser.parse_string(xml)
|
||||
assert parser.resources.get('r2', {}).get('src') == 'file:///video.mov'
|
||||
|
||||
|
||||
def test_asset_no_src_no_media_rep():
|
||||
"""Asset with neither src attr nor media-rep child should yield empty string, not crash."""
|
||||
asset_xml = '<asset id="r2" name="NoSrc" start="0s" duration="100s"/>'
|
||||
xml = _fcpxml(CLIP_A, asset_xml)
|
||||
parser = FCPXMLParser()
|
||||
parser.parse_string(xml)
|
||||
assert parser.resources.get('r2', {}).get('src') == ''
|
||||
|
||||
|
||||
def test_zero_denominator_frame_duration_raises():
|
||||
"""frameDuration with zero denominator must raise, not silently set fps=0."""
|
||||
xml = _fcpxml(CLIP_A, ASSET_R2, frame_dur="1/0s")
|
||||
with pytest.raises(ValueError, match="denominator"):
|
||||
FCPXMLParser().parse_string(xml)
|
||||
Executable
+516
@@ -0,0 +1,516 @@
|
||||
"""Tests for recently refactored helper functions.
|
||||
|
||||
Covers: _index_elements (writer), _iter_spine_clips (writer),
|
||||
_find_spine_clip_at_seconds (writer), _resolve_clip_duration (writer),
|
||||
_make_asset_clip (writer), _format_batch_result (server),
|
||||
and serialize_xml edge cases (safe_xml).
|
||||
|
||||
These helpers were extracted in v0.6.30–0.6.52 to eliminate duplication.
|
||||
Only tested indirectly through callers — these tests validate edge cases
|
||||
the callers never exercise.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import xml.etree.ElementTree as ET
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.safe_xml import serialize_xml
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shim the `mcp` package tree before importing server.py (same as test_server)
|
||||
# ---------------------------------------------------------------------------
|
||||
_NEEDS_SHIM = "mcp" not in sys.modules or "mcp.server" not in sys.modules
|
||||
|
||||
|
||||
def _install_mcp_shim():
|
||||
mcp = types.ModuleType("mcp")
|
||||
mcp_server = types.ModuleType("mcp.server")
|
||||
mcp_server_stdio = types.ModuleType("mcp.server.stdio")
|
||||
mcp_types = types.ModuleType("mcp.types")
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, *a, **kw): pass
|
||||
def call_tool(self): return lambda fn: fn
|
||||
def list_tools(self): return lambda fn: fn
|
||||
def list_resources(self): return lambda fn: fn
|
||||
def read_resource(self): return lambda fn: fn
|
||||
def list_prompts(self): return lambda fn: fn
|
||||
def get_prompt(self): return lambda fn: fn
|
||||
|
||||
mcp_server.Server = _FakeServer
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, *a): pass
|
||||
async def __aenter__(self): return (MagicMock(), MagicMock())
|
||||
async def __aexit__(self, *a): pass
|
||||
|
||||
mcp_server_stdio.stdio_server = _FakeCtx
|
||||
|
||||
class TextContent:
|
||||
def __init__(self, *, type: str, text: str):
|
||||
self.type = type
|
||||
self.text = text
|
||||
|
||||
for name in ("GetPromptResult", "Prompt", "PromptArgument",
|
||||
"PromptMessage", "Resource", "Tool"):
|
||||
setattr(mcp_types, name, MagicMock)
|
||||
mcp_types.TextContent = TextContent
|
||||
|
||||
sys.modules.setdefault("mcp", mcp)
|
||||
sys.modules.setdefault("mcp.server", mcp_server)
|
||||
sys.modules.setdefault("mcp.server.stdio", mcp_server_stdio)
|
||||
sys.modules.setdefault("mcp.types", mcp_types)
|
||||
|
||||
|
||||
if _NEEDS_SHIM:
|
||||
_install_mcp_shim()
|
||||
|
||||
from server import _format_batch_result, _markdown_table # noqa: E402, I001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal FCPXML fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SPINE_3_CLIPS = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<clip name="A" offset="0/2400s" duration="2400/2400s" start="0s" />
|
||||
<gap offset="2400/2400s" duration="1200/2400s" />
|
||||
<clip name="B" offset="3600/2400s" duration="2400/2400s" start="0s" />
|
||||
<asset-clip name="C" offset="6000/2400s" duration="2400/2400s" start="0s" />
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
EMPTY_SPINE = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine></spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
GAPS_ONLY_SPINE = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<gap offset="0/2400s" duration="2400/2400s" />
|
||||
<gap offset="2400/2400s" duration="1200/2400s" />
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
DUPLICATE_NAMES = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<clip name="Interview" offset="0/2400s" duration="2400/2400s" start="0s" />
|
||||
<clip name="Interview" offset="2400/2400s" duration="2400/2400s" start="0s" />
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
|
||||
def _make_modifier(xml_str: str) -> FCPXMLModifier:
|
||||
"""Create an FCPXMLModifier from an XML string via temp file."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.fcpxml', delete=False) as f:
|
||||
f.write(xml_str)
|
||||
path = f.name
|
||||
try:
|
||||
return FCPXMLModifier(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _index_elements
|
||||
# ===================================================================
|
||||
|
||||
class TestIndexElements:
|
||||
"""Tests for FCPXMLModifier._index_elements."""
|
||||
|
||||
def test_indexes_clips_by_name(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
assert "A" in mod.clips
|
||||
assert "B" in mod.clips
|
||||
|
||||
def test_indexes_asset_clips(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
assert "C" in mod.clips
|
||||
|
||||
def test_duplicate_names_last_wins(self):
|
||||
"""When multiple clips share a name, the last one indexed wins."""
|
||||
mod = _make_modifier(DUPLICATE_NAMES)
|
||||
# Both are named "Interview" — only one key exists
|
||||
clip = mod.clips["Interview"]
|
||||
# The last one has offset="2400/2400s"
|
||||
assert clip.get("offset") == "2400/2400s"
|
||||
|
||||
def test_fallback_prefix_when_no_id_or_name(self):
|
||||
"""Elements without id or name get a generated key."""
|
||||
xml = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<clip offset="0/2400s" duration="2400/2400s" start="0s" />
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
mod = _make_modifier(xml)
|
||||
# No name/id → fallback key "clip_0"
|
||||
assert "clip_0" in mod.clips
|
||||
|
||||
def test_id_preferred_over_name(self):
|
||||
"""When both id and name exist, id takes priority."""
|
||||
xml = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<clip id="r42" name="MyClip" offset="0/2400s" duration="2400/2400s" start="0s" />
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
mod = _make_modifier(xml)
|
||||
assert "r42" in mod.clips
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _iter_spine_clips
|
||||
# ===================================================================
|
||||
|
||||
class TestIterSpineClips:
|
||||
"""Tests for FCPXMLModifier._iter_spine_clips."""
|
||||
|
||||
def test_returns_only_clip_elements(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
clips = mod._iter_spine_clips()
|
||||
tags = [elem.tag for _, elem in clips]
|
||||
assert "gap" not in tags
|
||||
assert tags == ["clip", "clip", "asset-clip"]
|
||||
|
||||
def test_preserves_spine_index(self):
|
||||
"""Indices should be positions among ALL spine children, not just clips."""
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
clips = mod._iter_spine_clips()
|
||||
indices = [i for i, _ in clips]
|
||||
# spine has: clip(0), gap(1), clip(2), asset-clip(3)
|
||||
assert indices == [0, 2, 3]
|
||||
|
||||
def test_empty_spine_returns_empty(self):
|
||||
mod = _make_modifier(EMPTY_SPINE)
|
||||
assert mod._iter_spine_clips() == []
|
||||
|
||||
def test_gaps_only_spine_returns_empty(self):
|
||||
mod = _make_modifier(GAPS_ONLY_SPINE)
|
||||
assert mod._iter_spine_clips() == []
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _find_spine_clip_at_seconds
|
||||
# ===================================================================
|
||||
|
||||
class TestFindSpineClipAtSeconds:
|
||||
"""Tests for FCPXMLModifier._find_spine_clip_at_seconds."""
|
||||
|
||||
def test_finds_first_clip(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
clip, rel = mod._find_spine_clip_at_seconds(0.5)
|
||||
assert clip.get("name") == "A"
|
||||
assert abs(rel - 0.5) < 0.001
|
||||
|
||||
def test_finds_second_clip(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
# B starts at 3600/2400 = 1.5s
|
||||
clip, rel = mod._find_spine_clip_at_seconds(2.0)
|
||||
assert clip.get("name") == "B"
|
||||
assert abs(rel - 0.5) < 0.001
|
||||
|
||||
def test_exact_clip_boundary(self):
|
||||
"""Requesting exactly at clip start should find that clip."""
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
clip, rel = mod._find_spine_clip_at_seconds(0.0)
|
||||
assert clip.get("name") == "A"
|
||||
assert abs(rel) < 0.001
|
||||
|
||||
def test_raises_past_end(self):
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
with pytest.raises(ValueError, match="No spine clip"):
|
||||
mod._find_spine_clip_at_seconds(999.0)
|
||||
|
||||
def test_raises_in_gap(self):
|
||||
"""Position inside a gap (not a clip) should raise."""
|
||||
mod = _make_modifier(SPINE_3_CLIPS)
|
||||
# gap is at 2400/2400s=1.0s, dur 1200/2400s=0.5s → gap covers 1.0–1.5s
|
||||
with pytest.raises(ValueError, match="No spine clip"):
|
||||
mod._find_spine_clip_at_seconds(1.25)
|
||||
|
||||
def test_raises_on_empty_spine(self):
|
||||
mod = _make_modifier(EMPTY_SPINE)
|
||||
with pytest.raises(ValueError, match="No spine clip"):
|
||||
mod._find_spine_clip_at_seconds(0.0)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _resolve_clip_duration & _make_asset_clip (v0.6.52)
|
||||
# ===================================================================
|
||||
|
||||
ASSET_SPINE = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.10">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="Interview" duration="4800/2400s" start="0s"
|
||||
hasVideo="1" hasAudio="1" uid="AAAA"/>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TestProject">
|
||||
<sequence format="r1">
|
||||
<spine>
|
||||
<asset-clip ref="r2" name="Interview" offset="0/2400s"
|
||||
duration="4800/2400s" start="0s" format="r1"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
from fcpxml.models import TimeValue # noqa: E402
|
||||
|
||||
|
||||
class TestResolveClipDuration:
|
||||
"""Tests for FCPXMLModifier._resolve_clip_duration."""
|
||||
|
||||
def test_uses_in_out_points(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
asset = mod.resources['r2']
|
||||
dur, start = mod._resolve_clip_duration(
|
||||
asset, in_point="1200/2400s", out_point="3600/2400s",
|
||||
)
|
||||
assert dur == TimeValue(2400, 2400)
|
||||
assert start == TimeValue(1200, 2400)
|
||||
|
||||
def test_explicit_duration_overrides_asset(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
asset = mod.resources['r2']
|
||||
dur, start = mod._resolve_clip_duration(asset, duration="1200/2400s")
|
||||
assert dur == TimeValue(1200, 2400)
|
||||
assert start == TimeValue.zero()
|
||||
|
||||
def test_falls_back_to_asset_duration(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
asset = mod.resources['r2']
|
||||
dur, start = mod._resolve_clip_duration(asset)
|
||||
assert dur == TimeValue(4800, 2400)
|
||||
assert start == TimeValue.zero()
|
||||
|
||||
def test_in_out_takes_priority_over_duration(self):
|
||||
"""When both in/out and duration are given, in/out wins."""
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
asset = mod.resources['r2']
|
||||
dur, start = mod._resolve_clip_duration(
|
||||
asset, duration="600/2400s",
|
||||
in_point="0s", out_point="2400/2400s",
|
||||
)
|
||||
# in/out range = 2400/2400s, not the explicit 600/2400s
|
||||
assert dur == TimeValue(2400, 2400)
|
||||
|
||||
|
||||
class TestMakeAssetClip:
|
||||
"""Tests for FCPXMLModifier._make_asset_clip."""
|
||||
|
||||
def test_creates_detached_element(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
dur = TimeValue(2400, 2400)
|
||||
elem = mod._make_asset_clip(
|
||||
'r2', 'TestClip',
|
||||
TimeValue(0, 2400), TimeValue(0, 2400), dur,
|
||||
)
|
||||
assert elem.tag == 'asset-clip'
|
||||
assert elem.get('ref') == 'r2'
|
||||
assert elem.get('name') == 'TestClip'
|
||||
assert elem.get('duration') == dur.to_fcpxml()
|
||||
|
||||
def test_creates_subelement_when_parent_given(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
parent = ET.Element('clip')
|
||||
dur = TimeValue(1200, 2400)
|
||||
elem = mod._make_asset_clip(
|
||||
'r2', 'Child',
|
||||
TimeValue(0, 2400), TimeValue(0, 2400), dur,
|
||||
parent=parent,
|
||||
)
|
||||
assert elem in list(parent)
|
||||
assert elem.get('name') == 'Child'
|
||||
assert elem.get('duration') == dur.to_fcpxml()
|
||||
|
||||
def test_extra_attrs_applied(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
elem = mod._make_asset_clip(
|
||||
'r2', 'Audio',
|
||||
TimeValue(0, 2400), TimeValue(0, 2400), TimeValue(2400, 2400),
|
||||
lane='-1', audioRole='music',
|
||||
)
|
||||
assert elem.get('lane') == '-1'
|
||||
assert elem.get('audioRole') == 'music'
|
||||
|
||||
def test_format_attr_passthrough(self):
|
||||
mod = _make_modifier(ASSET_SPINE)
|
||||
elem = mod._make_asset_clip(
|
||||
'r2', 'Formatted',
|
||||
TimeValue(0, 2400), TimeValue(0, 2400), TimeValue(2400, 2400),
|
||||
format='r1',
|
||||
)
|
||||
assert elem.get('format') == 'r1'
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _format_batch_result (server.py)
|
||||
# ===================================================================
|
||||
|
||||
class TestFormatBatchResult:
|
||||
"""Tests for server._format_batch_result."""
|
||||
|
||||
def test_basic_structure(self):
|
||||
result = _format_batch_result(
|
||||
title="Flash Frames Fixed",
|
||||
summary={"Fixed": "3", "Skipped": "1"},
|
||||
headers=["Clip", "Action"],
|
||||
rows=[["A", "Removed"], ["B", "Trimmed"]],
|
||||
output_path="/tmp/out.fcpxml",
|
||||
)
|
||||
assert "# Flash Frames Fixed" in result
|
||||
assert "## Summary" in result
|
||||
assert "- **Fixed**: 3" in result
|
||||
assert "## Details" in result
|
||||
assert "| Clip | Action |" in result
|
||||
assert "Saved to: `/tmp/out.fcpxml`" in result
|
||||
|
||||
def test_empty_rows(self):
|
||||
result = _format_batch_result(
|
||||
title="Nothing Done",
|
||||
summary={"Total": "0"},
|
||||
headers=["Name"],
|
||||
rows=[],
|
||||
output_path="/tmp/empty.fcpxml",
|
||||
)
|
||||
assert "# Nothing Done" in result
|
||||
assert "| Name |" in result
|
||||
# No data rows but header and separator still present
|
||||
assert "Saved to:" in result
|
||||
|
||||
def test_markdown_table_alignment(self):
|
||||
table = _markdown_table(["A", "B"], [["1", "2"], ["3", "4"]])
|
||||
lines = table.strip().split("\n")
|
||||
assert len(lines) == 4 # header + sep + 2 data rows
|
||||
assert "---" in lines[1]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# serialize_xml edge cases (safe_xml.py)
|
||||
# ===================================================================
|
||||
|
||||
class TestSerializeXml:
|
||||
"""Tests for safe_xml.serialize_xml edge cases."""
|
||||
|
||||
def test_no_doctype(self):
|
||||
root = ET.Element("root")
|
||||
ET.SubElement(root, "child").text = "hello"
|
||||
with tempfile.NamedTemporaryFile(suffix='.xml', delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
serialize_xml(root, path)
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
assert '<?xml version="1.0" encoding="UTF-8"?>' in content
|
||||
assert "<!DOCTYPE" not in content
|
||||
assert "<child>hello</child>" in content
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_with_doctype(self):
|
||||
root = ET.Element("fcpxml")
|
||||
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
serialize_xml(root, path, doctype="<!DOCTYPE fcpxml>")
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
assert "<!DOCTYPE fcpxml>" in content
|
||||
assert content.index("<!DOCTYPE") > content.index("<?xml")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_strips_blank_lines(self):
|
||||
"""Output should not contain blank lines (minidom artifact)."""
|
||||
root = ET.Element("root")
|
||||
for i in range(3):
|
||||
ET.SubElement(root, f"item{i}")
|
||||
with tempfile.NamedTemporaryFile(suffix='.xml', delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
serialize_xml(root, path)
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
for line in content.split('\n'):
|
||||
if line: # non-empty lines should have content
|
||||
assert line.strip() != ""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
"""Tests for the relink_media bulk path-rewrite operation (v0.8.0)."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import server
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
RELINK_XML = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<fcpxml version="1.13"><resources>'
|
||||
'<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>'
|
||||
'<asset id="r2" name="Interview" start="0s" duration="100s">'
|
||||
'<media-rep kind="original-media" src="file:///Volumes/OldDrive/Media/My%20Footage/interview.mov"/>'
|
||||
'</asset>'
|
||||
'<asset id="r3" name="Broll" src="/Volumes/OldDrive/Media/broll.mov" start="0s" duration="50s"/>'
|
||||
'<asset id="r4" name="Elsewhere" src="file:///Volumes/OtherDrive/x.mov" start="0s" duration="10s"/>'
|
||||
'</resources>'
|
||||
'<library><event name="Evt"><project name="Relink Test">'
|
||||
'<sequence format="r1" duration="240/24s"><spine>'
|
||||
'<asset-clip ref="r2" offset="0s" name="Interview" start="0s" duration="120/24s" format="r1"/>'
|
||||
'</spine></sequence></project></event></library></fcpxml>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_file(tmp_path):
|
||||
f = tmp_path / "relink.fcpxml"
|
||||
f.write_text(RELINK_XML)
|
||||
return f
|
||||
|
||||
|
||||
def test_relink_url_and_plain_src(project_file):
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
result = modifier.relink_media("/Volumes/OldDrive/Media", "/Volumes/NewDrive/Footage")
|
||||
|
||||
assert result["relinked"] == 2
|
||||
assert result["total_assets"] == 3
|
||||
by_asset = {c["asset"]: c for c in result["changes"]}
|
||||
# file:// URL form preserved, percent-encoding intact
|
||||
assert by_asset["Interview"]["new"] == (
|
||||
"file:///Volumes/NewDrive/Footage/My%20Footage/interview.mov"
|
||||
)
|
||||
# plain path stays a plain path
|
||||
assert by_asset["Broll"]["new"] == "/Volumes/NewDrive/Footage/broll.mov"
|
||||
# non-matching asset untouched
|
||||
assert "Elsewhere" not in by_asset
|
||||
|
||||
|
||||
def test_relink_accepts_file_url_prefixes(project_file):
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
result = modifier.relink_media(
|
||||
"file:///Volumes/OldDrive/Media", "file:///Volumes/NewDrive"
|
||||
)
|
||||
assert result["relinked"] == 2
|
||||
|
||||
|
||||
def test_relink_whole_segment_matching(project_file):
|
||||
"""/Volumes/OldDrive/Med must NOT match /Volumes/OldDrive/Media/..."""
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
result = modifier.relink_media("/Volumes/OldDrive/Med", "/tmp/x")
|
||||
assert result["relinked"] == 0
|
||||
|
||||
|
||||
def test_relink_dry_run_does_not_mutate(project_file):
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
result = modifier.relink_media("/Volumes/OldDrive", "/Volumes/New", dry_run=True)
|
||||
assert result["relinked"] == 2
|
||||
assert result["dry_run"] is True
|
||||
# tree unchanged
|
||||
media_rep = modifier.root.find(".//media-rep")
|
||||
assert "OldDrive" in media_rep.get("src")
|
||||
|
||||
|
||||
def test_relink_target_exists_flag(project_file, tmp_path):
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
(real_dir / "broll.mov").write_bytes(b"x")
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
result = modifier.relink_media("/Volumes/OldDrive/Media", str(real_dir))
|
||||
by_asset = {c["asset"]: c for c in result["changes"]}
|
||||
assert by_asset["Broll"]["target_exists"] is True
|
||||
assert by_asset["Interview"]["target_exists"] is False
|
||||
|
||||
|
||||
def test_relink_empty_find_rejected(project_file):
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
modifier.relink_media("", "/tmp")
|
||||
|
||||
|
||||
def test_relink_persists_through_save(project_file, tmp_path):
|
||||
modifier = FCPXMLModifier(str(project_file))
|
||||
modifier.relink_media("/Volumes/OldDrive/Media", "/Volumes/NewDrive")
|
||||
out = tmp_path / "relinked.fcpxml"
|
||||
modifier.save(str(out))
|
||||
root = ET.parse(out).getroot()
|
||||
assert root.find(".//media-rep").get("src") == (
|
||||
"file:///Volumes/NewDrive/My%20Footage/interview.mov"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP handler
|
||||
# ============================================================
|
||||
|
||||
async def test_handler_relink(project_file):
|
||||
result = await server.handle_relink_media({
|
||||
"filepath": str(project_file),
|
||||
"find": "/Volumes/OldDrive/Media",
|
||||
"replace": "/Volumes/NewDrive",
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Relinked 2 media reference(s)" in text
|
||||
assert "_relinked" in text
|
||||
|
||||
|
||||
async def test_handler_relink_dry_run(project_file):
|
||||
result = await server.handle_relink_media({
|
||||
"filepath": str(project_file),
|
||||
"find": "/Volumes/OldDrive/Media",
|
||||
"replace": "/Volumes/NewDrive",
|
||||
"dry_run": True,
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Would relink 2" in text
|
||||
assert "Dry run" in text
|
||||
# No output file created
|
||||
assert not (project_file.parent / "relink_relinked.fcpxml").exists()
|
||||
|
||||
|
||||
async def test_handler_relink_no_match(project_file):
|
||||
result = await server.handle_relink_media({
|
||||
"filepath": str(project_file),
|
||||
"find": "/Nonexistent/Prefix",
|
||||
"replace": "/tmp",
|
||||
})
|
||||
assert "Nothing to relink" in result[0].text
|
||||
|
||||
|
||||
def test_relink_registered_in_dispatch():
|
||||
assert "relink_media" in server.TOOL_HANDLERS
|
||||
Executable
+685
@@ -0,0 +1,685 @@
|
||||
"""Tests for RoughCutGenerator — the flagship rough cut generation feature.
|
||||
|
||||
Covers: __init__, _detect_fps, _index_clips, _extract_clip_data, _index_resources,
|
||||
generate, _parse_duration, _filter_clips, _select_clips_simple,
|
||||
_select_clips_by_segments, _build_ab_sequence, _build_output,
|
||||
generate_rough_cut, generate_segmented_rough_cut convenience functions.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import PacingConfig, RoughCutResult, SegmentSpec, TimeValue
|
||||
from fcpxml.rough_cut import (
|
||||
RoughCutGenerator,
|
||||
generate_rough_cut,
|
||||
generate_segmented_rough_cut,
|
||||
)
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_fcpxml():
|
||||
"""Create a temp copy of sample.fcpxml for tests."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
shutil.copy(SAMPLE, f.name)
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output():
|
||||
"""Create a temp file path for output."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generator(temp_fcpxml):
|
||||
"""Pre-loaded RoughCutGenerator from sample."""
|
||||
return RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Initialization & Indexing
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_detect_fps(generator):
|
||||
"""Should detect 24fps from sample.fcpxml's frameDuration='1/24s'."""
|
||||
assert generator.fps == 24.0
|
||||
|
||||
|
||||
def test_detect_fps_default():
|
||||
"""Should default to 30fps when format has no frameDuration."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" name="test"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="C" duration="10s"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
assert gen.fps == 30.0
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_index_clips_count(generator):
|
||||
"""Sample has 9 asset-clips in spine — all should be indexed."""
|
||||
assert len(generator.clips) == 9
|
||||
|
||||
|
||||
def test_index_clips_extracts_names(generator):
|
||||
"""Clip names should match the FCPXML source."""
|
||||
names = [c["name"] for c in generator.clips]
|
||||
assert "Interview_A" in names
|
||||
assert "Broll_City" in names
|
||||
assert "Broll_Studio" in names
|
||||
|
||||
|
||||
def test_index_clips_extracts_keywords(generator):
|
||||
"""Keywords should be extracted from <keyword> children."""
|
||||
interview_clips = [c for c in generator.clips if "Interview" in c["keywords"]]
|
||||
broll_clips = [c for c in generator.clips if "B-Roll" in c["keywords"]]
|
||||
assert len(interview_clips) == 3
|
||||
assert len(broll_clips) == 2
|
||||
|
||||
|
||||
def test_index_clips_skips_very_short():
|
||||
"""Clips shorter than 0.1s should be skipped by _extract_clip_data."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="Tiny" duration="1/24s"/>
|
||||
<asset-clip ref="r1" name="Normal" duration="48/24s"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
names = [c["name"] for c in gen.clips]
|
||||
assert "Normal" in names
|
||||
# 1/24s = 0.0417s < 0.1s threshold, should be skipped
|
||||
assert "Tiny" not in names
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_index_resources(generator):
|
||||
"""Should index assets r2, r3, r4 from sample."""
|
||||
assert "r2" in generator.resources
|
||||
assert "r3" in generator.resources
|
||||
assert "r4" in generator.resources
|
||||
|
||||
|
||||
def test_index_formats(generator):
|
||||
"""Should index format r1 from sample."""
|
||||
assert "r1" in generator.formats
|
||||
|
||||
|
||||
def test_extract_clip_data_favorites():
|
||||
"""Should detect favorited and rejected clips."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="Fav" duration="48/24s" rating="1"/>
|
||||
<asset-clip ref="r1" name="Rej" duration="48/24s" rating="-1"/>
|
||||
<asset-clip ref="r1" name="Neutral" duration="48/24s"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
by_name = {c["name"]: c for c in gen.clips}
|
||||
assert by_name["Fav"]["is_favorite"] is True
|
||||
assert by_name["Fav"]["is_rejected"] is False
|
||||
assert by_name["Rej"]["is_rejected"] is True
|
||||
assert by_name["Neutral"]["is_favorite"] is False
|
||||
assert by_name["Neutral"]["is_rejected"] is False
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _parse_duration
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_parse_duration_shorthand_minutes(generator):
|
||||
"""'3m' should parse to 180 seconds."""
|
||||
tv = generator._parse_duration("3m")
|
||||
assert abs(tv.to_seconds() - 180.0) < 0.1
|
||||
|
||||
|
||||
def test_parse_duration_shorthand_minutes_seconds(generator):
|
||||
"""'3m30s' should parse to 210 seconds."""
|
||||
tv = generator._parse_duration("3m30s")
|
||||
assert abs(tv.to_seconds() - 210.0) < 0.1
|
||||
|
||||
|
||||
def test_parse_duration_shorthand_minutes_only_trailing_s(generator):
|
||||
"""'2m' should parse to 120 seconds (no trailing 's' after empty seconds)."""
|
||||
tv = generator._parse_duration("2m")
|
||||
assert abs(tv.to_seconds() - 120.0) < 0.1
|
||||
|
||||
|
||||
def test_parse_duration_timecode(generator):
|
||||
"""'00:00:10:00' timecode should parse to 10 seconds at 24fps."""
|
||||
tv = generator._parse_duration("00:00:10:00")
|
||||
assert abs(tv.to_seconds() - 10.0) < 0.1
|
||||
|
||||
|
||||
def test_parse_duration_rational(generator):
|
||||
"""'240/24s' should parse to 10 seconds."""
|
||||
tv = generator._parse_duration("240/24s")
|
||||
assert abs(tv.to_seconds() - 10.0) < 0.1
|
||||
|
||||
|
||||
def test_parse_duration_fractional_seconds(generator):
|
||||
"""'1m30.5s' should parse to 90.5 seconds, not crash."""
|
||||
tv = generator._parse_duration("1m30.5s")
|
||||
assert abs(tv.to_seconds() - 90.5) < 0.5
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _filter_clips
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_filter_clips_no_filters(generator):
|
||||
"""No filters should return all non-rejected clips (sample has none rejected)."""
|
||||
result = generator._filter_clips()
|
||||
assert len(result) == 9
|
||||
|
||||
|
||||
def test_filter_clips_by_keyword(generator):
|
||||
"""Filtering by 'Interview' keyword should return 3 clips."""
|
||||
result = generator._filter_clips(keywords=["Interview"])
|
||||
assert len(result) == 3
|
||||
for clip in result:
|
||||
assert "Interview" in clip["keywords"]
|
||||
|
||||
|
||||
def test_filter_clips_keyword_case_insensitive(generator):
|
||||
"""Keyword filtering should be case-insensitive."""
|
||||
result = generator._filter_clips(keywords=["interview"])
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_filter_clips_exclude_rejected():
|
||||
"""Should exclude rejected clips when exclude_rejected=True."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="Good" duration="48/24s"/>
|
||||
<asset-clip ref="r1" name="Bad" duration="48/24s" rating="-1"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
result = gen._filter_clips(exclude_rejected=True)
|
||||
names = [c["name"] for c in result]
|
||||
assert "Good" in names
|
||||
assert "Bad" not in names
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_filter_clips_include_rejected():
|
||||
"""Should include rejected clips when exclude_rejected=False."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="Good" duration="48/24s"/>
|
||||
<asset-clip ref="r1" name="Bad" duration="48/24s" rating="-1"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
result = gen._filter_clips(exclude_rejected=False)
|
||||
names = [c["name"] for c in result]
|
||||
assert "Good" in names
|
||||
assert "Bad" in names
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_filter_clips_favorites_only():
|
||||
"""Should return only favorited clips when favorites_only=True."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="Fav" duration="48/24s" rating="1"/>
|
||||
<asset-clip ref="r1" name="Neutral" duration="48/24s"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
result = gen._filter_clips(favorites_only=True)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Fav"
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _select_clips_simple — priority modes
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_select_clips_simple_respects_target(generator):
|
||||
"""Should stop selecting when target duration is reached."""
|
||||
clips = generator._filter_clips()
|
||||
target = TimeValue.from_seconds(5.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
selected = generator._select_clips_simple(clips, target, pacing, "best")
|
||||
total = sum(c["use_duration"].to_seconds() for c in selected)
|
||||
assert total <= 6.0 # Should be close to 5s target
|
||||
|
||||
|
||||
def test_select_clips_simple_priority_longest(generator):
|
||||
"""Priority 'longest' should select longest clips first."""
|
||||
clips = generator._filter_clips()
|
||||
target = TimeValue.from_seconds(60.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="slow")
|
||||
selected = generator._select_clips_simple(clips, target, pacing, "longest")
|
||||
# First selected clip should be the longest available
|
||||
assert len(selected) > 0
|
||||
durations = [c["use_duration"].to_seconds() for c in selected]
|
||||
# First clip duration should be >= second (sorted descending by source duration)
|
||||
if len(durations) > 1:
|
||||
assert durations[0] >= durations[1] - 0.1 # tolerance for pacing clamp
|
||||
|
||||
|
||||
def test_select_clips_simple_priority_shortest(generator):
|
||||
"""Priority 'shortest' should select shortest clips first."""
|
||||
clips = generator._filter_clips()
|
||||
target = TimeValue.from_seconds(60.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="fast")
|
||||
selected = generator._select_clips_simple(clips, target, pacing, "shortest")
|
||||
assert len(selected) > 0
|
||||
|
||||
|
||||
def test_select_clips_simple_adds_in_out_points(generator):
|
||||
"""Selected clips should have in_point and out_point set."""
|
||||
clips = generator._filter_clips()
|
||||
target = TimeValue.from_seconds(10.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
selected = generator._select_clips_simple(clips, target, pacing, "best")
|
||||
for clip in selected:
|
||||
assert "in_point" in clip
|
||||
assert "out_point" in clip
|
||||
assert "use_duration" in clip
|
||||
assert isinstance(clip["in_point"], TimeValue)
|
||||
assert isinstance(clip["out_point"], TimeValue)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _select_clips_by_segments
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_select_clips_by_segments_uses_keywords(generator):
|
||||
"""Segments with keywords should only select matching clips."""
|
||||
clips = generator._filter_clips()
|
||||
segments = [
|
||||
SegmentSpec(name="Interviews", keywords=["Interview"], duration_seconds=10),
|
||||
SegmentSpec(name="B-Roll", keywords=["B-Roll"], duration_seconds=5),
|
||||
]
|
||||
target = TimeValue.from_seconds(15.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
|
||||
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
|
||||
|
||||
# Check segment labels were applied
|
||||
interview_clips = [c for c in selected if c.get("segment") == "Interviews"]
|
||||
broll_clips = [c for c in selected if c.get("segment") == "B-Roll"]
|
||||
assert len(interview_clips) > 0
|
||||
assert len(broll_clips) > 0
|
||||
|
||||
|
||||
def test_select_clips_by_segments_distributes_unspecified_duration(generator):
|
||||
"""Segments without explicit duration should share remaining time equally."""
|
||||
clips = generator._filter_clips()
|
||||
# Total target = 20s, first segment = 10s, second should get ~10s
|
||||
segments = [
|
||||
SegmentSpec(name="Fixed", keywords=["Interview"], duration_seconds=10),
|
||||
SegmentSpec(name="Auto", keywords=["B-Roll"], duration_seconds=0),
|
||||
]
|
||||
target = TimeValue.from_seconds(20.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
|
||||
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
|
||||
auto_clips = [c for c in selected if c.get("segment") == "Auto"]
|
||||
assert len(auto_clips) > 0
|
||||
|
||||
|
||||
def test_select_clips_by_segments_overcommitted_clamps_to_zero(generator):
|
||||
"""When specified segments exceed target, unspecified segments get 0 not negative."""
|
||||
clips = generator._filter_clips()
|
||||
# Target=10s but specified segments total 30s — unspecified should get 0, not -20
|
||||
segments = [
|
||||
SegmentSpec(name="Big1", keywords=None, duration_seconds=15),
|
||||
SegmentSpec(name="Big2", keywords=None, duration_seconds=15),
|
||||
SegmentSpec(name="Leftover", keywords=None, duration_seconds=0),
|
||||
]
|
||||
target = TimeValue.from_seconds(10.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
|
||||
# Should not crash and leftover segment should produce no negative durations
|
||||
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
|
||||
leftover_clips = [c for c in selected if c.get("segment") == "Leftover"]
|
||||
for c in leftover_clips:
|
||||
assert c.get('duration_seconds', 0) >= 0
|
||||
|
||||
|
||||
def test_select_clips_by_segments_no_reuse_across_segments(generator):
|
||||
"""Clips used in one segment must not appear in subsequent segments."""
|
||||
clips = generator._filter_clips()
|
||||
# Two segments, both want all clips — second should get different ones
|
||||
segments = [
|
||||
SegmentSpec(name="Seg1", keywords=None, duration_seconds=5),
|
||||
SegmentSpec(name="Seg2", keywords=None, duration_seconds=5),
|
||||
]
|
||||
target = TimeValue.from_seconds(10.0, generator.fps)
|
||||
pacing = PacingConfig(pacing="medium")
|
||||
|
||||
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
|
||||
seg1_names = [c['name'] for c in selected if c.get('segment') == 'Seg1']
|
||||
seg2_names = [c['name'] for c in selected if c.get('segment') == 'Seg2']
|
||||
# No clip name should appear in both segments
|
||||
overlap = set(seg1_names) & set(seg2_names)
|
||||
assert len(overlap) == 0, f"Clips reused across segments: {overlap}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate() — full pipeline
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_generate_creates_file(generator, temp_output):
|
||||
"""generate() should create a valid FCPXML output file."""
|
||||
result = generator.generate(output_path=temp_output, target_duration="10s")
|
||||
assert Path(temp_output).exists()
|
||||
assert isinstance(result, RoughCutResult)
|
||||
assert result.clips_used > 0
|
||||
|
||||
|
||||
def test_generate_returns_correct_result(generator, temp_output):
|
||||
"""RoughCutResult should have all expected fields populated."""
|
||||
result = generator.generate(
|
||||
output_path=temp_output, target_duration="15s", pacing="fast"
|
||||
)
|
||||
assert result.output_path == temp_output
|
||||
assert result.clips_available > 0
|
||||
assert result.clips_used > 0
|
||||
assert result.target_duration > 0
|
||||
assert result.actual_duration > 0
|
||||
assert result.segments == 1 # No segments specified
|
||||
assert result.average_clip_duration > 0
|
||||
|
||||
|
||||
def test_generate_with_keyword_filter(generator, temp_output):
|
||||
"""generate() with keywords should only use matching clips."""
|
||||
result = generator.generate(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
keywords=["Interview"],
|
||||
)
|
||||
assert result.clips_available == 3 # Only Interview-tagged clips
|
||||
|
||||
|
||||
def test_generate_no_matching_clips_raises(generator, temp_output):
|
||||
"""generate() should raise ValueError when no clips match filters."""
|
||||
with pytest.raises(ValueError, match="No clips match"):
|
||||
generator.generate(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
keywords=["nonexistent_keyword"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_with_segments(generator, temp_output):
|
||||
"""generate() with segments should report correct segment count."""
|
||||
segments = [
|
||||
SegmentSpec(name="Intro", keywords=["Interview"], duration_seconds=5),
|
||||
SegmentSpec(name="Outro", keywords=["B-Roll"], duration_seconds=5),
|
||||
]
|
||||
result = generator.generate(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
segments=segments,
|
||||
)
|
||||
assert result.segments == 2
|
||||
assert result.clips_used > 0
|
||||
|
||||
|
||||
def test_generate_output_is_valid_xml(generator, temp_output):
|
||||
"""Output FCPXML should be parseable XML with correct structure."""
|
||||
generator.generate(output_path=temp_output, target_duration="10s")
|
||||
tree = ET.parse(temp_output)
|
||||
root = tree.getroot()
|
||||
assert root.tag == "fcpxml"
|
||||
assert root.find(".//spine") is not None
|
||||
assert len(root.findall(".//asset-clip")) > 0
|
||||
|
||||
|
||||
def test_generate_favorites_only(temp_output):
|
||||
"""favorites_only=True with no favorites should raise ValueError."""
|
||||
xml = """<?xml version="1.0"?>
|
||||
<fcpxml version="1.11">
|
||||
<resources><format id="r1" frameDuration="1/24s"/></resources>
|
||||
<library><event name="E"><project name="P">
|
||||
<sequence format="r1" duration="100s"><spine>
|
||||
<asset-clip ref="r1" name="C1" duration="48/24s"/>
|
||||
<asset-clip ref="r1" name="C2" duration="48/24s"/>
|
||||
</spine></sequence>
|
||||
</project></event></library>
|
||||
</fcpxml>"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
|
||||
f.write(xml)
|
||||
f.flush()
|
||||
gen = RoughCutGenerator(f.name)
|
||||
with pytest.raises(ValueError, match="No clips match"):
|
||||
gen.generate(
|
||||
output_path=temp_output,
|
||||
target_duration="5s",
|
||||
favorites_only=True,
|
||||
)
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_output
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_build_output_xml_structure(generator, temp_output):
|
||||
"""Output should have fcpxml > resources, library > event > project > sequence > spine."""
|
||||
generator.generate(output_path=temp_output, target_duration="10s")
|
||||
tree = ET.parse(temp_output)
|
||||
root = tree.getroot()
|
||||
assert root.find("resources") is not None
|
||||
assert root.find("library") is not None
|
||||
assert root.find(".//event") is not None
|
||||
assert root.find(".//project") is not None
|
||||
assert root.find(".//sequence") is not None
|
||||
|
||||
|
||||
def test_build_output_copies_resources(generator, temp_output):
|
||||
"""Output should contain format and asset resources for used clips."""
|
||||
generator.generate(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
keywords=["Interview"],
|
||||
)
|
||||
tree = ET.parse(temp_output)
|
||||
root = tree.getroot()
|
||||
resources = root.find("resources")
|
||||
# Should have at least a format element
|
||||
assert resources.find("format") is not None
|
||||
|
||||
|
||||
def test_build_output_doctype(generator, temp_output):
|
||||
"""Output should start with XML declaration and DOCTYPE."""
|
||||
generator.generate(output_path=temp_output, target_duration="10s")
|
||||
content = Path(temp_output).read_text()
|
||||
assert '<?xml version="1.0" encoding="UTF-8"?>' in content
|
||||
assert "<!DOCTYPE fcpxml>" in content
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_ab_sequence
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_build_ab_sequence_alternates(generator):
|
||||
"""A/B sequence should alternate between roll types."""
|
||||
a_clips = generator._filter_clips(keywords=["Interview"])
|
||||
b_clips = generator._filter_clips(keywords=["B-Roll"])
|
||||
target = TimeValue.from_seconds(20.0, generator.fps)
|
||||
a_dur = TimeValue.from_seconds(5.0, generator.fps)
|
||||
b_dur = TimeValue.from_seconds(3.0, generator.fps)
|
||||
|
||||
selected = generator._build_ab_sequence(
|
||||
a_clips, b_clips, target, a_dur, b_dur, "a"
|
||||
)
|
||||
|
||||
# First clip should be A-roll, second should be B-roll
|
||||
assert selected[0]["roll_type"] == "A"
|
||||
assert selected[1]["roll_type"] == "B"
|
||||
|
||||
|
||||
def test_build_ab_sequence_starts_with_b(generator):
|
||||
"""start_with='b' should begin with B-roll."""
|
||||
a_clips = generator._filter_clips(keywords=["Interview"])
|
||||
b_clips = generator._filter_clips(keywords=["B-Roll"])
|
||||
target = TimeValue.from_seconds(10.0, generator.fps)
|
||||
a_dur = TimeValue.from_seconds(3.0, generator.fps)
|
||||
b_dur = TimeValue.from_seconds(3.0, generator.fps)
|
||||
|
||||
selected = generator._build_ab_sequence(
|
||||
a_clips, b_clips, target, a_dur, b_dur, "b"
|
||||
)
|
||||
|
||||
assert selected[0]["roll_type"] == "B"
|
||||
|
||||
|
||||
def test_build_ab_sequence_loops_clips(generator):
|
||||
"""Should loop clips when pool is exhausted before target duration."""
|
||||
a_clips = generator._filter_clips(keywords=["Interview"]) # 3 clips
|
||||
b_clips = generator._filter_clips(keywords=["B-Roll"]) # 2 clips
|
||||
# Set a long target to force looping
|
||||
target = TimeValue.from_seconds(120.0, generator.fps)
|
||||
a_dur = TimeValue.from_seconds(3.0, generator.fps)
|
||||
b_dur = TimeValue.from_seconds(3.0, generator.fps)
|
||||
|
||||
selected = generator._build_ab_sequence(
|
||||
a_clips, b_clips, target, a_dur, b_dur, "a"
|
||||
)
|
||||
|
||||
# Should have more clips than available in either pool (looped)
|
||||
a_count = sum(1 for c in selected if c["roll_type"] == "A")
|
||||
b_count = sum(1 for c in selected if c["roll_type"] == "B")
|
||||
assert a_count > len(a_clips) or b_count > len(b_clips)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate_ab_roll — error cases
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_generate_ab_roll_no_a_clips_raises(generator, temp_output):
|
||||
"""Should raise ValueError when no A-roll clips match keywords."""
|
||||
with pytest.raises(ValueError, match="No A-roll clips found"):
|
||||
generator.generate_ab_roll(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
a_keywords=["nonexistent"],
|
||||
b_keywords=["B-Roll"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_ab_roll_no_b_clips_raises(generator, temp_output):
|
||||
"""Should raise ValueError when no B-roll clips match keywords."""
|
||||
with pytest.raises(ValueError, match="No B-roll clips found"):
|
||||
generator.generate_ab_roll(
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
a_keywords=["Interview"],
|
||||
b_keywords=["nonexistent"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Convenience functions
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_generate_rough_cut_convenience(temp_fcpxml, temp_output):
|
||||
"""generate_rough_cut() convenience function should produce output."""
|
||||
result = generate_rough_cut(
|
||||
source_fcpxml=temp_fcpxml,
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
pacing="fast",
|
||||
)
|
||||
assert isinstance(result, RoughCutResult)
|
||||
assert Path(temp_output).exists()
|
||||
assert result.clips_used > 0
|
||||
|
||||
|
||||
def test_generate_rough_cut_with_keywords(temp_fcpxml, temp_output):
|
||||
"""Convenience function should pass keyword filter through."""
|
||||
result = generate_rough_cut(
|
||||
source_fcpxml=temp_fcpxml,
|
||||
output_path=temp_output,
|
||||
target_duration="10s",
|
||||
keywords=["Interview"],
|
||||
)
|
||||
assert result.clips_available == 3
|
||||
|
||||
|
||||
def test_generate_segmented_rough_cut_convenience(temp_fcpxml, temp_output):
|
||||
"""generate_segmented_rough_cut() should create segmented output."""
|
||||
result = generate_segmented_rough_cut(
|
||||
source_fcpxml=temp_fcpxml,
|
||||
output_path=temp_output,
|
||||
segments=[
|
||||
{"name": "Intro", "keywords": ["Interview"], "duration": 5},
|
||||
{"name": "Cutaway", "keywords": ["B-Roll"], "duration": 5},
|
||||
],
|
||||
pacing="medium",
|
||||
)
|
||||
assert isinstance(result, RoughCutResult)
|
||||
assert result.segments == 2
|
||||
assert Path(temp_output).exists()
|
||||
Executable
+1167
File diff suppressed because it is too large
Load Diff
Executable
+618
@@ -0,0 +1,618 @@
|
||||
"""Tests for server.py — tool handlers, utility functions, and dispatch logic.
|
||||
|
||||
The `mcp` package may not be installed in the test Python environment (system
|
||||
Python vs uv venv). We shim it with lightweight stubs so server.py can be
|
||||
imported without the real MCP SDK.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shim the `mcp` package tree before importing server.py
|
||||
# ---------------------------------------------------------------------------
|
||||
_NEEDS_SHIM = "mcp" not in sys.modules or "mcp.server" not in sys.modules
|
||||
|
||||
|
||||
def _install_mcp_shim():
|
||||
"""Create lightweight fakes for the mcp modules server.py imports."""
|
||||
mcp = types.ModuleType("mcp")
|
||||
mcp_server = types.ModuleType("mcp.server")
|
||||
mcp_server_stdio = types.ModuleType("mcp.server.stdio")
|
||||
mcp_types = types.ModuleType("mcp.types")
|
||||
|
||||
# mcp.server.Server — needs to be callable and return an object with
|
||||
# decorator methods (call_tool, list_resources, etc.)
|
||||
class _FakeServer:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
# Decorator stubs — return the decorated function unchanged
|
||||
def call_tool(self):
|
||||
return lambda fn: fn
|
||||
|
||||
def list_tools(self):
|
||||
return lambda fn: fn
|
||||
|
||||
def list_resources(self):
|
||||
return lambda fn: fn
|
||||
|
||||
def read_resource(self):
|
||||
return lambda fn: fn
|
||||
|
||||
def list_prompts(self):
|
||||
return lambda fn: fn
|
||||
|
||||
def get_prompt(self):
|
||||
return lambda fn: fn
|
||||
|
||||
mcp_server.Server = _FakeServer
|
||||
|
||||
# mcp.server.stdio — just needs an async context manager
|
||||
async def _fake_stdio_server(srv):
|
||||
yield (MagicMock(), MagicMock())
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, *a):
|
||||
pass
|
||||
async def __aenter__(self):
|
||||
return (MagicMock(), MagicMock())
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
mcp_server_stdio.stdio_server = _FakeCtx
|
||||
|
||||
# mcp.types — dataclasses used by server.py
|
||||
class TextContent:
|
||||
def __init__(self, *, type: str, text: str):
|
||||
self.type = type
|
||||
self.text = text
|
||||
|
||||
for name in ("GetPromptResult", "Prompt", "PromptArgument",
|
||||
"PromptMessage", "Resource", "Tool"):
|
||||
setattr(mcp_types, name, MagicMock)
|
||||
mcp_types.TextContent = TextContent
|
||||
|
||||
# Register in sys.modules
|
||||
sys.modules.setdefault("mcp", mcp)
|
||||
sys.modules.setdefault("mcp.server", mcp_server)
|
||||
sys.modules.setdefault("mcp.server.stdio", mcp_server_stdio)
|
||||
sys.modules.setdefault("mcp.types", mcp_types)
|
||||
|
||||
|
||||
if _NEEDS_SHIM:
|
||||
_install_mcp_shim()
|
||||
|
||||
from server import ( # noqa: E402
|
||||
_parse_timestamp_parts,
|
||||
call_tool,
|
||||
find_fcpxml_files,
|
||||
format_duration,
|
||||
format_timecode,
|
||||
generate_output_path,
|
||||
handle_analyze_pacing,
|
||||
handle_analyze_timeline,
|
||||
handle_detect_duplicates,
|
||||
handle_detect_flash_frames,
|
||||
handle_detect_gaps,
|
||||
handle_export_csv,
|
||||
handle_export_edl,
|
||||
handle_find_long_clips,
|
||||
handle_find_short_cuts,
|
||||
handle_import_transcript_markers,
|
||||
handle_list_clips,
|
||||
handle_list_keywords,
|
||||
handle_list_markers,
|
||||
handle_list_projects,
|
||||
handle_validate_timeline,
|
||||
parse_srt,
|
||||
parse_transcript_timestamps,
|
||||
parse_vtt,
|
||||
)
|
||||
|
||||
SAMPLE = str(Path(__file__).parent.parent / "examples" / "sample.fcpxml")
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Utility Functions
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFormatDuration:
|
||||
def test_milliseconds(self):
|
||||
assert format_duration(0.25) == "250ms"
|
||||
|
||||
def test_sub_second(self):
|
||||
assert format_duration(0.999) == "999ms"
|
||||
|
||||
def test_seconds(self):
|
||||
assert format_duration(3.5) == "3.50s"
|
||||
|
||||
def test_one_second(self):
|
||||
assert format_duration(1.0) == "1.00s"
|
||||
|
||||
def test_minutes(self):
|
||||
assert format_duration(90.5) == "1m 30.5s"
|
||||
|
||||
def test_zero(self):
|
||||
assert format_duration(0) == "0ms"
|
||||
|
||||
|
||||
class TestFormatTimecode:
|
||||
def test_none_returns_zeros(self):
|
||||
assert format_timecode(None) == "00:00:00:00"
|
||||
|
||||
def test_with_timecode(self):
|
||||
from fcpxml.models import Timecode
|
||||
tc = Timecode(frames=48, frame_rate=24)
|
||||
result = format_timecode(tc)
|
||||
assert "00:00:02:00" == result
|
||||
|
||||
|
||||
class TestGenerateOutputPath:
|
||||
def test_default_suffix(self):
|
||||
result = generate_output_path("/path/to/project.fcpxml")
|
||||
assert result == "/path/to/project_modified.fcpxml"
|
||||
|
||||
def test_custom_suffix(self):
|
||||
result = generate_output_path("/path/to/edit.fcpxml", "_chapters")
|
||||
assert result == "/path/to/edit_chapters.fcpxml"
|
||||
|
||||
|
||||
class TestFindFcpxmlFiles:
|
||||
def test_finds_fcpxml(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
Path(d, "a.fcpxml").touch()
|
||||
Path(d, "b.fcpxmld").touch()
|
||||
Path(d, "c.txt").touch()
|
||||
files = find_fcpxml_files(d)
|
||||
assert len(files) == 2
|
||||
assert any("a.fcpxml" in f for f in files)
|
||||
assert any("b.fcpxmld" in f for f in files)
|
||||
|
||||
def test_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
assert find_fcpxml_files(d) == []
|
||||
|
||||
def test_nested_files(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
sub = Path(d, "sub")
|
||||
sub.mkdir()
|
||||
Path(sub, "deep.fcpxml").touch()
|
||||
files = find_fcpxml_files(d)
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SRT / VTT / Transcript Parsers
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestParseSrt:
|
||||
def test_basic_srt(self):
|
||||
srt = """1
|
||||
00:00:05,000 --> 00:00:10,000
|
||||
Hello world
|
||||
|
||||
2
|
||||
00:01:30,500 --> 00:01:35,000
|
||||
Second subtitle
|
||||
"""
|
||||
markers = parse_srt(srt)
|
||||
assert len(markers) == 2
|
||||
assert markers[0]["seconds"] == 5.0
|
||||
assert markers[0]["text"] == "Hello world"
|
||||
assert markers[1]["seconds"] == 90.5
|
||||
assert markers[1]["text"] == "Second subtitle"
|
||||
|
||||
def test_multiline_subtitle(self):
|
||||
srt = """1
|
||||
00:00:10,000 --> 00:00:15,000
|
||||
Line one
|
||||
Line two
|
||||
"""
|
||||
markers = parse_srt(srt)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["text"] == "Line one Line two"
|
||||
|
||||
def test_empty_input(self):
|
||||
assert parse_srt("") == []
|
||||
|
||||
def test_malformed_blocks_skipped(self):
|
||||
srt = """garbage
|
||||
|
||||
1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Valid
|
||||
"""
|
||||
markers = parse_srt(srt)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["text"] == "Valid"
|
||||
|
||||
|
||||
class TestParseVtt:
|
||||
def test_basic_vtt(self):
|
||||
vtt = """WEBVTT
|
||||
|
||||
00:00:05.000 --> 00:00:10.000
|
||||
Hello world
|
||||
|
||||
00:01:30.500 --> 00:01:35.000
|
||||
Second subtitle
|
||||
"""
|
||||
markers = parse_vtt(vtt)
|
||||
assert len(markers) == 2
|
||||
assert markers[0]["seconds"] == 5.0
|
||||
assert markers[1]["seconds"] == 90.5
|
||||
|
||||
def test_short_timestamp(self):
|
||||
"""VTT allows MM:SS.mmm format (no hours)."""
|
||||
vtt = """WEBVTT
|
||||
|
||||
01:30.500 --> 01:35.000
|
||||
Short format
|
||||
"""
|
||||
markers = parse_vtt(vtt)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["seconds"] == 90.5
|
||||
|
||||
def test_strips_html_tags(self):
|
||||
vtt = """WEBVTT
|
||||
|
||||
00:00:01.000 --> 00:00:05.000
|
||||
<b>Bold</b> and <i>italic</i>
|
||||
"""
|
||||
markers = parse_vtt(vtt)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["text"] == "Bold and italic"
|
||||
|
||||
def test_note_blocks_removed(self):
|
||||
vtt = """WEBVTT
|
||||
|
||||
NOTE
|
||||
This is a note
|
||||
|
||||
00:00:01.000 --> 00:00:05.000
|
||||
Real subtitle
|
||||
"""
|
||||
markers = parse_vtt(vtt)
|
||||
assert len(markers) >= 1
|
||||
assert any(m["text"] == "Real subtitle" for m in markers)
|
||||
|
||||
|
||||
class TestParseTranscriptTimestamps:
|
||||
def test_mm_ss_format(self):
|
||||
text = "0:00 Introduction\n1:30 Main Topic\n"
|
||||
markers = parse_transcript_timestamps(text)
|
||||
assert len(markers) == 2
|
||||
assert markers[0]["seconds"] == 0
|
||||
assert markers[0]["text"] == "Introduction"
|
||||
assert markers[1]["seconds"] == 90
|
||||
|
||||
def test_hh_mm_ss_format(self):
|
||||
text = "01:05:30 Conclusion\n"
|
||||
markers = parse_transcript_timestamps(text)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["seconds"] == 3930
|
||||
|
||||
def test_smpte_format(self):
|
||||
text = "00:00:10:00 Scene One\n"
|
||||
markers = parse_transcript_timestamps(text)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["seconds"] == 10
|
||||
|
||||
def test_smpte_nonzero_frames(self):
|
||||
"""SMPTE frames are converted to fractional seconds at default 24fps."""
|
||||
text = "00:00:10:12 Scene Two\n"
|
||||
markers = parse_transcript_timestamps(text)
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["seconds"] == pytest.approx(10.5, abs=1e-6)
|
||||
|
||||
def test_blank_lines_skipped(self):
|
||||
text = "0:00 Start\n\n\n0:30 Middle\n"
|
||||
markers = parse_transcript_timestamps(text)
|
||||
assert len(markers) == 2
|
||||
|
||||
def test_no_timestamps(self):
|
||||
assert parse_transcript_timestamps("just plain text") == []
|
||||
|
||||
|
||||
class TestParseTimestampParts:
|
||||
"""Direct unit tests for _parse_timestamp_parts SMPTE accuracy."""
|
||||
|
||||
def test_two_part(self):
|
||||
assert _parse_timestamp_parts(["1", "30"]) == 90.0
|
||||
|
||||
def test_three_part(self):
|
||||
assert _parse_timestamp_parts(["1", "05", "30"]) == 3930.0
|
||||
|
||||
def test_four_part_zero_frames(self):
|
||||
assert _parse_timestamp_parts(["0", "0", "10", "00"]) == 10.0
|
||||
|
||||
def test_four_part_nonzero_frames_24fps(self):
|
||||
result = _parse_timestamp_parts(["0", "0", "10", "12"])
|
||||
assert result == pytest.approx(10.5, abs=1e-6)
|
||||
|
||||
def test_four_part_custom_frame_rate(self):
|
||||
result = _parse_timestamp_parts(["0", "0", "5", "15"], frame_rate=30.0)
|
||||
assert result == pytest.approx(5.5, abs=1e-6)
|
||||
|
||||
def test_four_part_frame_rate_25fps(self):
|
||||
result = _parse_timestamp_parts(["0", "0", "1", "5"], frame_rate=25.0)
|
||||
assert result == pytest.approx(1.2, abs=1e-6)
|
||||
|
||||
def test_unrecognised_part_count(self):
|
||||
assert _parse_timestamp_parts(["10"]) is None
|
||||
assert _parse_timestamp_parts(["1", "2", "3", "4", "5"]) is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Read Handlers (against sample.fcpxml)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestHandleListProjects:
|
||||
async def test_finds_sample(self):
|
||||
example_dir = str(Path(SAMPLE).parent)
|
||||
result = await handle_list_projects({"directory": example_dir})
|
||||
assert len(result) == 1
|
||||
assert "sample.fcpxml" in result[0].text
|
||||
|
||||
async def test_empty_dir(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
result = await handle_list_projects({"directory": d})
|
||||
assert "No FCPXML files" in result[0].text
|
||||
|
||||
|
||||
class TestHandleAnalyzeTimeline:
|
||||
async def test_returns_markdown(self):
|
||||
result = await handle_analyze_timeline({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "# Timeline Analysis" in text
|
||||
assert "Music Video Edit" in text
|
||||
assert "1920" in text
|
||||
assert "Total Clips" in text
|
||||
|
||||
|
||||
class TestHandleListClips:
|
||||
async def test_lists_all_clips(self):
|
||||
result = await handle_list_clips({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Interview_A" in text
|
||||
assert "Broll_City" in text
|
||||
|
||||
async def test_with_limit(self):
|
||||
result = await handle_list_clips({"filepath": SAMPLE, "limit": 2})
|
||||
text = result[0].text
|
||||
# Only 2 data rows should appear (not all 9)
|
||||
data_rows = [line for line in text.split("\n") if line.startswith("| ") and "---" not in line and "#" not in line]
|
||||
assert len(data_rows) == 2
|
||||
|
||||
|
||||
class TestHandleListMarkers:
|
||||
async def test_default_format(self):
|
||||
result = await handle_list_markers({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Markers" in text
|
||||
assert "Good take" in text
|
||||
|
||||
async def test_youtube_format(self):
|
||||
result = await handle_list_markers({"filepath": SAMPLE, "format": "youtube"})
|
||||
text = result[0].text
|
||||
assert "YouTube Chapters" in text
|
||||
|
||||
async def test_simple_format(self):
|
||||
result = await handle_list_markers({"filepath": SAMPLE, "format": "simple"})
|
||||
text = result[0].text
|
||||
assert " - " in text
|
||||
|
||||
async def test_filter_chapter_markers(self):
|
||||
result = await handle_list_markers({"filepath": SAMPLE, "marker_type": "chapter"})
|
||||
text = result[0].text
|
||||
assert "Intro" in text
|
||||
assert "Good take" not in text
|
||||
|
||||
|
||||
class TestHandleFindShortCuts:
|
||||
async def test_finds_flash_frame(self):
|
||||
# Sample has a 6/24s = 0.25s clip (Broll_Studio)
|
||||
result = await handle_find_short_cuts({"filepath": SAMPLE, "threshold_seconds": 0.5})
|
||||
text = result[0].text
|
||||
assert "Short Clips" in text
|
||||
assert "Broll_Studio" in text
|
||||
|
||||
async def test_no_short_clips(self):
|
||||
result = await handle_find_short_cuts({"filepath": SAMPLE, "threshold_seconds": 0.01})
|
||||
text = result[0].text
|
||||
assert "No clips shorter than" in text
|
||||
|
||||
|
||||
class TestHandleFindLongClips:
|
||||
async def test_finds_long_clip(self):
|
||||
# Longest clip is Interview_A at 720/24s = 30s
|
||||
result = await handle_find_long_clips({"filepath": SAMPLE, "threshold_seconds": 20})
|
||||
text = result[0].text
|
||||
assert "Long Clips" in text
|
||||
assert "Interview_A" in text
|
||||
|
||||
async def test_no_long_clips(self):
|
||||
result = await handle_find_long_clips({"filepath": SAMPLE, "threshold_seconds": 999})
|
||||
text = result[0].text
|
||||
assert "No clips longer than" in text
|
||||
|
||||
|
||||
class TestHandleListKeywords:
|
||||
async def test_finds_keywords(self):
|
||||
result = await handle_list_keywords({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Interview" in text
|
||||
assert "B-Roll" in text
|
||||
|
||||
async def test_counts(self):
|
||||
result = await handle_list_keywords({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "clips)" in text
|
||||
|
||||
|
||||
class TestHandleExportEdl:
|
||||
async def test_edl_format(self):
|
||||
result = await handle_export_edl({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "TITLE:" in text
|
||||
assert "FROM CLIP NAME:" in text
|
||||
assert "```edl" in text
|
||||
|
||||
|
||||
class TestHandleExportCsv:
|
||||
async def test_csv_format(self):
|
||||
result = await handle_export_csv({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Name,Start,End,Duration,Keywords" in text
|
||||
assert "Interview_A" in text
|
||||
assert "```csv" in text
|
||||
|
||||
|
||||
class TestHandleAnalyzePacing:
|
||||
async def test_pacing_analysis(self):
|
||||
result = await handle_analyze_pacing({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Pacing Analysis" in text
|
||||
assert "Cuts/Min" in text
|
||||
assert "Q1" in text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QC Handlers
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestHandleDetectFlashFrames:
|
||||
async def test_detects_flash(self):
|
||||
# Sample has 6/24s = 0.25s clip = 6 frames at 24fps
|
||||
# Default warning_threshold_frames=6, so 6 < 6 is false
|
||||
# Use threshold=7 to catch it
|
||||
result = await handle_detect_flash_frames({
|
||||
"filepath": SAMPLE, "warning_threshold_frames": 7
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Flash Frame" in text
|
||||
assert "Broll_Studio" in text
|
||||
|
||||
async def test_no_flash_default(self):
|
||||
# With default thresholds, the 6-frame clip is exactly at the boundary
|
||||
result = await handle_detect_flash_frames({"filepath": SAMPLE})
|
||||
# At default (warning=6), clip with exactly 6 frames: 6 < 6 = False → no detection
|
||||
text = result[0].text
|
||||
assert "No flash frames detected" in text
|
||||
|
||||
|
||||
class TestHandleDetectDuplicates:
|
||||
async def test_same_source_mode(self):
|
||||
result = await handle_detect_duplicates({
|
||||
"filepath": SAMPLE, "mode": "same_source"
|
||||
})
|
||||
text = result[0].text
|
||||
# Interview_A used 4 times, Broll_City 3 times, Broll_Studio 2 times
|
||||
assert "Duplicate" in text
|
||||
|
||||
async def test_no_identical(self):
|
||||
result = await handle_detect_duplicates({
|
||||
"filepath": SAMPLE, "mode": "identical"
|
||||
})
|
||||
text = result[0].text
|
||||
# No two clips use exact same source range
|
||||
assert "No duplicate clips" in text
|
||||
|
||||
|
||||
class TestHandleDetectGaps:
|
||||
async def test_no_gaps_in_sample(self):
|
||||
result = await handle_detect_gaps({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
# Clips in sample are contiguous (offsets line up)
|
||||
assert "No gaps detected" in text
|
||||
|
||||
|
||||
class TestHandleValidateTimeline:
|
||||
async def test_returns_health_score(self):
|
||||
result = await handle_validate_timeline({"filepath": SAMPLE})
|
||||
text = result[0].text
|
||||
assert "Health Score" in text
|
||||
assert "%" in text
|
||||
|
||||
async def test_selective_checks(self):
|
||||
result = await handle_validate_timeline({
|
||||
"filepath": SAMPLE, "checks": ["flash_frames"]
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Flash Frames" in text
|
||||
|
||||
async def test_no_issues_passes(self):
|
||||
result = await handle_validate_timeline({
|
||||
"filepath": SAMPLE, "checks": ["gaps"]
|
||||
})
|
||||
text = result[0].text
|
||||
assert "PASS" in text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Transcript Import Handler
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestHandleImportTranscriptMarkers:
|
||||
async def test_inline_transcript(self):
|
||||
import shutil
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# Copy source into temp dir so output stays co-located (sandbox).
|
||||
src_copy = str(Path(d, "sample.fcpxml"))
|
||||
shutil.copy2(SAMPLE, src_copy)
|
||||
out = str(Path(d, "out.fcpxml"))
|
||||
result = await handle_import_transcript_markers({
|
||||
"filepath": src_copy,
|
||||
"output_path": out,
|
||||
"transcript": "0:10 Marker A\n0:47 Marker B",
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Markers Added" in text
|
||||
assert Path(out).exists()
|
||||
|
||||
async def test_no_transcript_provided(self):
|
||||
result = await handle_import_transcript_markers({
|
||||
"filepath": SAMPLE,
|
||||
})
|
||||
text = result[0].text
|
||||
assert "Provide either" in text
|
||||
|
||||
async def test_missing_transcript_file(self):
|
||||
result = await call_tool("import_transcript_markers", {
|
||||
"filepath": SAMPLE,
|
||||
"transcript_path": "/nonexistent/path.txt",
|
||||
})
|
||||
text = result[0].text
|
||||
assert "File not found" in text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tool Dispatch (call_tool)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCallTool:
|
||||
async def test_unknown_tool(self):
|
||||
result = await call_tool("nonexistent_tool", {})
|
||||
assert "Unknown tool" in result[0].text
|
||||
|
||||
async def test_file_not_found_handled(self):
|
||||
result = await call_tool("analyze_timeline", {"filepath": "/no/such/file.fcpxml"})
|
||||
assert "File not found" in result[0].text or "Error" in result[0].text
|
||||
|
||||
async def test_dispatches_correctly(self):
|
||||
example_dir = str(Path(SAMPLE).parent)
|
||||
result = await call_tool("list_projects", {"directory": example_dir})
|
||||
assert "sample.fcpxml" in result[0].text
|
||||
Executable
+417
@@ -0,0 +1,417 @@
|
||||
"""Tests for v0.3.0 Speed Cutting & AI-Powered features."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.models import (
|
||||
DuplicateGroup,
|
||||
FlashFrameSeverity,
|
||||
MontageConfig,
|
||||
PacingCurve,
|
||||
ValidationIssue,
|
||||
ValidationResult,
|
||||
)
|
||||
from fcpxml.parser import FCPXMLParser
|
||||
from fcpxml.rough_cut import RoughCutGenerator
|
||||
from fcpxml.writer import FCPXMLModifier
|
||||
|
||||
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_fcpxml():
|
||||
"""Create a temp copy of sample.fcpxml for modification tests."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
|
||||
shutil.copy(SAMPLE, f.name)
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output():
|
||||
"""Create a temp file path for output."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
|
||||
yield f.name
|
||||
Path(f.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Model Tests - Pacing Curves
|
||||
# ============================================================
|
||||
|
||||
def test_montage_config_constant_pacing():
|
||||
"""Constant pacing should return same duration at any position."""
|
||||
config = MontageConfig(
|
||||
target_duration=30.0,
|
||||
pacing_curve=PacingCurve.CONSTANT,
|
||||
start_duration=2.0,
|
||||
end_duration=0.5
|
||||
)
|
||||
|
||||
# Average of start and end
|
||||
expected = (2.0 + 0.5) / 2
|
||||
|
||||
assert config.get_duration_at_position(0.0) == expected
|
||||
assert config.get_duration_at_position(0.5) == expected
|
||||
assert config.get_duration_at_position(1.0) == expected
|
||||
|
||||
|
||||
def test_montage_config_accelerating_pacing():
|
||||
"""Accelerating pacing: starts slow, ends fast."""
|
||||
config = MontageConfig(
|
||||
target_duration=30.0,
|
||||
pacing_curve=PacingCurve.ACCELERATING,
|
||||
start_duration=2.0,
|
||||
end_duration=0.5
|
||||
)
|
||||
|
||||
# Should get start_duration at position 0
|
||||
assert config.get_duration_at_position(0.0) == 2.0
|
||||
|
||||
# Should get end_duration at position 1
|
||||
assert config.get_duration_at_position(1.0) == 0.5
|
||||
|
||||
# Middle should be in between
|
||||
mid_duration = config.get_duration_at_position(0.5)
|
||||
assert 0.5 < mid_duration < 2.0
|
||||
|
||||
|
||||
def test_montage_config_decelerating_pacing():
|
||||
"""Decelerating pacing: starts fast, ends slow."""
|
||||
config = MontageConfig(
|
||||
target_duration=30.0,
|
||||
pacing_curve=PacingCurve.DECELERATING,
|
||||
start_duration=2.0,
|
||||
end_duration=0.5
|
||||
)
|
||||
|
||||
# Should start fast (end_duration) and end slow (start_duration)
|
||||
assert config.get_duration_at_position(0.0) == 0.5
|
||||
assert config.get_duration_at_position(1.0) == 2.0
|
||||
|
||||
|
||||
def test_montage_config_pyramid_pacing():
|
||||
"""Pyramid pacing: slow -> fast -> slow."""
|
||||
config = MontageConfig(
|
||||
target_duration=30.0,
|
||||
pacing_curve=PacingCurve.PYRAMID,
|
||||
start_duration=2.0,
|
||||
end_duration=0.5
|
||||
)
|
||||
|
||||
# Start should be slow
|
||||
start_dur = config.get_duration_at_position(0.0)
|
||||
|
||||
# Middle should be fast
|
||||
mid_dur = config.get_duration_at_position(0.5)
|
||||
|
||||
# End should be slow again
|
||||
end_dur = config.get_duration_at_position(1.0)
|
||||
|
||||
# Middle should be faster (shorter duration) than start/end
|
||||
assert mid_dur < start_dur
|
||||
assert mid_dur < end_dur
|
||||
|
||||
|
||||
def test_montage_config_respects_min_max():
|
||||
"""Pacing should clamp to min/max duration."""
|
||||
config = MontageConfig(
|
||||
target_duration=30.0,
|
||||
pacing_curve=PacingCurve.ACCELERATING,
|
||||
start_duration=10.0, # Would exceed max
|
||||
end_duration=0.1, # Would be below min
|
||||
min_duration=0.5,
|
||||
max_duration=5.0
|
||||
)
|
||||
|
||||
# Should be clamped to max at start
|
||||
assert config.get_duration_at_position(0.0) == 5.0
|
||||
|
||||
# Should be clamped to min at end
|
||||
assert config.get_duration_at_position(1.0) == 0.5
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Model Tests - Data Classes
|
||||
# ============================================================
|
||||
|
||||
def test_flash_frame_severity():
|
||||
"""FlashFrameSeverity enum should have correct values."""
|
||||
assert FlashFrameSeverity.CRITICAL.value == "critical"
|
||||
assert FlashFrameSeverity.WARNING.value == "warning"
|
||||
|
||||
|
||||
def test_pacing_curve_values():
|
||||
"""PacingCurve enum should have all expected values."""
|
||||
assert PacingCurve.CONSTANT.value == "constant"
|
||||
assert PacingCurve.ACCELERATING.value == "accelerating"
|
||||
assert PacingCurve.DECELERATING.value == "decelerating"
|
||||
assert PacingCurve.PYRAMID.value == "pyramid"
|
||||
|
||||
|
||||
def test_duplicate_group_count():
|
||||
"""DuplicateGroup should correctly count clips."""
|
||||
group = DuplicateGroup(
|
||||
source_ref="r1",
|
||||
source_name="TestClip",
|
||||
clips=[
|
||||
{'name': 'Clip1', 'source_start': 0, 'source_duration': 5},
|
||||
{'name': 'Clip2', 'source_start': 10, 'source_duration': 5},
|
||||
{'name': 'Clip3', 'source_start': 20, 'source_duration': 5}
|
||||
]
|
||||
)
|
||||
|
||||
assert group.count == 3
|
||||
|
||||
|
||||
def test_duplicate_group_overlapping_ranges():
|
||||
"""DuplicateGroup should detect overlapping source ranges."""
|
||||
# Non-overlapping
|
||||
group_no_overlap = DuplicateGroup(
|
||||
source_ref="r1",
|
||||
source_name="TestClip",
|
||||
clips=[
|
||||
{'name': 'Clip1', 'source_start': 0, 'source_duration': 5},
|
||||
{'name': 'Clip2', 'source_start': 10, 'source_duration': 5}
|
||||
]
|
||||
)
|
||||
assert not group_no_overlap.has_overlapping_ranges
|
||||
|
||||
# Overlapping
|
||||
group_overlap = DuplicateGroup(
|
||||
source_ref="r1",
|
||||
source_name="TestClip",
|
||||
clips=[
|
||||
{'name': 'Clip1', 'source_start': 0, 'source_duration': 10},
|
||||
{'name': 'Clip2', 'source_start': 5, 'source_duration': 10} # Overlaps!
|
||||
]
|
||||
)
|
||||
assert group_overlap.has_overlapping_ranges
|
||||
|
||||
|
||||
def test_validation_result_counts():
|
||||
"""ValidationResult should count issues by severity."""
|
||||
result = ValidationResult(
|
||||
is_valid=False,
|
||||
health_score=75,
|
||||
issues=[
|
||||
ValidationIssue(issue_type=None, severity="error", message="Error 1"),
|
||||
ValidationIssue(issue_type=None, severity="error", message="Error 2"),
|
||||
ValidationIssue(issue_type=None, severity="warning", message="Warning 1"),
|
||||
]
|
||||
)
|
||||
|
||||
assert result.error_count == 2
|
||||
assert result.warning_count == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Writer Tests - Speed Cutting Operations
|
||||
# ============================================================
|
||||
|
||||
def test_fix_flash_frames_returns_list(temp_fcpxml):
|
||||
"""fix_flash_frames should return a list of fixed items."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
# May or may not find flash frames in sample, but should return list
|
||||
result = modifier.fix_flash_frames(mode='auto', threshold_frames=6)
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_rapid_trim_returns_list(temp_fcpxml):
|
||||
"""rapid_trim should return a list of trimmed clips."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
# Trim all clips to 1 second max
|
||||
result = modifier.rapid_trim(max_duration='1s', trim_from='end')
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_rapid_trim_respects_max_duration(temp_fcpxml):
|
||||
"""rapid_trim should not trim clips shorter than max."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
# Trim to 100s - most clips should be shorter
|
||||
result = modifier.rapid_trim(max_duration='100s')
|
||||
|
||||
# All trimmed clips should have new_duration <= 100
|
||||
for item in result:
|
||||
assert item['new_duration'] <= 100
|
||||
|
||||
|
||||
def test_fill_gaps_returns_list(temp_fcpxml):
|
||||
"""fill_gaps should return a list of filled gaps."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
result = modifier.fill_gaps(mode='extend_previous')
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_rapid_trim_skips_clips_below_min_duration(temp_fcpxml):
|
||||
"""rapid_trim with min_duration should skip clips shorter than the minimum.
|
||||
|
||||
Note: duplicate clip names exist in the sample data, so we compare
|
||||
result counts rather than names (a name can appear for both a short
|
||||
and a long clip).
|
||||
"""
|
||||
# Without min_duration — trims everything over 0.5s.
|
||||
modifier1 = FCPXMLModifier(temp_fcpxml)
|
||||
result_all = modifier1.rapid_trim(max_duration='0.5s')
|
||||
|
||||
# With min_duration=3s — clips under 3s are left alone, so fewer trims.
|
||||
modifier2 = FCPXMLModifier(temp_fcpxml)
|
||||
result_filtered = modifier2.rapid_trim(max_duration='0.5s', min_duration='3s')
|
||||
|
||||
assert len(result_filtered) < len(result_all), (
|
||||
f"min_duration filter should reduce trims: "
|
||||
f"got {len(result_filtered)} vs {len(result_all)} without filter"
|
||||
)
|
||||
|
||||
|
||||
def test_rapid_trim_saves_correctly(temp_fcpxml):
|
||||
"""rapid_trim changes should persist after save."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
# Trim clips to 0.5s max
|
||||
modifier.rapid_trim(max_duration='0.5s')
|
||||
|
||||
output = temp_fcpxml.replace('.fcpxml', '_trimmed.fcpxml')
|
||||
modifier.save(output)
|
||||
|
||||
# Reload and verify all clips are <= 0.5s
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(output)
|
||||
|
||||
for clip in project.primary_timeline.clips:
|
||||
assert clip.duration_seconds <= 0.6 # Allow small tolerance
|
||||
|
||||
Path(output).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RoughCutGenerator Tests - Montage & A/B Roll
|
||||
# ============================================================
|
||||
|
||||
def test_rough_cut_generator_loads(temp_fcpxml):
|
||||
"""RoughCutGenerator should load FCPXML file."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
assert generator.fps > 0
|
||||
assert len(generator.clips) > 0
|
||||
|
||||
|
||||
def test_generate_montage_creates_file(temp_fcpxml, temp_output):
|
||||
"""generate_montage should create output file."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
result = generator.generate_montage(
|
||||
output_path=temp_output,
|
||||
target_duration='10s',
|
||||
pacing_curve='accelerating'
|
||||
)
|
||||
|
||||
assert Path(temp_output).exists()
|
||||
assert result['clips_used'] > 0
|
||||
assert result['pacing_curve'] == 'accelerating'
|
||||
|
||||
|
||||
def test_generate_montage_respects_target_duration(temp_fcpxml, temp_output):
|
||||
"""generate_montage should approximate target duration."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
result = generator.generate_montage(
|
||||
output_path=temp_output,
|
||||
target_duration='15s',
|
||||
pacing_curve='constant'
|
||||
)
|
||||
|
||||
# Should be within reasonable range of target
|
||||
assert 10 <= result['actual_duration'] <= 20
|
||||
|
||||
|
||||
def test_generate_ab_roll_creates_file(temp_fcpxml, temp_output):
|
||||
"""generate_ab_roll should create output file."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
# Use keywords that match sample clips (case-insensitive matching)
|
||||
result = generator.generate_ab_roll(
|
||||
output_path=temp_output,
|
||||
target_duration='20s',
|
||||
a_keywords=['Interview'], # Matches sample's "Interview" keyword
|
||||
b_keywords=['B-Roll'], # Matches sample's "B-Roll" keyword
|
||||
a_duration='5s',
|
||||
b_duration='3s'
|
||||
)
|
||||
|
||||
assert Path(temp_output).exists()
|
||||
assert result['clips_used'] > 0
|
||||
|
||||
|
||||
def test_generate_ab_roll_alternates(temp_fcpxml, temp_output):
|
||||
"""generate_ab_roll should have both A and B segments."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
result = generator.generate_ab_roll(
|
||||
output_path=temp_output,
|
||||
target_duration='30s',
|
||||
a_keywords=['Interview'], # Matches sample's "Interview" keyword
|
||||
b_keywords=['B-Roll'], # Matches sample's "B-Roll" keyword
|
||||
a_duration='5s',
|
||||
b_duration='3s'
|
||||
)
|
||||
|
||||
# Should have both A and B segments
|
||||
assert result['a_segments'] > 0 or result['b_segments'] > 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Integration Tests
|
||||
# ============================================================
|
||||
|
||||
def test_montage_pacing_affects_clip_durations(temp_fcpxml, temp_output):
|
||||
"""Different pacing curves should produce different results."""
|
||||
generator = RoughCutGenerator(temp_fcpxml)
|
||||
|
||||
# Generate accelerating montage
|
||||
result_accel = generator.generate_montage(
|
||||
output_path=temp_output,
|
||||
target_duration='20s',
|
||||
pacing_curve='accelerating',
|
||||
start_duration=3.0,
|
||||
end_duration=0.5
|
||||
)
|
||||
|
||||
# Just verify montage was created with expected settings
|
||||
assert result_accel['pacing_curve'] == 'accelerating'
|
||||
assert result_accel['clips_used'] > 0
|
||||
|
||||
# Note: Actual clip durations depend on available clips and their durations,
|
||||
# so we can't strictly assert start > end without knowing clip inventory
|
||||
|
||||
|
||||
def test_full_workflow_trim_and_validate(temp_fcpxml):
|
||||
"""Test full workflow: trim clips then validate changes."""
|
||||
modifier = FCPXMLModifier(temp_fcpxml)
|
||||
|
||||
# Step 1: Rapid trim
|
||||
modifier.rapid_trim(max_duration='2s')
|
||||
|
||||
# Step 2: Save
|
||||
output = temp_fcpxml.replace('.fcpxml', '_workflow.fcpxml')
|
||||
modifier.save(output)
|
||||
|
||||
# Step 3: Verify with parser
|
||||
parser = FCPXMLParser()
|
||||
project = parser.parse_file(output)
|
||||
|
||||
# All clips should be <= 2s
|
||||
for clip in project.primary_timeline.clips:
|
||||
assert clip.duration_seconds <= 2.1 # Small tolerance
|
||||
|
||||
Path(output).unlink(missing_ok=True)
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
"""Targeted tests for untested branches in diff, export, and models.
|
||||
|
||||
Covers: clip-only-trim detection, marker addition/threshold, duplicate
|
||||
identity imbalance, has_changes property, XMEML frame math precision,
|
||||
video-only clip audio exclusion, TimeValue division edge cases,
|
||||
ValidationResult.summary format, and MontageConfig boundary clamping.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.diff import (
|
||||
TimelineDiff,
|
||||
_compare_clips,
|
||||
_compare_markers,
|
||||
compare_timelines,
|
||||
)
|
||||
from fcpxml.export import DaVinciExporter
|
||||
from fcpxml.models import (
|
||||
Clip,
|
||||
FlashFrame,
|
||||
FlashFrameSeverity,
|
||||
GapInfo,
|
||||
MontageConfig,
|
||||
PacingCurve,
|
||||
Timecode,
|
||||
Timeline,
|
||||
TimeValue,
|
||||
ValidationIssue,
|
||||
ValidationIssueType,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
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="Gap 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
|
||||
|
||||
|
||||
def _out(suffix=".fcpxml") -> str:
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
# ── Diff: trimmed-only (no move) ─────────────────────────────────────
|
||||
|
||||
class TestTrimmedOnly:
|
||||
"""Clip duration changes without position change → 'trimmed' action."""
|
||||
|
||||
def test_clip_trimmed_without_move(self):
|
||||
"""Shorten Clip_A in-place — position unchanged, duration changed."""
|
||||
xml_b = BASE_XML.replace(
|
||||
'name="Clip_A" start="0s" duration="240/24s"',
|
||||
'name="Clip_A" start="0s" duration="120/24s"',
|
||||
1, # only first occurrence (spine clip, not asset)
|
||||
)
|
||||
a, b = _tmp(BASE_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"]
|
||||
assert len(trimmed) >= 1
|
||||
assert trimmed[0].clip_name == "Clip_A"
|
||||
assert trimmed[0].old_duration != trimmed[0].new_duration
|
||||
# Position should be unchanged
|
||||
assert abs(trimmed[0].old_start - trimmed[0].new_start) <= 0.04
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
# ── Diff: marker addition ────────────────────────────────────────────
|
||||
|
||||
class TestMarkerAddition:
|
||||
"""Markers present in B but not A should report 'added'."""
|
||||
|
||||
def test_marker_added(self):
|
||||
xml_b = 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="New Mark"/>'
|
||||
"</asset-clip>",
|
||||
)
|
||||
a, b = _tmp(BASE_XML), _tmp(xml_b)
|
||||
try:
|
||||
diff = compare_timelines(a, b)
|
||||
added = [d for d in diff.marker_diffs if d.action == "added"]
|
||||
assert len(added) >= 1
|
||||
assert any(m.marker_name == "New Mark" for m in added)
|
||||
finally:
|
||||
os.unlink(a)
|
||||
os.unlink(b)
|
||||
|
||||
|
||||
# ── Diff: marker threshold boundary ──────────────────────────────────
|
||||
|
||||
class TestMarkerThreshold:
|
||||
"""Markers moved ≤1.0s are NOT reported; >1.0s are."""
|
||||
|
||||
def test_marker_at_exact_threshold_not_reported(self):
|
||||
"""1.0s movement is boundary — should NOT be reported as moved."""
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
# Simulate: marker at 0s in A, 1.0s in B (exactly at threshold)
|
||||
tl_a = _make_timeline_with_marker("Mk", 0.0)
|
||||
tl_b = _make_timeline_with_marker("Mk", 1.0)
|
||||
_compare_markers(tl_a, tl_b, diff)
|
||||
moved = [d for d in diff.marker_diffs if d.action == "moved"]
|
||||
assert len(moved) == 0, "Exactly 1.0s should NOT trigger 'moved'"
|
||||
|
||||
def test_marker_above_threshold_reported(self):
|
||||
"""~1.08s movement (26 frames at 24fps) exceeds threshold — reported."""
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
tl_a = _make_timeline_with_marker("Mk", 0.0)
|
||||
# 26 frames / 24fps = 1.083s, safely above 1.0s threshold
|
||||
tl_b = _make_timeline_with_marker("Mk", 26 / 24)
|
||||
_compare_markers(tl_a, tl_b, diff)
|
||||
moved = [d for d in diff.marker_diffs if d.action == "moved"]
|
||||
assert len(moved) == 1
|
||||
|
||||
|
||||
# ── Diff: duplicate identity imbalance ────────────────────────────────
|
||||
|
||||
class TestDuplicateIdentityImbalance:
|
||||
"""When clip identity maps have unequal counts, extras are added/removed."""
|
||||
|
||||
def test_extra_clip_in_b_is_added(self):
|
||||
"""3 clips with same identity in B vs 2 in A → 1 added."""
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
tc = Timecode(frames=0, frame_rate=24)
|
||||
dur = Timecode(frames=48, frame_rate=24)
|
||||
clips_a = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 2
|
||||
clips_b = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 3
|
||||
_compare_clips(clips_a, clips_b, diff)
|
||||
added = [d for d in diff.clip_diffs if d.action == "added"]
|
||||
assert len(added) == 1
|
||||
|
||||
def test_extra_clip_in_a_is_removed(self):
|
||||
tc = Timecode(frames=0, frame_rate=24)
|
||||
dur = Timecode(frames=48, frame_rate=24)
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
clips_a = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 3
|
||||
clips_b = [Clip(name="X", start=tc, duration=dur, source_start=tc)] * 1
|
||||
_compare_clips(clips_a, clips_b, diff)
|
||||
removed = [d for d in diff.clip_diffs if d.action == "removed"]
|
||||
assert len(removed) == 2
|
||||
|
||||
|
||||
# ── Diff: has_changes property ────────────────────────────────────────
|
||||
|
||||
class TestHasChanges:
|
||||
def test_no_changes(self):
|
||||
diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B")
|
||||
assert diff.has_changes is False
|
||||
|
||||
def test_with_format_change(self):
|
||||
diff = TimelineDiff(
|
||||
timeline_a_name="A", timeline_b_name="B",
|
||||
format_changes=["Resolution: 1920x1080 -> 3840x2160"],
|
||||
)
|
||||
assert diff.has_changes is True
|
||||
|
||||
|
||||
# ── Export: XMEML frame math ──────────────────────────────────────────
|
||||
|
||||
class TestXmemlFrameMath:
|
||||
"""Verify clipitem start/end/in/out frame calculations."""
|
||||
|
||||
def test_clipitem_frame_values(self):
|
||||
src = _tmp(BASE_XML)
|
||||
out = _out(".xml")
|
||||
try:
|
||||
exp = DaVinciExporter(src)
|
||||
exp.export_xmeml(out)
|
||||
tree = ET.parse(out)
|
||||
ci = tree.findall(".//clipitem")
|
||||
# First clip: start=0, duration=10s at 24fps = 240 frames
|
||||
first = ci[0]
|
||||
assert first.find("start").text == "0"
|
||||
assert first.find("end").text == "240"
|
||||
assert first.find("in").text == "0"
|
||||
assert first.find("out").text == "240"
|
||||
finally:
|
||||
os.unlink(src)
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
# ── TimeValue: division edge cases ────────────────────────────────────
|
||||
|
||||
class TestTimeValueDivision:
|
||||
def test_divide_by_zero_raises(self):
|
||||
"""Division by zero must raise ZeroDivisionError, not silently corrupt."""
|
||||
tv = TimeValue(100, 1)
|
||||
with pytest.raises(ZeroDivisionError, match="Cannot divide TimeValue by zero"):
|
||||
tv / 0.0
|
||||
|
||||
def test_negative_timevalue_comparison(self):
|
||||
"""Negative TimeValue should compare correctly."""
|
||||
neg = TimeValue(-10, 1)
|
||||
pos = TimeValue(10, 1)
|
||||
assert neg < pos
|
||||
assert not (pos < neg)
|
||||
|
||||
def test_multiply_preserves_denominator(self):
|
||||
tv = TimeValue(100, 2400)
|
||||
result = tv * 2
|
||||
assert result.denominator == 2400
|
||||
assert result.numerator == 200
|
||||
|
||||
|
||||
# ── ValidationResult.summary format ───────────────────────────────────
|
||||
|
||||
class TestValidationSummary:
|
||||
def test_summary_format(self):
|
||||
result = ValidationResult(
|
||||
is_valid=False,
|
||||
health_score=72,
|
||||
issues=[
|
||||
ValidationIssue(ValidationIssueType.FLASH_FRAME, "error", "flash"),
|
||||
ValidationIssue(ValidationIssueType.GAP, "warning", "gap"),
|
||||
ValidationIssue(ValidationIssueType.GAP, "warning", "gap2"),
|
||||
],
|
||||
flash_frames=[
|
||||
FlashFrame("clip1", "r2", Timecode(0, 24), 1, 0.04, FlashFrameSeverity.CRITICAL),
|
||||
],
|
||||
gaps=[
|
||||
GapInfo(Timecode(100, 24), 5, 0.21),
|
||||
GapInfo(Timecode(200, 24), 3, 0.13),
|
||||
],
|
||||
)
|
||||
s = result.summary()
|
||||
assert "72%" in s
|
||||
assert "Errors: 1" in s
|
||||
assert "Warnings: 2" in s
|
||||
assert "Flash frames: 1" in s
|
||||
assert "Gaps: 2" in s
|
||||
|
||||
|
||||
# ── MontageConfig: clamping at boundaries ─────────────────────────────
|
||||
|
||||
class TestMontageConfigClamping:
|
||||
def test_decelerating_at_end_clamps_to_max(self):
|
||||
"""Decelerating at position=1.0 returns start_duration, clamped to max."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.DECELERATING,
|
||||
start_duration=10.0,
|
||||
end_duration=0.5,
|
||||
max_duration=5.0,
|
||||
)
|
||||
# At position 1.0, decelerating = end + (start-end)*1.0 = start = 10.0
|
||||
# Clamped to max_duration = 5.0
|
||||
assert cfg.get_duration_at_position(1.0) == 5.0
|
||||
|
||||
def test_accelerating_at_start_clamps_to_max(self):
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.ACCELERATING,
|
||||
start_duration=8.0,
|
||||
end_duration=0.5,
|
||||
max_duration=5.0,
|
||||
)
|
||||
# At position 0.0, accelerating = start = 8.0, clamped to 5.0
|
||||
assert cfg.get_duration_at_position(0.0) == 5.0
|
||||
|
||||
def test_constant_clamps_to_min_duration(self):
|
||||
"""CONSTANT pacing must respect min_duration (was bypassing clamp)."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.CONSTANT,
|
||||
start_duration=0.1,
|
||||
end_duration=0.1,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
)
|
||||
# Average = 0.1, but must be clamped up to min_duration = 0.5
|
||||
for p in [0.0, 0.5, 1.0]:
|
||||
assert cfg.get_duration_at_position(p) == 0.5
|
||||
|
||||
def test_constant_clamps_to_max_duration(self):
|
||||
"""CONSTANT pacing must respect max_duration (was bypassing clamp)."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.CONSTANT,
|
||||
start_duration=10.0,
|
||||
end_duration=10.0,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
)
|
||||
# Average = 10.0, but must be clamped down to max_duration = 5.0
|
||||
for p in [0.0, 0.5, 1.0]:
|
||||
assert cfg.get_duration_at_position(p) == 5.0
|
||||
|
||||
def test_constant_within_bounds_unchanged(self):
|
||||
"""CONSTANT pacing returns unclamped average when within bounds."""
|
||||
cfg = MontageConfig(
|
||||
target_duration=60,
|
||||
pacing_curve=PacingCurve.CONSTANT,
|
||||
start_duration=3.0,
|
||||
end_duration=1.0,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
)
|
||||
# Average = 2.0, within [0.5, 5.0] — no clamp needed
|
||||
assert cfg.get_duration_at_position(0.5) == 2.0
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _make_timeline_with_marker(name: str, position_seconds: float) -> Timeline:
|
||||
"""Build a minimal Timeline with one clip-level marker at a given position."""
|
||||
from fcpxml.models import Marker, MarkerType
|
||||
|
||||
tc = Timecode(frames=0, frame_rate=24)
|
||||
dur = Timecode(frames=2400, frame_rate=24)
|
||||
marker_tc = Timecode(frames=int(position_seconds * 24), frame_rate=24)
|
||||
clip = Clip(
|
||||
name="Host",
|
||||
start=tc,
|
||||
duration=dur,
|
||||
markers=[Marker(name=name, start=marker_tc, marker_type=MarkerType.STANDARD)],
|
||||
)
|
||||
return Timeline(name="Test", duration=dur, clips=[clip])
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for kinetic-typography block layout (fcpxml/text_layout.py).
|
||||
|
||||
The reference values come from "Exemplo Letra.fcpxmld", a Final Cut export the
|
||||
user built by hand: the sentence "Toda a minha vida, assim," with each word
|
||||
positioned in the Inspector. Those hand-placed coordinates are the only ground
|
||||
truth for the template's coordinate space, so the calibration tests below pin
|
||||
the layout to them — if a change drifts from the user's own framing, they fail.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.text_layout import _SAFETY_MARGIN as SAFETY_MARGIN
|
||||
from fcpxml.text_layout import (
|
||||
LayoutBox,
|
||||
PlacedWord,
|
||||
layout_sentence,
|
||||
measure_text,
|
||||
metrics_for,
|
||||
rhythm_font_size,
|
||||
rhythm_italic,
|
||||
)
|
||||
|
||||
# Hand-placed positions from the calibration export, in canvas points.
|
||||
REFERENCE = {
|
||||
"Toda": (-251.828, -109.0),
|
||||
"a": (2.236, -101.135),
|
||||
"minha": (267.319, -95.0),
|
||||
"vida,": (71.0395, -233.65),
|
||||
"assim,": (210.88, -100.0),
|
||||
}
|
||||
REFERENCE_SIZES = (170, 128, 151, 128, 128)
|
||||
|
||||
|
||||
class Style:
|
||||
"""Stands in for WordStyle, carrying the reference export's values."""
|
||||
bold = False
|
||||
kerning = 2.048
|
||||
font_size = 128
|
||||
size_pattern = REFERENCE_SIZES
|
||||
italic_pattern = (False, False, False, False, True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentence():
|
||||
return [{"word": w} for w in REFERENCE]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def box():
|
||||
return LayoutBox.for_frame(2160, 3840)
|
||||
|
||||
|
||||
class TestMeasureText:
|
||||
def test_empty_string_has_no_width(self):
|
||||
assert measure_text("", 128) == 0.0
|
||||
|
||||
def test_width_scales_with_font_size(self):
|
||||
assert measure_text("minha", 256) > measure_text("minha", 128)
|
||||
|
||||
def test_narrow_letters_measure_less_than_wide_ones(self):
|
||||
assert measure_text("lli", 128) < measure_text("mmm", 128)
|
||||
|
||||
def test_accented_letters_measure_like_their_base(self):
|
||||
assert measure_text("ção", 128) == pytest.approx(
|
||||
measure_text("cao", 128), rel=1e-9
|
||||
)
|
||||
|
||||
def test_kerning_widens_by_one_step_per_character(self):
|
||||
plain = measure_text("abc", 100, kerning=0)
|
||||
kerned = measure_text("abc", 100, kerning=10)
|
||||
# 3 characters x 10 units, carried through the safety margin.
|
||||
assert kerned - plain == pytest.approx(30 * SAFETY_MARGIN, rel=1e-6)
|
||||
|
||||
def test_uses_the_real_font_metrics_when_available(self):
|
||||
"""Generic Helvetica advances differ from the actual macOS variants by
|
||||
up to 21% of the em. Measuring "Helvetica Neue" against the generic
|
||||
table once made words run narrow, which is what puts two of them on
|
||||
top of each other."""
|
||||
generic = measure_text("minha", 151)
|
||||
neue = measure_text("minha", 151, font="Helvetica Neue")
|
||||
assert generic != neue
|
||||
|
||||
def test_never_underestimates_real_advances(self):
|
||||
"""The whole point of the embedded metrics: a width that runs low by
|
||||
even a few points lets neighbouring words touch. Measured width must
|
||||
always be the true advance plus the safety margin, never less."""
|
||||
cases = [
|
||||
("Toda", "Helvetica", "Light", 170, 2.72, 398.3),
|
||||
("minha", "Helvetica Neue", None, 151, 2.416, 423.4),
|
||||
("assim,", "Helvetica Neue", "Light Italic", 128, 2.048, 367.7),
|
||||
("dificuldade", "Helvetica", "Light", 170, 2.72, 880.1),
|
||||
("encontrar", "Helvetica Neue", None, 151, 2.416, 667.7),
|
||||
]
|
||||
for text, font, face, size, kerning, truth in cases:
|
||||
got = measure_text(text, size, kerning=kerning, font=font, face=face)
|
||||
assert got >= truth, f"{text!r} measured {got:.1f} < real {truth}"
|
||||
assert got == pytest.approx(truth * SAFETY_MARGIN, rel=0.001)
|
||||
|
||||
def test_metrics_cover_every_font_the_rhythm_uses(self):
|
||||
for font, face in (
|
||||
("Helvetica", "Light"),
|
||||
("Helvetica Neue", None),
|
||||
("Helvetica Neue", "Light Italic"),
|
||||
):
|
||||
assert metrics_for(font, face) is not None, f"{font} {face}"
|
||||
|
||||
def test_unknown_font_falls_back_instead_of_raising(self):
|
||||
assert measure_text("oi", 100, font="No Such Font") > 0
|
||||
|
||||
def test_matches_reference_word_widths(self):
|
||||
"""Half-width sums must reproduce the center-to-center distances the
|
||||
user's hand-placed words show: 254.06 (Toda→a), 265.08 (a→minha)."""
|
||||
w_toda = measure_text("Toda", 170, kerning=2.72)
|
||||
w_a = measure_text("a", 128, kerning=2.048)
|
||||
w_minha = measure_text("minha", 151, kerning=2.416)
|
||||
assert (w_toda + w_a) / 2 == pytest.approx(254.06, abs=25)
|
||||
assert (w_a + w_minha) / 2 == pytest.approx(265.08, abs=25)
|
||||
|
||||
|
||||
class TestRhythm:
|
||||
def test_font_size_cycles_through_the_pattern(self):
|
||||
sizes = [rhythm_font_size(i, Style()) for i in range(5)]
|
||||
assert sizes == [float(s) for s in REFERENCE_SIZES]
|
||||
|
||||
def test_font_size_wraps_past_the_pattern(self):
|
||||
assert rhythm_font_size(5, Style()) == rhythm_font_size(0, Style())
|
||||
|
||||
def test_italic_follows_its_own_pattern(self):
|
||||
flags = [rhythm_italic(i, Style()) for i in range(5)]
|
||||
assert flags == [False, False, False, False, True]
|
||||
|
||||
def test_no_pattern_falls_back_to_plain_style(self):
|
||||
class Plain:
|
||||
font_size = 90
|
||||
assert rhythm_font_size(3, Plain()) == 90.0
|
||||
assert rhythm_italic(3, Plain()) is False
|
||||
|
||||
|
||||
class TestLayoutSentence:
|
||||
def test_places_every_word(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
assert [p.text for p in out.placed] == list(REFERENCE)
|
||||
assert out.overflow == []
|
||||
|
||||
def test_no_two_words_overlap(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
for i, a in enumerate(out.placed):
|
||||
for b in out.placed[i + 1:]:
|
||||
assert not a.overlaps(b), f"{a.text!r} overlaps {b.text!r}"
|
||||
|
||||
def test_blank_and_empty_words_are_skipped(self, box):
|
||||
out = layout_sentence(
|
||||
[{"word": "ok"}, {"word": " "}, {"word": ""}, {}], Style(), box
|
||||
)
|
||||
assert [p.text for p in out.placed] == ["ok"]
|
||||
|
||||
def test_no_words_yields_empty_layout(self, box):
|
||||
out = layout_sentence([], Style(), box)
|
||||
assert out.placed == [] and out.overflow == []
|
||||
|
||||
def test_lines_are_horizontally_centered(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
for line in {p.line_index for p in out.placed}:
|
||||
words = [p for p in out.placed if p.line_index == line]
|
||||
span = min(w.left for w in words) + max(w.right for w in words)
|
||||
assert span == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
def test_words_stay_inside_the_box(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
for p in out.placed:
|
||||
assert p.left >= -box.width / 2 - 1e-6
|
||||
assert p.right <= box.width / 2 + 1e-6
|
||||
|
||||
def test_later_lines_sit_lower(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
ys = {p.line_index: p.y for p in out.placed}
|
||||
assert ys[1] < ys[0], "y grows upward, so line 2 must be below line 1"
|
||||
|
||||
|
||||
class TestCalibrationAgainstRealExport:
|
||||
"""Pins the layout to the user's own hand-placed framing."""
|
||||
|
||||
def test_line_spacing_matches_the_export(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
ys = sorted({round(p.y, 4) for p in out.placed}, reverse=True)
|
||||
assert ys[0] - ys[1] == pytest.approx(132.65, abs=6)
|
||||
|
||||
def test_first_line_height_matches_the_export(self, sentence, box):
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
first = next(p for p in out.placed if p.line_index == 0)
|
||||
# The user's line 1 averages -101.1 across its hand-dragged words.
|
||||
assert first.y == pytest.approx(-101.1, abs=8)
|
||||
|
||||
def test_word_x_tracks_the_export(self, sentence, box):
|
||||
"""Within the slop of dragging by hand — the user placed these by eye,
|
||||
so this guards against a scale error, not against small offsets."""
|
||||
out = layout_sentence(sentence, Style(), box)
|
||||
for p in out.placed:
|
||||
if p.line_index != 0:
|
||||
continue # the user misplaced "assim," onto line 1
|
||||
assert p.x == pytest.approx(REFERENCE[p.text][0], abs=30)
|
||||
|
||||
|
||||
class TestOverflowSplitsIntoSubSentences:
|
||||
def test_long_sentence_overflows_instead_of_spilling(self, box):
|
||||
words = [{"word": f"palavra{i}"} for i in range(40)]
|
||||
out = layout_sentence(words, Style(), box)
|
||||
assert out.overflow, "a 40-word sentence must not all fit"
|
||||
assert out.placed
|
||||
assert len(out.placed) + len(out.overflow) == 40
|
||||
|
||||
def test_overflow_preserves_the_original_word_dicts(self, box):
|
||||
words = [{"word": f"palavra{i}", "start": float(i)} for i in range(40)]
|
||||
out = layout_sentence(words, Style(), box)
|
||||
assert all("start" in w for w in out.overflow)
|
||||
|
||||
def test_placed_words_stay_within_the_band(self, box):
|
||||
words = [{"word": f"palavra{i}"} for i in range(40)]
|
||||
out = layout_sentence(words, Style(), box)
|
||||
top = max(p.top for p in out.placed)
|
||||
bottom = min(p.bottom for p in out.placed)
|
||||
assert top - bottom <= box.height + 1e-6
|
||||
|
||||
def test_a_word_wider_than_the_box_is_still_placed(self, box):
|
||||
out = layout_sentence([{"word": "m" * 200}], Style(), box)
|
||||
assert len(out.placed) == 1, "never silently drop a spoken word"
|
||||
|
||||
|
||||
class TestPlacedWord:
|
||||
def test_position_param_formats_as_fcp_writes_it(self):
|
||||
w = PlacedWord("oi", 128, False, -251.828, -109.0, 400, 96, 0)
|
||||
assert w.position_param() == "-251.828 -109"
|
||||
|
||||
def test_boxes_that_touch_do_not_count_as_overlapping(self):
|
||||
a = PlacedWord("a", 128, False, 0, 0, 100, 100, 0)
|
||||
b = PlacedWord("b", 128, False, 100, 0, 100, 100, 0)
|
||||
assert not a.overlaps(b)
|
||||
|
||||
def test_boxes_that_intersect_do_overlap(self):
|
||||
a = PlacedWord("a", 128, False, 0, 0, 100, 100, 0)
|
||||
b = PlacedWord("b", 128, False, 50, 0, 100, 100, 0)
|
||||
assert a.overlaps(b)
|
||||
|
||||
|
||||
class TestLayoutBox:
|
||||
def test_for_frame_halves_pixels_into_points(self):
|
||||
box = LayoutBox.for_frame(2160, 3840, side_margin=0.0)
|
||||
assert box.width == pytest.approx(1080.0)
|
||||
|
||||
def test_side_margin_narrows_the_usable_width(self):
|
||||
wide = LayoutBox.for_frame(2160, 3840, side_margin=0.0)
|
||||
narrow = LayoutBox.for_frame(2160, 3840, side_margin=0.10)
|
||||
assert narrow.width < wide.width
|
||||
|
||||
def test_center_y_can_be_overridden(self):
|
||||
assert LayoutBox.for_frame(2160, 3840, center_y=-400.0).center_y == -400.0
|
||||
Executable
+438
@@ -0,0 +1,438 @@
|
||||
"""Tests for fcpxml/transcribe.py — transcript intelligence (v0.13).
|
||||
|
||||
Matching/merging/inversion helpers are pure and run everywhere; the
|
||||
transcribe() integration path degrades to None without faster-whisper, which
|
||||
is itself under test (the graceful-degradation contract).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fcpxml.parser import FCPXMLParser
|
||||
from fcpxml.transcribe import (
|
||||
DEFAULT_FILLERS,
|
||||
find_filler_spans,
|
||||
find_phrase_spans,
|
||||
format_timestamp,
|
||||
invert_ranges,
|
||||
merge_ranges,
|
||||
normalize_word,
|
||||
segments_to_srt,
|
||||
transcribe,
|
||||
)
|
||||
|
||||
|
||||
def w(word, start, end):
|
||||
return {"word": word, "start": start, "end": end}
|
||||
|
||||
|
||||
WORDS = [
|
||||
w("So,", 0.0, 0.3),
|
||||
w("um,", 0.4, 0.6),
|
||||
w("welcome", 0.7, 1.1),
|
||||
w("to", 1.15, 1.25),
|
||||
w("the", 1.3, 1.4),
|
||||
w("Show!", 1.45, 1.9),
|
||||
w("You", 2.0, 2.2),
|
||||
w("know,", 2.25, 2.4),
|
||||
w("the", 2.5, 2.6),
|
||||
w("show", 2.65, 3.0),
|
||||
w("is", 3.05, 3.15),
|
||||
w("um", 3.2, 3.5),
|
||||
w("great.", 3.6, 4.0),
|
||||
]
|
||||
|
||||
|
||||
class TestNormalizeWord:
|
||||
def test_lowercase_and_punctuation(self):
|
||||
assert normalize_word("Show!") == "show"
|
||||
assert normalize_word(" um, ") == "um"
|
||||
|
||||
def test_keeps_apostrophes(self):
|
||||
assert normalize_word("don't") == "don't"
|
||||
|
||||
def test_pure_punctuation_becomes_empty(self):
|
||||
assert normalize_word("...") == ""
|
||||
|
||||
|
||||
class TestFindPhraseSpans:
|
||||
def test_single_word_multiple_hits(self):
|
||||
spans = find_phrase_spans(WORDS, "um")
|
||||
assert spans == [(0.4, 0.6), (3.2, 3.5)]
|
||||
|
||||
def test_multi_word_phrase(self):
|
||||
spans = find_phrase_spans(WORDS, "welcome to the show")
|
||||
assert spans == [(0.7, 1.9)]
|
||||
|
||||
def test_case_and_punctuation_insensitive(self):
|
||||
assert find_phrase_spans(WORDS, "THE SHOW!") == [(1.3, 1.9), (2.5, 3.0)]
|
||||
|
||||
def test_no_match(self):
|
||||
assert find_phrase_spans(WORDS, "absent phrase") == []
|
||||
|
||||
def test_empty_phrase(self):
|
||||
assert find_phrase_spans(WORDS, "") == []
|
||||
assert find_phrase_spans(WORDS, "...") == []
|
||||
|
||||
def test_no_overlapping_rescan(self):
|
||||
words = [w("go", 0, 1), w("go", 1, 2), w("go", 2, 3)]
|
||||
assert find_phrase_spans(words, "go go") == [(0, 2)]
|
||||
|
||||
|
||||
class TestFindFillerSpans:
|
||||
def test_default_fillers(self):
|
||||
spans = find_filler_spans(WORDS)
|
||||
assert (0.4, 0.6) in spans
|
||||
assert (3.2, 3.5) in spans
|
||||
|
||||
def test_multi_word_filler(self):
|
||||
spans = find_filler_spans(WORDS, fillers=("you know",))
|
||||
assert spans == [(2.0, 2.4)]
|
||||
|
||||
def test_sorted_output(self):
|
||||
spans = find_filler_spans(WORDS, fillers=("um", "so"))
|
||||
assert spans == sorted(spans)
|
||||
|
||||
def test_defaults_are_conservative(self):
|
||||
# "like" and "so" are speech, not noise — must not be default-cut.
|
||||
assert "like" not in DEFAULT_FILLERS
|
||||
assert "so" not in DEFAULT_FILLERS
|
||||
|
||||
|
||||
class TestMergeRanges:
|
||||
def test_empty(self):
|
||||
assert merge_ranges([]) == []
|
||||
|
||||
def test_overlap_merges(self):
|
||||
assert merge_ranges([(0, 2), (1, 3)]) == [(0, 3)]
|
||||
|
||||
def test_disjoint_stay_split(self):
|
||||
assert merge_ranges([(0, 1), (2, 3)]) == [(0, 1), (2, 3)]
|
||||
|
||||
def test_min_gap_bridges(self):
|
||||
assert merge_ranges([(0, 1), (1.05, 2)], min_gap=0.1) == [(0, 2)]
|
||||
|
||||
def test_unsorted_input(self):
|
||||
assert merge_ranges([(2, 3), (0, 1)]) == [(0, 1), (2, 3)]
|
||||
|
||||
|
||||
class TestInvertRanges:
|
||||
def test_middle_keep(self):
|
||||
assert invert_ranges([(1, 2)], 0, 4) == [(0, 1), (2, 4)]
|
||||
|
||||
def test_keep_at_edges(self):
|
||||
assert invert_ranges([(0, 1), (3, 4)], 0, 4) == [(1, 3)]
|
||||
|
||||
def test_nothing_kept_cuts_everything(self):
|
||||
assert invert_ranges([], 0, 4) == [(0, 4)]
|
||||
|
||||
def test_everything_kept_cuts_nothing(self):
|
||||
assert invert_ranges([(0, 4)], 0, 4) == []
|
||||
|
||||
def test_keep_outside_window_ignored(self):
|
||||
assert invert_ranges([(10, 12)], 0, 4) == [(0, 4)]
|
||||
|
||||
def test_overlapping_keeps_merged_first(self):
|
||||
assert invert_ranges([(1, 2), (1.5, 3)], 0, 4) == [(0, 1), (3, 4)]
|
||||
|
||||
def test_empty_window(self):
|
||||
assert invert_ranges([(0, 1)], 5, 5) == []
|
||||
|
||||
|
||||
class TestTranscribeDegradation:
|
||||
def test_missing_file_returns_none(self):
|
||||
assert transcribe("/nonexistent/path/audio.wav") is None
|
||||
|
||||
def test_invalid_model_raises(self):
|
||||
with pytest.raises(ValueError, match="model_size"):
|
||||
transcribe("whatever.wav", model_size="huge-nonsense")
|
||||
|
||||
def test_missing_dependency_returns_none(self, tmp_path, monkeypatch):
|
||||
# A real file, but faster_whisper import blocked -> graceful None.
|
||||
f = tmp_path / "a.wav"
|
||||
f.write_bytes(b"RIFF0000WAVE")
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def block(name, *args, **kwargs):
|
||||
if name.startswith("faster_whisper"):
|
||||
raise ImportError("blocked for test")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", block)
|
||||
assert transcribe(str(f)) is None
|
||||
|
||||
|
||||
class TestSegmentsToSrt:
|
||||
def test_format(self):
|
||||
srt = segments_to_srt(
|
||||
[
|
||||
{"text": "Hello there.", "start": 0.0, "end": 1.5},
|
||||
{"text": "Second line.", "start": 61.25, "end": 3661.0},
|
||||
]
|
||||
)
|
||||
assert "1\n00:00:00,000 --> 00:00:01,500\nHello there.\n" in srt
|
||||
assert "2\n00:01:01,250 --> 01:01:01,000\nSecond line.\n" in srt
|
||||
|
||||
def test_empty(self):
|
||||
assert segments_to_srt([]) == ""
|
||||
|
||||
|
||||
class TestFormatTimestamp:
|
||||
def test_zero(self):
|
||||
assert format_timestamp(0) == "00:00:00.000"
|
||||
|
||||
def test_simple(self):
|
||||
assert format_timestamp(1.5) == "00:00:01.500"
|
||||
|
||||
def test_minutes_and_hours(self):
|
||||
assert format_timestamp(3661.0) == "01:01:01.000"
|
||||
|
||||
def test_rounds_to_ms(self):
|
||||
assert format_timestamp(1.234567) == "00:00:01.235"
|
||||
|
||||
|
||||
# ===== MCP handler integration (no whisper needed — uses cached transcripts) =====
|
||||
|
||||
PROJECT_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<fcpxml version="1.13">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<asset id="r2" name="interview" start="0s" duration="8s" hasVideo="0" hasAudio="1"
|
||||
audioSources="1" audioChannels="1" audioRate="48000">
|
||||
<media-rep kind="original-media" src="{src}"/>
|
||||
</asset>
|
||||
</resources>
|
||||
<library>
|
||||
<event name="Test">
|
||||
<project name="TranscriptTest">
|
||||
<sequence format="r1" duration="8s" tcStart="0s">
|
||||
<spine>
|
||||
<asset-clip ref="r2" offset="0s" name="interview" start="0s" duration="8s" audioRole="dialogue"/>
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>
|
||||
"""
|
||||
|
||||
TRANSCRIPT = {
|
||||
"source": "interview.wav",
|
||||
"language": "en",
|
||||
"duration": 8.0,
|
||||
"text": "Um, welcome to the show. Uh, today we talk about editing.",
|
||||
"segments": [
|
||||
{"text": "Um, welcome to the show.", "start": 0.0, "end": 2.5},
|
||||
{"text": "Uh, today we talk about editing.", "start": 3.0, "end": 7.5},
|
||||
],
|
||||
"words": [
|
||||
{"word": "Um,", "start": 0.0, "end": 0.5},
|
||||
{"word": "welcome", "start": 0.5, "end": 1.0},
|
||||
{"word": "to", "start": 1.0, "end": 1.25},
|
||||
{"word": "the", "start": 1.25, "end": 1.5},
|
||||
{"word": "show.", "start": 1.5, "end": 2.5},
|
||||
{"word": "Uh,", "start": 3.0, "end": 3.5},
|
||||
{"word": "today", "start": 3.5, "end": 4.0},
|
||||
{"word": "we", "start": 4.0, "end": 4.25},
|
||||
{"word": "talk", "start": 4.25, "end": 4.75},
|
||||
{"word": "about", "start": 4.75, "end": 5.25},
|
||||
{"word": "editing.", "start": 5.25, "end": 7.5},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _write_fixture(tmp_path: Path) -> str:
|
||||
"""Dummy media + cached transcript + project referencing them."""
|
||||
wav = tmp_path / "interview.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVEnot-real-audio")
|
||||
(tmp_path / "interview_transcript.json").write_text(json.dumps(TRANSCRIPT))
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src=f"file://{wav}"))
|
||||
return str(fcpxml_path)
|
||||
|
||||
|
||||
def _spine_segments(path: str, name: str = "interview"):
|
||||
project = FCPXMLParser().parse_file(path)
|
||||
return [c for c in project.primary_timeline.clips if c.name == name]
|
||||
|
||||
|
||||
class TestRemoveFillerWordsHandler:
|
||||
async def test_cuts_default_fillers(self, tmp_path):
|
||||
from server import handle_remove_filler_words
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_remove_filler_words({"filepath": filepath, "padding": 0})
|
||||
assert "_defillered" in result[0].text
|
||||
|
||||
segments = _spine_segments(str(tmp_path / "project_defillered.fcpxml"))
|
||||
# um (0-0.5) trims the clip head, uh (3-3.5) splits -> 2 segments, ~7s total
|
||||
assert len(segments) == 2
|
||||
total = sum(c.duration.seconds for c in segments)
|
||||
assert total == pytest.approx(7.0, abs=0.1)
|
||||
assert segments[0].source_start.seconds == pytest.approx(0.5, abs=0.05)
|
||||
assert segments[1].source_start.seconds == pytest.approx(3.5, abs=0.05)
|
||||
|
||||
async def test_no_fillers_found_saves_nothing(self, tmp_path):
|
||||
from server import handle_remove_filler_words
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_remove_filler_words(
|
||||
{"filepath": filepath, "fillers": ["nonexistentword"]}
|
||||
)
|
||||
assert "nothing saved" in result[0].text.lower()
|
||||
assert not (tmp_path / "project_defillered.fcpxml").exists()
|
||||
|
||||
async def test_rejects_bad_padding(self, tmp_path):
|
||||
from server import handle_remove_filler_words
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
with pytest.raises(ValueError):
|
||||
await handle_remove_filler_words({"filepath": filepath, "padding": 99})
|
||||
|
||||
|
||||
class TestEditByTranscriptHandler:
|
||||
async def test_remove_phrase(self, tmp_path):
|
||||
from server import handle_edit_by_transcript
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
await handle_edit_by_transcript(
|
||||
{"filepath": filepath, "phrases": ["welcome to the show"], "padding": 0}
|
||||
)
|
||||
segments = _spine_segments(str(tmp_path / "project_transcript_edit.fcpxml"))
|
||||
# 0.5-2.5 removed -> two segments, ~6s total
|
||||
assert len(segments) == 2
|
||||
total = sum(c.duration.seconds for c in segments)
|
||||
assert total == pytest.approx(6.0, abs=0.1)
|
||||
|
||||
async def test_keep_only_phrase(self, tmp_path):
|
||||
from server import handle_edit_by_transcript
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
await handle_edit_by_transcript(
|
||||
{"filepath": filepath, "phrases": ["today we talk about editing"],
|
||||
"mode": "keep_only", "padding": 0}
|
||||
)
|
||||
segments = _spine_segments(str(tmp_path / "project_transcript_edit.fcpxml"))
|
||||
# keep 3.5-7.5 -> one segment ~4s
|
||||
assert len(segments) == 1
|
||||
assert segments[0].duration.seconds == pytest.approx(4.0, abs=0.1)
|
||||
assert segments[0].source_start.seconds == pytest.approx(3.5, abs=0.1)
|
||||
|
||||
async def test_keep_only_without_match_leaves_clip(self, tmp_path):
|
||||
from server import handle_edit_by_transcript
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_edit_by_transcript(
|
||||
{"filepath": filepath, "phrases": ["absent phrase"], "mode": "keep_only"}
|
||||
)
|
||||
assert "left untouched" in result[0].text
|
||||
assert not (tmp_path / "project_transcript_edit.fcpxml").exists()
|
||||
|
||||
async def test_rejects_empty_phrases(self, tmp_path):
|
||||
from server import handle_edit_by_transcript
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
with pytest.raises(ValueError):
|
||||
await handle_edit_by_transcript({"filepath": filepath, "phrases": []})
|
||||
with pytest.raises(ValueError):
|
||||
await handle_edit_by_transcript({"filepath": filepath, "phrases": [" "]})
|
||||
|
||||
async def test_rejects_bad_mode(self, tmp_path):
|
||||
from server import handle_edit_by_transcript
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
with pytest.raises(ValueError):
|
||||
await handle_edit_by_transcript(
|
||||
{"filepath": filepath, "phrases": ["x"], "mode": "invert"}
|
||||
)
|
||||
|
||||
|
||||
class TestTranscribeMediaHandler:
|
||||
async def test_uses_cached_transcript(self, tmp_path):
|
||||
from server import handle_transcribe_media
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_transcribe_media({"filepath": filepath})
|
||||
text = result[0].text
|
||||
assert "interview.wav" in text
|
||||
assert "11" in text # word count
|
||||
assert "edit_by_transcript" in text
|
||||
|
||||
async def test_write_srt_from_cache(self, tmp_path):
|
||||
from server import handle_transcribe_media
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
await handle_transcribe_media({"filepath": filepath, "write_srt": True})
|
||||
srt = (tmp_path / "interview_transcript.srt").read_text()
|
||||
assert "welcome to the show" in srt
|
||||
assert "-->" in srt
|
||||
|
||||
async def test_no_whisper_no_cache_gives_install_hint(self, tmp_path, monkeypatch):
|
||||
import builtins
|
||||
|
||||
from server import handle_transcribe_media
|
||||
|
||||
wav = tmp_path / "raw.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVE")
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src=f"file://{wav}"))
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def block(name, *args, **kwargs):
|
||||
if name.startswith("faster_whisper"):
|
||||
raise ImportError("blocked for test")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", block)
|
||||
result = await handle_transcribe_media({"filepath": str(fcpxml_path)})
|
||||
assert "[transcribe]" in result[0].text
|
||||
|
||||
async def test_missing_media_reported(self, tmp_path):
|
||||
from server import handle_transcribe_media
|
||||
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src="file:///nonexistent/a.wav"))
|
||||
result = await handle_transcribe_media({"filepath": str(fcpxml_path)})
|
||||
assert "missing" in result[0].text.lower()
|
||||
|
||||
|
||||
class TestTranscriptMarkersHandler:
|
||||
async def test_adds_one_marker_per_segment(self, tmp_path):
|
||||
from server import handle_transcript_markers
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_transcript_markers({"filepath": filepath})
|
||||
text = result[0].text
|
||||
assert "Markers Added" in text
|
||||
assert "2" in text # two segments in TRANSCRIPT
|
||||
|
||||
project = FCPXMLParser().parse_file(str(tmp_path / "project_transcript_markers.fcpxml"))
|
||||
clip = project.primary_timeline.clips[0]
|
||||
assert len(clip.markers) == 2
|
||||
assert clip.markers[0].name == "Um, welcome to the show."
|
||||
assert clip.markers[1].name == "Uh, today we talk about editing."
|
||||
|
||||
async def test_truncates_long_labels(self, tmp_path):
|
||||
from server import handle_transcript_markers
|
||||
|
||||
filepath = _write_fixture(tmp_path)
|
||||
result = await handle_transcript_markers({"filepath": filepath, "max_label_length": 10})
|
||||
assert "Markers Added" in result[0].text
|
||||
project = FCPXMLParser().parse_file(str(tmp_path / "project_transcript_markers.fcpxml"))
|
||||
clip = project.primary_timeline.clips[0]
|
||||
assert all(len(m.name) <= 10 for m in clip.markers)
|
||||
|
||||
async def test_missing_media_reported_not_crashed(self, tmp_path):
|
||||
from server import handle_transcript_markers
|
||||
|
||||
fcpxml_path = tmp_path / "project.fcpxml"
|
||||
fcpxml_path.write_text(PROJECT_XML.format(src="file:///nonexistent/a.wav"))
|
||||
result = await handle_transcript_markers({"filepath": str(fcpxml_path)})
|
||||
text = result[0].text
|
||||
assert "nothing saved" in text.lower() or "missing" in text.lower()
|
||||
assert not (tmp_path / "project_transcript_markers.fcpxml").exists()
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
"""Tests for FCPXML validation functions and DTD infrastructure.
|
||||
|
||||
Covers the 6 _check_*() validators, _dtd_insert() ordering,
|
||||
_enforce_standard_timebases() snapping, and validate_fcpxml() orchestration.
|
||||
These are critical output-correctness guards — bugs here mean FCP import failures.
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from fcpxml.models import ValidationIssueType
|
||||
from fcpxml.writer import (
|
||||
_check_asset_sources,
|
||||
_check_child_order,
|
||||
_check_effect_refs,
|
||||
_check_frame_alignment,
|
||||
_check_required_attributes,
|
||||
_check_timebases,
|
||||
_dtd_insert,
|
||||
_enforce_standard_timebases,
|
||||
_sanitize_xml_value,
|
||||
validate_fcpxml,
|
||||
)
|
||||
|
||||
# ── _dtd_insert: DTD-ordered element insertion ──────────────────────
|
||||
|
||||
|
||||
def test_dtd_insert_marker_before_filter():
|
||||
"""Marker must appear before filter-video per DTD ordering."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "filter-video")
|
||||
marker = ET.Element("marker", value="test")
|
||||
_dtd_insert(clip, marker)
|
||||
tags = [c.tag for c in clip]
|
||||
assert tags.index("marker") < tags.index("filter-video")
|
||||
|
||||
|
||||
def test_dtd_insert_note_always_first():
|
||||
"""<note> has lowest priority — always inserted at position 0."""
|
||||
clip = ET.Element("clip")
|
||||
ET.SubElement(clip, "marker")
|
||||
ET.SubElement(clip, "filter-audio")
|
||||
note = ET.Element("note")
|
||||
_dtd_insert(clip, note)
|
||||
assert clip[0].tag == "note"
|
||||
|
||||
|
||||
def test_dtd_insert_unknown_tag_appended():
|
||||
"""Unknown tags get appended at the end."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "note")
|
||||
ET.SubElement(clip, "marker")
|
||||
custom = ET.Element("custom-extension")
|
||||
_dtd_insert(clip, custom)
|
||||
assert clip[-1].tag == "custom-extension"
|
||||
|
||||
|
||||
def test_dtd_insert_into_empty_parent():
|
||||
"""Inserting into an empty element works without error."""
|
||||
clip = ET.Element("clip")
|
||||
marker = ET.Element("marker", value="first")
|
||||
_dtd_insert(clip, marker)
|
||||
assert len(clip) == 1
|
||||
assert clip[0].tag == "marker"
|
||||
|
||||
|
||||
def test_dtd_insert_preserves_between_existing():
|
||||
"""Insert conform-rate between note and marker — correct middle position."""
|
||||
clip = ET.Element("asset-clip")
|
||||
ET.SubElement(clip, "note")
|
||||
ET.SubElement(clip, "marker")
|
||||
ET.SubElement(clip, "filter-video")
|
||||
conform = ET.Element("conform-rate")
|
||||
_dtd_insert(clip, conform)
|
||||
tags = [c.tag for c in clip]
|
||||
assert tags == ["note", "conform-rate", "marker", "filter-video"]
|
||||
|
||||
|
||||
# ── _check_child_order: DTD ordering violations ─────────────────────
|
||||
|
||||
|
||||
def test_check_child_order_valid():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip name="A">'
|
||||
'<note/><marker/><filter-video/>'
|
||||
'</asset-clip></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
def test_check_child_order_detects_violation():
|
||||
"""filter-video before marker violates DTD ordering."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip name="Bad">'
|
||||
'<filter-video ref="r1"/><marker value="x"/>'
|
||||
'</asset-clip></fcpxml>'
|
||||
)
|
||||
issues = _check_child_order(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.ELEMENT_ORDER
|
||||
assert "Bad" in issues[0].message
|
||||
|
||||
|
||||
def test_check_child_order_skips_single_child():
|
||||
"""Single-child elements can't be out of order."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip name="Solo"><marker/></clip></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
def test_check_child_order_ignores_non_clip_parents():
|
||||
"""Only clip/asset-clip/video/audio/ref-clip are checked."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><spine><filter-video/><marker/></spine></fcpxml>'
|
||||
)
|
||||
assert _check_child_order(root) == []
|
||||
|
||||
|
||||
# ── _check_required_attributes ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_required_attrs_all_present():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip ref="r1" duration="100/2400s" name="A"/>'
|
||||
'<format id="r1"/></fcpxml>'
|
||||
)
|
||||
assert _check_required_attributes(root) == []
|
||||
|
||||
|
||||
def test_required_attrs_missing_ref_on_asset_clip():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><asset-clip duration="100/2400s" name="No Ref"/></fcpxml>'
|
||||
)
|
||||
issues = _check_required_attributes(root)
|
||||
types = [i.issue_type for i in issues]
|
||||
assert ValidationIssueType.MISSING_ATTRIBUTE in types
|
||||
assert any("ref" in i.message for i in issues)
|
||||
|
||||
|
||||
def test_required_attrs_missing_id_on_format():
|
||||
root = ET.fromstring('<fcpxml><format name="HD"/></fcpxml>')
|
||||
issues = _check_required_attributes(root)
|
||||
assert len(issues) == 1
|
||||
assert "id" in issues[0].message
|
||||
|
||||
|
||||
def test_required_attrs_transition_missing_all():
|
||||
"""Transition missing name, offset, duration → 3 issues."""
|
||||
root = ET.fromstring('<fcpxml><transition/></fcpxml>')
|
||||
issues = _check_required_attributes(root)
|
||||
assert len(issues) == 3
|
||||
|
||||
|
||||
# ── _check_timebases ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_timebases_standard_ok():
|
||||
"""100/2400s simplifies to 1/24, 2400/2400s to 1/1 — both standard."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip offset="100/2400s" duration="2400/2400s"/></fcpxml>'
|
||||
)
|
||||
assert _check_timebases(root) == []
|
||||
|
||||
|
||||
def test_timebases_flags_nonstandard():
|
||||
"""Denominator 7 is not a standard FCPXML timebase."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip offset="3/7s" name="Weird"/></fcpxml>'
|
||||
)
|
||||
issues = _check_timebases(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.INVALID_TIMEBASE
|
||||
assert "7" in issues[0].message
|
||||
|
||||
|
||||
def test_timebases_deduplicates():
|
||||
"""Same non-standard value on same element+attr only reported once."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<clip offset="3/7s" name="A"/>'
|
||||
'<clip offset="3/7s" name="B"/>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
issues = _check_timebases(root)
|
||||
# Both have same (clip, offset, 3/7s) key — only 1 issue
|
||||
assert len(issues) == 1
|
||||
|
||||
|
||||
# ── _check_frame_alignment ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_frame_alignment_exact():
|
||||
"""100 frames at 24fps = 100/24s — frame-aligned."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip duration="100/24s" name="Aligned"/></fcpxml>'
|
||||
)
|
||||
assert _check_frame_alignment(root, fps=24.0) == []
|
||||
|
||||
|
||||
def test_frame_alignment_misaligned():
|
||||
"""7/24s = 7 frames — aligned. But 7/48s = 3.5 frames at 24fps — misaligned."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip duration="7/48s" name="Half Frame"/></fcpxml>'
|
||||
)
|
||||
issues = _check_frame_alignment(root, fps=24.0)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.FRAME_MISALIGNMENT
|
||||
|
||||
|
||||
def test_frame_alignment_ignores_non_clip_elements():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><format duration="7/48s"/></fcpxml>'
|
||||
)
|
||||
assert _check_frame_alignment(root, fps=24.0) == []
|
||||
|
||||
|
||||
# ── _check_effect_refs ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_effect_refs_valid():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources><effect id="e1"/></resources>'
|
||||
'<clip><filter-video ref="e1"/></clip></fcpxml>'
|
||||
)
|
||||
assert _check_effect_refs(root) == []
|
||||
|
||||
|
||||
def test_effect_refs_dangling():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><clip><filter-video ref="missing_effect"/></clip></fcpxml>'
|
||||
)
|
||||
issues = _check_effect_refs(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.MISSING_EFFECT_REF
|
||||
assert "missing_effect" in issues[0].message
|
||||
|
||||
|
||||
# ── _check_asset_sources ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_asset_with_src():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" src="file:///video.mov" name="V"/>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
assert _check_asset_sources(root) == []
|
||||
|
||||
|
||||
def test_asset_with_media_rep():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" name="V"><media-rep/></asset>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
assert _check_asset_sources(root) == []
|
||||
|
||||
|
||||
def test_asset_missing_both():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml><resources>'
|
||||
'<asset id="r1" name="Orphan"/>'
|
||||
'</resources></fcpxml>'
|
||||
)
|
||||
issues = _check_asset_sources(root)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_type == ValidationIssueType.MISSING_MEDIA_REP
|
||||
|
||||
|
||||
# ── _enforce_standard_timebases ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_enforce_timebases_snaps_nonstandard():
|
||||
root = ET.fromstring('<fcpxml><clip offset="3/7s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root)
|
||||
clip = root.find("clip")
|
||||
val = clip.get("offset")
|
||||
assert "/" in val and val.endswith("s")
|
||||
# Should be snapped to a standard timebase denominator
|
||||
denom = int(val.rstrip("s").split("/")[1])
|
||||
assert denom in {1, 24, 25, 30, 48, 50, 60, 90, 96, 100, 120, 240, 600, 2400}
|
||||
|
||||
|
||||
def test_enforce_timebases_leaves_standard_alone():
|
||||
root = ET.fromstring('<fcpxml><clip offset="3600/2400s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root)
|
||||
assert root.find("clip").get("offset") == "3600/2400s"
|
||||
|
||||
|
||||
def test_enforce_timebases_handles_unparseable():
|
||||
"""Malformed time values are silently skipped, not crashed on."""
|
||||
root = ET.fromstring('<fcpxml><clip offset="garbage/0s"/></fcpxml>')
|
||||
_enforce_standard_timebases(root) # Should not raise
|
||||
assert root.find("clip").get("offset") == "garbage/0s"
|
||||
|
||||
|
||||
# ── _sanitize_xml_value edge cases ──────────────────────────────────
|
||||
|
||||
|
||||
def test_sanitize_strips_null_bytes():
|
||||
assert _sanitize_xml_value("he\x00llo") == "hello"
|
||||
|
||||
|
||||
def test_sanitize_preserves_tabs_newlines():
|
||||
assert _sanitize_xml_value("line1\tline2\n") == "line1\tline2\n"
|
||||
|
||||
|
||||
def test_sanitize_enforces_max_length():
|
||||
result = _sanitize_xml_value("x" * 500, max_length=100)
|
||||
assert len(result) == 100
|
||||
|
||||
|
||||
def test_sanitize_coerces_non_string():
|
||||
assert _sanitize_xml_value(42) == "42"
|
||||
|
||||
|
||||
# ── validate_fcpxml orchestration ───────────────────────────────────
|
||||
|
||||
|
||||
def test_validate_fcpxml_aggregates_all_checks():
|
||||
"""Intentionally broken XML triggers multiple validator categories."""
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<resources><asset id="r1" name="No Source"/></resources>'
|
||||
'<asset-clip ref="r1" duration="7/48s" name="Clip">'
|
||||
'<filter-video ref="ghost"/><marker value="x"/>'
|
||||
'</asset-clip>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
issues = validate_fcpxml(root, fps=24.0)
|
||||
types = {i.issue_type for i in issues}
|
||||
# Should catch: ordering (filter before marker), missing media rep,
|
||||
# dangling effect ref, frame misalignment
|
||||
assert ValidationIssueType.ELEMENT_ORDER in types
|
||||
assert ValidationIssueType.MISSING_MEDIA_REP in types
|
||||
assert ValidationIssueType.MISSING_EFFECT_REF in types
|
||||
assert ValidationIssueType.FRAME_MISALIGNMENT in types
|
||||
|
||||
|
||||
def test_validate_fcpxml_clean_returns_empty():
|
||||
root = ET.fromstring(
|
||||
'<fcpxml>'
|
||||
'<resources><effect id="e1"/>'
|
||||
'<asset id="r1" src="file:///v.mov"/></resources>'
|
||||
'<asset-clip ref="r1" duration="100/2400s" name="OK">'
|
||||
'<marker value="ch1"/><filter-video ref="e1"/>'
|
||||
'</asset-clip>'
|
||||
'</fcpxml>'
|
||||
)
|
||||
assert validate_fcpxml(root) == []
|
||||
Executable
+2069
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user