---
title: "Editor de intros de vídeo con Python: Silero VAD + MoviePy en Mac"
description: "Crea un editor de intros de vídeo en Python con Silero VAD, MoviePy y FFmpeg. Detecta automáticamente el habla en tu metraje y genera un montaje de intro con estilo 3D en Mac."
date: 2025-12-09
categories: ["ai"]
tags: ["python","uv","moviepy"]
---

import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

Cortar una intro a mano significa recorrer el metraje, buscar los buenos momentos y reconstruir el mismo montaje en cada subida. Esta guía te muestra cómo crear un editor de intros de vídeo en Python que lo hace por ti: analiza tu metraje, detecta cuándo habla alguien usando detección de actividad de voz (VAD) y renderiza un montaje de intro con estilo 3D.

El stack: Silero VAD encuentra el habla en local, FFmpeg se encarga del desenfoque y los efectos de perspectiva, MoviePy compone los cortes rápidos y uv ejecuta todo como un único script sin gestión manual de dependencias. El tutorial está pensado para Mac; el script en sí es Python puro.

<Notice type="success" title="Lo que aprenderás">
<ListCheck>

- Detectar el habla en local con Silero VAD, sin clave de API
- Desenfocar y deformar clips en 3D con filtros de FFmpeg
- Componer cortes rápidos con MoviePy: velocidad 3x, deslizamientos y fundidos
- Ejecutarlo todo con un solo comando `uv run` gracias a las dependencias inline de PEP 723

</ListCheck>
</Notice>

## Requisitos previos

---

<ListCheck>

- FFmpeg instalado con Homebrew
- uv instalado (un comando curl)
- Un vídeo fuente con gente hablando
- Unos GB libres en disco para la descarga única de PyTorch

</ListCheck>

### FFmpeg

FFmpeg se encarga de la extracción de audio, el desenfoque y la deformación 3D. Instálalo con Homebrew:

```bash
brew install ffmpeg
```

Comprueba que funciona:

```bash
ffmpeg -version
```

Deberías ver una línea de versión como `ffmpeg version 7.x`. Si obtienes `zsh: command not found: ffmpeg`, Homebrew no lo enlazó; ejecuta `brew link ffmpeg` o reabre el terminal.

### uv

uv gestiona la parte de Python, incluido el propio intérprete. Instálalo con:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Compruébalo:

```bash
uv --version
```

### Sobre Python y el espacio en disco

El script declara `requires-python = ">=3.10"`, y uv descarga y gestiona un Python compatible en la primera ejecución, así que no necesitas instalar ni configurar Python a mano. Si prefieres mantener un intérprete normal con venv en la máquina, sigue esta guía para [instalar Python en Mac y configurar un entorno virtual](/install-upgrade-python-mac/).

Reserva unos gigabytes de espacio libre: la primera ejecución descarga PyTorch y los pesos del modelo Silero VAD. Todo lo demás queda en caché.

<Notice type="info" title="¿Nuevo en uv?">

Si eres nuevo en uv, [Primeros pasos con uv: configuración de proyectos Python en 2026](/uv-get-start/) cubre la configuración de proyectos y los conceptos básicos del entorno. Para este artículo solo necesitas un comando: `uv run`.

</Notice>

## Cómo funciona el editor de intros de vídeo en Python

---

El script ejecuta un pipeline directo desde el metraje en bruto hasta el montaje final:

1. Extrae el audio de tu vídeo con FFmpeg como WAV mono de 16 kHz
2. Ejecuta Silero VAD sobre el audio para encontrar dónde habla alguien
3. Elige segmentos de voz de más de 2 segundos y toma una muestra de seis
4. Construye un fondo desenfocado más un primer plano con deformación de perspectiva 3D por clip con FFmpeg
5. Compone las capas en MoviePy a velocidad 3x con deslizamiento y fundido
6. Concatena los clips y multiplexa una pista de audio muda en `intro_only.mp4`

Con los valores por defecto, son seis clips de 1,5 segundos cada uno: un montaje de unos 9 segundos con una pista de audio muda, listo para colocar bajo una base musical.

<figure>
  <svg
    role="img"
    viewBox="0 0 640 604"
    width="100%"
    xmlns="http://www.w3.org/2000/svg"
  >
    <title>Diagrama del pipeline del editor de intros de vídeo en Python: FFmpeg extrae el audio, Silero VAD detecta el habla, FFmpeg deforma los clips y MoviePy compone la intro.</title>
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"></path>
      </marker>
    </defs>
    <rect x="130" y="8" width="380" height="40" rx="8" fill="#f1f5f9" stroke="#94a3b8" strokeWidth="1.5"></rect>
    <text x="320" y="33" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fontWeight="600" fill="#0f172a">input.mp4</text>
    <line x1="320" y1="48" x2="320" y2="72" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="76" width="380" height="44" rx="8" fill="#eff6ff" stroke="#3b82f6" strokeWidth="1.5"></rect>
    <text x="320" y="103" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fill="#1e3a8a">FFmpeg · extraer audio a WAV mono de 16 kHz</text>
    <line x1="320" y1="120" x2="320" y2="144" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="148" width="380" height="44" rx="8" fill="#f5f3ff" stroke="#8b5cf6" strokeWidth="1.5"></rect>
    <text x="320" y="175" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fill="#4c1d95">Silero VAD · buscar marcas de voz</text>
    <line x1="320" y1="192" x2="320" y2="216" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="220" width="380" height="40" rx="8" fill="#f1f5f9" stroke="#94a3b8" strokeWidth="1.5"></rect>
    <text x="320" y="245" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fill="#0f172a">segmentos de voz +2 s · muestra de 6</text>
    <line x1="320" y1="260" x2="320" y2="288" stroke="#64748b" strokeWidth="1.5"></line>
    <line x1="180" y1="288" x2="460" y2="288" stroke="#64748b" strokeWidth="1.5"></line>
    <line x1="180" y1="288" x2="180" y2="308" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <line x1="460" y1="288" x2="460" y2="308" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <text x="332" y="282" textAnchor="start" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="12" fill="#64748b">por clip × 6</text>
    <rect x="70" y="312" width="220" height="48" rx="8" fill="#eff6ff" stroke="#3b82f6" strokeWidth="1.5"></rect>
    <text x="180" y="332" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fontWeight="700" fill="#1e3a8a">FFmpeg</text>
    <text x="180" y="349" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="12" fill="#1e3a8a">fondo desenfocado</text>
    <rect x="350" y="312" width="220" height="48" rx="8" fill="#eff6ff" stroke="#3b82f6" strokeWidth="1.5"></rect>
    <text x="460" y="332" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fontWeight="700" fill="#1e3a8a">FFmpeg</text>
    <text x="460" y="349" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="12" fill="#1e3a8a">perspectiva 3D</text>
    <line x1="180" y1="360" x2="180" y2="388" stroke="#64748b" strokeWidth="1.5"></line>
    <line x1="460" y1="360" x2="460" y2="388" stroke="#64748b" strokeWidth="1.5"></line>
    <line x1="180" y1="388" x2="460" y2="388" stroke="#64748b" strokeWidth="1.5"></line>
    <line x1="320" y1="388" x2="320" y2="408" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="412" width="380" height="44" rx="8" fill="#ecfdf5" stroke="#10b981" strokeWidth="1.5"></rect>
    <text x="320" y="439" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fill="#065f46">MoviePy · 3x, deslizamientos, fundidos</text>
    <line x1="320" y1="456" x2="320" y2="480" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="484" width="380" height="44" rx="8" fill="#eff6ff" stroke="#3b82f6" strokeWidth="1.5"></rect>
    <text x="320" y="511" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fill="#1e3a8a">FFmpeg · añadir audio mudo</text>
    <line x1="320" y1="528" x2="320" y2="552" stroke="#64748b" strokeWidth="1.5" markerEnd="url(#arrow)"></line>
    <rect x="130" y="556" width="380" height="40" rx="8" fill="#0f172a" stroke="#0f172a" strokeWidth="1.5"></rect>
    <text x="320" y="581" textAnchor="middle" fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fontSize="13" fontWeight="700" fill="#ffffff">intro_only.mp4</text>
  </svg>
  <figcaption>
    El pipeline de la intro: FFmpeg extrae el audio, Silero VAD encuentra el habla, el selector toma una muestra de seis segmentos, FFmpeg construye el fondo desenfocado y el primer plano deformado en 3D por clip, MoviePy compone y concatena, y una pasada final de FFmpeg añade la pista de audio muda.
  </figcaption>
</figure>

## El script completo

---

Aquí está el script completo. Guárdalo como `intro_generator.py`; la cabecera PEP 723 de arriba le dice a uv exactamente qué dependencias fijadas instalar, así que `uv run` es el único comando que necesitas.

```python
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "openai-whisper",
#     "torch",
#     "torchaudio<2.6",
#     "soundfile",
#     "numpy<2.0.0",
#     "moviepy==1.0.3",
#     "packaging",
#     "Pillow<10.0.0",
# ]
# ///

import os
import sys
import warnings

# 1. Suppress warnings BEFORE importing moviepy
# This hides the "invalid escape sequence" text
warnings.filterwarnings("ignore")

import random
import subprocess
from pathlib import Path

import torch
import whisper
from moviepy.editor import (
    CompositeVideoClip,
    VideoFileClip,
    concatenate_videoclips,
    vfx,
)

# --- Configuration ---
MIN_SILENCE = 0.5
MIN_SPEECH = 0.25
PADDING = 0.1

# Intro Style Settings
INTRO_CLIP_COUNT = 6  # Number of fast cuts
INTRO_SPEED = 3.0  # Speed multiplier (3x fast)
CLIP_DURATION = 1.5  # Duration of each clip in seconds
OUTPUT_FILENAME = "intro_only.mp4"


def check_ffmpeg():
    try:
        subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
    except:
        print("❌ Error: FFmpeg not found. Run: brew install ffmpeg")
        sys.exit(1)


def extract_audio(video_path, audio_path):
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            video_path,
            "-vn",
            "-acodec",
            "pcm_s16le",
            "-ar",
            "16000",
            "-ac",
            "1",
            "-loglevel",
            "error",
            audio_path,
        ],
        check=True,
    )


def get_good_segments(audio_path):
    """Finds segments where people are actually speaking (Key Moments)."""
    print("🧠 Scanning video for key moments...")

    # Load Silero VAD
    # trust_repo=True fixes the "cache" warning/error
    model, utils = torch.hub.load(
        repo_or_dir="snakers4/silero-vad", model="silero_vad", trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )

    segments = []
    for v in vad_stamps:
        segments.append((v["start"] / 16000, v["end"] / 16000))

    return segments


def apply_blur_background(input_path, output_path):
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            input_path,
            "-vf",
            "boxblur=40:5,eq=brightness=-0.4",
            "-c:v",
            "libx264",
            "-preset",
            "ultrafast",
            "-an",
            "-loglevel",
            "error",
            output_path,
        ],
        check=True,
    )


def apply_3d_warp(input_path, output_path, direction="left"):
    if direction == "left":
        vf = "perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination"
    else:
        vf = "perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination"

    vf += ",pad=w=iw+100:h=ih+100:x=50:y=50:color=black@0"

    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            input_path,
            "-vf",
            vf,
            "-c:v",
            "libx264",
            "-preset",
            "ultrafast",
            "-an",
            "-loglevel",
            "error",
            output_path,
        ],
        check=True,
    )


def generate_intro(video_path, segments):
    print("✨ Rendering 3D Intro (No Text)...")

    long_segments = [s for s in segments if (s[1] - s[0]) > 2.0]

    if len(long_segments) < INTRO_CLIP_COUNT:
        print(f"⚠️ Not enough footage found. Need {INTRO_CLIP_COUNT} distinct moments.")
        if not long_segments:
            return
        picks = random.choices(long_segments, k=INTRO_CLIP_COUNT)
    else:
        picks = sorted(random.sample(long_segments, INTRO_CLIP_COUNT))

    intro_clips = []
    temp_files = []

    for i, (start, end) in enumerate(picks):
        raw_dur = CLIP_DURATION * INTRO_SPEED
        mid = start + (end - start) / 2 - (raw_dur / 2)

        raw_clip = f"temp_raw_{i}.mp4"
        bg_clip = f"temp_bg_{i}.mp4"
        fg_clip = f"temp_fg_{i}.mp4"

        # 1. Extract
        subprocess.run(
            [
                "ffmpeg",
                "-y",
                "-ss",
                str(mid),
                "-t",
                str(raw_dur),
                "-i",
                video_path,
                "-c:v",
                "libx264",
                "-an",
                "-loglevel",
                "error",
                raw_clip,
            ],
            check=True,
        )

        # 2. Process
        apply_blur_background(raw_clip, bg_clip)

        direction = "left" if i % 2 == 0 else "right"
        apply_3d_warp(raw_clip, fg_clip, direction)

        # 3. Composite
        try:
            bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
            fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

            if direction == "left":
                fg = fg.set_position(
                    lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), "center")
                )
            else:
                fg = fg.set_position(
                    lambda t: (int(50 - 50 * (t / CLIP_DURATION)), "center")
                )

            comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)
            if i > 0:
                comp = comp.crossfadein(0.2)

            intro_clips.append(comp)
            temp_files.extend([raw_clip, bg_clip, fg_clip])

        except Exception as e:
            print(f"   ⚠️ Error processing clip {i}: {e}")

    if not intro_clips:
        print("❌ Failed to generate intro clips.")
        return

    # Concatenate
    full_montage = concatenate_videoclips(intro_clips, method="compose")

    print("   💾 Saving video file...")
    full_montage.write_videofile(
        "temp_visual_intro.mp4", fps=24, codec="libx264", logger=None
    )

    # Add silent audio
    print("   🔊 Adding silent audio track...")
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            "temp_visual_intro.mp4",
            "-f",
            "lavfi",
            "-i",
            "anullsrc=channel_layout=mono:sample_rate=44100",
            "-c:v",
            "copy",
            "-c:a",
            "aac",
            "-shortest",
            "-loglevel",
            "error",
            OUTPUT_FILENAME,
        ],
        check=True,
    )

    # Cleanup
    for f in temp_files:
        if os.path.exists(f):
            os.remove(f)
    if os.path.exists("temp_visual_intro.mp4"):
        os.remove("temp_visual_intro.mp4")

    print(f"✅ Success! Intro saved as: {OUTPUT_FILENAME}")


def main():
    if len(sys.argv) < 2:
        print("Usage: uv run intro_generator.py <video.mp4>")
        sys.exit(1)

    input_video = sys.argv[1]
    check_ffmpeg()

    temp_wav = "temp_analysis.wav"

    try:
        extract_audio(input_video, temp_wav)

        good_parts = get_good_segments(temp_wav)

        if not good_parts:
            print("⚠️ No speech detected. Picking random segments...")
            duration = float(
                subprocess.check_output(
                    [
                        "ffprobe",
                        "-v",
                        "error",
                        "-show_entries",
                        "format=duration",
                        "-of",
                        "default=noprint_wrappers=1:nokey=1",
                        input_video,
                    ]
                )
            )
            good_parts = [(t, t + 5) for t in range(0, int(duration), 10)]

        generate_intro(input_video, good_parts)

    finally:
        if os.path.exists(temp_wav):
            os.remove(temp_wav)


if __name__ == "__main__":
    main()
```

## Entendiendo el script

---

El script usa metadatos inline de PEP 723 para declarar sus dependencias:

```python
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "openai-whisper",
#     "torch",
#     "torchaudio<2.6",
#     "soundfile",
#     "numpy<2.0.0",
#     "moviepy==1.0.3",
#     "packaging",
#     "Pillow<10.0.0",
# ]
# ///
```

Cuando lo ejecutas, uv crea un entorno aislado, instala exactamente estas versiones y no toca nada de tu Python global. El patrón se cubre con más detalle en [uv run: ejecuta scripts de Python sin gestión de dependencias](/uv-run-scripts-guide/).

### Variables de configuración

| Variable | Por defecto | Descripción |
|----------|---------|-------------|
| `MIN_SILENCE` | 0.5 | Duración mínima de silencio en segundos |
| `MIN_SPEECH` | 0.25 | Duración mínima de voz en segundos |
| `INTRO_CLIP_COUNT` | 6 | Número de clips de la intro |
| `INTRO_SPEED` | 3.0 | Multiplicador de velocidad de los clips |
| `CLIP_DURATION` | 1.5 | Duración de cada clip en segundos |
| `OUTPUT_FILENAME` | "intro_only.mp4" | Nombre del archivo de salida |

Dos de ellas controlan la duración de la intro: el tiempo total siempre es `INTRO_CLIP_COUNT × CLIP_DURATION`, así que los valores por defecto producen un montaje de 9 segundos.

### Detección de actividad de voz (VAD)

La detección de voz es lo que convierte esto en un editor en lugar de un muestreador aleatorio de clips. `get_good_segments()` carga Silero VAD a través de `torch.hub` y le pide todas las ventanas donde alguien está hablando:

```python
def get_good_segments(audio_path):
    """Finds segments where people are actually speaking (Key Moments)."""
    model, utils = torch.hub.load(
        repo_or_dir="snakers4/silero-vad", model="silero_vad", trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )

    return [(v["start"] / 16000, v["end"] / 16000) for v in vad_stamps]
```

Silero VAD es la opción habitual para la detección de actividad de voz en Python: el modelo ocupa unos dos megabytes, funciona en CPU y corre completamente offline tras la descarga inicial. No necesitas clave de API y no hay coste por minuto. Devuelve posiciones de inicio y fin en muestras a 16 kHz, que la función convierte a segundos. El argumento `threshold=0.5` es el dial de sensibilidad: bájalo si el habla baja se escapa, súbelo si el ruido de fondo produce falsos positivos.

### Efectos de perspectiva 3D con FFmpeg

El aspecto 3D viene del filtro `perspective` de FFmpeg, aplicado a cada clip con direcciones alternas:

```python
def apply_3d_warp(input_path, output_path, direction="left"):
    if direction == "left":
        vf = "perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination"
    else:
        vf = "perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination"
```

`sense=destination` le dice a FFmpeg que mapee los cuatro puntos de esquina de salida sobre el origen. Eso comprime el fotograma en un trapecio en vez de estirarlo: un borde se inclina hacia dentro mientras el opuesto queda fijo. El filtro `pad` añadido tras la cadena de perspectiva agrega 50 píxeles de margen transparente en cada lado, para que el clip deformado pueda deslizarse sobre el fondo desenfocado después sin bordes duros.

### Composición de vídeo con MoviePy

MoviePy apila cada clip como un fondo desenfocado con un primer plano deformado deslizándose por encima:

```python
bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

if direction == "left":
    fg = fg.set_position(
        lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), "center")
    )

comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)
```

Ambas capas se aceleran 3x con `vfx.speedx`, el primer plano se mueve 50 píxeles hacia el centro durante los 1,5 segundos del clip y `CompositeVideoClip` aplana la pila. Cada clip terminado recibe un `crossfadein` de 0,2 segundos, y luego `concatenate_videoclips` apila los seis en el montaje.

### Por qué MoviePy está fijado a 1.0.3

MoviePy 2.0 se lanzó a finales de 2024 con una reescritura incompatible: el módulo `moviepy.editor` ya no existe, `set_position` pasó a ser `with_position` y el encadenamiento de `.fx()` cambió. Este script importa directamente de `moviepy.editor`, así que cualquier instalación 2.x falla en el import.

Ese pin es lo que mantiene el script reproducible. uv lee la cabecera, instala MoviePy 1.0.3 en un entorno limitado a este archivo y nada más en tu máquina cambia. Migrar a la API 2.x es un proyecto razonable (el estilo `with_position` es más limpio), pero es una reescritura del código de composición, no un cambio de versión. Si el error de import aparece de todos modos, la solución está en la sección de solución de problemas más abajo.

## Ejecutar el script

---

Con uv y FFmpeg instalados:

```bash
uv run intro_generator.py your_video.mp4
```

La primera ejecución es más lenta: uv resuelve e instala las dependencias fijadas (PyTorch es la grande) y `torch.hub` descarga el modelo Silero VAD una vez. Después todo queda en caché y las ejecuciones posteriores solo pagan el render.

Como todo es uv más FFmpeg, el script también viaja bien: si quieres un entorno reproducible en un servidor en vez de tu Mac, mira cómo [ejecutar cualquier app Python en Docker](/docker-run-python/).

### Salida esperada

```
🧠 Scanning video for key moments...
✨ Rendering 3D Intro (No Text)...
   💾 Saving video file...
   🔊 Adding silent audio track...
✅ Success! Intro saved as: intro_only.mp4
```

### Verificar el resultado

Tres comprobaciones antes de darlo por terminado.

La duración debería acercarse a los 9 segundos con los valores por defecto (6 clips × 1,5 s):

```bash
ffprobe -v error -show_entries format=duration \
  -of default=noprint_wrappers=1:nokey=1 intro_only.mp4
```

El stream de audio debe existir y ser `aac`, que es la pista muda que el script multiplexa:

```bash
ffprobe -v error -select_streams a -show_entries stream=codec_name \
  -of default=noprint_wrappers=1:nokey=1 intro_only.mp4
```

Salida esperada: `aac`. Si no imprime nada, el paso del audio mudo falló y los editores se quejarán de una pista ausente.

Por último, la comprobación visual: `open intro_only.mp4`. Deberías ver seis cortes rápidos alternando deformaciones 3D a izquierda y derecha sobre un fondo desenfocado y oscurecido, con fundidos rápidos entre clips. Cuando falla: una duración lejos de 9 segundos significa que algunos clips dieron error durante el procesado (busca las líneas con ⚠️ en el terminal), y una imagen plana sin deformar significa que la cadena del filtro de perspectiva se rompió en una edición.

## Personalizar el resultado

---

Edita el bloque de configuración al inicio del script para cambiar el estilo.

### Cambiar el número de clips

```python
INTRO_CLIP_COUNT = 8  # More clips
```

### Ajustar la velocidad

```python
INTRO_SPEED = 2.0  # Slower
INTRO_SPEED = 4.0  # Faster
```

### Cambiar la duración de los clips

```python
CLIP_DURATION = 2.0  # Longer clips
CLIP_DURATION = 1.0  # Shorter clips
```

### Modificar la intensidad del desenfoque

Edita la cadena de filtro en `apply_blur_background()`:

```python
# Stronger blur
"-vf", "boxblur=60:10,eq=brightness=-0.5"

# Lighter blur
"-vf", "boxblur=20:3,eq=brightness=-0.2"
```

### Preajustes de estilo

Tres puntos de partida si no quieres ajustar cuatro mandos desde cero. Cada uno cambia el bloque de configuración, y los preajustes que mencionan desenfoque también tocan la cadena de filtro en `apply_blur_background()`.

<Tabs>
<Tab name="Por defecto (rápida)">

```python
INTRO_CLIP_COUNT = 6
INTRO_SPEED = 3.0
CLIP_DURATION = 1.5
```

```python
# in apply_blur_background()
"boxblur=40:5,eq=brightness=-0.4"
```

Total: 6 × 1.5 = 9 segundos. El comportamiento original.

</Tab>
<Tab name="Relajada">

```python
INTRO_CLIP_COUNT = 4
INTRO_SPEED = 2.0
CLIP_DURATION = 2.0
```

```python
# in apply_blur_background()
"boxblur=20:3,eq=brightness=-0.2"
```

Total: 8 segundos. Cortes más lentos, fondo más limpio, más fácil de seguir.

</Tab>
<Tab name="Cinematográfica">

```python
INTRO_CLIP_COUNT = 5
INTRO_SPEED = 1.5
CLIP_DURATION = 2.5
```

```python
# in apply_blur_background()
"boxblur=40:5,eq=brightness=-0.5"
```

Total: 12.5 segundos. Movimiento más lento, fondo más oscuro, más espacio para un título superpuesto.

</Tab>
</Tabs>

Son puntos de partida, no datos de calibración. El montaje se renderiza con pista de audio muda a propósito, así que superpón música o una voz en off en tu editor.

## Dependencias explicadas

---

| Paquete | Propósito |
|---------|---------|
| **openai-whisper** | Kit de reconocimiento de voz; se importa arriba pero nunca se llama. Silero VAD hace la detección, así que puedes quitar esta línea y el import para una instalación más ligera |
| **torch** | PyTorch para ejecutar el modelo VAD |
| **torchaudio** | Procesamiento de audio con PyTorch; proporciona el helper `read_audio` que usa Silero VAD |
| **soundfile** | Backend de decodificación de audio que torchaudio puede usar al cargar el WAV |
| **numpy** | Operaciones numéricas |
| **moviepy** | Edición y composición de vídeo |
| **Pillow** | Procesamiento de imágenes (requerido por MoviePy) |
| **packaging** | Utilidades de gestión de versiones |

<Notice type="warning" title="Restricciones de versión">

Los pins de la cabecera PEP 723 son estructurales, no decoración:

- `moviepy==1.0.3`: MoviePy 2.x eliminó el módulo `moviepy.editor`, así que el import falla en cualquier instalación 2.x. Mira «Por qué MoviePy está fijado a 1.0.3» más arriba.
- `torchaudio<2.6`: evita problemas de emparejamiento torch/torchaudio en Apple Silicon. La sección de solución de problemas de abajo cubre instalaciones lentas o fallidas.
- `Pillow<10.0.0`: MoviePy 1.0.3 aún referencia `Image.ANTIALIAS`, que Pillow 10 eliminó.
- `numpy<2.0.0`: mantiene el stack de audio en la ABI de numpy 1.x que esperan esas builds de torch.

</Notice>

## Solución de problemas

---

<Accordion label="Error: FFmpeg no encontrado" group="faq">

Instala FFmpeg con Homebrew:

```bash
brew install ffmpeg
```

Comprueba la instalación:

```bash
ffmpeg -version
```

</Accordion>

<Accordion label="Advertencia: no hay metraje suficiente" group="faq">

Esto ocurre cuando el vídeo no tiene suficientes segmentos de habla de más de 2 segundos. El script reutiliza segmentos en ese caso. Prueba a:

- Usar un vídeo fuente más largo
- Reducir `INTRO_CLIP_COUNT`
- Bajar la duración mínima de segmento en el código

</Accordion>

<Accordion label="Errores de memoria con vídeos grandes" group="faq">

Para vídeos grandes, prueba a:

- Procesar clips más cortos
- Reducir la resolución del vídeo fuente
- Cerrar otras aplicaciones

</Accordion>

<Accordion label="La primera ejecución es lenta" group="faq">

La primera ejecución descarga el modelo Silero VAD y las dependencias de Python. Las ejecuciones posteriores son más rápidas porque `uv` y PyTorch cachean estos archivos.

</Accordion>

<Accordion label="ImportError: cannot import name 'editor' from 'moviepy'" group="faq">

Estás ejecutando MoviePy 2.x. Esto ocurre cuando el script se ejecuta con un intérprete distinto de `uv run` (un moviepy 2.x instalado con pip ensombrece al fijado) o cuando la cabecera PEP 723 perdió el pin `moviepy==1.0.3` en una edición.

Soluciones:

- Ejecuta el script exactamente como se documenta: `uv run intro_generator.py your_video.mp4`
- Confirma que la cabecera aún contiene `moviepy==1.0.3`
- Si migraste a 2.x a propósito, actualiza la API: `moviepy.editor` ya no existe (importa los clips desde `moviepy` directamente), `set_position` pasó a `with_position` y la sintaxis `.fx(vfx.speedx, ...)` cambió

</Accordion>

<Accordion label="La instalación de torch o torchaudio falla o va lenta en Apple Silicon" group="faq">

Ve siguiendo estos pasos en orden:

- Confirma que el pin `torchaudio<2.6` sigue en la cabecera. Un torchaudio sin pin puede traer una build que no coincide con tu versión de torch.
- Limpia la caché y reintenta: `uv cache clean`, y luego repite el mismo comando `uv run`. Una descarga a medias es la causa más común.
- Comprueba que uv eligió un Python arm64 nativo: `uv python list`. Un intérprete x86_64 corriendo bajo Rosetta produce instalaciones lentas y fallos aleatorios de wheels.

</Accordion>

<Accordion label="No se detecta voz en metraje solo con música o B-roll" group="faq">

Comportamiento esperado, no un bug: Silero VAD escucha voces, así que el metraje solo con música no produce marcas de tiempo y el script recurre a segmentos aleatorios espaciados cada 10 segundos.

Si quieres mejores elecciones en metraje silencioso o mixto:

- Baja el `threshold` del VAD de 0.5 a unos 0.4 en `get_good_segments()`. Más sensible, pero más falsos positivos.
- Baja el filtro de segmento mínimo de 2.0 segundos en `generate_intro()` para que califiquen ráfagas de voz más cortas.
- Dale metraje de gente hablando. Para eso está hecho el detector.

</Accordion>

## Preguntas frecuentes

---

<Accordion label="¿Silero VAD necesita clave de API o acceso a internet?" group="faq">

No. `torch.hub` descarga el modelo una vez en la primera ejecución; después, la detección de actividad de voz con Silero VAD corre completamente offline en tu CPU. No necesitas cuenta ni clave de API, y no hay facturación por minuto. El modelo ocupa unos dos megabytes, que es también por qué el arranque es rápido.

</Accordion>

<Accordion label="¿Funciona este editor de intros de vídeo en Python en Linux o Windows?" group="faq">

El script es Python puro, FFmpeg y paquetes pip, todo multiplataforma. Solo cambia el comando de instalación de FFmpeg: `apt install ffmpeg` en Debian/Ubuntu, `winget install ffmpeg` en Windows. Advertencia honesta: este tutorial está escrito en un Mac y no lo he vuelto a ejecutar en otros sistemas, así que trata Linux y Windows como «debería funcionar» hasta que lo ejecutes una vez tú mismo.

</Accordion>

<Accordion label="¿Por qué la intro renderizada es muda?" group="faq">

Por diseño. El script elimina el audio fuente y multiplexa una pista AAC muda para que el montaje encaje limpiamente bajo música o narración en tu editor sin pelear con el sonido original. Si quieres audio en el archivo, añádelo en postproducción o genera primero una voz en off.

</Accordion>

<Accordion label="¿Cuánto dura la intro?" group="faq">

`INTRO_CLIP_COUNT × CLIP_DURATION`. Los valores por defecto dan 6 × 1.5 = 9 segundos. Los preajustes de estilo de arriba producen 8 segundos (Relajada) y 12,5 segundos (Cinematográfica). Para una intro de YouTube, quedarse por debajo de 10 segundos es una buena regla; más que eso y los espectadores empiezan a saltársela.

</Accordion>

## Conclusión

---

Ese es todo el pipeline: un editor de intros de vídeo en Python donde Silero VAD encuentra los momentos hablados, FFmpeg hace el trabajo de desenfoque y perspectiva 3D, MoviePy compone los cortes y uv fija cada dependencia al propio archivo del script. Apúntalo a cualquier vídeo en bruto con gente hablando y obtienes un montaje de nueve segundos listo para colocar bajo una base musical.

Formas de llevarlo más lejos:

- La intro se renderiza muda a propósito, así que superpón música en tu editor, [genera una voz en off con un script Python de texto a voz](/uv-text-to-speech-script/), o usa una opción alojada con clonación de voz como [Fish Audio](https://go.bitdoze.com/fish-audio) para una voz de narrador consistente sin ejecutar modelos en local.
- La CLI acepta un argumento, lo que la convierte en buena candidata para [envolver este script en una UI de escritorio con NiceGUI](/es/empezar-nicegui/): un selector de archivos, un botón de renderizado, una barra de progreso.
- El montaje solo puede lucir tan bien como su entrada. [Screen.Studio](https://go.bitdoze.com/screen-studio) automatiza zooms y ediciones para grabaciones de pantalla en macOS; la [reseña completa de Screen.Studio](/screen-studio-review/) cubre la versión larga.
- ¿Sin metraje que cortar? [Las APIs de generación de vídeo con IA como Veo y Kling](/es/kie-ai-generacion-video/) pueden producir b-roll desde un prompt, y [Kie.ai](https://go.bitdoze.com/kie-ai) pone esos modelos detrás de una única API.
- Cuando el vídeo esté listo, [aloja la intro terminada y los vídeos con Bunny Stream](/es/guia-bunny-stream/) en lugar de pelear con los embeds de las plataformas, y acompaña la intro con arte de miniatura de la guía sobre cómo [generar imágenes con IA en local en Mac](/ai-images-mac/).