Automatiza lo repetitivo, cuando toque
Todo el curso se puede hacer por navegador. Estos scripts son para cuando ya publicas cada semana y quieres ordenar, generar, montar y validar con menos fricción.
Comprueba Python, FFmpeg, el acceso a tu generador (clave de API o pesos locales), la VRAM si generas en local y la estructura de carpetas. Si algo falla te dice exactamente qué.
#!/usr/bin/env python3
"""verificar_entorno.py - comprobacion previa del curso.
Agnostico de generador: sirve para SeedAnce 2.0 (recomendado, en la nube) o
para cualquier modelo local con modo de dos fotogramas.
"""
import os, shutil, subprocess, sys
CARPETAS = ["prompts", "frames", "clips", "audio", "export", "scripts", "luts"]
MODO = os.environ.get("GEN_MODO", "nube") # "nube" o "local"
MODELOS = os.environ.get("GEN_MODELOS", "models/generador")
API_KEY = os.environ.get("GEN_API_KEY", "")
OK, FAIL, WARN = "[OK]", "[FALLA]", "[AVISO]"
errores = []
def python_ok():
v = sys.version_info
if (v.major, v.minor) >= (3, 10):
print(f"{OK} Python {v.major}.{v.minor}.{v.micro}")
else:
errores.append("Se necesita Python 3.10 o superior")
print(f"{FAIL} Python {v.major}.{v.minor} demasiado antiguo")
def ffmpeg_ok():
ruta = shutil.which("ffmpeg")
if not ruta:
errores.append("FFmpeg no está en el PATH")
return print(f"{FAIL} FFmpeg no encontrado")
out = subprocess.run([ruta, "-version"], capture_output=True, text=True)
print(f"{OK} {out.stdout.splitlines()[0][:60]}")
def gpu_ok():
try:
import torch
if torch.cuda.is_available():
g = torch.cuda.get_device_properties(0)
vram = g.total_memory / 1024**3
print(f"{OK} GPU {g.name} - {vram:.1f} GB VRAM")
if vram < 12:
print(f"{WARN} Con menos de 12 GB usa el modelo 480p y --offload")
else:
errores.append("PyTorch no ve la GPU (CUDA no disponible)")
print(f"{FAIL} CUDA no disponible")
except ImportError:
errores.append("PyTorch no instalado")
print(f"{FAIL} PyTorch no instalado")
def modelos_ok():
if os.path.isdir(MODELOS) and os.listdir(MODELOS):
print(f"{OK} modelos en {MODELOS}")
else:
errores.append(f"No encuentro los pesos del modelo en {MODELOS}")
print(f"{FAIL} modelos ausentes en {MODELOS}")
def carpetas_ok():
for c in CARPETAS:
os.makedirs(c, exist_ok=True)
print(f"{OK} carpeta ./{c}")
if __name__ == "__main__":
print("== Verificacion del entorno ==")
python_ok(); ffmpeg_ok(); gpu_ok(); modelos_ok(); carpetas_ok()
print()
if errores:
print("Corrige antes de avanzar:")
for e in errores: print(" -", e)
sys.exit(1)
print("entorno listo")El adaptador que conecta el pipeline con tu generador. Traduce siempre los mismos argumentos (--first, --last, --prompt) a la llamada concreta de tu servicio. Cambiar de generador es reescribir solo este archivo.
#!/usr/bin/env python3
"""adaptadores/seedance_video.py - conecta el pipeline con SeedAnce 2.0.
El pipeline SIEMPRE llama con los mismos argumentos:
--first ruta.png --last ruta.png --prompt "..." --negative "..."
--frames 120 --fps 24 --size 1280*720 --out clips/01.mp4
Este archivo es el único que sabe cómo es tu generador. Para cambiar de
servicio, copia este archivo y reescribe pedir() y descargar(): el resto
del curso sigue funcionando sin tocar nada.
"""
import argparse, base64, json, os, time, urllib.request
API = os.environ.get("GEN_API_URL", "https://api.seedance.ai/v2/generate")
KEY = os.environ.get("GEN_API_KEY", "")
def b64(ruta):
with open(ruta, "rb") as fh:
return base64.b64encode(fh.read()).decode()
def pedir(a):
cuerpo = {
"mode": "first_last_frame", # el modo imprescindible del curso
"first_frame": b64(a.first),
"last_frame": b64(a.last),
"prompt": a.prompt,
"negative_prompt": a.negative,
"duration": round(a.frames / a.fps, 2),
"fps": a.fps,
"resolution": a.size.replace("*", "x"),
"motion_intensity": a.motion,
}
req = urllib.request.Request(
API, data=json.dumps(cuerpo).encode(),
headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=900) as r:
return json.load(r)
def esperar(datos):
"""Si el servicio devuelve un id en vez de la URL, sondea hasta que acabe."""
if datos.get("video_url"):
return datos["video_url"]
tarea = datos["id"]
for _ in range(180):
time.sleep(5)
req = urllib.request.Request(API + "/" + tarea,
headers={"Authorization": "Bearer " + KEY})
with urllib.request.urlopen(req, timeout=60) as r:
d = json.load(r)
if d.get("status") == "succeeded":
return d["video_url"]
if d.get("status") == "failed":
raise RuntimeError("generación fallida: " + str(d.get("error")))
raise TimeoutError("el servicio no respondio a tiempo")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--first", required=True)
ap.add_argument("--last", required=True)
ap.add_argument("--prompt", required=True)
ap.add_argument("--negative", default="")
ap.add_argument("--frames", type=int, default=120)
ap.add_argument("--fps", type=int, default=24)
ap.add_argument("--size", default="1280*720")
ap.add_argument("--motion", type=float, default=0.55)
ap.add_argument("--steps", type=int, default=40) # ignorado en la nube
ap.add_argument("--cfg", type=float, default=5.5) # ignorado en la nube
ap.add_argument("--offload_model", action="store_true")
ap.add_argument("--out", required=True)
a = ap.parse_args()
if not KEY:
raise SystemExit("Falta la variable de entorno GEN_API_KEY")
url = esperar(pedir(a))
urllib.request.urlretrieve(url, a.out)
print("guardado ->", a.out)
if __name__ == "__main__":
main()El documento que gobierna todo el video: 12 escenas con función, prompt de imagen inicial, imagen final y prompt de movimiento. Todos los scripts leen de aquí.
{
"proyecto": "batalla_01",
"fps": 24,
"duracion_escena": 5,
"estilo": "hyper-realistic 3D animation, dark epic fantasy style, modern anime cinematics, volumetric haze, sharp detail, dramatic contrast",
"personajes": {
"heroe": "anime-style warrior, lean build, sharp black spiky hair, glowing vibrant purple eyes, flowing black coat with a purple sigil on the back, twin glowing purple swords, purple energy aura",
"villano": "menacing bug-like monster, dark heavily armored insectoid body, burning red glowing eyes, fanged gaping mouth, large translucent red-veined wings, red energy pulsing through cracks in its armor"
},
"escenario": "vast dark cavernous arena, sharp glowing crystalline formations, jagged rock floor, faint celestial light beam from a ceiling opening, floating purple crystal shards",
"escenas": [
{
"n": 1,
"funcion": "apertura",
"start_prompt": "Extreme close-up of the warrior's eyes glowing vibrant purple, sharp black spiky hair, angry determined expression, dark shadowy background, cinematic lighting",
"end_prompt": "The warrior mid-lunge wielding a glowing purple sword, about to clash with the bug-like monster glowing red, dark crystal-filled cavern lit by opposing energies",
"clip_prompt": "extreme close-up on purple glowing eyes, cut to red glowing eyes, dynamic tracking shot as both fighters dash towards each other leaving purple and red energy trails, wide standoff, the warrior lunges and clashes",
"transicion_entrada": null
},
{
"n": 3,
"funcion": "escalada",
"start_prompt": "Comic-book style explosion in stark black and white, sharp jagged radial lines from a central blinding white burst",
"end_prompt": "The bug-like monster rising from a swirling cloud of dark dust, red glowing eyes, jagged rock shards and purple crystals suspended around it",
"clip_prompt": "black and white comic burst, momentary darkness, overhead tilted view as the warrior unleashes sweeping purple energy, the monster engulfed in dust then rising",
"transicion_entrada": "comic_burst"
}
],
"_nota": "Las 12 escenas completas estan en la seccion Prompts de ejemplo. El end_prompt de la escena N debe coincidir con el start_prompt de la N+1."
}Genera los 13 fotogramas clave del video y encadena automáticamente: el End Frame de cada escena se copia como Start Frame de la siguiente. Es el paso que da continuidad de personaje.
#!/usr/bin/env python3
"""generar_keyframes.py - crea los fotogramas clave encadenados.
Regla del curso: el END frame de la escena N ES el START frame de la N+1.
Solo se generan 13 imágenes para 12 escenas, no 24.
"""
import argparse, json, os, shutil, subprocess
SALIDA = "frames"
# ---- TU GENERADOR DE IMAGEN --------------------------------------------------
# Cambia solo esta línea. Ejemplos:
# SeedAnce 2.0 (recomendado): ["python", "adaptadores/seedance_imagen.py"]
# Servicio propio por CLI: ["mi-cli", "imagen"]
# Modelo local: ["python", "-m", "mi_modelo.t2i"]
CMD_IMAGEN = ["python", "adaptadores/seedance_imagen.py"]
# -----------------------------------------------------------------------------
NEGATIVO = ("blurry, deformed hands, extra fingers, extra limbs, mutated face, "
"watermark, text, logo, low contrast, flat lighting, washed out colors")
def prompt_completo(sb, texto):
"""Anade personajes, escenario y estilo al prompt de imagen."""
partes = [texto, sb["escenario"], sb["estilo"]]
return ", ".join(p for p in partes if p)
def generar(prompt, destino, semilla, ancho=1280, alto=720):
"""Llama al generador de imagen. Ajusta el comando a tu instalacion."""
cmd = [*CMD_IMAGEN,
"--prompt", prompt,
"--negative", NEGATIVO,
"--width", str(ancho), "--height", str(alto),
"--seed", str(semilla),
"--steps", "30", "--cfg", "6.5",
"--out", destino]
subprocess.run(cmd, check=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--storyboard", default="prompts/storyboard.json")
ap.add_argument("--semilla", type=int, default=118420)
ap.add_argument("--real", action="store_true", help="sin esto solo simula")
ap.add_argument("--solo", help="escenas concretas, ej: 03,07")
args = ap.parse_args()
sb = json.load(open(args.storyboard, encoding="utf-8"))
os.makedirs(SALIDA, exist_ok=True)
escenas = sorted(sb["escenas"], key=lambda e: e["n"])
filtro = [int(x) for x in args.solo.split(",")] if args.solo else None
for i, esc in enumerate(escenas):
n = str(esc["n"]).zfill(2)
start = os.path.join(SALIDA, f"{n}_start.png")
end = os.path.join(SALIDA, f"{n}_end.png")
if filtro and esc["n"] not in filtro:
continue
# 1. START: heredado del END anterior, o generado si es la escena 1
anterior = os.path.join(SALIDA, f"{str(escenas[i-1]['n']).zfill(2)}_end.png")
if i > 0 and os.path.exists(anterior):
print(f"[{n}] start <- hereda {os.path.basename(anterior)}")
if args.real:
shutil.copy(anterior, start)
else:
p = prompt_completo(sb, esc["start_prompt"])
print(f"[{n}] start genera: {p[:90]}...")
if args.real:
generar(p, start, args.semilla + esc["n"])
# 2. END: siempre se genera, y será el start de la siguiente
p = prompt_completo(sb, esc["end_prompt"])
print(f"[{n}] end genera: {p[:90]}...")
if args.real:
generar(p, end, args.semilla + 100 + esc["n"])
if not args.real:
print("\nSimulacion completa. Repite con --real para generar de verdad.")
else:
print("\nRevisa ./frames antes de pasar a generar_clips.py:")
print("si un personaje muta en un END frame, corrigelo AHORA.")
if __name__ == "__main__":
main()Genera los 12 clips en modo de dos fotogramas: cada clip interpola entre su Start y su End Frame siguiendo el prompt de movimiento. El comando del generador se declara arriba: SeedAnce 2.0, otro servicio o un modelo local.
#!/usr/bin/env python3
"""generar_clips.py - 12 clips en modo de dos fotogramas.
Cada clip recibe: imagen inicial + imagen final + prompt de movimiento.
Esa es la diferencia con texto-a-video: el personaje no puede mutar porque
los dos extremos del clip ya estan fijados como imagen.
"""
import argparse, json, os, subprocess, time
FRAMES, SALIDA = "frames", "clips"
# ---- TU GENERADOR DE VIDEO --------------------------------------------------
# Requisito único: aceptar imagen inicial + imagen final + prompt de movimiento.
# SeedAnce 2.0 (recomendado): ["python", "adaptadores/seedance_video.py"]
# Otro servicio por CLI: ["mi-cli", "flf2v"]
# Modelo local: ["python", "-m", "mi_modelo.flf2v"]
CMD_VIDEO = ["python", "adaptadores/seedance_video.py"]
# -----------------------------------------------------------------------------
NEGATIVO = ("blurry, deformed limbs, mutated face, flickering, "
"warping geometry, watermark, text, low contrast")
def generar(inicio, fin, prompt, destino, cfg):
cmd = [*CMD_VIDEO, # modo de dos fotogramas
"--first", inicio, "--last", fin,
"--prompt", prompt,
"--negative", NEGATIVO,
"--frames", str(cfg["fps"] * cfg["duracion_escena"]),
"--fps", str(cfg["fps"]),
"--size", "1280*720",
"--steps", "40", "--cfg", "5.5",
"--out", destino]
if cfg.get("offload"):
cmd.append("--offload_model") # GPUs con poca VRAM
subprocess.run(cmd, check=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--storyboard", default="prompts/storyboard.json")
ap.add_argument("--real", action="store_true")
ap.add_argument("--solo", help="escenas concretas, ej: 04,09")
ap.add_argument("--offload", action="store_true", help="para <12 GB VRAM")
args = ap.parse_args()
sb = json.load(open(args.storyboard, encoding="utf-8"))
cfg = {"fps": sb.get("fps", 24),
"duracion_escena": sb.get("duracion_escena", 5),
"offload": args.offload}
os.makedirs(SALIDA, exist_ok=True)
filtro = [int(x) for x in args.solo.split(",")] if args.solo else None
for esc in sorted(sb["escenas"], key=lambda e: e["n"]):
n = str(esc["n"]).zfill(2)
if filtro and esc["n"] not in filtro:
continue
inicio = os.path.join(FRAMES, f"{n}_start.png")
fin = os.path.join(FRAMES, f"{n}_end.png")
if not (os.path.exists(inicio) and os.path.exists(fin)):
print(f"[{n}] FALTAN frames, ejecuta generar_keyframes.py primero")
continue
destino = os.path.join(SALIDA, f"{n}_{esc['funcion']}.mp4")
prompt = ", ".join([esc["clip_prompt"], sb["estilo"]])
print(f"[{n}] {esc['funcion']:12} {prompt[:80]}...")
if not args.real:
print(f" (simulacion) {inicio} + {fin} -> {destino}")
continue
t0 = time.time()
try:
generar(inicio, fin, prompt, destino, cfg)
print(f" listo en {time.time()-t0:.0f}s -> {destino}")
except subprocess.CalledProcessError as e:
print(f" ERROR de generación: {e}")
if not args.real:
print("\nSimulacion completa. Repite con --real.")
if __name__ == "__main__":
main()Genera las transiciones de cómic en blanco y negro (líneas radiales) que separan los bloques de acción, y las inserta entre los clips indicados.
#!/usr/bin/env python3
"""comic_burst.py - transición de cómic en blanco y negro entre escenas.
Crea 8 fotogramas de líneas radiales con PIL y los convierte en un clip de
~0.33 s que se inserta entre dos escenas. Es la transición del estilo de
referencia: cubre el corte y sube la energía sin usar disolvencias.
"""
import argparse, math, os, random, subprocess
from PIL import Image, ImageDraw
def fotograma(ancho, alto, n_lineas, semilla, invertido=False):
fondo, tinta = ("black", "white") if invertido else ("white", "black")
img = Image.new("RGB", (ancho, alto), fondo)
d = ImageDraw.Draw(img)
cx, cy = ancho / 2, alto / 2
rnd = random.Random(semilla)
for i in range(n_lineas):
a = (i / n_lineas) * 2 * math.pi + rnd.uniform(-0.03, 0.03)
grosor = rnd.uniform(0.006, 0.028) * 2 * math.pi
r = max(ancho, alto)
p = [(cx + math.cos(a - grosor) * r * 0.12, cy + math.sin(a - grosor) * r * 0.12),
(cx + math.cos(a - grosor) * r, cy + math.sin(a - grosor) * r),
(cx + math.cos(a + grosor) * r, cy + math.sin(a + grosor) * r),
(cx + math.cos(a + grosor) * r * 0.12, cy + math.sin(a + grosor) * r * 0.12)]
d.polygon(p, fill=tinta)
d.ellipse([cx - ancho * 0.05, cy - ancho * 0.05, cx + ancho * 0.05, cy + ancho * 0.05],
fill=fondo)
return img
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--salida", default="clips/transicion_burst.mp4")
ap.add_argument("--ancho", type=int, default=1280)
ap.add_argument("--alto", type=int, default=720)
ap.add_argument("--fps", type=int, default=24)
ap.add_argument("--fotogramas", type=int, default=8)
args = ap.parse_args()
tmp = "frames/_burst"
os.makedirs(tmp, exist_ok=True)
for i in range(args.fotogramas):
# las líneas se multiplican y el último fotograma invierte a negro
img = fotograma(args.ancho, args.alto, 14 + i * 6, 100 + i,
invertido=(i >= args.fotogramas - 2))
img.save(f"{tmp}/{i:03d}.png")
subprocess.run(["ffmpeg", "-y", "-framerate", str(args.fps),
"-i", f"{tmp}/%03d.png", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-crf", "14", args.salida], check=True)
print("transición lista ->", args.salida)
print("insértala entre escenas con montar.py --transiciones 2,5,10")
if __name__ == "__main__":
main()Pipeline de post: normaliza, inserta las transiciones de cómic, concatena, aplica el LUT púrpura/rojo, pega la música y exporta master 16:9 y vertical 9:16.
#!/usr/bin/env python3
"""montar.py - de 12 clips a dos archivos publicables."""
import argparse, glob, logging, os, subprocess, sys
def ffmpeg(*args):
subprocess.run(["ffmpeg", "-y", *args], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def normalizar(clips, fps, destino="clips/_norm"):
os.makedirs(destino, exist_ok=True)
salida = []
for c in clips:
out = os.path.join(destino, os.path.basename(c))
ffmpeg("-i", c, "-vf", f"scale=1920:1080,fps={fps}",
"-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p", "-an", out)
salida.append(out)
return salida
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--clips", default="clips")
ap.add_argument("--audio", default="audio/track.mp3")
ap.add_argument("--lut", default="luts/purple_red.cube")
ap.add_argument("--nombre", default="batalla_01")
ap.add_argument("--fps", type=int, default=24)
ap.add_argument("--transiciones", default="", help="insertar burst tras estas escenas, ej: 2,5,8,10")
ap.add_argument("--burst", default="clips/transicion_burst.mp4")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s",
handlers=[logging.FileHandler("pipeline.log"),
logging.StreamHandler(sys.stdout)])
clips = sorted(glob.glob(os.path.join(args.clips, "[0-9][0-9]_*.mp4")))
if not clips:
sys.exit("No hay clips numerados en " + args.clips)
logging.info("%d clips encontrados", len(clips))
clips = normalizar(clips, args.fps)
tras = {int(x) for x in args.transiciones.split(",") if x.strip()}
orden = []
for i, c in enumerate(clips, start=1):
orden.append(c)
if i in tras and os.path.exists(args.burst):
orden.append(args.burst)
logging.info("transición insertada tras la escena %d", i)
os.makedirs("export", exist_ok=True)
lista = "export/lista.txt"
with open(lista, "w", encoding="utf-8") as fh:
for c in orden:
fh.write("file '%s'\n" % os.path.abspath(c))
unido = "export/unido.mp4"
ffmpeg("-f", "concat", "-safe", "0", "-i", lista, "-c", "copy", unido)
graduado = unido
if os.path.exists(args.lut):
graduado = "export/graduado.mp4"
ffmpeg("-i", unido, "-vf", "lut3d=" + args.lut,
"-c:v", "libx264", "-crf", "16", "-preset", "slow", graduado)
logging.info("LUT aplicado")
master = f"export/{args.nombre}_16x9.mp4"
if os.path.exists(args.audio):
ffmpeg("-i", graduado, "-i", args.audio, "-map", "0:v", "-map", "1:a",
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-af", "loudnorm=I=-14:TP=-1.5", master)
else:
os.replace(graduado, master)
vertical = f"export/{args.nombre}_9x16.mp4"
ffmpeg("-i", master, "-vf",
"crop=ih*9/16:ih:(iw-ih*9/16)/2:0,scale=1080:1920",
"-c:v", "libx264", "-crf", "18", "-c:a", "copy", vertical)
logging.info("listo: %s y %s", master, vertical)
if __name__ == "__main__":
main()Analiza la pista y devuelve los tiempos de los golpes para colocar los cortes y las transiciones de cómic exactamente encima.
#!/usr/bin/env python3
"""detectar_golpes.py - tiempos de los golpes de la música."""
import argparse, math, struct, subprocess
def pcm_mono(ruta, sr=22050):
out = subprocess.run(["ffmpeg", "-v", "quiet", "-i", ruta, "-ac", "1",
"-ar", str(sr), "-f", "s16le", "-"],
capture_output=True, check=True).stdout
n = len(out) // 2
return struct.unpack("<%dh" % n, out[:n * 2]), sr
def golpes(muestras, sr, ventana=0.02, sensibilidad=1.7):
paso = int(sr * ventana)
energia = [math.sqrt(sum(x * x for x in muestras[i:i + paso]) / paso)
for i in range(0, len(muestras) - paso, paso)]
media, picos = 20, []
for i in range(media, len(energia) - 1):
local = sum(energia[i - media:i]) / media
if energia[i] > local * sensibilidad and energia[i] >= energia[i + 1]:
t = i * ventana
if not picos or t - picos[-1] > 0.25:
picos.append(t)
return picos
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("audio")
ap.add_argument("--sensibilidad", type=float, default=1.7)
a = ap.parse_args()
m, sr = pcm_mono(a.audio)
p = golpes(m, sr, sensibilidad=a.sensibilidad)
print(f"{len(p)} golpes detectados\n")
for t in p:
print(f"{int(t//60):02d}:{t%60:06.3f}")
print("\nColoca los cortes EXACTAMENTE en estos tiempos.")
print("Las transiciones de cómic van en los golpes más fuertes.")
print("El corte al clímax va en el silencio ANTERIOR al golpe grande.")Los comandos que resuelven el resto de la post: rescatar clips, ralentizar el clímax, flash blanco, reencuadre vertical y control de calidad.
# 1. Concatenar sin recomprimir (requiere mismo codec, resolución y fps)
ffmpeg -f concat -safe 0 -i export/lista.txt -c copy export/unido.mp4
# 2. Normalizar clips que no coinciden (hazlo ANTES de concatenar)
for f in clips/[0-9][0-9]_*.mp4; do
ffmpeg -i "$f" -vf scale=1920:1080,fps=24 -c:v libx264 -crf 16 \
-pix_fmt yuv420p "clips/_norm/$(basename "$f")"
done
# 3. Rescatar un clip: quitar el medio segundo donde la mano se deforma
ffmpeg -ss 0.5 -i clips/06_escalada.mp4 -t 4.2 -c copy clips/06_fix.mp4
# 4. Camara lenta para el climax (60% de velocidad, sin audio)
ffmpeg -i clips/10_climax.mp4 -filter:v "setpts=1.667*PTS" -an clips/10_slow.mp4
# 5. Flash blanco de 3 fotogramas en el impacto
ffmpeg -f lavfi -i color=white:s=1920x1080:d=0.125:r=24 \
-c:v libx264 -pix_fmt yuv420p clips/flash.mp4
# 6. Aplicar el LUT purpura/rojo a todo el video
ffmpeg -i export/unido.mp4 -vf lut3d=luts/purple_red.cube \
-c:v libx264 -crf 16 -preset slow export/graduado.mp4
# 7. Realzar solo las energias: sube saturacion en magentas y rojos
ffmpeg -i export/graduado.mp4 \
-vf "eq=saturation=1.15:contrast=1.08,unsharp=5:5:0.6" \
-c:v libx264 -crf 16 export/final.mp4
# 8. Vertical 9:16 (x=0 recorta a la izquierda, iw-ih*9/16 a la derecha)
ffmpeg -i export/batalla_01_16x9.mp4 \
-vf "crop=ih*9/16:ih:(iw-ih*9/16)/2:0,scale=1080:1920" \
-c:v libx264 -crf 18 export/batalla_01_9x16.mp4
# 9. Control de calidad: sonoridad para redes y duracion exacta
ffmpeg -i export/final.mp4 -af loudnorm=I=-14:TP=-1.5 -c:v copy export/qc.mp4
ffprobe -v error -show_entries format=duration -of csv=p=0 export/final.mp4
# 10. Extraer un fotograma para usarlo como referencia de personaje
ffmpeg -i clips/01_apertura.mp4 -vf "select=eq(n\,60)" -vframes 1 frames/ref_heroe.png