feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão

Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha
alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a
etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado.

- generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro,
  legenda dinâmica só nas frases de ênfase, e a comum é desativada
  (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali.
- validate_subtitle_layout ignora títulos com enabled="0" — corrige falso
  positivo de colisão contra o que está desativado no lugar dele.
- Corrige zoom/marcador sendo descartado quando a borda encosta exatamente
  no início de um corte.
- Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com
  fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia
  entre "ativa" na tela e o que já foi cortado no FCPXML.
- Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json)
  antes da cadeia de remoção de silêncio/legendas — antes, desativar uma
  frase na etapa 5 não tinha efeito nenhum no vídeo final.
- Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder
  aparece assim que termina, sem slide extra.
- Palavra clicável na etapa 5 agora funciona como toggle (clique de novo
  desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte).
- fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento
  fonético via whisperx e roteirização local via Ollama/Gemma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-08-21 18:26:04 -04:00
co-authored by Claude Sonnet 5
parent 711c397dfe
commit 7b5aed79ee
36 changed files with 2922 additions and 624 deletions
+181
View File
@@ -0,0 +1,181 @@
"""Forced alignment — refine word timestamps against an acoustic model.
Why this exists
--------------
faster-whisper derives word times by cross-attention, which lands every word
*start* systematically ~0.3-0.5s early (the word-end is fine). That bias flows
straight into the voice timeline and makes zoom/cut land on the wrong frame —
measured on real footage in ``Engine/docs/05_EXPERIENCIAS.md`` (#14). Phonetic
forced alignment (wav2vec2, via whisperx) re-anchors each word against the
audio and brings that error down to ~30ms.
Design
------
* The dependency (``whisperx``) is **optional** and imported lazily, exactly
like the rest of this stack (librosa, faster-whisper). When it is missing, or
any step fails, :meth:`ForcedAligner.align` returns the words unchanged, so
transcription never breaks because alignment did.
* The aligner is a single responsibility class: it knows how to turn a
transcript into the shape whisperx wants, call it, and write the refined
times back. ``transcribe.py`` owns the decision of *whether* to align.
* Align models are cached per language on the instance so repeated calls
(e.g. many short clips) don't reload the wav2vec2 weights each time.
"""
import logging
from typing import List, Optional, Sequence
logger = logging.getLogger(__name__)
class ForcedAligner:
"""Refine word-level timestamps with whisperx phonetic forced alignment.
Usage::
aligner = ForcedAligner()
words = aligner.align(words, raw_segments, media_path, language, models_dir)
``words`` and ``raw_segments`` come straight from :func:`transcribe` —
``raw_segments`` carries the per-segment ``words`` lists (the same dict
objects as in ``words``) so the aligner knows which words belong to which
audio window. Returns a list of the *same* word dicts, with ``start``/``end``
overwritten in place where alignment produced a usable time.
"""
def __init__(self, device: Optional[str] = None):
self._device = device
self._models: dict = {}
# -- capability ------------------------------------------------------
@staticmethod
def available() -> bool:
"""Whether whisperx can be imported (the aligner can run at all)."""
try:
import whisperx # noqa: F401
except Exception:
return False
return True
def _resolve_device(self) -> str:
if self._device:
return self._device
try:
import torch
if torch.cuda.is_available():
return "cuda"
except Exception:
pass
return "cpu"
# -- public API ------------------------------------------------------
def align(
self,
words: Sequence[dict],
raw_segments: Sequence[dict],
audio_path: str,
language: str,
models_dir: Optional[str] = None,
) -> List[dict]:
"""Return ``words`` with forced-aligned timestamps where possible.
Falls back to the unchanged ``words`` on any failure (missing
dependency, model load error, audio read error, or a result that
doesn't line up with the input).
"""
if not words or not language:
return list(words)
try:
import whisperx
except Exception:
logger.info("whisperx not installed; skipping forced alignment")
return list(words)
try:
device = self._resolve_device()
align_input = self._build_align_input(words, raw_segments)
audio = whisperx.load_audio(audio_path)
if language not in self._models:
align_model, metadata = whisperx.load_align_model(
language_code=language,
device=device,
model_dir=str(models_dir) if models_dir else None,
)
self._models[language] = (align_model, metadata)
align_model, metadata = self._models[language]
result = whisperx.align(
align_input,
align_model,
metadata,
audio,
device,
return_char_alignments=False,
)
return self._merge_result(words, result.get("segments", []))
except Exception:
logger.warning(
"forced alignment failed for %s; using raw timestamps", audio_path
)
return list(words)
# -- internals -------------------------------------------------------
@staticmethod
def _build_align_input(
words: Sequence[dict], raw_segments: Sequence[dict]
) -> List[dict]:
"""Transcript in whisperx's expected shape: segments -> words.
whisperx.align requires each segment to carry ``text``/``start``/``end``
and a ``words`` list whose entries have ``word``/``start``/``end``/``score``.
We only read ``words`` from ``raw_segments`` (the flattened ``words``
list is the source of truth for counts), so the two stay consistent.
"""
align_segments: List[dict] = []
for seg in raw_segments:
seg_words = [
{
"word": w.get("word", ""),
"start": float(w.get("start", 0.0)),
"end": float(w.get("end", 0.0)),
"score": float(w.get("confidence", 0.0)),
}
for w in seg.get("words", [])
]
align_segments.append(
{
"text": (seg.get("text") or "").strip(),
"start": float(seg.get("start", 0.0)),
"end": float(seg.get("end", 0.0)),
"words": seg_words,
}
)
return align_segments
@staticmethod
def _merge_result(words: Sequence[dict], aligned_segments: Sequence[dict]) -> List[dict]:
"""Walk the aligned output in order and overwrite word times in place.
whisperx preserves word order within and across segments, so a single
running index over the output words lines up with ``words``. A word the
aligner failed to place gets ``None``/``0`` times — we skip those rather
than clobber a good timestamp, and if counts ever diverge we stop and
leave the rest untouched.
"""
out = list(words)
wi = 0
for seg in aligned_segments:
for aw in seg.get("words", []):
if wi >= len(out):
return out
start = aw.get("start")
end = aw.get("end")
if start is None or end is None or end < start:
wi += 1
continue
out[wi]["start"] = float(start)
out[wi]["end"] = float(end)
wi += 1
return out