ejemplo · lo escribe él en TikTokCanal Reposters · de un vídeo que ya funcionó en TikTok al MP4 que sube el reposter. Siete pasos.
Un vídeo que ya funcionó en TikTok. De ahí sale el gesto.
De ese vídeo se congela el segundo 0. Es la foto que manda.
La IA cambia la persona en esa foto. Misma pose, otra cara.
Se anima la foto usando el vídeo de TikTok como coreografía.
La tabla dice qué frase puede ir y con qué b-roll.
Cara delante, b-roll de la app detrás. Un solo MP4.
No va quemado: el reposter lo escribe desde la plataforma.
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.
1080×1920 · 30 fps · 18 s
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.
@kailyn.lifts · 13,7M de visualizaciones. Lo que se copia es el recorte de los primeros segundos: el gesto, no el vídeo entero.
| 01 | Mano en la cara | @kailyn.lifts ↗TikTok | 13,7M | 0,56% |
| 02 | Se tapa la boca | @honousmitfv ↗TikTok | 1,2M | 6,70% |
| 03 | Sorpresa contenida | @danclipping ↗X | n/d | n/d |
| 04 | No me lo creo | @sarafinalyfts ↗TikTok | 2,7M | 3,17% |
| 05 | Mano en la cara | @sarafinalyfts ↗TikTok | 4,9M | 0,60% |
| 06 | Se tapa la boca | @julesshufl ↗TikTok | 827,5K | 13,60% |
| 07 | Sorpresa contenida | @klaraa.ipod ↗TikTok | 17,6K | 3,68% |
| 08 | No me lo creo | @klaraa.ipod ↗TikTok | 1,2M | 15,23% |
| 09 | Mano en la cara | @klaraa.ipod ↗TikTok | 816,8K | 9,17% |
| 10 | Se tapa la boca | @sarafinalyfts ↗TikTok | 132,9K | 0,69% |
| 11 | Sorpresa contenida | @sophiaas.mp3 ↗TikTok | 20,7K | 3,65% |
| 12 | No me lo creo | @kailyn.lifts ↗TikTok | 348,7K | 0,44% |
Los 12 enlaces están en la tabla de arriba. Once de TikTok y uno de X (@danclipping).
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).
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.
/Users/cristobal/Library/Mobile Documents/com~apple~CloudDocs/ACTIVE/Reposters/TANDA VIDEOS - BROLLS /
Cinco carpetas: PLAN, HOME, ESCÁNER - NUTRI, FUERZA, RECORRIDO LARGO.
~/projects/codigo/tanda-brolls/ · entrada_ia/ → salida/
matriz.json, cruce.py, monta.py, ejemplo_rotulo.py. Todo el código está abajo.
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 /
…/TANDA VIDEOS - BROLLS /PLAN…/TANDA VIDEOS - BROLLS /HOME …/TANDA VIDEOS - BROLLS /ESCÁNER - NUTRI


…/TANDA VIDEOS - BROLLS /FUERZA 


…/TANDA VIDEOS - BROLLS /RECORRIDO LARGO 


| Frase | Semántica | Plan | Home | Escáner | Fuerza | Recorrido |
|---|---|---|---|---|---|---|
| 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 ESTO | CONDICIONAL | ● | ✓ | — | — | ✓ |
| ¿CÓMO QUE existe ESTO y no lo sabía nadie ??? | INCREDULIDAD | ✓ | ✓ | ✓ | ✓ | ✓ |
| Esto no puede ser real ??? | INCREDULIDAD | ✓ | ✓ | ✓ | ✓ | ✓ |
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
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTok
ejemplo · lo escribe él en TikTokLos 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.
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
{
"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"}}
]
}
#!/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")
#!/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")
#!/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])
# 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')"