flickies: Een Videogereedschapskist Die Toevallig Ook Lipsync Doet

Ik zat tot mijn nek in een avatarpipeline en had twee weinig glamoureuze dingen tegelijk nodig: een lipsync-pass, en een handvol ffmpeg-operaties eromheen, de bron bijknippen, de audio muxen, de uitvoer transcoderen, een thumbnailvel uitsnijden. Elke “oplossing” die ik vond was een of andere SaaS-wrapper om een model dat ik niet kon inspecteren, afgerekend per seconde uitvoer, weggezet achter een API-sleutel, met een watermerk in de hoek gebakken, want stel je voor dat ik 40 dollar per maand betaal en toch mijn eigen render bezit. Eén ervan wilde mijn gezichtsvideo ÉN mijn audio op hun servers geüpload hebben voordat ze me überhaupt een prijs noemden. Een ander had een “commerciële licentie”-laag die meer kostte dan mijn videokaart. Ik zat daar te denken: ik heb een 3060 die in een kast ligt en na zessen niks uitvoert, ffmpeg bestaat al sinds mensenheugenis, en Wav2Lip is open source sinds 2020. Waarom huur ik dit verdomme van een vreemde.

Dus dat deed ik niet meer. flickies is de videohelft van de zelfgehoste gereedschapskist die ik al een tijd aan het bouwen ben, docker run, je wijst het naar een gezicht en een audiospoor, je krijgt een mp4 terug. Geen account, geen teller per seconde, geen watermerk, geen shit van mij die naar andermans inferentieboerderij wordt geüpload. Het is de broer van audiolla (audio) en talkies (spraak), hetzelfde model van asynchrone jobs, hetzelfde bind-mount-/data-verhaal, dezelfde niet-commerciële opt-in-poort, dezelfde houding van “één poort, nul cloud”. Het klikt rechtstreeks in aigate als video-engine, achter dezelfde nginx-voordeur als al het andere dat ik draai.


Je Eigen Gezicht Terughuren

Lipsync als clouddienst is een heel eigen soort oplichterij, en ik heb een lijstje:

  • Je uploadt een menselijk gezicht naar de GPU van een vreemde. Geen kattenfoto. Een gezicht, dat verdomme zegt wat jij de audio hebt laten zeggen. Die data verdampt niet zodra de render klaar is, die blijft op andermans schijf staan, met andermans bewaarbeleid.
  • Afrekenen per seconde uitvoer maakt van experimenteren een boekhoudkundige oefening. Wil je tien takes proberen tot de sync klopt? Gefeliciteerd, je hebt net tien keer betaald.
  • Watermerken en abonnementsmuren, de “gratis” laag klapt een logo op je uitvoer, en de betaalde laag die het weghaalt kost meer dan de hardware waarop je dit in een weekend lokaal zou draaien.
  • Niemand vertelt je het licentieverhaal. Een schokkend aantal van deze SaaS-wrappers zit bovenop Wav2Lip, dat getraind is op LRS2, een dataset met een expliciete niet-commerciële clausule. De SaaS vraagt je geld om een niet-commercieel model te draaien en noemt dat stukje gewoon… niet. Het is niet aan mij om dat voor jou op te lossen als je zelf host, maar ik laat je tenminste actief een schakelaar omzetten om het te erkennen, in plaats van het weg te stoppen in voorwaarden die niemand leest.
  • Nul inzicht. Je krijgt een black box-REST-endpoint en een “vertrouw ons”, geen idee welke modelvariant draaide, met welke instellingen, of je gezichtsherstel-pass eraan vastzit of niet.

En aan de ffmpeg-kant, knippen, transcoderen, audio op een video plakken, een thumbnailraster trekken, elke “videoverwerkings-API” die ik daarvoor vond was op de een of andere manier OOK een betaald product, voor operaties die uit één ffmpeg-aanroep met de juiste vlaggen bestaan. Kale bestandsverwerking over het netwerk had ik al opgelost met mediaproc; flickies doet hetzelfde “roep een echt stuk gereedschap aan, hou op het opnieuw uit te vinden”, maar dan specifiek afgebakend op video en ook bedraad voor de ML-helft.


Eerst de Spec, Dan de Container

flickies is een FastAPI-dienst die één draadformaat spreekt voor twee heel verschillende soorten werk: pure ffmpeg-operaties op de CPU, en modelinferentie op de GPU voor lipsync en gezichtsherstel. Elk endpoint dat video produceert neemt dezelfde vorm van verzoek aan: precies één invoer (file_path lokaal klaargezet, of file_url die de server voor je ophaalt) en precies één uitvoer (output_path geschreven onder FILES_DIR, of output_url waar de server het resultaat naartoe PUT, vooraf ondertekende S3-URL, wat je maar wilt). Naar believen te combineren. Zet een bestand lokaal klaar, krijg een vooraf ondertekende URL terug. Haal van een URL, schrijf naar lokale schijf. Het kan hem niets schelen.

De ML-kant loopt via een registry die één GPU-pool beheert met hot-swap-uitzetting: je vraagt wav2lip, hij laadt. Je vraagt daarna gfpgan, de registry zet eerst wav2lip eruit (del op de referenties, gc.collect(), dan torch.cuda.empty_cache(), in precies die volgorde, want PyTorch-modelgrafen houden referentiecycli vast en het overslaan van de gc.collect()-stap betekent dat het “uitladen” van een model het VRAM helemaal niet vrijgeeft, een bug die ik in v0.1.0/v0.2.0 heb uitgeleverd en in v0.3.1 echt heb gefixt). Een achtergrondveger laadt ook uit wat er resident is zodra het langer inactief is dan FLICKIES_IDLE_UNLOAD_SECS (standaard 600s). Eén model leeft tegelijk in VRAM; dat is het hele ontwerp.

Alles wat daarna komt, de routes, de vormen van verzoek en antwoord, de foutcodes, komt uit één bestand: openapi.yaml. Het is geen documentatie achteraf, het is de daadwerkelijke generatorinvoer voor drie aparte dingen: de Pydantic-validatiemodellen van de server zelf, de Go-client en de Python-client. Verander de spec, draai make generate, alle drie worden samen opnieuw gegenereerd. make generate-check is een CI-poort die de build laat falen als er eentje van de spec afdrijft. Waarom dat uitmaakt komt hieronder, want dat is het stuk van dit project waar ik het meest over opschep.

Snelle Start

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

Twee images: psyb0t/flickies:latest (CPU, basis python:3.12-slim) en psyb0t/flickies:latest-cuda (basis nvidia/cuda 12.4 runtime). De CPU-image draait elke ffmpeg-operatie plus Wav2Lip op de CPU, traag, maar echt; de CUDA-image draait alles op een snelheid die je daadwerkelijk zou verdragen. Het geteste doelwit is een RTX 3060 van 12GB.


Vier ML-engines: Lipsync en Gezichtsherstel

engines.json definieert precies vier ML-engines, elk met een slug, een CUDA-vereistevlag, een VRAM-ondergrens en, waar het uitmaakt, een licentiepoort:

wav2lip / wav2lip-gan

Rudrabha/Wav2Lip, in de repo opgenomen, native resolutie 96×96. Twee varianten die één engineklasse delen, omgeschakeld via een variant-veld: base (maximale syncnauwkeurigheid, zachtere mond) en gan (GAN-verfijner, scherpere mond, heel licht slechtere sync). Beide kiezen hun apparaat zelf, FLICKIES_DEVICE=auto controleert torch.cuda.is_available() en valt netjes terug op CPU. In de benchmark van de eerste release is dat ~44 seconden voor een clip van 3 seconden op de CPU, ~22 seconden op de GPU. Dat CPU-getal is geen grap, het is oprecht bruikbaar voor korte clips, wat ik niet kan zeggen van de meeste open source lipsync-repo’s met “GPU vereist” die op een machine zonder kaart gewoon crashen in plaats van netjes terug te vallen.

latentsync-1.5

ByteDance’s LatentSync 1.5, Apache-2.0, bewust vastgezet op het 1.5-checkpoint omdat 1.6 18GB VRAM wil en mijn hardwareplafond op 12 ligt. Ruggengraat in de SD-1.5-latentruimte, Whisper-tiny audio-embeddings die cross-attention doen in een UNet3D via AnimateDiff, zwaardere machinerie dan Wav2Lip, en dat merk je: ~170 seconden voor een clip van 6 seconden op de 3060, met een piek rond 9.6GB VRAM. Dit is de enige engine uit de hele set die CUDA hard vereist, de code controleert bij het laden torch.cuda.is_available() en gooit een 400 als het er niet is, zonder ook maar een poging tot terugvallen op CPU. Het is ook de standaard-engine zolang er niet actief voor de niet-commerciële poort is gekozen, want anders dan Wav2Lip sleept hij geen LRS2-bagage mee.

gfpgan

TencentARC’s GFPGAN v1.4, Apache-2.0. Deze hangt achter Wav2Lip om de zachte, laag opgeloste monduitsnede te repareren die native inferentie op 96×96 achterlaat, Wav2Lip zit precies op de sync, GFPGAN ruimt de visuele rotzooi eromheen op. Hij werkt ook op zichzelf via POST /v1/video/restore als je alleen een gezichtsherstel-pass over bestaand materiaal wilt. Dezelfde automatische apparaatkeuze als Wav2Lip, valt terug op CPU. Beeld voor beeld: lezen via cv2, de hersteller per beeld draaien, naar een stille mp4 schrijven, en dan de originele audio weer over de herstelde beelden muxen.

De gewichten voor alle vier staan in de standaard HuggingFace-cachestructuur, onder /data/hf/hub/models--<org>--<name>/, inhoudsgeadresseerde blobs, snapshot-symlinks, herbruikbaar door alles wat HF kent en dezelfde bind mount deelt. Standaard lui: elke engine haalt zijn repo op bij het eerste verzoek. FLICKIES_PREFETCH_ALL=1 of een ingeperkte FLICKIES_ENABLED_ENGINES=wav2lip,gfpgan trekt de gewichten binnen bij het opstarten, nog voordat uvicorn draait, zodat je eerste echte verzoek geen kouddownload-straf van meerdere minuten opvreet.

De licentiepoort is geen versiering

De gewichten van Wav2Lip zijn getraind op LRS2, een niet-commerciële dataset. flickies begraaft dat niet in een README die niemand leest, de servercode weigert fysiek om een van beide wav2lip-varianten te laden tenzij FLICKIES_ENABLE_NONCOMMERCIAL=1 in de serveromgeving staat. Probeer de engine zonder dat te pakken en require_noncommercial_optin() gooit een NonCommercialOptInRequired, die de API als een fatsoenlijke fout naar boven brengt in plaats van als een stille 500. LatentSync 1.5 en GFPGAN zijn allebei Apache-2.0, geen poort, vrij laden. Hetzelfde mechanisme dat audiolla gebruikt voor zijn eigen niet-commerciële poorten bij MusicGen en matchering, ik heb het patroon van mezelf gejat, wat mag.


Zeven ffmpeg-operaties, Want Niet Alles Heeft Een GPU Nodig

De helft van wat mensen werkelijk nodig hebben van een “video-API” is helemaal geen ML, het is ffmpeg met verstandige standaardwaarden en foutafhandeling. flickies biedt zeven pure ffmpeg-operaties, geen model geladen, pure CPU, beschikbaar in beide images:

  • trim, knipt naar [start_sec, end_sec]. De standaardmodus is stream-copy met -c copy (snel, maar klikt vast op het dichtstbijzijnde keyframe, kan tot een hele GOP aan begincontent opeten). Zet precise: true en hij hercodeert via libx264 -crf 18 -preset veryfast plus AAC 192k, voor beeldnauwkeurige grenzen.
  • concat, plakt 2 of meer video’s op volgorde aan elkaar via de concat-demuxer. Dezelfde afweging tussen stream-copy en precies als bij trim; precise: true hercodeert door de demuxer met uniforme codecparameters, zodat invoer met ongelijke encoders echt aan elkaar gaat in plaats van corrupt te raken.
  • transcode, universele hercodering tussen mp4/webm/mov/mkv, met overschrijvingen voor codec, crf, preset en fps. Behandelt gif-uitvoer ook als een speciaal tweepassenpad: palettegen en dan paletteuse door een filter_complex, want een naïeve conversie van ffmpeg naar gif ziet eruit als troep en dat weet iedereen.
  • scale, schaalt naar breedte×hoogte, met optionele opvulling die de beeldverhouding behoudt.
  • mux_audio, vervangt een audiospoor in een video of mengt er een in.
  • extract_audio, haalt het audiospoor eruit als wav/mp3/m4a/ogg/flac.
  • thumbnail_grid, sprite-sheet-PNG via de filters thumbnail en tile, rijen, kolommen en celgrootte allemaal instelbaar.

Er is een achtste videomogelijkheid die niet in die “operaties”-lijst staat omdat hij geen video produceert, /v1/video/info roept ffprobe aan en geeft je duur, codec, fps, afmetingen en bitrate terug. Werkelijk elk van deze loopt door één knelpunt in ffmpeg.py dat het proces start via asyncio.create_subprocess_exec, stderr opvangt, en bij een exitcode ongelijk aan nul een gestructureerde FFMPEG_FAILED-fout gooit in plaats van een rauwe traceback te laten lekken.


Asynchrone Jobs en Webhooks, Voor Als Je Er Niet Naast Gaat Zitten Wachten

LatentSync op meer dan 170 seconden per clip is niet iets waarvoor je een HTTP-verbinding open wilt houden. Elk endpoint dat video produceert accepteert async_job: true (of je laat gewoon beide uitvoervelden weg en het is impliciet): de server reserveert vooraf een job-id, plant het werk als achtergrond-asyncio-taak, en geeft meteen 202 {job_id, status: "accepted"} terug. Je pollt GET /v1/jobs/{job_id} voor pending → running → complete/failed/cancelled. Wil je niet pollen, geef dan een webhook_url mee en de server levert de eindstand van de job zelf af.

De webhookaflevering is geen weggegooide POST met een schouderophalen. Hij is HMAC-SHA256-ondertekend over timestamp + "." + body, verstuurd als headers X-Webhook-Timestamp en X-Webhook-Signature: t={ts},v1={hex}, met een serieus herhaalschema met exponentiële backoff bij alles wat geen 2xx is: 30s, 1m, 5m, 30m, 2h, 12h, en dan schrijft hij een dead-letter-regel en geeft het op. Van jouw ontvanger wordt verwacht dat hij dedupliceert op (timestamp, signature), voor idempotentie. Dit heeft dezelfde vorm als het webhookcontract van een betaalprovider, want dat is het enige voorwerk dat het kopiëren waard is voor “iemand betrouwbaar vertellen dat een lange job klaar is”.


Elf Tools Op de MCP-draad

Gemount op /v1/mcp als JSON-RPC over streamable HTTP, biedt flickies elf MCP-tools die de REST-oppervlakte bijna 1:1 spiegelen: list_engines, info, lipsync, restore, transcode, trim, concat, scale, mux_audio, extract_audio, thumbnail_grid. Wijs er een LLM met function calling naartoe, LibreChat, Cursor, Claude met de MCP-connector, welk agentframework je ook draait, en het kan de hele pipeline zelf besturen: een gezichtsvideo klaarzetten, een audioclip klaarzetten, lipsync aanroepen met restore_face: true om GFPGAN automatisch achter de syncpass te hangen (wat, het vermelden waard, midden in de toolaanroep een hot-swap-uitzetting van het lipsync-model veroorzaakt, dat is opzettelijk, het maakt het VRAM vrij dat de herstelpass nodig heeft), en je dan een pad of een grootte teruggeven.

Dit is ook de laag waar aigate naartoe proxyt zodra je FLICKIES=1 of FLICKIES_CUDA=1 omzet, één nginx-voordeur voor elk van mijn zelfgehoste AI-diensten, deze inbegrepen.


Spec Eerst: Go- en Python-clients Gegenereerd Uit Hetzelfde Verrekte Bestand

Dit is voor mij het stuk dat flickies echt onderscheidt van “weer een ML-wrapper-API”. openapi.yaml is geen versiering die na de code is geschreven om professioneel te lijken, het is de enige bron van waarheid WAARUIT drie aparte artefacten worden gegenereerd, niet ernaartoe geschreven:

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

Bewerk gegenereerde bestanden nooit met de hand. Je bewerkt de spec, draait make generate, commit alles samen. De Go-client komt uit oapi-codegen, de Python-client uit openapi-python-client, allebei echte, getypeerde, importeerbare pakketten, geen curl-in-een-functie als bijgedachte:

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(...))

Het is niet perfect, de structs VideoTrimRequest en VideoConcatRequest van de Go-client missen op dit moment start_sec/end_sec/precise, door een bekende beperking van oapi-codegen bij schema’s die via allOf zijn samengesteld (het inline properties-blok op het samengestelde type valt weg). Go-aanroepers serialiseren die specifieke bodies ondertussen via map[string]any. Ik documenteer liever een echte beperking van de generator dan te doen alsof de pipeline vlekkeloos is, het punt is niet dat codegeneratie magie is, het punt is dat de spec en elke client die hem spreekt mechanisch niet uit elkaar kunnen lopen, omdat ze allemaal uit dezelfde buildstap komen.


Logging, Auth, Rate Limits, De Saaie Shit Die Er Om 3 Uur ‘s Nachts Echt Toe Doet

Gestructureerde JSON-logging gaat naar stderr ÉN naar een roterend bestand (FLICKIES_LOG_FILE, standaard 50MB × 5 backups), met een eigen formatter die alles wat matcht op password|token|secret|api_key|authorization|cookie|hf_*|sk-ant-* recursief zwart maakt, zowel in sleutels als in waarden, op het moment van formatteren, voordat de regel ooit wordt weggeschreven. Elke logregel draagt een trace_id en een request_id, doorgeregen via een ContextVar, gevoed vanuit de binnenkomende X-Request-Id-header als die een geldige vorm heeft (UUID v4 of ULID, hooguit 64 tekens, geen nieuwe regels, rommelinvoer krijgt gewoon een vers geslagen UUID in plaats van teruggekaatst te worden, wat een log-injectievector dichtzet). Zet FLICKIES_LOG_LEVEL=DEBUG en je krijgt tracing van reconstructiekwaliteit: elk ffmpeg- en ffprobe-commando, elke trim- of concat-beslissing tussen stream-copy en precieze hercodering, de kloktijd van de inferentie per engine, de opgehaalde en geüploade bytes per URL, de levenscyclusovergangen van jobs. Bij gelogde URL’s wordt eerst de query string afgeknipt, dus een vooraf ondertekende output_url lekt zijn handtekening niet naar je logbestanden.

De auth is een statisch bearer token via FLICKIES_AUTH_TOKEN, gecontroleerd met hmac.compare_digest zodat hij niet via timing aan te vallen is, met /healthz uitgezonderd zodat de probes van je orchestrator blijven werken. Zet de variabele niet en de auth staat gewoon uit, jouw keuze, jouw netwerkgrens. Daarbovenop: een token-bucket rate limiter per IP (standaard 60 verzoeken per minuut, instelbaar), en een dedupe-laag op basis van Idempotency-Key die (status, body) cachet per (sleutel, methode, pad), zodat een opnieuw geprobeerde POST een dure render niet dubbel draait. Alle drie zijn ASGI-middleware uit pure standaardbibliotheek, geen extra dependency alleen maar om verzoeken af te knijpen.


Waar Het Woont

flickies mount zichzelf binnen aigate op /flickies/ en /flickies-cuda/, achter dezelfde nginx die voor elke andere zelfgehoste AI-dienst staat die ik draai, één make run-bg, beide varianten omgezet met FLICKIES=1 en FLICKIES_CUDA=1. Het is het videovormige stuk van dezelfde puzzel die audiolla en talkies invullen voor audio en spraak, hetzelfde draadcontract, dezelfde ergonomie voor de beheerder, dus er later een vierde of vijfde dienst bij hangen betekent niet dat je alles opnieuw moet leren.


Wat Je Werkelijk Krijgt

flickies is een videogereedschapskist die toevallig vanaf het begin lipsync bevat: vier echte engines achter één hot-swap-GPU-pool, zeven ffmpeg-operaties die niet doen alsof ze iets chiquers zijn dan ffmpeg, asynchrone jobs met werkelijk ondertekende webhooks in plaats van een weggegooide POST, elf MCP-tools voor welke agent je er ook op richt, en getypeerde clients in Go en Python die gegenereerd zijn uit precies de spec waartegen de server valideert, niet met de hand onderhouden, niet afdrijvend, niet tegen je liegend over wat de API echt accepteert. Zelfgehost, WTFPL, draait op een GPU die je al hebt.

Pak het bij github.com/psyb0t/docker-flickies of trek de images rechtstreeks van Docker Hub. Doe ermee wat je wilt, maar hou op met een SaaS te betalen om een open source model te draaien op hardware die je met drie maanden van hun factuur helemaal had kunnen kopen.

Zo Zet Je Het In Je Agent

Een videogereedschapskist aan een agent geven werkt beter als hij de toollijst al kent. Alles onder .agents/ staat in één marketplace gecatalogiseerd, dus het zijn twee commando’s:

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

Codex gebruikt dezelfde marketplace met een ander werkwoord, codex plugin add flickies@psyb0t, omdat codex plugin install niet bestaat. Hij vindt de skill ook uit zichzelf in een checkout van de repo, aangezien hij .agents/skills/ native scant zonder dat er iets geïnstalleerd is. Het staat nu ook in het officiële MCP Registry, dus een client die zijn servers daarvandaan oplost kan het vinden zonder dat je hem een URL geeft.