← Projects

AFC

AI Film Compute: the generation service behind an AI film studio's apps, from single jobs to movie manifests and authored styles.

AFC (AI Film Compute) is the generation service behind Boby AI’s studio apps. A caller sends one atomic job (text, image, video, speech, music or a merge), a manifest for a whole movie or series, or a run of a published style, and hears about every state change on a signed webhook. AFC only executes: users, credits and product records stay with the caller, and generation requests pass media as R2 keys rather than bytes. Agents get a separate upload path over MCP, with streamed uploads or resumable base64 chunks.

How AFC runs a generation Callers, agents and the dashboard all go through one API Worker. A durable Workflow per job drives a Go container named after that job, generation media moves between the container and R2, and every state change is recorded in Postgres and queued as a signed webhook.

Parts: Studio backends (Access tokens), Agents (MCP · scoped tokens), Ops dashboard (static Next.js), API Worker (Hono · Zod contracts), Durable Workflow (one per job or run), Postgres (jobs · attempts), Go container (named by the job ID), R2 (media by key), Webhook queue (retries · DLQ).

  • Studio backends to API Worker (job · film)
  • Agents to API Worker
  • Ops dashboard to API Worker
  • API Worker to Durable Workflow (start)
  • API Worker to Postgres
  • Durable Workflow to Postgres
  • Durable Workflow to Go container
  • Durable Workflow to Webhook queue (events)
  • Go container to R2

Workflows drive, containers work

The API Worker (Hono) resolves the caller’s Cloudflare Access identity to a client account with read, write and run scopes, validates the body against strict Zod contracts, writes the job to Postgres and starts one durable Cloudflare Workflow named by the job’s ID. The Workflow is the control plane: it checkpoints each step, captures the retry and timeout policy once, and calls a Go container whose Durable Object name is that same job ID.

The container is the data plane. It validates the request again with its own Go contract, streams inputs from R2 to disk, runs the step, and streams the output back to R2 tagged with the job that wrote it. Generation media never passes through Worker memory; only the separate agent upload path decodes chunks in the Worker.

The life of one job The API answers right away; the durable Workflow does the slow part, and the caller hears back by signed webhook.
  1. Studio backend to API Worker: POST job + idempotency key
  2. API Worker to Postgres: insert job + body hash
  3. API Worker to Workflow: start Workflow named by job ID
  4. API Worker to Studio backend: 201 with the new job
  5. Workflow to Postgres: mark running
  6. Workflow to Go container: run step (checkpointed)
  7. Go container to R2: output already there?
  8. Go container: models in policy order
  9. Go container to R2: stream output to R2
  10. Go container to Workflow: key + attempts
  11. Workflow to Postgres: attempts + final state
  12. Workflow to Studio backend: signed webhook via the queue

Films as graphs

A production manifest is a dependency graph of up to 128 units: one root plan, cast sheets shared by every scene, storyboard grids of three scenes each, a split per grid, one clip per scene, and a merge per movie or episode. The plan is a structured JSON bible (style, locations, cast, narration cues) that planning and image steps receive as context; clip, score and narration units get prompts extracted from specific fields of it. Together with the cast sheets as visual references, that holds one look across grids drawn by separate model calls. Later grids also depend on earlier ones, so storyboards run in order rather than all at once. A split cuts its grid into panels and one strip per row. Each clip references its own row’s strip and then every cast sheet, never the whole grid.

If the graph has a cycle, misses a scene or wires a clip to the wrong strip, AFC rejects the whole manifest before any paid work. The production Workflow captures one policy snapshot, then runs every ready unit in batches up to the request’s parallelism. Under continue_independent, a failed unit cancels only what depends on it, so a series can end partially_succeeded. A film can also be made in two shots: a preview stops after the splits so the caller can replace the plan, a cast sheet or a grid, and a render picks up from the preview’s keys.

A film as a dependency graph A six-scene movie, simplified. Planning and image steps get the plan as context, clips get prompts extracted from it, the cast sheets reach grids and clips as references, and each clip sees only its own row's strip. Whatever is unblocked runs next, up to the production's parallelism.

Parts: Plan (JSON bible), Cast sheets (per role), Storyboard 1 (scenes 1–3), Storyboard 2 (scenes 4–6), Split 1 (row strips), Split 2 (one strip per row), Clips 1–3 (per scene), Clips 4–6 (strip + cast refs), Merge (FFmpeg).

  • Plan to Cast sheets
  • Cast sheets to Storyboard 1
  • Cast sheets to Storyboard 2
  • Storyboard 1 to Split 1
  • Storyboard 2 to Split 2
  • Split 1 to Clips 1–3
  • Split 2 to Clips 4–6
  • Clips 1–3 to Merge
  • Clips 4–6 to Merge

Providers and fallback

Each external service (Vertex, fal, EachLabs, Google Text-to-Speech) is a Go adapter behind small operation interfaces such as GenerateVideo. One attempt runner owns retries and fallback, so Workflow and API code never branch on a provider name. Style nodes list their models in order; standalone calls use compiled defaults. Authentication, invalid-input and cancellation errors stop the chain at once. Rate limits and timeouts retry with backoff, and refusals, exhausted retries and unavailable providers move to the next model only when the policy allows it. Each production unit falls back on its own, so two scenes can finish on different models.

One unit, three attempts Retries and fallback happen inside the container, under the policy captured when the run started. Authentication, invalid-input and cancellation errors would have stopped the chain at once.
  1. Workflow to Attempt runner: task + policy snapshot
  2. Attempt runner to R2: did this job already write the output?
  3. R2 to Attempt runner: no: do the work
  4. Attempt runner to First model: attempt 1
  5. First model to Attempt runner: 429: retryable
  6. Attempt runner: back off, same model
  7. Attempt runner to First model: attempt 2
  8. First model to Attempt runner: timed out again
  9. Attempt runner to Next model: fallback allowed: next model
  10. Next model to Attempt runner: clip
  11. Attempt runner to R2: stream the clip, tagged with the job
  12. Attempt runner to Workflow: key + 3 attempts

Merges run FFmpeg in the same container: probe every clip, scale and pad to one frame size, normalise to 30 fps, add silence where a clip has none, crossfade or fade to black, mix narration and score, add captions, and encode H.264/AAC with fast start.

Safe to retry

Every request carries an idempotency key and a body hash: the same key and body returns the existing job, and the same key with a different body is a 409. Before paying a provider, the executor checks the output key in R2 and returns the stored result if this job already wrote it, and it refuses to overwrite a key that belongs to another job. Style executions hold leased claims: one that is interrupted and expires is reported as indeterminate for reconciliation, never sent to the provider again. Terminal states use a conditional update, so whichever of success, failure or cancellation lands first wins.

Callbacks and the outbox

Job and production callbacks go through a Cloudflare Queue, so a slow receiver never blocks or repeats paid work. A consumer signs each event with HMAC and posts it to an allow-listed HTTPS URL, delivery state lives in Postgres, and a message that runs out of retries is dead-lettered with its exact envelope, which an admin can put back on the queue.

Style runs can pause for a human review, so they use a transactional outbox instead: run state, decisions and their events commit together. A drainer claims batches with FOR UPDATE SKIP LOCKED, leases them, then starts or wakes the run’s Workflow or posts the callback, backing off exponentially. A minute cron re-drains missed events and restarts errored Workflows.

Two ways out of a transaction Jobs queue their callbacks; style runs, which can wait days for a review, commit their events in the same transaction as their state and let a drainer deliver them.

Parts: Job workflow (enqueue step retries), Minute cron (re-drain · restart), State + events (one transaction), Webhook queue (retries with backoff), Outbox drainer (SKIP LOCKED · leases), Queue consumer (HMAC · delivery row), Signed callback (stable event ID), Start or wake (style Workflow), Caller callback (allow-listed HTTPS), Dead letters (kept · admin replay).

  • Job workflow to Webhook queue (event)
  • Webhook queue to Queue consumer
  • Queue consumer to Caller callback (signed POST)
  • Queue consumer to Dead letters (exhausted)
  • State + events to Outbox drainer
  • Minute cron to Outbox drainer
  • Outbox drainer to Signed callback
  • Outbox drainer to Start or wake

Styles and agents

A style is a public input form plus an executable graph built from AFC’s generic operations, such as structured text, image generation and editing, video creation and editing, music, speech, transcription, storyboard splitting and film assembly. Prompts, model order and review checkpoints live in the graph. Operators build it on a React Flow canvas in the dashboard. Agents build the same graphs through an MCP endpoint on the API Worker, with tools to read the catalog, validate, save against a revision, start a pinned draft run, inspect costs, and approve or revise a review. Publishing creates an immutable version, and each run pins its version, inputs and retry timing, so later edits never reach an active run.

DirectorAI, the studio’s video app, carries AFC’s published catalog forward: its importer rebuilds those styles as graphs in its own workflow engine, and its MCP server exposes the catalog and its generations as tools for agents.

Where a style goes Operators and agents edit the same graphs. A published version is frozen, runs pin it, and the public schema shows only the input form.

Parts: Dashboard editor (React Flow canvas), Agents (MCP tools), Style draft (revision checks), Published style (immutable version), Public schema (form only, no graph), Style run (pinned · reviews), DirectorAI (rebuilt graphs), Product backend (users · credits).

  • Dashboard editor to Style draft
  • Agents to Style draft
  • Style draft to Published style
  • Published style to Public schema
  • Published style to Style run
  • Public schema to DirectorAI (imported)
  • Style run to Product backend (signed events)

Cost and operations

Postgres, through Hyperdrive and Drizzle, is the operational and billing record, and nothing in it is pruned. Each persisted provider attempt stores the model, duration, sanitised error, usage in the provider’s own units, the raw response, and a cost marked as quoted by the provider, derived from a rate card, or local. An unpriced attempt stays null, not zero, and the dashboard counts unpriced attempts per model. Attempts lost to a dead executor process or a losing terminal write never reach the table, so they are missing from both the spend total and the unpriced count.

The Next.js dashboard is a static export served by the API Worker behind Cloudflare Access, and it reads live aggregates from the Worker every few seconds: jobs, productions, attempts, events, callback deliveries and the style editor. Go tests run with the race detector, and a fixture executor replaces paid providers but runs the real Workflow, steps and FFmpeg.