flickies: Una Caja de Herramientas de Vídeo Que Además Hace Lipsync

Estaba metido hasta el cuello en un pipeline de avatares y necesitaba dos cosas poco glamurosas a la vez: una pasada de lipsync, y un puñado de operaciones de ffmpeg alrededor, recortar la fuente, muxear el audio, transcodificar la salida, sacar una hoja de miniaturas. Cada “solución” que encontraba era algún wrapper SaaS por encima de un modelo que no podía inspeccionar, facturado por segundo de salida, encerrado tras una clave de API, con una marca de agua horneada en la esquina, porque no vaya a ser que pague 40 dólares al mes y encima sea dueño de mi propio render. Uno de ellos quería el vídeo de mi cara Y mi audio subidos a sus servidores antes siquiera de darme un precio. Otro tenía un nivel de “licencia comercial” que costaba más que mi gráfica. Me quedé ahí pensando: tengo una 3060 criando polvo en una caja sin hacer nada después de las seis, ffmpeg existe desde que el mundo es mundo, y Wav2Lip lleva siendo open source desde 2020. ¿Por qué cojones le estoy alquilando esto a un desconocido?

Así que dejé de hacerlo. flickies es la mitad de vídeo del kit autoalojado que llevo tiempo construyendo, docker run, lo apuntas a una cara y a una pista de audio, recuperas un mp4. Sin cuenta, sin contador por segundo, sin marca de agua, sin subir mis mierdas a la granja de inferencia de otro. Es el hermano de audiolla (audio) y de talkies (habla), mismo modelo de trabajos asíncronos, misma historia de bind mount en /data, misma barrera no comercial por consentimiento explícito, misma actitud de “un puerto, cero nube”. Se encaja directo en aigate como motor de vídeo, detrás de la misma puerta de nginx que todo lo demás que tengo corriendo.


Alquilarte Tu Propia Cara

El lipsync como servicio en la nube es un timo de una clase especial, y tengo una lista:

  • Estás subiendo una cara humana a la GPU de un desconocido. No una foto de un gato. Una cara, haciendo lo que coño le hayas dicho al audio que le hiciera decir. Esos datos no se evaporan cuando termina el render, se quedan en el disco de otro, con la política de retención de otro.
  • Facturar por segundo de salida convierte experimentar en un ejercicio de contabilidad. ¿Quieres iterar sobre diez tomas hasta clavar la sincronía? Enhorabuena, acabas de pagar diez veces.
  • Marcas de agua y muros de plan, el plan “gratis” te planta un logo en la salida, y el plan de pago que lo quita cuesta más que el hardware con el que correrías esto en local en un fin de semana.
  • Nadie te cuenta la historia de la licencia. Un número escandaloso de estos wrappers SaaS están montados encima de Wav2Lip, que está entrenado sobre LRS2, un conjunto de datos con una cláusula explícita de no comercial. El SaaS te cobra dinero por ejecutar un modelo no comercial y simplemente… no menciona esa parte. No es mi problema resolvértelo si te autoalojas, pero al menos te hago accionar activamente un interruptor para reconocerlo, en lugar de esconderlo en unos términos que no lee nadie.
  • Cero introspección. Te dan un endpoint REST de caja negra y un “confía en nosotros”, ni idea de qué variante del modelo se ejecutó, con qué ajustes, o si tu pasada de restauración de cara va encadenada o no.

Y por el lado de ffmpeg, recortar, transcodificar, plantarle audio a un vídeo, sacar una rejilla de miniaturas, cada “API de procesamiento de vídeo” que encontré para eso era de algún modo TAMBIÉN un producto de pago, para operaciones que son una sola invocación de ffmpeg con los flags correctos. Ya había resuelto el procesado simple de ficheros por red con mediaproc; flickies hace lo mismo de “llama a una herramienta de verdad, deja de reinventarla”, pero acotado específicamente a vídeo y cableado también para la mitad de ML.


Primero la Spec, Luego el Container

flickies es un servicio de FastAPI que habla un único formato de cable para dos tipos de trabajo muy distintos: operaciones de ffmpeg puras en CPU, e inferencia de modelos en GPU para lipsync y restauración de cara. Cada endpoint que produce vídeo acepta la misma forma de petición: exactamente una entrada (file_path preparado en local, o file_url que el servidor va a buscar por ti) y exactamente una salida (output_path escrito bajo FILES_DIR, o output_url al que el servidor hace PUT con el resultado, URL de S3 prefirmada, lo que sea). Los mezclas como quieras. Dejas un fichero en local, recuperas una URL prefirmada. Traes desde una URL, escribes en disco local. Le da igual.

La parte de ML pasa por un registro que gestiona un único pool de GPU con desalojo en caliente: pides wav2lip, se carga. Pides gfpgan después, el registro desaloja primero wav2lip (del a las referencias, gc.collect(), luego torch.cuda.empty_cache(), en ese orden exacto, porque los grafos de modelos de PyTorch mantienen ciclos de referencias y saltarse el paso de gc.collect() hace que “descargar” un modelo no libere de verdad la VRAM, un bug que entregué en v0.1.0/v0.2.0 y arreglé de verdad en v0.3.1). Un barrendero en segundo plano también descarga lo que esté residente en cuanto lleva ocioso más de FLICKIES_IDLE_UNLOAD_SECS (600s por defecto). Un solo modelo vive en VRAM a la vez; ese es el diseño entero.

Todo lo que viene después de eso, las rutas, las formas de petición y respuesta, los códigos de error, sale de un solo fichero: openapi.yaml. No es documentación escrita a posteriori, es la entrada real del generador para tres cosas distintas: los modelos de validación Pydantic del propio servidor, el cliente de Go y el cliente de Python. Cambias la spec, lanzas make generate, los tres se regeneran juntos. make generate-check es una barrera de CI que tumba el build si alguno de ellos se desvía de la spec. Más abajo explico por qué eso importa, porque es la parte de este proyecto de la que más me jacto.

Arranque Rápido

docker run -d --name flickies 
  -v $HOME/flickies-data:/data 
  -p 8000:8000 
  psyb0t/flickies:latest
curl -s -X POST https://ciprian.51k.eu00/v1/video/info 
  -H "Content-Type: application/json" 
  -d '{"file_path": "uploads/clip.mp4"}' | jq

Dos imágenes: psyb0t/flickies:latest (CPU, base python:3.12-slim) y psyb0t/flickies:latest-cuda (base nvidia/cuda 12.4 runtime). La imagen de CPU ejecuta todas las operaciones de ffmpeg más Wav2Lip en CPU, lento, pero real; la imagen CUDA lo ejecuta todo a una velocidad que de verdad aguantarías. El objetivo probado es una RTX 3060 de 12GB.


Cuatro Motores de ML: Lipsync y Restauración de Cara

engines.json define exactamente cuatro motores de ML, cada uno con un slug, una bandera de requisito de CUDA, un suelo de VRAM y, donde importa, una barrera de licencia:

wav2lip / wav2lip-gan

Rudrabha/Wav2Lip, metido en el repo, resolución nativa de 96×96. Dos variantes que comparten una misma clase de motor, conmutadas por un campo variant: base (máxima precisión de sincronía, boca más blanda) y gan (refinador GAN, boca más nítida, sincronía muy ligeramente peor). Las dos eligen dispositivo solas, FLICKIES_DEVICE=auto comprueba torch.cuda.is_available() y cae a CPU limpiamente. En el benchmark de la primera release eso son ~44 segundos para un clip de 3 segundos en CPU, ~22 segundos en GPU. Ese número de CPU no es broma, es genuinamente usable para clips cortos, que es más de lo que puedo decir de la mayoría de repos open source de lipsync con “GPU obligatoria” que simplemente revientan en una máquina sin gráfica en vez de degradarse con elegancia.

latentsync-1.5

LatentSync 1.5 de ByteDance, Apache-2.0, clavado específicamente al checkpoint 1.5 porque el 1.6 quiere 18GB de VRAM y mi techo de hardware es 12. Columna vertebral en espacio latente SD-1.5, embeddings de audio de Whisper-tiny haciendo cross-attention en un UNet3D vía AnimateDiff, maquinaria más pesada que Wav2Lip, y se nota: ~170 segundos para un clip de 6 segundos en la 3060, con pico alrededor de 9.6GB de VRAM. Este es el único motor de todo el conjunto que exige obligatoriamente CUDA, el código comprueba torch.cuda.is_available() al cargar y lanza un 400 si no está, sin intentar ningún repliegue a CPU. Es también el motor por defecto cuando no se ha activado la barrera no comercial, porque a diferencia de Wav2Lip no arrastra ningún lastre de LRS2.

gfpgan

GFPGAN v1.4 de TencentARC, Apache-2.0. Este se encadena después de Wav2Lip para arreglar el recorte de boca blando y de baja resolución que deja atrás la inferencia nativa a 96×96, Wav2Lip clava la sincronía, GFPGAN limpia el desastre visual de alrededor. También funciona por su cuenta vía POST /v1/video/restore si solo quieres una pasada de restauración de cara sobre material existente. Misma selección automática de dispositivo que Wav2Lip, cae a CPU. Fotograma a fotograma: lee con cv2, pasa el restaurador por cada fotograma, escribe en un mp4 mudo, y luego vuelve a muxear el audio original sobre los fotogramas restaurados.

Los pesos de los cuatro viven en la estructura estándar de caché de HuggingFace, bajo /data/hf/hub/models--<org>--<name>/, blobs direccionados por contenido, symlinks de snapshot, reutilizables por cualquier otra cosa que entienda de HF y comparta ese bind mount. Perezosos por defecto: cada motor se trae su repo en la primera petición. FLICKIES_PREFETCH_ALL=1 o un FLICKIES_ENABLED_ENGINES=wav2lip,gfpgan acotado tira de los pesos al arrancar, antes incluso de que uvicorn levante, para que tu primera petición de verdad no se coma una penalización de descarga en frío de varios minutos.

La barrera de licencia no es decorativa

Los pesos de Wav2Lip están entrenados sobre LRS2, un conjunto de datos no comercial. flickies no entierra eso en un README que no lee nadie, el código del servidor se niega físicamente a cargar cualquiera de las dos variantes de wav2lip salvo que FLICKIES_ENABLE_NONCOMMERCIAL=1 esté puesto en el entorno del servidor. Intenta adquirir el motor sin eso y require_noncommercial_optin() lanza un NonCommercialOptInRequired, que la API saca a la superficie como un error en condiciones en vez de un 500 silencioso. LatentSync 1.5 y GFPGAN son ambos Apache-2.0, sin barrera, carga libre. El mismo mecanismo que usa audiolla para sus propias barreras no comerciales de MusicGen y matchering, le robé el patrón a mí mismo, cosa que tengo permitida.


Siete Operaciones de ffmpeg, Porque No Todo Necesita Una GPU

La mitad de lo que la gente necesita de verdad de una “API de vídeo” no es ML en absoluto, es ffmpeg con valores por defecto con sentido y manejo de errores. flickies expone siete operaciones de ffmpeg puro, sin ningún modelo cargado, CPU puro, disponibles en las dos imágenes:

  • trim, corta a [start_sec, end_sec]. El modo por defecto es la copia de flujo con -c copy (rápido, pero se pega al keyframe más cercano, puede comerse hasta un GOP de contenido al principio). Pon precise: true y recodifica vía libx264 -crf 18 -preset veryfast más AAC 192k, para bordes exactos al fotograma.
  • concat, une 2 o más vídeos en orden mediante el demuxer concat. Mismo compromiso entre copia de flujo y preciso que en trim; precise: true recodifica a través del demuxer con parámetros de códec uniformes, para que entradas con codificadores dispares se unan de verdad en vez de corromperse.
  • transcode, recodificación universal entre mp4/webm/mov/mkv, con sobrescrituras de códec, crf, preset y fps. También trata la salida gif como una ruta especial de dos pasadas: palettegen y luego paletteuse a través de un filter_complex, porque una conversión ingenua de ffmpeg a gif parece una basura y lo sabe todo el mundo.
  • scale, redimensiona a ancho×alto, con relleno opcional que preserva la relación de aspecto.
  • mux_audio, sustituye o mezcla una pista de audio dentro de un vídeo.
  • extract_audio, saca la pista de audio como wav/mp3/m4a/ogg/flac.
  • thumbnail_grid, hoja de sprites PNG mediante los filtros thumbnail y tile, con filas, columnas y tamaño de celda todos configurables.

Hay una octava capacidad de vídeo que no está en esa lista de “operaciones” porque no produce un vídeo, /v1/video/info llama a ffprobe y te devuelve duración, códec, fps, dimensiones, bitrate. Absolutamente cada una de estas pasa por un único cuello de botella en ffmpeg.py que lanza el proceso vía asyncio.create_subprocess_exec, captura stderr, y lanza un error estructurado FFMPEG_FAILED si el código de salida no es cero, en lugar de dejar escapar un traceback en crudo.


Trabajos Asíncronos y Webhooks, Para Cuando No Vas a Quedarte Ahí Esperando

LatentSync a más de 170 segundos por clip no es algo para lo que quieras mantener una conexión HTTP abierta. Cada endpoint que produce vídeo acepta async_job: true (o simplemente omites los dos campos de salida y queda implícito): el servidor preasigna un id de trabajo, programa el trabajo como tarea de asyncio en segundo plano, y devuelve 202 {job_id, status: "accepted"} al instante. Consultas GET /v1/jobs/{job_id} para ver pending → running → complete/failed/cancelled. Si no quieres consultar, pasa un webhook_url y el servidor entrega él mismo el estado final del trabajo.

La entrega del webhook no es un POST lanzado al aire y a encogerse de hombros. Va firmada con HMAC-SHA256 sobre timestamp + "." + body, enviada como cabeceras X-Webhook-Timestamp y X-Webhook-Signature: t={ts},v1={hex}, con un calendario de reintentos en serio con backoff exponencial ante cualquier cosa que no sea un 2xx: 30s, 1m, 5m, 30m, 2h, 12h, y luego escribe una entrada de dead-letter y se rinde. Se espera que tu receptor deduplique por (timestamp, signature), por idempotencia. Tiene la misma forma que el contrato de webhooks de una pasarela de pago, porque ese es el único precedente que merece la pena copiar para “avisar a alguien de forma fiable de que un trabajo largo ha terminado”.


Once Herramientas en el Cable MCP

Montado en /v1/mcp como JSON-RPC sobre HTTP streamable, flickies expone once herramientas MCP que reflejan la superficie REST casi 1:1: list_engines, info, lipsync, restore, transcode, trim, concat, scale, mux_audio, extract_audio, thumbnail_grid. Apunta ahí un LLM con function calling, LibreChat, Cursor, Claude con el conector MCP, el framework de agentes que estés usando, y puede conducir el pipeline entero él solo: prepara un vídeo de cara, prepara un clip de audio, llama a lipsync con restore_face: true para encadenar GFPGAN automáticamente tras la pasada de sincronía (lo cual, conviene señalar, dispara un desalojo en caliente del modelo de lipsync en mitad de la llamada a la herramienta, es intencionado, libera la VRAM que necesita la pasada de restauración), y luego te devuelve una ruta o un tamaño.

Esta es también la capa hacia la que hace proxy aigate cuando activas FLICKIES=1 o FLICKIES_CUDA=1, una única puerta de nginx delante de cada uno de mis servicios de IA autoalojados, este incluido.


Primero la Spec: Clientes de Go y Python Generados del Mismo Puto Fichero

Esta es la parte que para mí separa de verdad a flickies de “otro wrapper de API sobre ML”. openapi.yaml no es decoración escrita después del código para parecer profesional, es la única fuente de verdad DESDE la que se generan tres artefactos distintos, no escritos para que cuadren:

make generate              # regenerate all three: server models + Go client + Python client
make generate-models       # just server-side Pydantic (src/flickies/schema/_generated.py)
make generate-client-go    # just the Go client (pkg/clients/go/client.gen.go)
make generate-client-python # just the Python client (pkg/clients/python/flickies-client/)
make generate-check        # CI gate — fails the build if generated files drift from openapi.yaml

Nunca edites a mano un fichero generado. Editas la spec, lanzas make generate, commiteas todo junto. El cliente de Go sale de oapi-codegen, el de Python de openapi-python-client, los dos paquetes reales, tipados e importables, no curl envuelto en una función como ocurrencia tardía:

go get github.com/psyb0t/docker-flickies/pkg/clients/go@latest
import flickies "github.com/psyb0t/docker-flickies/pkg/clients/go"
c, _ := flickies.NewClient("https://ciprian.51k.eu00")
resp, err := c.PostVideoLipsync(ctx, flickies.VideoLipsyncRequest{...})
pip install "git+https://github.com/psyb0t/docker-flickies.git#subdirectory=pkg/clients/python/flickies-client"
from flickies_client import Client
from flickies_client.api.lipsync import post_video_lipsync
from flickies_client.models import VideoLipsyncRequest
client = Client(base_url="https://ciprian.51k.eu00")
result = post_video_lipsync.sync(client=client, body=VideoLipsyncRequest(...))

No es perfecto, los structs VideoTrimRequest y VideoConcatRequest del cliente de Go pierden de momento start_sec/end_sec/precise, por una limitación conocida de oapi-codegen con esquemas compuestos mediante allOf (el bloque properties inline del tipo compuesto se pierde). Mientras tanto, quien llama desde Go serializa esos cuerpos concretos vía map[string]any. Prefiero documentar una limitación real del generador que fingir que el pipeline es impecable, la gracia no es que la generación de código sea magia, es que la spec y cada cliente que la habla son mecánicamente incapaces de separarse, porque todos salen del mismo paso de build.


Logs, Auth, Límites de Peticiones, la Mierda Aburrida Que De Verdad Importa a las 3 de la Mañana

El logging estructurado en JSON va a stderr Y a un fichero rotativo (FLICKIES_LOG_FILE, 50MB × 5 copias por defecto), con un formateador propio que censura recursivamente cualquier cosa que encaje con password|token|secret|api_key|authorization|cookie|hf_*|sk-ant-*, tanto en claves como en valores, en el momento de formatear, antes de que la línea llegue a escribirse. Cada registro lleva un trace_id y un request_id hilados a través de un ContextVar, sembrados desde la cabecera X-Request-Id entrante si tiene forma válida (UUID v4 o ULID, 64 caracteres como mucho, sin saltos de línea, a la entrada basura simplemente se le acuña un UUID nuevo en lugar de devolverla como eco, lo que cierra un vector de inyección en logs). Pon FLICKIES_LOG_LEVEL=DEBUG y obtienes trazas de calidad reconstrucción: cada comando de ffmpeg y ffprobe, cada decisión de trim o concat entre copia de flujo y recodificación precisa, el tiempo de reloj de inferencia de cada motor, los bytes traídos y subidos por URL, las transiciones del ciclo de vida de los trabajos. A las URL logueadas se les quita primero la query string, así que un output_url prefirmado no filtra su firma a tus ficheros de log.

La auth es un bearer token estático vía FLICKIES_AUTH_TOKEN, comprobado con hmac.compare_digest para que no sea atacable por temporización, con /healthz exento para que las sondas de tu orquestador sigan funcionando. No pongas la variable y la auth queda sin más apagada, tu decisión, tu frontera de red. Encima de eso: un limitador de peticiones de cubo de fichas por IP (60 peticiones por minuto por defecto, ajustable), y una capa de deduplicación basada en Idempotency-Key que cachea (estado, cuerpo) por (clave, método, ruta), para que un POST reintentado no ejecute dos veces un render caro. Los tres son middleware ASGI de librería estándar pura, sin una dependencia extra solo para limitar peticiones.


Dónde Vive

flickies se monta dentro de aigate en /flickies/ y /flickies-cuda/, detrás del mismo nginx que hace de frontal para todos los demás servicios de IA autoalojados que tengo, un solo make run-bg, las dos variantes conmutadas con FLICKIES=1 y FLICKIES_CUDA=1. Es la pieza con forma de vídeo del mismo puzle que audiolla y talkies rellenan para audio y habla, mismo contrato de cable, misma ergonomía de operador, así que añadir un cuarto o un quinto servicio a ese stack más adelante no significa reaprender nada.


Lo Que Realmente Te Llevas

flickies es una caja de herramientas de vídeo que da la casualidad de que incluye lipsync desde el principio: cuatro motores de verdad detrás de un único pool de GPU con intercambio en caliente, siete operaciones de ffmpeg que no pretenden ser nada más elegante que ffmpeg, trabajos asíncronos con webhooks realmente firmados en lugar de un POST lanzado al aire, once herramientas MCP para el agente que le apuntes, y clientes tipados en Go y Python generados desde la spec exacta contra la que valida el servidor, no mantenidos a mano, sin desviarse, sin mentirte sobre lo que la API acepta de verdad. Autoalojado, WTFPL, corre en una GPU que ya tienes.

Cógelo en github.com/psyb0t/docker-flickies o tira las imágenes directamente de Docker Hub. Haz lo que quieras con ello, pero deja de pagarle a un SaaS para que ejecute un modelo open source sobre hardware que podrías haber comprado a tocateja con tres meses de su factura.

Cómo Instalarlo En Tu Agente

Ponerle una caja de herramientas de vídeo en las manos a un agente funciona mejor cuando ya se sabe la lista de herramientas. Todo lo que hay bajo .agents/ está catalogado en un solo marketplace, así que son dos comandos:

claude plugin marketplace add psyb0t/agents
claude plugin install flickies@psyb0t

Codex usa el mismo marketplace con un verbo distinto, codex plugin add flickies@psyb0t, porque no existe codex plugin install. También encuentra el skill por su cuenta en un checkout del repo, ya que escanea .agents/skills/ de forma nativa sin nada instalado en absoluto. Además ahora está listado en el MCP Registry oficial, así que un cliente que resuelva servidores desde ahí puede encontrarlo sin que le den una URL.