Cómo se fabrica una pieza

Canal Reposters · de un vídeo que ya funcionó en TikTok al MP4 que sube el reposter. Siete pasos.

01
EL REFERENTE

Un vídeo que ya funcionó en TikTok. De ahí sale el gesto.

12 referencias · 4 gestos
02
EL FOTOGRAMA

De ese vídeo se congela el segundo 0. Es la foto que manda.

1 foto, la que manda
03
LA CARA

La IA cambia la persona en esa foto. Misma pose, otra cara.

IA · se tiran varias
04
EL VÍDEO

Se anima la foto usando el vídeo de TikTok como coreografía.

3 segundos
frase
frase
frase
obligatorio   ✓ vale   nunca
05
LA FRASE

La tabla dice qué frase puede ir y con qué b-roll.

10 frases × 5 carpetas
06
SE PEGAN

Cara delante, b-roll de la app detrás. Un solo MP4.

3 s + el resto
07
EL TEXTO

No va quemado: el reposter lo escribe desde la plataforma.

MP4 + frase + ejemplo

Una pieza de verdad, ya montada

Gesto 01 con la cara de IA, y detrás el b-roll de RECORRIDO LARGO. Corte seco, sin transición. Sale limpia: el texto lo pone después el operador.

CARA 3 s
B-ROLL 15 s

1080×1920 · 30 fps · 18 s

De dónde salen los 12 gestos

Los cuatro gestos vienen de vídeos que ya reventaron en TikTok. Tres referencias por gesto, doce en total: son los mismos doce que ve la modelo en el rodaje.

Referencia 01 · mano en la cara

@kailyn.lifts · 13,7M de visualizaciones. Lo que se copia es el recorte de los primeros segundos: el gesto, no el vídeo entero.

01Mano en la cara@kailyn.lifts ↗TikTok13,7M0,56%
02Se tapa la boca@honousmitfv ↗TikTok1,2M6,70%
03Sorpresa contenida@danclipping ↗Xn/dn/d
04No me lo creo@sarafinalyfts ↗TikTok2,7M3,17%
05Mano en la cara@sarafinalyfts ↗TikTok4,9M0,60%
06Se tapa la boca@julesshufl ↗TikTok827,5K13,60%
07Sorpresa contenida@klaraa.ipod ↗TikTok17,6K3,68%
08No me lo creo@klaraa.ipod ↗TikTok1,2M15,23%
09Mano en la cara@klaraa.ipod ↗TikTok816,8K9,17%
10Se tapa la boca@sarafinalyfts ↗TikTok132,9K0,69%
11Sorpresa contenida@sophiaas.mp3 ↗TikTok20,7K3,65%
12No me lo creo@kailyn.lifts ↗TikTok348,7K0,44%

El camino completo

Los originales · TikTok

Los 12 enlaces están en la tabla de arriba. Once de TikTok y uno de X (@danclipping).

Lo que ve la modelo en rodaje · público https://crm-whatsapp.vm.runnerpro.app/rodaje.html

En la VM: /home/cristobal/crm-whatsapp-reporters/src/public/rodaje.html — los 12 clips van dentro del propio HTML, recortados y en bucle (01_Mano_en_la_cara.mp4 … 12_No_me_lo_creo.mp4).

La ficha con métricas · vault 1-Proyectos/MARKETING/CANAL_REPOSTERS/REFERENCIAS_RODAJE_12_GESTOS.html

Cada referencia con su fotograma 0, sus vistas y su engagement, leídos el 16-sep-2026.

Los b-rolls · iCloud /Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS /

Cinco carpetas: PLAN, HOME, ESCÁNER - NUTRI, FUERZA, RECORRIDO LARGO.

El motor · local ~/projects/codigo/tanda-brolls/ · entrada_ia/ → salida/

matriz.json, cruce.py, monta.py, ejemplo_rotulo.py. Todo el código está abajo.

Los tres gestos que ya tienen vídeo con IA

01 · mano en la cara
02 · se tapa la boca
03 · sorpresa contenida

Los b-rolls · de dónde salen

iCloud Drive, una carpeta por funcionalidad de la app. El vídeo concreto dentro de la carpeta da igual: se rotan para que salgan todos las mismas veces.

/Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS /

PLAN vacía

…/TANDA VIDEOS - BROLLS /PLAN
Vacía. Lo que dependa de ella no se monta.

HOME vacía

…/TANDA VIDEOS - BROLLS /HOME
Vacía. Lo que dependa de ella no se monta.

ESCÁNER 3 clips

…/TANDA VIDEOS - BROLLS /ESCÁNER - NUTRI
ESCANER 1.MOV14.4 s
ESCANER 2.MOV8.8 s
ESCANER 3.MOV12.6 s

FUERZA 3 clips

…/TANDA VIDEOS - BROLLS /FUERZA
FUERZA 1 .MOV10.2 s
FUERZA 2.MOV11.6 s
FUERZA 3.MOV8.6 s

RECORRIDO 3 clips

…/TANDA VIDEOS - BROLLS /RECORRIDO LARGO
RECORRIDO LARGO 1.MOV17.5 s
RECORRIDO LARGO 2.MOV17.4 s
RECORRIDO LARGO 3.MOV21.4 s

La tabla · qué frase admite qué b-roll

FraseSemánticaPlanHomeEscánerFuerzaRecorrido
4 años yendo al gimnasio y AHORA me entero de ESTO ???AGRAVIO
2 años corriendo y NADIE me había dicho ESTO ??!AGRAVIO
3 años intentando ponerme en forma y NADIE me había dicho ESTO ??!AGRAVIO
¿CÓMO que la gente paga Strava TODOS LOS MESES si existe ESTO ?!?PAGA X
CÓMOOOO no conocía esto >>CÓMOOO
CÓMOOOO no conocía esto siendo runner >>CÓMOOO
Si corres y no sigues ningún plan, TIENES que ver ESTO 🤯CONDICIONAL
Si quieres empezar a correr, TIENES que ver ESTOCONDICIONAL
¿CÓMO QUE existe ESTO y no lo sabía nadie ???INCREDULIDAD
Esto no puede ser real ???INCREDULIDAD
obligatorio — en 3 de los 6 vídeos de esa frase admitido vetado, no se combina nunca tachado = carpeta vacía hoy
PLAN y HOME están vacías, y PLAN es obligatorio en 5 frases. Hoy esas 5 se montan con RECORRIDO. En cuanto subas clips a esas carpetas, se relanza el reparto y se recoloca solo.

Lo que recibe el operador

Tres cosas por pieza: el MP4 limpio, el texto para copiar, y el fotograma de ejemplo con la frase puesta donde va — centrada, blanca, sin caja. Él la escribe con el texto nativo de TikTok.

ejemplo · lo escribe él en TikTok
F01 · AGRAVIO · gesto 01 · mano en la cara
b-roll: FUERZARECORRIDO faltan: HOME
ejemplo · lo escribe él en TikTok
F02 · AGRAVIO · gesto 02 · se tapa la boca
b-roll: RECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F03 · AGRAVIO · gesto 03 · sorpresa contenida
b-roll: ESCÁNERFUERZARECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F04 · PAGA X · gesto 01 · mano en la cara
b-roll: RECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F05 · CÓMOOO · gesto 02 · se tapa la boca
b-roll: ESCÁNERFUERZARECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F06 · CÓMOOO · gesto 03 · sorpresa contenida
b-roll: RECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F07 · CONDICIONAL · gesto 01 · mano en la cara
b-roll: RECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F08 · CONDICIONAL · gesto 02 · se tapa la boca
b-roll: RECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F09 · INCREDULIDAD · gesto 03 · sorpresa contenida
b-roll: ESCÁNERFUERZARECORRIDO faltan: PLANHOME
ejemplo · lo escribe él en TikTok
F10 · INCREDULIDAD · gesto 01 · mano en la cara
b-roll: ESCÁNERFUERZARECORRIDO faltan: PLANHOME

Para el Claude de tu compi · solo texto

Los seis bloques de abajo son todo lo que necesita: el contexto con las trampas, los cuatro ficheros enteros y los comandos en orden. Cada uno con su botón de copiar.

BRIEFING · esto es lo que hay que pegarle a Claudetexto plano
CONTEXTO — cadena "vídeo IA + b-roll", Canal Reposters (RunnerPro). 16-sep-2026.
Pégalo entero al empezar. Está escrito para ejecutarlo, no para leerlo.

QUÉ SE FABRICA
Una pieza = [gesto de cara hecho con IA, ~3 s] + [b-roll de producto, el resto] concatenados en un MP4
vertical 1080x1920, tope 18 s. La frase-gancho NO se quema en el vídeo: se entrega aparte para que el
operador la escriba con la herramienta de texto de TikTok (rinde mejor que un rótulo quemado).

1) EL VÍDEO IA (el gesto)
[RESERVADO] El detalle de esta parte —proveedor, endpoints, precios, clave de API y los umbrales exactos
de los dos filtros de control— no se publica. Está en la versión interna del vault; pídesela a Cristóbal.
Lo único que hace falta saber aquí: de esa parte sale un MP4 vertical de ~3 s, 720x1280, con el gesto del
vídeo de referencia y una cara que no es de nadie real. Ese fichero es la entrada de todo lo que sigue.

2) LOS B-ROLLS (el plano de producto)
iCloud Drive, una carpeta por funcionalidad:
  /Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS /
  TRAMPA 1: ese nombre ACABA EN ESPACIO, y las subcarpetas "HOME ", "FUERZA " y "RECORRIDO LARGO " también.
            Un `cd` normal falla. Abre siempre por ruta literal.
  TRAMPA 2: los ficheros están en la nube. `du` da 0B aunque `stat` dé el tamaño real. Antes de tocarlos:
            `brctl download "<fichero>"` y esperar, o ffmpeg lee un fichero vacío sin avisar.
  Estado a 16-sep-2026: ESCÁNER - NUTRI, FUERZA y RECORRIDO LARGO tienen 3 clips cada una (8,6 a 21,4 s,
  1080x1920 a 30 fps). PLAN y HOME están VACÍAS.

3) LAS FRASES
10 frases-gancho con su semántica (AGRAVIO, PAGA X, CÓMOOO, CONDICIONAL, INCREDULIDAD) y, por cada una,
qué carpeta admite: obligatorio / admitido / vetado. Está en matriz.json, que es la tabla en datos.

EL REPARTO (cruce.py)
 - Las frases se reparten por turnos entre los vídeos IA (12 vídeos y 10 frases -> dos frases repiten).
 - Dentro de una frase, carpeta y clip se eligen por EL MENOS USADO hasta ese momento: los 3 clips de una
   carpeta salen el mismo número de veces. Eso es lo que se pidió: variedad proporcional.
 - "obligatorio" manda mientras no haya salido en la mitad de los vídeos de esa frase.
 - "vetado" no se combina NUNCA, ni aunque no quede otra. Si una frase se queda sin carpeta disponible, la
   pieza se marca pendiente en vez de montarse mal.
 - Hoy, con PLAN y HOME vacías, 5 frases se montan solo con RECORRIDO. Al llenar esas carpetas no se toca
   código: se relanza cruce.py y el reparto se recalcula.

EL MONTAJE (monta.py)
Gesto IA delante (sus ~3 s enteros) + b-roll detrás hasta 18 s totales. Antes de concatenar se normaliza
TODO —escala a 1080x1920 recortando, 30 fps, SAR 1, audio AAC 48k estéreo— o el corte da un salto. Si una
de las dos fuentes no trae audio se le mete anullsrc, o el concat de audio revienta.

LA ENTREGA
Por pieza: el MP4 limpio, el texto de la frase para copiar, y un fotograma de ejemplo (el fotograma 0 del
gesto con la frase pintada donde va). Estilo del rótulo, medido en el corpus de virales: centrado, blanco,
sin caja, Helvetica Neue Medium (índice 10 de HelveticaNeue.ttc; el 2 es Italic). Centro-alto rinde 2,4x
peor y la caja de color casi 3x peor.

DÓNDE ESTÁ TODO
 ~/projects/codigo/tanda-brolls/   matriz.json · brolls.json · cruce.py · monta.py · ejemplo_rotulo.py
   entrada_ia/  los MP4 de IA        salida/  las piezas montadas
 Vault: 1-Proyectos/MARKETING/CANAL_REPOSTERS/CADENA_TANDA_BROLLS/

REGLAS DE LA CASA
 - Los entregables (HTML, informes) van a la carpeta del canal del que tratan, dentro del vault de Obsidian.
   Nunca a la raíz, nunca dejar el resultado final en /tmp.
 - Los scripts .py NO van dentro del vault: van a ~/projects/codigo/.
 - Nombres de entregable en MAYÚSCULAS_CON_GUIONES_BAJOS.html
matriz.jsonla tabla en datos · se edita aquí
{
  "carpetas": ["PLAN", "HOME", "ESCANER", "FUERZA", "RECORRIDO"],
  "rutas": {
    "PLAN":      "PLAN",
    "HOME":      "HOME ",
    "ESCANER":   "ESCÁNER - NUTRI",
    "FUERZA":    "FUERZA ",
    "RECORRIDO": "RECORRIDO LARGO "
  },
  "base": "/Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS ",
  "frases": [
    {"id": "f01", "semantica": "AGRAVIO",      "texto": "4 años yendo al gimnasio y AHORA me entero de ESTO ???",              "broll": {"PLAN": "-", "HOME": "si", "ESCANER": "-",  "FUERZA": "obl", "RECORRIDO": "si"}},
    {"id": "f02", "semantica": "AGRAVIO",      "texto": "2 años corriendo y NADIE me había dicho ESTO ??!",                   "broll": {"PLAN": "obl", "HOME": "si", "ESCANER": "-", "FUERZA": "-",   "RECORRIDO": "si"}},
    {"id": "f03", "semantica": "AGRAVIO",      "texto": "3 años intentando ponerme en forma y NADIE me había dicho ESTO ??!", "broll": {"PLAN": "si", "HOME": "si", "ESCANER": "si", "FUERZA": "si", "RECORRIDO": "si"}},
    {"id": "f04", "semantica": "PAGA X",       "texto": "¿CÓMO que la gente paga Strava TODOS LOS MESES si existe ESTO ?!?",  "broll": {"PLAN": "obl", "HOME": "si", "ESCANER": "-", "FUERZA": "-",   "RECORRIDO": "si"}},
    {"id": "f05", "semantica": "CÓMOOO",       "texto": "CÓMOOOO no conocía esto >>",                                         "broll": {"PLAN": "si", "HOME": "si", "ESCANER": "si", "FUERZA": "si", "RECORRIDO": "si"}},
    {"id": "f06", "semantica": "CÓMOOO",       "texto": "CÓMOOOO no conocía esto siendo runner >>",                           "broll": {"PLAN": "obl", "HOME": "si", "ESCANER": "-", "FUERZA": "-",   "RECORRIDO": "si"}},
    {"id": "f07", "semantica": "CONDICIONAL",  "texto": "Si corres y no sigues ningún plan, TIENES que ver ESTO 🤯",           "broll": {"PLAN": "obl", "HOME": "si", "ESCANER": "-", "FUERZA": "-",   "RECORRIDO": "si"}},
    {"id": "f08", "semantica": "CONDICIONAL",  "texto": "Si quieres empezar a correr, TIENES que ver ESTO",                   "broll": {"PLAN": "obl", "HOME": "si", "ESCANER": "-", "FUERZA": "-",   "RECORRIDO": "si"}},
    {"id": "f09", "semantica": "INCREDULIDAD", "texto": "¿CÓMO QUE existe ESTO y no lo sabía nadie ???",                      "broll": {"PLAN": "si", "HOME": "si", "ESCANER": "si", "FUERZA": "si", "RECORRIDO": "si"}},
    {"id": "f10", "semantica": "INCREDULIDAD", "texto": "Esto no puede ser real ???",                                         "broll": {"PLAN": "si", "HOME": "si", "ESCANER": "si", "FUERZA": "si", "RECORRIDO": "si"}}
  ]
}
cruce.pyreparto frase × carpeta × clip
#!/usr/bin/env python3
"""Cruza los vídeos IA con los b-rolls de las carpetas según la tabla de frases.

Regla de la tabla:  obl = tiene que salir en la mitad de los vídeos de esa frase
                    si  = admitido
                    -   = vetado, no se combina nunca
Reparto: las frases se reparten por turnos entre los vídeos IA, y dentro de cada
frase la carpeta y el clip se eligen por el menos usado hasta ahora (proporcional).
"""
import json, os, sys, collections, pathlib

RAIZ = pathlib.Path(__file__).parent
M = json.load(open(RAIZ / "matriz.json"))
VIDEO_EXT = {".mp4", ".mov", ".m4v", ".webm"}


def clips_por_carpeta():
    out = {}
    for nombre, sub in M["rutas"].items():
        d = pathlib.Path(M["base"]) / sub
        if not d.is_dir():
            out[nombre] = []
            continue
        out[nombre] = sorted(
            str(p) for p in d.iterdir()
            if p.suffix.lower() in VIDEO_EXT and not p.name.startswith(".")
        )
    return out


def videos_ia(carpeta):
    d = pathlib.Path(carpeta)
    return sorted(
        str(p) for p in d.iterdir()
        if p.suffix.lower() in VIDEO_EXT and not p.name.startswith(".")
    )


def reparte(ias, clips):
    frases = M["frases"]
    uso_carpeta = collections.Counter()
    uso_clip = collections.Counter()
    obl_puestos = collections.Counter()   # cuántas veces se ha cumplido el obligatorio por frase
    veces_frase = collections.Counter()
    plan = []

    for i, ia in enumerate(ias):
        fr = frases[i % len(frases)]
        veces_frase[fr["id"]] += 1

        permitidas = [c for c, v in fr["broll"].items() if v in ("si", "obl") and clips.get(c)]
        vetadas    = [c for c, v in fr["broll"].items() if v == "-"]
        faltan     = [c for c, v in fr["broll"].items() if v in ("si", "obl") and not clips.get(c)]
        obligas    = [c for c, v in fr["broll"].items() if v == "obl" and clips.get(c)]

        if not permitidas:
            plan.append({"ia": ia, "frase": fr, "carpeta": None, "clip": None,
                         "motivo": "sin b-roll disponible (vetadas: %s | vacías: %s)"
                                   % (",".join(vetadas), ",".join(faltan))})
            continue

        # el obligatorio manda mientras no llegue a la mitad de los vídeos de esa frase
        candidatas = permitidas
        if obligas and obl_puestos[fr["id"]] * 2 < veces_frase[fr["id"]]:
            candidatas = obligas

        carpeta = min(candidatas, key=lambda c: (uso_carpeta[c], c))
        uso_carpeta[carpeta] += 1
        if fr["broll"][carpeta] == "obl":
            obl_puestos[fr["id"]] += 1

        clip = min(clips[carpeta], key=lambda p: (uso_clip[p], p))
        uso_clip[clip] += 1

        plan.append({"ia": ia, "frase": fr, "carpeta": carpeta, "clip": clip,
                     "vetadas": vetadas, "vacias": faltan})
    return plan, uso_carpeta, uso_clip


if __name__ == "__main__":
    carpeta_ia = sys.argv[1] if len(sys.argv) > 1 else str(RAIZ / "entrada_ia")
    clips = clips_por_carpeta()
    ias = videos_ia(carpeta_ia)

    print("B-ROLLS POR CARPETA")
    for c, l in clips.items():
        print(f"  {c:<10} {len(l):>2} clip(s)" + ("   ← VACÍA" if not l else ""))
    print(f"\nVÍDEOS IA en {carpeta_ia}: {len(ias)}")
    if not ias:
        print("  (mete ahí los MP4 de IA y vuelve a lanzar)")
        sys.exit(0)

    plan, uc, ucl = reparte(ias, clips)
    print("\nREPARTO")
    for p in plan:
        base = os.path.basename(p["ia"])
        if not p["carpeta"]:
            print(f"  {base:<28} {p['frase']['id']}  ⚠️  {p['motivo']}")
        else:
            print(f"  {base:<28} {p['frase']['id']}  {p['carpeta']:<10} {os.path.basename(p['clip'])}")
    print("\nUSO DE CARPETAS:", dict(uc))
    print("USO DE CLIPS:")
    for k, v in sorted(ucl.items()):
        print(f"  {v}×  {os.path.basename(k)}")

    json.dump([{**p, "frase": p["frase"]} for p in plan],
              open(RAIZ / "plan.json", "w"), ensure_ascii=False, indent=2)
    print("\n→ plan.json")
monta.pyel ffmpeg de cada pieza
#!/usr/bin/env python3
"""Monta vídeo IA (recortado al gesto) + b-roll de la carpeta que manda la tabla.

  python3 monta.py [carpeta_ia] [--gesto 3.0] [--tope 18] [--solo f01]

Todo se normaliza a 1080x1920 · 30 fps · AAC 48k para que el concat no dé saltos.
"""
import json, os, subprocess, sys, pathlib, unicodedata, re
import cruce

RAIZ = pathlib.Path(__file__).parent
SAL = RAIZ / "salida"
SAL.mkdir(exist_ok=True)

GESTO = 3.0     # segundos de vídeo IA que se quedan (mediana medida del corte: 2,9 s)
TOPE = 18.0     # duración total máxima del montaje


def arg(nombre, defecto):
    if nombre in sys.argv:
        return float(sys.argv[sys.argv.index(nombre) + 1])
    return defecto


def dur(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                          "-of", "csv=p=0", path], capture_output=True, text=True).stdout.strip()
    return float(out) if out else 0.0


def slug(t):
    t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()
    return re.sub(r"[^A-Za-z0-9]+", "_", t).strip("_")[:40].upper()


def tiene_audio(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a",
                          "-show_entries", "stream=index", "-of", "csv=p=0", path],
                         capture_output=True, text=True).stdout.strip()
    return bool(out)


def monta(ia, broll, destino, gesto, tope):
    d_ia = min(dur(ia), gesto)
    d_br = min(dur(broll), max(tope - d_ia, 3.0))

    entradas = ["-i", ia, "-i", broll]
    a_ia, a_br = tiene_audio(ia), tiene_audio(broll)
    idx_mudo = 2
    if not (a_ia and a_br):
        entradas += ["-f", "lavfi", "-t", str(round(max(d_ia, d_br) + 1, 2)), "-i",
                     "anullsrc=channel_layout=stereo:sample_rate=48000"]

    VN = ("[{i}:v]trim=0:{d},setpts=PTS-STARTPTS,"
          "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
          "fps=30,setsar=1[v{n}];")
    AN = ("[{i}:a]atrim=0:{d},asetpts=PTS-STARTPTS,"
          "aformat=sample_rates=48000:channel_layouts=stereo[a{n}];")

    f  = VN.format(i=0, d=round(d_ia, 3), n=0)
    f += VN.format(i=1, d=round(d_br, 3), n=1)
    f += AN.format(i=0 if a_ia else idx_mudo, d=round(d_ia, 3), n=0)
    f += AN.format(i=1 if a_br else idx_mudo, d=round(d_br, 3), n=1)
    f += "[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]"

    cmd = (["ffmpeg", "-y", "-v", "error"] + entradas +
           ["-filter_complex", f, "-map", "[v]", "-map", "[a]",
            "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", destino])
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        return False, r.stderr.strip()[:300]
    return True, round(d_ia + d_br, 2)


if __name__ == "__main__":
    carpeta_ia = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("--") else str(RAIZ / "entrada_ia")
    gesto, tope = arg("--gesto", GESTO), arg("--tope", TOPE)

    clips = cruce.clips_por_carpeta()
    ias = cruce.videos_ia(carpeta_ia)
    if not ias:
        sys.exit("No hay vídeos IA en " + carpeta_ia)
    plan, uc, ucl = cruce.reparte(ias, clips)

    salida = []
    for i, p in enumerate(plan, 1):
        fr = p["frase"]
        if not p["carpeta"]:
            print(f"  {i:02d}  ⚠️  {fr['id']} sin b-roll: {p['motivo']}")
            salida.append({**p, "frase": fr, "archivo": None, "duracion": None})
            continue
        nombre = f"{i:02d}_{fr['id']}_{p['carpeta']}_{slug(fr['texto'])}.mp4"
        ok, info = monta(p["ia"], p["clip"], str(SAL / nombre), gesto, tope)
        print(f"  {i:02d}  {'✅' if ok else '❌'}  {nombre}  {info}")
        salida.append({**p, "frase": fr, "archivo": nombre if ok else None,
                       "duracion": info if ok else None, "error": None if ok else info})

    json.dump(salida, open(RAIZ / "plan.json", "w"), ensure_ascii=False, indent=2)
    print("\n→ salida/ y plan.json")
ejemplo_rotulo.pyel fotograma de ejemplo
#!/usr/bin/env python3
"""Fotograma de ejemplo para el operador: fotograma 0 del gesto IA + la frase encima.

No se entrega quemado: es la referencia de DÓNDE va el texto que él escribe en TikTok.
Estilo medido en reference_corpus_rotulo_virales: sin caja, blanco, centro,
Helvetica Neue Medium. El rótulo real lo pone el operador con el texto nativo.
"""
import subprocess, sys, pathlib
from PIL import Image, ImageDraw, ImageFont

FUENTE = "/System/Library/Fonts/HelveticaNeue.ttc"
IDX_MEDIUM = 10


def frame0(video, destino):
    subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(video),
                    "-vf", "select=eq(n\\,0)", "-vframes", "1", str(destino)], check=True)
    return destino


def parte(texto, draw, fuente, ancho_max):
    lineas, actual = [], ""
    for p in texto.split():
        prueba = (actual + " " + p).strip()
        if draw.textlength(prueba, font=fuente) <= ancho_max or not actual:
            actual = prueba
        else:
            lineas.append(actual); actual = p
    if actual:
        lineas.append(actual)
    return lineas


def pinta(src, texto, destino, tam=None):
    img = Image.open(src).convert("RGB")
    W, H = img.size
    tam = tam or int(W * 0.064)          # 46 px sobre 720 de ancho
    d = ImageDraw.Draw(img, "RGBA")
    f = ImageFont.truetype(FUENTE, tam, index=IDX_MEDIUM)
    lineas = parte(texto, d, f, W * 0.86)
    if len(lineas) > 3:                   # si no cabe en 3, se baja el cuerpo
        tam = int(tam * 0.82)
        f = ImageFont.truetype(FUENTE, tam, index=IDX_MEDIUM)
        lineas = parte(texto, d, f, W * 0.86)
    alto = len(lineas) * int(tam * 1.26)
    y0 = int(H * 0.58) - alto // 2        # centro de la pantalla, como los virales
    for i, l in enumerate(lineas):
        y = y0 + i * int(tam * 1.26)
        x = (W - d.textlength(l, font=f)) / 2
        d.text((x + 2, y + 2), l, font=f, fill=(0, 0, 0, 120))
        d.text((x, y), l, font=f, fill=(255, 255, 255, 255))
    img.save(destino, quality=88)
    return destino


if __name__ == "__main__":
    pinta(sys.argv[1], sys.argv[2], sys.argv[3])
    print("OK", sys.argv[3])
comandos · de cero a las piezas montadaszsh
# 0 · dependencias
pip3 install fal-client requests pillow
brew install ffmpeg

# 1 · bajar los b-rolls de iCloud (si no, ffmpeg lee 0 bytes)
B="/Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS "
find "$B" -name "*.MOV" -exec brctl download {} \;

# 2 · refrescar el inventario de carpetas -> brolls.json
cd ~/projects/codigo/tanda-brolls && python3 - <<'EOF'
import json, subprocess, pathlib
M = json.load(open("matriz.json")); out = {}
for n, sub in M["rutas"].items():
    d = pathlib.Path(M["base"]) / sub; cl = []
    if d.is_dir():
        for p in sorted(d.iterdir()):
            if p.suffix.lower() in {".mov", ".mp4"} and not p.name.startswith("."):
                dur = subprocess.run(["ffprobe","-v","error","-show_entries","format=duration",
                                      "-of","csv=p=0",str(p)],capture_output=True,text=True).stdout.strip()
                cl.append({"nombre":p.name,"dur":round(float(dur),1),"mb":round(p.stat().st_size/1e6,1)})
    out[n] = {"sub": sub, "clips": cl}
json.dump(out, open("brolls.json","w"), ensure_ascii=False, indent=2)
EOF

# 3 · meter los vídeos IA y ver el reparto antes de gastar CPU
cp ~/Desktop/mis_videos_ia/*.mp4 ~/projects/codigo/tanda-brolls/entrada_ia/
python3 cruce.py entrada_ia/

# 4 · montar las piezas
python3 monta.py entrada_ia/ --gesto 3 --tope 18
open salida/

# 5 · el fotograma de ejemplo de una frase
python3 -c "import ejemplo_rotulo as e; e.pinta('frame0.jpg','2 años corriendo y NADIE me había dicho ESTO ??!','ejemplo.jpg')"