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>
290 lines
10 KiB
Python
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"])
|