@ :~/lab/il-wait-che-non-tornava-mai$
← cd ~/lab

./run il-wait-che-non-tornava-mai

Il Wait che non tornava mai: perché ho buttato n8n per una griglia 3×3

Da un workflow n8n con polling infiniti a un sistema multi-agente per produrre reel: il grid method 3×3 per la consistenza dei personaggi, e i timeout che mancavano.

status
draft
project
media-ai-agency
updated
2026-07-07
source
github.com/slackarea/media-ai-agency
tags
#agno#celery#fal.ai#kling#n8n#video-generativo
generate cover # alt: Diagramma di una griglia 3×3 di fotogrammi cinematografici con lo stesso personaggio in nove pose diverse, usata come start frame per un modello di generazione video

C’era un nodo, nel mio workflow n8n, che si chiamava Wait Video. Aspettava 60 secondi, poi ricontrollava se il video era pronto. Se non lo era, tornava indietro e aspettava di nuovo. Non c’era un contatore. Non c’era un timeout. Se fal.ai andava in FAILED in un modo che il mio ramo If non intercettava — e succedeva — quel nodo aspettava per sempre. Ho perso più di un pomeriggio a fissare un’esecuzione bloccata a “in corso” da tre ore, con crediti API già spesi e nessun output.

Quello è il momento in cui ho deciso di riscrivere tutto.

L’idea

Il progetto è una fabbrica di video short-form: dai una foto di un personaggio, una foto di un ambiente e un brief, e alla fine esce un reel 9:16 verticale con voce fuori campo — brand analysis → script → immagini → video → voiceover, end-to-end. Il workflow n8n originale faceva già quasi tutto: 66 nodi, dalla generazione del personaggio al merge finale. Ma era di vetro. Ogni chiamata a fal.ai era un trittico HTTP RequestIf ReadyWait, cablato a mano, con la API key hardcoded in chiaro dentro gli header di ogni singolo nodo. Zero retry intelligenti, zero audio, zero contesto di brand, un solo tenant: me.

Il pattern del loop, ricostruito dal JSON del workflow, è questo:

Check Video Status ──> If Video Ready ($json.status == "COMPLETED")

                    ┌── true ┴── false ──┐
                    ▼                     ▼
              Get Video Result        Wait Video (60s)

                                          └──> torna a Check Video Status

Elegante sul canvas. Una trappola in produzione: il ramo false non distingue tra “sta ancora lavorando” e “è morto”. FAILED, CANCELLED, un 500 transitorio — tutti finivano nel ramo “aspetta ancora”.

L’architettura vera

Ho spostato la stessa logica in un sistema a 3 layer: directive (SOP in markdown), orchestrazione (gli agenti che decidono) ed esecuzione (script Python deterministici). Il principio dietro è banale ma spietato: se fai tutto tu con un LLM, gli errori si compongono. 90% di accuratezza per passo, su 5 passi, fa 59% di successo. Quindi la complessità va spinta nel codice deterministico, e all’LLM resta solo il routing.

Lo stack: Agno 2.5.3 per gli agenti (un Team strategico con Claude Sonnet come leader, una pipeline creativa Analyst → Copywriter → Retention → Art Director → Assembler su GPT-4o), Celery 5.4 + Redis per i task media asincroni, fal.ai per immagini e video (nano-banana-pro, Seedream, Kling), ElevenLabs per il voiceover, MinIO come storage. Sei microservizi su Kubernetes, ognuno con la sua responsabilità.

E il Wait Video infinito è diventato questo:

def _poll_until_done(self, status_url, endpoint, wait_limit):
    elapsed = 0
    while elapsed < wait_limit:          # il timeout che mancava
        time.sleep(self.poll_interval)   # 10s, non 60
        elapsed += self.poll_interval
        data = httpx.get(status_url, headers=self.headers, timeout=15).json()
        status = data.get("status", "UNKNOWN")
        if status == "COMPLETED":
            return httpx.get(data["response_url"], headers=self.headers).json()
        elif status in ("FAILED", "CANCELLED"):   # il ramo che n8n non aveva
            raise RuntimeError(f"fal.ai job failed ({endpoint})")
    raise TimeoutError(f"fal.ai job timed out after {wait_limit}s ({endpoint})")

max_wait default 600s. FAILED e CANCELLED alzano un’eccezione invece di ciclare all’infinito. E sopra, un dettaglio di cui sono orgoglioso: prima di iniziare a fare poll salvo il request_id di fal.ai in Redis. Se il worker Celery crasha a metà, al riavvio ricontrolla Redis, trova il job orfano e riprende il polling senza ri-submittere — niente doppio addebito su un video da qualche dollaro. Al completamento cancella la chiave.

La spina: la consistenza non si insegue, si incorpora

Il problema vero del video AI multi-scena non è il polling. È la consistenza: il personaggio che cambia faccia tra una clip e l’altra, la location che si reinventa. I sistemi classici la inseguono a posteriori, allineando ogni clip alla precedente. È una battaglia persa.

Il grid method la ribalta. Invece di generare le 9 scene una alla volta, generi una sola immagine strutturata come griglia 3×3: nove fotogrammi dello stesso momento, stesso personaggio, stessa luce, nati da un unico prompt. La consistenza è strutturale, non inseguita — perché è tutto una generazione sola. Poi ritagli i nove pannelli e ognuno diventa lo start_frame per Kling.

Il crop è puro Pillow, deterministico, zero API:

PANEL_WIDTH, PANEL_HEIGHT = 576, 1024      # 9:16 nativo
GRID_WIDTH  = PANEL_WIDTH * 3   # 1728
GRID_HEIGHT = PANEL_HEIGHT * 3  # 3072
# pos 1 = (row 0, col 0) ... pos 9 = (row 2, col 2), row-major

Perché 3×3 e non 4×4 o 6×6? Due muri. Qualità per pannello: da una griglia in alta risoluzione, la 3×3 dà pannelli abbastanza densi per guidare Kling; una 6×6 li dimezza fino a diventare inutili come start frame. E capacità del modello: più pannelli chiedi in una generazione, più fatica a tenere la consistenza. Per fare più di 9 scene si concatenano più griglie 3×3, usando il pannello centrale (posizione 5, il più leggibile) come reference per la griglia successiva.

Gotcha onesti

  • La doc e il codice divergono, e parecchio. L’architettura v4 su carta dice fal-ai/flux/dev a 3072×3072 con ip_adapter a strength 0.6, e Kling v1.6. Il codice reale è andato altrove: griglia 1728×3072 con pannelli 576×1024 (verticale nativo, non quadrato), flux-pulid, nano-banana-pro e Seedream per le immagini, Kling v3 pro per il video. I documenti sono la mappa di due mesi fa, non il territorio di oggi.
  • Keycloak è evaporato. Il piano enterprise prometteva Keycloak + OIDC + multi-tenant. Nel codice l’auth è finita a bcrypt su PostgreSQL — più onesto per un sistema che ha un utente. La distanza tra il diagramma e il git log è dove vive la verità.
  • Il “completamente automatizzato” ha un gate manuale. La generazione griglia finisce in stato grid_review e aspetta che io approvi dalla UI prima di animare. Nella versione composita — quella che preserva lo sfondo reale pixel per pixel con rembg + inpainting a strength 0.15–0.30 — non mi fido ancora abbastanza da lasciarla correre da sola.
  • DockerHub e API key. La riscrittura nasce anche dal fatto che nel workflow vecchio la chiave fal.ai era in chiaro in ogni nodo. Ora sta nei secret. Ho volutamente tenuto fuori da questo post ogni credenziale e ogni nome di registry.

Come va

Onestamente: in pausa, in fase di planning. Ma non è uno scheletro. La pipeline media gira, il grid method è codice funzionante, il poller con timeout e recovery è in produzione. Come stress test ho prodotto un cortometraggio AI completo — cinque scene, personaggio consistente, merge audio-video finale con ffmpeg. Funziona. Quello che manca è il contorno enterprise: la Creator Platform Next.js, la dashboard admin coi costi, il cost tracking per provider (~3–6 dollari a video, stimati: la parte fal.ai è il grosso). Il piano ha cinque fasi su dieci settimane; sono realisticamente a metà della fase media, con la carne creativa già in piedi e l’impalcatura di prodotto ancora da montare.

Cosa ho imparato

Che uno strumento no-code ti fa arrivare al POC in un pomeriggio e poi ti punisce per il resto della vita del progetto. n8n era perfetto per scoprire se la pipeline aveva senso; era pessimo per gestire come fallisce. Il vero valore della riscrittura non è stato l’AI: è stato mettere un while elapsed < wait_limit e un ramo FAILED dove prima c’era un Wait che pregava.

E che la consistenza — nei personaggi come nel software — non si ottiene correggendo a valle. Si ottiene generando tutto dallo stesso processo, una volta sola, e ritagliando. La griglia 3×3 è quel principio fatto immagine.

Codice: github.com/slackarea/media-ai-agency