Content factory de clips con IA: de un repositorio de contenido a 1 clip por hora en YouTube

 

2026-09-09

Para qué sirve

El valor de este sistema no está en el clipping: está en la cadencia. Un canal que publica 24 clips por día compite con otro que publica 1 por semana en una dimensión donde no se puede empatar: la cantidad de apuestas simultáneas al algoritmo. Cuando cada clip cuesta centavos y cero minutos humanos, no hace falta elegir bien el clip. Alcanza con cubrir el espacio de posibilidades y dejar que los datos elijan.

Métrica del caso realValor
Frecuencia de publicación1 clip por hora (24/día)
Vistas~10.000.000 por mes
Intervención humana0 (pipeline 100% IA)
Antigüedad del canalDesde octubre
CTAAl final de cada clip, oferta propia

La consecuencia operativa: el cuello de botella deja de ser la producción y pasa a ser la calidad de la biblioteca fuente y la política de contenido. Todo lo demás se automatiza.

Arquitectura del sistema

Seis componentes, cada uno reemplazable:

clip-factory/
├── library/            # 1. Repositorio fuente (videos largos, propios o licenciados)
├── transcripts/        # 2. Transcripciones con timestamps por palabra (JSON)
├── candidates/         # 3. Cortes candidatos puntuados por el LLM (JSON)
├── renders/            # 4. Clips verticales renderizados (MP4)
├── queue/              # 5. Cola de publicación + metadatos
└── metrics/            # 6. Loop de datos (rendimiento por clip)

#ComponenteHerramientaSalida
1Bibliotecadisco / yt-dlp (yt-dlp)MP4 largos
2TranscripciónFaster-Whisper local u OpenAI Whisper APIJSON con start/end por palabra
3Selección de cortesLLM (DeepSeek / Kimi) con prompt de scoringJSON con inicio, fin, score, motivo
4RenderFFmpeg (ffmpeg)MP4 1080×1920 con subtítulos
5PublicaciónYouTube Data API v3 (videos.insert)Clip programado cada hora
6LoopYouTube Analytics API + yt-playbook-studioReglas de corte y realimentación

Requisitos previos

  • [ ] ffmpeg y ffprobe (con libass si vas a quemar subtítulos)
  • [ ] yt-dlp para traer material fuente
  • [ ] Python 3.11+ con un venv dedicado al proyecto
  • [ ] Credenciales: key de Whisper (u OpenAI), key del LLM, OAuth del canal dueño en YouTube
  • [ ] Cuota de YouTube Data API (default: 10.000 unidades/día)
  • [ ] Disco: unos 8 GB por cada 100 clips renderizados + 1 GB por hora de video fuente

Verificación rápida del entorno:

ffmpeg -version | head -1
ffmpeg -filters | grep -c subtitles      # 0 = sin libass → ver Paso 4
yt-dlp --version
python3 -c "import faster_whisper" 2>/dev/null && echo "whisper OK"

Paso 1 — Construir la biblioteca fuente

El sistema solo es tan bueno como su repositorio. Dos reglas no negociables:

  1. Derechos. Solo se clipea material propio, licenciado o con permiso explícito. El canal del caso real funciona porque está autorizado sobre el contenido que corta. Cortar videos de terceros sin permiso expone el canal a reclamos de copyright. También activa la política de contenido reutilizado de YouTube, que desmonetiza canales de clips sin valor agregado.
  2. Volumen. La fábrica necesita decenas de horas de fuente para sostener 24 clips diarios sin repetir. Con menos de 20 horas de material, los clips se queman en dos semanas.

Estructura y descarga:

mkdir -p ~/clip-factory/{library,transcripts,candidates,renders,queue,metrics}
yt-dlp -f "bv*[height<=1080]+ba/b[height<=1080]" \
  -o "~/clip-factory/library/%(title)s.%(ext)s" \
  "https://www.youtube.com/watch?v=VIDEO_ID"

Para el caso de MotorDigitalPress, la biblioteca natural son los videos largos propios y las entrevistas. El catálogo ya existe y los derechos son de la casa. La fábrica solo recorta lo que ya se publicó.

Paso 2 — Transcribir con timestamps por palabra

Sin timestamps por palabra no hay forma de cortar en el borde exacto de una frase: cortás en el silencio equivocado y el clip arranca a mitad de palabra.

Opción A — Faster-Whisper local (recomendada: sin costo, control total):

python3.11 -m venv ~/.venvs/clip && source ~/.venvs/clip/bin/activate
pip install faster-whisper

cat > transcribe.py <<'PY' import json, sys from faster_whisper import WhisperModel model = WhisperModel("large-v3", device="cpu", compute_type="int8") segments, info = model.transcribe(sys.argv[1], word_timestamps=True, vad_filter=True) words = [] for s in segments: for w in (s.words or []): words.append({"w": w.word, "start": round(w.start, 3), "end": round(w.end, 3)}) out = sys.argv[1].rsplit("/", 1)[-1].rsplit(".", 1)[0] json.dump({"language": info.language, "words": words}, open(f"transcripts/{out}.json", "w"), ensure_ascii=False) print(f"{out}: {len(words)} palabras") PY

python3 transcribe.py "library/mi-video-largo.mp4"

Opción B — OpenAI Whisper API (más rápido, cuesta ~US$0,006 por minuto, límite 25 MB por archivo → trocear):

ffmpeg -i "library/mi-video-largo.mp4" -ac 1 -ar 16000 -f segment \
  -segment_time 600 -c:a libmp3lame -b:a 32k /tmp/chunk_%03d.mp3

for f in /tmp/chunk_*.mp3; do curl -s https://api.openai.com/v1/audio/transcriptions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -F file=@"$f" -F model=whisper-1 \ -F response_format=verbose_json -F "timestamp_granularities[]=word" \ >> transcripts/chunks.jsonl done

Opción C — Subtítulos existentes (gratis, sin timestamps por palabra): yt-transcript.sh del stack sirve para videos que ya tienen subtítulos en YouTube. Suficiente para un MVP, insuficiente para cortes quirúrgicos.

Paso 3 — Selección de cortes puntuada por el LLM

El prompt hace el trabajo. Criterios que separan un clip que vuela de uno que muere en 200 vistas: hook en los primeros 3 segundos, unidad autocontenida (se entiende sin contexto), entre 30 y 60 segundos, tensión + resolución, y una frase citable.

cat > select.py <<'PY'
import json, os, sys, urllib.request
t = json.load(open(sys.argv[1]))
txt = " ".join(w["w"] for w in t["words"])
prompt = f"""Sos editor de clips virales. Del siguiente transcript elegí los 12 mejores cortes.
Reglas: hook en los primeros 3 segundos, autocontenido, 30-60 segundos, con tensión y cierre.
Devolvé SOLO JSON: [{{"inicio": , "fin": , "titulo": "...", "score": 1-10, "motivo": "..."}}]
Transcript: {txt[:24000]}"""
body = json.dumps({"model": "deepseek-chat", "messages": [{"role": "user", "content": prompt}],
                   "temperature": 0.4}).encode()
req = urllib.request.Request("https://api.deepseek.com/chat/completions", data=body,
    headers={"Authorization": "Bearer " + os.environ["DEEPSEEK_API_KEY"],
             "Content-Type": "application/json"})
r = json.loads(urllib.request.urlopen(req, timeout=180).read())
raw = r["choices"][0]["message"]["content"]
raw = raw[raw.find("["): raw.rfind("]") + 1]
open(sys.argv[1].replace("transcripts/", "candidates/").replace(".json", "-cortes.json"), "w").write(raw)
print(raw[:400])
PY

python3 select.py transcripts/mi-video-largo.json

Regla operativa: publicá todo lo que puntúe 7 o más. No filtres por intuición — el sistema está diseñado para que el algoritmo y los datos elijan, no el editor. La excepción: descartá cortes que mencionen terceros, datos sensibles o afirmaciones que no puedas sostener.

Paso 4 — Renderizar el clip vertical

Dos operaciones: recorte temporal y reencuadre 9:16. El recorte rápido con -c copy corta en el keyframe más cercano. El reencuadre se hace con zoom sobre el centro o sobre la cara.

IN="library/mi-video-largo.mp4"; INICIO=372.4; FIN=414.9; OUT="renders/clip-001.mp4"

ffmpeg -y -ss $INICIO -to $FIN -i "$IN" \ -vf "crop=ih*9/16:ih,scale=1080:1920,setsar=1" \ -af "loudnorm=I=-14:TP=-1.5:LRA=11" \ -c:v libx264 -preset veryfast -crf 21 -c:a aac -b:a 192k -movflags +faststart "$OUT"

ffprobe -v error -select_streams v -show_entries stream=width,height,duration -of csv=p=0 "$OUT"

Subtítulos quemados. Para subtítulos hay que generar un .ass con los timestamps por palabra. El quemado usa el filtro subtitles=, que requiere libass:

  • En la Mac (ffmpeg de evermeet) libass está incluido: ffmpeg -i in.mp4 -vf "subtitles=clip.ass" out.mp4 funciona.
  • En el worker no está: ffmpeg -filters | grep subtitles devuelve vacío. Dos caminos: (a) correr el burn-in en la Mac, o (b) renderizar los subtítulos como PNG con Pillow y superponerlos por overlay con enable='between(t,a,b)' (sin dependencias extra).

Si el clip es para Shorts, los subtítulos no son opcionales: la mayoría lo mira sin audio.

Paso 5 — Ensamblar el CTA al final

El CTA (oferta, link, suscripción) se pega con el concat demuxer. Todos los tramos deben compartir resolución, códec, fps y sample rate. Si no, el concat falla o mete saltos.

ffmpeg -y -i branding/cta-outro.mp4 -vf "scale=1080:1920,setsar=1,fps=30" \
  -af "loudnorm=I=-14:TP=-1.5:LRA=11" -c:v libx264 -crf 21 -c:a aac -b:a 192k outro-norm.mp4

printf "file 'renders/clip-001.mp4'\nfile 'outro-norm.mp4'\n" > /tmp/concat.txt ffmpeg -y -f concat -safe 0 -i /tmp/concat.txt -c copy renders/clip-001-final.mp4

Regla de coherencia: un solo outro para todos los clips. Cambiarlo cada semana convierte el canal en un collage y rompe el reconocimiento.

Paso 6 — Publicar y programar uno por hora

La publicación es donde la mayoría de los pipelines se rompen, por dos motivos concretos: la cuota y el tipo de credencial.

  • Credencial: subir video requiere OAuth del dueño del canal (una cuenta de servicio no puede subir a un canal que no le pertenece). El stack ya tiene el flujo OAuth iniciado en yt-playbook-studio (/api/analytics/callback) — conviene reutilizar ese cliente agregándole el scope youtube.upload.
  • Cuota: cada videos.insert cuesta 1.600 unidades y cada videos.update, 50. Con la cuota default de 10.000/día quedan ~6 subidas por día. Eso no alcanza para 24. Hay que pedir ampliación en Google Cloud (formulario de YouTube Data API quota extension) o repartir la carga entre varios canales y credenciales.

import json, datetime, google.oauth2.credentials, googleapiclient.discovery
from googleapiclient.http import MediaFileUpload

creds = google.oauth2.credentials.Credentials.from_authorized_user_file( "~/.hermes/credentials/youtube-oauth.json".replace("~", __import__("os").path.expanduser("~")), ["https://www.googleapis.com/auth/youtube.upload"]) yt = googleapiclient.discovery.build("youtube", "v3", credentials=creds)

slot = (datetime.datetime.utcnow() + datetime.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) req = yt.videos().insert(part="snippet,status", body={ "snippet": {"title": "TÍTULO DEL CLIP #shorts", "description": "Cómo montar una fábrica de clips con IA: transcripción por palabra, cortes elegidos por un LLM, render con FFmpeg y publicación programada en YouTube.", "tags": ["shorts"], "categoryId": "22"}, "status": {"privacyStatus": "private", "publishAt": slot.isoformat("T") + "Z", "selfDeclaredMadeForKids": False}}, media_body=MediaFileUpload("renders/clip-001-final.mp4", chunksize=-1, resumable=True)) print(req.execute()["id"], "programado", slot.isoformat())

Notas operativas:

  • Programar una hora en punto, no "cada 60 minutos": reproducís el patrón del canal de referencia y alineás los slots con las horas de mayor consumo.
  • publishAt exige privacyStatus: private. YouTube lo hace público solo en el horario indicado.
  • En el pipeline de Ariel, el yt-playbook-studio viene con publicacion_habilitada = 0: la herramienta decide, el humano publica. Si esta fábrica se engancha a ese circuito, primero hay que activar el toggle en Admin. El pipeline pasa a publicar solo.
  • Título y descripción: generalos con el LLM a partir del motivo del corte. Un clip puede ser excelente y morir por un título plano.

Paso 7 — Cerrar el loop de datos

Sin loop, la fábrica produce basura prolija. La realimentación tiene tres preguntas:

  1. ¿Qué formato de corte rinde? (duración, tipo de hook, tema)
  2. ¿Qué horas de publicación convierten mejor?
  3. ¿Qué fuente conviene volver a cortar?

python3 ~/.hermes/scripts/yps_analytics_sync.py
python3 ~/.hermes/scripts/yps_alerta_outliers.py

Tabla de decisión (ajustable con datos propios, no con intuición):

SeñalUmbralAcción
Retención a 3 s< 60%Cambiar el tipo de hook
CTR de miniatura< 4%Reescribir título y probar otra miniatura
Vistas d1< 300Bajar prioridad de ese formato
Outlier≥ 5x la mediana del canalFabricar 10 variantes del mismo formato
Un formato con 3 clips bajo umbralRetirarlo y volver a la biblioteca

El canal de referencia lleva esto al extremo: "está analizando los datos para ver qué funciona, volviendo al repositorio y entrando en un loop".

Control de calidad pre-publicación

  • [ ] Derechos del material fuente verificados (propio, licenciado o con permiso)
  • [ ] Hook en los primeros 3 segundos
  • [ ] Duración 30-60 s · sin cortes a mitad de palabra
  • [ ] Audio normalizado (I=-14 LUFS), sin clipping
  • [ ] Subtítulos presentes y sincronizados (revisar 3 marcas al azar)
  • [ ] CTA final idéntico al de los demás clips
  • [ ] Título y descripción generados y revisados (sin clickbait falso)
  • [ ] Miniatura coherente con el resto del canal
  • [ ] Sin datos personales de terceros ni afirmaciones no sostenibles

Costos y límites

ÍtemCostoNota
Transcripción local (Faster-Whisper)US$0CPU, ~1× tiempo real en M-series
Transcripción API (Whisper)~US$0,006/min10 h de fuente ≈ US$3,6
Selección de cortes (LLM)~US$0,01 por video fuenteDeepSeek con prompt acotado
Render (FFmpeg local)US$0CPU
PublicaciónCuota, no dinero1.600 unidades por subida
VPS opcional (si no corre en local)US$10-40/mesSolo si necesitás 24/7

Techo real del sistema con la cuota default: ~6 subidas/día. Todo lo que pase de ahí requiere ampliación de cuota o varias credenciales de canal.

Troubleshooting

SíntomaCausa probableSolución
quotaExceeded en videos.insertCuota diaria agotada (1.600/unidad)Esperar el reset (medianoche PT) o pedir ampliación
403 forbidden al subirLa credencial no es del dueño del canalRehacer OAuth con la cuenta del canal + scope youtube.upload
publishAt rechazadoprivacyStatus distinto de privateProgramar con privado + publishAt
ffmpeg: No such filter: 'subtitles'ffmpeg sin libass (caso del worker)Correr el burn-in en la Mac o superponer PNG con overlay
Subtítulos desincronizadosTimestamps de segmento en vez de palabraRegenerar con word_timestamps=True
El concat falla o mete saltosCodecs/fps/resolución distintosNormalizar todos los tramos antes de concatenar
Audio del clip saturadoFuente con picosloudnorm o dynaudnorm en el render
El canal se desmonetizaPolítica de contenido reutilizadoAportar valor (edición, subtítulos, comentario) o usar solo material propio
Vistas planas después de 20 clipsUn solo formato de corteVariar hooks y temas. Medir retención a 3 s

Mantenimiento y escalado

  • Semanal: cargar 2-4 horas nuevas de fuente, revisar los umbrales de la tabla de decisión.
  • Mensual: auditar derechos del material incorporado, rotar títulos/miniaturas de los clips con CTR bajo.
  • Escalado: el paso natural es abrir un segundo canal con otra biblioteca (otra marca, otro idioma, otro nicho) y reutilizar el pipeline. Con la cuota repartida, dos canales son 12 subidas diarias.
  • Cuándo no escalar: si la retención a 3 s cae por debajo del 60% sostenido, el problema es la selección de cortes, no el volumen. Sumar clips solo multiplica el problema.

Referencias

  • Fuente original: Cody Schneider (@codyschneider), post en X — 9/9/2026
  • YouTube Data API v3 — videos.insert y publicación programada: https://developers.google.com/youtube/v3/docs/videos/insert
  • Cuotas de la API: https://developers.google.com/youtube/v3/determine_quota_cost
  • Política de contenido reutilizado: https://support.google.com/youtube/answer/9528076
  • Faster-Whisper: https://github.com/SYSTRAN/faster-whisper
  • FFmpeg — filtros crop, subtitles, overlay, loudnorm: https://ffmpeg.org/ffmpeg-filters.html
  • Stack interno: skills yt-playbook-studio-ops, ffmpeg-skill, video-downloader, youtube-playbook
— Ariel Di Stefano