Building a no-sign-up image-to-video app on Cloudflare Workers and the fal.ai queue A developer built a no-sign-up image-to-video tool that turns a single photo into a 5-second AI clip, running on Cloudflare Workers with D1 for state, R2 for storage, a Cloudflare Container running FFmpeg, and cron triggers, while the video models run on fal.ai's queue API. Because generation takes 20–90 seconds, the work centered on plumbing: atomic D1 claims to prevent duplicate finishing, a per-minute cron to advance jobs whose tabs were closed, and a pass-based credit system spent via conditional updates so two tabs cannot spend the same clip. 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: js 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' } ; 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: js 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 ; 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. Two lessons from this part: Container subclass in its own module. @cloudflare/containers doesn't load in plain Node, and that broke my unit tests until I split it out. Video players seek, and Safari in particular insists on Range requests. R2's get accepts the request headers directly: js 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 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"