<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Next Js on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/tags/next-js/</link><description>Recent content in Next Js on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 07 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/tags/next-js/index.xml" rel="self" type="application/rss+xml"/><item><title>hybrid-image-search Dev Log #18 — OpenAI gpt-image-2 Joins, Model/Product Library, and Internal Permission Tiers</title><link>https://ice-ice-bear.github.io/posts/2026-05-07-hybrid-search-dev18/</link><pubDate>Thu, 07 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-07-hybrid-search-dev18/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search Dev Log #18 — OpenAI gpt-image-2 Joins, Model/Product Library, and Internal Permission Tiers" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;Since &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-hybrid-search-dev17/" &gt;#17 — tone pool swaps, model injection prompt v2&lt;/a&gt;, 73 commits have landed. The biggest shift is &lt;strong&gt;dropping the injection-mode abstraction itself&lt;/strong&gt; — what used to be a five-tone pill row collapsed into two tabs: model and product. At the same time, we started routing the comparison side B through OpenAI&amp;rsquo;s gpt-image-2.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 Old["Through dev #17 &amp;lt;br/&amp;gt; injection-mode pills (5 tones)"] --&gt; Refactor["Drop pills &amp;lt;br/&amp;gt; model / product tabs"]
 Refactor --&gt; A["Side A: Gemini 3.1 Flash &amp;lt;br/&amp;gt; (primary)"]
 Refactor --&gt; B["Side B: OpenAI gpt-image-2 &amp;lt;br/&amp;gt; (comparison)"]
 Library["Per-user library &amp;lt;br/&amp;gt; model + product"] --&gt; A
 Library --&gt; B
 Internal["Internal permission gate &amp;lt;br/&amp;gt; tone-lock + S3 admin"] --&gt; A&lt;/pre&gt;&lt;p&gt;73 commits, five threads.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="openai-gpt-image-2-lands-as-side-b"&gt;OpenAI gpt-image-2 lands as side B
&lt;/h2&gt;&lt;p&gt;Until now hybrid was a single-backend (Gemini) generator. dev #18 starts routing the comparison side B through OpenAI &lt;code&gt;gpt-image-2&lt;/code&gt;.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 UI["Frontend generate"] --&gt; Backend["FastAPI /generate"]
 Backend --&gt; Gather["asyncio.gather()"]
 Gather --&gt; SideA["Side A &amp;lt;br/&amp;gt; Gemini 3.1 Flash"]
 Gather --&gt; SideB["Side B &amp;lt;br/&amp;gt; OpenAI gpt-image-2"]
 SideA --&gt; CompareUI["Frontend a/b keyboard compare"]
 SideB --&gt; CompareUI&lt;/pre&gt;&lt;p&gt;Key commits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wire AsyncOpenAI client and OpenAI image-gen config&lt;/strong&gt; (&lt;code&gt;052d42f&lt;/code&gt;) — env vars, timeouts, retries in backend config.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared image IO helper + OpenAI image service&lt;/strong&gt; (&lt;code&gt;1fb9b43&lt;/code&gt;) — adapter that normalizes Gemini and OpenAI responses into a common shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refactor 5-tone fields into side A/B semantics&lt;/strong&gt; (&lt;code&gt;d91067e&lt;/code&gt;, &lt;code&gt;ec38fa8&lt;/code&gt;) — &lt;code&gt;tone3&lt;/code&gt;, &lt;code&gt;tone5&lt;/code&gt; → &lt;code&gt;side_a&lt;/code&gt;, &lt;code&gt;side_b&lt;/code&gt;. The label stops describing tone variants and starts describing comparison sides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shield gather from cancellation, drop unsupported quality param&lt;/strong&gt; (&lt;code&gt;8759a78&lt;/code&gt;) — &lt;code&gt;asyncio.gather&lt;/code&gt; cancels sibling tasks if any one raises. To keep both sides alive, shield with &lt;code&gt;return_exceptions=True&lt;/code&gt; and handle separately.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two corner cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Aspect ratio mapping&lt;/strong&gt; — gpt-image-2 only supports &lt;code&gt;1024x1024&lt;/code&gt;, &lt;code&gt;1024x1792&lt;/code&gt;, &lt;code&gt;1792x1024&lt;/code&gt; (&lt;code&gt;97f7204&lt;/code&gt;). Map arbitrary UI ratios to the nearest supported size.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface side-B failures&lt;/strong&gt; (&lt;code&gt;7d31f62&lt;/code&gt;) — even on the &amp;ldquo;comparison side,&amp;rdquo; failures must be visible. Quietly missing data confuses evaluators.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="drop-injection-modes-introduce-a-modelproduct-library"&gt;Drop injection modes; introduce a model/product library
&lt;/h2&gt;&lt;p&gt;Through dev #17 there was a &amp;ldquo;tone injection mode&amp;rdquo; abstraction. Five tones × user-uploaded models × options spread out across the UI, and learning cost was high. dev #18 swaps it out — &lt;strong&gt;model tab and product tab, two tabs&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Lib["LibraryTab"] --&gt; ModelTab["Model (people photos)"]
 Lib --&gt; ProductTab["Product (object photos)"]
 ModelTab --&gt; ModelUpload["Direct upload"]
 ModelTab --&gt; ModelGen["Regenerate as ID-photo via Gemini"]
 ProductTab --&gt; ProductUpload["Upload + auto preprocess"]
 ProductUpload --&gt; AutoPick["Auto-pick on ready"]
 ModelGen --&gt; Generate["generate call"]
 ProductUpload --&gt; Generate&lt;/pre&gt;&lt;p&gt;Sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Per-user asset library&lt;/strong&gt; (&lt;code&gt;b933191&lt;/code&gt;) — uploads stick to the user account; reusable across tones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replace injection-mode pills with model/product tabs&lt;/strong&gt; (&lt;code&gt;1450767&lt;/code&gt;) — UI simplifies. The &amp;ldquo;which mode&amp;rdquo; decision is gone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regenerate uploaded models as ID-photos&lt;/strong&gt; (&lt;code&gt;db64b05&lt;/code&gt;) — clean up uploaded portraits via Gemini for a consistent model slot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Role-aware prompt directives&lt;/strong&gt; (&lt;code&gt;ffb8ccf&lt;/code&gt;) — when model/product references go into the prompt, their roles are explicit: &amp;ldquo;this person as the model,&amp;rdquo; &amp;ldquo;this object as the product.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Product preprocess + auto-pick on ready&lt;/strong&gt; (&lt;code&gt;69db8c2&lt;/code&gt;) — upload → background preprocess → auto-activate. One fewer click for the user.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface processing state + toast&lt;/strong&gt; (&lt;code&gt;f3ff587&lt;/code&gt;) — processing assets show a distinct state. No silent waits.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There was a back-and-forth in the middle. &lt;strong&gt;Auto model injection turned off, then back on&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bdf0aae&lt;/code&gt; — drop auto model injection, direct upload only (also fixes label wrap)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;394f91f&lt;/code&gt; — restore auto model injection, also accept generated-image drops&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Direct upload only meant users had to drop in models one at a time, which created friction. Auto-injection won as default; direct upload stayed as an option.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="tone-pool-curation-0428--0429--0504"&gt;Tone pool curation: 0428 → 0429 → 0504
&lt;/h2&gt;&lt;p&gt;Eighty percent of generation quality lives in the tone reference pool. Too varied → results scatter; too narrow → results all look alike.&lt;/p&gt;
&lt;p&gt;This cycle&amp;rsquo;s curation work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Swap model_image_ref to 0428 model selection pool&lt;/strong&gt; (&lt;code&gt;c1e5d39&lt;/code&gt;) — the 0428 set has more consistent lighting; promoted to main model pool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-category tones + person-aware model slot&lt;/strong&gt; (&lt;code&gt;cb3a260&lt;/code&gt;) — split tones into two categories (natural/film, studio/clean), and only enable the model slot when the tone implies a person.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scope auto-pick to curated 0429 subfolders&lt;/strong&gt; (&lt;code&gt;27d335d&lt;/code&gt;) — when auto-picking a tone, only the 0429 curated set is in play. Cuts noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rewrite slug-named tone refs in generation_logs&lt;/strong&gt; (&lt;code&gt;76a1a64&lt;/code&gt;) — when the S3 corpus path naming changed, old logs needed remapping.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reseed a(natural,film) from 0429 to 0504&lt;/strong&gt; (&lt;code&gt;c43214e&lt;/code&gt;, the latest commit) — refresh the most-used tone category to the newest set.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A &lt;code&gt;scripts/&lt;/code&gt; directory now records the S3 corpus swap utilities (&lt;code&gt;f169dd4&lt;/code&gt;) so the next curation cycle can reuse them.&lt;/p&gt;
&lt;p&gt;A one-line &lt;code&gt;nginx&lt;/code&gt; fix (&lt;code&gt;9f252ff&lt;/code&gt;) had outsized impact. Backend timeouts and the nginx &lt;code&gt;/api/&lt;/code&gt; timeout were misaligned, so when OpenAI was slow, nginx returned 502 first and triggered backend retries. Aligned both, plus disabled upstream retries.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="internal-vs-external-permission-tiers"&gt;Internal vs external: permission tiers
&lt;/h2&gt;&lt;p&gt;This cycle introduced a real &lt;strong&gt;internal user&lt;/strong&gt; concept (team only). Demo days and external beta meant some features should not be visible to the world.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 User["Logged-in user"] --&gt; Check{"is_internal?"}
 Check -- "yes" --&gt; Internal["Internal features visible"]
 Check -- "no" --&gt; External["External (default)"]
 Internal --&gt; ToneLock["Tone refs pin &amp;lt;br/&amp;gt; lock across generations"]
 Internal --&gt; Admin["S3 image manager &amp;lt;br/&amp;gt; tone/model/product curation"]
 External --&gt; Generate["Standard generate flow"]&lt;/pre&gt;&lt;p&gt;Three PRs split the work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PR #16 — internal vs external user tiers + UI gating&lt;/strong&gt; (&lt;code&gt;f33e9d0&lt;/code&gt;) — &lt;code&gt;is_internal&lt;/code&gt; column on the user, internal-only components short-circuit to &lt;code&gt;&amp;lt;&amp;gt;&amp;lt;/&amp;gt;&lt;/code&gt; for everyone else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #17 — internal-only tone-lock&lt;/strong&gt; (&lt;code&gt;199a405&lt;/code&gt;) — pin the same tone references across generations for clean A/B evaluation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #18 — internal-only S3 image manager&lt;/strong&gt; (&lt;code&gt;8096425&lt;/code&gt;) — manage tone/model/product corpus from the web UI instead of the S3 console.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;feat/admin-s3-manager&lt;/code&gt; branch needed two main merges (&lt;code&gt;9d5fa1e&lt;/code&gt;, &lt;code&gt;a35bf53&lt;/code&gt;). Other tracks landed mid-development and conflicts piled up — lesson: rebase the admin branch right after each major merge to main.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="cameralens-picker-polish"&gt;Camera/lens picker polish
&lt;/h2&gt;&lt;p&gt;Camera/lens selection UX got a one-cycle pass.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Commit&lt;/th&gt;
 &lt;th&gt;What&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;2439c98&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Show thumbnails in angle picker dropdown (preview before selection)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;4f615a7&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Zoom button on hover for reference image&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;5be9daa&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Rename to &amp;ldquo;Camera &amp;amp; Lens&amp;rdquo;, random default lens, model creator&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;b4aeed3&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Show None option explicitly + add None to LensPicker&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;228ff9f&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;LensPicker auto-closes after selection&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;024253e&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Angle/lens picker still clickable when default is None&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;bb13dd3&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Bottom bar polish — General + Edit on right + stronger active state&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;020c509&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Multi-line breathing room for generation prompt&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;8208a11&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Library tab + prompt area zoom + transparent overlay&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;349d142&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Re-roll filters in preview modal, drop dead labels&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Keyboard&lt;/td&gt;
 &lt;td&gt;A/B compare via arrow + &amp;lsquo;a&amp;rsquo;,&amp;lsquo;b&amp;rsquo; keys (&lt;code&gt;fad542e&lt;/code&gt;)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The keyboard compare ended up the most loved tweak. Toggling between two results with &amp;lsquo;a&amp;rsquo;/&amp;lsquo;b&amp;rsquo; rather than mousing back and forth doubled comparison velocity.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Going from #17 to #18, &lt;strong&gt;reducing abstraction was the move forward.&lt;/strong&gt; &amp;ldquo;Tone injection mode&amp;rdquo; was a 5-axis abstraction that imposed our code model on the user. The actual mental model is binary: &amp;ldquo;include a person, or include an object.&amp;rdquo; Two tabs match that, and learning cost dropped accordingly.&lt;/p&gt;
&lt;p&gt;Routing OpenAI alongside Gemini follows the same shape. Guessing which model is better from a single response is much worse than seeing both side-by-side and toggling with the keyboard. Details like &lt;code&gt;asyncio.gather&lt;/code&gt; shielding matter, but if you nail down what happens when one side dies, the pattern is reusable.&lt;/p&gt;
&lt;p&gt;The permission gate was a high-leverage tiny change. One &lt;code&gt;is_internal&lt;/code&gt; column + conditional UI rendering, and the internal-only S3 admin and tone-lock can sit in the main codebase without exposing them. Avoiding a separate admin app saved a lot.&lt;/p&gt;
&lt;p&gt;Next in dev #19: results from the gpt-image-2 quality A/B, a &amp;ldquo;group&amp;rdquo; concept in the model library (multiple people in one shot), and the conditions under which internal tone-lock could open up to external users.&lt;/p&gt;</description></item><item><title>popcon Dev Log #11 — Credits System, R2 Migration, ToonOut, and a Brutal Redesign</title><link>https://ice-ice-bear.github.io/posts/2026-05-07-popcon-dev11/</link><pubDate>Thu, 07 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-07-popcon-dev11/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post popcon Dev Log #11 — Credits System, R2 Migration, ToonOut, and a Brutal Redesign" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;Since &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-popcon-dev10/" &gt;#10 — beta signups, balloon indicator, countdown&lt;/a&gt;, fifteen days have rolled in too much for a single dev log. Matting model swap, payments (credits), Cloudflare R2 cutover, brutal redesign, and Korean i18n — 156 commits across five effectively independent milestones.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Start["popcon dev #10 (594cceb)"] --&gt; M1["Matting model swap &amp;lt;br/&amp;gt; ToonOut on gray bg"]
 Start --&gt; M2["Credits system &amp;lt;br/&amp;gt; Credits/CreditCode/CreditLedger"]
 Start --&gt; M3["D1 brutal redesign &amp;lt;br/&amp;gt; tokens/fonts/primitives rewrite"]
 Start --&gt; M4["Cloudflare R2 cutover &amp;lt;br/&amp;gt; dual-write → backfill → drop"]
 Start --&gt; M5["Korean i18n &amp;lt;br/&amp;gt; next-intl + locale prefix"]
 M1 --&gt; End["popcon dev #11 (411c5ec)"]
 M2 --&gt; End
 M3 --&gt; End
 M4 --&gt; End
 M5 --&gt; End&lt;/pre&gt;&lt;p&gt;This post covers all five at once, but the same question echoes through every track — &lt;strong&gt;&amp;ldquo;how do we hop onto a new rail without stopping the existing system.&amp;rdquo;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="matting-model-birefnet--toonout"&gt;Matting model: BiRefNet → ToonOut
&lt;/h2&gt;&lt;p&gt;popcon separates the character from its background and composites that mask into 12 emoji actions. The previous matting model was trained on photographs and broke down on anime hair and translucent regions.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://github.com/MatteoKartoon/BiRefNet" target="_blank" rel="noopener"
 &gt;ToonOut&lt;/a&gt; is &lt;a class="link" href="https://github.com/zhengpeng7/birefnet" target="_blank" rel="noopener"
 &gt;BiRefNet&lt;/a&gt; fine-tuned on 1,228 hand-annotated anime images. Pixel accuracy jumps from 95.3% to 99.5% on the test set.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# gpu_worker — composite onto gray before feeding ToonOut&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# (ToonOut training-time gray = #808080)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_swap_bg_to_gray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;Soft white-key compositor: alpha-blend onto #808080.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two pre/post details that mattered:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Single source of truth for bg color&lt;/strong&gt; — made &lt;code&gt;bg_color&lt;/code&gt; authoritative on the backend and standardized to &lt;code&gt;#808080&lt;/code&gt; (commit &lt;code&gt;430f985&lt;/code&gt;). The frontend and worker had been drifting on slightly different grays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pylette per-character gray pick&lt;/strong&gt; — uses the Rec.709 luminance rule to pick a gray that matches the character&amp;rsquo;s average brightness (commit &lt;code&gt;94544df&lt;/code&gt;). The library I wrote about &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-pylette/" &gt;in the Pylette post&lt;/a&gt; finally has a real consumer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;While refactoring, a dynamic indirection turned out to be cargo-cult and got removed; the mask-fill threshold finally got a name (&lt;code&gt;081ddd6&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="credits-system-a-full-payment-loop-in-five-days"&gt;Credits system: a full payment loop in five days
&lt;/h2&gt;&lt;p&gt;Beta is wrapping, and we needed a credits system from scratch — SQLAlchemy ORM through to a frontend 402 handler — before flipping to paid.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Code["Admin CLI mint &amp;lt;br/&amp;gt; CreditCode (POPxxxxx)"] --&gt; Redeem["Redeem modal &amp;lt;br/&amp;gt; code → balance"]
 Redeem --&gt; Ledger["CreditLedger &amp;lt;br/&amp;gt; charge / refund / grant"]
 Action["Editor action &amp;lt;br/&amp;gt; (generate/refine/animate)"] --&gt; Quote["Pre-flight quote &amp;lt;br/&amp;gt; gate if balance low"]
 Quote --&gt; Ledger
 Ledger -- "402 emit" --&gt; Pill["Header balance pill &amp;lt;br/&amp;gt; global redeem modal"]
 Ledger --&gt; Account["/account page &amp;lt;br/&amp;gt; balance / redeem / history"]&lt;/pre&gt;&lt;p&gt;Three core decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ledger pattern&lt;/strong&gt; — &lt;code&gt;CreditLedger&lt;/code&gt; is append-only; &lt;code&gt;Credits.balance&lt;/code&gt; is a cached column. Every charge/refund runs in a strict transaction (&lt;code&gt;e28b100&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global 402 event&lt;/strong&gt; — when the backend throws HTTP 402 for insufficient balance, the frontend &lt;code&gt;useCredits()&lt;/code&gt; hook auto-refreshes and surfaces a global redeem modal (&lt;code&gt;d25739e&lt;/code&gt;, &lt;code&gt;1a32900&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stage-failure refund&lt;/strong&gt; — if emoji generation fails partway, that stage&amp;rsquo;s credits auto-refund (&lt;code&gt;6d7cc7f&lt;/code&gt;). No manual support tickets.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A small mishap in the middle: I tried sending Gemini&amp;rsquo;s &lt;code&gt;image_size&lt;/code&gt; as &lt;code&gt;&amp;quot;0.5K&amp;quot;&lt;/code&gt; to match the pricing tier — Gemini rejects that with INVALID_ARGUMENT (&lt;code&gt;b1ac23f&lt;/code&gt; revert → &lt;code&gt;55eda01&lt;/code&gt; corrects to &lt;code&gt;&amp;quot;512&amp;quot;&lt;/code&gt;). The pricing-table notation and the API input notation aren&amp;rsquo;t the same value space. I assumed they were.&lt;/p&gt;
&lt;p&gt;Commit &lt;code&gt;360115e&lt;/code&gt; is the funny one. During a refactor, the &lt;code&gt;POP&lt;/code&gt; brand prefix got auto-changed to &lt;code&gt;P0P&lt;/code&gt; (zero instead of letter O). Reverted. AI was being a little too eager about &amp;ldquo;consistency.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="d1-brutal-redesign-tokens-up"&gt;D1 brutal redesign: tokens up
&lt;/h2&gt;&lt;p&gt;popcon was running a generic Tailwind look. To match the flyer/branding, the whole UI got a brutal overhaul — chunky black borders, hard shadows, a 5-tone palette, bold sans-serifs.&lt;/p&gt;
&lt;p&gt;New font stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Archivo Black&lt;/strong&gt; — English headlines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black Han Sans&lt;/strong&gt; — Korean headlines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jua&lt;/strong&gt; — Korean body&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JetBrains Mono&lt;/strong&gt; — code/numerics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pretendard&lt;/strong&gt; — Korean fallback&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;/* tokens.css — 5-tone brutal palette */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nd"&gt;root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fafaf7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* page bg */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* body text + borders */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--violet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#7c3aed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* brand (P logo, actions) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--yellow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fbbf24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* active emphasis (ZIP button etc) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--pink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ec4899&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* erase / warning */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--mint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#10b981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* success */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Primitives got rewritten — &lt;code&gt;Card&lt;/code&gt;, &lt;code&gt;Chip&lt;/code&gt; (5 tones × 2 sizes), &lt;code&gt;StatusDot&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;Textarea&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt; (5 variants × 3 sizes), &lt;code&gt;StepIndicator&lt;/code&gt;. All in brutal style (&lt;code&gt;769df10&lt;/code&gt; ~ &lt;code&gt;0e013a8&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Pages were swapped one at a time — landing → editor panels → archive → account → auth modal → header. Each commit is one page or panel, so reviews stayed readable.&lt;/p&gt;
&lt;p&gt;The trickiest part was &lt;strong&gt;scrim handling&lt;/strong&gt;. The old design used a white veil; brutal demanded an ink scrim (semi-transparent black). But on the SAM2 / matte refine modal the ink scrim was so heavy you couldn&amp;rsquo;t see the reference image — so scrim became per-modal (&lt;code&gt;99b1908&lt;/code&gt;, &lt;code&gt;4096ba7&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;A WCAG AA pass caught one issue too: white text on the pink Erase active state was sub-AA, swapped to ink (&lt;code&gt;4827ed4&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="cloudflare-r2-cutover-phased-in-four-steps"&gt;Cloudflare R2 cutover: phased in four steps
&lt;/h2&gt;&lt;p&gt;popcon was writing emoji zips/APNGs/videos to the local disk of a fly.io machine. As we scale to multiple machines, assets fragment across disks and download routing breaks. Time to move to R2 (Cloudflare&amp;rsquo;s S3-compatible object store).&lt;/p&gt;
&lt;p&gt;To do this without downtime, I split it into four phases:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Phase&lt;/th&gt;
 &lt;th&gt;Content&lt;/th&gt;
 &lt;th&gt;PR&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;R2 client wrapper + &lt;code&gt;blob_key&lt;/code&gt; DB columns&lt;/td&gt;
 &lt;td&gt;#5&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Worker dual-writes — local disk &lt;strong&gt;and&lt;/strong&gt; R2&lt;/td&gt;
 &lt;td&gt;#6&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Backfill script + frontend passes through absolute R2 URLs&lt;/td&gt;
 &lt;td&gt;#7&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;D&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Drop legacy file routes; &lt;code&gt;/download_job&lt;/code&gt; 302 redirect; scratch GC&lt;/td&gt;
 &lt;td&gt;#8&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I waited between each phase to confirm traffic looked clean. The dual-write phase costs more (writing to both backends) but bought rollback safety — if anything broke, I could just turn off the R2 path and disk was still truth.&lt;/p&gt;
&lt;p&gt;Two follow-ups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rehydrate URLs from R2 keys&lt;/strong&gt; (&lt;code&gt;b43e802&lt;/code&gt;) — instead of storing absolute R2 URLs, derive them from &lt;code&gt;blob_key&lt;/code&gt; every time. Endpoint changes don&amp;rsquo;t require migrations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restore legacy asset routes&lt;/strong&gt; (&lt;code&gt;1e08937&lt;/code&gt;) — for users with in-flight jobs from before the cutover. Caught a bonus bug along the way: R2 URLs were being mistakenly mirrored into filesystem-path columns (&lt;code&gt;83d62c4&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="korean-i18n-next-intl--locale-prefixed-routes"&gt;Korean i18n: next-intl + locale-prefixed routes
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 URL1["/editor"] --&gt; Proxy["proxy.ts &amp;lt;br/&amp;gt; Next 16-style"]
 URL2["/ko/editor"] --&gt; Proxy
 URL3["/en/editor"] --&gt; Proxy
 Proxy --&gt; Locale{"extract locale"}
 Locale --&gt; Layout["[locale]/layout.tsx &amp;lt;br/&amp;gt; getMessages()"]
 Layout --&gt; Page["page render &amp;lt;br/&amp;gt; useTranslations()"]&lt;/pre&gt;&lt;p&gt;Korean was added with next-intl + locale-prefixed routes. Two key decisions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Move pages under a &lt;code&gt;[locale]&lt;/code&gt; segment&lt;/strong&gt; — &lt;code&gt;app/page.tsx&lt;/code&gt; → &lt;code&gt;app/[locale]/page.tsx&lt;/code&gt;. The layout splits into a root layout and a locale layout (&lt;code&gt;fe1eaa3&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use Next 16&amp;rsquo;s &lt;code&gt;proxy.ts&lt;/code&gt; for locale routing&lt;/strong&gt; — instead of middleware (&lt;code&gt;4f322e2&lt;/code&gt;). Static routing means caching works.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Translations are split by namespace — &lt;code&gt;home&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;, &lt;code&gt;account&lt;/code&gt;, &lt;code&gt;redeem&lt;/code&gt;, &lt;code&gt;actions&lt;/code&gt;, &lt;code&gt;picker&lt;/code&gt;, etc. Each page/panel has its own commit, which makes greps clean.&lt;/p&gt;
&lt;p&gt;One bug surfaced in the language switcher: switching language dropped search params, killing in-progress editor jobs. Replaced both &lt;code&gt;Link&lt;/code&gt; and &lt;code&gt;router&lt;/code&gt; with locale-aware wrappers that preserve search params (&lt;code&gt;d644b1b&lt;/code&gt;, PR #12).&lt;/p&gt;
&lt;p&gt;Also caught: in-app browsers (KakaoTalk, Instagram) block Google sign-in. Added an escape-to-external-browser guard (&lt;code&gt;29cd743&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="ops-skip_runpod-guard-and-sync-pod-id"&gt;Ops: SKIP_RUNPOD guard and sync-pod-id
&lt;/h2&gt;&lt;p&gt;Deploys are fly.io (API + frontend) + RunPod (GPU worker) + a GitHub Actions cron scheduler. The scheduler shuts down the RunPod pod overnight to save money. But manually-spawned dev pods were getting killed by the same scheduler.&lt;/p&gt;
&lt;p&gt;Fix: &lt;code&gt;SKIP_RUNPOD&lt;/code&gt; env-var guard (&lt;code&gt;e3fa9fa&lt;/code&gt;). When set, the scheduler leaves pods alone. An escape hatch for manual ops.&lt;/p&gt;
&lt;p&gt;Also added &lt;code&gt;sync-pod-id&lt;/code&gt; (&lt;code&gt;783238b&lt;/code&gt;) — auto-syncs a new RunPod ID into fly secrets. Used to be a manual fly secrets update that I&amp;rsquo;d forget.&lt;/p&gt;
&lt;p&gt;One more line that mattered: &lt;code&gt;fly(frontend)&lt;/code&gt; warm-machine config (&lt;code&gt;edf3d18&lt;/code&gt;, PR #9). Keep one frontend machine warm at 512 MB. Cold start dropped from 1.5s → 200ms.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Looking back over the 156 commits, the surprising thing is how &lt;strong&gt;parallel&lt;/strong&gt; these tracks ran. Matting/R2 were backend/worker. Brutal redesign was frontend. Credits and i18n were full-stack. Five tracks ran simultaneously and merge conflicts stayed minor — module boundaries were sharp enough to keep them apart.&lt;/p&gt;
&lt;p&gt;The R2 phased cutover pattern is the one I&amp;rsquo;d reuse first. The dual-write phase costs a little — writing to both backends — but it buys a clean rollback. If phase B had broken anything, we could&amp;rsquo;ve just disabled the R2 path and disk would still be truth.&lt;/p&gt;
&lt;p&gt;The credit ledger pattern is also a keeper. Cache &lt;code&gt;Credits.balance&lt;/code&gt; on the row, but keep &lt;code&gt;CreditLedger&lt;/code&gt; append-only. If anyone questions a balance, you re-derive from the ledger. This is exactly Stripe&amp;rsquo;s model.&lt;/p&gt;
&lt;p&gt;For redesigns, rebuilding the tokens and primitives &lt;strong&gt;before&lt;/strong&gt; touching pages was decisive. Touch pages first and you end up with old components that don&amp;rsquo;t pick up the new tokens, lingering forever.&lt;/p&gt;
&lt;p&gt;Coming up in dev #12: payment gateway integration (KG Inicis / PortOne), ToonOut matting quality A/B against the previous model, and the i18n micro-gaps left over (error toasts, admin CLI strings).&lt;/p&gt;</description></item></channel></rss>