¿Terminaste de grabar un tutorial técnico de más de una hora y te da pereza abrir Kdenlive o DaVinci Resolve para sacar clips? 😫✂️

Editar clips verticales manualmente para YouTube Shorts o TikTok consume horas de trabajo repetitivo: buscar momentos interesantes, recortar silencios, reencuadrar a formato vertical 9:16, desenfocar el fondo y subtitular palabra por palabra. Las herramientas SaaS en la nube como OpusClip cobran suscripciones mensuales recurrentes y suben tus grabaciones privadas a servidores externos. 💸🔒

La alternativa definitiva es montar tu propio pipeline automatizado de código abierto que corre al 100% en local. Con un solo script en Python, el sistema:

  1. Detecta si tu grabación se dividió en dos partes y las une sin pérdida de calidad.
  2. Analiza los canales de audio y aísla la pista limpia de tu micrófono (pista 2 de OBS).
  3. Elimina pausas muertas con auto-editor.
  4. Transcribe todo el audio con faster-whisper.
  5. Envía la transcripción completa a qwen2.5:14b vía Ollama para extraer de 10 a 15 ganchos virales en JSON estricto.
  6. Renderiza en lote los videos en formato vertical (9:16), con fondo desenfocado, título superior, subtítulos amarillos y metadatos incrustados.

📦 Requisitos previos en el sistema

Antes de ejecutar el script en tu distribución Linux, asegúrate de tener las dependencias listas:

Bash

# Herramientas base y modelos
sudo pacman -S ffmpeg   # En CachyOS / Arch (o apt install ffmpeg en Ubuntu/Debian)
pip install faster-whisper auto-editor

# Descarga del modelo en Ollama
ollama pull qwen2.5:14b

💻 El Código Completo (pipeline_ia_shorts.py)

Guarda este script en la carpeta donde tengas tus grabaciones (video.mp4 o video2.mp4):

Python

#!/usr/bin/env python3
import json
import os
import re
import subprocess
import urllib.request
from faster_whisper import WhisperModel

# --- CONFIGURACIÓN ---
VIDEO_BASE = "video.mp4" if os.path.exists("video.mp4") else "1.mp4"
VIDEO_EXTRA = "video2.mp4" if os.path.exists("video2.mp4") else ("2.mp4" if os.path.exists("2.mp4") else None)
VIDEO_PREPARED = "unido.mp4"
VIDEO_CLEAN = "video_ALTERED.mp4"
SRT_OUTPUT = "video_ALTERED.srt"
OUT_DIR = "/home/julioc/Automatizaciones/shorts/global/tutoriales"
OLLAMA_MODEL = "qwen2.5:14b"
OLLAMA_URL = "http://localhost:11434/api/generate"

os.makedirs(OUT_DIR, exist_ok=True)

# 0. DETECCIÓN DE PISTAS DE AUDIO Y UNIÓN DE VIDEOS
print("=" * 60)
print("[0/4] Verificando pistas de audio y videos a unir...")
print("=" * 60)

def count_audio_streams(file_path):
    try:
        cmd = [
            "ffprobe", "-v", "error", "-select_streams", "a",
            "-show_entries", "stream=index", "-of", "csv=p=0", file_path
        ]
        out = subprocess.check_output(cmd).decode().strip()
        return len(out.splitlines()) if out else 0
    except Exception:
        return 1

num_audio = count_audio_streams(VIDEO_BASE)
audio_map = "0:a:1" if num_audio >= 2 else "0:a:0"
print(f"[*] Pistas de audio detectadas: {num_audio} -> Seleccionada: {audio_map} ({'Micrófono pista 2' if audio_map == '0:a:1' else 'Pista principal'})")

if not os.path.exists(VIDEO_PREPARED) and not os.path.exists(VIDEO_CLEAN):
    if VIDEO_EXTRA and os.path.exists(VIDEO_EXTRA):
        print(f"[*] Detectado video adicional: {VIDEO_EXTRA}. Uniendo archivos...")
        with open("lista_unir.txt", "w", encoding="utf-8") as f:
            f.write(f"file '{VIDEO_BASE}'\nfile '{VIDEO_EXTRA}'\n")
        
        cmd_concat = [
            "ffmpeg", "-y", "-f", "concat", "-safe", "0",
            "-i", "lista_unir.txt",
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_concat, check=True)
        os.remove("lista_unir.txt")
    else:
        print(f"[*] Procesando solo {VIDEO_BASE} con audio {audio_map}...")
        cmd_extract = [
            "ffmpeg", "-y", "-i", VIDEO_BASE,
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_extract, check=True)
else:
    print(f"[!] Reutilizando archivo preparado existente...")

# 1. CORTE DE SILENCIOS CON AUTO-EDITOR
print("\n" + "=" * 60)
print("[1/4] Cortando silencios (>5s) con auto-editor...")
print("=" * 60)

input_for_autoeditor = VIDEO_PREPARED if os.path.exists(VIDEO_PREPARED) else VIDEO_BASE
if not os.path.exists(VIDEO_CLEAN):
    cmd_autoeditor = [
        "auto-editor", input_for_autoeditor,
        "--edit", "audio:threshold=4%,mincut=5s",
        "-o", VIDEO_CLEAN
    ]
    subprocess.run(cmd_autoeditor, check=True)
else:
    print(f"[!] Archivo {VIDEO_CLEAN} ya existe, reutilizando...")

# 2. TRANSCRIPCIÓN CON FASTER-WHISPER
print("\n" + "=" * 60)
print("[2/4] Verificando / generando subtítulos...")
print("=" * 60)

srt_text_dump = []
if not os.path.exists(SRT_OUTPUT):
    whisper_model = WhisperModel("small", device="cpu", compute_type="int8")
    segments, _ = whisper_model.transcribe(VIDEO_CLEAN, language="es")

    srt_entries = []
    for idx, seg in enumerate(segments, 1):
        t_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d},000"
        t_end = f"{int(seg.end//3600):02d}:{int((seg.end%3600)//60):02d}:{int(seg.end%60):02d},000"
        srt_entries.append(f"{idx}\n{t_start} --> {t_end}\n{seg.text.strip()}\n")
        
        clean_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d}"
        srt_text_dump.append(f"[{clean_start}] {seg.text.strip()}")

    with open(SRT_OUTPUT, "w", encoding="utf-8") as f:
        f.write("\n".join(srt_entries))
else:
    print(f"[!] Usando {SRT_OUTPUT} existente...")
    with open(SRT_OUTPUT, "r", encoding="utf-8") as f:
        lines = f.readlines()
    for i, line in enumerate(lines):
        if "-->" in line and i + 1 < len(lines):
            t_raw = line.split("-->")[0].strip().split(",")[0]
            txt = lines[i+1].strip()
            if txt:
                srt_text_dump.append(f"[{t_raw}] {txt}")

# 3. EXTRACCIÓN MASIVA CON QWEN 2.5 (10 A 15 CLIPS)
print("\n" + "=" * 60)
print(f"[3/4] Extrayendo entre 10 y 15 momentos virales con {OLLAMA_MODEL}...")
print("=" * 60)

prompt_analysis = f"""Eres un editor profesional de contenido en formato vertical (Shorts/TikTok).
Analiza detalladamente toda la transcripción y extrae OBLIGATORIAMENTE entre 10 y 15 momentos clave.

INSTRUCCIONES CRÍTICAS:
1. Cuota obligatoria: Mínimo 10 clips, máximo 15 clips. NO entregues menos de 10.
2. Cada clip debe durar entre 30 y 75 segundos.
3. Cubre TODO el video: extrae clips del inicio, de la parte media y del final.
4. Formato de timestamps: "HH:MM:SS" (estricto).
5. Títulos: En MAYÚSCULAS, breves, sin dos puntos ni comillas.
6. Campo filename: alfanumérico con guiones bajos (ej. "01_TITULO", "02_TITULO").

Devuelve EXCLUSIVAMENTE el arreglo JSON:
[
  {{
    "start": "00:00:45",
    "end": "00:01:35",
    "filename": "01_TITULO_RESUMEN",
    "title": "TITULO EN MAYUSCULAS",
    "desc": "Descripcion corta para metadatos"
  }}
]

Transcripción:
{chr(10).join(srt_text_dump)}
"""

payload = {
    "model": OLLAMA_MODEL,
    "prompt": prompt_analysis,
    "stream": False,
    "format": "json",
    "options": {
        "num_ctx": 16384,
        "num_predict": 4096,
        "temperature": 0.2
    }
}

req = urllib.request.Request(
    OLLAMA_URL,
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"}
)

with urllib.request.urlopen(req) as resp:
    res_data = json.loads(resp.read().decode("utf-8"))
    raw_response = res_data.get("response", "[]").strip()

if raw_response.startswith("```"):
    raw_response = re.sub(r"^```(?:json)?\s*", "", raw_response)
    raw_response = re.sub(r"\s*```$", "", raw_response)

try:
    parsed = json.loads(raw_response)
    clips = parsed.get("clips", parsed) if isinstance(parsed, dict) else parsed
except Exception as err:
    print(f"[!] Error procesando JSON de Ollama: {err}")
    print("Respuesta recibida:\n", raw_response)
    exit(1)

print(f"[✓] La IA detectó con éxito {len(clips)} segmentos.")

# 4. RENDERIZADO VERTICAL CON FFMPEG
print("\n" + "=" * 60)
print(f"[4/4] Renderizando {len(clips)} shorts en {OUT_DIR}...")
print("=" * 60)

for idx, clip in enumerate(clips, 1):
    start = clip["start"]
    end = clip["end"]
    fname = re.sub(r'[^a-zA-Z0-9_-]', '_', clip["filename"])
    title = clip["title"].replace(":", "\\:").replace("'", "").replace("%", "%%")
    desc = clip.get("desc", title)
    out_file = os.path.join(OUT_DIR, f"{fname}.mp4")

    print(f"\n[>] Short {idx}/{len(clips)}: {clip['title']}")
    print(f"    {start} -> {end}")

    filter_complex = (
        "[0:v]split=2[bg][fg];"
        "[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,avgblur=30[bg_b];"
        "[fg]scale=1080:-1[fg_s];"
        "[bg_b][fg_s]overlay=(W-w)/2:(H-h)/2,"
        f"drawtext=expansion=none:text='{title}':font='Liberation Sans\\:style=Bold':fontcolor=white:fontsize=38:bordercolor=black:borderw=4:shadowcolor=black@0.6:shadowx=2:shadowy=2:x=(w-text_w)/2:y=240,"
        f"subtitles='{SRT_OUTPUT}':force_style='PlayResX=1080,PlayResY=1920,FontSize=42,FontName=Liberation Sans,Bold=1,PrimaryColour=&H0000FFFF,OutlineColour=&H00000000,BackColour=&H80000000,BorderStyle=1,Outline=3,Shadow=2,Alignment=2,MarginV=180',"
        "setpts=PTS-STARTPTS[v]"
    )

    cmd_ffmpeg = [
        "ffmpeg", "-y",
        "-ss", start,
        "-to", end,
        "-copyts",
        "-i", VIDEO_CLEAN,
        "-filter_complex", filter_complex,
        "-map", "[v]",
        "-map", "0:a:0",
        "-af", "asetpts=PTS-STARTPTS",
        "-c:v", "libx264",
        "-preset", "veryfast",
        "-crf", "18",
        "-c:a", "aac",
        "-metadata", f"title={clip['title']}",
        "-metadata", f"description={desc}",
        "-metadata", "artist=Julio Cesar",
        "-metadata", "genre=Technology / Linux Tutorial",
        out_file
    ]
    subprocess.run(cmd_ffmpeg, check=True)

print("\n" + "=" * 60)
print(f"[✓] Proceso completado: se generaron {len(clips)} shorts.")
print(f"[✓] Ruta: {OUT_DIR}")
print("=" * 60)

🔍 Los 3 Trucos Técnicos que Hacen Funcionar este Script

  1. Auto-detección con ffprobe: Grabar con OBS suele generar la pista 1 (escritorio/audio del juego) y la pista 2 (micrófono). La función count_audio_streams() analiza los metadatos y asegura que Whisper y los videos procesen la voz limpia.
  2. Ventana de contexto de 16k y num_predict: 4096: Por defecto, Ollama utiliza ventanas reducidas que truncan la respuesta y provocan que solo se genere 1 clip. Ajustar estos dos parámetros permite al modelo leer transcripciones largas y devolver un archivo JSON con más de 10 elementos sin romperse.
  3. Filtro FFmpeg 9:16 con escape seguro: Los dos puntos (:) y porcentajes (%) rompen la sintaxis de drawtext. El script sanitiza automáticamente los títulos para que ningún carácter especial interrumpa el renderizado.

Ejecuta el script directamente en tu terminal:

Bash

python3 pipeline_ia_shorts.py

Al terminar, tendrás hasta 15 videos verticales listos en tu carpeta con títulos llamativos, subtítulos integrados y metadatos completos para subir a YouTube Shorts y TikTok sin pagar suscripciones. 🐧🚀