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:
co-authored by
Claude Sonnet 5
parent
711c397dfe
commit
7b5aed79ee
+44
-12
@@ -125,18 +125,27 @@ def transcribe(
|
||||
model_size: str = "base",
|
||||
language: Optional[str] = None,
|
||||
progress_cb: Optional[Callable[[float], None]] = None,
|
||||
align: bool = True,
|
||||
) -> Optional[dict]:
|
||||
"""Transcribe an audio/video file locally with word-level timestamps.
|
||||
|
||||
Requires the optional ``[transcribe]`` extra (faster-whisper). Returns
|
||||
``None`` when the model is unavailable or the file is missing/unreadable.
|
||||
|
||||
When ``align`` is true (default) and the optional ``whisperx`` dependency is
|
||||
present, word timestamps are refined by phonetic forced alignment, which
|
||||
corrects faster-whisper's systematic ~0.3-0.5s early bias on word *starts*
|
||||
(see ``Engine/docs/05_EXPERIENCIAS.md`` #14). The transcript reports
|
||||
whether this ran via the ``alignment`` flag, so downstream consumers can
|
||||
rely on the times without re-measuring.
|
||||
|
||||
The model weights are resolved from the configured models directory (see
|
||||
``model_manager.get_models_dir``), so a model selected/downloaded through
|
||||
the app is found without an implicit download to the default HF cache.
|
||||
|
||||
Returns:
|
||||
``{"language": str, "duration": float, "text": str,
|
||||
"alignment": bool,
|
||||
"segments": [{"text", "start", "end", "start_fmt", "end_fmt"}, ...],
|
||||
"words": [{"word", "start", "end", "confidence"}, ...]}``
|
||||
"""
|
||||
@@ -175,6 +184,7 @@ def transcribe(
|
||||
vad_filter=True,
|
||||
)
|
||||
segments: List[dict] = []
|
||||
raw_segments: List[dict] = []
|
||||
words: List[dict] = []
|
||||
# `info.duration` is known upfront (from the container), so each
|
||||
# segment's end time — yielded lazily as faster-whisper decodes —
|
||||
@@ -183,6 +193,20 @@ def transcribe(
|
||||
for seg in segments_iter:
|
||||
start = float(seg.start)
|
||||
end = float(seg.end)
|
||||
seg_words: List[dict] = []
|
||||
if progress_cb is not None and total_duration > 0:
|
||||
progress_cb(min(end / total_duration, 1.0))
|
||||
for w in seg.words or []:
|
||||
ws = float(w.start)
|
||||
we = float(w.end)
|
||||
word = {
|
||||
"word": w.word.strip(),
|
||||
"start": ws,
|
||||
"end": we,
|
||||
"confidence": float(w.probability),
|
||||
}
|
||||
words.append(word)
|
||||
seg_words.append(word)
|
||||
segments.append(
|
||||
{
|
||||
"text": seg.text.strip(),
|
||||
@@ -192,19 +216,26 @@ def transcribe(
|
||||
"end_fmt": format_timestamp(end),
|
||||
}
|
||||
)
|
||||
if progress_cb is not None and total_duration > 0:
|
||||
progress_cb(min(end / total_duration, 1.0))
|
||||
for w in seg.words or []:
|
||||
ws = float(w.start)
|
||||
we = float(w.end)
|
||||
words.append(
|
||||
{
|
||||
"word": w.word.strip(),
|
||||
"start": ws,
|
||||
"end": we,
|
||||
"confidence": float(w.probability),
|
||||
}
|
||||
raw_segments.append(
|
||||
{
|
||||
"text": seg.text.strip(),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"words": seg_words,
|
||||
}
|
||||
)
|
||||
|
||||
alignment_ran = False
|
||||
if align and raw_segments:
|
||||
from .forced_align import ForcedAligner
|
||||
|
||||
try:
|
||||
words = ForcedAligner().align(
|
||||
words, raw_segments, str(file_path), info.language, str(models_dir)
|
||||
)
|
||||
alignment_ran = True
|
||||
except Exception:
|
||||
logger.warning("forced alignment step failed; keeping raw timestamps")
|
||||
except Exception:
|
||||
logger.warning("whisper transcription failed for %s", file_path)
|
||||
return None
|
||||
@@ -212,6 +243,7 @@ def transcribe(
|
||||
"language": info.language,
|
||||
"duration": float(info.duration),
|
||||
"text": " ".join(s["text"] for s in segments),
|
||||
"alignment": alignment_ran,
|
||||
"segments": segments,
|
||||
"words": words,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user