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>
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""Tests for layering subtitle generation by emphasis (etapa 5 review).
|
|
|
|
Covers the pure helpers in server_tools/subtitles.py that decide which words
|
|
also get the dynamic treatment on top of the always-complete plain track, and
|
|
which clip-relative windows a plain title must be hidden (enabled="0") under.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from server_tools.subtitles import (
|
|
_load_emphasis_spans,
|
|
_overlaps_any_span,
|
|
_phrase_actions_path,
|
|
_segments_in_spans,
|
|
_word_in_spans,
|
|
_words_in_spans,
|
|
)
|
|
|
|
SPANS = [
|
|
{"start": 2.0, "end": 10.7, "level": 1, "text": "abertura"},
|
|
{"start": 127.7, "end": 135.0, "level": 2, "text": "mastopexia"},
|
|
]
|
|
|
|
|
|
def test_phrase_actions_path_matches_media_stem():
|
|
assert _phrase_actions_path("/x/y/0E6A8290.mp4") == Path(
|
|
"/x/y/0E6A8290_phrase_actions.json"
|
|
)
|
|
|
|
|
|
class TestLoadEmphasisSpans:
|
|
def test_missing_file_returns_empty(self, tmp_path):
|
|
media = tmp_path / "clip.mp4"
|
|
media.write_bytes(b"")
|
|
assert _load_emphasis_spans(str(media)) == []
|
|
|
|
def test_reads_spans_from_sibling_json(self, tmp_path):
|
|
media = tmp_path / "clip.mp4"
|
|
media.write_bytes(b"")
|
|
actions_path = tmp_path / "clip_phrase_actions.json"
|
|
actions_path.write_text(
|
|
json.dumps({"source": "clip.mp4", "actions": [], "emphasis_spans": SPANS}),
|
|
encoding="utf-8",
|
|
)
|
|
assert _load_emphasis_spans(str(media)) == SPANS
|
|
|
|
def test_corrupt_json_returns_empty(self, tmp_path):
|
|
media = tmp_path / "clip.mp4"
|
|
media.write_bytes(b"")
|
|
(tmp_path / "clip_phrase_actions.json").write_text("{not json", encoding="utf-8")
|
|
assert _load_emphasis_spans(str(media)) == []
|
|
|
|
def test_missing_emphasis_spans_key_returns_empty(self, tmp_path):
|
|
media = tmp_path / "clip.mp4"
|
|
media.write_bytes(b"")
|
|
(tmp_path / "clip_phrase_actions.json").write_text(
|
|
json.dumps({"source": "clip.mp4", "actions": []}), encoding="utf-8"
|
|
)
|
|
assert _load_emphasis_spans(str(media)) == []
|
|
|
|
|
|
class TestWordInSpans:
|
|
def test_word_fully_inside_span(self):
|
|
assert _word_in_spans(3.0, 3.4, SPANS) is True
|
|
|
|
def test_word_fully_outside_every_span(self):
|
|
assert _word_in_spans(50.0, 50.4, SPANS) is False
|
|
|
|
def test_word_straddling_span_boundary_follows_its_midpoint(self):
|
|
# midpoint 10.6 -> inside [2.0, 10.7)
|
|
assert _word_in_spans(10.4, 10.8, SPANS) is True
|
|
# midpoint 10.9 -> outside
|
|
assert _word_in_spans(10.7, 11.1, SPANS) is False
|
|
|
|
def test_span_end_is_exclusive(self):
|
|
assert _word_in_spans(10.7, 10.7, SPANS) is False
|
|
|
|
|
|
class TestWordsInSpans:
|
|
WORDS = [
|
|
{"word": "Aquela", "start": 2.03, "end": 2.69},
|
|
{"word": "mama", "start": 2.69, "end": 2.89},
|
|
{"word": "fora", "start": 50.0, "end": 50.2},
|
|
{"word": "mastopexia", "start": 127.74, "end": 128.58},
|
|
]
|
|
|
|
def test_no_spans_returns_nothing(self):
|
|
assert _words_in_spans(self.WORDS, []) == []
|
|
|
|
def test_keeps_only_words_inside_a_span(self):
|
|
kept = _words_in_spans(self.WORDS, SPANS)
|
|
assert [w["word"] for w in kept] == ["Aquela", "mama", "mastopexia"]
|
|
|
|
def test_does_not_remove_words_from_the_source_list(self):
|
|
"""This is a filter for the dynamic half, not a partition — the plain
|
|
half must still see every word, so this must never mutate `words`."""
|
|
before = list(self.WORDS)
|
|
_words_in_spans(self.WORDS, SPANS)
|
|
assert self.WORDS == before
|
|
|
|
|
|
class TestOverlapsAnySpan:
|
|
CLIP_SPANS = [(0.0, 8.64), (60.0, 67.22)]
|
|
|
|
def test_window_inside_a_span_overlaps(self):
|
|
assert _overlaps_any_span(1.0, 2.0, self.CLIP_SPANS) is True
|
|
|
|
def test_window_outside_every_span_does_not_overlap(self):
|
|
assert _overlaps_any_span(20.0, 21.0, self.CLIP_SPANS) is False
|
|
|
|
def test_window_straddling_a_span_edge_overlaps(self):
|
|
assert _overlaps_any_span(8.0, 9.0, self.CLIP_SPANS) is True
|
|
|
|
def test_touching_but_not_overlapping_is_not_an_overlap(self):
|
|
assert _overlaps_any_span(8.64, 9.0, self.CLIP_SPANS) is False
|
|
|
|
def test_no_spans_never_overlaps(self):
|
|
assert _overlaps_any_span(1.0, 2.0, []) is False
|
|
|
|
|
|
class TestSegmentsInSpans:
|
|
SEGMENTS = [
|
|
{"start": 2.0, "end": 10.67},
|
|
{"start": 40.0, "end": 43.0},
|
|
{"start": 127.74, "end": 134.96},
|
|
]
|
|
|
|
def test_no_spans_keeps_nothing(self):
|
|
assert _segments_in_spans(self.SEGMENTS, []) == []
|
|
|
|
def test_keeps_only_segments_inside_a_span(self):
|
|
kept = _segments_in_spans(self.SEGMENTS, SPANS)
|
|
assert kept == [self.SEGMENTS[0], self.SEGMENTS[2]]
|