Files
gart/code/tests/test_writer.py
T

2070 lines
77 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
<asset id="r2" src="file:///clip.mov" hasVideo="1" hasAudio="1"/>
</resources>
<library>
<event name="Test">
<project name="Test">
<sequence format="r1">
<spine>
<asset-clip name="FirstClip" ref="r2" offset="0s"
start="0s" duration="120/24s"/>
<asset-clip name="SecondClip" ref="r2" offset="120/24s"
start="0s" duration="120/24s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
""")
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. <note>) 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))
# <note> 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 <sequence> element when present."""
modifier = FCPXMLModifier(temp_fcpxml)
seq = modifier.root.find('.//sequence')
assert seq is not None, "Sample must have a <sequence>"
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 <sequence> 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 <sequence> element exists at all."""
xml_str = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" name="FFVideoFormat1080p24"
frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library>
<event name="Test">
<project name="Test">
<sequence format="r1">
<spine>
<asset-clip ref="r1" offset="0s" name="A"
start="0s" duration="2400/2400s"/>
<asset-clip ref="r1" offset="2400/2400s" name="B"
start="0s" duration="1200/2400s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>""")
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("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library><event name="T"><project name="T">
<sequence format="r1" duration="2400/2400s">
<spine>
<asset-clip ref="r1" offset="0s" name="A"
start="0s" duration="2400/2400s"/>
</spine>
</sequence>
</project></event></library>
</fcpxml>""")
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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE fcpxml>\n'
'<fcpxml version="1.11">\n'
' <resources>\n'
' <format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>\n'
' </resources>\n'
' <library><event name="T"><project name="T">\n'
' <sequence format="r1" duration="9600/2400s">\n'
f' <spine>{clip_xml}</spine>\n'
' </sequence>\n'
' </project></event></library>\n'
'</fcpxml>'
)
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("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library><event name="T"><project name="T">
<sequence format="r1" duration="0s">
<spine/>
</sequence>
</project></event></library>
</fcpxml>""")
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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE fcpxml>\n'
'<fcpxml version="1.11">\n'
' <resources>\n'
' <format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>\n'
' </resources>\n'
' <library><event name="T"><project name="T">\n'
' <sequence format="r1" duration="4800/2400s">\n'
' <spine>\n'
' <asset-clip ref="r1" offset="0s" name="UniqueA"'
' start="0s" duration="2400/2400s"/>\n'
' <asset-clip ref="r1" offset="2400/2400s" name="UniqueB"'
' start="0s" duration="2400/2400s"/>\n'
' </spine>\n'
' </sequence>\n'
' </project></event></library>\n'
'</fcpxml>'
)
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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE fcpxml>\n'
'<fcpxml version="1.11">\n'
' <resources>\n'
' <format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>\n'
' </resources>\n'
' <library><event name="T"><project name="T">\n'
' <sequence format="r1" duration="4800/2400s">\n'
' <spine>\n'
' <asset-clip ref="r1" offset="0s" name="UniqueX"'
' start="0s" duration="2400/2400s"/>\n'
' <asset-clip ref="r1" offset="2400/2400s" name="UniqueY"'
' start="0s" duration="2400/2400s"/>\n'
' </spine>\n'
' </sequence>\n'
' </project></event></library>\n'
'</fcpxml>'
)
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("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library><event name="T"><project name="T">
<sequence format="r1" duration="4800/2400s">
<spine>
<asset-clip ref="r1" offset="9999/2400s" name="A"
start="0s" duration="2400/2400s"/>
<asset-clip ref="r1" offset="9999/2400s" name="B"
start="0s" duration="1200/2400s"/>
<asset-clip ref="r1" offset="9999/2400s" name="C"
start="0s" duration="1200/2400s"/>
</spine>
</sequence>
</project></event></library>
</fcpxml>""")
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. <note>) are left untouched."""
import xml.etree.ElementTree as ET
xml_str = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<format id="r1" frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library><event name="T"><project name="T">
<sequence format="r1" duration="2400/2400s">
<spine>
<asset-clip ref="r1" offset="0s" name="A"
start="0s" duration="2400/2400s"/>
</spine>
</sequence>
</project></event></library>
</fcpxml>""")
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 <gap> should be removed and the sequence duration re-synced."""
xml_str = textwrap.dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<asset id="r1" name="clip" uid="u1" start="0s" duration="10s"
hasVideo="1" format="f1" hasAudio="1">
<media-rep kind="original-media" src="file:///tmp/clip.mp4"/>
</asset>
<format id="f1" name="FFVideoFormat1080p25" frameDuration="1/25s" width="1920" height="1080"/>
</resources>
<library>
<event name="E">
<project name="P">
<sequence format="f1">
<spine>
<asset-clip ref="r1" offset="0s" start="0s" duration="4s" name="clip"/>
<gap name="Espaço" offset="4s" duration="90s" start="4s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>""")
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("""\
<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<asset id="r1" name="clip" uid="u1" start="0s" duration="20s"
hasVideo="1" format="f1" hasAudio="1">
<media-rep kind="original-media" src="file:///tmp/clip.mp4"/>
</asset>
<format id="f1" name="FFVideoFormat1080p25" frameDuration="1/25s" width="1920" height="1080"/>
</resources>
<library>
<event name="E">
<project name="P">
<sequence format="f1">
<spine>
<asset-clip ref="r1" offset="0s" start="0s" duration="4s" name="clip"/>
<gap name="Espaço" offset="4s" duration="5s" start="4s"/>
<asset-clip ref="r1" offset="9s" start="4s" duration="4s" name="clip"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>""")
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("""\
<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<asset id="r1" name="clip" uid="u1" start="0s" duration="10s"
hasVideo="1" format="f1" hasAudio="1">
<media-rep kind="original-media" src="file:///tmp/clip.mp4"/>
</asset>
<format id="f1" name="FFVideoFormat1080p25" frameDuration="1/25s" width="1920" height="1080"/>
</resources>
<library>
<event name="E">
<project name="P">
<sequence format="f1">
<spine>
<asset-clip ref="r1" offset="0s" start="0s" duration="4s" name="clip"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>""")
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)