feat: etapa 5 do assistente — revisão de ênfases com timeline

Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da
IA chega carregada e o editor afina frase a frase o que é ênfase e o que
fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase
recebem zoom e legenda dinâmica; as demais ficam com legenda comum.

O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas
não muda e a etapa 6 segue intacta.

Backend (fcpxml/phrase_review.py):
- build_phrase_review funde o _voice_timeline.json com as actions da IA
- trim por frase que anda em fronteira de palavra; corte parcial da IA
  chega como trim em vez de ser arredondado fora
- phrase_review_to_actions volta a cuts/zooms + emphasis_spans
- merge_saved_decisions reaplica só as decisões salvas sobre uma revisão
  remontada da análise atual, para reprocessar a voz não ficar mascarado
- resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo

App (SwiftUI):
- layout de sala de edição: preview em cima, inspector à direita, timeline
  atravessando embaixo com seis trilhas rotuladas
- preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal,
  projeto vertical), com alternância para a mídia original
- reprodução pula os trechos removidos e para no fim do trecho
- zoom manual por trecho marcado, sem guardar escala: a forma vem das
  configurações de Análise de Voz no render
- emoção da fala exposta por frase

Correções encontradas no caminho:
- VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc;
  trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22)
- teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-08-19 21:29:27 -04:00
co-authored by Claude Opus 5
parent e7748c2c58
commit 1bebee4359
31 changed files with 4622 additions and 83 deletions
+15 -7
View File
@@ -40,7 +40,7 @@ WORDS = [
w("the", 2.5, 2.6),
w("show", 2.65, 3.0),
w("is", 3.05, 3.15),
w("um", 3.2, 3.5),
w("uh", 3.2, 3.5),
w("great.", 3.6, 4.0),
]
@@ -60,7 +60,7 @@ class TestNormalizeWord:
class TestFindPhraseSpans:
def test_single_word_multiple_hits(self):
spans = find_phrase_spans(WORDS, "um")
assert spans == [(0.4, 0.6), (3.2, 3.5)]
assert spans == [(0.4, 0.6)]
def test_multi_word_phrase(self):
spans = find_phrase_spans(WORDS, "welcome to the show")
@@ -84,9 +84,13 @@ class TestFindPhraseSpans:
class TestFindFillerSpans:
def test_default_fillers(self):
spans = find_filler_spans(WORDS)
assert (0.4, 0.6) in spans
assert (0.4, 0.6) not in spans
assert (3.2, 3.5) in spans
def test_um_can_still_be_explicit(self):
spans = find_filler_spans(WORDS, fillers=("um",))
assert spans == [(0.4, 0.6)]
def test_multi_word_filler(self):
spans = find_filler_spans(WORDS, fillers=("you know",))
assert spans == [(2.0, 2.4)]
@@ -96,7 +100,10 @@ class TestFindFillerSpans:
assert spans == sorted(spans)
def test_defaults_are_conservative(self):
# "like" and "so" are speech, not noise — must not be default-cut.
# "um", "uma", "like" and "so" are speech, not noise — must not be
# default-cut.
assert "um" not in DEFAULT_FILLERS
assert "uma" not in DEFAULT_FILLERS
assert "like" not in DEFAULT_FILLERS
assert "so" not in DEFAULT_FILLERS
@@ -269,11 +276,12 @@ class TestRemoveFillerWordsHandler:
assert "_defillered" in result[0].text
segments = _spine_segments(str(tmp_path / "project_defillered.fcpxml"))
# um (0-0.5) trims the clip head, uh (3-3.5) splits -> 2 segments, ~7s total
# Only uh (3-3.5) is removed by default; Portuguese "um" is preserved
# because it is often grammatical speech ("de um jeito").
assert len(segments) == 2
total = sum(c.duration.seconds for c in segments)
assert total == pytest.approx(7.0, abs=0.1)
assert segments[0].source_start.seconds == pytest.approx(0.5, abs=0.05)
assert total == pytest.approx(7.5, abs=0.1)
assert segments[0].source_start.seconds == pytest.approx(0.0, abs=0.05)
assert segments[1].source_start.seconds == pytest.approx(3.5, abs=0.05)
async def test_no_fillers_found_saves_nothing(self, tmp_path):