"""Tests for collision detection and subtitle-layout validation (Fase 1). Covers the pure functions in ``fcpxml.collision`` (spatial/temporal overlap, area/ratio classification, distance, separation suggestion, box measurement) and the integration through ``FCPXMLModifier.validate_subtitle_layout`` over a generated document — the post-generation guarantee the layout engine only provides by construction. """ import shutil import tempfile from pathlib import Path import pytest from fcpxml.collision import ( FONT_MISSING, FONT_TOO_SMALL, OUTSIDE_FRAME, OUTSIDE_SAFE_AREA, OVERLAP_PROBABLE, OVERLAP_RENDER_TOLERANCE, OVERLAP_SEVERE, SPATIAL_COLLISION, Box, blocking, classify_overlap, distance_between, measure_title_box, overlap_metrics, separation_suggestion, temporal_overlap, validate_titles, ) from fcpxml.models import DynamicSubtitleConfig from fcpxml.writer import FCPXMLModifier SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml" WORD_MODE = DynamicSubtitleConfig(granularity="word") WORDS = [ {"word": "Hello", "start": 0.0, "end": 0.4}, {"word": "there", "start": 0.4, "end": 0.8}, {"word": "friend", "start": 0.8, "end": 1.3}, ] @pytest.fixture def temp_fcpxml(): with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f: shutil.copy(SAMPLE, f.name) yield f.name Path(f.name).unlink(missing_ok=True) def _title(text, x=0.0, y=0.0, *, start=0.0, end=1.0, font="Helvetica Neue", face=None, font_size=100.0, kerning=0.0, group=0): return { "text": text, "x": x, "y": y, "start": start, "end": end, "font": font, "face": face, "font_size": font_size, "kerning": kerning, "group": group, } class TestBoxOverlap: def test_touching_boxes_do_not_overlap(self): a = Box(0, 100, 0, 50) b = Box(100, 200, 0, 50) # shares the right edge assert not a.overlaps(b) assert overlap_metrics(a, b)["overlap_area"] == 0 def test_overlap_by_one_pixel_detected(self): a = Box(0, 100, 0, 50) b = Box(99, 200, 0, 50) # one-pixel horizontal overlap assert a.overlaps(b) metrics = overlap_metrics(a, b) assert metrics["overlap_width"] == 1 assert metrics["overlap_area"] == 50 def test_vertical_only_overlap(self): a = Box(0, 100, 0, 50) b = Box(0, 100, 49, 100) # one-pixel vertical overlap assert a.overlaps(b) assert overlap_metrics(a, b)["overlap_height"] == 1 class TestTemporalOverlap: def test_adjacent_intervals_are_not_simultaneous(self): # [0, 1) and [1, 2) share no instant. assert not temporal_overlap(0.0, 1.0, 1.0, 2.0) assert not temporal_overlap(1.0, 2.0, 0.0, 1.0) def test_interleaved_intervals_overlap(self): assert temporal_overlap(0.0, 2.0, 1.0, 3.0) def test_contained_interval_overlaps(self): assert temporal_overlap(0.0, 5.0, 1.0, 2.0) def test_boundary_survives_float_noise_from_the_writer(self): """Found on real footage: the writer sets one block's title duration to make its end land EXACTLY on the next block's start (same exact FCPXML fraction), but end here is re-derived as start + duration — two independently-rounded floats — which isn't bit-identical to the other title's start read as a single division of that same fraction. 7 of 8 "severe" collisions from one real clip were this, off by ~1e-13s, far below any frame boundary.""" start_a, duration_a = 74883609 / 24000, 43043 / 24000 end_a = start_a + duration_a # float addition, like validate_titles does start_b = 74926652 / 24000 # the exact same instant, read directly assert end_a != start_b # the float noise is real assert abs(end_a - start_b) < 1e-6 # ...and far below one frame assert not temporal_overlap(start_a, end_a, start_b, start_b + 1.0) class TestClassifyOverlap: def test_zero_area_is_no_conflict(self): assert classify_overlap( {"overlap_area": 0, "overlap_height": 0, "overlap_ratio": 0} ) == "none" def test_ratio_dominates_small_height(self): # A sliver of 5px that covers most of a tiny box is still severe. metrics = {"overlap_area": 50, "overlap_height": 5, "overlap_ratio": 0.9} assert classify_overlap(metrics) == OVERLAP_SEVERE def test_height_buckets(self): def metrics(height): return {"overlap_area": 1, "overlap_height": height, "overlap_ratio": 0.0} assert classify_overlap(metrics(5)) == OVERLAP_RENDER_TOLERANCE assert classify_overlap(metrics(6)) == "warning" assert classify_overlap(metrics(21)) == OVERLAP_PROBABLE assert classify_overlap(metrics(51)) == OVERLAP_SEVERE class TestDistanceAndSeparation: def test_horizontal_distance(self): a = Box(0, 100, 0, 50) b = Box(150, 200, 0, 50) d = distance_between(a, b) assert d["distance_x"] == 50 assert d["distance_y"] == 0 assert d["distance"] == 50 def test_vertical_distance(self): a = Box(0, 100, 0, 50) b = Box(0, 100, 80, 130) d = distance_between(a, b) assert d["distance_y"] == 30 assert d["distance_x"] == 0 def test_separation_picks_smallest_axis(self): a = Box(0, 100, 0, 100) b = Box(90, 190, 95, 195) # 10px horizontal, 5px vertical penetration suggestion = separation_suggestion(a, b) assert suggestion["axis"] == "vertical" assert suggestion["minimum_movement"] == 5 def test_blocking_severities(self): assert blocking(OVERLAP_SEVERE) assert blocking(OVERLAP_PROBABLE) assert not blocking("warning") assert not blocking("none") class TestValidateTitles: def test_clean_layout_has_no_issues(self): report = validate_titles( [_title("um", x=-200), _title("dois", x=200)], 2160, 3840, ) assert report["severity"] == "none" assert report["issues"] == [] def test_simultaneous_collision_reported(self): titles = [_title("A", x=0), _title("B", x=0)] report = validate_titles(titles, 2160, 3840) collisions = [ i for i in report["issues"] if i["type"] == SPATIAL_COLLISION ] assert len(collisions) == 1 assert collisions[0]["severity"] == OVERLAP_SEVERE assert collisions[0]["suggested_correction"]["axis"] in ( "vertical", "horizontal", ) def test_collision_across_times_ignored(self): titles = [ _title("A", x=0, start=0.0, end=1.0), _title("B", x=0, start=1.0, end=2.0), ] report = validate_titles(titles, 2160, 3840) assert not any( i["type"] == SPATIAL_COLLISION for i in report["issues"] ) def test_outside_frame_is_error(self): report = validate_titles([_title("fora", x=5000)], 2160, 3840) assert any(i["type"] == OUTSIDE_FRAME for i in report["issues"]) def test_outside_safe_area_is_warning_not_frame(self): # Near the right edge: inside the frame, outside the 5% safe area. report = validate_titles( [_title("a", x=1000, font_size=40)], 2160, 3840, ) assert any(i["type"] == OUTSIDE_SAFE_AREA for i in report["issues"]) assert not any(i["type"] == OUTSIDE_FRAME for i in report["issues"]) def test_resolution_changes_safe_area(self): # Same x is fine on a wide frame but out of the safe area on a narrow one. narrow = validate_titles([_title("a", x=1000, font_size=40)], 1920, 1080) assert any(i["type"] == OUTSIDE_FRAME for i in narrow["issues"]) def test_font_missing_reported(self): report = validate_titles( [_title("oi", font="Comic Sans MS")], 2160, 3840, ) assert any(i["type"] == FONT_MISSING for i in report["issues"]) def test_font_too_small_reported(self): report = validate_titles( [_title("oi", font_size=10)], 2160, 3840, min_font_size=20, ) assert any(i["type"] == FONT_TOO_SMALL for i in report["issues"]) def test_measure_title_box_uses_real_width(self): box = measure_title_box("ii", 100, x=0, y=0, font="Helvetica Neue") # "ii" is the narrowest glyph; a single "W" is much wider. wide = measure_title_box("W", 100, x=0, y=0, font="Helvetica Neue") assert wide.width > box.width class TestIntegration: def test_generated_layout_is_clean(self, temp_fcpxml): modifier = FCPXMLModifier(temp_fcpxml) modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE) report = modifier.validate_subtitle_layout() assert report["summary"]["title_count"] >= 3 assert report["summary"]["spatial_collision"] == 0 assert not blocking(report["severity"]) def test_hand_edited_position_is_caught(self, temp_fcpxml): modifier = FCPXMLModifier(temp_fcpxml) titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE) def position(el): for p in el.findall("param"): if p.get("name") == "Position": return p return None p0 = position(titles[0]) position(titles[1]).set("value", p0.get("value")) report = modifier.validate_subtitle_layout() assert report["summary"]["spatial_collision"] >= 1 assert blocking(report["severity"]) def test_disabled_title_is_excluded_from_validation(self, temp_fcpxml): """A title with enabled="0" never renders in Final Cut (generate_subtitles_by_emphasis disables plain titles under an emphasis phrase instead of never creating them) — it must not count as a collision, or as outside-frame/outside-safe-area, against the title actually drawn in its place.""" modifier = FCPXMLModifier(temp_fcpxml) titles = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE) def position(el): for p in el.findall("param"): if p.get("name") == "Position": return p return None p0 = position(titles[0]) position(titles[1]).set("value", p0.get("value")) titles[1].set("enabled", "0") report = modifier.validate_subtitle_layout() assert report["summary"]["spatial_collision"] == 0 assert not blocking(report["severity"])