fix(voz): frases desativadas em sequência deixavam fatias sobrando no corte
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fd791e116a
commit
8257155fd3
@@ -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 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
|
> borda encosta em silêncio real (`gap_before` grande) ou em conteúdo que
|
||||||
> também será descartado; encostando em fala mantida, sempre recuar.
|
> 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.
|
||||||
|
|||||||
@@ -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
|
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.
|
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á
|
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.
|
dentro. Precisa ser olhado e resolvido — ou commitado, ou revertido.
|
||||||
|
|
||||||
|
|||||||
@@ -386,18 +386,40 @@ def phrase_review_to_actions(review: dict) -> dict:
|
|||||||
|
|
||||||
actions: List[dict] = []
|
actions: List[dict] = []
|
||||||
emphasis_spans: List[dict] = []
|
emphasis_spans: List[dict] = []
|
||||||
for phrase in phrases:
|
inactive_run: List[dict] = []
|
||||||
if not phrase["active"]:
|
|
||||||
|
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(
|
actions.append(
|
||||||
VoiceAction(
|
VoiceAction(
|
||||||
kind="cut",
|
kind="cut",
|
||||||
start=phrase["start"],
|
start=inactive_run[0]["start"],
|
||||||
end=phrase["end"],
|
end=inactive_run[-1]["end"],
|
||||||
reason=phrase["reason"] or "desativada na revisão",
|
reason=reason,
|
||||||
speaker=phrase["speaker"],
|
speaker=inactive_run[0]["speaker"],
|
||||||
).as_dict()
|
).as_dict()
|
||||||
)
|
)
|
||||||
|
inactive_run.clear()
|
||||||
|
|
||||||
|
for phrase in phrases:
|
||||||
|
if not phrase["active"]:
|
||||||
|
inactive_run.append(phrase)
|
||||||
continue
|
continue
|
||||||
|
flush_inactive_run()
|
||||||
|
|
||||||
# Head and tail the editor trimmed off — each becomes its own cut, so a
|
# Head and tail the editor trimmed off — each becomes its own cut, so a
|
||||||
# false start disappears without taking the line with it.
|
# false start disappears without taking the line with it.
|
||||||
@@ -436,6 +458,7 @@ def phrase_review_to_actions(review: dict) -> dict:
|
|||||||
"text": phrase["text"],
|
"text": phrase["text"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
flush_inactive_run()
|
||||||
|
|
||||||
# Hand-placed punch-ins carry no scale on purpose: an omitted scale lets the
|
# 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
|
# applier use the shape configured in "Análise de Voz" (zoom_scale, ease in
|
||||||
|
|||||||
@@ -260,6 +260,42 @@ class TestBackToActions:
|
|||||||
)
|
)
|
||||||
assert result["actions"] == []
|
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:
|
class TestManualZooms:
|
||||||
def test_manual_zoom_becomes_an_action_without_a_scale(self):
|
def test_manual_zoom_becomes_an_action_without_a_scale(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user