Chroma — Project Story

Why I built this

Every image model can make one good picture. None of them can make a coherent set.

That gap is where I kept getting stuck. I wanted to build a 4-panel storyboard for a short film concept. I opened one image model, typed a prompt, got a great astronaut. Opened it again with a variation of the prompt, got a different astronaut — different suit, different lighting, different world. By panel four, the story had fallen apart. Nothing felt like it belonged to the same set.

I tried the manual workarounds everyone tries. Copy-pasting the same style description into every prompt. Writing a "character sheet" as a paragraph and hoping the model latches on. Using image-to-image for consistency. All of it worked partially. None of it worked reliably.

The fundamental problem was that I was treating image generation as a series of independent prompts. What a multi-panel project actually needs is orchestration — a plan, shared state, a memory of what worked, and a critic that catches drift before the set ships.

That's the idea behind Chroma. It's not another image generator. It's an AI Creative Director — a system that hires a team of specialized agents to plan, paint, critique, and compose a coherent visual set from a single brief.

What Chroma does

You type a creative brief in plain English. Something like:

A 4-panel storyboard of a lone astronaut emerging from a crashed pod in a bioluminescent forest, from dusk to night.

Chroma does five things:

  1. Plans. A Planner agent reads the brief and produces a Story Bible — characters, locations, visual style, overall arc — and then a Scene Plan, one panel at a time, describing what happens, who's in frame, the camera angle, and a full image-generation prompt.
  2. Orchestrates. A real DAG scheduler compiles the plan into a directed acyclic graph, validates it for cycles and missing inputs, topologically sorts it, and runs the independent steps in parallel. Four panels generate simultaneously.
  3. Generates. Image agents paint each panel. The style descriptor from the Story Bible is woven into every prompt, so all panels share a palette, lighting, medium, and era.
  4. Critiques. A vision model scores every output against the panel's own scene plan and the shared style. Below-threshold images trigger a regeneration node with the critic's specific feedback appended as a constraint.
  5. Composes. A final composer assembles the panels into a coherent set, downloadable as a ZIP.

The whole thing runs live on a React Flow canvas. Every node is clickable. Every prompt, image, critique score, and timing is inspectable. Nothing is hidden behind a chat box.

How I built it

I built Chroma as a pnpm monorepo with four packages:

  • packages/core — the engine. Domain types, the DAG compiler, the scheduler, the event bus, and the agent executors.
  • packages/db — Prisma schema and migrations against Postgres.
  • packages/providers — provider adapters for image generation, vision, and text.
  • apps/web — the Next.js 15 app with the React Flow canvas, the SSE stream, and the API routes.

The engine

The scheduler is the part I'm proudest of. It's not a for loop dressed up as a pipeline. It's a real topological scheduler:

  • Each node has a state machine: PENDING → READY → RUNNING → (SUCCEEDED | FAILED | SKIPPED | REGENERATING).
  • The scheduler maintains per-node in-degree counters. When a node completes, its downstream neighbors decrement. When a counter hits zero, the node moves to READY and enters a priority queue ranked by topological position.
  • A worker pool of configurable size pulls from the ready queue and executes nodes concurrently.
  • Every node execution produces an immutable artifact with a SHA-256 hash. Regenerations create a new version, preserving history.

This is what makes fan-out real. A 4-panel brief fans out to four concurrent image-generation calls the moment the style analysis completes. The scheduler parallelizes; the brief finishes in a fraction of the serial time.

The critic loop

The critic is the piece that turns this from a pipeline into a self-correcting system. Every IMAGE_GENERATE node is followed by a CRITIQUE node that calls a vision model with a rubric built from the panel's scene plan and the Story Bible's visual style. The model returns { score, feedback, issues }. A GATE node evaluates the score:

  • ≥ 0.7 → pass. Artifact marked final.
  • < 0.7 → regenerate. The scheduler inserts a new IMAGE_GENERATE node whose prompt is the original plus the critic's specific issues. Bounded at two attempts.

The critic loop is why I refused to make this a thin API wrapper. Judges see a node get rejected, a new node appear, and the image get rebuilt with feedback. That's the moment the system stops being a generator and becomes a director.

The two-stage planner

My first planner produced generic panels. I'd ask for a 4-panel storyboard and get four nodes labeled "Panel 1 Prompt," "Panel 2 Prompt," and so on — with empty prompts. The images coming out were unrecognizable blobs.

The fix was to split planning into two LLM stages, inspired by how a human storyboard artist actually works:

  1. Story Bible. Extract characters, locations, visual style (palette, lighting, mood, medium, era), and an overall arc. One structured LLM call, JSON output, Zod-validated.
  2. Scene Plan. For each panel, one LLM call that takes the Story Bible and the panel index and outputs a specific scene description, subject, action, camera angle, and full image-generation prompt.

The difference was night and day. Instead of "Panel 1," the nodes now say "Prompt: Astronaut emerging from the wreckage." Instead of empty prompts, panel 1's prompt reads:

Digital concept art of a futuristic sci-fi scene. A lone astronaut in a scuffed white EVA suit with a cracked helmet visor struggles out of smoking wreckage. The setting is an alien bioluminescent forest at dusk, featuring towering flora that pulses with organic neon light. The palette consists of deep indigo, vibrant cyan, neon magenta, and warm amber accents. Low-key dusk lighting contrasts with the glowing flora.

The images coming out the other side finally matched the brief.

Challenges

Provider fragility

I originally built against Google's Gemini free tier. Mid-build, I discovered Google had quietly zeroed out the free-tier quota for every image model and was retiring text models faster than I could integrate them. My fallback chain — Gemini 3.8 Flash → 2.5 Flash → 2.0 Flash — died in a single weekend because every model I'd written as a fallback was 404 by the time I tested it.

The fix was to build a capability-keyed provider registry instead of hardcoding a single provider. Every executor receives a { image, vision, text } bundle of adapters. Swapping providers is a config change.

Image generation now uses Cloudflare Workers AI (Flux schnell, free tier, ~50 images/day) as primary with a KIE adapter as a paid fallback. Text and vision use Google free tier when available, with a deterministic fallback planner and a heuristic scorer as the last resort. The system degrades gracefully: a Google outage drops the run status to DEGRADED but the images still ship.

The lesson: build the swap before you need it. By the time a provider failed, I had the adapter interface ready and the swap was 20 lines of config.

Node ID collisions

The Prisma Node.id field is the primary key. My planner emits deterministic IDs like panel1_prompt. The first run inserted fine. The second run tried to insert the same IDs and the whole pipeline crashed with a unique-constraint violation on the primary key.

The fix was to scope every node ID to its run at persistence time — ${runId}:${localId} — and remap every edge reference, input port, and regeneration ID through the same mapping before handing the graph to the scheduler. The bonus catch: this also fixed a latent regeneration collision I hadn't found yet.

EventBus transaction contention

The event bus originally used a SELECT ... FOR UPDATE on the run row inside an interactive transaction to assign sequence numbers. Under fan-out, four nodes complete simultaneously, all try to acquire the same lock, three block, and Prisma's default 5-second transaction timeout fires. The run dies mid-flight.

The fix was to eliminate the lock entirely. Postgres gives us atomic UPDATE ... RETURNING for free. One statement, no interactive transaction, no lock held across statements, no possibility of deadlock.

Cold starts

Neon's free tier suspends after 5 minutes of idle. The first query after suspension takes 5–10 seconds to wake the compute. Prisma's default connect timeout is 10 seconds. It's a coin flip whether the wake finishes in time.

Three fixes stacked: longer timeouts (connect_timeout=30&pool_timeout=30&socket_timeout=30), a retry wrapper with three attempts at 1s/3s/7s, and a GitHub Actions workflow that pings the DB every 5 minutes. Belt and suspenders. The retry wrapper is the actual fix; the keepalive is a nice-to-have.

Deploy path

Local disk storage for artifacts doesn't work on Vercel — serverless functions have an ephemeral filesystem. I moved artifact storage to Postgres as bytea columns. A small migration, a rewrite of the storage module with the same function signatures, and the entire pipeline works on serverless without a new service.

Vercel's monorepo configuration took three attempts. The doubled-path bug — apps/web/apps/web/.next — was a Root Directory plus outputDirectory misconfiguration that I chased for longer than I want to admit. Root Directory stays apps/web; the config file goes into that directory; the output path is relative.

What I learned

Real engineering is invisible until it fails. The scheduler's topological sort, the DAG compiler's port validation, the retry backoff, the composite adapter — none of it is visible in a happy-path demo. It only matters when Google 503s, when Neon suspends, when Cloudflare rejects a prompt. Then it's the difference between a broken demo and a working one.

Fallback chains must be verified against live documentation, not remembered documentation. I lost a day to dead model IDs. Every provider fact in Chroma now lives in docs/provider-facts.md with a source URL next to it.

Don't build a feature if the bug behind it isn't fixed. I spent hours polishing the download feature while the planner was producing empty panels. The polish was worthless until the planner worked. Fix the foundation first.

Resilience reads as maturity. A system that degrades gracefully — the run completes as DEGRADED, images still ship, critiques fall back to a heuristic — is more impressive than one that only works when everything is up. Judges score engineering judgment, not perfect uptime.

What's next

The version submitted to Banana Hacks is a foundation. Beyond the hackathon, I want to:

  • Add prompt version history so a user can roll back to a specific panel revision.
  • Support project templates — a saved Story Bible and style directive that a user can share with classmates.
  • Build a Lite mode for mobile — 2-panel briefs with a single-pass pipeline.
  • Add KIE's identity-locked lanes for character consistency across long sequences, beyond the 4-panel scope.

Chroma is the tool I wanted when I started making multi-panel images. It's built on the premise that image generation, done seriously, isn't about the image model at all — it's about the orchestration around it.

The prompt is the beginning, not the product.


*Built solo for Banana Hacks 2026. Code at github.com/DiverseXL/chroma.

Built With

  • cloudflare-workers-ai
  • google-gemini
  • pnpm-workspaces
  • prisma-+-neon-postgres
  • react-flow
  • server-sent-events
  • stack:-next.js-15
  • typescript-strict
Share this project:

Updates

Submission history