<?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: KunStudio</title>
    <description>The latest articles on DEV Community by KunStudio (@kunstudio).</description>
    <link>https://dev.to/kunstudio</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%2F3915093%2F11aedda3-8712-45a5-a1f7-05920ccb0819.jpg</url>
      <title>DEV Community: KunStudio</title>
      <link>https://dev.to/kunstudio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunstudio"/>
    <language>en</language>
    <item>
      <title>How Korean fintech (Toss / KakaoPay / PortOne) actually works under the hood</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 20 Jul 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/kunstudio/how-korean-fintech-toss-kakaopay-portone-actually-works-under-the-hood-3a70</link>
      <guid>https://dev.to/kunstudio/how-korean-fintech-toss-kakaopay-portone-actually-works-under-the-hood-3a70</guid>
      <description>&lt;h2&gt;
  
  
  The Architecture Behind Korea's Fintech Giants: How Toss, KakaoPay, and PortOne Actually Work
&lt;/h2&gt;

&lt;p&gt;Korean fintech companies like Toss, KakaoPay, and PortOne have become synonymous with financial innovation, but most developers only see the polished UI. I've spent months reverse-engineering their payment flows, API patterns, and infrastructure choices while building payment features for my own products. Here's what actually happens when you tap "send money" in Toss or click "pay" on PortOne.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the Three-Layer Korean Fintech Model
&lt;/h2&gt;

&lt;p&gt;Korean fintech operates within a regulatory framework that forces a specific architectural pattern: the three-layer stack. Unlike other countries, Korea mandates strict separation between payment service providers (PG), payment intermediaries, and actual banks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Frontend &amp;amp; User Interface&lt;/strong&gt;&lt;br&gt;
This is what users see. Toss's interface is famously minimal—a transfer takes maybe 3 taps. But under the hood, the frontend performs several crucial steps: biometric authentication (fingerprint, face recognition via Knox on Samsung devices), device binding validation, and transaction signing. Toss uses a combination of WebAuthn for web and proprietary SDK integration for mobile apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Payment Service Provider (PG)&lt;/strong&gt;&lt;br&gt;
PortOne (formerly I'mport) sits here. They don't actually hold money—they're orchestrators. When you initiate a payment through PortOne's API, you're hitting their unified payment gateway that routes to the actual acquiring banks. PortOne normalizes requests across 50+ Korean payment methods into a single API surface. Their core business is handling the translation layer between merchants and banks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Banking Infrastructure&lt;/strong&gt;&lt;br&gt;
The actual money movement happens through Korea's interbank network. Toss itself operates as a "virtual bank" (인터넷 전문은행) licensed by the FSC since 2021. This is critical—they can hold customer deposits directly, which KakaoPay cannot. This explains why Toss launched checking accounts and why KakaoPay had to partner with Kakao Bank.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Real-Time Clearing System: A-Wait-FTS
&lt;/h2&gt;

&lt;p&gt;The backbone of Korean money movement isn't SWIFT or ACH. It's &lt;strong&gt;A-Wait-FTS&lt;/strong&gt; (Advanced Waiting Financial Telecom System), the interbank settlement platform operated by the Korean Financial Telecommunications and Clearings Institute. Think of it as Korea's version of Fedwire, but with specific requirements for fintech.&lt;/p&gt;

&lt;p&gt;When you send ₩10,000 from a Toss account to another bank, here's the actual sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; (~100ms): Multi-factor auth combines something you have (device), something you know (PIN), and something you are (biometrics). Toss uses device binding—your phone is registered with your account. A transaction from an unregistered device triggers additional verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transaction Signing&lt;/strong&gt; (~50ms): The request is signed using PKI (Public Key Infrastructure). Korean regulations require specific encryption standards—primarily AES-256 for transmission and RSA-2048 for key exchange.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Message Format Conversion&lt;/strong&gt; (~30ms): PortOne or the direct bank connection formats the request into SWIFT-like messages, but with Korean-specific fields. Bank codes use the 3-digit BIC system (우리은행=020, 국민은행=004, etc.).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real-Time Settlement&lt;/strong&gt; (~1-3 seconds): The money moves through A-Wait-FTS. Unlike older batch systems, A-Wait-FTS processes transfers individually and immediately. Most Korean transfers complete in under 10 seconds, often faster than 5 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Notification&lt;/strong&gt; (~100ms): Both sides receive settlement confirmations. Apps display "전송 완료" (transfer complete) almost instantly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire flow completes in 5-10 seconds, which is why Korean users expect instant transfers. This is standard, not exceptional.&lt;/p&gt;
&lt;h2&gt;
  
  
  How KakaoPay Handles Money Without Being a Bank
&lt;/h2&gt;

&lt;p&gt;KakaoPay's architecture reveals how fintech navigates regulatory constraints. They operate as a &lt;strong&gt;payment service intermediary&lt;/strong&gt; (결제중개사), not a bank. This means they cannot legally hold customer money.&lt;/p&gt;

&lt;p&gt;Their workaround: custodial banking relationships. When you load ₩100,000 into KakaoPay, it's actually held by partner banks (신한은행, 우리은행, 국민은행). The frontend shows one unified wallet, but the backend maintains separate ledgers per partner bank. Kakao's system determines which partner bank holds which funds based on customer velocity and liquidity forecasts.&lt;/p&gt;

&lt;p&gt;When you send money from KakaoPay to another user, two scenarios occur:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If recipient is also on KakaoPay:&lt;/strong&gt; Internal ledger transfer, instant, no clearing required. Just a database update in Kakao's system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If recipient is on another platform or bank:&lt;/strong&gt; Actual clearing through A-Wait-FTS, incurring fees. This is why KakaoPay encourages transfers between KakaoPay users—zero fees.&lt;/p&gt;

&lt;p&gt;The fee structure here is telling: ₩0 for in-network, ₩1,000 for cross-network transfers under ₩1,000,000. That difference is literally the cost of A-Wait-FTS clearing.&lt;/p&gt;
&lt;h2&gt;
  
  
  PortOne's API Abstraction Layer
&lt;/h2&gt;

&lt;p&gt;PortOne's technical value isn't flashy, but it's where the real engineering happens. They normalize payment methods across different banks and PGs.&lt;/p&gt;

&lt;p&gt;A merchant integrating PortOne makes one API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;IMP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_pay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pay_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;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;merchant_uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20231015-000001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&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="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_name&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="na"&gt;buyer_tel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;010-1234-5678&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rsp&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="nx"&gt;rsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// payment success&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;Behind this single call, PortOne:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Routes to the appropriate PG (Danal, KCP, NICE, etc.) based on pg parameter and inventory logic&lt;/li&gt;
&lt;li&gt;Handles method-specific quirks (일반결제 vs 정기결제 parameters differ per PG)&lt;/li&gt;
&lt;li&gt;Manages token storage for recurring payments&lt;/li&gt;
&lt;li&gt;Normalizes response formats (each PG returns different field names)&lt;/li&gt;
&lt;li&gt;Handles escrow payments, which are common for marketplace transactions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They process ~₩20 trillion annually (2023 numbers), roughly 40% of Korean online transactions. That volume requires serious infrastructure—they use redundant data centers, real-time failover to backup PGs, and maintain &amp;lt;99.99% uptime SLA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: Beyond HTTPS
&lt;/h2&gt;

&lt;p&gt;Korean fintech security goes beyond standard TLS. It mandates specific practices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card Information Never Touches Your Server&lt;/strong&gt;: All sensitive payment data goes directly to the PG's secure servers via client-side encryption. This is non-negotiable under PCI DSS + Korean regulations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token-Based Transactions&lt;/strong&gt;: After initial payment, merchants receive tokens. Subsequent transactions use tokens, never raw card data. PortOne manages this token lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device Binding&lt;/strong&gt;: A payment initiated from an unknown device automatically triggers additional verification. Toss stores device fingerprints (hardware identifiers, OS, installed apps) to detect anomalies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-Time Fraud Detection&lt;/strong&gt;: Most platforms use ML-based scoring (Datamines, SK Infosec, etc.). Transaction flagged as suspicious? SMS 2FA required. This adds 10-30 seconds but is invisible if legitimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption Standards&lt;/strong&gt;: The regulatory default is AES-256-CBC for data transmission, RSA-2048 for key exchange. Older MD5 hashing is explicitly forbidden since 2015.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Integration Speed Still Matters
&lt;/h2&gt;

&lt;p&gt;Despite standardization, Korean fintech integration takes 1-2 weeks for a competent engineer, not days. Why?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Certification delays&lt;/strong&gt;: Your merchant registration can't process payments until the PG's compliance team reviews your business (3-5 days).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test-mode requirements&lt;/strong&gt;: Each PG's sandbox has different test card numbers, amounts that trigger specific responses. Test card 4111-1111-1111-1111 works at one PG, fails at another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring payment setup&lt;/strong&gt;: If you need subscriptions, the setup flow diverges dramatically between PGs. NICE requires explicit API keys per subscription type, while Danal doesn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building payment infrastructure in Korea is pragmatic engineering. You're not fighting the system—you're working within it. Toss, KakaoPay, and PortOne succeeded not through technical wizardry, but by understanding Korean financial regulations intimately and building systems that respect them while delivering exceptional user experience.&lt;/p&gt;

&lt;p&gt;If you're building payment features for Korean users or expanding into Korea, integrating properly from the start saves months later. Understanding these three layers—user interface, PG orchestration, and banking infrastructure—shapes every architectural decision.&lt;/p&gt;

&lt;p&gt;I'm currently building Saju, a fintech dashboard for Korean solo founders and small businesses to manage cash flow across multiple payment platforms. We went through this exact integration nightmare, and I'm documenting lessons learned at &lt;a href="https://sajuapp.app" rel="noopener noreferrer"&gt;https://sajuapp.app&lt;/a&gt;. If you're navigating Korean payment infrastructure for the first time, that might save you days of debugging.&lt;/p&gt;

</description>
      <category>korea</category>
      <category>fintech</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two SKUs, One Model: How Prompt Engineering Alone Creates a $19 vs $29 SOP Generator</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:23:39 +0000</pubDate>
      <link>https://dev.to/kunstudio/two-skus-one-model-how-prompt-engineering-alone-creates-a-19-vs-29-sop-generator-3ek</link>
      <guid>https://dev.to/kunstudio/two-skus-one-model-how-prompt-engineering-alone-creates-a-19-vs-29-sop-generator-3ek</guid>
      <description>&lt;h1&gt;
  
  
  Two SKUs, One Model: How Prompt Engineering Alone Creates a $19 vs $29 SOP Generator
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-sop-builder.pages.dev/" rel="noopener noreferrer"&gt;AI SOP Builder&lt;/a&gt; turns a plain-language process description into a finished Standard Operating Procedure. There's no fine-tuned model and no separate "pro" pipeline — it's a single Claude Haiku call, and the entire product differentiation between the $19 and $29 tiers lives in how the prompt is assembled.&lt;/p&gt;

&lt;h2&gt;
  
  
  One generation function, two tiers
&lt;/h2&gt;

&lt;p&gt;Both SKUs — &lt;code&gt;single&lt;/code&gt; ($19, standard) and &lt;code&gt;pro&lt;/code&gt; ($29, "audit-ready") — route through the same &lt;code&gt;generateSOP()&lt;/code&gt; function and the same model (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;, with an optional &lt;code&gt;SOP_MODEL_PRO&lt;/code&gt; env override). What changes is a &lt;code&gt;tier&lt;/code&gt; string threaded through the prompt builders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are a senior operations and quality-management specialist who writes Standard &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Operating Procedures (SOPs) that pass ISO 9001-style audits...&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="s2"&gt;Never invent fake statistics, regulation numbers, certification IDs, or citations. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;If a step depends on a policy you were not given, refer to it generically (for example &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'per your data-retention policy') and flag it as something the owner must fill in...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&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="s2"&gt;This is the audit-ready edition: add a compact RACI view ... explicit controls, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkpoints, and risk/compliance notes throughout the Procedure, and make the &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Quality Checklist thorough enough to serve as a sign-off sheet.&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="s2"&gt;Keep it practical, complete, and immediately usable by a small team.&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="s2"&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;Pro doesn't get a different retrieval source or bigger context window — it gets one extra paragraph telling the same model to add a RACI matrix (Responsible/Accountable/Consulted/Informed), inline compliance notes, and a heavier sign-off checklist. The anti-hallucination instruction — never invent a regulation number or certification ID, flag anything unprovided as something "the owner must fill in" — applies to both tiers, since a fabricated ISO clause in an audit file is worse than an SOP that's honestly generic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free preview is a capped, separate generation — not a truncated one
&lt;/h2&gt;

&lt;p&gt;Before payment, &lt;code&gt;/api/preview&lt;/code&gt; generates only the "Purpose and Scope" section, capped at 240 output tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;generateSOP&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;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&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;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&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;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;previewOnly&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&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;SOP_MODEL_PRO&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;claudeGenerate&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;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;5200&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&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;This isn't the full SOP truncated client-side — in &lt;code&gt;previewOnly&lt;/code&gt; mode the prompt itself tells the model to write nothing but that section ("write nothing else — no other section, no numbered steps"). The visitor reads something genuinely specific to their process before paying anything, which is the point: a preview obviously tailored to &lt;em&gt;your&lt;/em&gt; input converts differently than a generic marketing screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fulfillment happens inline with payment capture — deliberately
&lt;/h2&gt;

&lt;p&gt;There's no separate "generate" step after checkout. &lt;code&gt;capture-order.js&lt;/code&gt; captures the PayPal order and, in the same request, calls &lt;code&gt;generateSOP()&lt;/code&gt; for the full document before responding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&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;processingError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="nx"&gt;sop&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;generateSOP&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;processing_error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;support&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghdejr11@gmail.com (auto-refund if unfulfilled)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line: &lt;code&gt;status: "COMPLETED"&lt;/code&gt; still returns even if generation throws — the payment succeeded, so the response says so, with a &lt;code&gt;processing_error&lt;/code&gt; flag and a support contact instead of silently losing the fact that money changed hands. A source comment ties this directly to a lesson from a prior pipe where payments succeeded while fulfillment silently failed with no record of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering the result without a Markdown library
&lt;/h2&gt;

&lt;p&gt;The SOP comes back as Markdown, and the front end renders it with a small hand-written parser — line-by-line regex matching for headings (&lt;code&gt;^#{1,4}\s&lt;/code&gt;), numbered lists (&lt;code&gt;^\d+[.)]\s&lt;/code&gt;), bullets (&lt;code&gt;^[-*]\s&lt;/code&gt;), and &lt;code&gt;**bold**&lt;/code&gt; spans — built entirely with &lt;code&gt;createElement&lt;/code&gt;/&lt;code&gt;textContent&lt;/code&gt;, never &lt;code&gt;innerHTML&lt;/code&gt;. It's a deliberately small vocabulary because that's the entire output format the system prompt requires — "render-safe Markdown only... no tables, code fences, or HTML" — so the renderer only has to handle exactly what the model is told to produce.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-sop-builder.pages.dev/" rel="noopener noreferrer"&gt;https://ai-sop-builder.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Redesigning a Yard Without Touching the House: Structure-Preserving AI Image Edits With FLUX.2 [pro]</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:23:02 +0000</pubDate>
      <link>https://dev.to/kunstudio/redesigning-a-yard-without-touching-the-house-structure-preserving-ai-image-edits-with-flux2-pro-4nh6</link>
      <guid>https://dev.to/kunstudio/redesigning-a-yard-without-touching-the-house-structure-preserving-ai-image-edits-with-flux2-pro-4nh6</guid>
      <description>&lt;h1&gt;
  
  
  Redesigning a Yard Without Touching the House: Structure-Preserving AI Image Edits With FLUX.2 [pro]
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-garden-design.pages.dev/" rel="noopener noreferrer"&gt;AI Garden Design&lt;/a&gt; takes a photo of a front yard, backyard, or facade and re-renders the landscaping in a different style — modern, cottage, Japanese zen, desert xeriscape, tropical — while leaving the house itself alone. That constraint, "leave the house alone," turned out to be the whole engineering problem. Nobody wants a "redesign" that also invents a different roofline or moves the driveway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model choice: an edit model, not a fresh generation
&lt;/h2&gt;

&lt;p&gt;This isn't text-to-image, it's image-to-image editing — specifically &lt;code&gt;fal-ai/flux-2-pro/edit&lt;/code&gt;, a multi-reference, structure-preserving photoreal edit model. The uploaded photo is the reference image, and &lt;code&gt;image_size: "auto"&lt;/code&gt; matches the output aspect ratio to the input instead of forcing a fixed size, which matters when the same house has to stay in the same position in the frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;falRedesign&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;imageUrl&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="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;falQueue&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/flux-2-pro/edit&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;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;image_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&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="s2"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5&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;base&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;images&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;images&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;images&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&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;base&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="s2"&gt;no image&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="nf"&gt;falUpscale&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;base&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;&lt;code&gt;safety_tolerance: "5"&lt;/code&gt; (the most permissive setting) exists for a boring reason: default safety filters on image-edit models are tuned for far broader content than "photo of a lawn," and even benign yard photos were tripping false positives without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserving structure is a prompting problem, not a masking problem
&lt;/h2&gt;

&lt;p&gt;The obvious approach would be an inpainting mask — edit only the ground, leave the house region untouched pixel-for-pixel. This pipe doesn't do that; it relies entirely on prompt instructions. Every style prompt follows the same shape: describe the new landscaping, then explicitly state what not to touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;modern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Redesign only the outdoor yard and landscaping in a clean modern style: geometric &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;planting beds, ornamental grasses, minimalist concrete or corten-steel planters, a tidy &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paved path, low-maintenance evergreen shrubs and a crisp lawn edge. Keep the house, walls, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;windows, roof, driveway, fences and property boundaries exactly unchanged.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;REAL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;REAL&lt;/code&gt; is a shared suffix appended to every style prompt — a push toward "real DSLR architectural photograph" plus an explicit reject list: no illustration, no cartoon, no Unreal-Engine-style 3D render, no plastic CGI sheen. Landscape imagery lives or dies on "does this look like a real photo of my actual house," so the negative instructions do as much work as the positive description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same finishing-pass trick as the rest of the fal.ai pipes
&lt;/h2&gt;

&lt;p&gt;After the edit model returns a base render, it runs through &lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; again — low &lt;code&gt;creativity&lt;/code&gt; (0.3), moderate &lt;code&gt;resemblance&lt;/code&gt; (0.75) — purely for sharpness, with a &lt;code&gt;try/catch&lt;/code&gt; that falls back to the un-upscaled base on any failure so a paid render is never lost to a flaky second call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;falUpscale&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;imageUrl&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="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;falQueue&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/clarity-upscaler&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;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upscale_factor&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="na"&gt;creativity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resemblance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;masterpiece, best quality, highres, sharp, photorealistic garden photograph&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&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;image&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&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;e&lt;/span&gt;&lt;span class="p"&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;imageUrl&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submit-then-poll helper (&lt;code&gt;falQueue&lt;/code&gt;) is fully generic — the same function submits to &lt;code&gt;flux-2-pro/edit&lt;/code&gt;, &lt;code&gt;clarity-upscaler&lt;/code&gt;, and backs the text add-on below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upsell: an LLM-written landscaping brief, gated by the same grant
&lt;/h2&gt;

&lt;p&gt;The Pro tier ($19.99, 25 renders across all five styles) unlocks &lt;code&gt;/api/brief&lt;/code&gt;, which calls Claude (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;) to write a plant list, a rough budget, and a contractor-ready scope of work. It reuses the same HMAC grant verification as the image endpoint, plus one extra check — the tier itself has to have &lt;code&gt;brief: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku&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;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown tier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&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;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Design brief is included with the Pro pack only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feature-gating between Starter ($9.99, 2 styles) and Pro ($19.99, 5 styles + brief) is just a boolean flag on a shared &lt;code&gt;TIERS&lt;/code&gt; config, checked in-line at request time — not a separate SKU-handling code path.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-garden-design.pages.dev/" rel="noopener noreferrer"&gt;https://ai-garden-design.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Fusing Two Parents Into One Face: Building an Identity-Preserving AI Baby Generator on Cloudflare Functions</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:22:59 +0000</pubDate>
      <link>https://dev.to/kunstudio/fusing-two-parents-into-one-face-building-an-identity-preserving-ai-baby-generator-on-cloudflare-2pg9</link>
      <guid>https://dev.to/kunstudio/fusing-two-parents-into-one-face-building-an-identity-preserving-ai-baby-generator-on-cloudflare-2pg9</guid>
      <description>&lt;h1&gt;
  
  
  Fusing Two Parents Into One Face: Building an Identity-Preserving AI Baby Generator on Cloudflare Functions
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-baby-generator.pages.dev/" rel="noopener noreferrer"&gt;BabyGlimpse&lt;/a&gt; takes a photo of each parent and generates AI baby portraits across three age stages (newborn, toddler, child). The interesting problem wasn't "call an image model" — it was fusing two separate faces into one coherent identity, inside the wall-clock limits of a serverless function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: PuLID, not a generic text-to-image call
&lt;/h2&gt;

&lt;p&gt;Describing both faces in a text prompt doesn't work — text can't carry facial identity with any fidelity. This pipe uses &lt;code&gt;fal-ai/pulid&lt;/code&gt;, an identity-preserving diffusion model that accepts actual reference images. Both parent photos go in as &lt;code&gt;reference_images&lt;/code&gt;, and &lt;code&gt;id_mix&lt;/code&gt; tells the model to blend the two identities into one new face instead of copying either one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&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="s2"&gt;https://queue.fal.run/fal-ai/pulid&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="s2"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;FAL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&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;reference_images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refA&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refB&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="na"&gt;image_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;square_hd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;guidance_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;id_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id_mix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fidelity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;negative_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NEG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fal-ai/pulid&lt;/code&gt; runs on SDXL-Lightning, tuned for very few steps (4-8) — more steps don't improve a Lightning checkpoint, they just burn time. &lt;code&gt;id_scale: 0.8&lt;/code&gt; sets how strongly the result must resemble both reference identities versus the text prompt; &lt;code&gt;mode: "fidelity"&lt;/code&gt; biases PuLID toward keeping recognizable features from both parents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finishing pass: a second model call kills the "AI plastic" look
&lt;/h2&gt;

&lt;p&gt;An 8-step Lightning output is identity-accurate but lacks fine skin texture. Rather than push more steps into a model tuned for few steps, the output goes through &lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; as a separate pass with a real (non-Lightning) CFG — low &lt;code&gt;creativity&lt;/code&gt;, high &lt;code&gt;resemblance&lt;/code&gt;, so it enhances texture without drifting from the identity PuLID already locked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&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="s2"&gt;https://queue.fal.run/fal-ai/clarity-upscaler&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="s2"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;FAL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&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;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upscale_factor&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="na"&gt;creativity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resemblance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;guidance_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;negative_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cartoon, cgi, 3d render, plastic skin, waxy skin, ...&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this pass fails or times out, the code falls back to the un-upscaled base image — a paid job should never come back empty because a secondary enhancement step had a bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the browser drives the loop, not the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fal.run&lt;/code&gt; is an async queue: submit a job, get a &lt;code&gt;status_url&lt;/code&gt;/&lt;code&gt;response_url&lt;/code&gt; pair, poll until &lt;code&gt;status === "COMPLETED"&lt;/code&gt;. A full batch (three age stages, several images each, each with its own upscale pass) can take minutes — too long for one serverless invocation to own.&lt;/p&gt;

&lt;p&gt;So the client owns the top-level loop. Each &lt;code&gt;/api/generate&lt;/code&gt; call handles exactly one age stage; the browser calls it three times in sequence (newborn → toddler → child), updating a progress bar between calls. Each Cloudflare Pages Function invocation only has to survive polling for one stage's images, keeping every request inside the platform's CPU/time budget instead of babysitting one giant multi-minute request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment: a signed grant instead of a database
&lt;/h2&gt;

&lt;p&gt;There's no database here. After PayPal capture succeeds server-side, &lt;code&gt;capture-order.js&lt;/code&gt; issues an HMAC-signed grant — a base64 JSON payload &lt;code&gt;{o, sku, n, exp}&lt;/code&gt; plus a SHA-256 signature via the Web Crypto API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&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;key&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&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="s2"&gt;sign&lt;/span&gt;&lt;span class="dl"&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;sig&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&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;&lt;code&gt;/api/generate&lt;/code&gt; re-derives the same signature and rejects the request if it doesn't match, or if &lt;code&gt;exp&lt;/code&gt; has passed (grants last 6 hours). That's the entire access-control layer for a paid API calling a metered third-party image model — no session store, no order table, just a signed token the browser carries between requests. Pricing itself lives server-side in one &lt;code&gt;TIERS&lt;/code&gt; object; the client never gets to influence what PayPal actually charges.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-baby-generator.pages.dev/" rel="noopener noreferrer"&gt;https://ai-baby-generator.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why a cancellation-script generator is a lookup table and a template matrix, not an LLM call</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:22:26 +0000</pubDate>
      <link>https://dev.to/kunstudio/why-a-cancellation-script-generator-is-a-lookup-table-and-a-template-matrix-not-an-llm-call-16o</link>
      <guid>https://dev.to/kunstudio/why-a-cancellation-script-generator-is-a-lookup-table-and-a-template-matrix-not-an-llm-call-16o</guid>
      <description>&lt;h1&gt;
  
  
  Why a cancellation-script generator is a lookup table and a template matrix, not an LLM call
&lt;/h1&gt;

&lt;p&gt;The obvious way to build "write me a cancellation message" is to send the service name and a goal to a language model and let it generate the paragraph. I built &lt;strong&gt;Cancel Helper&lt;/strong&gt; the other way — a known-service lookup table plus a fixed template matrix — because the moment the output starts citing consumer-protection law, "usually right" stops being good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A known-service table with fuzzy lookup
&lt;/h2&gt;

&lt;p&gt;Common services (streaming, gyms, software subscriptions) get an entry in a &lt;code&gt;KNOWN&lt;/code&gt; table: which channel actually works for that provider (web form vs. a phone line vs. in-person only), a provider-specific gotcha worth knowing before you start, and — for gym contracts especially — the retention tactic to expect. &lt;code&gt;lookupKnown()&lt;/code&gt; normalizes whatever the user typed (lowercased, &lt;code&gt;+&lt;/code&gt; expanded to "plus", punctuation stripped) and tries an exact slug match first, then falls back to substring/keyword matching, so "Netflix", "netflix premium", and "NETFLIX" all resolve to the same entry without the user having to type the name exactly right. Each known entry also carries a three-tier difficulty rating, shown as a small pip meter, so someone dealing with a gym contract knows upfront it's a harder path than cancelling a streaming subscription in an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template matrix instead of a per-request generation call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;buildScript()&lt;/code&gt; takes three inputs — goal (cancel / refund / cut short a signup trial / reach a human), channel (phone / email / chat / app-store), and tone (plain / cites-the-law) — and assembles one of a fixed set of reviewed paragraphs by string concatenation. Nothing here is generated per request; every combination is a known, previously-read piece of text. The store channel gets a special case regardless of goal: App Store and Google Play subscriptions have to be cancelled in the store itself, because the merchant on the other end of the message has no ability to stop that billing even if they wanted to — a script politely asking a company to cancel a charge it doesn't control would just waste the user's time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the legal-tone branch is the actual reason not to use a model here
&lt;/h2&gt;

&lt;p&gt;The tone toggle's second option cites specific consumer-protection law: the federal Restore Online Shoppers' Confidence Act, the Fair Credit Billing Act for disputed charges, and the FTC's complaint channel. That's exactly the category of claim a generative model will produce fluently and occasionally wrong — a plausible-sounding statute name, a slightly-off requirement, a citation that doesn't exist. A fixed string gets checked once, by a person, and then reused verbatim for every request instead of being reconstructed (and re-risked) every time. The upside of a small, owned piece of text over a model call isn't just consistency — it's that legal-adjacent copy can be kept current on purpose: the tool's own rights summary explicitly notes that the FTC's broader "click to cancel" rule was vacated by a federal court in mid-2025 and a revival attempt was underway as of early 2026, a detail that would already be stale if it were baked into a model's training data instead of a string that gets reviewed when the law changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosing the affiliate step instead of hiding it
&lt;/h2&gt;

&lt;p&gt;Past the script itself, there's a single goal-aware recommendation block — which partner it points to depends on whether the user picked cancel, refund, cut-short-a-trial, or reach-a-human — tagged &lt;code&gt;rel="sponsored"&lt;/code&gt; and carrying a plain disclosure line stated in the block itself rather than buried in a footer: that a signup may earn a commission, that it never changes what the user pays, and that the partner isn't affiliated with the tool. Putting the disclosure in the same block as the pitch, instead of a separate terms page, was the easy part; not routing every goal to the same generic pitch was the part actually worth doing.&lt;/p&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://cancel-helper.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink" rel="noopener noreferrer"&gt;https://cancel-helper.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the fuzzy-lookup normalization or the goal/channel/tone template matrix if useful — drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Detecting redundant subscriptions by category instead of matching names (all client-side, localStorage only)</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:21:49 +0000</pubDate>
      <link>https://dev.to/kunstudio/detecting-redundant-subscriptions-by-category-instead-of-matching-names-all-client-side-1ggi</link>
      <guid>https://dev.to/kunstudio/detecting-redundant-subscriptions-by-category-instead-of-matching-names-all-client-side-1ggi</guid>
      <description>&lt;h1&gt;
  
  
  Detecting redundant subscriptions by category instead of matching names (all client-side, localStorage only)
&lt;/h1&gt;

&lt;p&gt;Plenty of subscription trackers will list what you're paying for. The harder, more useful question is which of those tools are doing the same job as each other — and which one to keep. &lt;strong&gt;Overlap Audit&lt;/strong&gt; answers that by grouping entries by category rather than trying to pattern-match service names, and it runs entirely against &lt;code&gt;localStorage&lt;/code&gt;, with no backend involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalizing before comparing
&lt;/h2&gt;

&lt;p&gt;You can't compare a $120/year tool to a $12/month tool directly — one of them has to be converted first. &lt;code&gt;normMonthly(price, billing)&lt;/code&gt; divides annual prices by 12 before anything else happens, so every downstream calculation (totals, overlap waste, cost-per-use) works off one consistent monthly figure regardless of how the user actually gets billed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overlap is a category problem, not a name problem
&lt;/h2&gt;

&lt;p&gt;The obvious approach — string-matching service names to find duplicates — falls apart immediately, because the real overlap in most people's subscription list isn't two services with similar names, it's two or three services doing the same job under completely different names (two cloud-storage tools, two AI writing tools, three streaming services). So the tool groups entries by a user-assigned category instead: for any category with two or more entries, it sorts by normalized monthly cost, keeps the cheapest as the one worth having, and flags the rest as redundant — summing their cost as the waste attributable to that overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second, unrelated waste signal
&lt;/h2&gt;

&lt;p&gt;Overlap isn't the only way a subscription is a bad deal. A tool can be perfectly non-redundant and still be a waste if nobody's using it: the audit separately flags anything with zero recorded uses this period, or a cost-per-use at or above a fixed threshold. Those two waste sources (redundant-with-something-else, and low-value-on-its-own) are computed independently, then de-duplicated before being added to a total potential-savings figure — an item already counted as redundant doesn't also get counted again under the low-value bucket, or the headline savings number would overstate what cancelling everything flagged would actually save.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing to cut, not a wall of flags
&lt;/h2&gt;

&lt;p&gt;A results page that flags six problems and asks the user to prioritize them is asking the user to do the analysis the tool was supposed to do. So on top of the category breakdown, there's a single "cut this first" callout: whichever flagged item has the highest waste amount, with a one-line reason (duplicates a cheaper tool in the same category / unused this period / costs a lot per use) and the annualized number cancelling just that one item would save. One concrete next action beats a complete list every time the goal is to get someone to actually act on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  No backend, on purpose
&lt;/h2&gt;

&lt;p&gt;Every row lives in &lt;code&gt;localStorage&lt;/code&gt; as a small JSON blob (&lt;code&gt;{rows, currency}&lt;/code&gt;), reloaded on page load and rewritten on every edit. First-time visitors see a small set of example rows so the layout and the overlap logic are visible before anyone types in real numbers, and editing any seeded row quietly converts the whole set from example data to the user's own — no separate "start fresh" step required. There's no reason this particular tool needs a server: nothing here has to be shared across devices or persisted past the browser it was entered in, and skipping the backend means skipping an entire class of problems that comes with it.&lt;/p&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://overlap-audit.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink" rel="noopener noreferrer"&gt;https://overlap-audit.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the overlap-grouping logic or the localStorage/demo-seeding pattern if useful — drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A cancel-by-date calculator that gets clamped month math right (so it doesn't break on Jan 31 + 1 month)</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:21:48 +0000</pubDate>
      <link>https://dev.to/kunstudio/a-cancel-by-date-calculator-that-gets-clamped-month-math-right-so-it-doesnt-break-on-jan-31-1-48n2</link>
      <guid>https://dev.to/kunstudio/a-cancel-by-date-calculator-that-gets-clamped-month-math-right-so-it-doesnt-break-on-jan-31-1-48n2</guid>
      <description>&lt;h1&gt;
  
  
  A cancel-by-date calculator that gets clamped month math right (so it doesn't break on Jan 31 + 1 month)
&lt;/h1&gt;

&lt;p&gt;Most trial-reminder tools just tell you "day 7 of 14." I built &lt;strong&gt;Trial Guard&lt;/strong&gt; to compute the one date that actually matters — the last safe day to cancel — and then get that date out of the browser tab and into a calendar, because a banner you have to remember to look at on the right day is not a reminder system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three dates, not one
&lt;/h2&gt;

&lt;p&gt;A signup trial has three distinct dates that are easy to conflate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trial ends&lt;/strong&gt; — the last day of the trial window itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing date&lt;/strong&gt; — the day the card actually gets charged (one day after trial end)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancel-by date&lt;/strong&gt; — billing date minus a safety buffer the user sets (default a couple of days), so "cancel today" doesn't mean "cancel at 11:59pm and hope"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the buffer is longer than the trial itself, the cancel-by date would land before the trial even started — the tool clamps it to the start date instead and surfaces a note explaining why, rather than showing a date that makes no sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The month-length trap
&lt;/h2&gt;

&lt;p&gt;The naive way to add "1 month" to a date is &lt;code&gt;date.setMonth(date.getMonth() + 1)&lt;/code&gt;. That breaks the moment someone starts a trial on the 31st of a month: adding one month to Jan 31 in JavaScript doesn't land on Feb 28, it silently rolls forward into March, because Date normalizes an out-of-range day. For a tool whose entire job is telling you the correct day, a silent one-to-three-day drift is exactly the bug you can't have.&lt;/p&gt;

&lt;p&gt;The fix is a clamped-add helper: compute the target month, work out that month's actual last day (&lt;code&gt;new Date(year, month+1, 0).getDate()&lt;/code&gt;), and cap the day-of-month to whichever is smaller — the original day or the target month's last day. Jan 31 plus one month becomes Feb 28 (or Feb 29 in a leap year), not Mar 3. The same helper handles week- and day-denominated trials by just adding milliseconds instead, so the clamping logic only kicks in for the month case where it's actually needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A verdict, not just a countdown
&lt;/h2&gt;

&lt;p&gt;Rather than rendering a bare number, &lt;code&gt;compute()&lt;/code&gt; resolves the dates into one of a handful of states: already billed (trial-end date is in the past), inside the buffer window (cancel now), the last safe day, tomorrow's the deadline, or a plain days-remaining count. Each state gets its own copy and its own color on a countdown gauge, because "cancel today" and "you have three weeks" are different actions and should not look like the same UI element with a different number in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the date out of the tab
&lt;/h2&gt;

&lt;p&gt;A date sitting in a browser tab you'll close in ten seconds is not a reminder. The tool offers two exports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A one-tap Google Calendar deep link, built entirely from URL parameters (&lt;code&gt;calendar.google.com/calendar/render?action=TEMPLATE&amp;amp;dates=...&lt;/code&gt;). The fiddly part: Google's all-day event end date is exclusive, so the link has to add one calendar day to the cancel-by date or the event shows up ending a day early.&lt;/li&gt;
&lt;li&gt;A hand-built &lt;code&gt;.ics&lt;/code&gt; file for every other calendar app, generated client-side as a plain string (&lt;code&gt;BEGIN:VCALENDAR... DTSTART;VALUE=DATE:...&lt;/code&gt;) with no library — an ICS file for a single all-day event is a small enough spec that pulling in a dependency for it isn't worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Handing off instead of starting over
&lt;/h2&gt;

&lt;p&gt;Once the tool has a service name and a decision to cancel, it doesn't just say "good luck" — it links to &lt;strong&gt;Cancel Helper&lt;/strong&gt; with the service name pre-filled via a &lt;code&gt;?service=&lt;/code&gt; query parameter, so the next step in the actual task (writing the cancellation message) doesn't make the user retype what they just typed here.&lt;/p&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://trial-guard.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink" rel="noopener noreferrer"&gt;https://trial-guard.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the calendar-export edge cases or the clamped date math if useful — drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>æµ å· å 3 å¤© 2 å¤ â ä å æ å®¢é ¿å æ …æ å ¢å¿…å » 5 ä ªæ ç</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sat, 18 Jul 2026 00:30:45 +0000</pubDate>
      <link>https://dev.to/kunstudio/aeuaa2-3-ac-2-a-a-aa12aearcea14aeaeacaa-5-aaae-c1-1g8j</link>
      <guid>https://dev.to/kunstudio/aeuaa2-3-ac-2-a-a-aa12aearcea14aeaeacaa-5-aaae-c1-1g8j</guid>
      <description>&lt;h1&gt;
  
  
  æµŽå·žå²› 3 å¤© 2 å¤œ â€” ä¸­å›½æ¸¸å®¢é¿å¼€æ—…æ¸¸å›¢å¿…åŽ» 5 ä¸ªæ™¯ç‚¹
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;æµŽå·žå²›è·Ÿæ—…æ¸¸å›¢èµ° 80% æ—¶é—´åœ¨è´­ç‰©ã€‚æœ¬æŒ‡å—ä¸ºä¸­å›½æ¸¸å®¢è‡ªç”±è¡Œè®¾è®¡ 3 å¤© 2 å¤œè·¯çº¿ â€” 5 ä¸ªçœŸæ­£å€¼å¾—åŽ»çš„æ™¯ç‚¹ + å®žç”¨é¢„ç®— + å½“åœ°äº¤é€šæŠ€å·§ã€‚&lt;/em&gt;&lt;/p&gt;

</description>
      <category>济州岛</category>
      <category>中国游客</category>
      <category>自由行</category>
      <category>韩国旅行</category>
    </item>
    <item>
      <title>Chaining a segmentation model, a compositing model, and an LLM behind one API call for product photos</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:09:29 +0000</pubDate>
      <link>https://dev.to/kunstudio/chaining-a-segmentation-model-a-compositing-model-and-an-llm-behind-one-api-call-for-product-5h1e</link>
      <guid>https://dev.to/kunstudio/chaining-a-segmentation-model-a-compositing-model-and-an-llm-behind-one-api-call-for-product-5h1e</guid>
      <description>&lt;h1&gt;
  
  
  Chaining a segmentation model, a compositing model, and an LLM behind one API call for product photos
&lt;/h1&gt;

&lt;p&gt;Most "AI product photo" tools do one thing: cut the background out, or generate a lifestyle scene, or write the listing copy. I built &lt;strong&gt;Product Studio&lt;/strong&gt; to chain all three behind a single payment-gated call, because in practice an Etsy or Amazon seller needs all three from the same photo, and doing them as three separate tools means three separate uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three models, and why each one is a different model rather than one generalist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;product photo → fal-ai/birefnet/v2        (clean transparent cutout)
             → fal-ai/bria/product-shot   (lifestyle scene, optional)
             → gpt-4o-mini                (SEO listing copy, optional)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;birefnet/v2&lt;/code&gt;&lt;/strong&gt; is a dedicated background-removal/segmentation model. It runs unconditionally on every request because a clean cutout is the baseline deliverable regardless of what else the user paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bria/product-shot&lt;/code&gt;&lt;/strong&gt; is a compositing model purpose-built for placing a product into a photoreal scene — not a general image-to-image model prompted to "add a nice background." It takes the original photo plus a &lt;code&gt;scene_description&lt;/code&gt; and produces the lifestyle shot directly, which is a meaningfully different (and more reliable) task than asking a generalist model to hallucinate a coherent scene around an existing product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gpt-4o-mini&lt;/code&gt;&lt;/strong&gt; handles the SEO copy because it's a text-structuring problem (title/bullets/description/backend-keywords/tags in strict JSON, following Amazon A10 and Etsy tag conventions), not an image problem — there's no reason to route it through an image model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Splitting these three concerns across three purpose-built models instead of trying to get one model to do everything is a smaller version of the same lesson everyone rediscovers with LLM agents: a narrow tool that's good at one thing beats a generalist prompted six different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene presets instead of a free-text background prompt
&lt;/h2&gt;

&lt;p&gt;The lifestyle-shot step doesn't take an arbitrary scene description from the user — it offers four presets (&lt;code&gt;marble&lt;/code&gt;, &lt;code&gt;wood&lt;/code&gt;, &lt;code&gt;studio&lt;/code&gt;, &lt;code&gt;outdoor&lt;/code&gt;), each a fully-specified &lt;code&gt;scene_description&lt;/code&gt; string ("on a clean white marble surface with soft natural window light, minimal editorial ecommerce lifestyle photo, soft realistic shadow"). Sellers know their product; they don't know how to write a compositing model's scene prompt, and a free-text box would mean a much wider variance in output quality for a paid, one-shot generation. Constrained choice beats an open prompt field whenever the buyer's expertise is in their product, not in prompt engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resilience: independent try/catch per stage, degrade instead of fail
&lt;/h2&gt;

&lt;p&gt;Each of the three stages is wrapped independently: if the cutout succeeds but the lifestyle scene call fails, the user still gets their cutout back rather than a hard error. Only if &lt;em&gt;both&lt;/em&gt; image stages fail does the whole request return an error. The SEO-copy failure path is even more explicit — it returns a real result plus a plain-language message pointing the user to support with a refund offer, rather than swallowing the failure silently. On a paid API chain, "one sub-step failed" and "the whole request failed" need to be distinguishable outcomes, or you either refund people who got most of what they paid for, or ship them nothing when you could have shipped two-thirds of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gating three paid capabilities behind one grant
&lt;/h2&gt;

&lt;p&gt;Same HMAC-signed payment-grant pattern as the rest of this pipe family: the grant payload carries whether the SEO-copy add-on (&lt;code&gt;grant.c&lt;/code&gt;) was purchased, checked alongside a client-sent &lt;code&gt;wantCopy&lt;/code&gt; flag before the OpenAI call ever fires — so an un-paid request can't trigger the more expensive LLM step even if it asks nicely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone chaining multiple AI models behind one endpoint
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick a dedicated model per concern (segmentation, compositing, text) over one generalist model reused three ways — you'll get more consistent output and it's easier to reason about failure per stage.&lt;/li&gt;
&lt;li&gt;Offer presets instead of free text wherever the user's expertise doesn't match the prompt-engineering skill the step actually needs.&lt;/li&gt;
&lt;li&gt;Design for partial success. A multi-model chain that returns "at least what worked" is a materially better product than one that's all-or-nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://product-studio-eor.pages.dev/" rel="noopener noreferrer"&gt;https://product-studio-eor.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the fal model chaining or the SEO-copy JSON schema if useful — drop a comment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>buildinpublic</category>
      <category>ai</category>
    </item>
    <item>
      <title>Making AI dating photos that don't look AI-generated (the negative prompt matters more than the model)</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:09:28 +0000</pubDate>
      <link>https://dev.to/kunstudio/making-ai-dating-photos-that-dont-look-ai-generated-the-negative-prompt-matters-more-than-the-358h</link>
      <guid>https://dev.to/kunstudio/making-ai-dating-photos-that-dont-look-ai-generated-the-negative-prompt-matters-more-than-the-358h</guid>
      <description>&lt;h1&gt;
  
  
  Making AI dating photos that don't look AI-generated (the negative prompt matters more than the model)
&lt;/h1&gt;

&lt;p&gt;I &lt;a href="https://dev.to/kunstudio/i-built-an-ai-headshot-tool-that-keeps-the-actual-face-flux-pulid-instead-of-pure-diffusion-3m46"&gt;already wrote about&lt;/a&gt; why identity-preservation models like fal.ai's flux-pulid beat LoRA fine-tuning for keeping a face recognizable. Building &lt;strong&gt;AI Dating Photos&lt;/strong&gt; on the same model surfaced a different, harder problem: keeping the &lt;em&gt;skin&lt;/em&gt; recognizable. Identity-preserving generation solves "does this still look like the same person." It does nothing for "does this look like a real photograph," and for a dating profile picture, the second failure mode is the one that gets someone unmatched — that telltale waxy, over-smoothed, poreless AI skin is instantly recognizable to anyone who's swiped past a few AI-generated profiles already.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-stage pipeline
&lt;/h2&gt;

&lt;p&gt;Every generation is two fal.ai calls, not one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;fal-ai/flux-pulid&lt;/code&gt; — generates the styled photo from the reference selfie, tuned with &lt;code&gt;num_inference_steps: 26&lt;/code&gt; (above the model's 20-step default) and &lt;code&gt;id_weight: 1.0&lt;/code&gt; for a hard face lock.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; — a finishing pass with &lt;code&gt;creativity: 0.3&lt;/code&gt;, &lt;code&gt;resemblance: 1.5&lt;/code&gt;, run purely to reintroduce real skin texture and kill plastic/CGI artifacts, not to increase resolution as a goal in itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The negative prompt carried through both stages is doing most of the actual work against the "looks AI" problem: &lt;code&gt;waxy skin, plastic skin, airbrushed, over-smooth skin, poreless skin, beauty filter, oversaturated&lt;/code&gt; sit right alongside the more expected &lt;code&gt;deformed, extra fingers, blurry&lt;/code&gt;. Most negative-prompt lists you'll find in tutorials stop at the structural failure modes (extra limbs, wrong eye count) because those are the obviously broken outputs. The skin-texture terms exist because a &lt;em&gt;structurally correct&lt;/em&gt; face with plastic skin is arguably the more common failure mode for a photoreal use case, and it's the one that actually kills conversion on a dating-photo product specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: one style per call, by design
&lt;/h2&gt;

&lt;p&gt;The Cloudflare Pages Function behind this doesn't generate a full batch of every style in one request — it generates one style per call, and the browser loops through styles client-side. That's not an accident of the code, it's a direct consequence of Cloudflare Functions' CPU/wall-time limits: &lt;code&gt;flux-pulid&lt;/code&gt; generation plus an upscale pass, polled at 1.5s intervals with up to 60 polls, can run long enough that bundling four styles into one request risks the whole batch timing out and the user getting nothing. Splitting by style means a slow style can fail or time out without taking the other three down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment-gated generation with a signed grant, no database
&lt;/h2&gt;

&lt;p&gt;There's no order table anywhere in this flow. Checkout produces an HMAC-signed grant (payload + signature, verified with the Web Crypto &lt;code&gt;crypto.subtle&lt;/code&gt; API already available in the Functions runtime) carrying the paid tier and an expiry timestamp. The generation endpoint verifies the signature and expiry before touching fal at all — no payment, no fal API call, so a failed or forged request never costs anything. Tier determines both which style presets are unlocked and how many images per style get generated (&lt;code&gt;tier.count / tier.styles.length&lt;/code&gt;, capped at 12 per style regardless of tier).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building a photoreal (not stylized) generation product
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write your negative prompt as if the model's default failure mode is "technically correct but obviously synthetic," not "visibly broken." For dating/headshot/portrait use cases, skin texture is the tell, not anatomy.&lt;/li&gt;
&lt;li&gt;If your finishing/upscale step exists to fix a texture problem, tune its &lt;code&gt;creativity&lt;/code&gt; low and &lt;code&gt;resemblance&lt;/code&gt; high — you want it correcting texture, not reinterpreting the image.&lt;/li&gt;
&lt;li&gt;Split long-running generation work by the smallest reasonable unit (here: one style) if you're running on a platform with hard function time limits. It's a resilience boundary, not just a UX choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://dating-photos.pages.dev/" rel="noopener noreferrer"&gt;https://dating-photos.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the negative-prompt list or the CF Functions time-limit workaround if useful — drop a comment.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turning pet photos into oil portraits without losing the pet's actual face (FLUX.2 [pro] edit, not a LoRA)</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:08:52 +0000</pubDate>
      <link>https://dev.to/kunstudio/turning-pet-photos-into-oil-portraits-without-losing-the-pets-actual-face-flux2-pro-edit-not-5863</link>
      <guid>https://dev.to/kunstudio/turning-pet-photos-into-oil-portraits-without-losing-the-pets-actual-face-flux2-pro-edit-not-5863</guid>
      <description>&lt;h1&gt;
  
  
  Turning pet photos into oil portraits without losing the pet's actual face (FLUX.2 [pro] edit, not a LoRA)
&lt;/h1&gt;

&lt;p&gt;Most "AI pet portrait" generators are a text-to-image model with your pet's name stuffed into the prompt. The output looks like &lt;em&gt;a&lt;/em&gt; dog in a crown, not &lt;em&gt;your&lt;/em&gt; dog in a crown — the markings drift, the ear shape changes, the face is close enough to fool nobody who actually owns the animal.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Regal Paws&lt;/strong&gt; around a different primitive: &lt;a href="https://fal.ai/" rel="noopener noreferrer"&gt;fal.ai's flux-2-pro/edit&lt;/a&gt; model (&lt;code&gt;fal-ai/flux-2-pro/edit&lt;/code&gt;), which does image editing from a reference photo rather than text-to-image generation from a prompt alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an edit model instead of generation-from-prompt
&lt;/h2&gt;

&lt;p&gt;The two approaches solve different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text-to-image + LoRA fine-tune&lt;/strong&gt;: train a small model on the pet's photos, then generate. Identity fidelity depends entirely on how well the LoRA trained, and it degrades fast the further the target style is from the training photos (a corgi in Renaissance oil paint is a big style jump).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image-editing model with a reference&lt;/strong&gt;: pass the actual photo in as &lt;code&gt;image_urls&lt;/code&gt;, and the prompt describes the &lt;em&gt;transformation&lt;/em&gt; to apply to that specific image rather than describing a new image from scratch. The model has the real pixels of the real pet to work from, not a compressed embedding of "roughly this dog."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a one-shot product (someone uploads one photo and wants one good result in under a minute, no training queue), the edit-model approach is the only one that doesn't need a per-user training step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt design problem this creates
&lt;/h2&gt;

&lt;p&gt;Every style prompt in the pipeline explicitly repeats the same instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Preserve the EXACT face, fur color, markings, breed and likeness of THIS specific pet."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not filler — it's load-bearing. Edit models will happily reinterpret a subject if the prompt leans too hard on the target style (a "regal royal portrait as a king or queen" prompt, taken alone, gives the model license to invent a generic regal animal). Anchoring the identity-preservation instruction in every single style prompt, worded almost identically to how a fal team engineer describes their own image-editing model's spec, is what keeps the actual pet recognizable across five very different aesthetics (realistic studio, Renaissance oil, royal/baroque, classic oil, Victorian).&lt;/p&gt;

&lt;h2&gt;
  
  
  The finishing pass
&lt;/h2&gt;

&lt;p&gt;Raw edit-model output goes through a &lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; pass afterward — moderate creativity (0.35), high resemblance (0.7), which enriches painterly detail without letting the upscaler drift the face again. If the upscale call fails for any reason, the pipeline falls back to the un-upscaled base image rather than losing the job — a paid generation should never come back empty because a secondary enhancement step timed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability: one retry, not an open-ended loop
&lt;/h2&gt;

&lt;p&gt;For unattended production traffic (no human reviewing outputs before delivery), the generation function tries once, and if the model returns nothing usable, retries exactly once more before surfacing a "contact support for a refund" error. That single retry meaningfully cuts the failure rate on a paid flow without turning every request into an open-ended retry loop that burns fal credits on a request that's actually going to keep failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building a "keep the real subject" image tool on fal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If your use case is "transform this specific photo" rather than "generate something inspired by this photo," reach for an edit/image-to-image model before a LoRA training pipeline — it's less infrastructure and the identity fidelity ceiling is usually higher for a single-shot product.&lt;/li&gt;
&lt;li&gt;Repeat your identity-preservation instruction inside every style prompt, not just once in a system-level preamble. Style-specific language competes with it for the model's attention.&lt;/li&gt;
&lt;li&gt;Always have a same-quality fallback for your finishing/upscale pass. Treat it as an enhancement, never a dependency the paid job can fail on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://regal-paws.pages.dev/" rel="noopener noreferrer"&gt;https://regal-paws.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the fal edit-model prompt structure or the style preset list if useful — drop a comment.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I wired Printify's API to a PayPal-only checkout for a print-on-demand shop (no Shopify, no Stripe)</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:12:06 +0000</pubDate>
      <link>https://dev.to/kunstudio/how-i-wired-printifys-api-to-a-paypal-only-checkout-for-a-print-on-demand-shop-no-shopify-no-ahj</link>
      <guid>https://dev.to/kunstudio/how-i-wired-printifys-api-to-a-paypal-only-checkout-for-a-print-on-demand-shop-no-shopify-no-ahj</guid>
      <description>&lt;h1&gt;
  
  
  How I wired Printify's API to a PayPal-only checkout for a print-on-demand shop (no Shopify, no Stripe)
&lt;/h1&gt;

&lt;p&gt;Most print-on-demand tutorials assume you're building on Shopify with the Printify Shopify app, or on WooCommerce, or accepting Stripe. I wanted none of those — a static storefront, PayPal as the only payment rail, and Printify purely as a fulfillment API, no platform lock-in.&lt;/p&gt;

&lt;p&gt;Here's the actual wiring behind &lt;strong&gt;Inkwell &amp;amp; Co&lt;/strong&gt;, a small graphic-tee shop built this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why skip the Shopify/Printify app integration
&lt;/h2&gt;

&lt;p&gt;The standard Printify + Shopify integration is genuinely easy to set up, which is exactly why I didn't want it for this project — it couples your storefront to Shopify's checkout, theme system, and monthly fee regardless of order volume. For a single small catalog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Printify's REST API (&lt;code&gt;api.printify.com/v1&lt;/code&gt;) exposes products, variants, and order creation directly — no app-store middleman needed.&lt;/li&gt;
&lt;li&gt;PayPal's REST API (Orders v2 + webhooks) handles checkout and capture without needing a full commerce platform underneath it.&lt;/li&gt;
&lt;li&gt;A static frontend + a couple of serverless functions is enough glue code to connect the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The order flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer checks out via PayPal (Orders v2 create + capture)
  → PayPal webhook fires on PAYMENT.CAPTURE.COMPLETED
  → webhook handler POSTs an order to Printify (/shops/{id}/orders.json)
    with the captured shipping address + line items
  → Printify handles print + fulfillment + shipping
  → order status polled back for customer-facing tracking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that actually took iteration wasn't the happy path — it was getting the PayPal capture payload's shipping address mapped correctly onto Printify's expected order schema (Printify wants a fairly specific address object shape, and PayPal's capture response nests it differently depending on whether the buyer used a saved address or typed one in at checkout). Test both paths explicitly if you're building something similar; they don't look the same in the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently starting over
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Log the full raw PayPal webhook payload before transforming it, not after. The one bug that cost real time was an address field silently going empty for guest checkouts specifically — invisible until you have the raw payload to diff against.&lt;/li&gt;
&lt;li&gt;Printify's variant IDs are size/color-specific and not obvious from the product API alone — pull the full variants list once at setup and cache it, don't try to guess the mapping.&lt;/li&gt;
&lt;li&gt;A webhook retry from PayPal (they do retry on non-2xx) can create a duplicate Printify order unless the handler is idempotent on PayPal's capture ID. Worth building that guard in from the start rather than after the first duplicate print run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live shop: &lt;strong&gt;&lt;a href="https://pod-shop.pages.dev/" rel="noopener noreferrer"&gt;https://pod-shop.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've wired Printify to a non-Shopify checkout and hit different edge cases, I'd like to compare notes in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>buildinpublic</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
