Files
gart/code/tests/test_collision.py
T
João HenriqueandClaude Sonnet 5 7b5aed79ee feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão
Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha
alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a
etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado.

- generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro,
  legenda dinâmica só nas frases de ênfase, e a comum é desativada
  (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali.
- validate_subtitle_layout ignora títulos com enabled="0" — corrige falso
  positivo de colisão contra o que está desativado no lugar dele.
- Corrige zoom/marcador sendo descartado quando a borda encosta exatamente
  no início de um corte.
- Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com
  fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia
  entre "ativa" na tela e o que já foi cortado no FCPXML.
- Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json)
  antes da cadeia de remoção de silêncio/legendas — antes, desativar uma
  frase na etapa 5 não tinha efeito nenhum no vídeo final.
- Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder
  aparece assim que termina, sem slide extra.
- Palavra clicável na etapa 5 agora funciona como toggle (clique de novo
  desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte).
- fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento
  fonético via whisperx e roteirização local via Ollama/Gemma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:26:04 -04:00

290 lines
10 KiB
Python

"""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"])