<?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: Kazutaka Sugiyama</title>
    <description>The latest articles on DEV Community by Kazutaka Sugiyama (@kazutaka-dev).</description>
    <link>https://dev.to/kazutaka-dev</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3793477%2F14e12e52-e930-4e5f-a70f-ed68eb7eaa6b.png</url>
      <title>DEV Community: Kazutaka Sugiyama</title>
      <link>https://dev.to/kazutaka-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kazutaka-dev"/>
    <language>en</language>
    <item>
      <title>My First Paying Customer Hit a Bug 14 Times in a Row — Here's What I Found</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Fri, 27 Mar 2026 09:14:35 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/my-first-paying-customer-hit-a-bug-14-times-in-a-row-heres-what-i-found-2mg9</link>
      <guid>https://dev.to/kazutaka-dev/my-first-paying-customer-hit-a-bug-14-times-in-a-row-heres-what-i-found-2mg9</guid>
      <description>&lt;p&gt;You know what's worse than having zero customers?&lt;/p&gt;

&lt;p&gt;Having your &lt;em&gt;first&lt;/em&gt; customer — someone who just paid you real money — fail to use your product 14 times in a row while you sleep through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Context: 5 Weeks of Silence
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://repoclip.io" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt;, a SaaS that auto-generates promotional videos from GitHub repositories. I launched on February 19, 2026. For five weeks, the revenue dashboard showed exactly $0.00.&lt;/p&gt;

&lt;p&gt;Then on March 26, everything changed at once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://futuretools.io" rel="noopener noreferrer"&gt;FutureTools&lt;/a&gt; featured us in their newsletter. Traffic exploded — 163 real users in a single day (vs. our usual ~10). I'd also just shipped a Credit Pack feature: a one-time $5 purchase for 40 credits, aimed at users who want to try video mode without committing to a subscription.&lt;/p&gt;

&lt;p&gt;By 9:50 AM UTC, I had my first sale. Nenad from Belgrade bought a Credit Pack. Then at 10:43, he bought another one.&lt;/p&gt;

&lt;p&gt;$10 in revenue. My first paying customer.&lt;/p&gt;

&lt;p&gt;I found out the next morning that he'd spent the next 2.5 hours trying to generate a video — and failing every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom: 14 Consecutive Failures
&lt;/h2&gt;

&lt;p&gt;Digging into the data, Nenad's journey looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09:50&lt;/td&gt;
&lt;td&gt;Credit Pack #1 ($5)&lt;/td&gt;
&lt;td&gt;+40 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:43&lt;/td&gt;
&lt;td&gt;Credit Pack #2 ($5)&lt;/td&gt;
&lt;td&gt;+40 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:51&lt;/td&gt;
&lt;td&gt;Image mode generation&lt;/td&gt;
&lt;td&gt;Success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~11:00&lt;/td&gt;
&lt;td&gt;Video Short attempt #1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Failed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~11:03&lt;/td&gt;
&lt;td&gt;Video Short attempt #2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Failed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~13:30&lt;/td&gt;
&lt;td&gt;Video Short attempt #14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Failed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single Video Short generation failed. Image mode worked fine. He kept retrying for 2.5 hours before giving up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The Silent Upload Failure
&lt;/h2&gt;

&lt;p&gt;I opened the Inngest dashboard and filtered for failed runs. 18 failures stared back at me — all Video Short generations, all from March 26.&lt;/p&gt;

&lt;p&gt;The error message: &lt;strong&gt;"All 3 video clips failed to prefetch from CDN — rendering would likely fail"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But here's what the execution trace showed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline Step&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fetch-github-code&lt;/td&gt;
&lt;td&gt;~29s&lt;/td&gt;
&lt;td&gt;Success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;analyze-code (Gemini)&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;td&gt;Success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generate-narrations (TTS)&lt;/td&gt;
&lt;td&gt;~4s&lt;/td&gt;
&lt;td&gt;Success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;generate-videos-ltx&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Success&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;prefetch-videos&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Failed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The video clips were &lt;em&gt;generated successfully&lt;/em&gt; by LTX-2.3. The failure was in the prefetch step — where we download the clips from fal.ai's CDN and re-upload them to Supabase Storage to avoid rendering timeouts.&lt;/p&gt;

&lt;p&gt;The code looked reasonable at first glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storagePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Upload failed for &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="nx"&gt;sceneId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Return original CDN URL&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the bug? When the Supabase upload fails, it logs a warning and returns immediately — &lt;strong&gt;no retry&lt;/strong&gt;. Compare this to the CDN download failure path just above it, which properly retries with backoff.&lt;/p&gt;

&lt;p&gt;And when &lt;em&gt;all&lt;/em&gt; clips fail to upload, the parent function throws:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&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;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`All &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; video clips failed to prefetch from CDN — rendering would likely fail`&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;So: CDN download succeeds, Supabase upload fails silently (no retry), error count hits 100%, and the entire pipeline throws. Every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Add retry logic to the upload path, and fall back to CDN URLs instead of throwing when all prefetch attempts fail. The videos exist — let Remotion try to render with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: The Phantom Pro Subscription
&lt;/h2&gt;

&lt;p&gt;While investigating, I noticed something strange in GA4. A &lt;code&gt;purchase_complete&lt;/code&gt; event showed &lt;code&gt;plan: "pro", billing_cycle: "annual"&lt;/code&gt;. But Lemon Squeezy recorded only three Credit Pack purchases — no Pro subscription at all.&lt;/p&gt;

&lt;p&gt;Nenad's journey explained it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;He visited the &lt;code&gt;/pricing&lt;/code&gt; page and toggled to annual billing while viewing the Pro plan&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PricingButton&lt;/code&gt; component called &lt;code&gt;window.LemonSqueezy.Setup()&lt;/code&gt; — registering a global event handler with &lt;code&gt;plan: "pro"&lt;/code&gt; baked into its closure&lt;/li&gt;
&lt;li&gt;He navigated to &lt;code&gt;/dashboard/new&lt;/code&gt; where &lt;code&gt;BuyCreditsButton&lt;/code&gt; registered &lt;em&gt;its own&lt;/em&gt; handler&lt;/li&gt;
&lt;li&gt;When his Credit Pack checkout succeeded, the &lt;strong&gt;old handler from the pricing page was still alive&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Both handlers fired — the stale one logged a phantom Pro Annual purchase&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Lemon Squeezy SDK keeps event handlers on a global &lt;code&gt;window&lt;/code&gt; object. Next.js client-side navigation doesn't reset it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The fix: track mount state, ignore events after unmount&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mountedRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mountedRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="c1"&gt;// In the event handler:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mountedRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug #3: The Content Filter Lottery
&lt;/h2&gt;

&lt;p&gt;After deploying the prefetch fix, I tested Video Short myself with a different repo. It failed again — but with a new error: &lt;strong&gt;"Unprocessable Entity"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This time the prefetch step was never reached. LTX-2.3 itself was rejecting the video prompts with HTTP 422.&lt;/p&gt;

&lt;p&gt;I tested all 5 scene prompts individually:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;Original Prompt&lt;/th&gt;
&lt;th&gt;Sanitized&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;intro&lt;/td&gt;
&lt;td&gt;Camera pans over a desk...&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ai_learning&lt;/td&gt;
&lt;td&gt;Virtual AI assistant chatbot...&lt;/td&gt;
&lt;td&gt;Stripped special chars&lt;/td&gt;
&lt;td&gt;OK after sanitize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;targeted_study&lt;/td&gt;
&lt;td&gt;Dashboard with 'weakness' section...&lt;/td&gt;
&lt;td&gt;Stripped quotes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Still failed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flexible_review&lt;/td&gt;
&lt;td&gt;Hand tapping through flashcards...&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;call_to_action&lt;/td&gt;
&lt;td&gt;Hands holding smartphones...&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One scene out of five was rejected, but because all scenes run inside &lt;code&gt;Promise.all()&lt;/code&gt;, one rejection kills the entire batch.&lt;/p&gt;

&lt;p&gt;The existing sanitizer removed brackets and code-like characters, but missed single quotes. And even after stripping those, the word combination kept triggering LTX's content filter. The filter rules are opaque — "legal news" fails but "legal documents" passes. It's not deterministic.&lt;/p&gt;

&lt;p&gt;The fix was a 3-stage fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;originalPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// Stage 1: Try as-is&lt;/span&gt;
  &lt;span class="nf"&gt;sanitizeVideoPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalPrompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// Stage 2: Strip risky chars&lt;/span&gt;
  &lt;span class="nx"&gt;FALLBACK_PROMPTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_PROMPTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// Stage 3: Safe generic prompt&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage 3 uses pre-tested abstract motion graphics prompts that always pass the content filter. The video won't match the scene's narration perfectly, but the pipeline won't crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Cost
&lt;/h2&gt;

&lt;p&gt;Nenad spent $10 and 2.5 hours on a product that didn't work. His credit balance wasn't affected (credits are only deducted on success), but his &lt;em&gt;time&lt;/em&gt; and &lt;em&gt;trust&lt;/em&gt; were gone.&lt;/p&gt;

&lt;p&gt;I sent him a personal apology email, added 40 bonus credits to his account, and explained what went wrong and that it was fixed. The technical honesty matters — he's a developer, he'll appreciate knowing it was a real bug, not handwaving.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Retry every I/O operation, not just the ones you expect to fail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had retries on CDN downloads (expected to be flaky) but not on Supabase uploads (expected to be reliable). The upload was the one that broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;throw&lt;/code&gt; in a data pipeline is a nuclear option.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Throwing when &lt;em&gt;all&lt;/em&gt; prefetches fail seemed conservative. In practice, it meant "if storage has a hiccup, destroy the entire 2-minute pipeline run." A warning + fallback would have let the video render successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Global event handlers leak across SPA navigation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Third-party SDKs (Lemon Squeezy, Stripe, etc.) that register on &lt;code&gt;window&lt;/code&gt; don't clean up on React unmount. Always guard with a mounted ref.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. AI content filters are a black box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can't enumerate what LTX-2.3 will reject. The only reliable strategy is fallback — try progressively safer prompts until one passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Your first customer is watching closer than anyone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;163 people visited that day. Most bounced. Nenad was the one who &lt;em&gt;believed enough to pay&lt;/em&gt;. Of all the users to hit a bug, it was the one whose experience mattered most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mar 26, 09:50 UTC&lt;/td&gt;
&lt;td&gt;First Credit Pack sale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 26, 10:43&lt;/td&gt;
&lt;td&gt;Second Credit Pack sale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 26, 11:00–13:30&lt;/td&gt;
&lt;td&gt;14 failed Video Short attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 27, 07:00&lt;/td&gt;
&lt;td&gt;Investigation begins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 27, 07:30&lt;/td&gt;
&lt;td&gt;Root cause identified (prefetch + GA4 + content filter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 27, 08:00&lt;/td&gt;
&lt;td&gt;Fixes deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 27, 08:08&lt;/td&gt;
&lt;td&gt;First successful Video Short post-fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 27, 08:30&lt;/td&gt;
&lt;td&gt;Apology + 40 bonus credits sent to Nenad&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;If you're building a SaaS with AI pipelines, I hope this saves you from the same mistakes. Every I/O boundary needs a retry. Every &lt;code&gt;throw&lt;/code&gt; needs a fallback. And every first customer deserves an apology when things break.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://repoclip.io" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt; — AI-powered videos from your GitHub repos.&lt;/p&gt;

</description>
      <category>sass</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>My Safety Check Killed 100% of Video Generations — Right When Traffic Spiked 3x</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Fri, 20 Mar 2026 15:44:50 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/my-safety-check-killed-100-of-video-generations-right-when-traffic-spiked-3x-4eho</link>
      <guid>https://dev.to/kazutaka-dev/my-safety-check-killed-100-of-video-generations-right-when-traffic-spiked-3x-4eho</guid>
      <description>&lt;p&gt;You know what's worse than a bug in production?&lt;/p&gt;

&lt;p&gt;A bug you &lt;em&gt;introduced&lt;/em&gt; while fixing another bug — deployed at midnight, right when your biggest traffic spike ever is happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: Our Best Day Ever
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://repoclip.io" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt;, an AI-powered SaaS that generates promotional videos from GitHub repositories. On March 19, 2026, &lt;a href="https://console.dev" rel="noopener noreferrer"&gt;console.dev&lt;/a&gt; featured us in their newsletter.&lt;/p&gt;

&lt;p&gt;Traffic exploded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Active Users&lt;/th&gt;
&lt;th&gt;New Users&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;March 19 (feature day)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;448&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;445&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March 20 (day after)&lt;/td&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;td&gt;139&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;448 users. For a solo indie SaaS, that felt massive.&lt;/p&gt;

&lt;p&gt;But there was a problem I wouldn't discover until 24 hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Fix" That Broke Everything
&lt;/h2&gt;

&lt;p&gt;Earlier on March 19, I noticed that some video generations were failing because Remotion Lambda (our video renderer on AWS) couldn't download images from fal.ai's temporary CDN URLs fast enough. The URLs were expiring or timing out.&lt;/p&gt;

&lt;p&gt;So at midnight (00:20 JST, March 20), I shipped what I thought was a solid improvement:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-fetch all media&lt;/strong&gt; from fal.ai CDN → Supabase Storage before rendering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add retry logic&lt;/strong&gt; — 3 attempts with exponential backoff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw early&lt;/strong&gt; if all clips failed to prefetch, instead of sending unreliable URLs to the renderer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third point was the killer. Here's the diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// video-prefetch.ts — the "improvement"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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;cached&lt;/span&gt; &lt;span class="o"&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;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`All &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; video clips failed to prefetch from CDN`&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;My reasoning: "If we can't cache any clips locally, Remotion will probably fail anyway. Let's fail fast and give the user a clear error instead of wasting 5 minutes on a doomed render."&lt;/p&gt;

&lt;p&gt;It sounded so reasonable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data I Should Have Checked First
&lt;/h2&gt;

&lt;p&gt;The next evening, I ran a query against our projects table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-19T15:00:00+00:00'&lt;/span&gt;  &lt;span class="c1"&gt;-- after deploy&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-20T15:00:00+00:00'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_mode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;failed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;video_short&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;td&gt;image&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero. Not a single video succeeded after my deploy.&lt;/p&gt;

&lt;p&gt;Every single failure had the same message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All 3 video clips failed to prefetch from CDN — rendering would likely fail&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Meanwhile, &lt;strong&gt;before&lt;/strong&gt; my fix on the same day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;td&gt;video_short&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed&lt;/td&gt;
&lt;td&gt;video_short&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;td&gt;image&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed&lt;/td&gt;
&lt;td&gt;image&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;76% video success rate before. &lt;strong&gt;0% after.&lt;/strong&gt; My "safety check" didn't just fail to help — it made things infinitely worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why My Assumption Was Wrong
&lt;/h2&gt;

&lt;p&gt;Here's what I didn't understand about my own infrastructure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The prefetch path (broken):
  Inngest (Vercel Edge) → fal.media CDN → frequently times out

The render path (working fine):
  Remotion Lambda (AWS us-east-1) → fal.media CDN → usually succeeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prefetch runs on Vercel's serverless infrastructure. Remotion Lambda runs on AWS in us-east-1. &lt;strong&gt;The network path from AWS to fal.ai's CDN was far more reliable than from Vercel.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before my fix, when prefetch failed, the code silently fell back to the original fal.media URLs. Remotion Lambda would then download them directly — and it usually worked. The 7 pre-fix failures were mostly unrelated issues (prompt rejections, Lambda crashes), not CDN problems.&lt;/p&gt;

&lt;p&gt;By adding the &lt;code&gt;throw&lt;/code&gt;, I cut off the fallback path entirely. The pipeline would die at the prefetch step without ever giving Remotion a chance to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Cost
&lt;/h2&gt;

&lt;p&gt;6 unique users were affected. All on the free plan, all trying video generation for the first time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User&lt;/th&gt;
&lt;th&gt;Failed Attempts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:rafael@"&gt;rafael@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:gabomaldi@"&gt;gabomaldi@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:ale@"&gt;ale@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:enzo@"&gt;enzo@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:moussan@"&gt;moussan@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="mailto:tthorjen@"&gt;tthorjen@&lt;/a&gt;...&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These were people who came from console.dev, signed up, and tried to generate a video — our core product experience. One user tried &lt;strong&gt;3 times&lt;/strong&gt; before giving up.&lt;/p&gt;

&lt;p&gt;The silver lining: our pipeline deducts credits only on success (Step 13, after &lt;code&gt;status: "completed"&lt;/code&gt;), and failed projects are excluded from the free allowance count. So no one lost credits, and they could retry. But the first impression was ruined.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: One Line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  if (cached === 0 &amp;amp;&amp;amp; videos.length &amp;gt; 0) {
&lt;span class="gd"&gt;-   throw new Error(
-     `All ${videos.length} video clips failed to prefetch from CDN`
&lt;/span&gt;&lt;span class="gi"&gt;+   console.warn(
+     `[video-prefetch] All ${videos.length} clips failed to prefetch — falling back to original CDN URLs`
&lt;/span&gt;    );
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Keep the retry logic (it helps when it works), but don't block the pipeline when it doesn't. Let Remotion Lambda try the direct download path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Lesson
&lt;/h2&gt;

&lt;p&gt;I've seen this pattern before, and I'll probably see it again:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Fail fast" is not always the right answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a pipeline with multiple fallback paths, throwing early can be &lt;em&gt;worse&lt;/em&gt; than doing nothing. My prefetch-to-Supabase step was an optimization, not a requirement. The system had a perfectly good fallback — Remotion downloading directly from fal.media — and my throw statement eliminated it.&lt;/p&gt;

&lt;p&gt;The mental model I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prefetch fails → render will fail → fail fast = better UX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prefetch fails → render might succeed via different network path → fail fast = 0% success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rules I'm Taking Away
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Measure the fallback before removing it.&lt;/strong&gt; I never checked how often Remotion Lambda could download from fal.media directly. If I had, I'd have seen it was ~90%+ reliable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Probably will fail" ≠ "will fail."&lt;/strong&gt; My error message literally said "rendering would likely fail." &lt;em&gt;Likely&lt;/em&gt; isn't &lt;em&gt;certainly&lt;/em&gt;. Don't throw on &lt;em&gt;likely&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deploy ≠ done.&lt;/strong&gt; I shipped at midnight and went to sleep. The next morning I was celebrating the traffic spike, not checking if video generation still worked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your biggest traffic day is your worst day for bugs.&lt;/strong&gt; Murphy's law for SaaS: the feature that brought all those users? They tried the thing that was broken.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (JST)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mar 19, 17:00&lt;/td&gt;
&lt;td&gt;console.dev features RepoClip — traffic spikes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 19, ~23:00&lt;/td&gt;
&lt;td&gt;I notice some CDN-related render failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 20, 00:20&lt;/td&gt;
&lt;td&gt;Deploy prefetch retry + throw on failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 20, 02:16&lt;/td&gt;
&lt;td&gt;First post-deploy video generation fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 20, 22:27&lt;/td&gt;
&lt;td&gt;Last failure before I investigate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 20, 23:30&lt;/td&gt;
&lt;td&gt;I discover 0% video success rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 21, 00:15&lt;/td&gt;
&lt;td&gt;One-line fix deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;24 hours of broken video generation. During our highest-traffic period ever.&lt;/p&gt;




&lt;p&gt;If you're building a media pipeline with external CDN dependencies, don't assume your serverless function's network path is the same as your renderer's. And if you're adding safety checks — make sure you're not removing a working fallback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://repoclip.io" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt; turns GitHub repos into promotional videos. It works again now. Probably. 😅&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you ever shipped a "fix" that made things worse? I'd love to hear your story in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>saas</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>Our Videos Silently Failed for a Week — How a Stale Env Var Cost Us $60 and 12 Unhappy Users</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Mon, 09 Mar 2026 10:01:33 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/our-videos-silently-failed-for-a-week-how-a-stale-env-var-cost-us-60-and-12-unhappy-users-2eb3</link>
      <guid>https://dev.to/kazutaka-dev/our-videos-silently-failed-for-a-week-how-a-stale-env-var-cost-us-60-and-12-unhappy-users-2eb3</guid>
      <description>&lt;p&gt;You know that sinking feeling when you check your billing dashboard and something doesn't add up?&lt;/p&gt;

&lt;p&gt;That's how this started. I noticed &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt; — my AI video generation SaaS — was burning through &lt;strong&gt;$20/day on fal.ai&lt;/strong&gt; credits for three consecutive days. My first thought was optimistic: "Great, more users are trying the product!"&lt;/p&gt;

&lt;p&gt;I was wrong. The money was being spent. The videos were being generated. But &lt;strong&gt;not a single user was getting their finished video&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom: $60 Burned, Zero Videos Delivered
&lt;/h2&gt;

&lt;p&gt;RepoClip generates promotional videos from GitHub repos. The pipeline 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;GitHub URL → Gemini Analysis → Kling Video Clips (fal.ai) → Remotion Lambda Render → Done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each "Video Short" generates 5 AI video clips using Kling 3.0 Pro on fal.ai, then stitches them together with narration using Remotion on AWS Lambda.&lt;/p&gt;

&lt;p&gt;When I checked the fal.ai dashboard, I saw $20/day being consumed on March 7, 8, and 9. That's roughly 2–4 video generations per day. Seemed plausible for organic traffic.&lt;/p&gt;

&lt;p&gt;But then I queried the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was alarming:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mar 1&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 1&lt;/td&gt;
&lt;td&gt;failed&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 1&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 2&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 5&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 6&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 7&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 8&lt;/td&gt;
&lt;td&gt;rendering&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;After March 1, not a single project reached "completed".&lt;/strong&gt; Every video was stuck in "rendering" — the step right after fal.ai finishes generating clips.&lt;/p&gt;

&lt;p&gt;The fal.ai credits were being consumed successfully. The Kling clips were generated and saved. Then the pipeline just... stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Investigation: Following the Money
&lt;/h2&gt;

&lt;p&gt;The video pipeline runs on &lt;a href="https://www.inngest.com/" rel="noopener noreferrer"&gt;Inngest&lt;/a&gt;, which breaks the work into discrete steps. Each step is memoized — if the function retries, completed steps aren't re-executed. The relevant steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch GitHub code&lt;/li&gt;
&lt;li&gt;Analyze with Gemini&lt;/li&gt;
&lt;li&gt;Generate video clips (fal.ai) ← money spent here&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger Remotion Lambda render&lt;/strong&gt; ← failure here&lt;/li&gt;
&lt;li&gt;Poll for render completion&lt;/li&gt;
&lt;li&gt;Update project to "completed"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 succeeded (fal.ai charged us), but Step 4 was clearly failing. The project status gets set to "rendering" just before Step 4 runs, which explains why everything was stuck there.&lt;/p&gt;

&lt;p&gt;I checked the Remotion Lambda configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What our env var said:&lt;/span&gt;
&lt;span class="nv"&gt;REMOTION_LAMBDA_FUNCTION_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;remotion-render-4-0-414-mem2048mb-disk2048mb-600sec

&lt;span class="c"&gt;# What actually exists in AWS:&lt;/span&gt;
aws lambda list-functions &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Functions[?starts_with(FunctionName, `remotion`)]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;remotion-render-4-0-429-mem2048mb-disk2048mb-600sec
remotion-render-4-0-429-mem3008mb-disk4096mb-900sec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. &lt;strong&gt;The Lambda function &lt;code&gt;4-0-414&lt;/code&gt; didn't exist anymore.&lt;/strong&gt; We had upgraded Remotion from v4.0.414 to v4.0.429, deployed new Lambda functions, deleted the old ones — and never updated the environment variable on Vercel.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;renderMediaOnLambda()&lt;/code&gt; call was throwing &lt;code&gt;ResourceNotFoundException&lt;/code&gt;, silently, inside an Inngest background job that no user ever sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why No One Noticed
&lt;/h2&gt;

&lt;p&gt;This is the insidious part. The failure was completely invisible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No client-side errors&lt;/strong&gt; — the API returned a project ID successfully. Users saw "Rendering..." and waited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GA4 events&lt;/strong&gt; — we tracked &lt;code&gt;video_generate_start&lt;/code&gt; but had no &lt;code&gt;video_generate_complete&lt;/code&gt; event. The absence of completions was invisible in analytics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No monitoring&lt;/strong&gt; — we had Sentry for errors, but the Inngest function's retry logic was eating the exceptions before they surfaced meaningfully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fal.ai charges looked normal&lt;/strong&gt; — if anything, increasing spend looked like a good sign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only signal was the billing anomaly — and even that was ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery: Don't Re-generate, Re-render
&lt;/h2&gt;

&lt;p&gt;Here's the silver lining: all 12 stuck projects had their assets &lt;strong&gt;fully saved&lt;/strong&gt; in the database. The Kling video clips, the narration audio, the video config — everything was persisted in the &lt;code&gt;assets&lt;/code&gt; JSONB column before the render step.&lt;/p&gt;

&lt;p&gt;Re-triggering the full pipeline would have re-generated all the video clips on fal.ai, costing another $60+. Instead, I wrote a targeted retry script that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Queries all projects with &lt;code&gt;status = 'rendering'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reads their saved &lt;code&gt;video_config&lt;/code&gt; and &lt;code&gt;assets&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;renderMediaOnLambda()&lt;/code&gt; with the correct (new) function name&lt;/li&gt;
&lt;li&gt;Polls for completion&lt;/li&gt;
&lt;li&gt;Updates the project to &lt;code&gt;completed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sends the completion email
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The key insight: assets are already saved, just re-render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;renderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bucketName&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderMediaOnLambda&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;functionName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FUNCTION_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// now pointing to the correct function&lt;/span&gt;
  &lt;span class="na"&gt;serveUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SERVE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;composition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ProductVideo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;inputProps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// built from saved assets&lt;/span&gt;
  &lt;span class="na"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h264&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;12 out of 12 videos recovered&lt;/strong&gt; (9 on first attempt, 3 on retry after transient network timeouts). Every user got a completion email with their finished video. Zero additional fal.ai charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prevention: Three Layers of Defense
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Layer 1: Deploy Script Auto-Updates Env Vars
&lt;/h3&gt;

&lt;p&gt;The root cause was a manual step that was easy to forget. The old deploy script ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Set the following environment variables:"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  REMOTION_LAMBDA_FUNCTION_NAME=&amp;lt;function name from above&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it automatically extracts and updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Extract function name from deploy output&lt;/span&gt;
&lt;span class="nv"&gt;FUNC_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FUNC_OUTPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'remotion-render-[a-zA-Z0-9-]+'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Verify function exists&lt;/span&gt;
aws lambda get-function &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FUNC_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Auto-update Vercel + local env&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FUNC_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | npx vercel &lt;span class="nb"&gt;env rm &lt;/span&gt;REMOTION_LAMBDA_FUNCTION_NAME production &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FUNC_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | npx vercel &lt;span class="nb"&gt;env &lt;/span&gt;add REMOTION_LAMBDA_FUNCTION_NAME production
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s2"&gt;"s|^REMOTION_LAMBDA_FUNCTION_NAME=.*|REMOTION_LAMBDA_FUNCTION_NAME=&lt;/span&gt;&lt;span class="nv"&gt;$FUNC_NAME&lt;/span&gt;&lt;span class="s2"&gt;|"&lt;/span&gt; .env.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more "please update manually" — the script handles it end-to-end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Inngest Cron Alert for Stuck Renders
&lt;/h3&gt;

&lt;p&gt;An hourly cron job checks for projects stuck in "rendering" for more than 30 minutes:&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;monitorStuckRendersFunction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inngest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;monitor-stuck-renders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cron&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stuckProjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;check-stuck-projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, repo_name, content_mode, updated_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&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;rendering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stuckProjects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Send alert email with project details&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this had existed a week ago, we'd have known within an hour instead of seven days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: GA4 Start/Complete Event Comparison
&lt;/h3&gt;

&lt;p&gt;We added &lt;code&gt;video_generate_complete&lt;/code&gt; and &lt;code&gt;video_generate_fail&lt;/code&gt; events that fire when the user's browser sees the status change via Supabase Realtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ProjectStatusListener.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`project-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;gaEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video_generate_complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;gaEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video_generate_fail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now our BigQuery funnel query shows the start-to-complete ratio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'video_generate_start'&lt;/span&gt;
    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;user_pseudo_id&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;start_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'video_generate_complete'&lt;/span&gt;
    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;user_pseudo_id&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;complete_users&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events_&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sudden drop in the complete/start ratio is now a visible signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: The Cost Optimization That Came From This
&lt;/h2&gt;

&lt;p&gt;While investigating, I realized every free-tier user was getting the same Kling 3.0 &lt;strong&gt;Pro&lt;/strong&gt; clips as paying customers. At ~$5.60 per video, with a ~3% conversion rate, the customer acquisition cost was unsustainable.&lt;/p&gt;

&lt;p&gt;The fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free plan&lt;/strong&gt;: Kling 3.0 Standard (3 clips, ~15s) — $2.52/video&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid plans&lt;/strong&gt;: Kling 3.0 Pro (5 clips, ~25s) — $5.60/video&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This turns "Kling 3.0 Pro quality" into a tangible upgrade incentive while cutting free-tier costs by 55%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Env vars are a silent single point of failure.&lt;/strong&gt; Automate their lifecycle. If a deploy script creates a resource, it should update the env var that references it — in the same script, in the same run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Background job failures are invisible by default.&lt;/strong&gt; If your pipeline runs asynchronously (Inngest, Bull, SQS), you need explicit monitoring for "things that should have finished but didn't." A simple cron checking for stale statuses catches an entire class of bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Track completion, not just initiation.&lt;/strong&gt; We had &lt;code&gt;video_generate_start&lt;/code&gt; in GA4 from day one. We never added &lt;code&gt;video_generate_complete&lt;/code&gt;. The absence of data is the hardest signal to notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Persist intermediate results.&lt;/strong&gt; The only reason we recovered without re-incurring $60+ in fal.ai charges is that every pipeline step saves its output to the database before moving on. This turned a potential re-generation into a simple re-render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Billing anomalies are monitoring signals.&lt;/strong&gt; The first hint of trouble wasn't an error log or a user complaint — it was an unexpected spend pattern. If you're running a SaaS with external API costs, set up billing alerts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt; generates AI-powered promotional videos from GitHub repositories. If you want to try it out, paste any public repo URL and get a video in minutes — free, no credit card required.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nextjs</category>
      <category>showdev</category>
      <category>sass</category>
    </item>
    <item>
      <title>Beyond the AI Hype: How I Gained "100% Clean" Trust for My Rust-Powered Search App</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Mon, 09 Mar 2026 08:06:17 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/beyond-the-ai-hype-how-i-gained-100-clean-trust-for-my-rust-powered-search-app-55i1</link>
      <guid>https://dev.to/kazutaka-dev/beyond-the-ai-hype-how-i-gained-100-clean-trust-for-my-rust-powered-search-app-55i1</guid>
      <description>&lt;p&gt;A few days ago, I shared &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke"&gt;my journey of choosing Tauri over Electron&lt;/a&gt; to build &lt;strong&gt;OmniFile&lt;/strong&gt;, a privacy-first local search tool. The response from the community was incredible, and it reinforced my belief that performance and memory efficiency still matter deeply to developers.&lt;/p&gt;

&lt;p&gt;However, since then, I’ve encountered a fascinating paradox in the modern software ecosystem.&lt;/p&gt;

&lt;p&gt;I recently launched another project, &lt;strong&gt;RepoClip&lt;/strong&gt; (an AI-powered tool for GitHub &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;https://repoclip.io/&lt;/a&gt;). Within weeks, it was "picked up" by hundreds of AI directories, resulting in over 400 backlinks almost overnight. It was a whirlwind of automated growth.&lt;/p&gt;

&lt;p&gt;But for &lt;strong&gt;OmniFile&lt;/strong&gt;, a tool that handles sensitive local data, the "bot-driven" approach didn't work. And honestly? I’m glad it didn’t.&lt;/p&gt;

&lt;p&gt;For utility tools that prioritize privacy, trust isn't something that can be automated by a crawler. It has to be earned. Today, I’m thrilled to share a major milestone: OmniFile has been officially listed on &lt;strong&gt;Softpedia&lt;/strong&gt; and granted the &lt;strong&gt;"100% Clean" Award&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this post, I want to discuss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The stark difference between &lt;strong&gt;"AI Trend Hype"&lt;/strong&gt; and &lt;strong&gt;"Utility Trust."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Why a single authoritative listing (like Softpedia) outweighs 400 automated links.&lt;/li&gt;
&lt;li&gt;How to navigate the rigorous security reviews for desktop apps in 2026.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Section 1: The Illusion of "400 Backlinks" (AI Directory Hype)
&lt;/h2&gt;

&lt;p&gt;One morning, I checked Ahrefs for my new AI project, RepoClip, and was stunned: 400+ backlinks in less than a month.&lt;/p&gt;

&lt;p&gt;I hadn't done much—just listed it on a few major platforms like Futurepedia and Product Hunt. Then, the "bots" took over. In the current AI gold rush, hundreds of directory sites use automated crawlers to scrape new tools. They copy your description, tag it with "AI," and link back to you instantly.&lt;/p&gt;

&lt;p&gt;It feels great for SEO metrics, but there’s a catch: &lt;strong&gt;These links are often empty calories.&lt;/strong&gt; They provide high volume but low trust. They are essentially "digital posters" put up by robots for other robots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 2: Why OmniFile Required a Different Map
&lt;/h2&gt;

&lt;p&gt;When I launched OmniFile, my Rust-based local search app, the experience was the polar opposite. Despite being built with a modern stack (Tauri), it didn't fit the "AI Directory" mold. The bots ignored it. After a month, I had only 20 backlinks.&lt;/p&gt;

&lt;p&gt;But here’s the thing: &lt;strong&gt;OmniFile handles your local files.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a tool like this, a random link from an automated aggregator is worthless. Users won't download a file-system utility based on a bot's recommendation. They need to know it’s safe.&lt;/p&gt;

&lt;p&gt;That’s why I pivoted my strategy from "Volume" to &lt;strong&gt;"Authority."&lt;/strong&gt; I focused on getting listed on Softpedia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 3: The Value of the "100% Clean" Badge
&lt;/h2&gt;

&lt;p&gt;Softpedia is old school—and I mean that in the best way possible. They don’t just scrape your site; they test your software.&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.amazonaws.com%2Fuploads%2Farticles%2Fnoul0my6bl7ado7atwmo.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.amazonaws.com%2Fuploads%2Farticles%2Fnoul0my6bl7ado7atwmo.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting the &lt;strong&gt;"100% Clean" Award&lt;/strong&gt; means their team verified that OmniFile is free of malware, adware, and viruses. In an era where "Local-First" and "Privacy" are becoming mainstream, this badge is more than a marketing asset—it’s a security certification for my users.&lt;/p&gt;

&lt;p&gt;It reminds us that while AI bots can give you visibility, only human-curated platforms can give you &lt;strong&gt;credibility&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 4: Tips for Navigating Security Reviews (How to get listed)
&lt;/h2&gt;

&lt;p&gt;If you are building a desktop app (Tauri, Electron, or native) and want to earn this kind of trust, here are a few tips from my experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code Signing is Non-Negotiable:&lt;/strong&gt; Ensure your binaries are signed. It’s the first thing professional reviewers and OS filters (like SmartScreen) look for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear Privacy Policy:&lt;/strong&gt; Especially for local-first apps, explicitly state that no data leaves the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be Patient with Manual Reviews:&lt;/strong&gt; Unlike AI directories that list you in seconds, sites like Softpedia or MajorGeeks take time. The wait is the proof of the quality of the review.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion &amp;amp; CTA
&lt;/h2&gt;

&lt;p&gt;Building trust in 2026 feels like a constant battle against automated noise. While the 400+ bot links for my AI project were a fun experiment, the "100% Clean" badge for OmniFile is what helps me sleep at night.&lt;/p&gt;

&lt;p&gt;I’m curious to hear from other indie hackers: &lt;strong&gt;How are you balancing the need for rapid growth with the necessity of building long-term trust?&lt;/strong&gt; Do you still value these "old school" software directories?&lt;/p&gt;

&lt;p&gt;If you’re looking for a privacy-first way to search your local files, I’d love for you to give OmniFile a spin.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📁 &lt;strong&gt;Try OmniFile:&lt;/strong&gt; &lt;a href="https://omnifile.app/" rel="noopener noreferrer"&gt;https://omnifile.app/&lt;/a&gt; (or find us on &lt;a href="https://www.softpedia.com/get/File-managers/OmniFile.shtml" rel="noopener noreferrer"&gt;Softpedia&lt;/a&gt;!)&lt;/li&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/TwistTheoryGames/OmniFile-releases" rel="noopener noreferrer"&gt;TwistTheoryGames/OmniFile-releases&lt;/a&gt; (If you want to see how a Tauri app is structured!)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s chat in the comments!&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>28% of My Users Are on Mobile. My Conversion Page Was Broken for All of Them.</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Tue, 03 Mar 2026 11:28:17 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/28-of-my-users-are-on-mobile-my-conversion-page-was-broken-for-all-of-them-70j</link>
      <guid>https://dev.to/kazutaka-dev/28-of-my-users-are-on-mobile-my-conversion-page-was-broken-for-all-of-them-70j</guid>
      <description>&lt;p&gt;I had analytics data for weeks showing mobile underperformance. I never noticed because I was looking at the wrong number.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt;, a SaaS that generates promo videos from GitHub repos. The pipeline analyzes code with Gemini, generates images/video clips, adds narration, and renders a video. The previous articles covered &lt;a href="https://dev.to/kazutaka-dev/i-switched-my-ai-image-model-and-the-cost-went-up-67x-heres-why-i-did-it-anyway-1fdl"&gt;image model migration&lt;/a&gt; and &lt;a href="https://dev.to/kazutaka-dev/i-added-ai-video-clips-to-my-saas-and-it-broke-everything-5-times-25gi"&gt;video clip integration&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This time, the problem wasn't in the AI pipeline. It was in a CSS grid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Aggregate Lie
&lt;/h2&gt;

&lt;p&gt;My GA4 dashboard showed an overall bounce rate that looked fine. Mobile users were 28% of traffic — a meaningful chunk. But the aggregate number hid the real story.&lt;/p&gt;

&lt;p&gt;When I broke it down by page using BigQuery, the conversion funnel on &lt;code&gt;/dashboard/new&lt;/code&gt; (the video creation page) told a different story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;29 page views → 5 video generations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a 17% conversion rate on the page where users actually create videos. Desktop was converting at roughly 3x that rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Actually Broken
&lt;/h2&gt;

&lt;p&gt;I opened Chrome DevTools, switched to iPhone SE (375px), and immediately saw the problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: Grids That Don't Fit
&lt;/h3&gt;

&lt;p&gt;The content mode selector uses a 3-column grid. On 375px, each button was ~105px wide — barely enough for the emoji, label, and "100 credits" text. The visual style selector was worse: 4 columns on a phone screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: cramped on mobile&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-3 gap-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;CONTENT_MODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col items-center gap-1.5 p-3 ..."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emoji&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xs font-medium"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-[10px]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-[10px]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; credits&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was one class change per grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- &amp;lt;div className="grid grid-cols-3 gap-3"&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ &amp;lt;div className="grid grid-cols-2 sm:grid-cols-3 gap-3"&amp;gt;
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;- &amp;lt;div className="grid grid-cols-4 gap-3"&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ &amp;lt;div className="grid grid-cols-2 sm:grid-cols-4 gap-3"&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two columns on mobile, full grid on desktop. The buttons went from cramped and barely tappable to comfortable with clear labels.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: The URL Input Squeeze
&lt;/h3&gt;

&lt;p&gt;The URL input and "Validate" button sat side by side in a &lt;code&gt;flex&lt;/code&gt; row. On a phone, the input was too narrow to show the full URL — users couldn't see what they'd typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- &amp;lt;div className="flex gap-3"&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ &amp;lt;div className="flex flex-col sm:flex-row gap-2 sm:gap-3"&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stacking vertically on mobile gave the input full width. Simple, obvious in hindsight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: The Real Friction — Typing a URL on a Phone
&lt;/h3&gt;

&lt;p&gt;This was the insight that went beyond CSS.&lt;/p&gt;

&lt;p&gt;On desktop, users copy a GitHub URL from their browser and paste it. One action. On mobile, users have to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Switch to their browser&lt;/li&gt;
&lt;li&gt;Navigate to GitHub&lt;/li&gt;
&lt;li&gt;Find the repo&lt;/li&gt;
&lt;li&gt;Copy the URL&lt;/li&gt;
&lt;li&gt;Switch back to RepoClip&lt;/li&gt;
&lt;li&gt;Paste it&lt;/li&gt;
&lt;li&gt;Tap "Validate"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's 7 steps before they even start configuring their video. No amount of responsive CSS fixes this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One-Tap Solution
&lt;/h2&gt;

&lt;p&gt;RepoClip already has users' GitHub OAuth tokens (for private repo access). Those tokens can fetch their recent repositories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// New GET handler in /api/github&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;github_access_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;github_access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;octokit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Octokit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;repos&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;octokit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listForAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;per_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stargazers_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isPrivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the frontend, when the URL input is empty, the user sees their 5 most recent repos as tappable chips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;githubUrl&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;repoInfo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;recentRepos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xs text-slate-500 mb-2"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Recent repositories&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-wrap gap-2"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;recentRepos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://github.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;setGithubUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="c1"&gt;// Auto-validate immediately&lt;/span&gt;
            &lt;span class="nf"&gt;validateRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"px-3 py-1.5 text-xs font-medium rounded-full border ..."&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tap a chip, auto-validate, start configuring. Three taps instead of 7 steps. On desktop, it's a nice convenience. On mobile, it eliminates the primary friction point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Analytics Blind Spot
&lt;/h2&gt;

&lt;p&gt;While investigating the mobile conversion gap, I tried to answer a natural question: do mobile users choose portrait (9:16) video and desktop users choose landscape (16:9)?&lt;/p&gt;

&lt;p&gt;I couldn't answer it. The &lt;code&gt;video_generate_start&lt;/code&gt; GA4 event didn't include &lt;code&gt;aspect_ratio&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: missing aspect_ratio and visual_style&lt;/span&gt;
&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video_generate_start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;repo_owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;repoInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;repoInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;content_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bgm_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bgmEnabled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&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;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could see aspect ratio distribution in Supabase (82% landscape, 13% portrait, 5% square) and device breakdown in GA4 (83% desktop, 17% mobile for generations). But I couldn't correlate them — different systems, different user IDs.&lt;/p&gt;

&lt;p&gt;The fix was two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  event("video_generate_start", {
    repo_owner: repoInfo?.owner ?? "",
    repo_name: repoInfo?.name ?? "",
    content_mode: contentMode,
&lt;span class="gi"&gt;+   aspect_ratio: aspectRatio,
+   visual_style: visualStyle,
&lt;/span&gt;    bgm_enabled: bgmEnabled ? "true" : "false",
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I can run this BigQuery query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string_value&lt;/span&gt;
   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aspect_ratio'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;aspect_ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;generates&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`analytics.events_*`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'video_generate_start'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aspect_ratio&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;generates&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data won't be available until the next deployment collects new events, but the hypothesis is testable now. If mobile users do prefer portrait videos, I should default to 9:16 when &lt;code&gt;navigator.userAgent&lt;/code&gt; indicates a mobile device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Landing Page Badge Overflow
&lt;/h2&gt;

&lt;p&gt;One more mobile issue, unrelated to conversions but visible to every visitor: the third-party badges (TAAFT, BetaList, Futurepedia) on the landing page had hard-coded pixel widths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: fixed widths overflow on 375px screens&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex justify-center items-center gap-6 py-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"156"&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"54"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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="mi"&gt;156&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;54&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;54&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;54&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;300 + 156 + 250 + gaps = 730px minimum. iPhone SE is 375px. The badges just overflowed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: responsive widths with flex-wrap&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-wrap justify-center items-center gap-4 sm:gap-6 px-4 py-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-48 sm:w-[300px]"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-10 sm:h-[54px] w-auto"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-10 sm:h-[54px] w-auto"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flex-wrap&lt;/code&gt; lets badges flow to a second row on small screens. Responsive height classes scale them down proportionally.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Aggregate metrics hide page-level problems.&lt;/strong&gt; "Mobile bounce rate" told me nothing. Breaking conversion down by page and device category revealed that one specific page was broken. If you have GA4 + BigQuery, query by page path — not just by device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The biggest mobile friction isn't layout — it's input.&lt;/strong&gt; Responsive grids are table stakes. The real question is: what actions require typing on mobile that could be replaced with tapping? For RepoClip, the answer was URL input. For your app, it might be search, filters, or form fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Track what you'll want to correlate later.&lt;/strong&gt; I added &lt;code&gt;content_mode&lt;/code&gt; to the GA4 event at launch but forgot &lt;code&gt;aspect_ratio&lt;/code&gt; and &lt;code&gt;visual_style&lt;/code&gt;. When I needed to correlate device type with aspect ratio choice, the data wasn't there. Think about the questions you'll ask in 3 months and instrument for them now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Fixed pixel widths are mobile time bombs.&lt;/strong&gt; Every &lt;code&gt;width="300"&lt;/code&gt; and &lt;code&gt;style={{ width: 250 }}&lt;/code&gt; is a mobile overflow waiting to happen. Use responsive classes from the start, even if your primary audience is desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;If you're building a SaaS and haven't checked your mobile conversion funnel recently, here's my quick audit list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Query GA4 by page path + device category, not just aggregate&lt;/li&gt;
&lt;li&gt;[ ] Open every conversion-critical page on a 375px viewport&lt;/li&gt;
&lt;li&gt;[ ] Check every grid: does it still make sense at half the width?&lt;/li&gt;
&lt;li&gt;[ ] Find every input that requires typing — can it be replaced with selection?&lt;/li&gt;
&lt;li&gt;[ ] Search your codebase for hard-coded &lt;code&gt;width=&lt;/code&gt; attributes&lt;/li&gt;
&lt;li&gt;[ ] Verify your analytics events include all dimensions you'll want to slice by&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The mobile improvements are live now: &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;repoclip.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a GitHub repo, try the one-tap flow: log in, go to "Create New Video," and your recent repos should appear as chips. On a phone, it's noticeably faster than typing a URL.&lt;/p&gt;

&lt;p&gt;Questions for the community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's the worst mobile UX bug you've found hiding in aggregate analytics?&lt;/li&gt;
&lt;li&gt;How do you decide when to optimize for mobile vs. accepting that some workflows are desktop-first?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment or find me on &lt;a href="https://github.com/TwistTheoryGames/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Four CSS changes, one API endpoint, and two GA4 parameters. Sometimes the highest-impact work isn't in the AI pipeline — it's in the form that feeds it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>nextjs</category>
      <category>analytics</category>
    </item>
    <item>
      <title>I Added AI Video Clips to My SaaS and It Broke Everything — 5 Times</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Sun, 01 Mar 2026 15:37:10 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/i-added-ai-video-clips-to-my-saas-and-it-broke-everything-5-times-4j8l</link>
      <guid>https://dev.to/kazutaka-dev/i-added-ai-video-clips-to-my-saas-and-it-broke-everything-5-times-4j8l</guid>
      <description>&lt;p&gt;Static images were working fine. Users were happy. Revenue was growing. So naturally, I decided to break everything.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt;, a SaaS that turns GitHub repos into promotional videos. The pipeline analyzes code with Gemini, generates scene images, adds AI narration, and renders the final video with Remotion. The previous article covered &lt;a href="https://dev.to/kazutaka-dev/i-switched-my-ai-image-model-and-the-cost-went-up-67x-heres-why-i-did-it-anyway-1fdl"&gt;switching the image model from FLUX.2 to Nano Banana 2&lt;/a&gt; — a 6.7x cost increase that turned out to be noise.&lt;/p&gt;

&lt;p&gt;This time, I went bigger. Instead of still images, I wanted each scene to be an &lt;strong&gt;AI-generated video clip&lt;/strong&gt;. Five 5-second clips stitched together with narration. The kind of output that makes people say "wait, AI made this?"&lt;/p&gt;

&lt;p&gt;The model: &lt;strong&gt;Kling 3.0 Pro&lt;/strong&gt; via Fal.ai's queue API.&lt;/p&gt;

&lt;p&gt;The result: it works — beautifully. But getting there nearly broke me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Video Clips?
&lt;/h2&gt;

&lt;p&gt;The still-image pipeline was solid. Nano Banana 2 produces gorgeous frames. But promo videos with static images and Ken Burns zoom feel like... slideshows. Because they are.&lt;/p&gt;

&lt;p&gt;AI video generation has matured enough that 5-second clips look cinematic. Camera pans, particle effects, dynamic lighting — things you'd need After Effects for a year ago. For a tool that generates &lt;strong&gt;promotional&lt;/strong&gt; content, this changes the value proposition entirely.&lt;/p&gt;

&lt;p&gt;The plan was simple: add a "Video Short" content mode alongside the existing image mode. Five scenes, five 5-second AI video clips, same narration pipeline, same Remotion renderer.&lt;/p&gt;

&lt;p&gt;Simple plan. Five production incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Math
&lt;/h2&gt;

&lt;p&gt;Before writing code, I ran the numbers. Kling 3.0 Pro costs &lt;strong&gt;$0.224 per second&lt;/strong&gt; on Fal.ai. Each clip is 5 seconds.&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;Image Mode&lt;/th&gt;
&lt;th&gt;Video Short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scenes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Visual cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.08 × 6 = $0.48&lt;/td&gt;
&lt;td&gt;$1.12 × 5 = $5.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TTS cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.10&lt;/td&gt;
&lt;td&gt;~$0.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.05&lt;/td&gt;
&lt;td&gt;~$0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total per video&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.63&lt;/td&gt;
&lt;td&gt;~$5.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RepoClip credits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At $0.01/credit (Starter plan pricing), video mode generates $1.00 revenue per video against $5.73 cost. That's underwater.&lt;/p&gt;

&lt;p&gt;But this is a premium feature. At Pro plan pricing ($0.004/credit × 100 = $0.40), it's even more underwater. The economics only work at scale with Agency plan users, or as a differentiator that drives subscription upgrades.&lt;/p&gt;

&lt;p&gt;I decided to launch it anyway. The quality gap between a slideshow and cinematic AI clips is the kind of thing that converts free users to paid. Sometimes the feature pays for itself indirectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident #1: The 5-Minute Wall
&lt;/h2&gt;

&lt;p&gt;First deploy. First test. Video generation starts, reaches "Generating Assets"... and stays there for 15 minutes. Then the whole pipeline dies silently.&lt;/p&gt;

&lt;p&gt;The culprit: &lt;strong&gt;Vercel's serverless function timeout&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My existing image pipeline used &lt;code&gt;fal.subscribe()&lt;/code&gt; — a convenience method that submits a job and long-polls until completion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This worked for images (7-30 seconds)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/nano-banana-2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aspect_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;16:9&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fal.subscribe&lt;/code&gt; blocks the HTTP connection, waiting for the result. For images that generate in 30 seconds, this is fine. For video clips that take &lt;strong&gt;18 minutes&lt;/strong&gt;, it's a death sentence.&lt;/p&gt;

&lt;p&gt;Vercel Pro plan has a hard limit: &lt;strong&gt;300 seconds (5 minutes)&lt;/strong&gt; per function invocation. The function gets killed mid-poll. No error. No cleanup. Just gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Fix: Submit-Sleep-Collect
&lt;/h2&gt;

&lt;p&gt;The solution was to stop waiting. Instead of long-polling inside a single function call, I split the work into three phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Submit all jobs (fast, &amp;lt;10s)
    ↓
step.sleep("15m")  ← Inngest manages this, zero Vercel resources
    ↓
Collect results (fast HTTP GETs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where &lt;a href="https://www.inngest.com/" rel="noopener noreferrer"&gt;Inngest&lt;/a&gt; saved the architecture. Inngest step functions let you &lt;code&gt;step.sleep()&lt;/code&gt; between operations. During the sleep, no Vercel function is running. No compute. No timeout risk. Inngest wakes your function up after the sleep and runs the next step as a fresh HTTP request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Phase 1: Submit all video jobs to fal.ai queue (&amp;lt;10 seconds)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoJobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit-video-jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;submitVideoJobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: Sleep while fal.ai processes (no Vercel resources used)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-videos&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;15m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 3: Collect results (fast HTTP GETs)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`collect-video-results-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;collectVideoResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remainingJobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... handle completed/pending&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;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wait-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;each &lt;code&gt;step.run()&lt;/code&gt; is a separate Vercel function invocation&lt;/strong&gt;. As long as each individual step completes within 5 minutes, the overall pipeline can run for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident #2: The 18-Minute Surprise
&lt;/h2&gt;

&lt;p&gt;After implementing the submit-sleep-collect pattern, I deployed and tested. The pipeline submitted 5 jobs, slept for 3 minutes, then checked results.&lt;/p&gt;

&lt;p&gt;All 5 clips: still processing.&lt;/p&gt;

&lt;p&gt;I waited. Checked again at 5 minutes. Still processing. 8 minutes. Still processing.&lt;/p&gt;

&lt;p&gt;I started a timer and polled manually with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Submit a test job&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://queue.fal.run/fal-ai/kling-video/v2/master/text-to-video"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Key &lt;/span&gt;&lt;span class="nv"&gt;$FAL_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"prompt":"Futuristic data visualization dashboard","duration":"5"}'&lt;/span&gt;

&lt;span class="c"&gt;# Poll every 2 minutes...&lt;/span&gt;
&lt;span class="c"&gt;# IN_PROGRESS... IN_PROGRESS... IN_PROGRESS...&lt;/span&gt;
&lt;span class="c"&gt;# 18 minutes later: COMPLETED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Kling 3.0 Pro takes ~18 minutes per clip.&lt;/strong&gt; The documentation says 2-5 minutes. The reality is 3-4x slower.&lt;/p&gt;

&lt;p&gt;The fix: increase the initial sleep from 3 minutes to &lt;strong&gt;15 minutes&lt;/strong&gt;, set max collect attempts to 10 with 2-minute intervals. Total maximum wait: ~35 minutes. Not elegant, but reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident #3: Same Bug, Different Step
&lt;/h2&gt;

&lt;p&gt;Video clips finally generate. Five beautiful 5-second clips. Pipeline moves to "Rendering Video" — Remotion Lambda stitches the clips with narration into a final MP4.&lt;/p&gt;

&lt;p&gt;Status stays at "Rendering Video" for 30 minutes. Then silence.&lt;/p&gt;

&lt;p&gt;Same root cause. The &lt;code&gt;pollRenderProgress()&lt;/code&gt; function was a polling loop inside a single &lt;code&gt;step.run()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This loop runs for up to 10 minutes — kills the Vercel function&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRenderProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bucketName&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;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputFile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 3s × 200 = 10 min&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same fix. Split into check-sleep-retry:&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;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-render&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;5m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`check-render-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkRenderProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;videoUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wait-render-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is universal: &lt;strong&gt;never long-poll inside a serverless function&lt;/strong&gt;. If the operation takes more than a minute, use an external orchestrator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident #4: The Invisible Bundle
&lt;/h2&gt;

&lt;p&gt;Three deploys later. Video clips generate. Rendering starts. Remotion Lambda times out after 15 minutes with no output.&lt;/p&gt;

&lt;p&gt;This time the code was fine. The problem was &lt;strong&gt;deployment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Remotion Lambda runs a pre-bundled React application on AWS. I'd added &lt;code&gt;&amp;lt;OffthreadVideo&amp;gt;&lt;/code&gt; to the scene component to render video clips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoUrl&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OffthreadVideo&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;objectFit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{...}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But I'd only deployed the &lt;strong&gt;Vercel app&lt;/strong&gt;. The Remotion Lambda bundle on S3 was still the old version — no &lt;code&gt;OffthreadVideo&lt;/code&gt;, no video clip support. Lambda was trying to render scenes with undefined video components and silently failing.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Redeploy the Remotion bundle to S3&lt;/span&gt;
npx remotion lambda sites create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--site-name&lt;/span&gt; repoclip &lt;span class="se"&gt;\&lt;/span&gt;
  remotion/src/Root.tsx

&lt;span class="c"&gt;# Also upgrade Lambda resources for video decoding&lt;/span&gt;
npx remotion lambda functions deploy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 3008 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 900 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk&lt;/span&gt; 4096
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deployment targets. Two separate deploy processes. I now have a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident #5: The BGM Credit Trap
&lt;/h2&gt;

&lt;p&gt;Pipeline works end-to-end. Video clips render. I enable background music (ElevenLabs Music API) and run a test.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;[bgm_generation] ElevenLabs Music API error (401): insufficient_credits&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The BGM for a 35-second video consumed &lt;strong&gt;~4,000 ElevenLabs credits&lt;/strong&gt; — nearly all of my monthly quota on the Creator plan. One BGM generation cost more than 400 TTS narrations.&lt;/p&gt;

&lt;p&gt;Two fixes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Graceful fallback&lt;/strong&gt; — BGM failure no longer kills the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bgm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bgmEnabled&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate-bgm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bgm_generation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nf"&gt;generateBGM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bgmDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BGM failed, continuing without:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="c1"&gt;// Video generates fine without BGM&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Price adjustment&lt;/strong&gt; — BGM addon went from 5 credits to 20 credits. The original 5-credit price was set before I knew the actual API cost. Always validate pricing against real usage data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline Today
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User submits GitHub URL
  ↓
[Inngest] Fetch code → Gemini analysis → Generate video config
  ↓
[Inngest] Submit 5 video jobs to fal.ai queue (&amp;lt; 10 seconds)
  ↓
[Inngest] step.sleep("15m") — zero compute cost
  ↓
[Inngest] Collect completed clips (retry up to 10× at 2-min intervals)
  ↓
[Inngest] Generate TTS narrations in parallel (&amp;lt; 30 seconds)
  ↓
[Inngest] Trigger Remotion Lambda render
  ↓
[Inngest] step.sleep("5m") — zero compute cost
  ↓
[Inngest] Check render progress (retry up to 10× at 1-min intervals)
  ↓
Final MP4 with AI video clips, narration, and BGM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total wall-clock time: ~30 minutes. Total Vercel compute: under 2 minutes.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Serverless timeouts are the #1 constraint for AI pipelines.&lt;/strong&gt; Not cost. Not quality. Not rate limits. The hard timeout wall shapes your entire architecture. Design for it from day one, not after your fifth production incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The submit-sleep-collect pattern is essential.&lt;/strong&gt; If you're calling any AI API that takes more than 60 seconds, you need an orchestrator that can sleep without consuming resources. Inngest, Temporal, AWS Step Functions — pick one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "2-5 minutes" in AI API docs means "maybe 18 minutes."&lt;/strong&gt; Always measure actual latency with your real workload before setting timeouts. Published benchmarks are best-case scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Two deployment targets means two deploy checklists.&lt;/strong&gt; When your rendering engine runs on separate infrastructure (Lambda, GPU workers, etc.), code changes require deploying to both. Automate this or you'll forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Make expensive features fail gracefully.&lt;/strong&gt; BGM, video clips, 4K rendering — anything with high API costs should degrade, not crash. Users would rather have a video without music than no video at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers After Launch
&lt;/h2&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;Image Mode&lt;/th&gt;
&lt;th&gt;Video Short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Generation time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~8 min&lt;/td&gt;
&lt;td&gt;~30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API cost per video&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.63&lt;/td&gt;
&lt;td&gt;~$5.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RepoClip price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 credits&lt;/td&gt;
&lt;td&gt;100 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Quality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Slideshow with Ken Burns&lt;/td&gt;
&lt;td&gt;Cinematic AI clips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production incidents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Was it worth five incidents? Look at the output and judge for yourself: &lt;a href="https://repoclip.io/gallery" rel="noopener noreferrer"&gt;repoclip.io/gallery&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js 16 (App Router) + TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration&lt;/strong&gt;: Inngest (the hero of this story)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Analysis&lt;/strong&gt;: Gemini 2.5 Flash&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Generation&lt;/strong&gt;: Nano Banana 2 via Fal.ai&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Generation&lt;/strong&gt;: Kling 3.0 Pro via Fal.ai&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narration&lt;/strong&gt;: OpenAI TTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background Music&lt;/strong&gt;: ElevenLabs Music API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Rendering&lt;/strong&gt;: Remotion Lambda (AWS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database/Auth/Storage&lt;/strong&gt;: Supabase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt;: Vercel&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Want to see what Kling 3.0 Pro produces in a real pipeline? Try it on your own repo: &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;repoclip.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select "Video Short" mode when creating a video. The free tier gives you 2 videos/month. Fair warning: it takes ~30 minutes, but the result is worth the wait.&lt;/p&gt;

&lt;p&gt;Questions for the community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Have you hit serverless timeout walls with AI APIs? How did you solve it?&lt;/li&gt;
&lt;li&gt;What orchestration tool do you use for long-running AI pipelines?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment or find me on &lt;a href="https://github.com/TwistTheoryGames/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Five production incidents, three architectural rewrites, and one pricing mistake. Sometimes the best way to learn a platform's limits is to exceed them — repeatedly.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>I Switched My AI Image Model and the Cost Went Up 6.7x — Here's Why I Did It Anyway</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Fri, 27 Feb 2026 15:33:53 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/i-switched-my-ai-image-model-and-the-cost-went-up-67x-heres-why-i-did-it-anyway-1fdl</link>
      <guid>https://dev.to/kazutaka-dev/i-switched-my-ai-image-model-and-the-cost-went-up-67x-heres-why-i-did-it-anyway-1fdl</guid>
      <description>&lt;p&gt;You know that feeling when you find a better tool, but the pricing page makes you close the tab?&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt;, a SaaS that generates promo videos from GitHub repos using AI. The pipeline analyzes source code with Gemini, generates scene images, adds narration, and renders a video — all automatically. Images are at the center of the output quality. Every video has 6 scene images, and they're what users see first.&lt;/p&gt;

&lt;p&gt;I'd been using &lt;strong&gt;FLUX.2 [dev]&lt;/strong&gt; via Fal.ai for image generation since launch. It worked. Then I saw that &lt;strong&gt;Nano Banana 2&lt;/strong&gt; — Google's new image model — had landed on Fal.ai. I decided to test it with the exact same prompt.&lt;/p&gt;

&lt;p&gt;The results were not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same Prompt, Different Universe
&lt;/h2&gt;

&lt;p&gt;I built a comparison script that runs the same prompt through both models across 4 visual styles (Tech, Realistic, Minimal, Vibrant). Here's the prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"A digital dashboard showing interconnected data nodes and flowing information streams, representing an intelligent automation platform that connects multiple services and workflows seamlessly"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Tech Style
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FLUX.2&lt;/th&gt;
&lt;th&gt;Nano Banana 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8xmuae09wnsm5pb3s86s.png" alt=" " width="800" height="450"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy3gdsab834a0kp9x4q62.png" alt=" " width="800" height="446"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FLUX.2 gives you a flat infographic with a UI overlay. Nano Banana 2 produces a cinematic, three-dimensional data flow with depth and lighting that looks like concept art.&lt;/p&gt;

&lt;h3&gt;
  
  
  Realistic Style
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FLUX.2&lt;/th&gt;
&lt;th&gt;Nano Banana 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg176o0r7qlq2s40h2uhy.png" alt=" " width="800" height="450"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffir8vtu57zn7bifch7t0.png" alt=" " width="800" height="446"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FLUX.2 renders a network graph that looks like a screenshot from a monitoring tool. Nano Banana 2 creates a photorealistic control room scene — you can almost feel the screen glow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vibrant Style
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FLUX.2&lt;/th&gt;
&lt;th&gt;Nano Banana 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6q2kphc6d81xcphhnh9h.png" alt=" " width="800" height="450"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5zi9o2g57j8k0vg4dujb.png" alt=" " width="800" height="446"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is where the gap is widest. FLUX.2 gives a cartoon explosion of color. Nano Banana 2 produces a bold, structured composition with neon circuit aesthetics that actually looks intentional.&lt;/p&gt;

&lt;p&gt;The difference wasn't subtle. Every style, every prompt — Nano Banana 2 was producing images that looked like they belonged in a final product, not a prototype.&lt;/p&gt;

&lt;p&gt;But then I checked the pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 6.7x Problem
&lt;/h2&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;FLUX.2 [dev]&lt;/th&gt;
&lt;th&gt;Nano Banana 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost per image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.012&lt;/td&gt;
&lt;td&gt;~$0.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Generation time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~7.7s&lt;/td&gt;
&lt;td&gt;~31.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a 6.7x increase in per-image cost and 4x slower generation. For a bootstrapped SaaS, these numbers don't inspire confidence.&lt;/p&gt;

&lt;p&gt;My gut said "too expensive." But I'd learned from past experience that gut feelings about costs are usually wrong — you need actual numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Simulation
&lt;/h2&gt;

&lt;p&gt;Instead of staring at a spreadsheet, I asked Claude Code to run a scenario:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumptions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10 Starter users ($29/mo each) generating 50 videos total&lt;/li&gt;
&lt;li&gt;3 Pro users ($79/mo each) generating 50 videos total&lt;/li&gt;
&lt;li&gt;6 images per video&lt;/li&gt;
&lt;li&gt;Only revenue: subscription fees. Only cost: image generation API.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    FLUX.2          Nano Banana 2
─────────────────────────────────────────────────
Images generated    600             600
Image cost          $7.20           $48.00
Monthly revenue     $527.00         $527.00
Profit              $519.80         $479.00
Margin              98.6%           90.9%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference was &lt;strong&gt;$40.80/month&lt;/strong&gt;. That's it.&lt;/p&gt;

&lt;p&gt;Even in this conservative scenario — just 13 paying users, no free tier buffer, ignoring all other operational costs — the profit impact was under 8 percentage points. With real-world numbers where hosting, AI analysis, and TTS costs dominate the bill, image generation was a rounding error either way.&lt;/p&gt;

&lt;p&gt;The quality gap was obvious. The cost gap was negligible. Decision made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #1: File Size Explosion
&lt;/h2&gt;

&lt;p&gt;The first thing I noticed after switching: Nano Banana 2's PNG outputs were &lt;strong&gt;massive&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;Format&lt;/th&gt;
&lt;th&gt;File Size (same prompt)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FLUX.2 PNG&lt;/td&gt;
&lt;td&gt;978 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nano Banana 2 PNG&lt;/td&gt;
&lt;td&gt;2,043 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nano Banana 2 JPEG&lt;/td&gt;
&lt;td&gt;401 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nano Banana 2 PNGs were 2x larger than FLUX.2. For a video pipeline that downloads 6 images per generation and passes them to a Lambda renderer, this matters — both for speed and storage costs.&lt;/p&gt;

&lt;p&gt;The fix was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/nano-banana-2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;enhancedPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;aspect_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;output_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;-- this&lt;/span&gt;
    &lt;span class="na"&gt;num_images&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="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;Since these images are frames in a video (which itself uses lossy H.264 compression), lossless PNG was overkill from the start. JPEG at default quality cut the size by 80% with no visible difference in the final rendered video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #2: The 4x Slower Generation
&lt;/h2&gt;

&lt;p&gt;Nano Banana 2 takes ~31 seconds per image versus FLUX.2's ~8 seconds. For 6 scenes, that's 186 seconds sequential — over 3 minutes just on images.&lt;/p&gt;

&lt;p&gt;But I was already generating all scene images in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;generateImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imagePrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visualStyle&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;With &lt;code&gt;Promise.all&lt;/code&gt;, the wall-clock time is the slowest single image, not the sum. In practice, that's ~36 seconds — about 28 seconds more than before. Against a pipeline timeout of 15 minutes, this was a non-issue.&lt;/p&gt;

&lt;p&gt;If you're calling AI APIs sequentially and wondering why things are slow, this is your sign to parallelize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #3: Brand Logo Hallucination
&lt;/h2&gt;

&lt;p&gt;This one surprised me. Nano Banana 2 is significantly better at rendering recognizable imagery — and that's not always a good thing.&lt;/p&gt;

&lt;p&gt;When the scene prompt contained words like "GitHub" or "Python," FLUX.2 would generate abstract tech art. Nano Banana 2 would render the &lt;strong&gt;actual Octocat logo&lt;/strong&gt; or a realistic Python logo. For a product that generates promotional videos, having trademarked imagery appear in user content is a liability.&lt;/p&gt;

&lt;p&gt;The fix was adding explicit exclusion instructions to every prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;enhancedPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stylePrompt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. No text, no UI elements, no screenshots, no logos, no brand imagery, no mascots.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last three exclusions (&lt;code&gt;no logos, no brand imagery, no mascots&lt;/code&gt;) were added specifically for Nano Banana 2. FLUX.2 never needed them because it wasn't capable enough to reproduce them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Diff
&lt;/h2&gt;

&lt;p&gt;The actual code change was small. Here's the core of &lt;code&gt;fal.ts&lt;/code&gt; before and after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const result = await fal.subscribe("fal-ai/flux-2", {
&lt;/span&gt;&lt;span class="gi"&gt;+ const result = await fal.subscribe("fal-ai/nano-banana-2", {
&lt;/span&gt;    input: {
      prompt: enhancedPrompt,
&lt;span class="gd"&gt;-     image_size: "landscape_16_9",
-     num_inference_steps: 28,
&lt;/span&gt;&lt;span class="gi"&gt;+     aspect_ratio: aspectRatio,
+     resolution: "1K",
+     output_format: "jpeg",
&lt;/span&gt;      num_images: 1,
    },
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different model, different API shape, but the wrapper function signature stayed the same. Downstream code didn't change at all.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Always simulate before you panic.&lt;/strong&gt; A 6.7x per-unit cost increase sounds terrifying in isolation. In the context of actual revenue and usage patterns, it was noise. Run the numbers before making decisions based on sticker shock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Lossy formats are fine for intermediate assets.&lt;/strong&gt; If your images are being consumed by a video encoder, compressed for web display, or otherwise transformed downstream, PNG is a waste of bandwidth. Match the format to the use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Better models bring new problems.&lt;/strong&gt; FLUX.2 couldn't render brand logos, so I never had to worry about it. Nano Banana 2 can, so now I need explicit exclusion prompts. Capability upgrades aren't just free wins — they shift the problem space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Parallelization absorbs latency.&lt;/strong&gt; A 4x slower model barely matters when you're already running requests concurrently. Design for parallelism from the start and model speed becomes a secondary concern.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js (App Router) + TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration&lt;/strong&gt;: Inngest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Analysis&lt;/strong&gt;: Gemini 2.5 Flash&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Generation&lt;/strong&gt;: Nano Banana 2 via Fal.ai&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narration&lt;/strong&gt;: OpenAI TTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Rendering&lt;/strong&gt;: Remotion Lambda&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database/Auth/Storage&lt;/strong&gt;: Supabase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt;: Vercel&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;If you want to see what Nano Banana 2 produces in a real pipeline, try it on your own repo: &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;repoclip.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The free tier gives you 2 videos/month. Paste a GitHub URL and you'll have a narrated demo video in a few minutes.&lt;/p&gt;

&lt;p&gt;I'd love to hear from the community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Have you switched AI models in production and been surprised by the cost impact?&lt;/li&gt;
&lt;li&gt;What's your approach to evaluating model upgrades — vibes, benchmarks, or simulations?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment or find me on &lt;a href="https://github.com/TwistTheoryGames/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The comparison images in this post were generated with the exact same prompt, same visual style settings, same pipeline. The only variable was the model. Sometimes the upgrade really is worth it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Built a desktop file search app with Tauri + Rust instead of Electron. This post covers integrating 7 cloud OAuth providers and the surprising workarounds I needed. Feedback welcome!</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Thu, 26 Feb 2026 06:12:23 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/built-a-desktop-file-search-app-with-tauri-rust-instead-of-electron-this-post-covers-integrating-3o70</link>
      <guid>https://dev.to/kazutaka-dev/built-a-desktop-file-search-app-with-tauri-rust-instead-of-electron-this-post-covers-integrating-3o70</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke" class="crayons-story__hidden-navigation-link"&gt;Building a Cross-Platform File Search App With Tauri — Not Electron&lt;/a&gt;
    &lt;div class="crayons-article__cover crayons-article__cover__image__feed"&gt;
      &lt;iframe src="https://www.youtube.com/embed/WMwgBcLOIkM" title="Building a Cross-Platform File Search App With Tauri — Not Electron"&gt;&lt;/iframe&gt;
    &lt;/div&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/kazutaka-dev" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3793477%2F14e12e52-e930-4e5f-a70f-ed68eb7eaa6b.png" alt="kazutaka-dev profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/kazutaka-dev" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Kazutaka Sugiyama
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Kazutaka Sugiyama
                
              
              &lt;div id="story-author-preview-content-3286460" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/kazutaka-dev" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3793477%2F14e12e52-e930-4e5f-a70f-ed68eb7eaa6b.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Kazutaka Sugiyama&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Feb 26&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke" id="article-link-3286460"&gt;
          Building a Cross-Platform File Search App With Tauri — Not Electron
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/rust"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;rust&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tauri"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tauri&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/opensource"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;opensource&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>rust</category>
      <category>tauri</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Just published my first post — a deep dive into orchestrating 6+ AI services in a single serverless pipeline. Would love feedback from the community!</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Thu, 26 Feb 2026 06:09:02 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/just-published-my-first-post-a-deep-dive-into-orchestrating-6-ai-services-in-a-single-serverless-pph</link>
      <guid>https://dev.to/kazutaka-dev/just-published-my-first-post-a-deep-dive-into-orchestrating-6-ai-services-in-a-single-serverless-pph</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91" class="crayons-story__hidden-navigation-link"&gt;How I Built an AI Pipeline That Turns GitHub Repos into Demo Videos&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/kazutaka-dev" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3793477%2F14e12e52-e930-4e5f-a70f-ed68eb7eaa6b.png" alt="kazutaka-dev profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/kazutaka-dev" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Kazutaka Sugiyama
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Kazutaka Sugiyama
                
              
              &lt;div id="story-author-preview-content-3286397" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/kazutaka-dev" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3793477%2F14e12e52-e930-4e5f-a70f-ed68eb7eaa6b.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Kazutaka Sugiyama&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Feb 26&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91" id="article-link-3286397"&gt;
          How I Built an AI Pipeline That Turns GitHub Repos into Demo Videos
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/nextjs"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;nextjs&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/remotion"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;remotion&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt; reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>remotion</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a Cross-Platform File Search App With Tauri — Not Electron</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Thu, 26 Feb 2026 05:42:53 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke</link>
      <guid>https://dev.to/kazutaka-dev/building-a-cross-platform-file-search-app-with-tauri-not-electron-2nke</guid>
      <description>&lt;p&gt;Every knowledge worker I know has the same problem: files scattered across Google Drive, Dropbox, SharePoint, Slack, Notion, GitHub, and their local machine. When you need to find something, you end up opening 4 different search bars.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://omnifile.app/" rel="noopener noreferrer"&gt;OmniFile&lt;/a&gt; to fix that — a single search bar that finds files across all your sources instantly. Desktop app, privacy-first, everything stays on your machine.&lt;/p&gt;

&lt;p&gt;Here's what I learned building it with &lt;strong&gt;Tauri + Rust&lt;/strong&gt; instead of Electron, and why integrating 7 OAuth providers in a desktop app was harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tauri Over Electron
&lt;/h2&gt;

&lt;p&gt;The decision was simple: OmniFile needs to launch instantly (it's triggered by a global shortcut) and stay lightweight in the background. Electron ships a full Chromium browser. Tauri uses the OS's native webview and a Rust backend.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~8MB&lt;/strong&gt; installer vs Electron's ~80MB+&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~30MB&lt;/strong&gt; RAM at idle vs Electron's ~150MB+&lt;/li&gt;
&lt;li&gt;Rust backend for CPU-intensive indexing and file I/O&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is that you write your backend in Rust instead of JavaScript. For file search, that's actually a benefit — Rust's performance for walking directories and parsing file formats is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full-Text Search with Tantivy
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/quickwit-oss/tantivy" rel="noopener noreferrer"&gt;Tantivy&lt;/a&gt; is Rust's answer to Lucene. I use it as the local search engine that indexes everything into a single queryable index.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Schema Design
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;schema_builder&lt;/span&gt;&lt;span class="nf"&gt;.add_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// Tokenized + returned&lt;/span&gt;
&lt;span class="n"&gt;schema_builder&lt;/span&gt;&lt;span class="nf"&gt;.add_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// Exact match&lt;/span&gt;
&lt;span class="n"&gt;schema_builder&lt;/span&gt;&lt;span class="nf"&gt;.add_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// Searchable but NOT stored&lt;/span&gt;
&lt;span class="n"&gt;schema_builder&lt;/span&gt;&lt;span class="nf"&gt;.add_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// "local", "gdrive", etc.&lt;/span&gt;
&lt;span class="n"&gt;schema_builder&lt;/span&gt;&lt;span class="nf"&gt;.add_i64_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"modified_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INDEXED&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key decision: &lt;strong&gt;content is indexed but not stored&lt;/strong&gt;. For a desktop search app, this saves significant disk space — the content is already on disk, so we re-extract it when needed for display. This keeps the index small while enabling full-text search.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Source Indexing
&lt;/h3&gt;

&lt;p&gt;Each cloud provider indexes into the same Tantivy index but with a different &lt;code&gt;source&lt;/code&gt; tag. When re-indexing Google Drive, I delete all documents where &lt;code&gt;source = "gdrive"&lt;/code&gt; and re-add them — without touching Dropbox or local results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;source_term&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Term&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_field_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"gdrive"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="nf"&gt;.delete_term&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_term&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Clear only gdrive docs&lt;/span&gt;
&lt;span class="c1"&gt;// ... re-index gdrive files&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="nf"&gt;.commit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means each provider can index independently without affecting others.&lt;/p&gt;

&lt;h3&gt;
  
  
  File Format Extraction
&lt;/h3&gt;

&lt;p&gt;OmniFile doesn't just search filenames — it extracts and indexes content from DOCX, XLSX, and text files. DOCX files are ZIP archives containing XML, so extraction means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the ZIP&lt;/li&gt;
&lt;li&gt;Find &lt;code&gt;word/document.xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Parse XML, extract text from &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt; tags&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;XLSX is trickier because Excel uses a shared strings table — cell values are stored as indices into a deduplicated string array. The extractor resolves these references at index time.&lt;/p&gt;

&lt;p&gt;For text files, I try UTF-8 first, then fall back to Shift-JIS (common for Japanese files), then lossy UTF-8 as a last resort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OAuth Problem: 7 Providers, 7 Headaches
&lt;/h2&gt;

&lt;p&gt;Desktop apps can't receive OAuth callbacks the way web apps do. There's no public URL to redirect to. My solution: spin up a temporary local HTTP server for each OAuth flow.&lt;/p&gt;

&lt;p&gt;Each provider gets its own port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google Drive  → localhost:14200
Dropbox       → localhost:14201
Box           → localhost:14202
SharePoint    → localhost:14203
Slack         → localhost:14204 (HTTPS!)
Notion        → localhost:14205
GitHub        → localhost:14206
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow: open the browser → user logs in → provider redirects to &lt;code&gt;localhost:PORT/callback?code=XXX&lt;/code&gt; → local server catches it → exchange code for token.&lt;/p&gt;

&lt;p&gt;All providers use &lt;strong&gt;PKCE&lt;/strong&gt; (Proof Key for Code Exchange) to prevent authorization code interception, which matters more for desktop apps since the redirect happens on localhost.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Slack HTTPS Problem
&lt;/h3&gt;

&lt;p&gt;Six of the seven providers accept &lt;code&gt;http://localhost&lt;/code&gt; redirects. Slack doesn't. It requires HTTPS, even for localhost.&lt;/p&gt;

&lt;p&gt;My solution: generate a self-signed TLS certificate on-the-fly using &lt;code&gt;rcgen&lt;/code&gt;, then serve the callback over HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;subject_alt_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"localhost"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;CertifiedKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_pair&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_simple_self_signed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject_alt_names&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;rustls&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ServerConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.with_no_client_auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.with_single_cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cert_der&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key_der&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tls_acceptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TlsAcceptor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the browser hits &lt;code&gt;https://localhost:14204/callback&lt;/code&gt;, it shows a certificate warning. The user clicks through, and the callback completes. Not the most elegant UX, but it works — and the TLS handshake failure from the initial browser check is handled gracefully with a retry loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tls_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;tls_acceptor&lt;/span&gt;&lt;span class="nf"&gt;.accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Browser cert warning — wait for retry&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A critical detail: the TCP listener must be bound &lt;strong&gt;before&lt;/strong&gt; opening the browser to prevent a race condition where the redirect arrives before the server is ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub: Why the Search API Wasn't Enough
&lt;/h2&gt;

&lt;p&gt;GitHub's Search API seems like the obvious choice for file search. But it has a frustrating limitation: not all files are indexed. Repositories need to meet certain criteria, and even then the index can be stale.&lt;/p&gt;

&lt;p&gt;Instead, I use the &lt;strong&gt;Trees API&lt;/strong&gt; — fetch the entire file tree for each repository in a single recursive call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns every file path in the repository. I cache the results for 5 minutes and do case-insensitive matching client-side. To avoid hammering GitHub's rate limits, tree fetches are batched 10 repos at a time using &lt;code&gt;futures::future::join_all&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The search results are ranked by relevance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exact filename match (highest)&lt;/li&gt;
&lt;li&gt;Partial filename match&lt;/li&gt;
&lt;li&gt;Shorter path depth (higher-level files = more relevant)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the cache refresh fails (network issues, rate limiting), the system gracefully degrades to the stale cache instead of returning an error. For a search feature, showing slightly outdated results is better than showing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global Shortcut with Debouncing
&lt;/h2&gt;

&lt;p&gt;OmniFile is designed to pop up when you hit a keyboard shortcut (like Spotlight or Alfred). Tauri's &lt;code&gt;global_shortcut&lt;/code&gt; plugin handles this, but I needed a few extras:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debouncing&lt;/strong&gt;: Without it, holding the shortcut key triggers multiple show/hide cycles. A 300ms threshold prevents this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback on failure&lt;/strong&gt;: If registering a new shortcut fails (e.g., it's already claimed by another app), the system automatically re-registers the old shortcut. The user never loses their ability to summon the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three-level persistence&lt;/strong&gt;: The shortcut is stored in memory (fast access), persisted to a settings file (survives restart), and reflected in the tray menu label (user visibility).&lt;/p&gt;

&lt;h2&gt;
  
  
  iCloud: The Clever Non-Integration
&lt;/h2&gt;

&lt;p&gt;Apple doesn't provide a public API for iCloud Drive search. My solution was embarrassingly simple: detect the iCloud Drive folder at &lt;code&gt;~/Library/Mobile Documents/com~apple~CloudDocs&lt;/code&gt; and treat it as a local directory.&lt;/p&gt;

&lt;p&gt;The file watcher picks up changes, Tantivy indexes the contents, and users get iCloud search without any OAuth flow or API integration. It just works — as long as iCloud Drive syncs files to disk (which it does by default on macOS).&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Unify the OAuth config structs.&lt;/strong&gt; I have 7 separate &lt;code&gt;*OAuthConfig&lt;/code&gt; structs that are 90% identical. A trait-based approach would reduce duplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Use Tantivy's query parser for scoring.&lt;/strong&gt; My current search iterates all documents with substring matching. It works for desktop-scale data, but Tantivy's built-in BM25 scoring would be more sophisticated and faster for large indexes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Plan for token refresh from day one.&lt;/strong&gt; Some providers (Slack, Notion, GitHub) give non-expiring tokens. Others (Google, Microsoft) require refresh token flows. This divergence created special cases throughout the codebase.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Tauri 2 (Rust backend + native webview)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: React 19 + TypeScript + Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search Engine&lt;/strong&gt;: Tantivy (Rust, full-text search)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth&lt;/strong&gt;: oauth2 crate + PKCE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS&lt;/strong&gt;: rustls + rcgen (for Slack)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File Watching&lt;/strong&gt;: notify crate with debouncer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File Parsing&lt;/strong&gt;: quick-xml (DOCX/XLSX), encoding_rs (Shift-JIS)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;If you're tired of searching 5 different places for one file: &lt;a href="https://omnifile.app/" rel="noopener noreferrer"&gt;omnifile.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free tier covers local search. Pro ($129 lifetime) unlocks all cloud integrations.&lt;/p&gt;

&lt;p&gt;I'd love to hear from the dev.to community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which cloud integrations matter most to you?&lt;/li&gt;
&lt;li&gt;Would you use a CLI version alongside the GUI?&lt;/li&gt;
&lt;li&gt;Any clever search UX ideas?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment or find me on &lt;a href="https://github.com/TwistTheoryGames/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built as a solo project with Tauri + Rust. The entire app idles at ~30MB RAM. Every search query stays on your machine — no server, no telemetry.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I Built an AI Pipeline That Turns GitHub Repos into Demo Videos</title>
      <dc:creator>Kazutaka Sugiyama</dc:creator>
      <pubDate>Thu, 26 Feb 2026 05:10:59 +0000</pubDate>
      <link>https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91</link>
      <guid>https://dev.to/kazutaka-dev/how-i-built-an-ai-pipeline-that-turns-github-repos-into-demo-videos-4k91</guid>
      <description>&lt;p&gt;You know that moment when you finish building something cool, push it to GitHub, and then realize... nobody's going to read your README?&lt;/p&gt;

&lt;p&gt;I've been there too many times. So I built &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;RepoClip&lt;/a&gt; — a tool that takes a GitHub URL, analyzes the source code with AI, and generates a 60-second promo video with narration, images, and music. Automatically.&lt;/p&gt;

&lt;p&gt;In this post, I'll walk through the architecture: how I orchestrate 6+ AI services in a single pipeline, the technical decisions that actually mattered, and the mistakes I made along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline at a Glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub URL → Code Fetch → Gemini Analysis → Parallel Asset Gen → Remotion Render → MP4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple on paper. In practice, it's 13 orchestrated steps, each of which can fail in creative ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Inngest for Orchestration
&lt;/h2&gt;

&lt;p&gt;The first decision was how to manage a pipeline where each step depends on the last, calls to external APIs can take 30+ seconds, and failures need targeted retries.&lt;/p&gt;

&lt;p&gt;I went with &lt;a href="https://www.inngest.com/" rel="noopener noreferrer"&gt;Inngest&lt;/a&gt; over a simple queue for one reason: &lt;strong&gt;step-level isolation&lt;/strong&gt;. Each &lt;code&gt;step.run()&lt;/code&gt; is independently retried and logged.&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;generateVideo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inngest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate-video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retries&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/generate.requested&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-github-code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchGitHubCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;githubUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;analyze-with-gemini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;analyzeWithGemini&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;commitHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customPrompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate-assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;narrations&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;generateImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visualStyle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;generateNarrations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voice&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="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;narrations&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// ... render step&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Gemini fails, it retries that step — not the whole pipeline. If rendering fails after assets are already generated, those assets are preserved.&lt;/p&gt;

&lt;p&gt;This also enabled a &lt;strong&gt;selective refund strategy&lt;/strong&gt;: credits are only refunded if the render step fails, because at that point we've already consumed API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teaching Gemini to Think Like a Video Producer
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't calling the API — it was prompt engineering for consistent, structured output.&lt;/p&gt;

&lt;p&gt;Gemini 2.5 Flash analyzes the repository code and returns a &lt;code&gt;VideoConfig&lt;/code&gt; JSON: title, scenes, narration scripts, image prompts, voice selection, and styling. Here's what I learned:&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sandwich Technique
&lt;/h3&gt;

&lt;p&gt;Custom user instructions (like "make it sound enthusiastic" or "use anime style visuals") get placed &lt;strong&gt;both before and after&lt;/strong&gt; the code content in the prompt. This "sandwich" structure significantly improved instruction adherence — placing them only at the beginning caused Gemini to "forget" them after processing thousands of lines of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Language Detection ≠ Prompt Language
&lt;/h3&gt;

&lt;p&gt;A Japanese developer writing a custom prompt in Japanese might still have an English-language repository. The system detects the repository's language from README content, code comments, and UI strings — &lt;strong&gt;not&lt;/strong&gt; from the custom prompt language. This was a subtle but important distinction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Prompts Stay English
&lt;/h3&gt;

&lt;p&gt;Regardless of the detected language, all &lt;code&gt;imagePrompt&lt;/code&gt; fields are generated in English. Why? Flux.2 (our image model) performs significantly better with English prompts. The narration and titles are in the detected language, but image generation always goes through English.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parallel Asset Generation
&lt;/h2&gt;

&lt;p&gt;Once Gemini returns the video structure, we generate images and narrations &lt;strong&gt;in parallel&lt;/strong&gt; for all scenes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;narrations&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="c1"&gt;// All 5-8 images generated simultaneously via Fal.ai&lt;/span&gt;
  &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;generateImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imagePrompt&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="c1"&gt;// All narrations generated simultaneously via OpenAI TTS&lt;/span&gt;
  &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;generateNarration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;narration&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cuts asset generation time from ~60s (sequential) to ~12s. The tradeoff is higher burst API usage, but for a credit-based SaaS the speed improvement is worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audio Duration: The Surprisingly Hard Problem
&lt;/h3&gt;

&lt;p&gt;To compose the video, I need to know exactly how long each narration clip is. My approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Primary&lt;/strong&gt;: Parse the actual MP3 with &lt;code&gt;music-metadata&lt;/code&gt; to get precise duration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback&lt;/strong&gt;: Estimate from word count at 130 words/minute&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fallback exists because &lt;code&gt;music-metadata&lt;/code&gt; occasionally fails on certain MP3 encodings. A 3-second minimum prevents empty scenes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remotion for Programmatic Video
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.remotion.dev/" rel="noopener noreferrer"&gt;Remotion&lt;/a&gt; lets you write video compositions as React components. Each scene is a &lt;code&gt;&amp;lt;Sequence&amp;gt;&lt;/code&gt; with calculated frame offsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateStartFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&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;durationFrames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audioDuration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fps&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;startFrame&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;durationFrames&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SceneSlide&lt;/span&gt; &lt;span class="na"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Audio&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audioUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ken Burns Effect in React
&lt;/h3&gt;

&lt;p&gt;Every scene uses a slow zoom (1x → 1.08x) over its duration to keep static images visually engaging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;],&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="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;extrapolateRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small detail, but it makes the difference between "slideshow" and "video."&lt;/p&gt;

&lt;h3&gt;
  
  
  Responsive Across Aspect Ratios
&lt;/h3&gt;

&lt;p&gt;RepoClip supports 16:9, 9:16 (Reels/Shorts), and 1:1 (social). Font sizes and layout scale based on viewport dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPortrait&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;width&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;fontSize&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;round&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPortrait&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.056&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.033&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach avoids maintaining separate templates per ratio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error Handling: The 80% of the Work
&lt;/h2&gt;

&lt;p&gt;Every external API fails differently. Here's my retry strategy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Backoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Fetch&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Exponential (1s → 2s → 4s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini Analysis&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Fixed 5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image Gen (per image)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Fixed 2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS (per scene)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Fixed 2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video Render&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why exponential for GitHub but fixed for AI services? GitHub rate limits respond well to backoff. AI services either work or they don't — waiting longer rarely helps.&lt;/p&gt;

&lt;p&gt;Each failure is wrapped in a &lt;code&gt;PipelineError&lt;/code&gt; that captures the stage name, a user-friendly message, and the raw error for Sentry. This lets me show users "Image generation failed, retrying..." instead of a cryptic 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Cache more aggressively.&lt;/strong&gt; I cache Gemini analysis by repo+commit hash, but I should also cache at the asset level. Re-generating images for a retry is wasteful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Start with webhooks, not polling.&lt;/strong&gt; Remotion Lambda supports webhook notifications, but I started with polling (every 3s, max 200 attempts). Polling is simpler to implement but wasteful. I'm migrating to webhooks now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Don't underestimate prompt testing.&lt;/strong&gt; I spent more time tuning Gemini prompts than writing actual application code. If you're building AI features, budget 40% of development time for prompt iteration.&lt;/p&gt;

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

&lt;p&gt;For anyone curious about the full stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js (App Router) + TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration&lt;/strong&gt;: Inngest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Analysis&lt;/strong&gt;: Gemini 2.5 Flash&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Generation&lt;/strong&gt;: Flux.2 via Fal.ai&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narration&lt;/strong&gt;: OpenAI TTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Rendering&lt;/strong&gt;: Remotion Lambda&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database/Auth/Storage&lt;/strong&gt;: Supabase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments&lt;/strong&gt;: Lemon Squeezy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt;: Vercel&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;If you want to see what this produces, try it on your own repo: &lt;a href="https://repoclip.io/" rel="noopener noreferrer"&gt;repoclip.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The free tier gives you 2 videos/month — enough to test it on your side projects and see how the pipeline handles your codebase.&lt;/p&gt;

&lt;p&gt;I'd love feedback from the dev.to community, especially on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What kind of repos produce the best/worst results?&lt;/li&gt;
&lt;li&gt;Is 60 seconds the right length, or would shorter/longer be better?&lt;/li&gt;
&lt;li&gt;Any feature ideas for the roadmap?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment or find me on &lt;a href="https://github.com/TwistTheoryGames/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Happy to discuss the architecture in more detail.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built as a solo dev project. The entire pipeline from URL input to rendered MP4 takes about 2-3 minutes. Still feels like magic every time.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>remotion</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
