Eram băgat până la gât într-un pipeline de avataruri și îmi trebuiau două chestii neglamuroase deodată: o trecere de lipsync și un pumn de operații ffmpeg în jurul ei, taie sursa, bagă audio peste, transcodează ieșirea, scoate o planșă de thumbnailuri. Fiecare “soluție” pe care o găseam era vreun wrapper SaaS peste un model pe care nu îl puteam inspecta, taxat pe secunda de ieșire, ținut în spatele unei chei de API, cu un watermark copt în colț, că doamne ferește să plătesc 40 de dolari pe lună și totuși să îmi dețin propriul render. Unul dintre ele voia și videoul cu fața mea, ȘI audio, încărcate pe serverele lor, înainte să îmi dea măcar un preț. Altul avea un nivel cu “licență comercială” care costa mai mult decât placa mea video. Am stat acolo gândindu-mă: am un 3060 care zace într-o cutie și nu face nimic după ora 18, ffmpeg există de pe vremea bunicii, iar Wav2Lip e open source din 2020. De ce mama dracului închiriez chestia asta de la un străin.
Așa că nu am mai făcut-o. flickies e jumătatea video din trusa self-hosted pe care o tot construiesc, docker run, îl îndrepți spre o față și o pistă audio, primești un mp4 înapoi. Fără cont, fără contor pe secundă, fără watermark, fără să îmi urc căcaturile pe ferma de inferență a altcuiva. E fratele lui audiolla (audio) și al lui talkies (vorbire), același model de joburi asincrone, aceeași poveste cu bind mount pe /data, aceeași poartă opt-in pentru necomercial, aceeași atitudine de “un port, zero cloud”. Se îmbucă direct în aigate ca motor video, în spatele aceleiași uși de nginx ca tot restul lucrurilor pe care le rulez.
Cum Îți Închiriezi Propria Față Înapoi
Lipsyncul ca serviciu în cloud e un tip special de țeapă, și am o listă:
- Urci fața unui om pe placa video a unui străin. Nu o poză cu pisici. O față, care face orice dracului i-ai zis audio-ului să o pună să spună. Datele alea nu se evaporă după ce se termină renderul, stau pe discul altcuiva, cu politica de retenție a altcuiva.
- Taxarea pe secunda de ieșire transformă experimentarea într-un exercițiu de contabilitate. Vrei să iterezi pe zece duble ca să prinzi sincronul? Felicitări, tocmai ai plătit de zece ori.
- Watermarkuri și ziduri de abonament, nivelul “gratuit” îți plesnește un logo pe ieșire, iar nivelul plătit care îl scoate costă mai mult decât hardware-ul pe care ai rula asta local într-un weekend.
- Nimeni nu îți spune povestea licenței. Un număr șocant din wrapperele astea de SaaS stau peste Wav2Lip, care e antrenat pe LRS2, un set de date cu o clauză explicită de necomercial. SaaS-ul îți ia bani ca să ruleze un model necomercial și pur și simplu… nu pomenește partea aia. Nu e problema mea să ți-o rezolv dacă te autogăzduiești, dar măcar te pun să apeși activ un comutator ca să recunoști asta, în loc să o ascund într-un ToS pe care nu îl citește nimeni.
- Zero introspecție. Capeți un endpoint REST cutie neagră și un “ai încredere în noi”, habar n-ai ce variantă de model a rulat, cu ce setări, dacă trecerea de restaurare a feței e înlănțuită sau nu.
Iar pe partea de ffmpeg, tăiat, transcodat, plesnit audio peste un video, scos o grilă de thumbnailuri, fiecare “API de procesare video” pe care l-am găsit pentru asta era cumva TOT un produs cu plată, pentru operații care sunt o singură invocare de ffmpeg cu flagurile potrivite. Deja rezolvasem procesarea simplă de fișiere prin rețea cu mediaproc; flickies face același lucru de “cheamă o unealtă adevărată, nu o mai reinventa”, dar limitat special la video și cablat și pentru jumătatea de ML.
Întâi Specificația, Apoi Containerul
flickies e un serviciu FastAPI care vorbește un singur format pe fir pentru două feluri foarte diferite de muncă: operații ffmpeg pure pe CPU și inferență de model pe GPU pentru lipsync și restaurare de față. Fiecare endpoint care produce video ia aceeași formă de cerere: exact o intrare (file_path pregătit local, sau file_url pe care îl aduce serverul pentru tine) și exact o ieșire (output_path scris sub FILES_DIR, sau output_url la care serverul face PUT cu rezultatul, URL S3 presemnat, ce vrei). Le combini cum vrei. Pregătești un fișier local, primești înapoi un URL presemnat. Aduci de la un URL, scrii pe disc local. Nu îi pasă.
Partea de ML trece printr-un registru care administrează un singur pool de GPU cu evacuare la cald: ceri wav2lip, se încarcă. Ceri gfpgan după, registrul evacuează întâi wav2lip (del pe referințe, gc.collect(), apoi torch.cuda.empty_cache(), exact în ordinea asta, pentru că grafurile de modele PyTorch țin cicluri de referințe, iar dacă sari peste pasul gc.collect(), “descărcarea” unui model nu eliberează de fapt VRAM-ul, un bug pe care l-am livrat în v0.1.0/v0.2.0 și l-am reparat de-adevăratelea în v0.3.1). Un măturător de fundal descarcă și el ce e rezident, odată ce a stat degeaba mai mult decât FLICKIES_IDLE_UNLOAD_SECS (implicit 600s). Un singur model stă în VRAM la un moment dat; ăsta e tot designul.
Tot ce vine după asta, rutele, formele de cerere și răspuns, codurile de eroare, vine dintr-un singur fișier: openapi.yaml. Nu e documentație făcută ulterior, e chiar intrarea generatorului pentru trei lucruri separate: modelele de validare Pydantic ale serverului, clientul Go și clientul Python. Schimbi specificația, rulezi make generate, toate trei se regenerează împreună. make generate-check e o barieră de CI care pică buildul dacă vreuna dintre ele se abate de la specificație. Ajung mai jos la de ce contează asta, pentru că e partea din proiectul ăsta cu care mă laud cel mai tare.
Pornire Rapidă
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"}' | jqDouă imagini: psyb0t/flickies:latest (CPU, bază python:3.12-slim) și psyb0t/flickies:latest-cuda (bază nvidia/cuda 12.4 runtime). Imaginea de CPU rulează fiecare operație ffmpeg plus Wav2Lip pe CPU, încet, dar real; imaginea CUDA rulează tot la o viteză pe care chiar ai suporta-o. Ținta testată e un RTX 3060 de 12GB.
Patru Motoare ML: Lipsync și Restaurare de Față
engines.json definește exact patru motoare ML, fiecare cu un slug, un flag de cerință CUDA, un prag minim de VRAM și, unde contează, o poartă de licență:
wav2lip / wav2lip-gan
Rudrabha/Wav2Lip, adus în repo, rezoluție nativă 96×96. Două variante care împart o singură clasă de motor, comutate printr-un câmp variant: base (acuratețe maximă de sincron, gură mai moale) și gan (rafinare GAN, gură mai clară, sincron foarte puțin mai prost). Ambele își aleg singure dispozitivul, FLICKIES_DEVICE=auto verifică torch.cuda.is_available() și cade curat pe CPU. La benchmarkul din primul release, asta înseamnă ~44 de secunde pentru un clip de 3 secunde pe CPU, ~22 de secunde pe GPU. Numărul ăla de pe CPU nu e o glumă, chiar e utilizabil pentru clipuri scurte, ceea ce e mai mult decât pot zice despre majoritatea repo-urilor open source de lipsync “cu GPU obligatoriu”, care pur și simplu crapă pe o mașină fără placă, în loc să se degradeze elegant.
latentsync-1.5
LatentSync 1.5 de la ByteDance, Apache-2.0, fixat special pe checkpointul 1.5, pentru că 1.6 vrea 18GB de VRAM, iar plafonul hardware-ului meu e 12. Coloană vertebrală în spațiul latent SD-1.5, embeddinguri audio Whisper-tiny care fac cross-attention într-un UNet3D prin AnimateDiff, mecanică mai grea decât Wav2Lip, și se vede: ~170 de secunde pentru un clip de 6 secunde pe 3060, cu vârf pe la 9.6GB VRAM. Ăsta e singurul motor din tot setul care cere obligatoriu CUDA, codul verifică torch.cuda.is_available() la încărcare și ridică un 400 dacă nu e acolo, fără să încerce vreo cădere pe CPU. E și motorul implicit când nu se intră în poarta de necomercial, pentru că, spre deosebire de Wav2Lip, nu cară după el bagajul LRS2.
gfpgan
GFPGAN v1.4 de la TencentARC, Apache-2.0. Ăsta se înlănțuie după Wav2Lip ca să repare decupajul moale și de rezoluție mică al gurii, pe care îl lasă în urmă inferența nativă la 96×96, Wav2Lip nimerește sincronul, GFPGAN curăță mizeria vizuală din jurul lui. Merge și de sine stătător, prin POST /v1/video/restore, dacă vrei doar o trecere de restaurare a feței pe material existent. Aceeași alegere automată de dispozitiv ca la Wav2Lip, cade pe CPU. Cadru cu cadru: citește prin cv2, rulează restauratorul pe fiecare cadru, scrie într-un mp4 mut, apoi bagă audio-ul original înapoi peste cadrele restaurate.
Ponderile pentru toate patru stau în structura standard de cache HuggingFace, sub /data/hf/hub/models--<org>--<name>/, bloburi adresate prin conținut, symlinkuri de snapshot, refolosibile de orice altceva conștient de HF care împarte același bind mount. Leneșe implicit: fiecare motor își aduce repo-ul la prima cerere. FLICKIES_PREFETCH_ALL=1 sau un FLICKIES_ENABLED_ENGINES=wav2lip,gfpgan restrâns trage ponderile la pornire, înainte să pornească măcar uvicorn, ca prima ta cerere reală să nu înghită o penalizare de descărcare la rece de câteva minute.
Poarta de licență nu e decorativă
Greutățile Wav2Lip sunt antrenate pe LRS2, un set de date necomercial. flickies nu îngroapă asta într-un README pe care nu îl citește nimeni, codul serverului refuză fizic să încarce vreuna dintre variantele wav2lip dacă nu e setat FLICKIES_ENABLE_NONCOMMERCIAL=1 în mediul serverului. Încearcă să obții motorul fără el și require_noncommercial_optin() ridică un NonCommercialOptInRequired, pe care API-ul îl scoate la suprafață ca o eroare ca lumea, în loc de un 500 tăcut. LatentSync 1.5 și GFPGAN sunt amândouă Apache-2.0, fără poartă, se încarcă liber. Același mecanism pe care îl folosește audiolla pentru propriile porți de necomercial la MusicGen și matchering, am furat tiparul de la mine însumi, ceea ce am voie să fac.
Șapte Operații ffmpeg, Pentru Că Nu Orice Are Nevoie de GPU
Jumătate din ce le trebuie oamenilor de fapt de la un “API de video” nu e deloc ML, e ffmpeg cu valori implicite cu cap și tratare de erori. flickies expune șapte operații pur ffmpeg, fără niciun model încărcat, pur CPU, disponibile în ambele imagini:
- trim, taie la
[start_sec, end_sec]. Modul implicit e copiere de flux cu-c copy(rapid, dar se lipește de cel mai apropiat keyframe, poate mânca până la un GOP de conținut la început). Puiprecise: trueși reencodează prinlibx264 -crf 18 -preset veryfastplus AAC 192k, pentru margini exacte pe cadru. - concat, lipește 2 sau mai multe videouri în ordine, prin demuxerul concat. Același compromis între copiere de flux și precis ca la trim;
precise: truereencodează prin demuxer cu parametri de codec uniformi, ca intrările cu encodere nepotrivite chiar să se lipească în loc să se corupă. - transcode, reencodare universală peste mp4/webm/mov/mkv, cu suprascrieri de codec, crf, preset și fps. Tratează și ieșirea gif ca o cale specială în două treceri:
palettegenapoipaletteuseprintr-un filter_complex, pentru că o conversie naivă din ffmpeg în gif arată ca un căcat și știe toată lumea asta. - scale, redimensionează la lățime×înălțime, cu pad opțional care păstrează raportul de aspect.
- mux_audio, înlocuiește sau combină o pistă audio într-un video.
- extract_audio, scoate pista audio ca wav/mp3/m4a/ogg/flac.
- thumbnail_grid, planșă PNG de sprite-uri prin filtrele
thumbnailșitile, cu rânduri, coloane și mărime de celulă toate configurabile.
Mai există o a opta capabilitate video care nu e în lista aia de “operații”, pentru că nu produce un video, /v1/video/info cheamă ffprobe și îți dă înapoi durată, codec, fps, dimensiuni, bitrate. Absolut fiecare dintre astea trece printr-un singur punct de strangulare din ffmpeg.py, care cheamă procesul prin asyncio.create_subprocess_exec, captează stderr și ridică o eroare structurată FFMPEG_FAILED la ieșire nenulă, în loc să scape un traceback brut.
Joburi Asincrone și Webhookuri, Pentru Când Nu Stai Acolo Să Aștepți
LatentSync la peste 170 de secunde pe clip nu e genul de chestie pentru care vrei să ții o conexiune HTTP deschisă. Fiecare endpoint care produce video acceptă async_job: true (sau pur și simplu omiți ambele câmpuri de ieșire și se subînțelege): serverul prealocă un id de job, programează munca drept task asyncio de fundal și întoarce imediat 202 {job_id, status: "accepted"}. Interoghezi GET /v1/jobs/{job_id} pentru pending → running → complete/failed/cancelled. Dacă nu vrei să interoghezi, dai un webhook_url și serverul livrează singur starea finală a jobului.
Livrarea webhookului nu e un POST aruncat și uitat, cu ridicare din umeri. E semnată HMAC-SHA256 peste timestamp + "." + body, trimisă ca headere X-Webhook-Timestamp plus X-Webhook-Signature: t={ts},v1={hex}, cu un program serios de reîncercări cu backoff exponențial la orice nu e 2xx: 30s, 1m, 5m, 30m, 2h, 12h, după care scrie o intrare de dead-letter și renunță. Se așteaptă ca receptorul tău să deduplice pe (timestamp, signature), pentru idempotență. E aceeași formă ca un contract de webhook de la un procesator de plăți, pentru că ăla e singurul precedent care merită copiat pentru “spune-i cuiva în mod fiabil că un job lung s-a terminat”.
Unsprezece Unelte pe Firul MCP
Montat la /v1/mcp ca JSON-RPC peste HTTP streamable, flickies expune unsprezece unelte MCP care oglindesc suprafața REST aproape 1:1: list_engines, info, lipsync, restore, transcode, trim, concat, scale, mux_audio, extract_audio, thumbnail_grid. Îndrepți spre el un LLM cu function calling, LibreChat, Cursor, Claude cu conectorul MCP, orice framework de agenți rulezi, și poate conduce singur tot pipelineul: pregătește un video cu o față, pregătește un clip audio, cheamă lipsync cu restore_face: true ca să înlănțuie automat GFPGAN după trecerea de sincron (ceea ce, merită notat, declanșează o evacuare la cald a modelului de lipsync în mijlocul apelului de unealtă, e intenționat, eliberează VRAM-ul de care are nevoie trecerea de restaurare), apoi îți dă înapoi o cale sau o mărime.
Ăsta e și stratul spre care face proxy aigate când comuți FLICKIES=1 sau FLICKIES_CUDA=1, o singură ușă de nginx în fața fiecăruia dintre serviciile mele AI self-hosted, ăsta inclusiv.
Întâi Specificația: Clienți Go și Python Generați din Același Fișier Nenorocit
Asta e partea care chiar desparte flickies de “încă un wrapper de API peste ML”, pentru mine. openapi.yaml nu e decorație scrisă după cod ca să pară profesionist, e sursa unică de adevăr DIN care sunt generate trei artefacte separate, nu scrise ca să se potrivească:
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.yamlNu edita niciodată de mână fișiere generate. Editezi specificația, rulezi make generate, comiți tot împreună. Clientul Go vine din oapi-codegen, cel Python din openapi-python-client, ambele pachete reale, tipizate, importabile, nu curl-învelit-în-funcție ca gând de pe urmă:
go get github.com/psyb0t/docker-flickies/pkg/clients/go@latestimport 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(...))Nu e perfect, structurile VideoTrimRequest și VideoConcatRequest din clientul Go pierd momentan start_sec/end_sec/precise, din cauza unei limitări cunoscute a lui oapi-codegen cu scheme compuse prin allOf (blocul inline de properties de pe tipul compus se pierde). Apelanții din Go serializează între timp exact acele corpuri prin map[string]any. Prefer să documentez o limitare reală de generator decât să mă prefac că pipelineul e fără cusur, ideea nu e că generarea de cod e magie, e că specificația și fiecare client care o vorbește sunt mecanic incapabile să se despartă, pentru că toate vin din același pas de build.
Logging, Auth, Limite de Rată, Căcaturile Plictisitoare Care Chiar Contează la 3 Dimineața
Loggingul structurat în JSON se duce pe stderr ȘI într-un fișier rotativ (FLICKIES_LOG_FILE, implicit 50MB × 5 copii), cu un formator custom care redactează recursiv orice se potrivește cu password|token|secret|api_key|authorization|cookie|hf_*|sk-ant-*, și în chei, și în valori, la momentul formatării, înainte ca linia să fie scrisă vreodată. Fiecare înregistrare de log poartă un trace_id și un request_id, trecute printr-un ContextVar, pornite din headerul X-Request-Id de la intrare, dacă are forma validă (UUID v4 sau ULID, cel mult 64 de caractere, fără linii noi, intrarea gunoi primește pur și simplu un UUID proaspăt bătut în loc să fie întoarsă ca ecou, ceea ce închide un vector de injectare în loguri). Pui FLICKIES_LOG_LEVEL=DEBUG și capeți trasare de nivel reconstrucție: fiecare comandă ffmpeg și ffprobe, fiecare decizie de trim sau concat între copiere de flux și reencodare precisă, timpul de ceas al inferenței fiecărui motor, numărul de octeți aduși și urcați pe URL-uri, tranzițiile de ciclu de viață ale joburilor. URL-urile logate au întâi query stringul tăiat, deci un output_url presemnat nu își scurge semnătura în fișierele tale de log.
Autentificarea e un bearer token static prin FLICKIES_AUTH_TOKEN, verificat cu hmac.compare_digest, deci nu poate fi atacat prin cronometrare, cu /healthz scutit, ca sondele orchestratorului tău să meargă în continuare. Nu setezi variabila și autentificarea e pur și simplu oprită, alegerea ta, granița ta de rețea. Pe deasupra: un limitator de rată cu găleată de jetoane per IP (implicit 60 de cereri pe minut, reglabil) și un strat de deduplicare bazat pe Idempotency-Key, care pune în cache (status, corp) per (cheie, metodă, cale), ca un POST reîncercat să nu ruleze de două ori un render scump. Toate trei sunt middleware ASGI din biblioteca standard curată, fără vreo dependență în plus doar ca să limitezi rata cererilor.
Unde Stă
flickies se montează înăuntrul lui aigate la /flickies/ și /flickies-cuda/, în spatele aceluiași nginx care stă în fața fiecărui alt serviciu AI self-hosted pe care îl rulez, un singur make run-bg, ambele variante comutate cu FLICKIES=1 și FLICKIES_CUDA=1. E piesa în formă de video din același puzzle pe care audiolla și talkies îl umplu pentru audio și vorbire, același contract pe fir, aceeași ergonomie pentru operator, deci să adaugi un al patrulea sau al cincilea serviciu la stackul ăla mai târziu nu înseamnă să reînveți nimic.
Ce Capeți de Fapt
flickies e o trusă de scule video care se întâmplă să includă lipsync de la bun început: patru motoare reale în spatele unui singur pool de GPU cu schimb la cald, șapte operații ffmpeg care nu se prefac că sunt ceva mai fițos decât ffmpeg, joburi asincrone cu webhookuri chiar semnate în loc de un POST aruncat și uitat, unsprezece unelte MCP pentru orice agent îndrepți spre el, și clienți tipizați în Go și Python, generați din exact specificația față de care validează serverul, nu întreținuți de mână, nu în derivă, nu mințindu-te despre ce acceptă de fapt API-ul. Self-hosted, WTFPL, rulează pe o placă video pe care deja o ai.
Ia-l de la github.com/psyb0t/docker-flickies sau trage imaginile direct de pe Docker Hub. Fă ce vrei cu el, dar nu mai plăti un SaaS ca să ruleze un model open source pe hardware pe care l-ai fi putut cumpăra de tot cu trei luni din factura lor.
Cum Îl Instalezi în Agentul Tău
Să dai o trusă de scule video pe mâna unui agent merge mai bine când el știe deja lista de unelte. Tot ce e sub .agents/ e catalogat într-un singur marketplace, deci sunt două comenzi:
claude plugin marketplace add psyb0t/agents
claude plugin install flickies@psyb0tCodex folosește același marketplace cu alt verb, codex plugin add flickies@psyb0t, pentru că nu există codex plugin install. Găsește singur și skillul într-un checkout al repo-ului, pentru că scanează .agents/skills/ nativ, fără să fie instalat absolut nimic. E listat acum și pe MCP Registry-ul oficial, deci un client care rezolvă servere de acolo îl poate găsi fără să i se dea un URL.