Inspiration
Every generative video tool works the same way: you type a prompt, you wait, you get a clip. If the clip is wrong, your only move is to type a different prompt and pay again. There is nowhere to stand in the middle and direct.
Comics solved narrative pacing a century ago, and motion comics are the one form where AI generated art is already good enough today: a still plate, a cut out figure, a camera that moves, a speech bubble timed to a voice. That form needs a timeline, not a prompt box. So we built Ninth around a single claim: the interesting product is the editing surface, not the generator.
What it does
You bring an idea and a style. Ninth invents the cast, the places and the visual identity, writes the chapter, draws the panels, casts and performs the voices, scores it, stages every panel on a timeline you can edit, and bakes the result to an MP4.
A location is generated once as a full 360 equirectangular panorama at 3584x1792, and every shot is carved out of it as a rectilinear crop. That carve is pure geometry, so framing ten shots inside the same room costs one generation. Characters are cut out automatically with a chroma key pass. Before placing them, a vision model runs bounding box detection over the plate and returns the walkable ground plane plus the reference objects a figure can be measured against, so a character stands at the right height instead of floating at the wrong size. Rain, fog, dust and embers are added as live effects at playback.
Everything the AI produced stays editable. You can hold a shot longer, move a layer, change its parallax depth, retime a line against the voice track, or ask the AI to edit the artwork itself from a written instruction, which regenerates that one asset and leaves the rest alone. Anything you already like can be locked so nothing overwrites it.
Before it spends anything, the compose panel tells you what a chapter will cost: what will be reused, what will be created, and the projected spend. The composer's first move is always to search the asset library for a semantic match and reuse the hit.
When you render, the worker drives the same player the editor previews, frame by frame, into ffmpeg. Preview and output cannot drift apart because there is only one engine.
How we built it
One FastAPI service holds both the web layer and the generation engines. The frontend is React 19, Vite and Tailwind 4, with a self contained player that exposes __seek(t). The editor uses it to scrub and the render worker uses it to capture frames, which is what makes preview and export parity structural rather than a promise. PostgreSQL stores the project graph and Qdrant holds the embeddings behind library search.
The comic grammar is deterministic code. About 70 numbered rules cover minimum panel life, caption reading speed at 15 characters per second, ground line placement, maximum figure height as a fraction of the frame, and inset placement beside the speaker rather than over the art. Each rule that came from a real defect has a regression test named after that defect.
The render worker runs as its own process, both because Playwright's sync API refuses to run inside a thread of an asyncio app and because a crash mid render must never take the API down with it. For deployment the whole stack builds into a single container, since the render worker and the frame grabber drive a headless Chromium against the app itself and need to share one origin with it.
Genblaze as the orchestration layer
Every medium Ninth produces runs as a genblaze_core.Pipeline step: text, image, video, voice, sound effects and music. Nine named pipelines cover the product, among them ninth-treatment and ninth-planner for text, ninth-img for plates and cut outs, ninth-veo and ninth-omni for video, and ninth-tts, ninth-sfx and ninth-music for audio. Steps chain where the medium calls for it, so ninth-animated-plate feeds an image step straight into a video step and a still plate becomes an animated shot inside one run.
Every run goes through a single helper, run_with_provenance, which attaches an ObjectStorageSink configured with KeyStrategy.CONTENT_ADDRESSABLE. That one decision is what makes the SDK write a canonical hash manifest for the run and rewrite each asset URL to its durable location, and it is why every asset row in our database now carries the run id, the model and the prompt that produced it.
We ended up writing seven custom providers, and the reason for two of them says the most about how we used the SDK. The chapter script is the single most important artefact in the project, and genblaze_google.chat() has no Azure equivalent, so the script would have been the one thing generated outside the SDK with no run id and no manifest. We wrote AzureStructuredTextProvider so it stays inside. Music was the second artefact escaping provenance, since genblaze-elevenlabs ships TTS and SFX providers but nothing for music, so ElevenLabsMusicProvider closed that gap too. The other five are AzureGptImageProvider for gpt-image-2 on Azure, GeminiImageProvider which passes the aspect ratio so a 9:16 reel renders vertical, VeoKeyframeProvider which forwards image= and config.last_frame= to enable image to video, OmniVideoProvider for first to last keyframe interpolation, and StructuredTextProvider for Gemini calls with a forced JSON schema. All of them subclass BaseProvider, so they survive the type check added to Pipeline.step() in the 2026-07-29 release.
Backblaze B2 as the storage layer
B2 is the layer the pipeline runs on rather than the place a finished file is copied to at the end.
Every asset is stored under the ninth/ prefix with a key that is the SHA-256 of its own bytes. Two identical generations collapse to a single object with no dedup pass, and provenance becomes a property of the key instead of a record somebody has to keep in sync. Each pipeline run writes its manifest to ninth/manifests/{run_id}.json, so any finished frame traces back to the run behind it.
B2 is also the read path. GET /cdn/{project_id}/{path} answers with a 302 to the durable B2 URL when the bucket holds the object, so the bytes travel from Backblaze straight to the browser and the API stays out of the way.
Two endpoints exist so durability can be tested rather than asserted. /b2/audit reports how many assets are on B2, on disk, or on both, and the local only count is the one that matters, since those are the assets a disk failure would take with it. /b2/restore pulls every missing file back from the bucket, and it is correct by construction: the key is the SHA-256 the database already stores, so a restored file either hashes to the name it was fetched under or it is rejected. Delete the local media directory and the project comes back, verified file by file. Finished renders are uploaded the same way, with the B2 URL kept on the render row so the deliverable survives the machine it was baked on.
The two layers work together on the thing that actually saves money. Generation is the expensive part, so the composer searches the library for a semantic match above a measured cosine threshold before commissioning anything. Genblaze gives every asset recorded lineage, and B2 keeps that library durable enough to still be worth searching months later.
Challenges we ran into
The story engine was the first surprise. A model asked to invent a story and emit a JSON schema in the same breath does neither well. Splitting it into two agents, one writing prose and one transcribing that prose faithfully into scene JSON, roughly tripled the length of the average spoken line and moved the median panel from 3.3 seconds of dead air to about 10.7 seconds of actual content.
Four provider problems cost us real days. Veo could not receive keyframes through the stock provider, because VeoProvider.submit() forwards only model, prompt and config, so image= and config.last_frame= never reach generate_videos and image to video is out of reach. GeminiImageProvider hard codes its content config and never passes types.ImageConfig(aspect_ratio=), so every vertical reel comes back silently horizontal. Vertex Express rejected every Veo id with RESOURCE_PROJECT_INVALID while the same API key worked on the Gemini Developer API. And genblaze-google pins google-genai<2, but Google retired the legacy Interactions API schema on 2026-06-08, which put the Omni video model out of reach of a 1.x client entirely. Four provider adapters and one dependency override got us through all of it.
The panorama needed a second pass too. A 70 degree carve out of a 2048x1024 panorama only gets 398 native pixels stretched onto a 1536 canvas, which is visibly soft, and every plate carved from that set inherits it. Moving to 3584x1792 put the carve at 697 pixels and dropped the upscale from 3.9x to 2.2x.
The vision review needed discipline to be worth having. It reports from a closed vocabulary so every finding maps to a field somebody can change, it is told what the direction claims so it can spot disagreements between the plan and the picture, and it reports nothing by default. An empty list is the expected answer.
Accomplishments that we're proud of
122 regression tests, each named after the defect that motivated it, each mutation verified: revert the fix and exactly one test fails.
A verified end to end render at 72.46 seconds, 1,739 frames, h264 and aac, 58 MB, uploaded to B2 with its hash on the render row.
Durability we can demonstrate instead of assert. Delete the local media directory, call restore, and the project comes back from the bucket with every file verified against the SHA-256 already stored on its asset row.
Seven custom Genblaze providers, four of which produced findings worth filing upstream, every one verified live against the real APIs rather than read off the source.
Roughly 11,300 lines of Python and 7,500 of TypeScript, and a one command deploy that builds the frontend, installs Chromium and applies its own migrations.
What we learned
Split the model's jobs. Prose and schema in the same call produces mediocre versions of both.
Show, do not describe. The first image generated becomes the style plate, and every later image is given that picture as a reference. A written style prompt drifts between generations; a picture of the style holds.
Put provenance in the key. When the object key is the SHA-256 of the bytes, identical generations collapse to one object for free and lineage stops being a sidecar that somebody has to keep in sync.
Close the gaps in the pipeline rather than working around them. Every artefact that generates outside the SDK is an artefact with no run id, no manifest and no way back to the prompt that made it, which is why two of our seven providers exist purely to keep text and music inside.
Judge the result, not the plan. Reading the direction JSON cannot see a figure standing on a bench or an inset landing over somebody's eye. Only looking at the frame can.
Make failures loud. A B2 upload failure is recorded and surfaced by /api/generation/status, because an app that looks healthy while nothing reaches the bucket is the one outcome this project cannot afford.
What's next for Ninth
Pre signed B2 URLs, so assets stay private and directly linkable at the same time. Text and vision calls into the cost meter, which today reports media generation only and misses roughly thirty calls per run. An evaluation of Genblaze 0.7.0, which exists upstream and has not been assessed. Vertical export presets for reels. And syncing the raw generation directory to the bucket, so post processing can be redone from anywhere rather than only on the machine that generated it.
Built With
- genblaze
- next.js
- python
Log in or sign up for Devpost to join the conversation.