<?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: 王龙</title>
    <description>The latest articles on DEV Community by 王龙 (@_df88eb1337eb23b75c94ba).</description>
    <link>https://dev.to/_df88eb1337eb23b75c94ba</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%2F3546238%2F8cdf4a41-fd81-4706-984e-7ee0d2571f5c.jpg</url>
      <title>DEV Community: 王龙</title>
      <link>https://dev.to/_df88eb1337eb23b75c94ba</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_df88eb1337eb23b75c94ba"/>
    <language>en</language>
    <item>
      <title>Build log: turning one photo into a ~13-second racing meme video on Cloudflare Workers</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:43:07 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/build-log-turning-one-photo-into-a-13-second-racing-meme-video-on-cloudflare-workers-3714</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/build-log-turning-one-photo-into-a-13-second-racing-meme-video-on-cloudflare-workers-3714</guid>
      <description>&lt;p&gt;A short racing clip has been going around on short-video apps: someone in a deep squat, flying down a highway at absurd speed, with a chase behind them. People wanted to put &lt;em&gt;their own&lt;/em&gt; face in it. I built a small site that does exactly one thing: you upload one photo, and about 4 minutes later you get an about 13-second racing meme video with an original backing track.&lt;/p&gt;

&lt;p&gt;This post is the build log: the pipeline, the parts that were harder than they looked, and the mistakes that cost me re-runs. No benchmarks; where I only have one data point, I say so.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disclosure: I built this. MemeMotion is unofficial and not affiliated with TikTok, the creator, or the Crazy Frog rights holders. Motion reference from the original post by @wdmowski. Rights to the original clip belong to its creator.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shape of the product
&lt;/h2&gt;

&lt;p&gt;The constraints I set myself before writing any code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One template, one button.&lt;/strong&gt; No prompt box. The user picks a photo and pays; everything else is fixed on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay per video, no subscription.&lt;/strong&gt; One credit = one video. Packs are $7.99 / 1, $14.99 / 2, $27.99 / 4, paid once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google sign-in&lt;/strong&gt; before generating, so jobs, credits and downloads belong to an account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short retention.&lt;/strong&gt; The finished video (and the source photo) is deleted 7 days after the job finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never charge for something that can't work.&lt;/strong&gt; Check the photo before money moves; refund automatically when generation fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxl0p24jv1wztki266mgi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxl0p24jv1wztki266mgi.png" alt="Pipeline flowchart: photo upload, face check, credit charge, queue, video model on fal with refund on failure, audio mux, R2 storage and ready email, deletion after 7 days" width="800" height="1540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything runs in one Cloudflare Worker: D1 for users, jobs and the credit ledger; R2 for photos, reference media and outputs; a Queue producer/consumer for jobs; and two cron triggers for sweeps and cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: check the photo before charging
&lt;/h2&gt;

&lt;p&gt;The model wants one clear face. If you send a group photo or a profile shot, it still bills you and the result is bad. So the first thing that runs, before any credit or budget is touched, is a cheap check: exactly one, roughly frontal face.&lt;/p&gt;

&lt;p&gt;Workers can't run a face-landmark model in-process, and I didn't want a new vendor just for this, so the check uses a small vision model on the same provider I already send the photo to. The comment in the code says it plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Pre-charge photo check: exactly one, roughly frontal face.
 * ...
 * Runs before any credit or budget is touched.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that mattered in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tiny face boxes are usually faces in posters or backgrounds, so boxes below 1.5% of the frame are ignored.&lt;/li&gt;
&lt;li&gt;If the check service itself errors, the request &lt;strong&gt;fails closed&lt;/strong&gt;: a 503 and nothing charged. Silently letting it through would mean charging for jobs that are likely to fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: the reference clip is the product
&lt;/h2&gt;

&lt;p&gt;This is a video-to-video job: a fixed motion reference plus the user's photo. Getting the reference right turned out to be most of the work.&lt;/p&gt;

&lt;p&gt;The reference is stored privately in R2 and handed to the model through a short-lived signed link. It is never used as a public &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; source on the site; the pages show only outputs made with fictional people. The page credits the original post.&lt;/p&gt;

&lt;p&gt;I went through more than one model while prototyping (the code still has a switch for three providers), and the default is now a recast-style model that keeps the reference's camera move, timing and poses and swaps in the person from the photo. It bills by output seconds, and output length equals reference length, which brings me to the most useful bug of the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  The close-pass shot that broke identity
&lt;/h3&gt;

&lt;p&gt;The first outputs looked right for most of the clip, then in the last 1–2 seconds the face drifted away from the uploaded photo and back toward the man in the template footage.&lt;/p&gt;

&lt;p&gt;The cause was the end of the reference: a fast close pass by the camera with heavy motion blur. In that shot the model had almost nothing sharp to anchor the new face to, so it fell back to the person it could see in the reference.&lt;/p&gt;

&lt;p&gt;The fix didn't need any paid re-runs. I trimmed the reference so that shot is gone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The racing reference is trimmed to exactly 12.80s (384 frames; the old shot 5,
 * a blurred close pass, made the rider's face drift) ...
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECAST_REF_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;12.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and tightened the prompt so the person from the photo stays the rider "in every shot, including any close pass by the camera".&lt;/p&gt;

&lt;p&gt;A side effect: billing follows output length, so the shorter reference also made every clip cheaper. It's also why the site says "about 13 seconds" instead of 15.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a guard against "the model returned a different length"
&lt;/h2&gt;

&lt;p&gt;Because the pipeline assumes output length equals reference length, the runner checks it. The tolerance is deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;outputDurationMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputSeconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;outputSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;tolerance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputSeconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputSeconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;inputSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tolerance&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 same module rewrites the MP4 timing table (&lt;code&gt;stts&lt;/code&gt;) to constant frame rate when needed, and there's a test named "accepts small encoder drift and rejects a shortened or lengthened clip". A clip that is a second short would break the music sync in the next step, so it's better to catch it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: strip the audio, then add our own
&lt;/h2&gt;

&lt;p&gt;Whatever audio comes back must not ship. The reference had its audio removed when it was prepared, but I don't trust that alone, so there's a small MP4 box walker that drops audio tracks from the result and zeroes the orphaned sample bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Drop audio tracks from a non-fragmented MP4 and zero the orphaned sample bytes.
 * Reference-clip audio must not ship with the result. If the file is not an MP4
 * we can prove is silent, callers should refuse to store it.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The music is an &lt;strong&gt;original backing track&lt;/strong&gt; generated for the site, not any existing song. It's stored privately in R2 and merged with a hosted ffmpeg merge endpoint. The trick that removed all filter work: the track is cut to 13.0 s with a 0.5 s fade-out that ends at 12.8 s, which is exactly the clip length. The merge never cuts the music mid-fade.&lt;/p&gt;

&lt;p&gt;Muxing is best effort. If it fails, the job still succeeds with the silent version. Users get both: the music version by default, and a silent copy, because some platforms want you to add a sound inside the app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhypnfn75kucwpsuza6l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhypnfn75kucwpsuza6l.png" alt="Job progress page: a ring at 43% estimated, three steps (queued, generating, finishing) and a tip about posting the silent version to TikTok" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The job page. The uploaded photo is blurred here for the screenshot.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: money, queues and refunds
&lt;/h2&gt;

&lt;p&gt;This is the part I'd tell anyone building a paid generator to design first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One active job per user.&lt;/strong&gt; A unique index on active jobs in D1 enforces it. If someone pays for another video while one is running, it's charged and parked as &lt;code&gt;waiting_slot&lt;/code&gt;, then started when the previous job ends (queue consumer hook) or by the cron sweep. If it can never start, its credit is refunded once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A site-wide daily spend cap.&lt;/strong&gt; Generation costs real money per second of output, so there's a daily cap on provider spend plus a per-user daily limit. At the cap, a new job is parked as &lt;code&gt;waiting_budget&lt;/code&gt; with &lt;strong&gt;no credit charged and no budget reserved&lt;/strong&gt;, and the cron promotes parked jobs oldest-first when budget frees up. An admin alert goes out once per day when spend crosses a threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures are classified, not retried blindly.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A moderation rejection from the provider is final: never resubmitted, credit refunded, plain message to the user.&lt;/li&gt;
&lt;li&gt;Other failures get at most &lt;strong&gt;one&lt;/strong&gt; automatic rerun, with a fresh idempotency key so the provider doesn't dedupe it onto the failed request. Then refund.&lt;/li&gt;
&lt;li&gt;Refunds are deduplicated per job, so a retry storm can't refund twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Never submit twice by accident.&lt;/strong&gt; A network failure on submit doesn't mean the job wasn't created. Before any second submit, the runner looks up an existing request for that job; a failed status check never triggers a submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: delivery, sharing and the 7-day rule
&lt;/h2&gt;

&lt;p&gt;When a job finishes, the user gets one "ready" email (or one "failed" email) and the video is in their account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzmywaeoxw8i3obsu13z3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzmywaeoxw8i3obsu13z3.png" alt="Email saying the video is ready, with a Watch your video button and a note that the video is deleted 7 days after it finishes" width="620" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Name in the greeting blurred.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The share panel links to the site, not to the video. Videos stay private until the user posts the file themselves; on mobile, TikTok/Instagram use the native share sheet with a download fallback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjuxxn9696quw8f0ng09.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjuxxn9696quw8f0ng09.png" alt="Share panel with X, Facebook, TikTok and Instagram buttons, a copyable caption and a copy-link field" width="520" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Retention is enforced in code, not policy text: when a job completes it gets &lt;code&gt;expires_at = now + 7 days&lt;/code&gt;, and a cleanup job deletes &lt;strong&gt;both&lt;/strong&gt; the output and the original photo from R2. Failed or never-finished jobs are cleaned up 7 days after creation.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch the reference shot by shot before tuning prompts.&lt;/strong&gt; The single biggest quality fix was removing one shot from the reference, and it cost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make output length a server constant from day one.&lt;/strong&gt; My copy said "15 seconds" for a while and had to be changed everywhere when the reference changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the refund paths before the happy path.&lt;/strong&gt; Parking, one rerun and per-job dedupe were much easier to reason about as a state machine than as patches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one fact source.&lt;/strong&gt; Prices live in one JSON catalog that both Stripe and the UI read, so copy and checkout can't drift apart.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Numbers I can actually stand behind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Output: about 13 seconds (the reference is 12.80 s).&lt;/li&gt;
&lt;li&gt;Time to finish: about 4 minutes. This is from the first live job, a single data point, not an average.&lt;/li&gt;
&lt;li&gt;Retention: 7 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. If you've fought identity drift in video-to-video models, I'd like to hear what worked for you in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you want to try it: &lt;a href="https://crazyfrogvideoai.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;MemeMotion&lt;/a&gt;. MemeMotion is unofficial and not affiliated with TikTok, the creator, or the Crazy Frog rights holders. Motion reference from the original post by @wdmowski.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why your transparent video turns black: I tested WebM alpha, ProRes 4444 and HEVC alpha in Chrome, Firefox, Edge and OBS</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:07:19 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/why-your-transparent-video-turns-black-i-tested-webm-alpha-prores-4444-and-hevc-alpha-in-chrome-52po</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/why-your-transparent-video-turns-black-i-tested-webm-alpha-prores-4444-and-hevc-alpha-in-chrome-52po</guid>
      <description>&lt;p&gt;Search for "transparent video" and you'll find plenty of confident compatibility tables. Most of them don't say whether anyone actually played the file. I wanted one where every cell answers a simpler question: &lt;strong&gt;did someone check this, and how?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I encoded the same 4-second clip five ways, put each one over a solid magenta background, and read the pixels back. The rule for the table: a cell is either &lt;strong&gt;tested&lt;/strong&gt; (by me, with versions and a date), &lt;strong&gt;per official docs&lt;/strong&gt; (with a link to the vendor's own words), or &lt;strong&gt;not tested&lt;/strong&gt;. No guessing to fill gaps.&lt;/p&gt;

&lt;p&gt;That rule leaves a lot of cells empty, and I think the gaps are useful too. If you can fill one in from macOS or Windows, please do it in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web (Chrome, Firefox, Edge):&lt;/strong&gt; WebM VP9 with alpha kept transparency in all three (tested, Linux).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OBS:&lt;/strong&gt; WebM VP9 alpha &lt;em&gt;and&lt;/em&gt; ProRes 4444 both kept transparency through a Media Source (tested, OBS 30.2.3 on Linux).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;H.264 MP4 never has alpha.&lt;/strong&gt; It plays, and the transparent area comes out black (tested). Asking x264 for &lt;code&gt;yuva420p&lt;/code&gt; doesn't help; it quietly falls back to &lt;code&gt;yuv420p&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari + WebM alpha:&lt;/strong&gt; WebKit's own bug tracker describes it as playing "with a grey background rather than transparent" (per docs, not tested by me).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEVC with alpha:&lt;/strong&gt; Apple documents it for Safari on iOS 13 / macOS Catalina and later. I &lt;strong&gt;could not test it&lt;/strong&gt;, because &lt;code&gt;libx265&lt;/code&gt; on Linux silently drops the alpha plane. More on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editors (Premiere, Resolve, After Effects, Final Cut Pro):&lt;/strong&gt; vendor docs point to ProRes 4444 for alpha. I didn't run import tests in any editor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test setup
&lt;/h2&gt;

&lt;p&gt;Everything ran on &lt;strong&gt;2026-09-24&lt;/strong&gt; on one machine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Debian GNU/Linux 13 (trixie), x86_64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg&lt;/td&gt;
&lt;td&gt;7.1.5-0+deb13u1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;151.0.7922.169&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;153.0 (Playwright build)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;153.0.4234.48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OBS Studio&lt;/td&gt;
&lt;td&gt;30.2.3 (X11)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The source clip is synthetic: ffmpeg drew a cyan circle on a fully transparent background, 320×180, 4 seconds. A synthetic clip makes the check easy. The corner pixel should be fully transparent and the circle fully opaque, so there's nothing in between to argue about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encodes (the exact commands I ran)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# WebM VP9 with alpha&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source-synthetic.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libvpx-vp9 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuva420p &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-auto-alt-ref&lt;/span&gt; 0 &lt;span class="nt"&gt;-deadline&lt;/span&gt; good &lt;span class="nt"&gt;-cpu-used&lt;/span&gt; 4 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 0 &lt;span class="nt"&gt;-crf&lt;/span&gt; 33 &lt;span class="nt"&gt;-t&lt;/span&gt; 4 01-webm-vp9-alpha.webm

&lt;span class="c"&gt;# HEVC "with alpha" attempt (spoiler: no alpha in the output)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source-synthetic.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx265 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuva420p &lt;span class="nt"&gt;-t&lt;/span&gt; 4 02-hevc-alpha-attempt.mov

&lt;span class="c"&gt;# ProRes 4444 with alpha (profile 4 = 4444)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source-synthetic.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v prores_ks &lt;span class="nt"&gt;-profile&lt;/span&gt;:v 4 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuva444p10le &lt;span class="nt"&gt;-t&lt;/span&gt; 4 03-prores4444-alpha.mov

&lt;span class="c"&gt;# H.264 MP4 control (no alpha by design)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source-synthetic.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-preset&lt;/span&gt; fast &lt;span class="nt"&gt;-crf&lt;/span&gt; 23 &lt;span class="nt"&gt;-t&lt;/span&gt; 4 04-mp4-h264-noalpha.mp4

&lt;span class="c"&gt;# PNG sequence (RGBA)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source-synthetic.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rgba &lt;span class="nt"&gt;-start_number&lt;/span&gt; 0 05-png-seq/frame_%04d.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Browser check: magenta stage + canvas pixel read
&lt;/h3&gt;

&lt;p&gt;A local HTTP page put each file inside a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with &lt;code&gt;background: #ff00ff&lt;/code&gt;. Playwright drove Chrome, Edge and Firefox. Once the video played, the page seeked to 0.1 s, painted magenta onto a canvas, drew the video frame over it, and sampled two pixels: a corner (should be transparent) and the circle (should be cyan).&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ff00ff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&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;corner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// transparent area&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;circle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// opaque area&lt;/span&gt;
&lt;span class="c1"&gt;// corner ≈ [255, 0, 255] → alpha kept (magenta shows through)&lt;/span&gt;
&lt;span class="c1"&gt;// corner ≈ [0, 0, 0]     → no alpha (black)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real readings from Chrome 151: the WebM corner was &lt;code&gt;[255, 0, 255, 255]&lt;/code&gt;. The H.264 MP4 corner was &lt;code&gt;[0, 0, 0, 255]&lt;/code&gt;. Same clip, same page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F10t0jfdgbumh8rzfzs35.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F10t0jfdgbumh8rzfzs35.png" alt="Same clip in Chrome 151 on Linux over magenta: WebM VP9 alpha shows magenta through (left); H.264 MP4 shows black (right)" width="656" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  OBS check: Color Source + Media Source
&lt;/h3&gt;

&lt;p&gt;In OBS I put a magenta &lt;strong&gt;Color Source&lt;/strong&gt; at the bottom of the scene and a &lt;strong&gt;Media Source&lt;/strong&gt; pointing at the test file above it, then took a screenshot of the preview. If alpha survives, you see a cyan circle on magenta. If it doesn't, you see a black rectangle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0axnkpfdiypi75sc39ls.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0axnkpfdiypi75sc39ls.png" alt="OBS 30.2.3 on Linux: WebM VP9 alpha keeps alpha (left); H.264 MP4 shows a black box (right)" width="800" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Legend: &lt;strong&gt;✅ tested&lt;/strong&gt; = I ran it, on Linux, 2026-09-24, with the versions above. &lt;strong&gt;📄 docs&lt;/strong&gt; = the vendor's own documentation says so (footnote links); not tested by me. &lt;strong&gt;— not tested&lt;/strong&gt; = no test and no usable official statement, so I'm not claiming anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browsers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Chrome 151&lt;/th&gt;
&lt;th&gt;Firefox 153&lt;/th&gt;
&lt;th&gt;Edge 153&lt;/th&gt;
&lt;th&gt;Safari (macOS)&lt;/th&gt;
&lt;th&gt;Safari (iOS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebM VP9 + alpha&lt;/td&gt;
&lt;td&gt;✅ alpha kept&lt;/td&gt;
&lt;td&gt;✅ alpha kept&lt;/td&gt;
&lt;td&gt;✅ alpha kept&lt;/td&gt;
&lt;td&gt;📄 plays, grey bg instead of transparent [1]&lt;/td&gt;
&lt;td&gt;— not tested [2]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEVC + alpha&lt;/td&gt;
&lt;td&gt;— not tested [3]&lt;/td&gt;
&lt;td&gt;— not tested [3]&lt;/td&gt;
&lt;td&gt;— not tested [3]&lt;/td&gt;
&lt;td&gt;📄 supported (Catalina+) [4]&lt;/td&gt;
&lt;td&gt;📄 supported (iOS 13+) [4]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProRes 4444 MOV&lt;/td&gt;
&lt;td&gt;✅ won't play&lt;/td&gt;
&lt;td&gt;✅ won't play&lt;/td&gt;
&lt;td&gt;✅ won't play&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 MP4 (control)&lt;/td&gt;
&lt;td&gt;✅ plays, black bg&lt;/td&gt;
&lt;td&gt;✅ plays, black bg&lt;/td&gt;
&lt;td&gt;✅ plays, black bg&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG sequence&lt;/td&gt;
&lt;td&gt;✅ N/A as &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;; frame via &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; keeps alpha&lt;/td&gt;
&lt;td&gt;✅ same&lt;/td&gt;
&lt;td&gt;✅ same&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  OBS and editors
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;OBS 30.2.3&lt;/th&gt;
&lt;th&gt;Premiere Pro&lt;/th&gt;
&lt;th&gt;DaVinci Resolve&lt;/th&gt;
&lt;th&gt;After Effects&lt;/th&gt;
&lt;th&gt;Final Cut Pro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebM VP9 + alpha&lt;/td&gt;
&lt;td&gt;✅ alpha kept&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEVC + alpha&lt;/td&gt;
&lt;td&gt;— not tested [3]&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProRes 4444 MOV&lt;/td&gt;
&lt;td&gt;✅ alpha kept&lt;/td&gt;
&lt;td&gt;📄 imports ProRes; 4444 carries alpha [5]&lt;/td&gt;
&lt;td&gt;📄 ProRes decode Yes; alpha exports 4444 [6]&lt;/td&gt;
&lt;td&gt;📄 ProRes import/export; 4444 carries alpha [7]&lt;/td&gt;
&lt;td&gt;📄 imports all ProRes; use 4444 to preserve alpha [8]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 MP4 (control)&lt;/td&gt;
&lt;td&gt;✅ plays, black inside the frame&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG sequence&lt;/td&gt;
&lt;td&gt;— not tested&lt;/td&gt;
&lt;td&gt;📄 PNG import listed; sequence alpha not stated [9]&lt;/td&gt;
&lt;td&gt;📄 png decode/encode Yes [6]&lt;/td&gt;
&lt;td&gt;📄 PNG import; stills importable as sequence [7]&lt;/td&gt;
&lt;td&gt;📄 PNG stills supported [8]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Count:&lt;/strong&gt; 50 cells. 15 tested, 11 per official docs, 24 not tested.&lt;/p&gt;

&lt;p&gt;The "✅ won't play" cells are real results, not failures of the test. Chrome and Edge returned &lt;code&gt;DEMUXER_ERROR_NO_SUPPORTED_STREAMS&lt;/code&gt; for the ProRes MOV. Firefox returned &lt;code&gt;NS_ERROR_DOM_MEDIA_DEMUXER_ERR&lt;/code&gt;. ProRes is an editing format, not something to put on a web page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources for 📄 cells&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Safari 14.1 release notes: "WebKit on macOS supports WebM files containing VP8 or VP9 video tracks" (&lt;a href="https://webkit.org/blog/11648/new-webkit-features-in-safari-14-1/" rel="noopener noreferrer"&gt;webkit.org&lt;/a&gt;). For alpha, WebKit bug #275908, "VP9 with transparency plays back incorrectly … with a grey background rather than transparent", status NEW when I checked (&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=275908" rel="noopener noreferrer"&gt;bugs.webkit.org&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;The bug doesn't say whether it covers iOS, and the Safari 14.1 WebM paragraph is about macOS, so I'm not extending either one to iOS.&lt;/li&gt;
&lt;li&gt;I couldn't produce a real HEVC-with-alpha file on Linux. See the next section.&lt;/li&gt;
&lt;li&gt;Apple WWDC19 session 506: "HEVC video with Alpha is supported by Safari in iOS 13 and macOS Catalina" (&lt;a href="https://developer.apple.com/videos/play/wwdc2019/506/" rel="noopener noreferrer"&gt;developer.apple.com&lt;/a&gt;). See also &lt;a href="https://developer.apple.com/documentation/avfoundation/using-hevc-video-with-alpha" rel="noopener noreferrer"&gt;Using HEVC video with alpha&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Adobe lists Apple ProRes as a supported native format (&lt;a href="https://helpx.adobe.com/premiere-pro/using/supported-file-formats.html" rel="noopener noreferrer"&gt;helpx.adobe.com&lt;/a&gt;). Apple: ProRes 4444 and 4444 XQ "are also the only Apple ProRes codecs that support alpha channels" (&lt;a href="https://support.apple.com/en-us/102207" rel="noopener noreferrer"&gt;support.apple.com/102207&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;DaVinci Resolve 20 Supported Codec List: Apple ProRes, mov/mkv, Alpha Exports: 4444, Decode: Yes. png: Decode/Encode Yes (&lt;a href="https://documents.blackmagicdesign.com/SupportNotes/DaVinci_Resolve_20_Supported_Codec_List.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;After Effects supported formats: "Apple ProRes codec — Import and Export", "Portable Network Graphics (.png)", and "You can import files of any still image format as a sequence" (&lt;a href="https://helpx.adobe.com/after-effects/desktop/get-started/supported-file-formats/supported-file-formats.html" rel="noopener noreferrer"&gt;helpx.adobe.com&lt;/a&gt;). The alpha part comes from Apple 102207, as in [5].&lt;/li&gt;
&lt;li&gt;Final Cut Pro supported media: "Apple ProRes (all versions)", PNG under still-image formats (&lt;a href="https://support.apple.com/guide/final-cut-pro/supported-media-formats-ver2833f855/mac" rel="noopener noreferrer"&gt;support.apple.com&lt;/a&gt;). Apple's FCP transcoding note: "To preserve the alpha channel, choose ProRes 4444 or ProRes 4444XQ" (&lt;a href="https://support.apple.com/en-us/101763" rel="noopener noreferrer"&gt;support.apple.com/101763&lt;/a&gt;). For compositing see &lt;a href="https://support.apple.com/guide/final-cut-pro/use-alpha-channels-ver52cf382e/mac" rel="noopener noreferrer"&gt;Use alpha channels&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Premiere's supported-formats page lists PNG. I found no Adobe sentence that says sequence alpha is preserved, so I'm not claiming it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  ffprobe will mislead you twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A VP9 file with alpha reports &lt;code&gt;yuv420p&lt;/code&gt;.&lt;/strong&gt; This is the ffprobe output for the WebM that kept alpha in every browser above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"codec_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vp9"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"pix_fmt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yuv420p"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alpha_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ENCODER"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lavc61.19.101 libvpx-vp9"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only grep for &lt;code&gt;yuva&lt;/code&gt;, you'll decide this file has no alpha, and you'll be wrong. For WebM, look for the &lt;code&gt;alpha_mode=1&lt;/code&gt; tag, then confirm with a visual composite test like the one above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. x264 and x265 accept a &lt;code&gt;yuva&lt;/code&gt; request and silently ignore it.&lt;/strong&gt; I asked for alpha three times and got none:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command asked for&lt;/th&gt;
&lt;th&gt;ffprobe reported&lt;/th&gt;
&lt;th&gt;Alpha?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libx264 -pix_fmt yuva420p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yuv420p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libx265 -pix_fmt yuva420p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;yuv420p&lt;/code&gt; (Main)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libx265 -pix_fmt yuva444p10le&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;yuv444p10le&lt;/code&gt; (Rext)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ProRes encode was the opposite case: I asked for &lt;code&gt;yuva444p10le&lt;/code&gt; and ffprobe reported &lt;code&gt;yuva444p12le&lt;/code&gt;, profile &lt;code&gt;4444&lt;/code&gt;, tag &lt;code&gt;ap4h&lt;/code&gt;. The alpha survived, and the decoder just reports the 12-bit pixel format. In short, when an encoder can store alpha, ffprobe shows a &lt;code&gt;yuva&lt;/code&gt; format. When ffprobe shows &lt;code&gt;yuv&lt;/code&gt; with no alpha tag, the alpha is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I couldn't test HEVC with alpha on Linux
&lt;/h2&gt;

&lt;p&gt;Apple's HEVC-with-alpha format isn't simply "HEVC with an extra channel." According to WWDC19 session 506, each frame carries two layers inside a single video track, a base color layer and an alpha layer, using special HEVC syntax. Apple says encoding goes through AVFoundation / VideoToolbox (&lt;code&gt;AVAssetWriter&lt;/code&gt;, export presets "with Alpha", or &lt;code&gt;VTCompressionSession&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;On Linux I have &lt;code&gt;libx265&lt;/code&gt;, which (as shown above) drops the alpha plane without raising an error. I don't have VideoToolbox there. So my "HEVC alpha" file was really ordinary opaque HEVC, and testing it would only show how browsers handle opaque HEVC. For the record, Chrome and Edge refused to play it, and Firefox played it with a black background. That's why every HEVC cell in my browser and OBS columns is &lt;strong&gt;not tested&lt;/strong&gt;, and the Safari cells rely on Apple's documentation.&lt;/p&gt;

&lt;p&gt;The same talk explains why a "working" HEVC alpha file can still lose its transparency somewhere else: an HEVC decoder "that does not know how to handle the Alpha layer will ignore it and display the base layer alone." The file plays, but the alpha layer is skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving one overlay to every browser
&lt;/h2&gt;

&lt;p&gt;MDN documents how &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; fallback works: "The browser goes through a list of &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; elements to find a format it supports. It uses the first one it can display" (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;). The usual pattern pairs an HEVC-alpha file for Safari with a WebM VP9 alpha file for everyone else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;muted&lt;/span&gt; &lt;span class="na"&gt;loop&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- HEVC with alpha (encoded on macOS) — Safari per Apple docs; NOT tested by me --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"overlay-hevc-alpha.mov"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"video/quicktime"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- WebM VP9 alpha — tested: Chrome 151 / Firefox 153 / Edge 153 on Linux --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"overlay-vp9-alpha.webm"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"video/webm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats, both based on the documents above rather than on a test of my own. First, the order matters, because the browser takes the first source it can play. Second, per the WWDC note, a browser that can decode HEVC but not the alpha layer would show the opaque base layer. Before you ship this, open it in every browser you care about, on the operating systems your users actually run. I haven't run this snippet, so I can't tell you which source each browser picks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the format by destination
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome / Firefox / Edge &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;WebM VP9 alpha (&lt;code&gt;yuva420p&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✅ tested (Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HEVC with alpha, encoded with Apple tools&lt;/td&gt;
&lt;td&gt;📄 Apple WWDC19 506; not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OBS Media Source&lt;/td&gt;
&lt;td&gt;WebM VP9 alpha or ProRes 4444&lt;/td&gt;
&lt;td&gt;✅ tested (OBS 30.2.3, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premiere / Resolve / AE / Final Cut&lt;/td&gt;
&lt;td&gt;ProRes 4444 MOV&lt;/td&gt;
&lt;td&gt;📄 vendor + Apple docs; not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything where you need alpha&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not&lt;/strong&gt; H.264 MP4&lt;/td&gt;
&lt;td&gt;✅ tested: black background&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  "My transparent video turned black": a checklist
&lt;/h2&gt;

&lt;p&gt;Each item points to its evidence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The file never had alpha.&lt;/strong&gt; H.264 MP4 has no alpha. ✅ In Chrome, Firefox, Edge and OBS the transparent area rendered black.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your encoder dropped the alpha without telling you.&lt;/strong&gt; x264 and x265 both did this (the table above). Check with ffprobe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You exported ProRes, but not 4444.&lt;/strong&gt; Apple: only ProRes 4444 and 4444 XQ support alpha. 📄&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebM VP9 alpha in Safari.&lt;/strong&gt; WebKit #275908 describes playback "with a grey background rather than transparent". 📄 (I haven't seen it myself; Safari isn't in my test.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEVC alpha in a decoder that doesn't know about the alpha layer.&lt;/strong&gt; Per Apple, it shows the base layer only. 📄&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The editor interprets the alpha differently.&lt;/strong&gt; Saying an editor "supports ProRes" doesn't tell you whether it treats alpha as straight or premultiplied by default. If edges or backgrounds look wrong after import, check the footage interpretation settings (Interpret Footage in Adobe apps) before you blame the file.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linux only.&lt;/strong&gt; Chrome, Firefox, Edge and OBS ran on Debian 13. Don't assume the same results on macOS, Windows or mobile, where decoders and GPU paths differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OBS used software GL&lt;/strong&gt; (X11 + llvmpipe). Hardware decoding paths may behave differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic clips.&lt;/strong&gt; The test files came from ffmpeg, not from any particular app's export pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Per docs" isn't a test.&lt;/strong&gt; I quoted vendor documentation exactly and linked it, but I didn't open Premiere, Resolve, After Effects or Final Cut Pro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG sequences aren't a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; format.&lt;/strong&gt; The browser cells only show that a single RGBA frame kept alpha when drawn with &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I haven't tested yet (help wanted)
&lt;/h2&gt;

&lt;p&gt;These are the 24 empty cells, grouped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari on macOS and iOS:&lt;/strong&gt; every format. I have no Safari results of my own, including WebM alpha (only the WebKit bug above) and HEVC alpha (only Apple's docs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real HEVC with alpha anywhere:&lt;/strong&gt; it needs an encode from VideoToolbox on a Mac.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebM VP9 alpha in Premiere, Resolve, After Effects and Final Cut Pro&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;H.264 MP4 in all four editors and in Safari&lt;/strong&gt; (expected to have no alpha, but nobody has checked)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG sequence in OBS&lt;/strong&gt; (Image Slide Show or similar)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS and Windows builds&lt;/strong&gt; of Chrome, Firefox, Edge and OBS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can run the magenta test on any of these, post the app version, OS, file type and what you saw in the comments. I'll update the table and credit you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I produce these files day to day
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://exportalpha.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=transparent-video-test" rel="noopener noreferrer"&gt;ExportAlpha&lt;/a&gt;.&lt;/em&gt; It's a browser tool that removes the background from a video and exports it as WebM VP9 with alpha (&lt;code&gt;yuva420p&lt;/code&gt;, clips up to 60 seconds) or ProRes 4444 MOV (up to 8 seconds), which are the two formats that passed the OBS test above. It doesn't export HEVC with alpha, so for the Safari row you'll still need Apple's tools. The test clips in this post were made with plain ffmpeg, not with ExportAlpha, so the matrix doesn't depend on my product. The full matrix, with per-cell notes, is also &lt;a href="https://exportalpha.com/guides/what-transparent-video-format?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=transparent-video-test" rel="noopener noreferrer"&gt;published here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've been fighting a black box behind a "transparent" overlay, I'd like to hear which app and format it was. Questions about the Playwright and canvas setup are welcome in the comments, too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance; every test result comes from the run described above, and each cited claim links to its source.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>video</category>
      <category>ffmpeg</category>
      <category>obs</category>
    </item>
    <item>
      <title>Build log: a free BaZi chart calculator and one-time PDF reports on Cloudflare Workers</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Mon, 05 Oct 2026 19:26:43 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/build-log-a-free-bazi-chart-calculator-and-one-time-pdf-reports-on-cloudflare-workers-12n1</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/build-log-a-free-bazi-chart-calculator-and-one-time-pdf-reports-on-cloudflare-workers-12n1</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://pillarchart.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;PillarChart&lt;/a&gt; — live under the product name &lt;strong&gt;BaZi Chart&lt;/strong&gt; — a small site that turns a birth date, time and place into a BaZi (Four Pillars) chart, shows every step of the calculation, and offers optional one-time PDF reports.&lt;/p&gt;

&lt;p&gt;BaZi isn't rare on the English web. What I wanted was different from the "drop a birthday, get a destiny score" pattern: a calculator whose rules you can audit, content pages that cite classical sources instead of inventing personality claims, and a monetization path that doesn't need a subscription. The funnel shape is familiar — free chart, then a paid written report, the same shape that works for other niche calendar/astrology calculators with sample PDFs — but the visual language and the content depth are not copied from anyone else's layout. I cared more about looking like a careful calendar tool than like a generic "AI oracle."&lt;/p&gt;

&lt;p&gt;This post is the engineering and editorial log: the engine, the receipt, the Workers stack, the content wedge, and how the paid reports sit on top of deterministic chart facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Everything runs on Cloudflare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; deployed to &lt;strong&gt;Workers&lt;/strong&gt; via &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; for accounts, orders and report jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; for finished PDF reports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queues&lt;/strong&gt; for report generation (&lt;code&gt;max_batch_size = 1&lt;/code&gt;, &lt;code&gt;max_retries = 3&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth.js (next-auth)&lt;/strong&gt; for Google sign-in when someone wants a written report&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib&lt;/strong&gt; to assemble the report PDFs on the Worker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The chart engine itself is custom TypeScript under &lt;code&gt;src/lib/bazi/&lt;/code&gt;. It never calls a network API. Solar-term instants for 1900–2100 are a committed lookup table generated offline with &lt;a href="https://github.com/cosinekitty/astronomy" rel="noopener noreferrer"&gt;Astronomy Engine&lt;/a&gt; (&lt;code&gt;SearchSunLongitude&lt;/code&gt;). Independent calendar libraries (lunar-javascript, tyme4ts, sxtwl) appear &lt;strong&gt;only in tests&lt;/strong&gt;, as cross-check oracles — they are not the production engine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpb9n0zk2o2hxbacwll8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpb9n0zk2o2hxbacwll8.png" alt="Homepage: free BaZi chart form on PillarChart" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a receipt matters more than a reading
&lt;/h2&gt;

&lt;p&gt;Most BaZi disagreements online aren't mystical. They're convention disagreements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the &lt;strong&gt;year&lt;/strong&gt; change at Li Chun (Sun at ecliptic longitude 315°) or at Chinese New Year?&lt;/li&gt;
&lt;li&gt;Does the &lt;strong&gt;day&lt;/strong&gt; change at 23:00 (start of the Zi hour) or at midnight?&lt;/li&gt;
&lt;li&gt;Is the &lt;strong&gt;hour&lt;/strong&gt; taken from clock time, or corrected to &lt;strong&gt;true solar time&lt;/strong&gt; at the birthplace (longitude + equation of time, daylight saving removed)?&lt;/li&gt;
&lt;li&gt;Was daylight saving stripped out before the hour pillar was set?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our defaults are published on &lt;a href="https://pillarchart.com/methodology?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/methodology&lt;/code&gt;&lt;/a&gt;: Li Chun year, solar-term months, true solar time on when you give a city, day change at 23:00 (switchable to midnight). Every chart prints an engine version — currently &lt;code&gt;bazi-lichun-jieqi-tst-v1&lt;/code&gt; — and a &lt;strong&gt;receipt&lt;/strong&gt;: UTC instant, DST minutes removed, longitude and equation-of-time corrections, the solar terms before and after the birth, and the resulting true solar time.&lt;/p&gt;

&lt;p&gt;If you don't know your birth time, you get three pillars and no hour. The form never fills in noon.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5c38yctodwgsdfnzd8rw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5c38yctodwgsdfnzd8rw.png" alt="Chart result with calculation receipt open" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check the engine (published numbers)
&lt;/h2&gt;

&lt;p&gt;I don't want to claim "accurate" without saying against what. The methodology page lists the checks that ship with the site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solar terms:&lt;/strong&gt; all &lt;strong&gt;4,824&lt;/strong&gt; terms from 1900–2100 compared with sxtwl, tyme4ts and lunar-javascript. Largest difference: &lt;strong&gt;73 seconds&lt;/strong&gt;. Sample published Li Chun instants match to the minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four pillars:&lt;/strong&gt; &lt;strong&gt;10,000&lt;/strong&gt; random births plus &lt;strong&gt;2,500&lt;/strong&gt; near a boundary, against lunar-javascript (midnight day change) and tyme4ts (23:00 day change), with no differences; another &lt;strong&gt;1,200&lt;/strong&gt; births within three minutes of a solar term also match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Luck pillars:&lt;/strong&gt; &lt;strong&gt;5,000&lt;/strong&gt; random births vs lunar-javascript's minute-precise method — same direction and pillars; start offset within one day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other calculators:&lt;/strong&gt; reproduced the published outputs of seven calculators in the open &lt;a href="https://jadealmanac.com/bazi-calculator/conventions" rel="noopener noreferrer"&gt;Jade Almanac conventions dataset&lt;/a&gt; (CC0) once each tool's conventions were aligned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual spot check:&lt;/strong&gt; &lt;strong&gt;20&lt;/strong&gt; charts vs bazi-calculator.com; &lt;strong&gt;18&lt;/strong&gt; match on all pillars. The two that differ are births 14 and 18 minutes after Li Chun — border cases that site itself flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those numbers live on the public methodology page, not in a private spreadsheet. If a future engine revision changes a rule, the version string on the chart changes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free layer (no account)
&lt;/h2&gt;

&lt;p&gt;Three calculators are free and need no sign-up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full BaZi chart&lt;/strong&gt; on the homepage — pillars, hidden stems, Ten Gods labels, weighted five-element count, Day Master strength, luck pillars when gender is given, plus the receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pillarchart.com/day-master-calculator?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/day-master-calculator&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — just the Day Master stem, for people who only searched that question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pillarchart.com/compatibility?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/compatibility&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — two charts side by side: Day Master element relation and stem combinations, Ten Gods both ways, day-branch harmony/clash/same/none, and five-element complement vs doubling. &lt;strong&gt;No score, no percentage match, no "compatible / not" verdict.&lt;/strong&gt; It also doesn't do Chinese zodiac (year-animal) compatibility — different method.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing from those free flows is saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thick content instead of thin SEO glue
&lt;/h2&gt;

&lt;p&gt;A calculator alone feels thin. I wrote a first wave of English explainers that sit next to the engine and quote only passages I could check against an online edition (Wikisource / Chinese Text Project), with our own translations labelled as such:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Live page&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/methodology" rel="noopener noreferrer"&gt;&lt;code&gt;/methodology&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Every calculation rule, sources, school disagreements, verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/classical-sources" rel="noopener noreferrer"&gt;&lt;code&gt;/classical-sources&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Which books we cite (&lt;em&gt;Di Tian Sui&lt;/em&gt;, &lt;em&gt;Yuanhai Ziping&lt;/em&gt;, &lt;em&gt;Sanming Tonghui&lt;/em&gt;, &lt;em&gt;Ziping Zhenquan&lt;/em&gt;, …) and for what&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/li-chun-bazi-year-start" rel="noopener noreferrer"&gt;&lt;code&gt;/li-chun-bazi-year-start&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Li Chun vs Chinese New Year, with worked boundary charts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/five-elements" rel="noopener noreferrer"&gt;&lt;code&gt;/five-elements&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generating/controlling cycles, seasonal strength, how we count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/hidden-stems" rel="noopener noreferrer"&gt;&lt;code&gt;/hidden-stems&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Twelve-branch table from the &lt;em&gt;Yuanhai Ziping&lt;/em&gt; verse; our weights called out as ours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://pillarchart.com/weak-day-master" rel="noopener noreferrer"&gt;&lt;code&gt;/weak-day-master&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://pillarchart.com/strong-day-master" rel="noopener noreferrer"&gt;&lt;code&gt;/strong-day-master&lt;/code&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;What the labels mean; thresholds (42% / 58%) are our method&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/day-master/jia-wood" rel="noopener noreferrer"&gt;&lt;code&gt;/day-master/jia-wood&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Pilot Day Master page (甲); more stems follow the same template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pillarchart.com/about" rel="noopener noreferrer"&gt;&lt;code&gt;/about&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Editorial team (no invented master lineage), how AI is and isn't used&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The about page is deliberate: charts are &lt;strong&gt;never&lt;/strong&gt; computed by AI; optional short written readings for signed-in users are disclosed separately; articles are drafted with AI assistance and classical quotes are checked against linked editions. We write "the &lt;em&gt;Di Tian Sui&lt;/em&gt; describes…" — not "you will…".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbppggkqujgjfaou0tts.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbppggkqujgjfaou0tts.png" alt="Methodology: published verification numbers" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time reports (not a subscription)
&lt;/h2&gt;

&lt;p&gt;After a free chart, the result page offers written PDF reports. Prices are listed on &lt;a href="https://pillarchart.com/pricing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/pricing&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SKU&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Essential Natal Report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 chapters · listed as 11–14 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Complete Natal + 10-Year Luck Report&lt;/strong&gt; (featured)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Everything in Essential + luck-pillar chapters and a decision filter · 14 chapters · listed as 15–23 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compatibility Report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two charts · 8 chapters · listed as 9–13 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2027 Annual Forecast&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Year pillar vs natal + current luck · month by month · listed as 11–13 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Anyone with a Google account can buy a report after &lt;strong&gt;signing in with Google&lt;/strong&gt; — the free chart still needs no account. Delivery is a PDF (sample downloads are already public). Refunds: delivery failures in full; wrong payment or wrong birth details reviewed within &lt;strong&gt;7&lt;/strong&gt; days; delivered personalized reports are not normally refunded — same wording as the pricing page.&lt;/p&gt;

&lt;p&gt;Each chapter is built the same way: &lt;strong&gt;chart evidence&lt;/strong&gt; (facts the engine already computed) → a &lt;strong&gt;reading&lt;/strong&gt; → one &lt;strong&gt;question to reflect on&lt;/strong&gt;. There is no overall life score. The only percentage in a natal report is the Day Master strength share from the published point method.&lt;/p&gt;

&lt;h3&gt;
  
  
  Samples you can read without paying
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pillarchart.com/sample-report?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/sample-report&lt;/code&gt;&lt;/a&gt; — Complete report for one fictional person (real engine chart: 15 June 1990, 14:30, Shanghai → 庚午 / 壬午 / &lt;strong&gt;辛亥&lt;/strong&gt; / 乙未; Xin Day Master; weak at 38% on our method). Sample PDF: &lt;strong&gt;15&lt;/strong&gt; pages. The page states clearly that Essential has 10 of Complete's 14 chapters, so Essential buyers don't expect luck-pillar chapters.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pillarchart.com/sample-compatibility-report?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;&lt;code&gt;/sample-compatibility-report&lt;/code&gt;&lt;/a&gt; — Maya and Leo, two fictional people, two real charts (Chicago / Manchester). Sample PDF: &lt;strong&gt;9&lt;/strong&gt; pages. Relations shown are ones the engine actually computes (e.g. Wood produces Fire; Mao–You clash) — still no match score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpiywm6u0pfwm6if28uwf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpiywm6u0pfwm6if28uwf.png" alt="Sample Complete report page with PDF thumbnails" width="800" height="984"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq42qi8fi24qpi5hnh80p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq42qi8fi24qpi5hnh80p.png" alt="Sample compatibility report for Maya and Leo" width="622" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a chart into a PDF on a Worker
&lt;/h2&gt;

&lt;p&gt;The paid path is intentionally boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You calculate a free chart (still no account).&lt;/li&gt;
&lt;li&gt;On the result page you pick Essential or Complete (or start from the compatibility calculator for the pair report).&lt;/li&gt;
&lt;li&gt;Sign in with Google (any Google account) to check out that SKU; the order stores the &lt;strong&gt;chart query&lt;/strong&gt;, not a free-form essay prompt.&lt;/li&gt;
&lt;li&gt;A queue consumer builds chapter facts from the same engine functions the free page uses, fills the template (and may polish prose with a model when enabled — the sample pages show the template wording), assembles a PDF with &lt;strong&gt;pdf-lib&lt;/strong&gt;, stores it in &lt;strong&gt;R2&lt;/strong&gt;, and emails a time-limited download link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That order of operations is the whole point of the product bet: if the pillars were wrong, prettier prose would make it worse. The expensive mistake in this category is letting a model invent a clash or a Ten God the code never computed. Keeping the engine authoritative and the PDF downstream is how I avoid that.&lt;/p&gt;

&lt;p&gt;The 2027 annual SKU is the same pipeline with an extra year-pillar overlay: natal chart + current luck pillar + month-by-month walk of 2027. It is listed on pricing now so people who care about the coming Li Chun boundary can see it early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design notes (without pretending to be a temple)
&lt;/h2&gt;

&lt;p&gt;I wanted the UI to feel like paper, ink and vermilion calendar work — because BaZi &lt;em&gt;is&lt;/em&gt; a calendar system — not a generic "mystic AI" gradient. Stems, branches and solar terms belong to the calculation. Motifs that belong to other Chinese traditions (luopan compass, Hetu/Luoshu, bagua logos) stay out unless they're clearly labelled as something else. The homepage already walks year → month → hour the way the engine does: Li Chun, the twelve &lt;em&gt;jie&lt;/em&gt;, true solar time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons so far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publish the conventions, or you'll spend forever explaining "why does site X disagree?"&lt;/strong&gt; The receipt and the methodology page are the product, not a footer link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the LLM away from the pillars.&lt;/strong&gt; Deterministic code owns the chart; any prose sits on top of facts the code already produced. The sample natal page even says buyers get the template text when no writing model is used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-check libraries are oracles, not dependencies.&lt;/strong&gt; Shipping tyme4ts (or any single library) as "the" engine would hide which conventions you chose. Generating a solar-term table with Astronomy Engine and owning the rest of the rules made verification reports possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compatibility without a score is a feature.&lt;/strong&gt; People arrive from zodiac-compatibility habits; saying what we &lt;em&gt;don't&lt;/em&gt; compute (year animals, match %) sets better expectations than a fake 87%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Day Master page as a pilot beats ten thin stubs.&lt;/strong&gt; Jia Wood is live; the other nine stems reuse the same outline once the depth and citation style are locked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same funnel shape, different surface.&lt;/strong&gt; Free calculator → sample PDF → one-time report is a proven indie pattern. The differentiation is auditability and content thickness, not a novel checkout flow.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you want to see the receipt on a real birth (yours or a fictional one), &lt;a href="https://pillarchart.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;try the free chart on PillarChart&lt;/a&gt;. No account for the calculator. After you sign in with Google, you can buy a one-time PDF report from the result page or &lt;a href="https://pillarchart.com/pricing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;. If you read Chinese metaphysics content for fun, I'd especially like feedback on where the methodology page is still unclear, or which Day Master page you'd want next after Jia Wood.&lt;/p&gt;

&lt;p&gt;Questions about the OpenNext/Workers setup, the solar-term table pipeline or the report chapter structure are welcome in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BaZi is a traditional Chinese cultural practice. PillarChart / BaZi Chart is for reflection, education and entertainment — not medical, legal, financial or psychological advice. Different schools may read the same chart differently.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Build log: turning photos into printable coloring pages with Seedream on Cloudflare Workers</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Sun, 04 Oct 2026 17:53:11 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/build-log-turning-photos-into-printable-coloring-pages-with-seedream-on-cloudflare-workers-oe9</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/build-log-turning-photos-into-printable-coloring-pages-with-seedream-on-cloudflare-workers-oe9</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://crayonink.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;CrayonInk&lt;/a&gt;, a small web app that turns a photo into a black-and-white coloring page you can print at home. Upload a picture of your dog, your grandparents, a wedding photo, and get back a page with clean, closed outlines that's ready for crayons.&lt;/p&gt;

&lt;p&gt;The idea isn't new. This post is about the engineering: the pipeline, a model comparison with per-call billing data, the prompt changes that mattered, and the cost guardrails I added before letting strangers spend my inference budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;All of it runs on Cloudflare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; deployed to &lt;strong&gt;Workers&lt;/strong&gt; via &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; for jobs, accounts, credits and a spend ledger&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; for source photos and finished pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queues&lt;/strong&gt; for generation jobs (&lt;code&gt;max_batch_size = 1&lt;/code&gt;, &lt;code&gt;max_retries = 3&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;cron trigger every 15 minutes&lt;/strong&gt; that recovers stuck jobs (and refunds the user's page), deletes source photos older than 24h, and clears out expired results and PDFs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Email Service&lt;/strong&gt; for operator alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI step is a single image-edit call to &lt;strong&gt;fal&lt;/strong&gt;, using &lt;code&gt;fal-ai/bytedance/seedream/v5/lite/edit&lt;/code&gt;. One more mode, "outline", skips AI entirely: it's Canny edge detection running inside the Worker. It's instant and costs nothing, but the lines are rougher and often don't close.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidzwjin5j7998k6ucwld.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidzwjin5j7998k6ucwld.png" alt="CrayonInk tool: upload a photo, pick a detail level, get a printable page" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a photo becomes a page
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In the browser:&lt;/strong&gt; the photo is downscaled to at most 2048px on the long edge, re-encoded as JPEG (quality 0.9), and its EXIF data is stripped. Nothing is uploaded until the user clicks Generate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit:&lt;/strong&gt; the API checks the user's credits, creates a job in D1 and drops a message on the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue consumer:&lt;/strong&gt; builds the prompt from the selected detail level and style and calls Seedream. The photo goes inline as a data URI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-processing:&lt;/strong&gt; the model's output gets converted to grayscale, thresholded to a &lt;strong&gt;1-bit PNG at 300 DPI&lt;/strong&gt;, and padded so the page keeps the photo's aspect ratio. The pages are pure black and white, with no gray anti-aliasing to muddy a home print.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery:&lt;/strong&gt; PNG, or a single-page PDF in A4 or US Letter. Several pages can also be assembled into a coloring book PDF with a cover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Post-processing is what turns "a drawing" into "something that prints cleanly," so every model in the comparison below went through this exact step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs my first smoke test caught
&lt;/h2&gt;

&lt;p&gt;Early on I ran 8 jobs through the live API (6 test images, all AI-generated with FLUX schnell, including the two children, so no real kids' photos). All 8 succeeded, nothing was blocked, and AI pages averaged &lt;strong&gt;46.6 s&lt;/strong&gt; end to end. Two problems mattered more than speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The model recomposed my photos into squares.&lt;/strong&gt; Without &lt;code&gt;image_size&lt;/code&gt;, Seedream returned a square. It didn't stretch a 4:3 photo; it &lt;em&gt;re-framed&lt;/em&gt; it, and on one dog photo it invented a porch above the dog. For a product that promises "your photo," that's a bug.&lt;/p&gt;

&lt;p&gt;The fix: compute an output size that matches the input's aspect ratio. This is the version from the commit that fixed it (the current code adds an optional long-edge target for larger paid sizes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Seedream 5 Lite accepts 2560×1440 … 4096×4096 total pixels. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SEEDREAM_MIN_PIXELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2560&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1440&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;SEEDREAM_MAX_PIXELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4096&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;SEEDREAM_TARGET_PIXELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2048&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;MAX_ASPECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;falImageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FalImageSize&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;aspect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_ASPECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;MAX_ASPECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;height&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;round16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&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;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SEEDREAM_TARGET_PIXELS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;aspect&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SEEDREAM_TARGET_PIXELS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;aspect&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SEEDREAM_MIN_PIXELS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;aspect&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SEEDREAM_MAX_PIXELS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;aspect&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="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbdcfnfk1veg0m0fdblb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbdcfnfk1veg0m0fdblb.png" alt="Dog photo and the coloring page after the framing fix" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;sync_mode&lt;/code&gt; doesn't do what my code comment said.&lt;/strong&gt; I'd written that &lt;code&gt;sync_mode&lt;/code&gt; "keeps the output out of fal's request history." It doesn't; it only returns the output inline instead of as a CDN URL. Querying the request history, I could pull back the full input payload, base64 photo included, for 5 of 7 requests. That matters when parents upload photos of their kids.&lt;/p&gt;

&lt;p&gt;What I do now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FAL_RETENTION_HEADERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Fal-Store-IO&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;0&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;X-Fal-Object-Lifecycle-Preference&lt;/span&gt;&lt;span class="dl"&gt;"&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;expiration_duration_seconds&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 90 seconds after each request, a queue message checks fal's request history for it. If anything was kept, it tries to delete the payload, and it records the outcome on the job (&lt;code&gt;not_stored&lt;/code&gt; / &lt;code&gt;deleted&lt;/code&gt; / &lt;code&gt;stored&lt;/code&gt; / &lt;code&gt;pending&lt;/code&gt; / &lt;code&gt;error&lt;/code&gt;). Caveat: deleting payloads needs an &lt;strong&gt;admin&lt;/strong&gt; API key. The privacy page names fal and what it receives, and I deliberately don't promise "we never train on your photos," because that's not my policy to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model bake-off: Seedream vs Nano Banana 2 vs GPT Image 2.5
&lt;/h2&gt;

&lt;p&gt;Then I checked whether I'd picked the right model, using the exact production prompt and parameters and the production post-processing, all via fal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; 8 photos from Wikimedia Commons (2 adults, 2 multi-person family photos, 2 children, 2 pets) × 2 detail levels (Standard and Detailed) × 4 model configs = &lt;strong&gt;64 calls&lt;/strong&gt;, run 8 at a time. Total spend: &lt;strong&gt;$3.37&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Seedream 5.0 Lite edit (production)&lt;/th&gt;
&lt;th&gt;Nano Banana 2 edit (1K)&lt;/th&gt;
&lt;th&gt;GPT Image 2.5 Flare (medium)&lt;/th&gt;
&lt;th&gt;GPT Image 2.5 Sunburst (high)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per page*&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.0350&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.0800&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.0266&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.0689&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-to-end time, median (max)&lt;/td&gt;
&lt;td&gt;61.7 s (127.5 s)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;13.1 s&lt;/strong&gt; (44.1 s)&lt;/td&gt;
&lt;td&gt;21.8 s (42.4 s)&lt;/td&gt;
&lt;td&gt;41.7 s (56.5 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Child photos blocked&lt;/td&gt;
&lt;td&gt;0 of 4&lt;/td&gt;
&lt;td&gt;0 of 4&lt;/td&gt;
&lt;td&gt;0 of 4&lt;/td&gt;
&lt;td&gt;0 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Likeness, 1–5 (subjective)&lt;/td&gt;
&lt;td&gt;3.6&lt;/td&gt;
&lt;td&gt;2.9&lt;/td&gt;
&lt;td&gt;3.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Printability, 1–5 (Standard / Detailed)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.5 / 4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 / 3.5&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;3.5 / 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main problem&lt;/td&gt;
&lt;td&gt;Slowest; children's eyes often left blank&lt;/td&gt;
&lt;td&gt;Least like the person; invents things (added trees to a cat photo)&lt;/td&gt;
&lt;td&gt;Cartoonish, pupils filled solid black&lt;/td&gt;
&lt;td&gt;Ignores framing, often deletes the background; heavy hatching&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*Cost is fal's per-request &lt;code&gt;x-fal-billable-units&lt;/code&gt; response header × unit price. My API key couldn't read the billing endpoints (they need an admin key), so these numbers aren't reconciled against an invoice. The GPT configs came out above fal's listed 1024px prices because &lt;code&gt;auto&lt;/code&gt; size produced roughly 2048px outputs and my prompt is long.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8gnhm9m6ohlahkzfcly.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8gnhm9m6ohlahkzfcly.jpg" alt="Model comparison grid, Standard detail: source, Seedream, Nano Banana 2, GPT Image 2.5 Flare, GPT Image 2.5 Sunburst" width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few notes on reading that table honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Likeness" isn't a face-recognition metric.&lt;/strong&gt; It's a 1–5 score from eyeballing side-by-side face crops (every image at Standard, three spot-checked at Detailed). With 8 photos, it's a hint, not a benchmark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Blocked" means the model refused or returned an error.&lt;/strong&gt; No model refused any of the 4 child-photo calls (or the 4 family-photo calls with kids), and Seedream never returned an NSFW flag. Don't expect the model's safety filter to set your policy on kids' photos; your product has to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The printability gap is mostly ink density.&lt;/strong&gt; After post-processing, all four outputs are pure black and white. The difference is how much black: on GPT's Standard pages heavy strokes and solid black take about 10–14% of the page, versus about 3% for Seedream and about 1% for Nano Banana 2.&lt;/li&gt;
&lt;li&gt;Speeds were measured with 8 requests in flight, so they're comparative, not single-request latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I decided:&lt;/strong&gt; Seedream stays the default. Among the cheap options it holds the frame best (no cropping, deleting or inventing), and its Standard pages are the cleanest to color. The price is speed: roughly a minute per page, so I don't promise "seconds." GPT Image 2.5 Flare is the challenger: about 24% cheaper and roughly 3× faster, but more cartoonish, so it's my next A/B candidate on a small slice of real traffic. Sunburst had the best likeness but costs about twice as much and drops backgrounds, so it only fits a premium "refine" option. Nano Banana 2 costs 2.3× as much and scored lowest on likeness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt tuning: what actually changed
&lt;/h2&gt;

&lt;p&gt;The prompt is assembled from parts: a base instruction, a detail-level line, and a block of shared rules. Three changes made a real difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Forbid recomposing, explicitly.&lt;/strong&gt; The &lt;code&gt;image_size&lt;/code&gt; fix handled the canvas shape, but the model still liked to "improve" the shot. The shared rules went from "Keep the same composition and pose" to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Trace the photo exactly as framed: keep the same composition, camera angle, subject position, scale and pose, and the same aspect ratio. Do not crop, zoom, extend the scene, add new elements or recompose. Keep faces and key features recognizable and faithful to the photo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Post-processing had the same bug in miniature: it used to crop to the ink's bounding box, which also changed the aspect ratio. Now it pads each side proportionally instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Say what a coloring page &lt;em&gt;isn't&lt;/em&gt;.&lt;/strong&gt; Image models love shading, so the shared rules spell out the negatives: &lt;em&gt;"Black lines only: no shading, no gray tones, no color, no hatching, no gradients, no text, no watermark, no frame or border."&lt;/em&gt; The thresholding step would turn gray shading into black blobs anyway, so it's better if the model never draws it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A preset for detail-heavy photos.&lt;/strong&gt; For things like wedding photos, "Bold &amp;amp; detailed" asks for &lt;em&gt;"the same thick, even, confident black stroke, like a thick felt-tip marker,"&lt;/em&gt; with &lt;em&gt;"facial features … drawn cleanly and recognizably with the eyes left open and not filled in"&lt;/em&gt; and &lt;em&gt;"lace, embroidery and beading as simplified open motifs."&lt;/em&gt; Post-processing then thins the lines to a centerline and redraws them with a round pen: 9px in open areas, down to 5px in dense ones like hair and lace. It's still one Seedream call per page.&lt;/p&gt;

&lt;p&gt;Eyes are still a weak spot: in the bake-off, Seedream left the eyes blank on one child photo at both detail levels. I haven't changed the default preset for that yet; it's next, as its own small experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abuse prevention and cost control
&lt;/h2&gt;

&lt;p&gt;At $0.035 per page, an anonymous free tier is an invitation to drain my fal balance. Here's what's in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI pages need Google sign-in.&lt;/strong&gt; Signed-out visitors can browse examples and use the zero-cost outline tool. Clicking Generate opens a sign-in modal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-time signup grant, not a monthly allowance.&lt;/strong&gt; New accounts get a small number of free pages once, and it never resets. The grant is idempotent per user, and the same IP (or IPv6 /64) or the same normalized account name can claim at most &lt;strong&gt;2 grants in 24 hours&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One active AI job per account&lt;/strong&gt;, enforced atomically when the job is created, so two tabs can't race each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A USD circuit breaker on the backend.&lt;/strong&gt; Every AI page is charged to a D1 ledger at a configured cost per page &lt;strong&gt;at the moment it's submitted to the provider&lt;/strong&gt;, and the charge is never reversed, even if the job fails, gets blocked or is deleted. Grant-funded pages have their own daily budget. When it's hit, new grants pause and never-paid accounts can't start AI pages until 00:00 UTC, while paying users carry on. An optional overall breaker can stop everything. Alert emails go out at 80% and 100%.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pure admission rule: paid users ignore the grant breaker; the overall breaker blocks everyone.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;admissionAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BreakerState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userHasPaid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overallTripped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grantTripped&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userHasPaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Charging on submission and never refunding the ledger is the key choice: the provider bills you either way, so the breaker counts the same way. (Users still get their page credit back on failure; that's a separate counter.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A private-beta switch.&lt;/strong&gt; &lt;code&gt;GENERATION_ALLOWLIST&lt;/code&gt; is a comma-separated list of Google emails. When it's set, only those accounts can generate, receive the signup grant or open checkout, and everyone else sees a "coming soon" panel. I ran with just my own account for a while, then cleared the list to open sign-ups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turnstile&lt;/strong&gt; is on in production (Managed mode, server-side verification and a 30-minute pass cookie). Most people never see a challenge; scripted requests without a valid token get a 403 before any model call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons and trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smoke-test the live API before tuning anything.&lt;/strong&gt; Eight requests found a framing bug and a privacy bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your provider's retention docs, not your own comments.&lt;/strong&gt; The comment was confidently wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare models on post-processed output.&lt;/strong&gt; Users print the thresholded page, not the raw generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label where cost numbers come from.&lt;/strong&gt; Without an admin key I can't reconcile against an invoice, so I say whether a number is list price or billable units.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow is a real trade-off.&lt;/strong&gt; I'm accepting about a minute per page for better framing and cleaner lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small samples are small.&lt;/strong&gt; 8 photos, 64 calls and one person's eyeball scores. Good enough to rule out a 2.3× more expensive model, not good enough to declare a winner between two close ones.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you want to see what it does with one of your photos, &lt;a href="https://crayonink.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=buildlog" rel="noopener noreferrer"&gt;try CrayonInk&lt;/a&gt;. Sign in with Google and your new account gets a one-time batch of free pages to test with. The outline tool works without signing in. I'd love feedback on where it fails: hair, glasses, busy backgrounds, whatever breaks it.&lt;/p&gt;

&lt;p&gt;Questions about the Workers/OpenNext setup, the queue design or the breaker are welcome in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Comparison photos: adult portraits by Anthony Ginsbrook and Leroy Skalstad, Labrador by Dktue (all CC0, via Wikimedia Commons); cat by Anil Öztas, CC BY-SA 4.0, via Wikimedia Commons. The derived comparison grid is shared under CC BY-SA 4.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>ai</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>I shipped a birthday keepsake that sings their name — not another Happy Birthday name-swapper</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Fri, 04 Sep 2026 20:24:35 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-birthday-keepsake-that-sings-their-name-not-another-happy-birthday-name-swapper-4bo2</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-birthday-keepsake-that-sings-their-name-not-another-happy-birthday-name-swapper-4bo2</guid>
      <description>&lt;p&gt;Most “personalized birthday song” tools still feel like stuffing a name into Happy Birthday. I wanted a keepsake: their &lt;strong&gt;name in the chorus&lt;/strong&gt;, your &lt;strong&gt;real memories in the verses&lt;/strong&gt;, and a page they can open — not just an MP3 dump.&lt;/p&gt;

&lt;p&gt;So I shipped &lt;a href="https://nameverse.app/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;NameVerse&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4vpe9y0fbea6orz5mz2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4vpe9y0fbea6orz5mz2.jpg" alt="NameVerse homepage: custom birthday songs from real memories" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;NameVerse homepage. Official product screenshot. Source: &lt;a href="https://nameverse.app/" rel="noopener noreferrer"&gt;nameverse.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You write who it is for, add a few inside stories, pick a style, and get a &lt;strong&gt;Free 60s Preview (Take A)&lt;/strong&gt; before you unlock. Unlocking is one-time: &lt;strong&gt;$19.99 for one song&lt;/strong&gt; (WAV + gift envelope) or &lt;strong&gt;$49 for three&lt;/strong&gt; ($16.33 each). No subscription — the live site says it does not believe in recurring subscriptions for personal gifts. Checked on &lt;a href="https://nameverse.app/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, 5 September 2026.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhr3jthu0yncojguyr5g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhr3jthu0yncojguyr5g.png" alt="Transparent pricing: $19.99 one song, $49 three songs" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pricing. Official product screenshot. Source: &lt;a href="https://nameverse.app/pricing" rel="noopener noreferrer"&gt;nameverse.app/pricing&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually unlock
&lt;/h2&gt;

&lt;p&gt;Paying unlocks the full master, an alternate take, and an &lt;strong&gt;interactive gift page&lt;/strong&gt; the recipient opens. There is a public sample so you can see the unboxing without buying:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nameverse.app/g/sample-elena" rel="noopener noreferrer"&gt;Sample gift for Elena&lt;/a&gt; — marked Sample. Demo from Jamie to Elena (wife).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filllzkru1hpsl20qibpz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filllzkru1hpsl20qibpz.png" alt="Sample gift page: My Elena" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sample gift page (&lt;code&gt;/g/sample-elena&lt;/code&gt;). Official product screenshot. Source: &lt;a href="https://nameverse.app/g/sample-elena" rel="noopener noreferrer"&gt;nameverse.app/g/sample-elena&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The site also states a &lt;strong&gt;Birthday Promise&lt;/strong&gt;: one free quality re-record if pronunciation or a memory needs tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The searches that became pages
&lt;/h2&gt;

&lt;p&gt;Each of these is a question page first, then a CTA — not a cloned “AI song generator” homepage fighting MusicSmith and App Store results.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-for-wife" rel="noopener noreferrer"&gt;Birthday song for your wife&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-for-mom" rel="noopener noreferrer"&gt;Birthday song for your mom&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-for-dad" rel="noopener noreferrer"&gt;Birthday song for your dad&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-for-daughter" rel="noopener noreferrer"&gt;Birthday song for your daughter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-for-husband" rel="noopener noreferrer"&gt;Birthday song for your husband&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-with-name" rel="noopener noreferrer"&gt;A birthday song that sings their name&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/birthday-song-with-memories" rel="noopener noreferrer"&gt;A birthday song with your memories&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nameverse.app/custom-birthday-song" rel="noopener noreferrer"&gt;A custom birthday song, not a template&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc63lddnvoujtwqte0bn3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc63lddnvoujtwqte0bn3.png" alt="Guide: birthday song that sings their name" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Question page: will it actually sing their name? Official product screenshot. Source: &lt;a href="https://nameverse.app/birthday-song-with-name" rel="noopener noreferrer"&gt;nameverse.app/birthday-song-with-name&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe19x99cl82ypehkgw1tn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe19x99cl82ypehkgw1tn.png" alt="Guide: birthday song for your wife" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Question page for wife gifts. Official product screenshot. Source: &lt;a href="https://nameverse.app/birthday-song-for-wife" rel="noopener noreferrer"&gt;nameverse.app/birthday-song-for-wife&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;Copied from the site’s own framing: &lt;strong&gt;not a novelty robot track&lt;/strong&gt;, and “we do not make disposable AI novelty sounds.” Preview first, pay once, send a gift page. If the singer misses a rare name, pick the other take or respell how it sounds — the gift page keeps the real spelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live tool:&lt;/strong&gt; &lt;a href="https://nameverse.app/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;https://nameverse.app/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>I shipped a transparent-video exporter because “remove background” usually just means a black fill</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Mon, 24 Aug 2026 19:17:53 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-transparent-video-exporter-because-remove-background-usually-just-means-a-black-fill-40dg</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-transparent-video-exporter-because-remove-background-usually-just-means-a-black-fill-40dg</guid>
      <description>&lt;p&gt;I kept hitting the same search after “AI remove video background”: why is the result a black rectangle? Does MP4 even carry transparency? Why is the WebM fine in Chrome and black in Safari? How do I drop this into OBS without chroma-keying a green screen that was never there?&lt;/p&gt;

&lt;p&gt;The answer is usually not “the model failed.” Everyday H.264 MP4 does not carry an alpha channel. A lot of players composite transparent pixels onto black. Safari ignores WebM alpha. So I shipped &lt;a href="https://exportalpha.com/" rel="noopener noreferrer"&gt;ExportAlpha&lt;/a&gt; — a small exporter that writes a &lt;strong&gt;real&lt;/strong&gt; alpha channel: transparent WebM (VP9, &lt;code&gt;yuva420p&lt;/code&gt;) for Chrome, Firefox, and OBS, or ProRes 4444 MOV for Premiere, Resolve, and Final Cut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4j11xt7sinti8kpocjva.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4j11xt7sinti8kpocjva.png" alt="ExportAlpha homepage: export transparent video with alpha channel" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ExportAlpha homepage. Official product screenshot. Source: &lt;a href="https://exportalpha.com/" rel="noopener noreferrer"&gt;exportalpha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fifteen free seconds on first Google sign-in. No card required to try a short clip. Upload is MP4 / MOV / WebM / M4V, up to 60 seconds, 1080p, 200MB. It is a file workflow, not a live-stream plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I refused to invent
&lt;/h2&gt;

&lt;p&gt;The site has a public &lt;a href="https://exportalpha.com/sources" rel="noopener noreferrer"&gt;Sources &amp;amp; Method&lt;/a&gt; page. Retrieval dates are 19 August 2026. A few gaps stay labeled instead of being filled in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HEVC-with-alpha for Safari&lt;/strong&gt; is the correct destination format on paper (WWDC 2019 Session 506). We have not verified Safari transparency on our own exports. The sources page marks that feature experimental.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CapCut / iMovie / DaVinci Resolve&lt;/strong&gt; importing WebM-with-alpha: public docs contradict each other. The format guide says test your version; if WebM fails, use ProRes 4444.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEVC inside OBS&lt;/strong&gt; on Windows/Linux depends on system codecs. The OBS guide recommends VP9 WebM as the cross-platform overlay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also do not claim this is real-time webcam matting. The OBS page says it in plain language: no virtual camera, no live filter, no stream plugin. You upload a clip, download a file, then add a Media Source.&lt;/p&gt;

&lt;p&gt;The model stack is listed on that same sources page: fal.ai calling &lt;strong&gt;Bria VRMBG 3.0&lt;/strong&gt;. RVM is cited only as a paper on temporal consistency, not as the production model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The searches that became pages
&lt;/h2&gt;

&lt;p&gt;Each URL is a question people already type. The page answers it, then links the dated source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/why-transparent-video-has-black-background" rel="noopener noreferrer"&gt;Why does my transparent video have a black background?&lt;/a&gt; — alpha was never written, or the player composites onto black. Check &lt;code&gt;ffprobe&lt;/code&gt; for &lt;code&gt;yuva420p&lt;/code&gt; / &lt;code&gt;yuva444p10le&lt;/code&gt; / &lt;code&gt;rgba&lt;/code&gt;, or drop the file over a colored plate in an NLE.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/does-mp4-support-transparent-background" rel="noopener noreferrer"&gt;Does MP4 support a transparent background?&lt;/a&gt; — everyday H.264 does not. Do not export H.264 if you need transparency.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/what-transparent-video-format" rel="noopener noreferrer"&gt;Which transparent video format should I export?&lt;/a&gt; — WebM for Chrome/Firefox/OBS; HEVC-with-alpha for Safari (we have not verified our own HEVC); ProRes 4444 for editors; already-composited H.264 only as a finished deliverable.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/transparent-webm-safari" rel="noopener noreferrer"&gt;Why is my transparent WebM black in Safari?&lt;/a&gt; — Safari ignores WebM alpha. Chrome and Firefox composite it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/how-to-make-a-webm-transparent-video" rel="noopener noreferrer"&gt;How do I make a WebM video with a transparent background?&lt;/a&gt; — VP9 with &lt;code&gt;yuva420p&lt;/code&gt;, or FFmpeg if you already have alpha.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/how-to-use-transparent-video-in-obs" rel="noopener noreferrer"&gt;Can I use a transparent video overlay in OBS?&lt;/a&gt; — Media Source + local WebM, or Browser Source for a hosted page. Do not add a Chroma Key filter if the file already has alpha.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/why-video-background-removal-flickers" rel="noopener noreferrer"&gt;Why does AI video background removal flicker?&lt;/a&gt; — frame-by-frame masks drift; a temporal video model keeps edges stable.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://exportalpha.com/guides/what-is-a-video-alpha-channel" rel="noopener noreferrer"&gt;What is a video alpha channel?&lt;/a&gt; — the fourth channel. Without it, “transparent” regions become black.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxbf9g6gmdxhinwbkrm9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxbf9g6gmdxhinwbkrm9.png" alt="Homepage examples: original vs transparent (hair, hands, talking head, product)" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Homepage examples: hair, hand overlay, home-office talking head, product. Official product screenshot. Source: &lt;a href="https://exportalpha.com/#examples" rel="noopener noreferrer"&gt;exportalpha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1kqfhxc2dv6hv2rrg3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1kqfhxc2dv6hv2rrg3.png" alt="Guide: why a “transparent” export shows a black background" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Guide: black background is usually format or playback, not a failed job. Official product screenshot. Source: &lt;a href="https://exportalpha.com/guides/why-transparent-video-has-black-background" rel="noopener noreferrer"&gt;exportalpha.com/guides/why-transparent-video-has-black-background&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwxczhrt3trxbwonikd6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwxczhrt3trxbwonikd6.png" alt="Guide: which transparent format to export" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Decision table: WebM vs HEVC vs ProRes vs composited MP4. Official product screenshot. Source: &lt;a href="https://exportalpha.com/guides/what-transparent-video-format" rel="noopener noreferrer"&gt;exportalpha.com/guides/what-transparent-video-format&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl44xyl2uwg1008j0fmpt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl44xyl2uwg1008j0fmpt.png" alt="OBS overlay guide: Media Source, no chroma key" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;OBS: Media Source + VP9 WebM. Not a live camera plugin. Official product screenshot. Source: &lt;a href="https://exportalpha.com/guides/how-to-use-transparent-video-in-obs" rel="noopener noreferrer"&gt;exportalpha.com/guides/how-to-use-transparent-video-in-obs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing as it sits on the page today
&lt;/h2&gt;

&lt;p&gt;Credits: &lt;strong&gt;1 credit = 1 started second&lt;/strong&gt;. New accounts get &lt;strong&gt;15 seconds&lt;/strong&gt;. Plan credits reset monthly; pack credits never expire. Failed jobs refund credits. Successful exports do not. Checked on the live homepage, 25 August 2026.&lt;/p&gt;

&lt;p&gt;Monthly: Creator &lt;strong&gt;$19&lt;/strong&gt; (1,200 seconds), Pro &lt;strong&gt;$39&lt;/strong&gt; (3,000 seconds), Studio &lt;strong&gt;$79&lt;/strong&gt; (7,200 seconds). Annual is shown as &lt;strong&gt;$15.83 / $32.50 / $65.83&lt;/strong&gt; per month ($190 / $390 / $790 billed once). One-time packs: Quick &lt;strong&gt;$5.99&lt;/strong&gt; / 240s, Starter &lt;strong&gt;$9.99&lt;/strong&gt; / 480s, Plus &lt;strong&gt;$29.99&lt;/strong&gt; / 1,800s.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpplbzsm84vwxvhs2q8ww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpplbzsm84vwxvhs2q8ww.png" alt="ExportAlpha pricing on the homepage" width="800" height="694"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Homepage pricing. Official product screenshot. Source: &lt;a href="https://exportalpha.com/#pricing" rel="noopener noreferrer"&gt;exportalpha.com/#pricing&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;p&gt;Copied from the site’s own “We can’t” list: restore quality lost to heavy compression, act as a real-time live-stream plugin, bypass content safety, or process clips longer than 60 seconds. Adult content, minors, and non-consensual use are blocked. Results are kept &lt;strong&gt;7 days&lt;/strong&gt;, then deleted.&lt;/p&gt;

&lt;p&gt;If you are exporting a stream alert, a lower-third, or a talking-head overlay and you keep getting a black plate: the file probably never had alpha, or the player is lying to you. Export WebM or ProRes 4444, then test over a colored background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live tool:&lt;/strong&gt; &lt;a href="https://exportalpha.com/" rel="noopener noreferrer"&gt;https://exportalpha.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>video</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I shipped a sourced question hub for Mortal Shell II instead of another cloned wiki</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Sat, 22 Aug 2026 19:50:25 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-sourced-question-hub-for-mortal-shell-ii-instead-of-another-cloned-wiki-k97</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-sourced-question-hub-for-mortal-shell-ii-instead-of-another-cloned-wiki-k97</guid>
      <description>&lt;p&gt;Mortal Shell II released worldwide on 20 August 2026. The searches I kept seeing were not "give me a weapons dump." They were when did it release, what are the PC specs, which edition has Advanced Access, how many Shells are there, and which weapon names are actually official.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpd5a34uilt807j7vsnx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpd5a34uilt807j7vsnx.jpg" alt="Official Steam screenshot of Mortal Shell II combat" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Official Steam screenshot. © 2026, Playstack and Cold Symmetry, all rights reserved. Source: Steam store, app 2584270.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Official answers exist, but they are split across the Steam store, Playstack notes, mortalshell.com, and Steam achievement unlock titles. A cloned wiki that invents the missing numbers would rank for a week and then rot.&lt;/p&gt;

&lt;p&gt;So I shipped &lt;a href="https://mortalshellii.info/" rel="noopener noreferrer"&gt;mortalshellii.info&lt;/a&gt; as an independent question hub: one search question per page, official source URL next to every changing fact, gaps left visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfkojn2reno3mvpnmitn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfkojn2reno3mvpnmitn.png" alt="mortalshellii.info homepage" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;mortalshellii.info homepage. Independent question hub. Not affiliated with Cold Symmetry or Playstack.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I refused to invent
&lt;/h2&gt;

&lt;p&gt;Steam App &lt;a href="https://store.steampowered.com/app/2584270/Mortal_Shell_II/" rel="noopener noreferrer"&gt;2584270&lt;/a&gt; lists &lt;strong&gt;minimum&lt;/strong&gt; PC requirements only (as of the hub's 22 August 2026 check): Windows 10/11, Intel i7-10700K or AMD Ryzen 5 3600, 16 GB RAM, RTX 2060 SUPER or RX 6600, 70 GB SSD required. No recommended tier is published. The specs page does not invent one.&lt;/p&gt;

&lt;p&gt;Steam achievements officially name &lt;strong&gt;7 melee weapons&lt;/strong&gt; and &lt;strong&gt;6 sidearms&lt;/strong&gt; via individual "Unlock [weapon name]" achievements, plus "Lord of War" and "Guns. Lots of Guns." Community guides commonly list 8+8, including three names (The Iconoclast, Naylshotte, Troubadour's Lute) that are &lt;strong&gt;not&lt;/strong&gt; in Steam achievement unlock titles. The weapons page keeps those labeled Community / Pending instead of merging them into an invented official list.&lt;/p&gt;

&lt;p&gt;Total weapon/sidearm counts stay &lt;strong&gt;pending&lt;/strong&gt;. "Unlock all Weapons" and "Unlock all Sidearms" confirm the sets exist; they do not publish the totals.&lt;/p&gt;

&lt;p&gt;Acquisition routes and region labels from community guides stay Pending. The page does not turn GameRant routes into first-party map pins.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9r7fpjwppbktadrlq45.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9r7fpjwppbktadrlq45.jpg" alt="Official Steam screenshot of Mortal Shell II world / exploration" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Official Steam screenshot. © 2026, Playstack and Cold Symmetry, all rights reserved. Source: Steam store, app 2584270.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pages that match the search
&lt;/h2&gt;

&lt;p&gt;Each URL is a question a launch-week player actually types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://mortalshellii.info/release-date" rel="noopener noreferrer"&gt;When did Mortal Shell II release?&lt;/a&gt; — Worldwide &lt;strong&gt;20 August 2026&lt;/strong&gt; on Steam, PlayStation 5, and Xbox Series X|S (10:00 AM PDT / 17:00 UTC per the hub's timezone table). Devout Edition Advanced Access started approximately &lt;strong&gt;17 August 2026&lt;/strong&gt; (up to 3 days early). Sourced to Playstack and Steam.&lt;/li&gt;
&lt;li&gt;What are the system requirements? — Steam minimum only; no recommended tier invented.&lt;/li&gt;
&lt;li&gt;What editions are available? — Standard &lt;strong&gt;$49.99 / €49.99 / £39.99&lt;/strong&gt;; Devout &lt;strong&gt;$59.99 / €59.99 / £47.99&lt;/strong&gt; (Advanced Access + 8 Obsidian Shell skins). Physical PS5 Revered Edition noted as sold-out in most territories, matching official site copy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mortalshellii.info/weapons" rel="noopener noreferrer"&gt;Weapons &amp;amp; sidearms&lt;/a&gt; — Steam achievement names first; community names labeled; totals pending.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mortalshellii.info/guides" rel="noopener noreferrer"&gt;Guides hub&lt;/a&gt; — combat, Shells, bosses, progression, 53 Steam achievements, difficulty options — strategy tips labeled unofficial; factual claims tied to Steam / Playstack / mortalshell.com.&lt;/li&gt;
&lt;li&gt;Quick facts on the homepage also call out eight Shells, stamina-unrestricted combat, a compact interconnected open world, and 60+ dungeons — language that tracks Steam / official marketing rather than fan wiki inventiveness.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Steam English user reviews, as listed on the store page when this draft was prepared, read &lt;strong&gt;Very Positive&lt;/strong&gt; (83% of 3,195). That number lives on Steam. I do not treat it as a Metascore substitute.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3k190cwhb4h07d5xo5o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3k190cwhb4h07d5xo5o.jpg" alt="Official Steam screenshot of Mortal Shell II" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Official Steam screenshot. © 2026, Playstack and Cold Symmetry, all rights reserved. Source: Steam store, app 2584270.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a website and not a Notion dump
&lt;/h2&gt;

&lt;p&gt;Searchers land on a question. The page has to answer that question, cite the dated official note, and say what is still unknown. That is why the homepage is a question hub, not a franchise cover.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q05tpifhkem30w879ij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q05tpifhkem30w879ij.png" alt="Weapons page on mortalshellii.info" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Weapons page: Steam achievement names vs Community/Pending labels. Gaps left visible.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I did not invent recommended PC specs, a closed weapon count, or step-by-step dungeon unlock maps that Cold Symmetry and Playstack have not published as official lists.&lt;/p&gt;

&lt;p&gt;The site is not affiliated with Cold Symmetry or Playstack. Corrections get a source and a same-day edit.&lt;/p&gt;

&lt;p&gt;If you are building a launch-week game hub: start from the search question, keep official text dated, and leave the gaps visible. The empty cell is more honest than a confident guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live hub:&lt;/strong&gt; &lt;a href="https://mortalshellii.info/" rel="noopener noreferrer"&gt;https://mortalshellii.info/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>indiegame</category>
      <category>documentation</category>
    </item>
    <item>
      <title>I pinned Palworld 1.0 breeding data in the browser so the pair table is not a live API</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Wed, 19 Aug 2026 18:46:47 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-pinned-palworld-10-breeding-data-in-the-browser-so-the-pair-table-is-not-a-live-api-2anm</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-pinned-palworld-10-breeding-data-in-the-browser-so-the-pair-table-is-not-a-live-api-2anm</guid>
      <description>&lt;p&gt;The useful Palworld breeding sites already own the obvious search queries. I did not build Palbreed to outrank them.&lt;/p&gt;

&lt;p&gt;I built it because I wanted a &lt;strong&gt;local-first&lt;/strong&gt; 1.0 calculator: a pinned pair table, three tools that read the same snapshot, and no live breeding API in the critical path.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://palbreeding.org/calculator" rel="noopener noreferrer"&gt;Palbreed&lt;/a&gt;. Unofficial. Not affiliated with Pocketpair. Palworld and related assets belong to their rights holders.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "local-first" means here
&lt;/h2&gt;

&lt;p&gt;Palbreed is a Next.js app. After the page loads, the solver reads a bundled snapshot from &lt;code&gt;/data/palworld-1.0.json&lt;/code&gt; (&lt;code&gt;cache: "force-cache"&lt;/code&gt;). The client refuses to boot the graph unless all three checks pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;meta.palcalcRelease === "v1.17.6"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pals.length === 299&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;breeding.length === 44851&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The snapshot is labeled PalCalc &lt;strong&gt;database v26&lt;/strong&gt; on Palworld &lt;strong&gt;1.0&lt;/strong&gt;. The loading copy on the site is blunt: the local graph and portraits are prepared in the browser, and &lt;strong&gt;no network data source is required&lt;/strong&gt; for that graph.&lt;/p&gt;

&lt;p&gt;That is the product claim I am making. The pair table is not fetched from a wiki, a scrape, or a third-party breeding endpoint at query time. If a later patch moves a recipe, the badges have to move with the file. Until then, the calculator, reverse search, and path solver all read the same pin.&lt;/p&gt;

&lt;p&gt;Roster, settings, and saved plans stay in &lt;code&gt;localStorage&lt;/code&gt;. There is no account and no application database for that state.&lt;/p&gt;

&lt;p&gt;If you want a specific child, parent pair, or route, open the tool. I am not going to paste a combo table into this post. Hand-filled lists go stale the moment the snapshot changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 1: two parents, one child
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Page:&lt;/strong&gt; &lt;a href="https://palbreeding.org/calculator" rel="noopener noreferrer"&gt;https://palbreeding.org/calculator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The homepage 308s to &lt;code&gt;/calculator&lt;/code&gt;. The screen is a direct pair tool:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Choose two parents. Reveal their child.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It uses the verified 1.0 snapshot, including fixed combinations and gender-specific results. "Why this result?" on the child card says the answer is read from the bundled PalCalc 1.0 breeding table, which already applies fixed recipes, gender rules, exclusions, power ranking, and tie-breaking.&lt;/p&gt;

&lt;p&gt;I am not listing example pairs here. If you care about a specific outcome, go check the calculator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2jm2p2kf0a4au9adh7u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2jm2p2kf0a4au9adh7u.png" alt="Palbreed calculator" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 2: start from the child
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Page:&lt;/strong&gt; &lt;a href="https://palbreeding.org/reverse" rel="noopener noreferrer"&gt;https://palbreeding.org/reverse&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reverse Search flips the question. Pick a target Pal, then read every direct parent pair in the bundled snapshot.&lt;/p&gt;

&lt;p&gt;The page copy is: it shows direct Palworld 1.0 parent pairs from that snapshot. Filters on the live screen include owned pairs first, gender-specific recipes only, and both parents owned.&lt;/p&gt;

&lt;p&gt;Again: no handwritten list in this post. The ranking belongs on &lt;code&gt;/reverse&lt;/code&gt;, next to the same version badges.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnw8htgem25e8qe3s4e47.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnw8htgem25e8qe3s4e47.png" alt="Palbreed reverse search" width="800" height="1384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool 3: a route from what you already have
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Page:&lt;/strong&gt; &lt;a href="https://palbreeding.org/paths" rel="noopener noreferrer"&gt;https://palbreeding.org/paths&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Path Solver is the multigeneration view. It expands only from Pals marked owned in &lt;strong&gt;My Pals&lt;/strong&gt;, and falls back to the smallest direct capture set when needed.&lt;/p&gt;

&lt;p&gt;The route graph comes from the bundled 1.0 pair table. Egg counts on that screen are &lt;strong&gt;planning estimates&lt;/strong&gt;, not simulated probabilities. The disclaimer page says the same thing: generation counts come from the graph; egg and time labels are estimates; later patches, mods, or extraction errors can disagree with the snapshot.&lt;/p&gt;

&lt;p&gt;Save a route as a plan if you want. Plans stay on the device.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c8dnmjrn5wlazgijo43.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c8dnmjrn5wlazgijo43.png" alt="Palbreed path solver" width="800" height="866"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I refused to ship in this post
&lt;/h2&gt;

&lt;p&gt;A combo dump. A "best Anubis line" list. A promise that Palbreed will rank for &lt;code&gt;Palworld breeding calculator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those SERP slots are already held by large databases. This post is a build note and a link to a pinned tool, not a content-site play.&lt;/p&gt;

&lt;p&gt;The Data Status screen on the site also records a boundary I kept: 299 PalCalc breeding entities drive the solver. One extra Paldeck-only boss entry is bundled for roster completeness and never enters breeding results.&lt;/p&gt;

&lt;p&gt;If a number in this post disagrees with the live badges, trust the badges and the calculator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Calculator: &lt;a href="https://palbreeding.org/calculator" rel="noopener noreferrer"&gt;https://palbreeding.org/calculator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reverse Search: &lt;a href="https://palbreeding.org/reverse" rel="noopener noreferrer"&gt;https://palbreeding.org/reverse&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Path Solver: &lt;a href="https://palbreeding.org/paths" rel="noopener noreferrer"&gt;https://palbreeding.org/paths&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unofficial fan tool. Check the result in-game before you spend Cake.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I shipped a sourced question hub for The Sinking City 2 instead of another cloned wiki</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:14:31 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-sourced-question-hub-for-the-sinking-city-2-instead-of-another-cloned-wiki-5fg3</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-shipped-a-sourced-question-hub-for-the-sinking-city-2-instead-of-another-cloned-wiki-5fg3</guid>
      <description>&lt;p&gt;The Sinking City 2 launched on 18 August 2026. The searches I kept seeing were not "give me a weapons dump." They were when does it release, what are the PC specs, which edition has Holloway Manor, is there a demo, and do I need the first game.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjwbnelb21t2nb898yau.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjwbnelb21t2nb898yau.jpg" alt="Flooded 1920s street from The Sinking City 2" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. © 2019–2026 Frogwares Ireland Ltd. Source: Steam store, app 2825860.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Official answers exist, but they are split across the &lt;a href="https://store.steampowered.com/app/2825860/The_Sinking_City_2/" rel="noopener noreferrer"&gt;Steam store&lt;/a&gt;, dated community announcements, and Frogwares press notes. A cloned wiki that invents the missing numbers would rank for a week and then rot.&lt;/p&gt;

&lt;p&gt;So I shipped &lt;a href="https://thesinkingcity2.net/" rel="noopener noreferrer"&gt;thesinkingcity2.net&lt;/a&gt; as an independent question hub: one search question per page, official source URL next to every changing fact, gaps left visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm01kjlvi1jre5e6eibz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm01kjlvi1jre5e6eibz2.png" alt="thesinkingcity2.net homepage" width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;thesinkingcity2.net homepage. Independent question hub. Not affiliated with Frogwares.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I refused to invent
&lt;/h2&gt;

&lt;p&gt;Steam App &lt;strong&gt;2825860&lt;/strong&gt; lists &lt;strong&gt;single-player&lt;/strong&gt; only. I do not quote "no crossplay" as an official line. Frogwares has not published a crossplay feature, so the page says that.&lt;/p&gt;

&lt;p&gt;Frogwares has &lt;strong&gt;not&lt;/strong&gt; said whether you need The Sinking City 1. The FAQ does not invent a "must play" or "skip it" answer.&lt;/p&gt;

&lt;p&gt;Steam Recommended names a &lt;strong&gt;Medium&lt;/strong&gt; preset. The 10 July 2026 press copy says &lt;strong&gt;Balanced&lt;/strong&gt;. The specs page shows both and dates them. I do not pick a winner.&lt;/p&gt;

&lt;p&gt;Playtime is not a first-party number. Official channels do not publish campaign length. If a reviews page cites named outlets, it is labeled as reception, not as Frogwares copy.&lt;/p&gt;

&lt;p&gt;Mac, Linux, and Switch are &lt;strong&gt;not listed&lt;/strong&gt; on the current Steam and press pages. That is not the same as "confirmed never."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawb9y05xoow7cxly4oeu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawb9y05xoow7cxly4oeu.jpg" alt="Arkham interior from The Sinking City 2" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. © 2019–2026 Frogwares Ireland Ltd. Source: Steam store, app 2825860.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pages that match the search
&lt;/h2&gt;

&lt;p&gt;Each URL is a question a launch-week player actually types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/release-date/" rel="noopener noreferrer"&gt;When does it release?&lt;/a&gt; — 18 August 2026 on PC, PlayStation 5, and Xbox Series X|S. Delay history only as Frogwares announced it. Physical copies: no official announcement.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/system-requirements/" rel="noopener noreferrer"&gt;What are the system requirements?&lt;/a&gt; — Steam min/rec, including 16 GB RAM, GTX 1070 / RX 5700, 70 GB SSD.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/editions/" rel="noopener noreferrer"&gt;Which editions?&lt;/a&gt; — Standard $49.99, Deluxe $54.99, Premium $59.99 on Steam, plus the named DLC and the Chthonic Arsenal pre-order items as edition deltas, not a weapons table.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/demo/" rel="noopener noreferrer"&gt;Is there a demo?&lt;/a&gt; — a Next Fest demo shipped in June. After launch I point at the live Steam page instead of declaring it forever available.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/news/" rel="noopener noreferrer"&gt;Official news&lt;/a&gt; — Frogwares Steam announcements only, dated.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thesinkingcity2.net/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; and &lt;a href="https://thesinkingcity2.net/sources/" rel="noopener noreferrer"&gt;sources&lt;/a&gt; — the editorial rules are public.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Steam current user reviews, as of 19 August 2026, read &lt;strong&gt;Very Positive (92% of 339)&lt;/strong&gt;. That number lives on Steam. I do not treat it as a Metascore substitute.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sirgf1fbx9r9106kvfe.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sirgf1fbx9r9106kvfe.jpg" alt="Night street from The Sinking City 2" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. © 2019–2026 Frogwares Ireland Ltd. Source: Steam store, app 2825860.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a website and not a Notion dump
&lt;/h2&gt;

&lt;p&gt;Searchers land on a question. The page has to answer that question, cite the dated official note, and say what is still unknown. That is why the homepage is a question hub, not a franchise cover.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxvzisenpq272erx5m35.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxvzisenpq272erx5m35.png" alt="Release-date page on thesinkingcity2.net" width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Release-date page: August 18, 2026, sourced to Steam. Mac/Linux/Switch left as “not listed.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I did not add a weapons wiki, a maps directory, or a settings optimizer. Those pages would need numbers Frogwares has not published.&lt;/p&gt;

&lt;p&gt;The site is not affiliated with Frogwares. Contact is &lt;a href="mailto:support@thesinkingcity2.net"&gt;support@thesinkingcity2.net&lt;/a&gt;. Corrections get a source and a same-day edit.&lt;/p&gt;

&lt;p&gt;If you are building a launch-week game hub: start from the search question, keep official text dated, and leave the gaps visible. The empty cell is more honest than a confident guess.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>I built a source-attributed hub for Hell Let Loose: Vietnam instead of another cloned wiki</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:24:34 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-built-a-source-attributed-hub-for-hell-let-loose-vietnam-instead-of-another-cloned-wiki-30ja</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-built-a-source-attributed-hub-for-hell-let-loose-vietnam-instead-of-another-cloned-wiki-30ja</guid>
      <description>&lt;p&gt;Hell Let Loose: Vietnam launched on 13 August 2026. In the first week, the searches I kept seeing were not "give me a weapon dump." They were how do I join a server, why did my AMD GPU crash, why does proximity chat sound robotic, and is Hotfix 1 actually out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg3thf5e0d0h20un5gu1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg3thf5e0d0h20un5gu1.jpg" alt="US squad moving through jungle cover in Hell Let Loose: Vietnam" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. Team17 / Expression Games.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Official answers exist, but they are split across Steam announcements, a living Team17 comms thread, and a performance blog. A cloned wiki that invents the missing numbers would rank for a week and then rot.&lt;/p&gt;

&lt;p&gt;So I shipped &lt;a href="https://hellletloosevietnam.org/" rel="noopener noreferrer"&gt;Hell Let Loose Vietnam&lt;/a&gt; as an independent hub: official news in, dated claims, gameplay pages separated from troubleshooting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I refused to invent
&lt;/h2&gt;

&lt;p&gt;Steam App 3079210 lists &lt;strong&gt;17 roles&lt;/strong&gt;. The marketing page says &lt;strong&gt;19 specialised roles&lt;/strong&gt;. The site keeps the explicit 17-role directory and flags the mismatch instead of filling two mystery slots.&lt;/p&gt;

&lt;p&gt;First-party material names &lt;strong&gt;six launch maps&lt;/strong&gt;. Only &lt;strong&gt;four modes&lt;/strong&gt; currently have detailed published rules (Warfare, Offensive, Conquest, Domination). Undocumented mode names stay off the page.&lt;/p&gt;

&lt;p&gt;Steam current-player counts are &lt;strong&gt;Steam only&lt;/strong&gt;. They are never treated as a cross-platform population.&lt;/p&gt;

&lt;p&gt;Vietnam PC requirements come from that same Steam app, not the franchise site page that still looks like the original Hell Let Loose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy9m8ro0mcuw6t4k0wbka.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy9m8ro0mcuw6t4k0wbka.jpg" alt="First-person sprint down a jungle path" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. The site does not invent undocumented weapon stats to match a pretty picture.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch issues get their own library
&lt;/h2&gt;

&lt;p&gt;Gameplay stays on maps, roles, vehicles and guides. Launch defects live under &lt;a href="https://hellletloosevietnam.org/fixes/" rel="noopener noreferrer"&gt;Fixes &amp;amp; Troubleshooting&lt;/a&gt;, each with a status label: acknowledged, monitoring, still investigating, or confirmed in Hotfix 1.&lt;/p&gt;

&lt;p&gt;Hotfix 1 went live on 16 August. The pages I keep pointing people to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://hellletloosevietnam.org/fixes/cannot-join-server-vip-message/" rel="noopener noreferrer"&gt;Cannot join a server / incorrect VIP message&lt;/a&gt; — browser listings went stale at launch; Hotfix 1 refreshes more often and hides full servers by default&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hellletloosevietnam.org/fixes/amd-gpu-crashes/" rel="noopener noreferrer"&gt;AMD GPU crashes&lt;/a&gt; — official DX11 / DX12 launch-option sets, plus "do not combine them"&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hellletloosevietnam.org/fixes/distorted-robotic-voice-chat/" rel="noopener noreferrer"&gt;Robotic proximity voice&lt;/a&gt; — still investigating; I do not pretend a community toggle is a fix&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hellletloosevietnam.org/fixes/ps5-audio-cutting-out/" rel="noopener noreferrer"&gt;PS5 audio cutting out&lt;/a&gt; — named dropout is marked fixed in Hotfix 1; later cut-outs are a new report&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hellletloosevietnam.org/updates/hotfix-1-patch-notes/" rel="noopener noreferrer"&gt;Hotfix 1 notes, player-facing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a workaround is official and temporary, the page says so. If a number is not in a first-party source, it is not on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a website and not a Notion dump
&lt;/h2&gt;

&lt;p&gt;Searchers land on a question. The page has to answer that question, cite the dated official note, and tell them what is still unknown. That is why &lt;a href="https://hellletloosevietnam.org/guides/role-selection/" rel="noopener noreferrer"&gt;role selection&lt;/a&gt;, &lt;a href="https://hellletloosevietnam.org/guides/us-vs-nva/" rel="noopener noreferrer"&gt;US vs NVA&lt;/a&gt;, and &lt;a href="https://hellletloosevietnam.org/guides/map-selection/" rel="noopener noreferrer"&gt;map selection&lt;/a&gt; are decision guides, not lore essays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foflnu43mjpqsvspct8qt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foflnu43mjpqsvspct8qt.jpg" alt="Sniper overwatch from a bamboo structure" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Official Steam screenshot. Role pages stay on documented responsibilities, not cinematic loadouts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The full editorial rules are public: &lt;a href="https://hellletloosevietnam.org/editorial-policy/" rel="noopener noreferrer"&gt;editorial policy&lt;/a&gt; and &lt;a href="https://hellletloosevietnam.org/sources-and-corrections/" rel="noopener noreferrer"&gt;sources &amp;amp; corrections&lt;/a&gt;. Contact is &lt;a href="mailto:support@hellletloosevietnam.org"&gt;support@hellletloosevietnam.org&lt;/a&gt;. The site is not affiliated with Team17 or Expression Games.&lt;/p&gt;

&lt;p&gt;If you are building a launch-week game hub: start from the search question, keep official text dated, and leave the gaps visible. The empty cell is more honest than a confident guess.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>I Built an AI Tool to Reconstruct Matcha-Filtered Images—Here’s What I Learned</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:50:37 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-built-an-ai-tool-to-reconstruct-matcha-filtered-images-heres-what-i-learned-5ad7</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-built-an-ai-tool-to-reconstruct-matcha-filtered-images-heres-what-i-learned-5ad7</guid>
      <description>&lt;p&gt;A heavily stylized “matcha” filter can do more than shift colors toward green. It can flatten skin tones, posterize shadows, exaggerate edges, replace texture, and destroy small details.&lt;/p&gt;

&lt;p&gt;That creates an interesting product question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can generative AI turn the remaining visual structure into a natural-looking image again?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I recently built &lt;a href="https://matchafilterremover.online" rel="noopener noreferrer"&gt;Matcha Filter Remover&lt;/a&gt;, a small AI tool that explores exactly that problem. This post is a practical breakdown of what worked, what did not, and the engineering decisions that mattered most.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: reconstruction is not recovery
&lt;/h2&gt;

&lt;p&gt;This was the most important product decision.&lt;/p&gt;

&lt;p&gt;When a destructive filter overwrites pixels, those original pixels are gone. No model can reliably recover the exact source photo from information that no longer exists.&lt;/p&gt;

&lt;p&gt;The tool therefore does &lt;strong&gt;AI reconstruction&lt;/strong&gt;, not original-photo recovery.&lt;/p&gt;

&lt;p&gt;That distinction appears throughout the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The output is described as a new AI-generated interpretation.&lt;/li&gt;
&lt;li&gt;Exact identity, text, logos, hands, jewelry, and background details are not guaranteed.&lt;/li&gt;
&lt;li&gt;The before/after interface keeps the filtered input visible.&lt;/li&gt;
&lt;li&gt;The product never claims forensic restoration or access to hidden content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not only safer wording. It also sets an honest user expectation for a probabilistic system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model comparison surprised me
&lt;/h2&gt;

&lt;p&gt;I tested multiple image-editing models with different kinds of matcha-filtered inputs.&lt;/p&gt;

&lt;p&gt;The first test image was a portrait with a strong yellow-green embossed effect. The second was a tiny, low-resolution café scene with severe edge enhancement and very little reliable color information.&lt;/p&gt;

&lt;p&gt;Two models stood out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seedream 5 Pro&lt;/strong&gt; produced cleaner color reconstruction and stronger overall photographic coherence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nano Banana Pro&lt;/strong&gt; was useful as a fallback, especially when preserving composition, but sometimes left more of the original green cast in the image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For this specific task, Seedream became the primary model and Nano Banana the fallback.&lt;/p&gt;

&lt;p&gt;The key lesson was that “best image model” is too broad a question. A model can be excellent at creative editing but weak at removing a particular destructive visual style. The evaluation set has to resemble the actual user input.&lt;/p&gt;

&lt;p&gt;I also stopped relying on an aggressive pre-classification step. In practice, matcha-filter detection produced too many uncertain or incorrect decisions. A false negative is especially frustrating because the user already knows why they uploaded the image.&lt;/p&gt;

&lt;p&gt;The current workflow instead uses a fixed, conservative reconstruction prompt and lets the image editor handle the full scene.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt design: constrain the invention
&lt;/h2&gt;

&lt;p&gt;A useful prompt for this workflow needs two opposing properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the strong artificial filter.&lt;/li&gt;
&lt;li&gt;Avoid redesigning the entire image.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prompt emphasizes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;natural photographic color and lighting;&lt;/li&gt;
&lt;li&gt;preservation of pose, framing, subject placement, and scene structure;&lt;/li&gt;
&lt;li&gt;removal of olive/yellow-green tint, embossed edges, posterization, and false texture;&lt;/li&gt;
&lt;li&gt;realistic skin, fabric, objects, and background;&lt;/li&gt;
&lt;li&gt;no beautification, identity substitution, or unrelated additions;&lt;/li&gt;
&lt;li&gt;no claim that the result is the original photo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This does not eliminate hallucination, but it gives the model a much narrower job than “make this look good.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The production architecture
&lt;/h2&gt;

&lt;p&gt;The app is built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16 and React 19&lt;/strong&gt; for the application;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for the runtime;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare D1&lt;/strong&gt; for users, credits, jobs, and payment records;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt; for temporary uploads and generated results;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Queues&lt;/strong&gt; for asynchronous reconstruction jobs;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fal.ai&lt;/strong&gt; for image-generation models;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google OAuth&lt;/strong&gt; for account access;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe Checkout&lt;/strong&gt; for one-time credit purchases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified request flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser upload
  -&amp;gt; authenticated API route
  -&amp;gt; temporary R2 object
  -&amp;gt; D1 job + credit reservation
  -&amp;gt; Cloudflare Queue
  -&amp;gt; primary image model
  -&amp;gt; output safety check
  -&amp;gt; temporary R2 result
  -&amp;gt; authenticated result page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uploads and results are scheduled for deletion within 24 hours. The credit balance itself does not expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payments were more than adding a button
&lt;/h2&gt;

&lt;p&gt;Stripe Checkout made card collection straightforward, but reliable fulfillment still required server-side work.&lt;/p&gt;

&lt;p&gt;The live payment flow validates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Checkout Session belongs to the signed-in user;&lt;/li&gt;
&lt;li&gt;the session is in live mode;&lt;/li&gt;
&lt;li&gt;payment status is paid;&lt;/li&gt;
&lt;li&gt;currency and amount match the selected pack;&lt;/li&gt;
&lt;li&gt;the returned Stripe Price ID matches the server configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Credits are granted through an idempotent ledger entry. If Stripe retries the webhook—or the success page and webhook both request fulfillment—the same Checkout Session cannot grant credits twice.&lt;/p&gt;

&lt;p&gt;The site sells one-time packs rather than subscriptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8 credits for $4.99&lt;/li&gt;
&lt;li&gt;20 credits for $9.99&lt;/li&gt;
&lt;li&gt;45 credits for $19.99&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Purchased credits remain available until used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety boundaries belong in the architecture
&lt;/h2&gt;

&lt;p&gt;An image reconstruction tool can be misunderstood if its limits are vague.&lt;/p&gt;

&lt;p&gt;The product explicitly prohibits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;explicit content;&lt;/li&gt;
&lt;li&gt;content involving minors;&lt;/li&gt;
&lt;li&gt;non-consensual imagery;&lt;/li&gt;
&lt;li&gt;clothing removal or “see-through” behavior;&lt;/li&gt;
&lt;li&gt;private-content reveal claims;&lt;/li&gt;
&lt;li&gt;attempts to bypass safety controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generated results pass an output safety check before being retained and displayed. A blocked or failed job returns the reserved reconstruction credit through an idempotent refund entry.&lt;/p&gt;

&lt;p&gt;The UI also requires the user to confirm that they are 18+ and own the image or have permission to use it.&lt;/p&gt;

&lt;p&gt;These decisions are not a policy-page afterthought. They affect job state, storage, credit accounting, result delivery, and copy across the entire product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Build the evaluation set first
&lt;/h3&gt;

&lt;p&gt;I initially spent too much time tuning around individual examples. A small benchmark containing portraits, objects, indoor scenes, tiny images, text-heavy images, and multiple filter strengths would have made model selection faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Treat copy as part of system design
&lt;/h3&gt;

&lt;p&gt;Phrases such as “remove,” “restore,” and “recover” can imply very different capabilities. The final product repeatedly uses “AI reconstruction” because the system generates plausible pixels rather than retrieves missing ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Test mobile checkout early
&lt;/h3&gt;

&lt;p&gt;One responsive CSS rule accidentally hid all purchase buttons on mobile. Desktop tests passed; mobile end-to-end tests caught it before launch.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Make every credit mutation idempotent
&lt;/h3&gt;

&lt;p&gt;Generation failures, safety blocks, webhook retries, and payment redirects can all repeat. A ledger with unique idempotency keys is much safer than directly incrementing and decrementing a balance in scattered code paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Keep temporary media and permanent entitlements separate
&lt;/h3&gt;

&lt;p&gt;Images expire after 24 hours for privacy. Purchased credits do not expire. Mixing those concepts would create confusing product behavior and complicated data retention rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The project is now live at &lt;a href="https://matchafilterremover.online" rel="noopener noreferrer"&gt;matchafilterremover.online&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is still an experiment in a narrow visual problem, and the output will not be perfect. But that constraint is exactly what made the build interesting: model quality, honest product language, privacy, safety, async processing, and payment integrity all had to work together.&lt;/p&gt;

&lt;p&gt;If you are building a small AI image product, my biggest recommendation is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Evaluate on the real failure cases, and design the product around what the model cannot know.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I would love to hear how other developers evaluate image-editing models or design reliable credit-based AI workflows.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>I Built a Privacy-First QR Code Generator for Print-Ready Batch Workflows</title>
      <dc:creator>王龙</dc:creator>
      <pubDate>Tue, 30 Jun 2026 16:29:07 +0000</pubDate>
      <link>https://dev.to/_df88eb1337eb23b75c94ba/i-built-a-privacy-first-qr-code-generator-for-print-ready-batch-workflows-4pp</link>
      <guid>https://dev.to/_df88eb1337eb23b75c94ba/i-built-a-privacy-first-qr-code-generator-for-print-ready-batch-workflows-4pp</guid>
      <description>&lt;p&gt;I recently built QRForge, a QR code generator focused less on tracking dashboards and more on print-ready workflows.&lt;/p&gt;

&lt;p&gt;Most QR tools I found fell into two buckets: simple one-off generators, or dynamic QR platforms that route scans through their own tracking URLs. I wanted something more boring but useful: direct static QR codes, CSV batch generation, clear previews, and export files that are ready for labels, badges, packaging, or menus.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;QRForge currently supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static URL QR generation without signup&lt;/li&gt;
&lt;li&gt;CSV upload for batch QR projects&lt;/li&gt;
&lt;li&gt;First-row previews before export&lt;/li&gt;
&lt;li&gt;Named PNG and SVG outputs&lt;/li&gt;
&lt;li&gt;Manifest and error report files&lt;/li&gt;
&lt;li&gt;PDF project summary&lt;/li&gt;
&lt;li&gt;ZIP delivery for print workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main idea is that a user should know what they are about to print before they generate hundreds of QR codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why static QR codes?
&lt;/h2&gt;

&lt;p&gt;Dynamic QR codes are useful, but they also introduce a dependency: the QR provider has to keep redirecting scans forever.&lt;/p&gt;

&lt;p&gt;For printed materials, that tradeoff matters. A static QR code points directly to the destination URL. Once printed, it does not depend on my app staying online.&lt;/p&gt;

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

&lt;p&gt;The project is built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js&lt;/li&gt;
&lt;li&gt;React&lt;/li&gt;
&lt;li&gt;Cloudflare Workers&lt;/li&gt;
&lt;li&gt;Stripe Checkout for one-time batch exports&lt;/li&gt;
&lt;li&gt;Server-side ZIP generation for print packs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I am still figuring out
&lt;/h2&gt;

&lt;p&gt;The biggest question is positioning.&lt;/p&gt;

&lt;p&gt;I am testing whether “Verified Print Pack” clearly communicates the value: preview the CSV, catch obvious problems, then export a print-ready bundle.&lt;/p&gt;

&lt;p&gt;I’d love feedback from builders, designers, or anyone who has had to generate QR codes for real-world print materials.&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://qrcodegenerator.page" rel="noopener noreferrer"&gt;https://qrcodegenerator.page&lt;/a&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>saas</category>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
