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:
co-authored by
Claude Opus 5
parent
e7748c2c58
commit
1bebee4359
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user