From 8257155fd3b84c7057ec7280ed7cf3d13c4c3d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Fri, 21 Aug 2026 18:49:13 -0400 Subject: [PATCH] =?UTF-8?q?fix(voz):=20frases=20desativadas=20em=20sequ?= =?UTF-8?q?=C3=AAncia=20deixavam=20fatias=20sobrando=20no=20corte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phrase_review_to_actions() cortava cada frase desativada isoladamente (start..end da própria frase) — quando várias seguidas estavam desativadas, a pausa ENTRE elas não pertencia a nenhuma frase e sobrevivia como um clipe minúsculo (0,1-0,5s) na timeline final. Confirmado no projeto Mastopexia real: 29 cuts individuais geravam mais de uma dezena de fatias sub-segundo; agrupar frases desativadas consecutivas num único cut (do início da primeira ao fim da última) reduziu para 3 cuts e 4 fatias residuais (menores, provavelmente do padding do remove_media_silence — registrado como dívida separada em 09_MANUTENCAO.md §2.5). Co-Authored-By: Claude Sonnet 5 --- code/Engine/docs/05_EXPERIENCIAS.md | 35 ++++++++++++++++++++++++ code/Engine/docs/09_MANUTENCAO.md | 11 +++++++- code/fcpxml/phrase_review.py | 41 ++++++++++++++++++++++------- code/tests/test_phrase_review.py | 36 +++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md index 7b83d42..758eb47 100644 --- a/code/Engine/docs/05_EXPERIENCIAS.md +++ b/code/Engine/docs/05_EXPERIENCIAS.md @@ -1459,3 +1459,38 @@ o outro; percentil entrega um punhado útil nos dois casos. > borda que respire. Regra prática: só cortar rente ao timestamp quando a > borda encosta em silêncio real (`gap_before` grande) ou em conteúdo que > também será descartado; encostando em fala mantida, sempre recuar. + +--- + +### 2026-08-21 — Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando + +- **Sintoma:** usuário viu, no Final Cut, um clipe minúsculo sobrando entre + dois clipes normais na timeline (projeto Mastopexia, confirmado por + screenshot). Investigação achou 29 `cut`s individuais no + `_phrase_actions.json` gerado pela etapa 5, e a timeline final saiu com + mais de uma dezena de fatias de 0,1-0,5s entre clipes. +- **Causa:** `phrase_review_to_actions()` (`fcpxml/phrase_review.py`) gerava + **um `cut` por frase desativada**, cobrindo só `[phrase.start, phrase.end]`. + Quando duas ou mais frases seguidas estão desativadas, a pausa **entre** + elas nunca pertence a nenhuma frase — não é coberta por nenhum `cut` — e + sobrevive como um clipe próprio, minúsculo, que ninguém pediu para manter. +- **Correção:** `phrase_review_to_actions()` agora agrupa frases desativadas + **consecutivas** (`flush_inactive_run()`) e emite um único `cut` cobrindo do + início da primeira ao fim da última do grupo, absorvendo as pausas entre + elas. Uma frase ativa no meio ainda quebra o grupo — cuts continuam + separados quando há conteúdo mantido entre eles. +- **Validação:** `tests/test_phrase_review.py` ganhou + `test_consecutive_inactive_phrases_merge_into_one_cut`, + `test_inactive_run_at_the_end_still_flushes` e + `test_isolated_inactive_phrases_stay_separate_cuts`. No projeto Mastopexia + real, 29 cuts individuais viraram 3 cuts mescladas; a contagem de fatias + sub-segundo na timeline final caiu de mais de uma dezena para 4 (resíduo + menor, provavelmente do padding do `remove_media_silence` na emenda entre + clipes — não investigado a fundo nesta sessão, ver `09_MANUTENCAO.md`). +- **Estado:** `resolvido` (a causa principal); a sobra residual do + `remove_media_silence` continua como dívida separada. + +> **Aprendizado:** "cortar cada frase desativada" não é a mesma coisa que +> "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido +> entre elas — a pausa entre duas coisas descartadas também precisa ser +> descartada, e ninguém a cobre por definição se o corte for por frase. diff --git a/code/Engine/docs/09_MANUTENCAO.md b/code/Engine/docs/09_MANUTENCAO.md index ddbf230..6fb0db6 100644 --- a/code/Engine/docs/09_MANUTENCAO.md +++ b/code/Engine/docs/09_MANUTENCAO.md @@ -59,7 +59,16 @@ Várias entradas do `05_EXPERIENCIAS.md` estão marcadas como resolvidas *no XML XML válido não é o mesmo que XML que renderiza como o esperado. Ao mexer em legenda, zoom ou keyframe, a confirmação final é abrir no FCP. -### 2.5 Submódulo `WHISPERX` com conteúdo modificado e não commitado +### 2.5 `remove_media_silence` deixa fatias sub-segundo nas emendas entre clipes +Mesmo depois de corrigir o merge de cortes consecutivos (`05_EXPERIENCIAS.md` +#28), sobraram 4 clipes de 0,07-0,23s no projeto Mastopexia real, todos bem +na emenda entre dois clipes vizinhos — mesma família do #6 (clipe-fantasma de +1 frame por padding sem vizinho na borda), mas não confirmado se é a mesma +causa raiz. Não investigado a fundo ainda. +→ `fcpxml/writer/cut.py` (`cut_clip_ranges`, `min_keep_seconds`), padding do +`remove_media_silence`. + +### 2.6 Submódulo `WHISPERX` com conteúdo modificado e não commitado Está fora dos commits de propósito, porque ninguém verificou o que mudou lá dentro. Precisa ser olhado e resolvido — ou commitado, ou revertido. diff --git a/code/fcpxml/phrase_review.py b/code/fcpxml/phrase_review.py index bc8ea68..a6f70bf 100644 --- a/code/fcpxml/phrase_review.py +++ b/code/fcpxml/phrase_review.py @@ -386,18 +386,40 @@ def phrase_review_to_actions(review: dict) -> dict: actions: List[dict] = [] emphasis_spans: List[dict] = [] + inactive_run: List[dict] = [] + + def flush_inactive_run() -> None: + """One cut per RUN of consecutive deactivated phrases, not one per + phrase. A phrase-by-phrase cut leaves the pause BETWEEN two + deactivated phrases uncut — that gap was never anyone's content, so + nothing asked for it to survive, but it does anyway: a 0.1-0.5s + sliver clip in the final timeline for every such gap. Spanning the + whole run absorbs those gaps into the one cut.""" + if not inactive_run: + return + if len(inactive_run) == 1: + reason = inactive_run[0]["reason"] or "desativada na revisão" + else: + reason = ( + f"desativadas na revisão ({len(inactive_run)} frases): " + + "; ".join(p["text"][:40] for p in inactive_run if p["text"]) + ) + actions.append( + VoiceAction( + kind="cut", + start=inactive_run[0]["start"], + end=inactive_run[-1]["end"], + reason=reason, + speaker=inactive_run[0]["speaker"], + ).as_dict() + ) + inactive_run.clear() + for phrase in phrases: if not phrase["active"]: - actions.append( - VoiceAction( - kind="cut", - start=phrase["start"], - end=phrase["end"], - reason=phrase["reason"] or "desativada na revisão", - speaker=phrase["speaker"], - ).as_dict() - ) + inactive_run.append(phrase) continue + flush_inactive_run() # Head and tail the editor trimmed off — each becomes its own cut, so a # false start disappears without taking the line with it. @@ -436,6 +458,7 @@ def phrase_review_to_actions(review: dict) -> dict: "text": phrase["text"], } ) + flush_inactive_run() # Hand-placed punch-ins carry no scale on purpose: an omitted scale lets the # applier use the shape configured in "Análise de Voz" (zoom_scale, ease in diff --git a/code/tests/test_phrase_review.py b/code/tests/test_phrase_review.py index a18bbc4..22d57d2 100644 --- a/code/tests/test_phrase_review.py +++ b/code/tests/test_phrase_review.py @@ -260,6 +260,42 @@ class TestBackToActions: ) assert result["actions"] == [] + def test_consecutive_inactive_phrases_merge_into_one_cut(self): + """Two deactivated phrases in a row must not leave the pause between + them (2.0-2.3 here) uncut — a phrase-by-phrase cut would strand it as + a tiny surviving sliver clip in the final timeline.""" + review = build_phrase_review( + _timeline([_segment(0, 2), _segment(2.3, 4), _segment(4.5, 6)]) + ) + review["phrases"][0]["active"] = False + review["phrases"][1]["active"] = False + result = phrase_review_to_actions(review) + cuts = [(a["start"], a["end"]) for a in result["actions"] if a["kind"] == "cut"] + assert cuts == [(0.0, 4.0)] + + def test_inactive_run_at_the_end_still_flushes(self): + """A run of deactivated phrases with nothing active after it must + still produce its cut — regression for merging logic that only + flushed on hitting the next active phrase.""" + review = build_phrase_review(_timeline([_segment(0, 2), _segment(2.3, 4)])) + review["phrases"][0]["active"] = False + review["phrases"][1]["active"] = False + result = phrase_review_to_actions(review) + cuts = [(a["start"], a["end"]) for a in result["actions"] if a["kind"] == "cut"] + assert cuts == [(0.0, 4.0)] + + def test_isolated_inactive_phrases_stay_separate_cuts(self): + """An active phrase between two inactive ones must not be swallowed — + only truly CONSECUTIVE inactive phrases merge.""" + review = build_phrase_review( + _timeline([_segment(0, 2), _segment(2.3, 4), _segment(4.5, 6)]) + ) + review["phrases"][0]["active"] = False + review["phrases"][2]["active"] = False + result = phrase_review_to_actions(review) + cuts = [(a["start"], a["end"]) for a in result["actions"] if a["kind"] == "cut"] + assert cuts == [(0.0, 2.0), (4.5, 6.0)] + class TestManualZooms: def test_manual_zoom_becomes_an_action_without_a_scale(self):