Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

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 allowe

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.

1. Never wait on the model inside a request

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' });

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.

2. Two ways a job moves forward

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.

3. Claim the row before finishing

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));

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.

4. FFmpeg in a Container, not in the Worker

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:

  • Keep the 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.
  • My first idea was to burn a watermark into free videos with a hosted blend-video endpoint. It worked in YUV and gave everything a magenta tint. Doing it in my own FFmpeg was trivial. In the end I dropped the watermark entirely and made free videos 480p instead.

5. Serving video from R2 with Range support

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

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.

7. Paid access without accounts

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 > ?

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.

Numbers so far

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.

The server side is about 1,250 lines of plain JavaScript, and the test suite runs against an in-memory D1 built on node:sqlite with a fake R2 and a fake container.

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.