<?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>DEV Community: 田WB</title>
    <description>The latest articles on DEV Community by 田WB (@wb_7ff5e372a07dea755a347).</description>
    <link>https://dev.to/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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866805%2F02088064-e2cb-457e-a057-729e6bdcfe20.png</url>
      <title>DEV Community: 田WB</title>
      <link>https://dev.to/wb_7ff5e372a07dea755a347</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wb_7ff5e372a07dea755a347"/>
    <language>en</language>
    <item>
      <title>How I take a paper game online, one decision at a time</title>
      <dc:creator>田WB</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:56:31 +0000</pubDate>
      <link>https://dev.to/wb_7ff5e372a07dea755a347/how-i-take-a-paper-game-online-one-decision-at-a-time-53gb</link>
      <guid>https://dev.to/wb_7ff5e372a07dea755a347/how-i-take-a-paper-game-online-one-decision-at-a-time-53gb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvrayj81kbefy4wwzzx4.png" class="article-body-image-wrapper"&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvrayj81kbefy4wwzzx4.png" alt="front page" width="712" height="1584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My son is 9. He is reading Ben Orlin's Math Games with Bad Drawings, and he picks the next game. I build that one with a model in the editor.&lt;/p&gt;

&lt;p&gt;The model is fast at a first version. It is careless about where the truth lives, and it will write that truth down twice. So the work I keep is short. I write the decision in a file, I choose which machine owns the rules, and I play the position the first check gets wrong.&lt;/p&gt;

&lt;p&gt;By 1 October, four games were playable in the browser. The notes are dated, so the sequence below is the one that happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  He picks, and the decision goes in a file
&lt;/h2&gt;

&lt;p&gt;A game in that book fits on a table. Online it has to answer questions the book skips. Who checks a move? What happens when the other connection drops? Can two browsers disagree about a freehand curve?&lt;/p&gt;

&lt;p&gt;I write the answer before the code spreads. The file says what is true, which machine owns it, and which shortcut is already dead. The next session starts from that file, whether the session is mine or the model's.&lt;/p&gt;

&lt;p&gt;The Sprouts notes are dated 23 August 2026. The online-play notes are dated 26 August. The maze notes are dated 16 September. Ultimate tic-tac-toe is 27 September. The room layer is 1 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first game keeps two models
&lt;/h2&gt;

&lt;p&gt;Sprouts came first. You join two dots with a curve, and a new dot grows on the curve. A dot can meet three curves.&lt;/p&gt;

&lt;p&gt;The screen stores the sample points, so the line looks like a pen. The rules store dots, links, and a degree. One move updates both. The first version was two people on one screen. A second city, and a computer opponent, waited until those two models had a place to live.&lt;/p&gt;

&lt;p&gt;The first endgame check added up the lives left on the board. A total under 2 means the game is over, and that half is right. Real games kept hitting a position that sum misses. Two new dots, each sealed in its own loop, still add up to 2 lives, and neither dot can reach the other. From 23 August until 27 September, a player in that position pressed a button to say they had no move. Then the check started walking the faces after every move. That position showed up with a person at the screen, before a computer opponent had to trust the same check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server runs the session
&lt;/h2&gt;

&lt;p&gt;For online play I wanted one process to hand out a room code, track the seats, remember whose turn it is, and notice a disconnect. The Sprouts geometry stayed in the TypeScript that already drew the line. A second copy of that engine in Go would drift away from the freehand curves.&lt;/p&gt;

&lt;p&gt;The Go server runs the session. The browser that draws the curve checks crossings, dots in the way, and a degree of 3. The move goes out as that list of points. The other browser draws the points and updates its graph. A second geometry check on a freehand curve can disagree with the first, so only the browser that drew the line runs it.&lt;/p&gt;

&lt;p&gt;The points live in board space. A different window size does not move the dots. While the line is still being drawn, the other screen sees the stroke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next game keeps a different home for the rules
&lt;/h2&gt;

&lt;p&gt;Franco maze came next. Each of you builds a maze for the other, then walks it blind. You do not know the walls. A browser that answers "you hit a wall" can lie, and a dropped connection stalls the turn.&lt;/p&gt;

&lt;p&gt;The server holds both mazes and answers the wall check. The browser sends the step and draws the result. The Sprouts split would be the wrong split here, because the hidden board is the game.&lt;/p&gt;

&lt;p&gt;Ultimate tic-tac-toe has no hidden board and no curve. Nine small boards, and the cell you pick sends your opponent to one of them. The rules are a short pure function. That function lives once, in TypeScript, and the shared-screen game uses the same file. Online, the server forwards the move. The other browser runs the function. A failed check is treated as a broken connection.&lt;/p&gt;

&lt;p&gt;A second copy of those rules in Go would have to change every time a small-board rule changed. I left them in the one TypeScript file.&lt;/p&gt;

&lt;p&gt;Penny Wise is the coin game, added on 1 October, for 2 to 6 players. The server is the referee. A turn lasts 30 seconds. Someone can leave and the hand continues.&lt;/p&gt;

&lt;p&gt;Sprouts geometry stays on the browser that drew the line. Franco maze and Penny Wise stay on the server. Ultimate tic-tac-toe stays in one TypeScript file, because both sides can run it and get the same answer. That placement is the note. The model fills in the code around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  After three games, one room
&lt;/h2&gt;

&lt;p&gt;Through September the pages and the Go server lived in one repo. On 30 September I split them. nginx serves the pages. The HTTP API is tRPC. The game socket has its own port. Each game is its own package.&lt;/p&gt;

&lt;p&gt;On 1 October the old matchmaking became a room, and every game uses it. You create a room, send the code, sit, mark ready, and start. You can switch games in the same room. A new game is a package plus one line in the list the room builds at startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;games&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logic&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sprouts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;labyrinth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;ultimate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;pennywise&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;games&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;labyrinth&lt;/code&gt; is Franco maze. The room talks to a game only through one interface. The interface says how many players the game wants, how a game starts, and what happens when someone leaves. A two-player game decides that the other person has won, inside that same method.&lt;/p&gt;

&lt;p&gt;The socket code is split the same way. One package accepts the connection. One package is the room. One package is the game in progress, with the seats and the timer. One package is a single connection, its queues and its heartbeat. A package starts as an interface. A helper with no interface goes back into the package that calls it.&lt;/p&gt;

&lt;p&gt;A logged-in player who drops keeps the seat and can come back. Playing needs no account. A finished game is saved, and stepping through the moves needs a login. Accounts sit in MySQL. The session is a cookie.&lt;/p&gt;

&lt;p&gt;A few of those choices are also rules in the repo, so the next session inherits them. The user row is the user type, passed through as it is. A setting with one production value is a constant in that package. The room tests construct the real games.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page after the game
&lt;/h2&gt;

&lt;p&gt;On 2 October the explanation stopped being a stack of paragraphs. Sprouts and Franco maze each have one page you can poke. The layout, the figures, and the little demos exist once. Each language is a text file with a fixed shape. Leave out a field and the TypeScript build fails. On 5 October each of those pages got its own URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  A computer opponent, then a second site
&lt;/h2&gt;

&lt;p&gt;Quick join is how a computer opponent shows up. If you are the only person in that automatic room, and the switch on that screen is on, the site waits 10 seconds. Nobody else arrives, and the empty seat becomes a computer that tries to win. There is one strength. A second person arriving during those 10 seconds cancels it, and you play each other.&lt;/p&gt;

&lt;p&gt;The computer for Sprouts, ultimate tic-tac-toe, and Penny Wise runs in the page. The Franco maze computer runs on the server, because the server is still the one holding the walls, so that game needs a connection.&lt;/p&gt;

&lt;p&gt;The English site is a separate deploy, with Cloudflare in front. Players, logins, and saved games stay on that site. Google login is there, and the username is the email from the first sign-in. On 6 October I added a page for a parent, about sitting down for one game and then reading the math in it.&lt;/p&gt;

&lt;p&gt;The Chinese side is a WeChat mini game. It has Sprouts, and its own login.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still check by hand
&lt;/h2&gt;

&lt;p&gt;I still play the awkward position. For Sprouts it was two dots that cannot reach each other. For Franco maze it was a player who can lie about a wall. For ultimate tic-tac-toe it was a rule I would otherwise keep in two languages.&lt;/p&gt;

&lt;p&gt;I built this with my 9-year-old son. He picks the game. The pages are TypeScript and canvas, with no framework. The server is Go. HTTP is tRPC, and the game is a WebSocket. Accounts are in MySQL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mathgamefree.com/family-math-games?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch_2026_10" rel="noopener noreferrer"&gt;Math Charm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you play one, tell me which game a kid picks and which rule was hard to see. We hope kids play for a while and start to like math along the way.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <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://dev.to/wb_7ff5e372a07dea755a347/how-i-built-an-ai-birthday-photo-generator-with-cloudflare-workers-gemini-25-flash-and-flux2-pro-40pm</link>
      <guid>https://dev.to/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>
