<?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: Lucas Zhuang</title>
    <description>The latest articles on DEV Community by Lucas Zhuang (@lucas_zhuang).</description>
    <link>https://dev.to/lucas_zhuang</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%2F3921270%2F39159a2b-8965-4bbb-a7aa-76d4fa0451d9.png</url>
      <title>DEV Community: Lucas Zhuang</title>
      <link>https://dev.to/lucas_zhuang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucas_zhuang"/>
    <language>en</language>
    <item>
      <title>Building TwitFlow: Designing a 30-Second AI Spark for What to Tweet</title>
      <dc:creator>Lucas Zhuang</dc:creator>
      <pubDate>Sun, 07 Jun 2026 13:47:19 +0000</pubDate>
      <link>https://dev.to/lucas_zhuang/building-twitflow-designing-a-30-second-ai-spark-for-what-to-tweet-56jl</link>
      <guid>https://dev.to/lucas_zhuang/building-twitflow-designing-a-30-second-ai-spark-for-what-to-tweet-56jl</guid>
      <description>&lt;p&gt;Introduction&lt;/p&gt;

&lt;p&gt;Coming up with something to tweet is an oddly common blocker. You know what you want to build, share, or teach, but when you open Twitter the cursor stares back at you. TwitFlow is a tiny product I built to remove that friction: a 30-second AI-powered spark that gives you angles, starter lines, and a place to stash local drafts.&lt;/p&gt;

&lt;p&gt;In this post I’ll walk through the problem we solved, the product decisions that let us ship fast, the technical choices (Next.js, IndexedDB, Creem for one-time payments), and a few lessons learned that might help you ship similar micro-products.&lt;/p&gt;

&lt;p&gt;The problem: blank-cursor syndrome&lt;/p&gt;

&lt;p&gt;Many tools aim to make posting easier by handling scheduling, analytics, or multi-account publishing. Those problems assume the user already knows what to say. TwitFlow solves the step before that: "What should I tweet today?" The product is intentionally tiny: no account system, no Twitter API integration, and no server-side draft storage in the MVP. The goal was to get someone from blank page to a tweetable string in under 30 seconds.&lt;/p&gt;

&lt;p&gt;Core product decisions&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Focus: only solve "what to tweet". Not social management, not analytics.&lt;/li&gt;
&lt;li&gt;No Twitter API. The Basic write permission is expensive and fragile; instead we copy-to-clipboard and open the compose URL. This keeps costs near-zero and avoids platform rate limits.&lt;/li&gt;
&lt;li&gt;No account needed. Drafts live in IndexedDB. This removes signup friction and privacy concerns.&lt;/li&gt;
&lt;li&gt;Monetization: free with ads + one-time $4.99 buyout to remove ads. No subscriptions in v1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The minimal flow (30 seconds)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open TwitFlow.&lt;/li&gt;
&lt;li&gt;Type a keyword, short phrase, or a short sentence you want to expand on.&lt;/li&gt;
&lt;li&gt;Hit generate. AI returns five angles with short starter lines and a small engagement rationale.&lt;/li&gt;
&lt;li&gt;Save as a local draft and continue editing it if you want; when ready, copy the content and jump to twitter.com/compose/tweet to paste and publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why these constraints? Speed and trust. By keeping everything local and lightweight we reduce user resistance. The product surface is small enough that people can try it in a single browser session without registering.&lt;/p&gt;

&lt;p&gt;Architecture and tech choices&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frontend: Next.js (App Router). Familiar developer DX and Vercel deployment.&lt;/li&gt;
&lt;li&gt;Styling: Tailwind + shadcn/ui for rapid UI iterations.&lt;/li&gt;
&lt;li&gt;Local storage: IndexedDB via the &lt;code&gt;idb&lt;/code&gt; wrapper for reliable, structured local persistence.&lt;/li&gt;
&lt;li&gt;AI: a low-cost model (MiniMax M3 family in our stack) to balance quality and API cost.&lt;/li&gt;
&lt;li&gt;Payments: Creem for one-time license key issuance. The flow issues a License Key by email; users paste it into TwitFlow to remove ads. The site verifies the key via a Creem license API and returns an HMAC-signed token stored in localStorage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small code example — generate endpoint (client side)&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;// client-side call to the generate API&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchIdeas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="p"&gt;}),&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Generation failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// { drafts: [{content, score, reason}, ...] }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key trade-offs and why they matter&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;No account vs. no cloud sync: keeping drafts entirely local reduces friction and privacy risk. The trade-off is cross-device continuity — something we reserve for a future version once users ask for it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No Twitter API vs. direct publishing: using copy-and-open keeps costs down and avoids API breakage, but it means TwitFlow can’t auto-post. For our target users (indie hackers and creators), copy+paste is acceptable and familiar.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One-time buyout vs. subscription: psychologically, a low one-time fee (e.g. $4.99) lowers the barrier for small wins. Subscriptions add churn pressure and require more features to justify recurring cost.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Product iteration and lessons learned&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ship narrow: the clearest feedback came from people who used TwitFlow as a single-purpose tool. Raising scope too fast dilutes the value proposition.&lt;/li&gt;
&lt;li&gt;Local-first storage is a feature: users appreciate that their drafts are private by default. When we later considered cloud sync, retention metrics were already decent without it.&lt;/li&gt;
&lt;li&gt;Offer multiple angles, not polished long tweets: users prefer a starter idea they can adapt to their voice rather than a fully written tweet that feels "AI-generated".&lt;/li&gt;
&lt;li&gt;Small UI niceties matter: instant copy-to-clipboard, clear save icons, and an obvious path from idea → draft → tweet make the 30-second promise believable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Challenges we faced&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payment verification UX: Creem returns license keys via email. We added a short path for users to paste keys into the product. To avoid lost keys or repeated activations, &lt;code&gt;/api/activate&lt;/code&gt; validates keys and returns a signed token the client stores.&lt;/li&gt;
&lt;li&gt;Pricing uncertainty: deciding between $4.99 and $9.99 was delicate. We launched with $4.99; early conversion data is the true judge.&lt;/li&gt;
&lt;li&gt;Multi-language prompt tuning: supporting Japanese, Korean, Portuguese, and Spanish required localized prompt templates more than new models. That was cheap to implement but still required native review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roadmap highlights&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Short-term (v1.x): improve prompts, add "quote mode" (give angles for replying/quoting an existing tweet), and polish the buyout flow.&lt;/li&gt;
&lt;li&gt;Medium-term (v1.3+): optional account + cloud sync, a lightweight queue/ scheduler (local-first), and small analytics for local insights (which drafts users actually post).&lt;/li&gt;
&lt;li&gt;Long-term (v2.0): browser extension and optional cross-device sync for users who want it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How you can try or reuse parts&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you want to reproduce the core idea: a tiny UI + an AI endpoint + local IndexedDB storage is all you need. The UX is the product: fast, single-purpose flows win.&lt;/li&gt;
&lt;li&gt;Use an inexpensive LLM for drafts (cheap model + tight prompt engineering). Store drafts locally and use the &lt;code&gt;twitter.com/compose/tweet&lt;/code&gt; compose flow for publishing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Closing thoughts&lt;/p&gt;

&lt;p&gt;Micro-products can win by solving the single smallest friction in a larger workflow. TwitFlow’s job isn’t to replace publishing tools — it’s to make the first 30 seconds count. Keep the tool tiny, private by default, and optimize for the single ritual you expect users to repeat.&lt;/p&gt;

&lt;p&gt;If you’re curious, I can share the exact prompt patterns and the &lt;code&gt;/api/activate&lt;/code&gt; snippet that validates Creem license keys. Want those in a follow-up post?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How I Built an AI Emoji Generator with Next.js 15 &amp; Cloudflare Workers AI</title>
      <dc:creator>Lucas Zhuang</dc:creator>
      <pubDate>Sat, 09 May 2026 08:14:26 +0000</pubDate>
      <link>https://dev.to/lucas_zhuang/how-i-built-an-ai-emoji-generator-with-nextjs-15-cloudflare-workers-ai-4hn2</link>
      <guid>https://dev.to/lucas_zhuang/how-i-built-an-ai-emoji-generator-with-nextjs-15-cloudflare-workers-ai-4hn2</guid>
      <description>&lt;p&gt;Every emoji tool I could find did the same thing: let you pick from a fixed set of combos.&lt;/p&gt;

&lt;p&gt;Emoji Kitchen has 50K+ monthly visitors exactly because people love emoji combinations. But it's just a lookup table — Google pre-rendered ~40,000 combinations and serves them as static images. There's no AI, no creativity, no support for combinations that don't exist in the dataset.&lt;/p&gt;

&lt;p&gt;I wanted to build something different: type two emoji, get a brand-new AI-generated image that's never existed before, with a transparent background so you can actually use it anywhere.&lt;/p&gt;

&lt;p&gt;Here's how &lt;a href="https://www.forgemoji.com" rel="noopener noreferrer"&gt;Forgemoji&lt;/a&gt; works under the hood.&lt;/p&gt;

&lt;p&gt;A quick note on scope: I'm a UI/UX designer by trade, so I handled the full product — interface design, interaction design, and the engineering. The stack choices below reflect someone who thinks in user flows first and infrastructure second.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Three Layers
&lt;/h2&gt;

&lt;p&gt;I ended up with a three-layer generation system, each progressively more capable (and more expensive):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1 — Static lookup     ← REMOVED (copyright risk)
Layer 2 — Text-to-image     ← Primary path (free, ~30s)
Layer 3 — Image-to-image    ← Upload your photo, fuse it with an emoji (~120s, async)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layer 1 was the original plan — use Google's pre-rendered emoji kitchen images. I killed it on day 5 after reading Google's image attribution policy more carefully. The risk wasn't worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: Text-to-Image with a Provider Fallback Chain
&lt;/h2&gt;

&lt;p&gt;The core challenge with T2I emoji generation is: &lt;strong&gt;how do you make the model output something that actually looks like an emoji?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The naive approach — &lt;code&gt;"🐱 + 🔥"&lt;/code&gt; — produces whatever the model thinks a cat fire is. Usually it's a realistic cat on fire. Not what you want.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt Engineering: Descriptions &amp;gt; Unicode
&lt;/h3&gt;

&lt;p&gt;I built a mapping table (&lt;code&gt;emoji-prompt-map.ts&lt;/code&gt;) that translates emoji into concrete visual descriptions:&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;EMOJI_DESCRIPTIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&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;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;round yellow face, wide open grin showing teeth, simple oval dot eyes&lt;/span&gt;&lt;span class="dl"&gt;'&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;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bright orange and red flame, cartoon style, rounded base&lt;/span&gt;&lt;span class="dl"&gt;'&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;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cute round cat face, large eyes, small pink nose, whiskers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// 200+ entries...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the full prompt looks like:&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;function&lt;/span&gt; &lt;span class="nf"&gt;buildLayer2Prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emoji1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emoji2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_DESCRIPTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;emoji1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;emoji1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_DESCRIPTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;emoji2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;emoji2&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`A single emoji character that fuses: [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;desc1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] merged with [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;desc2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;].`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Flat cartoon illustration style. Centered on pure white background.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;One single character only, no text, no multiple objects side by side.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Clean vector-like look, bold outlines, vivid saturated colors.&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="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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "&lt;strong&gt;one single character only&lt;/strong&gt;" constraint is critical. Without it, models love to render two separate objects next to each other — a cat on the left, a flame on the right — which defeats the whole point.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Provider Chain
&lt;/h3&gt;

&lt;p&gt;No single free provider is reliable enough to use alone. I set up a priority fallback chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T2I: Cloudflare Workers AI (flux-1-schnell)
       → ModelScope Z-Image-Turbo
         → MiniMax image-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare's Workers AI gives ~230 free generations per day. For a bootstrapped side project, that's more than enough to handle organic traffic without paying anything. When it's exhausted or errors out, the request automatically falls to the next provider.&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;// Simplified provider selection&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GenerateOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isI2I&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-proxy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-proxy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modelscope-i2i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minimax-i2i&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modelscope-t2i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minimax-t2i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;logProviderError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="c1"&gt;// try next&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;All providers failed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each provider failure fires a Discord webhook alert, so I can see in real time if a provider is down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Transparent Background Problem
&lt;/h2&gt;

&lt;p&gt;This is where most emoji tools stop. They return a white or colored background, which makes the result useless for Discord stickers, Telegram emoji, or overlay use.&lt;/p&gt;

&lt;p&gt;I run every generated image through &lt;a href="https://github.com/danielgatis/rembg" rel="noopener noreferrer"&gt;rembg&lt;/a&gt;, hosted on my own server:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;removeBackground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBase64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&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;REMBG_API_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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;REMBG_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;image_base64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageBase64&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;}&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="c1"&gt;// base64 PNG with alpha channel&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But here's the UX problem: rembg takes 2–4 seconds. If you wait for it before showing anything, the whole generation feels slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Show the image immediately with the background, then swap to the transparent version once rembg finishes. The user gets visual feedback fast, and the "better" version appears a few seconds later without any loading state.&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;// In the API route: fire rembg async, don't await it before responding&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rembgPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;removeBackground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawImageBase64&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Return raw image first, rembg result comes via a separate /api/rembg call&lt;/span&gt;
&lt;span class="c1"&gt;// triggered client-side after the initial image loads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Layer 3: Image-to-Image (Upload Your Face)
&lt;/h2&gt;

&lt;p&gt;The I2I mode lets users upload a photo and fuse it with an emoji style. This is the "Genmoji" experience — without requiring an Apple device.&lt;/p&gt;

&lt;p&gt;The challenge here is latency. I2I models take 60–120 seconds. A synchronous API call times out on Vercel (max function timeout is 300s, and 120s is cutting it close).&lt;/p&gt;

&lt;p&gt;I solved it with a &lt;strong&gt;submit/poll architecture&lt;/strong&gt;:&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="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/api/generate/submit&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&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="err"&gt;task_id&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="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;/api/generate/poll?id=xxx&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&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="err"&gt;status:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'pending'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'done'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;result?&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;The client submits the job, gets a &lt;code&gt;task_id&lt;/code&gt;, then polls every 3 seconds until done. The UI shows a countdown timer so users know roughly how long to wait.&lt;/p&gt;

&lt;p&gt;For I2I, prompt engineering gets trickier. The model needs to clearly understand which elements to keep from the photo (the person's face) and which to replace with emoji style. I use a "dual prompt" strategy:&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;function&lt;/span&gt; &lt;span class="nf"&gt;buildLayer3DualPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userEmoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;photoContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emojiDesc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_DESCRIPTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userEmoji&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;userEmoji&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`Transform the person in the photo into a single emoji character.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Emoji style: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;emojiDesc&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Keep the person's facial features and expression. Apply flat cartoon illustration style.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Result must be ONE centered emoji character. No background, no text.`&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Rate Limiting Without a Database
&lt;/h2&gt;

&lt;p&gt;I didn't want to add a database just for rate limiting. Solution: IP-based limits stored in a &lt;strong&gt;Vercel KV-compatible in-memory map&lt;/strong&gt; (good enough for the current traffic level, will migrate when needed):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 generations per IP per day&lt;/li&gt;
&lt;li&gt;3 generations per IP per minute (burst protection)&lt;/li&gt;
&lt;li&gt;500 total generations per day across all users
&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="c1"&gt;// In-memory store — resets on each Vercel function cold start&lt;/span&gt;
&lt;span class="c1"&gt;// Good enough for &amp;lt;1K daily users, not suitable for horizontal scaling&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ipDayMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ipMinuteMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;globalDailyCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this production-grade? No. But for a side project at &amp;lt;500 daily users, it works perfectly and avoids adding infrastructure complexity before you need it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Animated Emoji Feature
&lt;/h2&gt;

&lt;p&gt;Static emoji are fine. Animated emoji are shareable.&lt;/p&gt;

&lt;p&gt;I added 6 preset animations (Bounce, Float, Wiggle, Pulse, Rubber, Spin) using Canvas frame-by-frame rendering, then encoding to GIF or WebP.&lt;/p&gt;

&lt;p&gt;The browser-side approach (gif.js) is the fallback — it works but is slow on low-end devices. The real implementation sends the PNG to my server and uses FFmpeg:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client: PNG + animation_name + size + format
  → POST /api/animate (my rembg server also handles this)
  → FFmpeg palettegen + paletteuse with reserve_transparent=1
  → Return GIF / WebP bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For transparent GIFs (critical for Discord/Telegram stickers), the FFmpeg command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; frames_%03d.png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-filter_complex&lt;/span&gt; &lt;span class="s2"&gt;"[0:v] palettegen=reserve_transparent=1 [p]; [0:v][p] paletteuse=alpha_threshold=128"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  output.gif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting the transparency right took way longer than I expected. The &lt;code&gt;reserve_transparent=1&lt;/code&gt; + &lt;code&gt;alpha_threshold=128&lt;/code&gt; combination is the key — without both flags, you get either a black background or jagged edges.&lt;/p&gt;




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

&lt;p&gt;The tool is live at &lt;a href="https://www.forgemoji.com" rel="noopener noreferrer"&gt;forgemoji.com&lt;/a&gt; — free to use, no account required.&lt;/p&gt;

&lt;p&gt;Current stats after ~2 weeks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;261 pre-generated combo pages for SEO long-tail&lt;/li&gt;
&lt;li&gt;3-provider T2I fallback chain (zero paid cost for up to ~230 daily generations)&lt;/li&gt;
&lt;li&gt;Full animated export: 6 effects × 3 sizes × 2 formats (GIF/WebP)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Things I'm still working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AdSense monetization (applied, pending review)&lt;/li&gt;
&lt;li&gt;Product Hunt launch (waiting for stable UV baseline)&lt;/li&gt;
&lt;li&gt;More emoji in the mapping table (currently ~200 entries, want 500+)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and ran into the same "how do I make AI output something that actually looks like an emoji" problem, I hope this helps. Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
