Files
gart/code/tests/test_speed_cutting.py

418 lines
13 KiB
Python
Executable File

"""Tests for v0.3.0 Speed Cutting & AI-Powered features."""
import shutil
import tempfile
from pathlib import Path
import pytest
from fcpxml.models import (
DuplicateGroup,
FlashFrameSeverity,
MontageConfig,
PacingCurve,
ValidationIssue,
ValidationResult,
)
from fcpxml.parser import FCPXMLParser
from fcpxml.rough_cut import RoughCutGenerator
from fcpxml.writer import FCPXMLModifier
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
@pytest.fixture
def temp_fcpxml():
"""Create a temp copy of sample.fcpxml for modification tests."""
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
shutil.copy(SAMPLE, f.name)
yield f.name
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def temp_output():
"""Create a temp file path for output."""
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
# ============================================================
# Model Tests - Pacing Curves
# ============================================================
def test_montage_config_constant_pacing():
"""Constant pacing should return same duration at any position."""
config = MontageConfig(
target_duration=30.0,
pacing_curve=PacingCurve.CONSTANT,
start_duration=2.0,
end_duration=0.5
)
# Average of start and end
expected = (2.0 + 0.5) / 2
assert config.get_duration_at_position(0.0) == expected
assert config.get_duration_at_position(0.5) == expected
assert config.get_duration_at_position(1.0) == expected
def test_montage_config_accelerating_pacing():
"""Accelerating pacing: starts slow, ends fast."""
config = MontageConfig(
target_duration=30.0,
pacing_curve=PacingCurve.ACCELERATING,
start_duration=2.0,
end_duration=0.5
)
# Should get start_duration at position 0
assert config.get_duration_at_position(0.0) == 2.0
# Should get end_duration at position 1
assert config.get_duration_at_position(1.0) == 0.5
# Middle should be in between
mid_duration = config.get_duration_at_position(0.5)
assert 0.5 < mid_duration < 2.0
def test_montage_config_decelerating_pacing():
"""Decelerating pacing: starts fast, ends slow."""
config = MontageConfig(
target_duration=30.0,
pacing_curve=PacingCurve.DECELERATING,
start_duration=2.0,
end_duration=0.5
)
# Should start fast (end_duration) and end slow (start_duration)
assert config.get_duration_at_position(0.0) == 0.5
assert config.get_duration_at_position(1.0) == 2.0
def test_montage_config_pyramid_pacing():
"""Pyramid pacing: slow -> fast -> slow."""
config = MontageConfig(
target_duration=30.0,
pacing_curve=PacingCurve.PYRAMID,
start_duration=2.0,
end_duration=0.5
)
# Start should be slow
start_dur = config.get_duration_at_position(0.0)
# Middle should be fast
mid_dur = config.get_duration_at_position(0.5)
# End should be slow again
end_dur = config.get_duration_at_position(1.0)
# Middle should be faster (shorter duration) than start/end
assert mid_dur < start_dur
assert mid_dur < end_dur
def test_montage_config_respects_min_max():
"""Pacing should clamp to min/max duration."""
config = MontageConfig(
target_duration=30.0,
pacing_curve=PacingCurve.ACCELERATING,
start_duration=10.0, # Would exceed max
end_duration=0.1, # Would be below min
min_duration=0.5,
max_duration=5.0
)
# Should be clamped to max at start
assert config.get_duration_at_position(0.0) == 5.0
# Should be clamped to min at end
assert config.get_duration_at_position(1.0) == 0.5
# ============================================================
# Model Tests - Data Classes
# ============================================================
def test_flash_frame_severity():
"""FlashFrameSeverity enum should have correct values."""
assert FlashFrameSeverity.CRITICAL.value == "critical"
assert FlashFrameSeverity.WARNING.value == "warning"
def test_pacing_curve_values():
"""PacingCurve enum should have all expected values."""
assert PacingCurve.CONSTANT.value == "constant"
assert PacingCurve.ACCELERATING.value == "accelerating"
assert PacingCurve.DECELERATING.value == "decelerating"
assert PacingCurve.PYRAMID.value == "pyramid"
def test_duplicate_group_count():
"""DuplicateGroup should correctly count clips."""
group = DuplicateGroup(
source_ref="r1",
source_name="TestClip",
clips=[
{'name': 'Clip1', 'source_start': 0, 'source_duration': 5},
{'name': 'Clip2', 'source_start': 10, 'source_duration': 5},
{'name': 'Clip3', 'source_start': 20, 'source_duration': 5}
]
)
assert group.count == 3
def test_duplicate_group_overlapping_ranges():
"""DuplicateGroup should detect overlapping source ranges."""
# Non-overlapping
group_no_overlap = DuplicateGroup(
source_ref="r1",
source_name="TestClip",
clips=[
{'name': 'Clip1', 'source_start': 0, 'source_duration': 5},
{'name': 'Clip2', 'source_start': 10, 'source_duration': 5}
]
)
assert not group_no_overlap.has_overlapping_ranges
# Overlapping
group_overlap = DuplicateGroup(
source_ref="r1",
source_name="TestClip",
clips=[
{'name': 'Clip1', 'source_start': 0, 'source_duration': 10},
{'name': 'Clip2', 'source_start': 5, 'source_duration': 10} # Overlaps!
]
)
assert group_overlap.has_overlapping_ranges
def test_validation_result_counts():
"""ValidationResult should count issues by severity."""
result = ValidationResult(
is_valid=False,
health_score=75,
issues=[
ValidationIssue(issue_type=None, severity="error", message="Error 1"),
ValidationIssue(issue_type=None, severity="error", message="Error 2"),
ValidationIssue(issue_type=None, severity="warning", message="Warning 1"),
]
)
assert result.error_count == 2
assert result.warning_count == 1
# ============================================================
# Writer Tests - Speed Cutting Operations
# ============================================================
def test_fix_flash_frames_returns_list(temp_fcpxml):
"""fix_flash_frames should return a list of fixed items."""
modifier = FCPXMLModifier(temp_fcpxml)
# May or may not find flash frames in sample, but should return list
result = modifier.fix_flash_frames(mode='auto', threshold_frames=6)
assert isinstance(result, list)
def test_rapid_trim_returns_list(temp_fcpxml):
"""rapid_trim should return a list of trimmed clips."""
modifier = FCPXMLModifier(temp_fcpxml)
# Trim all clips to 1 second max
result = modifier.rapid_trim(max_duration='1s', trim_from='end')
assert isinstance(result, list)
def test_rapid_trim_respects_max_duration(temp_fcpxml):
"""rapid_trim should not trim clips shorter than max."""
modifier = FCPXMLModifier(temp_fcpxml)
# Trim to 100s - most clips should be shorter
result = modifier.rapid_trim(max_duration='100s')
# All trimmed clips should have new_duration <= 100
for item in result:
assert item['new_duration'] <= 100
def test_fill_gaps_returns_list(temp_fcpxml):
"""fill_gaps should return a list of filled gaps."""
modifier = FCPXMLModifier(temp_fcpxml)
result = modifier.fill_gaps(mode='extend_previous')
assert isinstance(result, list)
def test_rapid_trim_skips_clips_below_min_duration(temp_fcpxml):
"""rapid_trim with min_duration should skip clips shorter than the minimum.
Note: duplicate clip names exist in the sample data, so we compare
result counts rather than names (a name can appear for both a short
and a long clip).
"""
# Without min_duration — trims everything over 0.5s.
modifier1 = FCPXMLModifier(temp_fcpxml)
result_all = modifier1.rapid_trim(max_duration='0.5s')
# With min_duration=3s — clips under 3s are left alone, so fewer trims.
modifier2 = FCPXMLModifier(temp_fcpxml)
result_filtered = modifier2.rapid_trim(max_duration='0.5s', min_duration='3s')
assert len(result_filtered) < len(result_all), (
f"min_duration filter should reduce trims: "
f"got {len(result_filtered)} vs {len(result_all)} without filter"
)
def test_rapid_trim_saves_correctly(temp_fcpxml):
"""rapid_trim changes should persist after save."""
modifier = FCPXMLModifier(temp_fcpxml)
# Trim clips to 0.5s max
modifier.rapid_trim(max_duration='0.5s')
output = temp_fcpxml.replace('.fcpxml', '_trimmed.fcpxml')
modifier.save(output)
# Reload and verify all clips are <= 0.5s
parser = FCPXMLParser()
project = parser.parse_file(output)
for clip in project.primary_timeline.clips:
assert clip.duration_seconds <= 0.6 # Allow small tolerance
Path(output).unlink(missing_ok=True)
# ============================================================
# RoughCutGenerator Tests - Montage & A/B Roll
# ============================================================
def test_rough_cut_generator_loads(temp_fcpxml):
"""RoughCutGenerator should load FCPXML file."""
generator = RoughCutGenerator(temp_fcpxml)
assert generator.fps > 0
assert len(generator.clips) > 0
def test_generate_montage_creates_file(temp_fcpxml, temp_output):
"""generate_montage should create output file."""
generator = RoughCutGenerator(temp_fcpxml)
result = generator.generate_montage(
output_path=temp_output,
target_duration='10s',
pacing_curve='accelerating'
)
assert Path(temp_output).exists()
assert result['clips_used'] > 0
assert result['pacing_curve'] == 'accelerating'
def test_generate_montage_respects_target_duration(temp_fcpxml, temp_output):
"""generate_montage should approximate target duration."""
generator = RoughCutGenerator(temp_fcpxml)
result = generator.generate_montage(
output_path=temp_output,
target_duration='15s',
pacing_curve='constant'
)
# Should be within reasonable range of target
assert 10 <= result['actual_duration'] <= 20
def test_generate_ab_roll_creates_file(temp_fcpxml, temp_output):
"""generate_ab_roll should create output file."""
generator = RoughCutGenerator(temp_fcpxml)
# Use keywords that match sample clips (case-insensitive matching)
result = generator.generate_ab_roll(
output_path=temp_output,
target_duration='20s',
a_keywords=['Interview'], # Matches sample's "Interview" keyword
b_keywords=['B-Roll'], # Matches sample's "B-Roll" keyword
a_duration='5s',
b_duration='3s'
)
assert Path(temp_output).exists()
assert result['clips_used'] > 0
def test_generate_ab_roll_alternates(temp_fcpxml, temp_output):
"""generate_ab_roll should have both A and B segments."""
generator = RoughCutGenerator(temp_fcpxml)
result = generator.generate_ab_roll(
output_path=temp_output,
target_duration='30s',
a_keywords=['Interview'], # Matches sample's "Interview" keyword
b_keywords=['B-Roll'], # Matches sample's "B-Roll" keyword
a_duration='5s',
b_duration='3s'
)
# Should have both A and B segments
assert result['a_segments'] > 0 or result['b_segments'] > 0
# ============================================================
# Integration Tests
# ============================================================
def test_montage_pacing_affects_clip_durations(temp_fcpxml, temp_output):
"""Different pacing curves should produce different results."""
generator = RoughCutGenerator(temp_fcpxml)
# Generate accelerating montage
result_accel = generator.generate_montage(
output_path=temp_output,
target_duration='20s',
pacing_curve='accelerating',
start_duration=3.0,
end_duration=0.5
)
# Just verify montage was created with expected settings
assert result_accel['pacing_curve'] == 'accelerating'
assert result_accel['clips_used'] > 0
# Note: Actual clip durations depend on available clips and their durations,
# so we can't strictly assert start > end without knowing clip inventory
def test_full_workflow_trim_and_validate(temp_fcpxml):
"""Test full workflow: trim clips then validate changes."""
modifier = FCPXMLModifier(temp_fcpxml)
# Step 1: Rapid trim
modifier.rapid_trim(max_duration='2s')
# Step 2: Save
output = temp_fcpxml.replace('.fcpxml', '_workflow.fcpxml')
modifier.save(output)
# Step 3: Verify with parser
parser = FCPXMLParser()
project = parser.parse_file(output)
# All clips should be <= 2s
for clip in project.primary_timeline.clips:
assert clip.duration_seconds <= 2.1 # Small tolerance
Path(output).unlink(missing_ok=True)