← cd ~/lab

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

The Wait that never returned: why I ditched n8n for a 3×3 grid

From an n8n workflow with infinite polling to a multi-agent system for producing reels: the 3×3 grid method for character consistency, and the timeouts that were missing.

status
paused
project
media-ai-agency
updated
2026-07-08
source
github.com/slackarea/media-ai-agency
tags
#agno#celery#fal.ai#kling#n8n#video-generativo
Diagram of a 3×3 grid of cinematic frames with the same character in nine different poses, used as start frame for a video generation model

There was a node in my n8n workflow called Wait Video. It waited 60 seconds, then re-checked whether the video was ready. If it wasn’t, it went back and waited again. There was no counter. There was no timeout. If fal.ai went FAILED in a way my If branch didn’t catch — and it happened — that node waited forever. I lost more than one afternoon staring at an execution stuck at “in progress” for three hours, with API credits already spent and no output.

That’s the moment I decided to rewrite everything.

The idea

The project is a short-form video factory: you give it a photo of a character, a photo of an environment and a brief, and out comes a vertical 9:16 reel with voiceover — brand analysis → script → images → video → voiceover, end-to-end. The original n8n workflow already did almost all of it: 66 nodes, from character generation to the final merge. But it was made of glass. Every fal.ai call was a triptych HTTP RequestIf ReadyWait, wired by hand, with the API key hardcoded in plaintext inside the headers of every single node. Zero smart retries, zero audio, zero brand context, a single tenant: me.

The loop pattern, reconstructed from the workflow JSON, is this:

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

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

                                          └──> torna a Check Video Status

Elegant on the canvas. A trap in production: the false branch doesn’t distinguish between “still working” and “dead”. FAILED, CANCELLED, a transient 500 — they all ended up in the “keep waiting” branch.

The real architecture

I moved the same logic into a 3-layer system: directive (SOPs in markdown), orchestration (the agents that decide) and execution (deterministic Python scripts). The principle behind it is trivial but ruthless: if you do everything with an LLM, errors compound. 90% accuracy per step, across 5 steps, makes 59% success. So complexity has to be pushed into the deterministic code, and the LLM is left with only the routing.

The stack: Agno 2.5.3 for the agents (a strategic Team with Claude Sonnet as leader, a creative pipeline Director of Photography → Copywriter → Retention → Art Director on GPT-4o, plus a deterministic Assembler in Python), Celery 5.4 + Redis for the asynchronous media tasks, fal.ai for images and video (nano-banana-pro, Seedream, Kling), ElevenLabs for the voiceover, MinIO as storage. Six microservices on Kubernetes, each with its own responsibility.

And the infinite Wait Video became this:

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 defaults to 600s. FAILED and CANCELLED raise an exception instead of looping forever. And above it, a detail I’m proud of: before starting to poll I save fal.ai’s request_id in Redis. If the Celery worker crashes midway, on restart it re-checks Redis, finds the orphaned job and resumes polling without re-submitting — no double charge on a video worth a few dollars. On completion it deletes the key.

The catch: consistency isn’t chased, it’s baked in

The real problem with multi-scene AI video isn’t polling. It’s consistency: the character changing face from one clip to the next, the location reinventing itself. Classic systems chase it after the fact, aligning each clip to the previous one. It’s a losing battle.

The grid method flips it. Instead of generating the 9 scenes one at a time, you generate a single image structured as a 3×3 grid: nine frames of the same moment, same character, same light, born from a single prompt. The consistency is structural, not chased — because it’s all one generation. Then you crop the nine panels and each becomes the start_frame for Kling.

The crop is pure Pillow, deterministic, 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

Why 3×3 and not 4×4 or 6×6? Two walls. Per-panel quality: from a high-resolution grid, the 3×3 gives panels dense enough to guide Kling; a 6×6 halves them until they become useless as start frames. And model capacity: the more panels you ask for in one generation, the harder it struggles to keep consistency. To do more than 9 scenes you chain multiple 3×3 grids, using the center panel (position 5, the most legible) as reference for the next grid.

Honest gotchas

  • The docs and the code diverge, and quite a bit. The v4 architecture on paper says fal-ai/flux/dev at 3072×3072 with ip_adapter at strength 0.6, and Kling v1.6. The real code went elsewhere: a 1728×3072 grid with 576×1024 panels (native vertical, not square), flux-pulid, nano-banana-pro and Seedream for the images, Kling v3 pro for the video. The docs are the map of two months ago, not today’s territory.
  • Keycloak evaporated. The enterprise plan promised Keycloak + OIDC + multi-tenant. In the code the auth ended up as bcrypt on PostgreSQL — more honest for a system that has one user. The distance between the diagram and the git log is where the truth lives.
  • The “fully automated” has a manual gate. The grid generation ends in grid_review state and waits for me to approve from the UI before animating. In the composite version — the one that preserves the real background pixel by pixel with rembg + inpainting at strength 0.15–0.30 — I don’t yet trust it enough to let it run on its own.
  • DockerHub and API key. The rewrite is also born from the fact that in the old workflow the fal.ai key was in plaintext in every node. Now it lives in the secrets. I deliberately kept every credential and every registry name out of this post.

How it’s going

Honestly: paused, in the planning phase. But it’s not a skeleton. The media pipeline runs, the grid method is working code, the poller with timeout and recovery is in production. As a stress test I produced a complete AI short film — five scenes, consistent character, final audio-video merge with ffmpeg. It works. What’s missing is the enterprise trimmings: the Next.js Creator Platform, the admin dashboard with costs, per-provider cost tracking (~3–6 dollars per video, estimated: the fal.ai part is the bulk). The plan has five phases over ten weeks; I’m realistically halfway through the media phase, with the creative meat already standing and the product scaffolding still to be assembled.

What I learned

That a no-code tool gets you to a POC in an afternoon and then punishes you for the rest of the project’s life. n8n was perfect for discovering whether the pipeline made sense; it was terrible at handling how it fails. The real value of the rewrite wasn’t the AI: it was putting a while elapsed < wait_limit and a FAILED branch where before there was a Wait that prayed.

And that consistency — in characters as in software — isn’t achieved by correcting downstream. It’s achieved by generating everything from the same process, once, and cropping. The 3×3 grid is that principle made image.

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