chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for FCP MCP Server."""
+174
View File
@@ -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")
+86
View File
@@ -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([]) == []
+284
View File
@@ -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
+322
View File
@@ -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()
+136
View File
@@ -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
+764
View File
@@ -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"
+617
View File
@@ -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)
+323
View File
@@ -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)
+579
View File
@@ -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
+876
View File
@@ -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 == []
+1046
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -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
+259
View File
@@ -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"
+586
View File
@@ -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)
+841
View File
@@ -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)
+384
View File
@@ -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)
+516
View File
@@ -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)
+143
View File
@@ -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
+685
View File
@@ -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()
+1167
View File
File diff suppressed because it is too large Load Diff
+618
View File
@@ -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
+417
View File
@@ -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)
+351
View File
@@ -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])
+256
View File
@@ -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
+438
View File
@@ -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()
+350
View File
@@ -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) == []
+2069
View File
File diff suppressed because it is too large Load Diff