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>
132 lines
5.3 KiB
Python
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()
|