chore: atualização geral
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user