fix(legendas): impede legenda comum sob composição dinâmica e duplicação ao regerar

Marca cada título gerado (dynamic/plain) em metadata para que regenerar
substitua a saída anterior em vez de empilhar, e usa os spans de ênfase
revisados (não os segmentos brutos do Whisper) como janela da composição
dinâmica, evitando que ela invada o trecho de legenda comum seguinte.
suppress_plain_under_dynamic corta qualquer sobra visível como rede de
segurança.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-09-22 21:51:02 -04:00
co-authored by Claude Sonnet 5
parent 32d78d0f8d
commit 0fdfe33613
5 changed files with 479 additions and 10 deletions
@@ -0,0 +1,72 @@
"""Exercise the real subtitle handler across emphasis gaps and regeneration."""
import asyncio
from pathlib import Path
import pytest
from fcpxml.models import DynamicSubtitleConfig
from fcpxml.writer import FCPXMLModifier
from server_tools import subtitles
@pytest.fixture
def caption_job(tmp_path, monkeypatch):
modifier = FCPXMLModifier(Path(__file__).parents[1] / "examples/sample.fcpxml")
parent = modifier._require_clip("Interview_A")
parent.set("start", "0s")
parent.set("duration", "10s")
media = tmp_path / "speech.mov"
media.touch()
modifier.resources[parent.get("ref")]["src"] = media.as_uri()
monkeypatch.setattr(modifier, "_iter_spine_clips", lambda: iter([(0, parent)]))
monkeypatch.setattr(subtitles, "_setup_modifier", lambda *args: (
"input.fcpxml", str(tmp_path / "output.fcpxml"), modifier,
))
monkeypatch.setattr(subtitles, "get_active_dynamic_subtitle_layouts", lambda: [
{"role": "titles.dinamicas"},
])
monkeypatch.setattr(subtitles, "_build_dynamic_subtitle_config", lambda *args: DynamicSubtitleConfig())
monkeypatch.setattr(subtitles, "load_plain_subtitle_config", lambda: {
"role": "titles.convencionais", "font": "Helvetica Neue", "font_size": 48,
"font_color": "1 1 1 1", "max_words": 1, "position_y": -100,
"uppercase": False, "keep_punctuation": True,
})
monkeypatch.setattr(subtitles, "_load_plain_exclude_spans", lambda _: [])
monkeypatch.setattr(subtitles, "_load_emphasis_spans", lambda _: [
{"start": 0, "end": 1}, {"start": 4, "end": 5},
])
monkeypatch.setattr(subtitles, "_load_or_transcribe", lambda *args: ({
"words": [
{"word": "corpo", "start": 0, "end": 1},
{"word": "muda", "start": 2, "end": 3},
{"word": "também", "start": 4, "end": 5},
],
"segments": [{"start": 0, "end": 1}, {"start": 4, "end": 5}],
}, None))
return modifier, parent
def test_dynamic_composition_clears_before_plain_words_in_emphasis_gap(caption_job):
modifier, parent = caption_job
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
dynamic = parent.findall("ref-clip")
plain = parent.findall("title")
assert dynamic and plain
for composition in dynamic:
start = modifier._parse_time(composition.get("offset"))
end = start + modifier._parse_time(composition.get("duration"))
for title in plain:
plain_start = modifier._parse_time(title.get("offset"))
plain_end = plain_start + modifier._parse_time(title.get("duration"))
assert not (start < plain_end and plain_start < end), "dynamic and plain overlap"
def test_regeneration_replaces_generated_subtitles_and_preserves_manual_titles(caption_job):
modifier, parent = caption_job
manual = modifier.add_text_title(parent, "Nome da médica", role="titles.manual")
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
initial = len(parent.findall("ref-clip")) + len(parent.findall("title"))
asyncio.run(subtitles.handle_generate_subtitles_by_emphasis({}))
assert len(parent.findall("ref-clip")) + len(parent.findall("title")) == initial
assert manual in list(parent)