{"slug": "building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai", "title": "Building a no-sign-up image-to-video app on Cloudflare Workers and the fal.ai queue", "summary": "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.", "body_md": "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.\n\nThe 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.\n\nfal'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:\n\n``` js\nconst queued = await fal.submit(env, model, { image, prompt, seconds, resolution });\nawait env.DB.prepare(`INSERT INTO videos (id, token, status, fal_status_url, fal_response_url, ...)\n  VALUES (?, ?, 'queued', ?, ?, ...)`).bind(id, token, queued.statusUrl, queued.responseUrl /* ... */).run();\nreturn json({ id, token, status: 'queued' });\n```\n\nOne 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.\n\nThe 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.\n\nPeople also close tabs, so a cron trigger running every minute moves along the jobs nobody is polling. Both paths call the same `advance()` function.\n\nWhen 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:\n\n``` js\nconst claim = await env.DB.prepare(\n  `UPDATE videos SET status = 'finishing', updated_at = ? WHERE id = ? AND status = 'queued'`\n).bind(now, id).run();\nif (claim.meta.changes !== 1) return; // someone else has it\ncontext.waitUntil(finish(env, row));\n```\n\n`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`.\n\nI 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.\n\nTwo lessons from this part:\n\n`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.\nVideo players seek, and Safari in particular insists on Range requests. R2's `get` accepts the request headers directly:\n\n``` js\nconst object = await env.MEDIA.get(key, { range: request.headers });\n// respond 206 with Content-Range when object.range is set and the request had a Range header\n```\n\nModels 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.\n\nPaying 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:\n\n```\nUPDATE passes SET pack_clips = pack_clips - ?\nWHERE id = ? AND pack_clips >= ? AND pack_expires_at > ?\n```\n\nIf 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.\n\nThe 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.\n\nThe 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.\n\nIf you want to see the result, it's live at [pikmova.com](https://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.", "url": "https://wpnews.pro/news/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai", "canonical_source": "https://dev.to/jeff_li_deee6ec68274e6300/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-falai-queue-375b", "published_at": "2026-11-04 16:14:41+00:00", "updated_at": "2026-11-04 16:23:52.547074+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "generative-ai", "ai-infrastructure", "developer-tools"], "entities": ["Cloudflare", "Cloudflare Workers", "fal.ai", "FFmpeg", "Safari", "xai"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai", "markdown": "https://wpnews.pro/news/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai.md", "text": "https://wpnews.pro/news/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai.txt", "jsonld": "https://wpnews.pro/news/building-a-no-sign-up-image-to-video-app-on-cloudflare-workers-and-the-fal-ai.jsonld"}}