Files
gart/code/ai_edit.py
João HenriqueandClaude Sonnet 5 7b5aed79ee 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>
2026-08-21 18:26:04 -04:00

132 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""AI Voice Editor - Pipeline completo: transcrição + análise acústica → JSON para IA.
Uso:
python ai_edit.py <media_path> [--model base] [--lang pt] [--no-diarize] [--output dir]
Gera dois arquivos na pasta output (ou ao lado do mídia):
<nome>_transcript.json — transcrição com timestamps por palavra
<nome>_voice_timeline.json — timeline de voz com ênfase, pitch, energy, speakers
Esses arquivos são a ENTRADA para a IA analisar e gerar o roteiro/edição.
"""
import argparse
import json
import sys
import time
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description="Pipeline de análise de voz para IA")
parser.add_argument("media", help="Caminho do arquivo de mídia (.mp4, .mov, .wav, etc.)")
parser.add_argument("--model", default="base", help="Modelo Whisper (tiny/base/small/medium/large-v3)")
parser.add_argument("--lang", default=None, help="Idioma (ex: pt, en). Auto-detect se omitido")
parser.add_argument("--hf-token", default=None, help="HuggingFace token para diarização (opcional)")
parser.add_argument("--no-diarize", action="store_true", help="Pular diarização de falantes")
parser.add_argument("--output", default=None, help="Pasta de saída (padrão: ao lado do mídia)")
parser.add_argument("--no-align", action="store_true", help="Pular alinhamento fonético (whisperx)")
args = parser.parse_args()
media_path = Path(args.media).resolve()
if not media_path.is_file():
print(f"ERRO: Arquivo não encontrado: {media_path}", file=sys.stderr)
sys.exit(1)
# Output dir
out_dir = Path(args.output) if args.output else media_path.parent
out_dir.mkdir(parents=True, exist_ok=True)
stem = media_path.stem
# ── Fase 1: Transcrição ──────────────────────────────────────────
print(f"[1/2] Transcrevendo {media_path.name} (modelo: {args.model})...")
t0 = time.time()
# Adiciona code/ ao path para imports do projeto
code_dir = Path(__file__).resolve().parent
sys.path.insert(0, str(code_dir))
from fcpxml.transcribe import transcribe
def transcribe_progress(pct):
bar_len = 30
filled = int(bar_len * pct)
bar = "█" * filled + "░" * (bar_len - filled)
print(f"\r [{bar}] {pct*100:.0f}%", end="", flush=True)
transcript = transcribe(
str(media_path),
model_size=args.model,
language=args.lang,
progress_cb=transcribe_progress,
align=not args.no_align,
)
print() # newline after progress bar
if transcript is None:
print("ERRO: Transcrição falhou. Verifique se faster-whisper está instalado:", file=sys.stderr)
print(" uv pip install faster-whisper", file=sys.stderr)
sys.exit(1)
print(f" → {len(transcript.get('words', []))} palavras, "
f"{len(transcript.get('segments', []))} segmentos, "
f"idioma: {transcript.get('language', '?')}")
# Salva transcrição
transcript_path = out_dir / f"{stem}_transcript.json"
transcript_path.write_text(json.dumps(transcript, indent=2, ensure_ascii=False), encoding="utf-8")
print(f" → Salvo: {transcript_path}")
# ── Fase 2: Análise de voz (timeline) ────────────────────────────
print(f"\n[2/2] Analisando voz (pitch, energia, ênfase)...")
t1 = time.time()
from fcpxml.voice_timeline import build_voice_timeline
def voice_progress(fraction, stage):
print(f"\r {stage} ({fraction*100:.0f}%)", end="", flush=True)
hf_token = None if args.no_diarize else args.hf_token
timeline = build_voice_timeline(
str(media_path),
transcript,
hf_token=hf_token,
progress_cb=voice_progress,
)
print()
# Salva voice timeline
timeline_path = out_dir / f"{stem}_voice_timeline.json"
timeline_path.write_text(json.dumps(timeline, indent=2, ensure_ascii=False), encoding="utf-8")
print(f" → Salvo: {timeline_path}")
# ── Resumo ───────────────────────────────────────────────────────
elapsed = time.time() - t0
summary = timeline.get("summary", {})
layers = timeline.get("layers", {})
n_words = len(transcript.get("words", []))
n_segments = len(transcript.get("segments", []))
n_speakers = len(timeline.get("speakers", []))
duration = transcript.get("duration", 0)
print(f"\n{'='*50}")
print(f" ARQUIVOS GERADOS:")
print(f" {transcript_path}")
print(f" {timeline_path}")
print(f"\n RESUMO:")
print(f" Duração: {duration:.1f}s ({duration/60:.1f}min)")
print(f" Palavras: {n_words}")
print(f" Segmentos: {n_segments}")
print(f" Falantes: {n_speakers}")
print(f" Camadas: transcript={layers.get('transcript')}, "
f"acoustics={layers.get('acoustics')}, "
f"diarization={layers.get('diarization')}")
print(f" Tempo: {elapsed:.1f}s")
print(f"{'='*50}")
print(f"\n→ Pronto! Agora peça à IA para analisar o voice timeline e gerar o roteiro.")
if __name__ == "__main__":
main()