<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: 田WB</title>
    <description>The latest articles on Forem by 田WB (@wb_7ff5e372a07dea755a347).</description>
    <link>https://forem.com/wb_7ff5e372a07dea755a347</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866805%2F02088064-e2cb-457e-a057-729e6bdcfe20.png</url>
      <title>Forem: 田WB</title>
      <link>https://forem.com/wb_7ff5e372a07dea755a347</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/wb_7ff5e372a07dea755a347"/>
    <language>en</language>
    <item>
      <title>How I Built an AI Birthday Photo Generator with Cloudflare Workers, Gemini 2.5 Flash, and FLUX.2 Pro</title>
      <dc:creator>田WB</dc:creator>
      <pubDate>Sat, 11 Apr 2026 04:32:46 +0000</pubDate>
      <link>https://forem.com/wb_7ff5e372a07dea755a347/how-i-built-an-ai-birthday-photo-generator-with-cloudflare-workers-gemini-25-flash-and-flux2-pro-40pm</link>
      <guid>https://forem.com/wb_7ff5e372a07dea755a347/how-i-built-an-ai-birthday-photo-generator-with-cloudflare-workers-gemini-25-flash-and-flux2-pro-40pm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Upload a selfie → Gemini analyzes the photo and writes 3 birthday scene prompts → FLUX.2 Pro generates the images → Cloudflare R2 stores them. The whole backend runs on Cloudflare Workers with zero servers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I recently launched &lt;a href="https://bdayphoto.com" rel="noopener noreferrer"&gt;bdayphoto.com&lt;/a&gt;, an AI-powered birthday photo generator. Upload your photo and get 3 unique AI birthday celebration scenes in about 60 seconds. Here's how I built the whole thing on Cloudflare's serverless stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before → After&lt;/strong&gt; — one selfie, three AI-generated birthday scenes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Original photo&lt;/th&gt;
&lt;th&gt;Scene 1&lt;/th&gt;
&lt;th&gt;Scene 2&lt;/th&gt;
&lt;th&gt;Scene 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9hmk8wjagy0b7fbmkyfw.jpeg" alt="Original selfie" width="800" height="1067"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk51spk0s9ryvm0zqkfbd.png" alt="AI birthday scene 1" width="768" height="1360"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcs07tfhws75j5ov700qe.png" alt="AI birthday scene 2" width="768" height="1360"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8ioaqk89b34acd2xduev.png" alt="AI birthday scene 3" width="768" height="1360"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend: Next.js 15 (App Router) → static export → Cloudflare Pages
Backend:  Cloudflare Workers (TypeScript)
Database: Cloudflare D1 (SQLite)
Storage:  Cloudflare R2
Cache:    Cloudflare KV (sessions)
Queue:    Cloudflare Queues
AI:       Gemini 2.5 Flash (via Replicate) + BFL FLUX.2 Pro
Payment:  PayPal
Auth:     Google OAuth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire backend runs on a single Cloudflare Worker. No EC2, no containers, no ops headaches.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Pipeline
&lt;/h2&gt;

&lt;p&gt;The generation flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User uploads photo
      ↓
Worker validates + deducts credits + enqueues job
      ↓
Queue consumer picks it up (runs up to 15 min)
      ↓
Step 1: Gemini 2.5 Flash analyzes the photo → outputs 3 scene prompts (JSON)
      ↓
Step 2: Submit 3 FLUX.2 Pro jobs sequentially → each fires a webhook when done
      ↓
Webhook handler saves images to R2, finalizes task
      ↓
User polls /api/task/:id → gets results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 1: Gemini Analyzes the Photo
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't the image generation. It was writing a prompt good enough to make Gemini output exactly what FLUX needs.&lt;/p&gt;

&lt;p&gt;I use Gemini 2.5 Flash via Replicate's API. The system prompt is ~800 words and instructs Gemini to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count people in the foreground (ignore background bystanders)&lt;/li&gt;
&lt;li&gt;Describe each person's face features in detail (for face preservation in the generated image)&lt;/li&gt;
&lt;li&gt;Design 3 completely different birthday scene themes&lt;/li&gt;
&lt;li&gt;Output a structured JSON with &lt;code&gt;start_prompt&lt;/code&gt;, &lt;code&gt;end_prompt&lt;/code&gt;, and 3 &lt;code&gt;scenes&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The JSON structure separates &lt;strong&gt;shared&lt;/strong&gt; prompt parts from scene-specific content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"people_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This is the same person from the reference photo. Preserve their exact face shape..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"end_prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shot on Canon EOS R5, 85mm f/1.4, photorealistic, 8K ultra HD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scenes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Golden_Gala"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glamorous gold ballroom with 40 metallic gold balloons..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tropical_Paradise"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vibrant beach party setting with palm trees..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enchanted_Garden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Magical outdoor garden with floral arches..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When submitting to FLUX, I assemble the full prompt as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fullPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end_prompt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes it easy to tune the shared "face preservation" instructions without touching each scene prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: FLUX.2 Pro Image Generation
&lt;/h2&gt;

&lt;p&gt;I use &lt;a href="https://api.bfl.ai" rel="noopener noreferrer"&gt;BFL's FLUX.2 Pro&lt;/a&gt; directly (not via Replicate or fal.ai). The BFL API supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;input_image&lt;/code&gt; + &lt;code&gt;input_image_2&lt;/code&gt;: both set to the user's photo — this enables face-consistent generation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;webhook_url&lt;/code&gt; + &lt;code&gt;webhook_secret&lt;/code&gt;: BFL calls back when done instead of polling
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fullPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input_image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dataUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input_image_2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dataUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// face reference&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;safety_tolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;webhook_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;webhook_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I submit 3 scenes &lt;strong&gt;sequentially&lt;/strong&gt;, not in parallel. BFL's API occasionally returns "Task not found" errors when you hammer it too fast — sequential submission with a small gap is much more reliable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Webhooks on Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;BFL fires a webhook when each image is ready. The webhook handler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the &lt;code&gt;webhook_secret&lt;/code&gt; (a UUID generated per task)&lt;/li&gt;
&lt;li&gt;Downloads the image from BFL's CDN&lt;/li&gt;
&lt;li&gt;Uploads it to R2&lt;/li&gt;
&lt;li&gt;Updates the &lt;code&gt;bfl_tasks&lt;/code&gt; record&lt;/li&gt;
&lt;li&gt;Checks if all 3 are done → finalizes the parent task
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Idempotent finalization using CAS update&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`UPDATE tasks SET status = 'done', r2_key_1 = ?, r2_key_2 = ?, r2_key_3 = ?, updated_at = ?
   WHERE id = ? AND status != 'done'`&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r2Keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;r2Keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;r2Keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;updateResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;updateResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Already finalized by another concurrent webhook, skip&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AND status != 'done'&lt;/code&gt; guard makes the finalization idempotent — safe even if two webhooks arrive simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Webhook Loss Compensation
&lt;/h2&gt;

&lt;p&gt;Webhooks can fail or get lost. I added a &lt;strong&gt;polling compensation&lt;/strong&gt; mechanism triggered when the user polls &lt;code&gt;/api/task/:id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If task is generating and it's been &amp;gt; 30 seconds since submission,&lt;/span&gt;
&lt;span class="c1"&gt;// actively poll BFL's result API for any bfl_tasks that haven't completed&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;generating&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;compensateMissingResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compensation function queries &lt;code&gt;bfl_tasks&lt;/code&gt; where &lt;code&gt;status = 'generating'&lt;/code&gt; and the task was submitted more than 30 seconds ago, then polls BFL directly. &lt;code&gt;ctx.waitUntil()&lt;/code&gt; runs it asynchronously without delaying the HTTP response.&lt;/p&gt;




&lt;h2&gt;
  
  
  D1 Schema Design
&lt;/h2&gt;

&lt;p&gt;The database has three main tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Tracks the overall generation job&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- pending → analyzing → generating → done&lt;/span&gt;
  &lt;span class="n"&gt;gemini_analysis&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- raw JSON from Gemini&lt;/span&gt;
  &lt;span class="n"&gt;analyze_duration_sec&lt;/span&gt; &lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;scene_name_1&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scene_name_2&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scene_name_3&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;r2_key_1&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2_key_2&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2_key_3&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;credits_cost&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error_message&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- One record per FLUX job (3 per task)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bfl_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;scene_index&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- 1, 2, or 3&lt;/span&gt;
  &lt;span class="n"&gt;bfl_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- BFL's job ID&lt;/span&gt;
  &lt;span class="n"&gt;polling_url&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;webhook_secret&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- pending → generating → saving → done | failed&lt;/span&gt;
  &lt;span class="n"&gt;r2_key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error_message&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Tracks concurrent generation lock per user&lt;/span&gt;
&lt;span class="c1"&gt;-- users table has a `generating_since` TEXT field&lt;/span&gt;
&lt;span class="c1"&gt;-- Atomic lock: UPDATE users SET generating_since = ? WHERE id = ? AND (generating_since = '' OR generating_since &amp;lt; ?)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;atomic lock&lt;/strong&gt; for preventing concurrent generations is clever: it's a single &lt;code&gt;UPDATE&lt;/code&gt; with a conditional &lt;code&gt;WHERE&lt;/code&gt; — if the update affects 0 rows, someone else is already generating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Credits System
&lt;/h2&gt;

&lt;p&gt;I track credits with an event log pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Deduct credits + create task + log — all in one D1 batch&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE users SET credits = credits - ? WHERE id = ? AND credits &amp;gt;= ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creditsCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;creditsCost&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO tasks ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO credit_logs (change_amount, balance_after, reason) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;creditsCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newBalance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;generate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first statement affects 0 rows (concurrent deduction), I clean up and return a 403. The batch is atomic at the D1 level.&lt;/p&gt;

&lt;p&gt;Partial failures are handled gracefully: if 1 of 3 FLUX jobs fails, I refund &lt;code&gt;ceil(credits_cost * failed_count / 3)&lt;/code&gt; credits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend: Next.js + Static Export
&lt;/h2&gt;

&lt;p&gt;The frontend is Next.js 15 with &lt;code&gt;output: 'static'&lt;/code&gt; deployed to Cloudflare Pages. Static export means there's no Node.js server — just HTML/CSS/JS on the CDN.&lt;/p&gt;

&lt;p&gt;For SEO, I needed server components to export &lt;code&gt;metadata&lt;/code&gt;. Since the app was originally all &lt;code&gt;'use client'&lt;/code&gt;, I refactored each page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/app/page.tsx          ← server component, exports metadata
src/components/home-page.tsx  ← client component with all the interactivity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way Next.js can statically render the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; with proper &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt;, and JSON-LD tags while keeping the interactive bits client-side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Queue consumers are the secret weapon of Cloudflare Workers.&lt;/strong&gt;&lt;br&gt;
Without Queues, you'd have to orchestrate long jobs externally. With Queues, you get 15 minutes of wall time for complex AI pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Webhook + polling compensation is more robust than polling alone.&lt;/strong&gt;&lt;br&gt;
Webhooks are fast but can be lost. Pure polling is slow. The combination — webhooks for speed, compensation polling as a fallback — gives you reliability without burning money on unnecessary API calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Idempotent operations everywhere.&lt;/strong&gt;&lt;br&gt;
Multiple webhooks can fire. Queues can retry messages. D1 batches can partially fail. Design every write as a CAS (Compare-And-Swap) update with conditional &lt;code&gt;WHERE&lt;/code&gt; clauses. &lt;code&gt;OR IGNORE&lt;/code&gt; and &lt;code&gt;AND status != 'done'&lt;/code&gt; guards have saved me from data corruption multiple times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. BFL over Replicate for FLUX.2 Pro.&lt;/strong&gt;&lt;br&gt;
BFL is the official API from Black Forest Labs (the FLUX team). It's cheaper, faster, and the parameters are documented correctly. Third-party platforms like Replicate have slightly different parameter semantics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Split Gemini's output into &lt;code&gt;start_prompt&lt;/code&gt; + &lt;code&gt;scenes&lt;/code&gt; + &lt;code&gt;end_prompt&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
This separation makes it easy to iterate on face-preservation instructions globally without regenerating every scene prompt. It also keeps individual scene prompts concise and focused.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pinterest strategy for organic reach&lt;/li&gt;
&lt;li&gt;More scene themes (holiday, vintage, anime style)&lt;/li&gt;
&lt;li&gt;Batch generation improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check out the live product at &lt;a href="https://bdayphoto.com" rel="noopener noreferrer"&gt;bdayphoto.com&lt;/a&gt; — you get 10 free credits on signup (no credit card required).&lt;/p&gt;

&lt;p&gt;Happy to answer any questions about the architecture in the comments!&lt;/p&gt;




</description>
      <category>ai</category>
      <category>generativeai</category>
      <category>cloudflarechallenge</category>
    </item>
  </channel>
</rss>
