Building a no-sign-up image-to-video app on Cloudflare Workers and the fal.ai queue
I recently shipped a small tool that turns one photo into a 5-second AI video. It has no accounts at all, not even for paid users. Video generation takes 20–90 seconds, which is far longer than a Worker request is allowed to run, so most of the interesting work was in the plumbing around the model,

I recently shipped a small tool that turns one photo into a 5-second AI video. It has no accounts at all, not even for paid users. Video generation takes 20–90 seconds, which is far longer than a Worker request is allowed to run, so most of the interesting work was in the plumbing around the model, not the model call itself.
The stack: Cloudflare Workers, plus D1 for state, R2 for files, a Cloudflare Container running FFmpeg, and cron triggers. The models run on fal.ai's queue API. Here is how the pieces fit together, and the few things that bit me.
fal's queue API returns immediately with a status_url and a response_url. The Worker stores both in a videos row and answers the browser right away:
const queued = await fal. submit ( env, model, { image, prompt, seconds, resolution }); await env. DB. prepare ( `INSERT INTO videos (id, token, status, fal_status_url, fal_response_url,...) VALUES (?,?, 'queued',?,?,...)` ). bind ( id, token, queued. statusUrl, queued. responseUrl /*... */ ). run (); return json ({ id, token, status: ' queued ' }); Enter fullscreen mode Exit fullscreen mode One gotcha: for models on a sub-path (for example xai/grok-imagine-video/v1.5/lite/image-to-video ), the status lives under the app root, not the path you posted to. Use the URLs fal hands back instead of building them yourself.
The browser polls GET /api/videos/:id every few seconds. Each poll may check fal, but at most once every 4 seconds per job, so a tab left open doesn't turn into a flood of requests.
People also close tabs, so a cron trigger running every minute moves along the jobs nobody is polling. Both paths call the same advance() function.
When fal reports COMPLETED, the clip still has to be downloaded, checked, maybe re-encoded, and written to R2. Two pollers, or a poller and the cron, can arrive at the same moment, so the first step is an atomic claim:
const claim = await env. DB. prepare ( `UPDATE videos SET status = 'finishing', updated_at =? WHERE id =? AND status = 'queued'` ). bind ( now, id ). run (); if ( claim. meta. changes!== 1 ) return; // someone else has it context. waitUntil ( finish ( env, row )); Enter fullscreen mode Exit fullscreen mode waitUntil lets the response return while the finishing work continues. If a Worker dies halfway, the minute cron puts any row stuck in finishing for more than 5 minutes back to queued.
I needed two video operations: grab a middle frame for a content check, and scale free videos down to 480p. Workers can't run FFmpeg, so a tiny Node server in a Cloudflare Container ( node:22 + ffmpeg ) exposes POST /frame and POST /downscale, and exits after 2 idle minutes. The Worker reaches it through a Durable Object binding.
Video players seek, and Safari in particular insists on Range requests. R2's get accepts the request headers directly:
const object = await env. MEDIA. get ( key, { range: request. headers }); // respond 206 with Content-Range when object.range is set and the request had a Range header Enter fullscreen mode Exit fullscreen mode 6. Each model gets one backup Models refuse some inputs, and sometimes they just fail. Every model has exactly one fallback from a different vendor, so it also has a different safety filter. If the fallback fails too, the job ends with a friendly error, and nothing is counted against the user.
Paying users get a "pass" ( <uuid>.<token> ) stored in localStorage. It's sent as a header and spent with a conditional update, so two tabs can never spend the same clip:
UPDATE passes SET pack_clips = pack_clips -? WHERE id =? AND pack_clips >=? AND pack_expires_at >? Enter fullscreen mode Exit fullscreen mode If a video fails, its clips go back to the pass, exactly once. A lost pass can be restored with the payment email (stored only as a salted hash) plus the order number. Payment webhooks are verified, de-duplicated by event ID, and recorded only after they've been handled, so a crash lets the provider's retry run again.
The first live end-to-end free video took 63 seconds and cost about $0.05 in model fees. An HD clip on Grok Imagine costs $0.16.
If you want to see the result, it's live at pikmova.com. It gives 2 free videos a day with no sign-up. I'm happy to answer questions about any of the pieces above.