"""Tests for FCPXML writer/modifier."""
import os
import shutil
import tempfile
import textwrap
from pathlib import Path
import pytest
from fcpxml.parser import FCPXMLParser
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)
# ============================================================
# Insert Clip Tests
# ============================================================
def test_insert_clip_at_end(temp_fcpxml):
"""Insert a library clip at the end of the timeline."""
modifier = FCPXMLModifier(temp_fcpxml)
# Get initial clip count
initial_count = len(list(modifier._get_spine()))
# Insert Broll_City (r3) at the end
result = modifier.insert_clip(
asset_id='r3',
position='end',
duration='2s'
)
assert result is not None
# Verify clip was added
final_count = len(list(modifier._get_spine()))
assert final_count == initial_count + 1
def test_insert_clip_at_start(temp_fcpxml):
"""Insert a library clip at the start of the timeline."""
modifier = FCPXMLModifier(temp_fcpxml)
# Insert clip at start
modifier.insert_clip(
asset_id='r4', # Broll_Studio
position='start',
duration='1s'
)
# Verify clip is at offset 0
spine = modifier._get_spine()
first_clip = list(spine)[0]
assert first_clip.get('offset') == '0/24s' or first_clip.get('offset') == '0s'
def test_insert_clip_at_timecode(temp_fcpxml):
"""Insert a library clip at a specific timecode."""
modifier = FCPXMLModifier(temp_fcpxml)
# Insert at 00:00:05:00 (5 seconds)
result = modifier.insert_clip(
asset_id='r3',
position='00:00:05:00',
duration='1s'
)
# Verify clip was inserted
assert result is not None
def test_insert_clip_with_subclip(temp_fcpxml):
"""Insert a portion of a library clip (subclip)."""
modifier = FCPXMLModifier(temp_fcpxml)
# Insert with in/out points
result = modifier.insert_clip(
asset_id='r2', # Interview_A (300s long)
position='end',
in_point='00:00:10:00', # Start at 10s in source
out_point='00:00:15:00' # End at 15s in source (5s clip)
)
# Verify the start attribute matches in_point
start_attr = result.get('start')
assert start_attr is not None
# Should be 10 seconds worth of frames (240/24s at 24fps)
assert '240' in start_attr or '10s' in start_attr
def test_insert_clip_ripples_subsequent(temp_fcpxml):
"""Inserting a clip should shift subsequent clips forward."""
modifier = FCPXMLModifier(temp_fcpxml)
# Get second clip's original offset
spine = modifier._get_spine()
clips = [c for c in spine if c.tag in ('asset-clip', 'clip')]
original_second_clip_offset = clips[1].get('offset')
# Insert 2 seconds at start
modifier.insert_clip(
asset_id='r4',
position='start',
duration='2s',
ripple=True
)
# Verify second clip shifted
spine = modifier._get_spine()
clips = [c for c in spine if c.tag in ('asset-clip', 'clip')]
# Original second clip is now at index 2 (after new clip at 0)
new_second_clip = clips[2] # Was clips[1], now clips[2]
# Original offset was 72/24s = 3s, now should be 120/24s = 5s (+2s)
new_offset = new_second_clip.get('offset')
assert new_offset != original_second_clip_offset
def test_insert_clip_by_name(temp_fcpxml):
"""Can insert a clip by asset name instead of ID."""
modifier = FCPXMLModifier(temp_fcpxml)
result = modifier.insert_clip(
asset_name='Broll_City', # Instead of asset_id='r3'
position='end',
duration='1s'
)
assert result is not None
assert result.get('ref') == 'r3'
def test_insert_clip_after_specific_clip(temp_fcpxml):
"""Insert clip after a specific clip."""
modifier = FCPXMLModifier(temp_fcpxml)
result = modifier.insert_clip(
asset_id='r3',
position='after:Interview_A', # After first Interview_A
duration='1s'
)
assert result is not None
def test_insert_clip_saves_correctly(temp_fcpxml):
"""Inserted clip should persist after save and reload."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
modifier.insert_clip(
asset_id='r3',
position='end',
duration='2s'
)
# Save to temp file
output = temp_fcpxml.replace('.fcpxml', '_modified.fcpxml')
modifier.save(output)
# Reload and verify
parser = FCPXMLParser()
project = parser.parse_file(output)
reloaded_clip_count = len(project.primary_timeline.clips)
# Clean up
Path(output).unlink(missing_ok=True)
assert reloaded_clip_count == initial_count + 1
# ============================================================
# Add Marker Tests
# ============================================================
def test_add_marker_standard(temp_fcpxml):
"""Add a standard marker to a clip."""
modifier = FCPXMLModifier(temp_fcpxml)
marker = modifier.add_marker(
clip_id='Broll_City',
timecode='00:00:01:00',
name='Review this'
)
assert marker is not None
assert marker.tag == 'marker'
assert marker.get('value') == 'Review this'
assert marker.get('start') is not None
def test_add_marker_chapter(temp_fcpxml):
"""Add a chapter marker to a clip."""
from fcpxml.models import MarkerType
modifier = FCPXMLModifier(temp_fcpxml)
marker = modifier.add_marker(
clip_id='Broll_City',
timecode='00:00:00:12',
name='Chapter Start',
marker_type=MarkerType.CHAPTER
)
assert marker.tag == 'chapter-marker'
assert marker.get('value') == 'Chapter Start'
assert marker.get('posterOffset') == '0s'
def test_add_marker_incomplete(temp_fcpxml):
"""Add an incomplete marker to a clip — must set completed='0' for round-trip fidelity."""
from fcpxml.models import MarkerType
modifier = FCPXMLModifier(temp_fcpxml)
marker = modifier.add_marker(
clip_id='Broll_City',
timecode='00:00:00:06',
name='Fix color',
marker_type=MarkerType.INCOMPLETE
)
assert marker.tag == 'marker'
assert marker.get('value') == 'Fix color'
assert marker.get('completed') == '0'
def test_add_marker_completed(temp_fcpxml):
"""Add a completed marker to a clip."""
from fcpxml.models import MarkerType
modifier = FCPXMLModifier(temp_fcpxml)
marker = modifier.add_marker(
clip_id='Broll_City',
timecode='00:00:00:06',
name='Done item',
marker_type=MarkerType.COMPLETED
)
assert marker.get('completed') == '1'
def test_marker_type_roundtrip(temp_fcpxml, tmp_path):
"""INCOMPLETE and COMPLETED markers survive save/re-parse without losing their type."""
from fcpxml.models import MarkerType
from fcpxml.parser import FCPXMLParser
modifier = FCPXMLModifier(temp_fcpxml)
modifier.add_marker('Broll_City', '00:00:00:06', 'Todo task', MarkerType.INCOMPLETE)
modifier.add_marker('Broll_City', '00:00:00:12', 'Done task', MarkerType.COMPLETED)
output = str(tmp_path / 'roundtrip.fcpxml')
modifier.save(output)
project = FCPXMLParser().parse_file(output)
# Collect all markers across all clips (duplicate names mean markers land on last Broll_City)
all_markers = []
for clip in project.primary_timeline.clips:
all_markers.extend(clip.markers)
types = {m.name: m.marker_type for m in all_markers}
assert types['Todo task'] == MarkerType.INCOMPLETE
assert types['Done task'] == MarkerType.COMPLETED
def test_marker_type_from_string_roundtrip(temp_fcpxml, tmp_path):
"""MarkerType.from_string('todo') → write → parse must survive as INCOMPLETE, not STANDARD."""
from fcpxml.models import MarkerType
from fcpxml.parser import FCPXMLParser
modifier = FCPXMLModifier(temp_fcpxml)
# Use from_string (the path batch_add_markers takes) instead of enum directly
modifier.add_marker('Broll_City', '00:00:00:06', 'Via string',
MarkerType.from_string('todo'))
modifier.add_marker('Broll_City', '00:00:00:12', 'Via alias',
MarkerType.from_string('todo-marker'))
output = str(tmp_path / 'from_string_rt.fcpxml')
modifier.save(output)
project = FCPXMLParser().parse_file(output)
all_markers = []
for clip in project.primary_timeline.clips:
all_markers.extend(clip.markers)
types = {m.name: m.marker_type for m in all_markers}
assert types['Via string'] == MarkerType.INCOMPLETE
assert types['Via alias'] == MarkerType.INCOMPLETE
def test_marker_completed_attr_no_whitespace(temp_fcpxml):
"""Written completed attributes must be exact '0' or '1' — no whitespace padding."""
from fcpxml.models import MarkerType
modifier = FCPXMLModifier(temp_fcpxml)
todo = modifier.add_marker('Broll_City', '00:00:00:06', 'Strict0', MarkerType.INCOMPLETE)
done = modifier.add_marker('Broll_City', '00:00:00:12', 'Strict1', MarkerType.COMPLETED)
assert todo.get('completed') == '0', "INCOMPLETE marker must write exact '0'"
assert done.get('completed') == '1', "COMPLETED marker must write exact '1'"
# Verify no leading/trailing whitespace
assert todo.get('completed').strip() == todo.get('completed')
assert done.get('completed').strip() == done.get('completed')
def test_from_string_whitespace_roundtrip(temp_fcpxml, tmp_path):
"""from_string(' completed ') must roundtrip as COMPLETED, not STANDARD."""
from fcpxml.models import MarkerType
from fcpxml.parser import FCPXMLParser
modifier = FCPXMLModifier(temp_fcpxml)
modifier.add_marker('Broll_City', '00:00:00:06', 'Padded type',
MarkerType.from_string(' completed '))
output = str(tmp_path / 'padded_type_rt.fcpxml')
modifier.save(output)
project = FCPXMLParser().parse_file(output)
all_markers = []
for clip in project.primary_timeline.clips:
all_markers.extend(clip.markers)
types = {m.name: m.marker_type for m in all_markers}
assert types['Padded type'] == MarkerType.COMPLETED
def test_add_marker_with_note(temp_fcpxml):
"""Add a marker with a note."""
modifier = FCPXMLModifier(temp_fcpxml)
marker = modifier.add_marker(
clip_id='Broll_City',
timecode='00:00:00:06',
name='Note marker',
note='Needs color correction'
)
assert marker.get('note') == 'Needs color correction'
def test_add_marker_invalid_clip(temp_fcpxml):
"""Adding a marker to a nonexistent clip raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.add_marker(
clip_id='nonexistent_clip',
timecode='00:00:01:00',
name='Should fail'
)
# ============================================================
# Trim Clip Tests
# ============================================================
def test_trim_clip_end_delta(temp_fcpxml):
"""Trim the end of a clip by a delta."""
modifier = FCPXMLModifier(temp_fcpxml)
# Broll_Studio at offset 222/24s has duration 120/24s = 5s
clip = modifier.trim_clip(
clip_id='Broll_Studio',
trim_end='-1s',
ripple=False
)
# Duration should decrease by 1s (24 frames at 24fps)
new_duration = modifier._parse_time(clip.get('duration'))
assert new_duration.to_seconds() == pytest.approx(4.0, abs=0.1)
def test_trim_clip_start_delta(temp_fcpxml):
"""Trim the start of a clip by a delta."""
modifier = FCPXMLModifier(temp_fcpxml)
original_clip = modifier.clips['Broll_Studio']
original_start = modifier._parse_time(original_clip.get('start')).to_seconds()
original_duration = modifier._parse_time(original_clip.get('duration')).to_seconds()
clip = modifier.trim_clip(
clip_id='Broll_Studio',
trim_start='+1s',
ripple=False
)
new_start = modifier._parse_time(clip.get('start')).to_seconds()
new_duration = modifier._parse_time(clip.get('duration')).to_seconds()
# Start moved forward by 1s, duration decreased by 1s
assert new_start == pytest.approx(original_start + 1.0, abs=0.1)
assert new_duration == pytest.approx(original_duration - 1.0, abs=0.1)
def test_trim_clip_strips_only_first_sign_char(temp_fcpxml):
"""Trim delta must strip only the leading sign, not multiple +/- chars.
Regression: lstrip('+-') would eat '-+--120/24s' → '120/24s', silently
consuming the entire prefix. The fix uses [1:] so only the first
character is removed — triple-signs like '---1s' become '--1s' which
the parser rejects instead of silently treating as '1s'.
"""
modifier = FCPXMLModifier(temp_fcpxml)
# A well-formed single-sign delta should still work
clip = modifier.trim_clip(clip_id='Broll_Studio', trim_end='-1s', ripple=False)
dur = modifier._parse_time(clip.get('duration')).to_seconds()
assert dur == pytest.approx(4.0, abs=0.1)
# A triple-sign like '---1s' should fail (old lstrip would silently accept it)
modifier2 = FCPXMLModifier(temp_fcpxml)
with pytest.raises((ValueError, Exception)):
modifier2.trim_clip(clip_id='Broll_Studio', trim_end='---1s', ripple=False)
def test_trim_clip_invalid_clip(temp_fcpxml):
"""Trimming a nonexistent clip raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.trim_clip(clip_id='ghost_clip', trim_end='-1s')
# ============================================================
# Delete Clip Tests
# ============================================================
def test_delete_clip_ripple(temp_fcpxml):
"""Delete a clip with ripple (shifts subsequent clips)."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
broll_before = sum(
1 for c in modifier._get_spine() if c.get('name') == 'Broll_City'
)
modifier.delete_clip(clip_ids=['Broll_City'], ripple=True)
final_count = len(list(modifier._get_spine()))
assert final_count == initial_count - 1
# One fewer Broll_City in spine; index still valid if duplicates remain
broll_after = sum(
1 for c in modifier._get_spine() if c.get('name') == 'Broll_City'
)
assert broll_after == broll_before - 1
def test_delete_clip_no_ripple_leaves_gap(temp_fcpxml):
"""Delete a clip without ripple replaces it with a gap."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
modifier.delete_clip(clip_ids=['Broll_City'], ripple=False)
# Element count stays the same (clip replaced by gap)
final_count = len(list(modifier._get_spine()))
assert final_count == initial_count
# Verify a gap element was created
spine = modifier._get_spine()
gaps = [c for c in spine if c.tag == 'gap']
assert len(gaps) >= 1
def test_delete_nonexistent_clip_is_noop(temp_fcpxml):
"""Deleting a nonexistent clip silently succeeds."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
modifier.delete_clip(clip_ids=['does_not_exist'])
final_count = len(list(modifier._get_spine()))
assert final_count == initial_count
def test_delete_clip_duplicate_names_targets_first(temp_fcpxml):
"""Deleting a duplicate-named clip removes the first spine match, not the last-indexed."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
# Sample has 4 Interview_A clips; collect their offsets before deletion
interview_clips_before = [
c for c in spine if c.get('name') == 'Interview_A'
]
assert len(interview_clips_before) == 4, "fixture should have 4 Interview_A clips"
first_offset = interview_clips_before[0].get('offset')
modifier.delete_clip(clip_ids=['Interview_A'], ripple=False)
# The first Interview_A (by spine order) should be gone
interview_clips_after = [
c for c in modifier._get_spine() if c.get('name') == 'Interview_A'
]
assert len(interview_clips_after) == 3
# The removed clip was the one at first_offset
remaining_offsets = {c.get('offset') for c in interview_clips_after}
assert first_offset not in remaining_offsets
# Index should still point to a remaining Interview_A (not orphaned)
assert 'Interview_A' in modifier.clips
assert modifier.clips['Interview_A'] in interview_clips_after
def test_delete_clip_duplicate_names_sequential(temp_fcpxml):
"""Deleting duplicate-named clips one by one removes them in spine order."""
modifier = FCPXMLModifier(temp_fcpxml)
# Delete all 4 Interview_A clips one at a time
for expected_remaining in [3, 2, 1, 0]:
remaining = [
c for c in modifier._get_spine() if c.get('name') == 'Interview_A'
]
if not remaining:
break
modifier.delete_clip(clip_ids=['Interview_A'], ripple=False)
after = [
c for c in modifier._get_spine() if c.get('name') == 'Interview_A'
]
assert len(after) == expected_remaining
# All gone, index entry removed
assert 'Interview_A' not in modifier.clips
# ============================================================
# Split Clip Tests
# ============================================================
def test_split_clip_single_point(temp_fcpxml):
"""Split a clip at one point produces two segments."""
modifier = FCPXMLModifier(temp_fcpxml)
# The indexed Interview_A is the last one: offset 1122/24s, duration 168/24s = 7s
original_duration = modifier._parse_time(
modifier.clips['Interview_A'].get('duration')
).to_seconds()
results = modifier.split_clip(
clip_id='Interview_A',
split_points=['00:00:03:00'] # Split 3s into a 7s clip
)
assert len(results) == 2
# Both segments should have duration summing to original
dur1 = modifier._parse_time(results[0].get('duration')).to_seconds()
dur2 = modifier._parse_time(results[1].get('duration')).to_seconds()
assert dur1 + dur2 == pytest.approx(original_duration, abs=0.5)
def test_split_clip_removes_stale_index_entry(temp_fcpxml):
"""After splitting, the original clip_id must be removed from self.clips.
Previously, split_clip left the original key pointing at a detached
XML element, causing silent corruption on subsequent operations.
"""
modifier = FCPXMLModifier(temp_fcpxml)
assert 'Interview_A' in modifier.clips
modifier.split_clip(
clip_id='Interview_A',
split_points=['00:00:03:00']
)
# Original key must be gone
assert 'Interview_A' not in modifier.clips
# Split keys must exist and reference live spine elements
assert 'Interview_A_split_0' in modifier.clips
assert 'Interview_A_split_1' in modifier.clips
spine = modifier._get_spine()
spine_elements = list(spine)
assert modifier.clips['Interview_A_split_0'] in spine_elements
assert modifier.clips['Interview_A_split_1'] in spine_elements
def test_split_clip_invalid_clip(temp_fcpxml):
"""Splitting a nonexistent clip raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.split_clip(clip_id='ghost', split_points=['00:00:01:00'])
# ============================================================
# Change Speed Tests
# ============================================================
def test_change_speed_double(temp_fcpxml):
"""Doubling speed halves clip duration."""
modifier = FCPXMLModifier(temp_fcpxml)
original_duration = modifier._parse_time(
modifier.clips['Broll_Studio'].get('duration')
).to_seconds()
clip = modifier.change_speed(clip_id='Broll_Studio', speed=2.0)
new_duration = modifier._parse_time(clip.get('duration')).to_seconds()
assert new_duration == pytest.approx(original_duration / 2.0, abs=0.1)
# Should have a timeMap element
timemap = clip.find('timeMap')
assert timemap is not None
def test_change_speed_half(temp_fcpxml):
"""Halving speed doubles clip duration."""
modifier = FCPXMLModifier(temp_fcpxml)
original_duration = modifier._parse_time(
modifier.clips['Broll_Studio'].get('duration')
).to_seconds()
clip = modifier.change_speed(clip_id='Broll_Studio', speed=0.5)
new_duration = modifier._parse_time(clip.get('duration')).to_seconds()
assert new_duration == pytest.approx(original_duration * 2.0, abs=0.1)
def test_change_speed_invalid_clip(temp_fcpxml):
"""Changing speed on nonexistent clip raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.change_speed(clip_id='ghost', speed=2.0)
def test_change_speed_zero_raises(temp_fcpxml):
"""Speed=0 raises ValueError instead of ZeroDivisionError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Speed must be positive"):
modifier.change_speed(clip_id='Broll_Studio', speed=0)
def test_change_speed_negative_raises(temp_fcpxml):
"""Negative speed raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Speed must be positive"):
modifier.change_speed(clip_id='Broll_Studio', speed=-1.0)
def test_add_zoom_creates_keyframed_transform(temp_fcpxml):
"""4 keyframes: 100% -> scale -> scale -> 100%, all within [start, end]."""
modifier = FCPXMLModifier(temp_fcpxml)
clip = modifier.add_zoom(clip_id='Broll_Studio', start=1.0, end=3.0, scale=1.3, ease=0.5)
transform = clip.find('adjust-transform')
assert transform is not None
param = transform.find('param')
assert param.get('name') == 'scale'
keyframes = param.find('keyframeAnimation').findall('keyframe')
assert len(keyframes) == 4
assert [kf.get('value') for kf in keyframes] == ['1 1', '1.3 1.3', '1.3 1.3', '1 1']
assert all(kf.get('interp') == 'ease' for kf in keyframes)
times = [modifier._parse_time(kf.get('time')).to_seconds() for kf in keyframes]
assert times == pytest.approx([1.0, 1.5, 2.5, 3.0], abs=0.05)
assert times == sorted(times)
def test_add_zoom_replaces_existing_zoom(temp_fcpxml):
"""Calling add_zoom twice replaces the transform instead of stacking."""
modifier = FCPXMLModifier(temp_fcpxml)
clip = modifier.add_zoom(clip_id='Broll_Studio', start=0.0, end=2.0, scale=1.2, ease=0.3)
clip = modifier.add_zoom(clip_id='Broll_Studio', start=1.0, end=3.0, scale=1.5, ease=0.3)
assert len(clip.findall('adjust-transform')) == 1
values = [kf.get('value') for kf in clip.find('.//keyframeAnimation').findall('keyframe')]
assert '1.5 1.5' in values
assert '1.2 1.2' not in values
def test_add_zoom_invalid_clip_raises(temp_fcpxml):
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.add_zoom(clip_id='ghost', start=0.0, end=1.0)
def test_add_zoom_end_before_start_raises(temp_fcpxml):
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="must be greater than"):
modifier.add_zoom(clip_id='Broll_Studio', start=2.0, end=1.0)
def test_add_zoom_window_outside_clip_duration_raises(temp_fcpxml):
"""Broll_Studio is 5s long — a window past that must be rejected."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="clip's duration"):
modifier.add_zoom(clip_id='Broll_Studio', start=0.0, end=10.0)
def test_add_zoom_ease_too_long_for_window_raises(temp_fcpxml):
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="doesn't fit"):
modifier.add_zoom(clip_id='Broll_Studio', start=0.0, end=1.0, ease=1.0)
def test_change_speed_twice_no_duplicate_elements(temp_fcpxml):
"""Calling change_speed twice must not create duplicate timeMap/conform-rate."""
modifier = FCPXMLModifier(temp_fcpxml)
modifier.change_speed(clip_id='Broll_Studio', speed=2.0)
modifier.change_speed(clip_id='Broll_Studio', speed=0.5)
clip = modifier.clips['Broll_Studio']
timemaps = clip.findall('timeMap')
conform_rates = clip.findall('conform-rate')
assert len(timemaps) == 1, (
f"Expected 1 timeMap after two speed changes, got {len(timemaps)}"
)
assert len(conform_rates) == 1, (
f"Expected 1 conform-rate after two speed changes, got {len(conform_rates)}"
)
# Verify the final speed is 0.5x (duration should double)
duration_str = clip.get('duration')
duration = modifier._parse_time(duration_str)
original = modifier._parse_time('2400/2400s') # 1s original
assert duration.to_seconds() > original.to_seconds(), (
"0.5x speed should produce longer duration than original"
)
# ============================================================
# change_speed — Fractional & Edge-Case Speeds
# ============================================================
def test_change_speed_fractional_1_5x(temp_fcpxml):
"""1.5x speed (cinema ramp) produces valid rational timeMap values."""
modifier = FCPXMLModifier(temp_fcpxml)
orig_dur = modifier._parse_time(
modifier.clips['Broll_Studio'].get('duration')
).to_seconds()
clip = modifier.change_speed(clip_id='Broll_Studio', speed=1.5)
new_dur = modifier._parse_time(clip.get('duration')).to_seconds()
# 1.5x → duration shrinks to ~2/3
assert new_dur == pytest.approx(orig_dur / 1.5, abs=0.05)
# timeMap end keyframe must use rational format, never floats
timemap = clip.find('timeMap')
tp2 = list(timemap)[1]
assert 's' in tp2.get('time')
assert '.' not in tp2.get('time'), "timeMap must use rational time, not floats"
def test_change_speed_quarter(temp_fcpxml):
"""0.25x extreme slow-mo: duration 4x, timeMap keyframes are rational."""
modifier = FCPXMLModifier(temp_fcpxml)
orig_dur = modifier._parse_time(
modifier.clips['Broll_Studio'].get('duration')
).to_seconds()
clip = modifier.change_speed(clip_id='Broll_Studio', speed=0.25)
new_dur = modifier._parse_time(clip.get('duration')).to_seconds()
assert new_dur == pytest.approx(orig_dur * 4.0, abs=0.2)
def test_change_speed_conform_rate_has_src_framerate(temp_fcpxml):
"""conform-rate element must carry srcFrameRate matching timeline fps."""
modifier = FCPXMLModifier(temp_fcpxml)
clip = modifier.change_speed(clip_id='Broll_Studio', speed=2.0)
conform = clip.find('conform-rate')
assert conform is not None
assert conform.get('srcFrameRate') == str(int(modifier.fps))
def test_change_speed_preserve_pitch_default(temp_fcpxml):
"""preserve_pitch=True is default — conform-rate scaleEnabled should be set."""
modifier = FCPXMLModifier(temp_fcpxml)
clip = modifier.change_speed(clip_id='Broll_Studio', speed=2.0, preserve_pitch=True)
conform = clip.find('conform-rate')
assert conform.get('scaleEnabled') == '1'
def test_change_speed_three_times_still_single_elements(temp_fcpxml):
"""Triple speed change must still have exactly 1 timeMap + 1 conform-rate."""
modifier = FCPXMLModifier(temp_fcpxml)
modifier.change_speed(clip_id='Broll_Studio', speed=2.0)
modifier.change_speed(clip_id='Broll_Studio', speed=0.5)
modifier.change_speed(clip_id='Broll_Studio', speed=3.0)
clip = modifier.clips['Broll_Studio']
assert len(clip.findall('timeMap')) == 1
assert len(clip.findall('conform-rate')) == 1
# ============================================================
# DTD Element Ordering Tests
# ============================================================
def test_change_speed_dtd_order(temp_fcpxml):
"""timeMap and conform-rate must precede markers in DTD order."""
modifier = FCPXMLModifier(temp_fcpxml)
# First add a marker, then change speed — timeMap must end up before markers
modifier.add_marker(clip_id='Broll_City', timecode='00:00:04:00', name='test')
modifier.change_speed(clip_id='Broll_City', speed=2.0)
clip = modifier.clips['Broll_City']
children = list(clip)
tags = [c.tag for c in children]
# timeMap must come before any marker/chapter-marker
if 'timeMap' in tags and 'marker' in tags:
assert tags.index('timeMap') < tags.index('marker'), \
f"timeMap must precede marker in DTD order, got: {tags}"
if 'conform-rate' in tags and 'marker' in tags:
assert tags.index('conform-rate') < tags.index('marker'), \
f"conform-rate must precede marker in DTD order, got: {tags}"
def test_marker_after_adjust_elements(temp_fcpxml):
"""Markers added after adjust elements must maintain DTD order."""
modifier = FCPXMLModifier(temp_fcpxml)
modifier.change_speed(clip_id='Broll_City', speed=0.5)
modifier.add_marker(clip_id='Broll_City', timecode='00:00:04:00', name='after-speed')
clip = modifier.clips['Broll_City']
children = list(clip)
tags = [c.tag for c in children]
if 'timeMap' in tags and 'marker' in tags:
assert tags.index('timeMap') < tags.index('marker'), \
f"timeMap must precede marker, got: {tags}"
# ============================================================
# Add Transition Tests
# ============================================================
def test_add_transition_at_end(temp_fcpxml):
"""Add a cross-dissolve transition at the end of a clip."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
initial_count = len(list(spine))
transition = modifier.add_transition(
clip_id='Broll_City',
position='end',
transition_type='cross-dissolve',
duration='00:00:00:12'
)
assert transition.tag == 'transition'
assert transition.get('name') == 'Cross Dissolve'
assert transition.get('duration') is not None
fv = transition.find('filter-video')
assert fv is not None
assert fv.get('ref') is not None # must reference an effect resource
assert len(list(modifier._get_spine())) == initial_count + 1
def test_add_transition_at_both(temp_fcpxml):
"""Add transitions at both ends inserts two elements."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
result = modifier.add_transition(
clip_id='Broll_City', position='both',
transition_type='cross-dissolve', duration='00:00:00:12'
)
assert isinstance(result, list) or result.tag == 'transition'
assert len(list(modifier._get_spine())) == initial_count + 2
def test_add_transition_invalid_clip(temp_fcpxml):
"""Adding a transition to a nonexistent clip raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier.add_transition(clip_id='ghost_clip', position='end')
# ============================================================
# Reorder Clips Tests
# ============================================================
def test_reorder_clips_to_end(temp_fcpxml):
"""Move a clip to the end of the timeline."""
modifier = FCPXMLModifier(temp_fcpxml)
initial_count = len(list(modifier._get_spine()))
modifier.reorder_clips(
clip_ids=['Broll_City'], target_position='end', ripple=True
)
spine_list = list(modifier._get_spine())
assert len(spine_list) == initial_count
assert spine_list[-1].get('name') == 'Broll_City'
def test_reorder_clips_to_start(temp_fcpxml):
"""Move a clip to the start of the timeline."""
modifier = FCPXMLModifier(temp_fcpxml)
modifier.reorder_clips(
clip_ids=['Broll_City'], target_position='start', ripple=True
)
first_clip = list(modifier._get_spine())[0]
assert first_clip.get('name') == 'Broll_City'
assert first_clip.get('offset') in ('0s', '0/24s')
def test_reorder_clips_invalid_raises(temp_fcpxml):
"""Reordering nonexistent clips raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="No clips found"):
modifier.reorder_clips(clip_ids=['nonexistent'], target_position='start')
# ============================================================
# Add Marker at Timeline Tests
# ============================================================
def test_add_marker_at_timeline_position(temp_fcpxml):
"""Add a marker at a timeline position within an indexed clip."""
modifier = FCPXMLModifier(temp_fcpxml)
# Broll_Studio (last indexed): offset 222/24s=9.25s, dur 120/24s=5s
marker = modifier.add_marker_at_timeline(
timecode='00:00:10:00', name='Timeline marker'
)
assert marker is not None
assert marker.get('value') == 'Timeline marker'
def test_add_marker_at_timeline_invalid_position(temp_fcpxml):
"""Adding a marker past the timeline end raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="No spine clip at position"):
modifier.add_marker_at_timeline(timecode='99:00:00:00', name='Way past end')
def test_add_marker_at_timeline_duplicate_clip_names(temp_fcpxml):
"""Marker lands on the correct clip even when multiple clips share a name.
The sample has 4 clips named 'Interview_A'. The old implementation used
the name-indexed ``self.clips`` dict which only kept the *last* one,
so markers targeting earlier Interview_A clips would silently fail.
The fix uses ``_find_spine_clip_at_seconds`` which walks the spine
directly.
"""
modifier = FCPXMLModifier(temp_fcpxml)
# First Interview_A: offset=0s, duration=72/24s (3s) → timeline 0–3s
marker = modifier.add_marker_at_timeline(
timecode='00:00:01:00', name='First interview marker'
)
assert marker is not None
assert marker.get('value') == 'First interview marker'
# The marker should be a child of the FIRST spine clip (offset=0s),
# not the last Interview_A clip (offset=1122/24s=46.75s).
spine = modifier._get_spine()
first_clip = list(spine)[0]
assert first_clip.get('offset') == '0s'
marker_values = [m.get('value') for m in first_clip.findall('marker')]
assert 'First interview marker' in marker_values
# ============================================================
# Batch Add Markers Tests
# ============================================================
def test_batch_add_markers_explicit(temp_fcpxml):
"""Add multiple explicit markers in a batch."""
modifier = FCPXMLModifier(temp_fcpxml)
markers = [
{'timecode': '00:00:10:00', 'name': 'Marker A'},
{'timecode': '00:00:48:00', 'name': 'Marker B'},
]
created = modifier.batch_add_markers(markers=markers)
assert len(created) == 2
assert created[0].get('value') == 'Marker A'
assert created[1].get('value') == 'Marker B'
def test_batch_add_markers_with_type(temp_fcpxml):
"""Batch markers respect marker_type parameter."""
modifier = FCPXMLModifier(temp_fcpxml)
markers = [
{'timecode': '00:00:10:00', 'name': 'Chapter 1', 'marker_type': 'chapter'},
]
created = modifier.batch_add_markers(markers=markers)
assert len(created) == 1
assert created[0].tag == 'chapter-marker'
# ============================================================
# Select by Keyword Tests
# ============================================================
def test_select_by_keyword_any(temp_fcpxml):
"""Select clips matching any of the given keywords."""
modifier = FCPXMLModifier(temp_fcpxml)
matches = modifier.select_by_keyword(keywords=['B-Roll'], match_mode='any')
assert len(matches) >= 1
for clip_id in matches:
clip = modifier.clips[clip_id]
kw_values = [kw.get('value', '') for kw in clip.findall('keyword')]
assert 'B-Roll' in kw_values
def test_select_by_keyword_no_matches(temp_fcpxml):
"""Selecting with a nonexistent keyword returns empty list."""
modifier = FCPXMLModifier(temp_fcpxml)
matches = modifier.select_by_keyword(keywords=['FakeKeyword'], match_mode='any')
assert matches == []
def test_select_by_keyword_all_mode(temp_fcpxml):
"""'all' mode requires clips to have every keyword."""
modifier = FCPXMLModifier(temp_fcpxml)
# No clip has both 'Interview' AND 'B-Roll'
matches = modifier.select_by_keyword(
keywords=['Interview', 'B-Roll'], match_mode='all'
)
assert matches == []
# ============================================================
# _absorb_into_neighbor Tests
# ============================================================
def test_absorb_into_prev_extends_duration(temp_fcpxml):
"""Absorbing a clip into its previous neighbor grows the neighbor's duration."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
clips = list(modifier._iter_spine_clips())
assert len(clips) >= 3
target_idx, target = clips[1]
prev_idx, prev_clip = clips[0]
prev_dur_before = modifier._parse_time(prev_clip.get('duration', '0s')).to_seconds()
target_dur = modifier._parse_time(target.get('duration', '0s')).to_seconds()
result = modifier._absorb_into_neighbor(spine, target, 'prev')
assert result is prev_clip
prev_dur_after = modifier._parse_time(prev_clip.get('duration', '0s')).to_seconds()
assert abs(prev_dur_after - (prev_dur_before + target_dur)) < 0.001
assert target not in list(spine)
def test_absorb_into_next_extends_duration(temp_fcpxml):
"""Absorbing a clip into its next neighbor grows the neighbor and adjusts start."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
clips = list(modifier._iter_spine_clips())
assert len(clips) >= 3
target_idx, target = clips[1]
next_idx, next_clip = clips[2]
next_dur_before = modifier._parse_time(next_clip.get('duration', '0s')).to_seconds()
next_start_before = modifier._parse_time(next_clip.get('start', '0s')).to_seconds()
target_dur = modifier._parse_time(target.get('duration', '0s')).to_seconds()
result = modifier._absorb_into_neighbor(spine, target, 'next')
assert result is next_clip
next_dur_after = modifier._parse_time(next_clip.get('duration', '0s')).to_seconds()
assert abs(next_dur_after - (next_dur_before + target_dur)) < 0.001
assert target not in list(spine)
# Start should be pulled earlier (if it was >= absorbed duration)
if next_start_before >= target_dur:
next_start_after = modifier._parse_time(next_clip.get('start', '0s')).to_seconds()
assert abs(next_start_after - (next_start_before - target_dur)) < 0.001
def test_absorb_no_neighbor_returns_none(temp_fcpxml):
"""Returns None when there's no neighbor in the requested direction."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
clips = list(modifier._iter_spine_clips())
first_clip = clips[0][1]
result = modifier._absorb_into_neighbor(spine, first_clip, 'prev')
assert result is None
# Element should NOT have been removed
assert first_clip in list(spine)
def test_trim_clip_rejects_negative_duration(temp_fcpxml):
"""Trimming beyond clip length must raise, not silently write negative duration."""
modifier = FCPXMLModifier(temp_fcpxml)
# Broll_Studio has 5s duration — trimming end by 6s would give -1s
with pytest.raises(ValueError, match="non-positive duration"):
modifier.trim_clip(clip_id='Broll_Studio', trim_end='-6s', ripple=False)
def test_trim_clip_rejects_zero_duration(temp_fcpxml):
"""Trimming to exactly zero must also raise."""
modifier = FCPXMLModifier(temp_fcpxml)
# Broll_Studio has 5s duration — trimming end by exactly 5s gives 0s
with pytest.raises(ValueError, match="non-positive duration"):
modifier.trim_clip(clip_id='Broll_Studio', trim_end='-5s', ripple=False)
def test_trim_clip_start_rejects_over_trim(temp_fcpxml):
"""Moving start forward by more than duration must raise."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="non-positive duration"):
modifier.trim_clip(clip_id='Broll_Studio', trim_start='+6s', ripple=False)
def test_add_transition_rejects_negative_offset():
"""Transition at start of first clip must raise when offset would go negative."""
xml = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml)
f.flush()
modifier = FCPXMLModifier(f.name)
# FirstClip offset=0, half of 20s transition = 10s → offset -10s
with pytest.raises(ValueError, match="negative offset"):
modifier.add_transition(clip_id='FirstClip', duration='20s', position='start')
os.unlink(f.name)
def test_absorb_next_clamps_when_start_insufficient(temp_fcpxml):
"""When absorbing forward, if neighbor start < absorbed duration, clamp to 0."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
clips = list(modifier._iter_spine_clips())
# Find a neighbor whose start is 0 (or very small)
target_idx, target = clips[1]
next_idx, next_clip = clips[2]
# Force next_clip start to 0 so absorb can't shift it further back
next_clip.set('start', '0s')
next_dur_before = modifier._parse_time(next_clip.get('duration', '0s')).to_seconds()
result = modifier._absorb_into_neighbor(spine, target, 'next')
assert result is next_clip
# Start should be clamped to 0, not negative
next_start_after = modifier._parse_time(next_clip.get('start', '0s')).to_seconds()
assert next_start_after == 0.0
# Duration only extended by available headroom (0), so unchanged
next_dur_after = modifier._parse_time(next_clip.get('duration', '0s')).to_seconds()
assert next_dur_after == pytest.approx(next_dur_before, abs=0.001)
# ============================================================
# _resolve_asset Tests
# ============================================================
def test_resolve_asset_by_id(temp_fcpxml):
"""Resolves an asset by its resource ID."""
modifier = FCPXMLModifier(temp_fcpxml)
first_id = next(iter(modifier.resources))
asset, resolved_id = modifier._resolve_asset(first_id, None)
assert resolved_id == first_id
assert asset is modifier.resources[first_id]
def test_resolve_asset_by_name(temp_fcpxml):
"""Resolves an asset by name when no ID is given."""
modifier = FCPXMLModifier(temp_fcpxml)
first_id = next(iter(modifier.resources))
expected_name = modifier.resources[first_id].get('name')
asset, resolved_id = modifier._resolve_asset(None, expected_name)
assert resolved_id == first_id
assert asset.get('name') == expected_name
def test_resolve_asset_not_found(temp_fcpxml):
"""Raises ValueError when neither ID nor name matches."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Asset not found"):
modifier._resolve_asset('r_nonexistent', None)
# ============================================================
# _unique_resource_id Tests
# ============================================================
def test_unique_resource_id_no_collision(temp_fcpxml):
"""Returns the prefix as-is when no collision exists."""
modifier = FCPXMLModifier(temp_fcpxml)
resources = modifier.root.find('.//resources')
result = modifier._unique_resource_id(resources, 'r_fresh1')
assert result == 'r_fresh1'
def test_unique_resource_id_with_collision(temp_fcpxml):
"""Increments the counter suffix until no collision."""
modifier = FCPXMLModifier(temp_fcpxml)
resources = modifier.root.find('.//resources')
# The first resource ID should already exist
existing_id = next(iter(modifier.resources))
result = modifier._unique_resource_id(resources, existing_id)
assert result != existing_id
assert result not in {el.get('id', '') for el in resources}
# ============================================================
# _find_spine_element_at_timecode Tests
# ============================================================
def test_find_spine_element_at_timecode_match(temp_fcpxml):
"""Finds a spine element whose offset matches the given timecode."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
first_child = list(spine)[0]
offset_str = first_child.get('offset', '0s')
from fcpxml.models import TimeValue
tc = TimeValue.from_timecode(offset_str, modifier.fps).to_timecode(modifier.fps)
found = modifier._find_spine_element_at_timecode(spine, tc)
assert found is first_child
def test_find_spine_element_at_timecode_no_match(temp_fcpxml):
"""Returns None when no element has a matching offset."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
result = modifier._find_spine_element_at_timecode(spine, '99999/1s')
assert result is None
def test_find_spine_element_require_clip(temp_fcpxml):
"""With require_clip=True, skips non-clip elements like gaps."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
# Insert a gap at the beginning to test filtering
import xml.etree.ElementTree as ET
gap = ET.Element('gap')
gap.set('offset', '0s')
gap.set('duration', '100/2400s')
spine.insert(0, gap)
# require_clip=True should skip the gap
result = modifier._find_spine_element_at_timecode(spine, '0s', require_clip=True)
assert result is None or result.tag != 'gap'
# ============================================================
# _require_clip / _require_spine_clip Tests
# ============================================================
def test_require_clip_returns_element(temp_fcpxml):
"""_require_clip returns the clip element for a valid ID."""
modifier = FCPXMLModifier(temp_fcpxml)
clip_id = next(iter(modifier.clips))
result = modifier._require_clip(clip_id)
assert result is modifier.clips[clip_id]
def test_require_clip_raises_on_missing(temp_fcpxml):
"""_require_clip raises ValueError for a non-existent ID."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found: no_such_clip"):
modifier._require_clip("no_such_clip")
def test_require_spine_clip_returns_tuple(temp_fcpxml):
"""_require_spine_clip returns (spine, clip, index) for a spine clip."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
spine_children = list(spine)
# Find a clip_id that's actually in the spine
target_id = None
for cid, elem in modifier.clips.items():
if elem in spine_children:
target_id = cid
break
assert target_id is not None, "No clip in spine for this test"
result_spine, result_clip, result_idx = modifier._require_spine_clip(target_id)
assert result_spine is spine
assert result_clip is modifier.clips[target_id]
assert spine_children[result_idx] is result_clip
def test_require_spine_clip_raises_for_missing(temp_fcpxml):
"""_require_spine_clip raises ValueError for a non-existent clip."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Clip not found"):
modifier._require_spine_clip("nonexistent_clip")
def test_require_spine_clip_raises_for_non_spine(temp_fcpxml):
"""_require_spine_clip raises ValueError for a clip not in the spine."""
import xml.etree.ElementTree as ET
modifier = FCPXMLModifier(temp_fcpxml)
# Inject a clip into the index that isn't in the spine
fake = ET.Element('clip')
fake.set('name', 'orphan')
modifier.clips['orphan'] = fake
with pytest.raises(ValueError, match="Clip not in spine: orphan"):
modifier._require_spine_clip("orphan")
# ============================================================
# _ripple_from_index Tests
# ============================================================
def test_ripple_from_index_shifts_offsets(temp_fcpxml):
"""Shifts offsets of spine elements from start_index onward by positive delta."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
assert len(children) >= 3, "Need at least 3 clips for this test"
from fcpxml.models import TimeValue
original_offsets = [c.get('offset', '0s') for c in children]
delta = TimeValue(240, 24) # +10 seconds
modifier._ripple_from_index(spine, 1, delta)
updated = list(spine)
# First element unchanged
assert updated[0].get('offset') == original_offsets[0]
# Elements from index 1 onward shifted by delta
for i in range(1, len(updated)):
orig = modifier._parse_time(original_offsets[i])
shifted = modifier._parse_time(updated[i].get('offset', '0s'))
assert shifted == orig + delta
def test_ripple_from_index_negative_delta(temp_fcpxml):
"""Shifts offsets backward with a negative delta."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
from fcpxml.models import TimeValue
# Use a small negative delta that won't go below zero for later clips
delta = TimeValue(-24, 24) # -1 second
original_offset_2 = modifier._parse_time(children[2].get('offset', '0s'))
modifier._ripple_from_index(spine, 2, delta)
new_offset_2 = modifier._parse_time(list(spine)[2].get('offset', '0s'))
assert new_offset_2 == original_offset_2 + delta
def test_ripple_from_index_past_end_is_noop(temp_fcpxml):
"""Rippling from an index beyond children count does nothing."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
original_offsets = [c.get('offset') for c in children]
from fcpxml.models import TimeValue
modifier._ripple_from_index(spine, len(children) + 10, TimeValue(100, 1))
for i, child in enumerate(spine):
assert child.get('offset') == original_offsets[i]
def test_ripple_from_index_skips_non_spine_tags(temp_fcpxml):
"""Non-spine-element tags (e.g. ) are left unchanged."""
import xml.etree.ElementTree as ET
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
note = ET.SubElement(spine, 'note')
note.text = 'test note'
note.set('offset', '0s')
from fcpxml.models import TimeValue
modifier._ripple_from_index(spine, 0, TimeValue(48, 24))
# is not in SPINE_ELEMENT_TAGS, so its offset stays
assert note.get('offset') == '0s'
# ============================================================
# _timeline_duration Tests
# ============================================================
def test_timeline_duration_from_sequence(temp_fcpxml):
"""Reads duration from element when present."""
modifier = FCPXMLModifier(temp_fcpxml)
seq = modifier.root.find('.//sequence')
assert seq is not None, "Sample must have a "
expected = modifier._parse_time(seq.get('duration'))
result = modifier._timeline_duration()
assert result == expected
def test_timeline_duration_fallback_to_spine_sum(temp_fcpxml):
"""Falls back to summing spine durations when has no duration attr."""
modifier = FCPXMLModifier(temp_fcpxml)
seq = modifier.root.find('.//sequence')
# Remove the duration attribute to force fallback
if seq is not None and 'duration' in seq.attrib:
del seq.attrib['duration']
from fcpxml.models import TimeValue
spine = modifier._get_spine()
expected = TimeValue.zero()
for child in spine:
if child.tag in ('clip', 'asset-clip', 'video', 'audio', 'gap',
'transition', 'ref-clip'):
expected = expected + modifier._parse_time(child.get('duration', '0s'))
result = modifier._timeline_duration()
assert result == expected
assert result.numerator > 0 # sanity: non-empty timeline
def test_timeline_duration_no_sequence_element():
"""Returns spine sum when no element exists at all."""
xml_str = textwrap.dedent("""\
""")
import tempfile
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
# Remove duration from sequence to force fallback
seq = modifier.root.find('.//sequence')
if seq is not None and 'duration' in seq.attrib:
del seq.attrib['duration']
result = modifier._timeline_duration()
from fcpxml.models import TimeValue
assert result == TimeValue(3600, 2400) # 2400 + 1200
Path(f.name).unlink(missing_ok=True)
# ============================================================
# _find_neighbor_clip Tests
# ============================================================
def test_find_neighbor_clip_prev(temp_fcpxml):
"""Finds the previous clip in the spine."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
spine_list = list(spine)
assert len(spine_list) >= 2
result = FCPXMLModifier._find_neighbor_clip(spine_list, 1, 'prev')
assert result is spine_list[0]
def test_find_neighbor_clip_next(temp_fcpxml):
"""Finds the next clip in the spine."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
spine_list = list(spine)
result = FCPXMLModifier._find_neighbor_clip(spine_list, 0, 'next')
assert result is spine_list[1]
def test_find_neighbor_clip_none_at_boundary(temp_fcpxml):
"""Returns None when no neighbor exists in the given direction."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
spine_list = list(spine)
assert FCPXMLModifier._find_neighbor_clip(spine_list, 0, 'prev') is None
last = len(spine_list) - 1
assert FCPXMLModifier._find_neighbor_clip(spine_list, last, 'next') is None
def test_find_neighbor_clip_skips_gaps():
"""Skips gap elements when searching for neighbor clips."""
import xml.etree.ElementTree as ET
spine_list = [
ET.Element('asset-clip'),
ET.Element('gap'),
ET.Element('asset-clip'),
]
# Searching next from index 0 should skip the gap at index 1
result = FCPXMLModifier._find_neighbor_clip(spine_list, 0, 'next')
assert result is spine_list[2]
# ============================================================
# _resolve_asset Edge Cases
# ============================================================
def test_resolve_asset_both_none(temp_fcpxml):
"""Raises ValueError when both asset_id and asset_name are None."""
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="Asset not found"):
modifier._resolve_asset(None, None)
def test_resolve_asset_id_takes_precedence(temp_fcpxml):
"""When both ID and name are given, ID lookup wins."""
modifier = FCPXMLModifier(temp_fcpxml)
first_id = next(iter(modifier.resources))
asset, resolved = modifier._resolve_asset(first_id, 'wrong_name')
assert resolved == first_id
# ============================================================
# _find_clip_index Tests
# ============================================================
def test_find_clip_index_returns_position(temp_fcpxml):
"""Returns correct index for a clip present in spine."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
for i, child in enumerate(children):
assert modifier._find_clip_index(spine, child) == i
def test_find_clip_index_returns_none_for_missing():
"""Returns None for an element not in the spine."""
import xml.etree.ElementTree as ET
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
spine = modifier._get_spine()
orphan = ET.Element('asset-clip')
assert modifier._find_clip_index(spine, orphan) is None
Path(f.name).unlink(missing_ok=True)
# ============================================================
# _absorb_into_neighbor Tests
# ============================================================
def _make_absorb_modifier(clips):
"""Build a modifier with a custom spine for absorption tests.
clips: list of (tag, name, offset, start, duration) tuples.
"""
clip_xml = '\n'.join(
f'<{tag} ref="r1" offset="{off}" name="{n}" start="{st}" duration="{dur}"/>'
for tag, n, off, st, dur in clips
)
xml_str = (
'\n'
'\n'
'\n'
' \n'
' \n'
' \n'
' \n'
' \n'
f' {clip_xml}\n'
' \n'
' \n'
''
)
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
return FCPXMLModifier(f.name), f.name
def test_absorb_into_neighbor_prev_extends_duration():
"""Absorbing into previous clip extends its duration."""
modifier, path = _make_absorb_modifier([
('asset-clip', 'A', '0s', '0s', '2400/2400s'),
('gap', 'G', '2400/2400s', '0s', '600/2400s'),
('asset-clip', 'B', '3000/2400s', '0s', '2400/2400s'),
])
try:
spine = modifier._get_spine()
gap = list(spine)[1]
result = modifier._absorb_into_neighbor(spine, gap, 'prev')
assert result is not None
assert result.get('name') == 'A'
# A was 2400/2400s, absorbed gap of 600/2400s → 3000/2400s
assert modifier._parse_time(result.get('duration')).numerator == 3000
# Gap should be removed
assert len(list(spine)) == 2
finally:
Path(path).unlink(missing_ok=True)
def test_absorb_into_neighbor_next_shifts_start():
"""Absorbing into next clip shifts its start backward."""
modifier, path = _make_absorb_modifier([
('asset-clip', 'A', '0s', '0s', '2400/2400s'),
('gap', 'G', '2400/2400s', '0s', '600/2400s'),
('asset-clip', 'B', '3000/2400s', '2400/2400s', '2400/2400s'),
])
try:
spine = modifier._get_spine()
gap = list(spine)[1]
result = modifier._absorb_into_neighbor(spine, gap, 'next')
assert result is not None
assert result.get('name') == 'B'
# B start was 2400/2400s, shifted back by 600/2400s → 1800/2400s
assert modifier._parse_time(result.get('start')).numerator == 1800
# B duration was 2400/2400s + 600/2400s → 3000/2400s
assert modifier._parse_time(result.get('duration')).numerator == 3000
finally:
Path(path).unlink(missing_ok=True)
def test_absorb_into_neighbor_next_clamps_negative_start():
"""When absorbing would push start below zero, clamp to 0."""
modifier, path = _make_absorb_modifier([
('asset-clip', 'A', '0s', '0s', '2400/2400s'),
('gap', 'G', '2400/2400s', '0s', '1200/2400s'),
# B starts at 0s in source — can't go negative
('asset-clip', 'B', '3600/2400s', '0s', '2400/2400s'),
])
try:
spine = modifier._get_spine()
gap = list(spine)[1]
result = modifier._absorb_into_neighbor(spine, gap, 'next')
assert result is not None
# Start clamped to 0
from fcpxml.models import TimeValue
assert modifier._parse_time(result.get('start')) == TimeValue(0, 1)
# available = neighbor_start (0s) → only extends by 0, so duration unchanged
assert modifier._parse_time(result.get('duration')).to_seconds() == 1.0
finally:
Path(path).unlink(missing_ok=True)
def test_absorb_into_neighbor_no_neighbor_returns_none():
"""Returns None when no neighbor clip exists in given direction."""
modifier, path = _make_absorb_modifier([
('gap', 'G', '0s', '0s', '600/2400s'),
])
try:
spine = modifier._get_spine()
gap = list(spine)[0]
assert modifier._absorb_into_neighbor(spine, gap, 'prev') is None
assert modifier._absorb_into_neighbor(spine, gap, 'next') is None
finally:
Path(path).unlink(missing_ok=True)
# ============================================================
# _resolve_insert_position Tests
# ============================================================
def test_resolve_insert_position_start(temp_fcpxml):
"""'start' returns zero offset and index 0."""
from fcpxml.models import TimeValue
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
offset, idx = modifier._resolve_insert_position('start', list(spine))
assert offset == TimeValue(0, 1)
assert idx == 0
def test_resolve_insert_position_end(temp_fcpxml):
"""'end' returns offset past last clip and index at end."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
offset, idx = modifier._resolve_insert_position('end', children)
assert idx == len(children)
# Offset should equal last clip offset + last clip duration
last = children[-1]
expected = (modifier._parse_time(last.get('offset', '0s'))
+ modifier._parse_time(last.get('duration', '0s')))
assert offset == expected
def test_resolve_insert_position_end_empty_spine():
"""'end' on empty spine returns zero offset and index 0."""
from fcpxml.models import TimeValue
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
offset, idx = modifier._resolve_insert_position('end', [])
assert offset == TimeValue(0, 1)
assert idx == 0
Path(f.name).unlink(missing_ok=True)
def test_resolve_insert_position_after_clip():
"""'after:ClipName' resolves to offset+duration of named clip."""
from fcpxml.models import TimeValue
xml_str = (
'\n'
'\n'
'\n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
''
)
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
spine = modifier._get_spine()
children = list(spine)
offset, idx = modifier._resolve_insert_position('after:UniqueA', children)
assert offset == TimeValue(2400, 2400)
assert idx == 1
Path(f.name).unlink(missing_ok=True)
def test_resolve_insert_position_before_clip():
"""'before:ClipName' resolves to offset of named clip."""
from fcpxml.models import TimeValue
xml_str = (
'\n'
'\n'
'\n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
' \n'
''
)
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
spine = modifier._get_spine()
children = list(spine)
offset, idx = modifier._resolve_insert_position('before:UniqueY', children)
assert offset == TimeValue(2400, 2400)
assert idx == 1
Path(f.name).unlink(missing_ok=True)
def test_resolve_insert_position_invalid_ref_raises(temp_fcpxml):
"""Invalid reference clip ID raises ValueError."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
with pytest.raises(ValueError, match="Reference clip not found"):
modifier._resolve_insert_position('after:nonexistent_clip_xyz', list(spine))
def test_resolve_insert_position_timecode(temp_fcpxml):
"""Timecode string resolves to correct index via binary search."""
modifier = FCPXMLModifier(temp_fcpxml)
spine = modifier._get_spine()
children = list(spine)
# Insert at the offset of the second clip — should get index 1
if len(children) >= 2:
second_offset = children[1].get('offset', '0s')
offset, idx = modifier._resolve_insert_position(second_offset, children)
assert idx == 1
# ============================================================
# _make_transition_element Tests
# ============================================================
def test_make_transition_element_with_effect_ref(temp_fcpxml):
"""Creates transition with filter-video child when ref ID given."""
from fcpxml.models import TimeValue
modifier = FCPXMLModifier(temp_fcpxml)
t = modifier._make_transition_element(
'Cross Dissolve', TimeValue(0, 1), TimeValue(600, 2400), 'effect_1'
)
assert t.tag == 'transition'
assert t.get('name') == 'Cross Dissolve'
assert t.get('duration') == '600/2400s'
fv = t.find('filter-video')
assert fv is not None
assert fv.get('ref') == 'effect_1'
assert fv.get('name') == 'Cross Dissolve'
def test_make_transition_element_without_effect_ref(temp_fcpxml):
"""Creates transition without filter-video child when ref ID is None."""
from fcpxml.models import TimeValue
modifier = FCPXMLModifier(temp_fcpxml)
t = modifier._make_transition_element(
'Fade', TimeValue(0, 1), TimeValue(300, 2400), None
)
assert t.tag == 'transition'
assert t.get('name') == 'Fade'
assert t.find('filter-video') is None
# ============================================================
# _recalculate_offsets Tests
# ============================================================
def test_recalculate_offsets_sequential():
"""Offsets are recalculated sequentially based on durations."""
from fcpxml.models import TimeValue
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
spine = modifier._get_spine()
modifier._recalculate_offsets(spine)
children = list(spine)
# A: offset=0, B: offset=2400/2400, C: offset=3600/2400
assert modifier._parse_time(children[0].get('offset')) == TimeValue(0, 1)
assert modifier._parse_time(children[1].get('offset')) == TimeValue(2400, 2400)
assert modifier._parse_time(children[2].get('offset')) == TimeValue(3600, 2400)
Path(f.name).unlink(missing_ok=True)
def test_recalculate_offsets_skips_non_spine_tags():
"""Non-spine tags (e.g. ) are left untouched."""
import xml.etree.ElementTree as ET
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
spine = modifier._get_spine()
# Add a non-spine element
note = ET.SubElement(spine, 'note')
note.text = 'test'
modifier._recalculate_offsets(spine)
# Note should have no offset attribute
assert note.get('offset') is None
Path(f.name).unlink(missing_ok=True)
# ============================================================
# Trailing Gap Removal Tests
# ============================================================
def test_remove_trailing_gaps_removes_final_gap():
"""A trailing should be removed and the sequence duration re-synced."""
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
modifier.remove_trailing_gaps()
spine = modifier._get_spine()
children = list(spine)
assert len(children) == 1
assert children[0].tag == 'asset-clip'
sequence = modifier.root.find('.//sequence')
assert sequence.get('duration') == '4s'
Path(f.name).unlink(missing_ok=True)
def test_remove_trailing_gaps_keeps_middle_gap():
"""A gap in the middle must NOT be removed by remove_trailing_gaps."""
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
modifier.remove_trailing_gaps()
spine = modifier._get_spine()
children = list(spine)
assert len(children) == 3
assert children[1].tag == 'gap'
Path(f.name).unlink(missing_ok=True)
def test_remove_trailing_gaps_noop_without_gap():
"""A timeline ending in a clip should be untouched."""
xml_str = textwrap.dedent("""\
""")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f:
f.write(xml_str)
f.flush()
modifier = FCPXMLModifier(f.name)
modifier.remove_trailing_gaps()
children = list(modifier._get_spine())
assert len(children) == 1
assert children[0].tag == 'asset-clip'
Path(f.name).unlink(missing_ok=True)