ai newsFr., 30. Okt.

Building an AI Video App on Cloudflare Workers with TanStack Start

I run a small AI video web app: users upload a character photo and a short motion clip, and a hosted video model renders the character performing those moves. The whole thing (UI, API, auth, payments, storage and the job pipeline) runs as one Cloudflare Worker . No servers, no containers, no separa

dev.toFreitag 04:12 · 30.10.5 Min. Lesezeit
Bild: dev.to

I run a small AI video web app: users upload a character photo and a short motion clip, and a hosted video model renders the character performing those moves. The whole thing (UI, API, auth, payments, storage and the job pipeline) runs as one Cloudflare Worker. No servers, no containers, no separate queue service.

This post walks through the architecture and the handful of problems that turned out to be interesting. If you're considering Workers for anything that handles large files or long-running third-party jobs, most of this transfers.

TanStack Start's server functions ( createServerFn with a Zod validator and middleware chain) replace a separate API layer. The client calls them like ordinary async functions, and they run inside the same Worker with direct access to the bindings.

Motion clips can be tens of megabytes. Buffering that in a Worker is a bad idea. Instead, the upload route takes the raw file as the request body and streams it straight into R2:

// simplified const key = `uploads/ ${ userId } / ${ crypto. randomUUID ()}.mp4`; await env. BUCKET. put ( key, request. body, { httpMetadata: { contentType: request. headers. get ( ' content-type ' )?? '' }, }); return Response. json ({ key }); Enter fullscreen mode Exit fullscreen mode The client then passes that key to the "start job" server function. Uploads are deleted as soon as a job has used them, and an R2 lifecycle rule removes anything older than a day as a safety net. That keeps storage costs flat and means user-provided material doesn't sit around.

Pricing is per second of motion video, so the server needs the clip's duration before charging. Reading the entire file just to get one number defeats the point of streaming.

MP4 and MOV files store their duration in the mvhd box inside moov. So the server uses R2 range reads to walk the top-level box headers (8–16 bytes each), skips over mdat without reading it, fetches only the moov box, and parses timescale and duration from mvhd. Typically that's a few kilobytes read instead of the whole file. When the job is submitted, the video body goes to the model provider as a stream, again never held whole in memory.

Video rendering takes a few minutes, far longer than a request should stay open. Queues or Durable Objects would work, but a simpler pattern is enough:

Because the status call is idempotent (it only moves a job forward from its stored state), duplicate polls from multiple tabs are harmless.

The codebase has a withCredits helper that charges, runs a callback and refunds if the callback throws. That's perfect for synchronous AI calls but useless here: the job can fail five minutes after the request that charged for it has finished.

The fix was to store the spend on the job row. When a poll discovers a failure, it refunds exactly what that job cost and marks the row as refunded, so the refund can't happen twice. Treat the job row as the source of truth for money, not the call stack.

Every photo passes through a Workers AI vision model via the AI binding before any credits move. It flags sexual content and recognizable public figures (both always refused) and applies extra rules for photos that appear to include minors. If the check itself errors, the photo is refused rather than let through. Running it first means users are never charged for a request that would be rejected anyway.

Finished videos are private. They're served from a route that checks the owner's session. But generating a first-frame poster image uses Cloudflare Media Transformations, which needs to fetch the video by URL. The answer was short-lived signed links: the video route accepts either the owner's session or a valid signature, and the poster pipeline uses the latter, then stores the WebP through the Images binding.

One opinionated choice: local dev and end-to-end tests connect to the remote D1, R2 and KV ( "remote": true on each binding in wrangler.jsonc ) rather than local emulators. That removes a whole class of "works locally" bugs around SQLite versions and R2 behaviour, at the cost of needing a dev database. For a solo project that trade has been worth it.

Yes. The single-Worker model keeps operations close to zero: one deploy, one log stream, costs that scale with usage, and no idle servers. Its constraints (no Node-only APIs, careful memory use, request time limits) pushed the design toward streaming and stateless polling, which ended up simpler than a queue-based design.

If you want to see the result, the app is live at MotionControlAI. It's an independent project, not affiliated with Kling AI or Kuaishou, even though it runs their motion control models. There's a plain-English guide to how Kling Motion Control works if you're curious about the model side.

Expand Bhavin Sheth Bhavin Sheth Bhavin Sheth Follow Founder of AllInOneTools. Building privacy-first, browser-based tools with no sign-up, no tracking, and simple UX for daily tasks. Location Modasa, Gujarat, India Pronouns he/him Work Founder & Builder at AllInOneTools Joined Jan 9, 2026 • Oct 30 Dropdown menu Copy link Hide I really like how this architecture handles long-running video jobs without introducing a separate queue service. Storing credit usage on the job record for reliable refunds, streaming large files directly to R2, and using idempotent polling are particularly smart design decisions. Great example of keeping a serverless architecture simple without ignoring the hard parts.

Quelle: dev.to (dev.to). Original öffnen