<?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: snipforge</title>
    <description>The latest articles on DEV Community by snipforge (@snipforge).</description>
    <link>https://dev.to/snipforge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3953436%2F3820fef0-c3f0-499e-87a0-8b1ab1b36de1.jpg</url>
      <title>DEV Community: snipforge</title>
      <link>https://dev.to/snipforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/snipforge"/>
    <language>en</language>
    <item>
      <title>The Function That Lied: How a Duplicate def Silently Broke GIF Trimming in My 16,000-Line Flask App.</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Sat, 15 Aug 2026 05:07:31 +0000</pubDate>
      <link>https://dev.to/snipforge/the-function-that-lied-how-a-duplicate-def-silently-broke-gif-trimming-in-my-16000-line-flask-app-3a0h</link>
      <guid>https://dev.to/snipforge/the-function-that-lied-how-a-duplicate-def-silently-broke-gif-trimming-in-my-16000-line-flask-app-3a0h</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://snipforge.video" rel="noopener noreferrer"&gt;snipforge.video&lt;/a&gt; is an AI video editing SaaS I run solo. Users upload a video and run tools on it in the browser: trimming, compression, GIF creation, AI dubbing with speaker diarization, auto captions, background removal, and about twenty other operations. Under the hood it is a single-file Flask app of roughly 16,000 lines, deployed on Railway, with FFmpeg doing the heavy lifting and Cloudflare R2 storing the media.&lt;/p&gt;

&lt;p&gt;Being a solo founder means I am also the QA team, which is fitting because my day job is Lead QE. This bug still got past me for a while, and the reason it did is the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The GIF tool lets users pick a start and end time so they can turn a five second moment into a GIF instead of converting the whole video. Users were reporting that clip selection did nothing. Pick seconds 12 to 17 of a three minute video and you get a GIF of the entire three minutes: wrong content, a bloated file, and a job that takes far longer than it should.&lt;/p&gt;

&lt;p&gt;The route handler looked correct. It read &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt; from the request and passed them into the worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;convert_gif&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function signature looked correct too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_gif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&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="n"&gt;end&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No errors, no exceptions, no log lines. Everything about the call site said this feature worked.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;convert_gif&lt;/code&gt; was defined &lt;strong&gt;twice&lt;/strong&gt; in the file. Once with full trim support, and again about 200 lines below it as an older, simpler version. Python does not warn you about duplicate function definitions in the same module. The second &lt;code&gt;def&lt;/code&gt; silently rebinds the name, so the old version won. Its signature accepted &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt;, but its body never referenced them. The parameters were accepted, then thrown away.&lt;/p&gt;

&lt;p&gt;A 16,000-line single file made this easy to miss. Both definitions are 200 lines apart, both have identical signatures, and nothing in the runtime behavior distinguishes "trim ignored" from "trim never requested."&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;snipforge is a closed-source production app, so instead of a repo link I am sharing the exact changes as snippets. Function and file names are lightly genericized; the diff is otherwise verbatim.&lt;/p&gt;

&lt;p&gt;The fix itself is deliberately minimal. Delete the shadowing duplicate, keep the complete implementation (which trims via FFmpeg before the palette pass and cleans up its temp files), and leave a guard comment where the duplicate lived:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NOTE: an older duplicate `convert_gif` used to be defined here. Python silently lets a
# second same-name def shadow the first, and the older copy ignored its start/end
# params, so clip-range GIFs converted the ENTIRE video. Keep exactly one convert_gif
# (defined above, with trim support). Do not re-add a second definition.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before and after, same source video, same requested 5 second range:&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;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requested range&lt;/td&gt;
&lt;td&gt;12s to 17s&lt;/td&gt;
&lt;td&gt;12s to 17s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output duration&lt;/td&gt;
&lt;td&gt;180s&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output size&lt;/td&gt;
&lt;td&gt;48 MB&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job time&lt;/td&gt;
&lt;td&gt;74s&lt;/td&gt;
&lt;td&gt;6s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;A few decisions worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why delete instead of merge.&lt;/strong&gt; The surviving implementation already did everything the old one did, plus trimming, plus a palette-generation fallback, plus temp file cleanup on both success and failure paths. There was nothing in the duplicate worth keeping. The tempting move was to rename one of them and keep both around "just in case," which is exactly how the file got to 16,000 lines in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verifying the fix without a test harness for FFmpeg.&lt;/strong&gt; Unit testing FFmpeg pipelines is painful, so I verified structurally: an AST walk confirming exactly one &lt;code&gt;convert_gif&lt;/code&gt; definition remains and that its body actually references the trim parameters. That is the check that would have caught this bug on day one, since the broken version parses cleanly and runs happily.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;
&lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;gifs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FunctionDef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;convert_gif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gifs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have since generalized this into a lint step that fails on any duplicate top-level &lt;code&gt;def&lt;/code&gt; in the module. Pylint's &lt;code&gt;function-redefined&lt;/code&gt; (E0102) catches this class of bug for free, and I was not running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A known limitation I chose to keep.&lt;/strong&gt; The trim uses &lt;code&gt;-ss&lt;/code&gt; before &lt;code&gt;-i&lt;/code&gt; with &lt;code&gt;-c copy&lt;/code&gt;, which is keyframe seeking. Depending on GOP size the clip can start up to a couple of seconds early. For GIF creation that trade-off is fine, since the alternative is a frame-accurate re-encode that roughly doubles job time for a format where nobody notices a keyframe of slack. Worth knowing it is there, though.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real lesson.&lt;/strong&gt; This bug threw no exception, logged nothing, and returned a successful result every single time. The signature was lying and nothing in the runtime could tell. The only ways to catch a bug like this are static analysis or telemetry that compares what was requested against what was produced, which is where Sentry came in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;I instrumented the job pipeline with Sentry's Flask SDK and added distributed tracing around each FFmpeg operation. The key addition for this bug class is a span attribute that records the requested clip range next to the actual output duration probed from the finished file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sentry_sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video.gif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GIF conversion&lt;/span&gt;&lt;span class="sh"&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;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested_end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... run the ffmpeg pipeline ...
&lt;/span&gt;    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_duration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_duration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&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 broken code, every GIF trace shows the same signature: &lt;code&gt;requested_end - requested_start&lt;/code&gt; of a few seconds, &lt;code&gt;output_duration&lt;/code&gt; equal to the full source. No error monitoring in the world flags that, because nothing errored. But the trace data makes the lie visible at a glance, and a simple Sentry dashboard query for jobs where output duration exceeds the requested range by more than a threshold turns a silent bug into an alert.&lt;/p&gt;

&lt;p&gt;The broader win is that snipforge, a production app with real users, had zero error monitoring before this challenge. It now has error tracking and per-operation tracing across the whole FFmpeg pipeline.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
    </item>
    <item>
      <title>The Video That Played Everywhere Except on Phones: A Codec Bug Hiding Behind a Healthy Server.</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Sat, 15 Aug 2026 05:07:05 +0000</pubDate>
      <link>https://dev.to/snipforge/the-video-that-played-everywhere-except-on-phones-a-codec-bug-hiding-behind-a-healthy-server-4aff</link>
      <guid>https://dev.to/snipforge/the-video-that-played-everywhere-except-on-phones-a-codec-bug-hiding-behind-a-healthy-server-4aff</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://snipforge.video" rel="noopener noreferrer"&gt;snipforge.video&lt;/a&gt;, an AI video editing SaaS, solo. One of its core features is share links: process a video, get a URL, send it to someone. A user reported that a shared video showed 0:00 and refused to play on their iPhone. I opened the same link on desktop Chrome and it played perfectly. Classic "works on my machine," except my machine was every desktop and the failure was every iPhone.&lt;/p&gt;

&lt;p&gt;This is the worst kind of bug report for a solo founder, because the person hitting it is usually not your user. It is the person your user shared the video with. They do not file a bug. They just conclude your product is broken and close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong suspects
&lt;/h2&gt;

&lt;p&gt;Everything about the symptom pointed at the serving path, so that is where I went first. Shared files live on Cloudflare R2 behind the app, and mobile Safari is famously picky about how video is served, so I had a lineup of plausible suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Range request support, because Safari refuses to play video from servers that do not honor byte ranges&lt;/li&gt;
&lt;li&gt;The moov atom, because an MP4 without &lt;code&gt;faststart&lt;/code&gt; makes Safari wait for metadata that lives at the end of the file&lt;/li&gt;
&lt;li&gt;Content-Type headers coming off R2&lt;/li&gt;
&lt;li&gt;CORS, because it is always legal to suspect CORS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked each one. Range requests: honored, correct 206 responses with proper &lt;code&gt;Content-Range&lt;/code&gt;. Faststart: moov atom at the front, verified. Headers: &lt;code&gt;video/mp4&lt;/code&gt;, correct. Every infrastructure suspect had an alibi. The serving path was completely healthy, which was somehow worse news, because now the file itself was the suspect and the file was "an MP4 that plays fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual culprit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ffprobe&lt;/code&gt; on the shared file settled it in one line. The container was MP4, but the video stream inside it was VP9. Desktop Chrome plays VP9 in an MP4 wrapper without complaint. iPhone Safari does not, and it fails the way Safari loves to fail: no error, no console message, just a player showing 0:00 forever.&lt;/p&gt;

&lt;p&gt;The file extension said .mp4, the Content-Type said video/mp4, and both were telling the truth about the container while saying nothing about the codec. Every layer of the stack was technically honest and the sum was a lie.&lt;/p&gt;

&lt;p&gt;The fix belonged in the share pipeline's web-optimize step: probe the actual codecs, and if the video is not H.264 or the audio is not AAC, re-encode to the one combination that plays everywhere, instead of trusting the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that fixed nothing
&lt;/h2&gt;

&lt;p&gt;Here is the part I am actually writing this post for.&lt;/p&gt;

&lt;p&gt;My first patch probed the streams with ffprobe's CSV output, something shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;codec_type,codec_name &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 input.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the parser assuming each line came back as &lt;code&gt;codec_type,codec_name&lt;/code&gt;, so &lt;code&gt;video,vp9&lt;/code&gt;. Deployed it, re-shared the test video, still 0:00 on the iPhone.&lt;/p&gt;

&lt;p&gt;The re-encode condition never fired, because ffprobe prints the fields in its own fixed order, not the order you listed them in &lt;code&gt;-show_entries&lt;/code&gt;. The actual output was &lt;code&gt;vp9,video&lt;/code&gt;. My parser was comparing the codec name against the string "video", the match always failed, the code concluded every file was fine, and the function returned the original untouched file. No exception, no log noise, a clean deploy, and zero effect.&lt;/p&gt;

&lt;p&gt;A fix that runs successfully and does nothing is more dangerous than a fix that crashes, because a crash tells you where to look. This one told me nothing, and for a little while I went back to re-suspecting the innocent serving path.&lt;/p&gt;

&lt;p&gt;Printing the raw ffprobe output side by side with what my parser thought it saw ended the mystery in about thirty seconds. The lesson was old and evergreen: when parsing tool output, log the raw input next to the parsed result, because your mental model of the format is a hypothesis, not a fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The after
&lt;/h2&gt;

&lt;p&gt;With the parser reading fields in the right order, the probe correctly flagged VP9, the pipeline re-encoded to H.264/AAC with faststart, and the same share link played on the same iPhone immediately.&lt;/p&gt;

&lt;p&gt;The durable wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every shared video now gets a codec compatibility probe before it is served, so the entire class of "plays on desktop, dead on mobile" bugs is closed, not just this instance&lt;/li&gt;
&lt;li&gt;I stopped trusting containers. An .mp4 extension is a promise about the box, not the contents&lt;/li&gt;
&lt;li&gt;My web-optimize step now logs raw probe output alongside the parsed verdict, so the next silent parser bug has nowhere to hide&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I am proud of
&lt;/h2&gt;

&lt;p&gt;Not the fix itself, which is twenty lines. It is that the debugging held discipline when the evidence was contradictory: the server was provably healthy AND the file provably did not play. Instead of picking a side and flailing, following the contradiction down one more layer found the third option nobody was accusing, the codec inside a truthful container.&lt;/p&gt;

&lt;p&gt;Also, mild pride in having personally shipped, broken, and re-shipped the fix within the same day, which is the full solo founder experience in miniature.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>video</category>
    </item>
    <item>
      <title>The Leaf Is the Page: My Mother's Sunday Meal, Served in Eating Order</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Mon, 03 Aug 2026 00:34:51 +0000</pubDate>
      <link>https://dev.to/snipforge/the-leaf-is-the-page-my-mothers-sunday-meal-served-in-eating-order-3bc8</link>
      <guid>https://dev.to/snipforge/the-leaf-is-the-page-my-mothers-sunday-meal-served-in-eating-order-3bc8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fov8aci99z4qwf9xgdywm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fov8aci99z4qwf9xgdywm.png" alt=" " width="747" height="717"&gt;&lt;/a&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, Perfect Landing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The leaf is the page.&lt;/p&gt;

&lt;p&gt;For my CSS Art entry, I drew my mother's Sunday meal: sixteen dishes on a banana leaf, each one placed where Telugu tradition puts it. For Perfect Landing, that artwork became the navigation. Tap any dish on the leaf and the page takes you to that dish's course. Scroll instead, and you move through the meal in eating order: ghee first, then the curries, the pulusu, rasam, the rice varieties, the crunch, the sweet, and finally perugu. The scroll is the serving order. The structure of the page is the structure of the meal.&lt;/p&gt;

&lt;p&gt;It is deliberately not a restaurant. No menu cards, no reservation form, no gallery. One family, one Sunday, eight courses, and the rules my mother enforces at each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I cared most about:
&lt;/h2&gt;

&lt;p&gt;a screen reader is served this meal the same way my mother serves it. The heading order, the tab order, and the reading order all follow the eating order. Tap targets on the leaf move focus to the course they open, so keyboard and screen reader users travel with everyone else. Telugu headings carry lang="te" so they are pronounced as Telugu, not mangled as English. The course nav marks where you are. And with reduced motion on, the smooth scrolling and the ghee-pour animation both settle down together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/editor/Vimala-Gandham/embed/019fc4e1-c7ce-7f2c-bb9d-633bb56f777e?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to try:
&lt;/h2&gt;

&lt;p&gt;tap the rice mound (or the ghee spoon) on the leaf and see where it takes you. Press Tab from the top of the page and watch the skip link appear before anything else. Scroll and watch the Telugu nav track your course. Turn on reduced motion and take the calm version of the same journey.&lt;/p&gt;

&lt;p&gt;Every visual on the page is CSS. No images, no SVG, no canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;The concept came from the eating itself. On a banana leaf, order is information: neyyi before anything, perugu always last. Most landing pages invent an information architecture. This meal already had one, and it is thirty years older than CSS grid. My whole job was not to break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing the art as UI:
&lt;/h2&gt;

&lt;p&gt;The sixteen-dish leaf from my CSS Art entry dropped in as the hero almost unchanged. The dishes already had names and tap behavior; the landing layer only adds a data-section per dish and a small script that shows the Telugu name for a beat, then scrolls you to the course and moves focus there. The art and the page share one visual language because they are literally the same code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility as the design, not the audit:
&lt;/h2&gt;

&lt;p&gt;I made the accessibility decisions before the visual ones this time, and it changed what I built:&lt;/p&gt;

&lt;p&gt;The heading outline is the menu: one h1, eight h2s, in serving order. If you read only the headings, you have eaten the meal.&lt;br&gt;
lang="te" on every Telugu heading. Without it, screen readers attempt Telugu words with English phonetics, and my mother would not forgive the pronunciation.&lt;br&gt;
After leaf navigation, focus moves to the target course with tabindex="-1". Scrolling a sighted user somewhere while leaving a keyboard user behind is not navigation.&lt;br&gt;
The course nav highlight uses IntersectionObserver with aria-current, so "where am I" is exposed to assistive tech, not just painted on.&lt;br&gt;
scroll-margin-top on every course so the sticky header never swallows a heading you just navigated to.&lt;br&gt;
prefers-reduced-motion turns off smooth scrolling and the ghee animation in the same query. Calm should be consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately left out:
&lt;/h2&gt;

&lt;p&gt;This challenge's Perfect Landing pool is full of beautiful cultural food pages, several with menus, modals, and reservation forms. I cut all of it. Modals are where keyboard experiences usually go to die, and a fictional reservation form on a page about my mother's kitchen would be a lie. The page does one thing: it serves the meal in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm proud of:
&lt;/h2&gt;

&lt;p&gt;the sentence I kept testing against was "a blind visitor is served this meal the way amma serves it." Getting the reading order, focus order, and serving order to be the same order took more restraint than code, and restraint turned out to be the whole design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next:
&lt;/h2&gt;

&lt;p&gt;recordings of the Telugu dish names in a real voice, and a festival-day mode where the sweet moves to the front of the leaf, because on festival days, it does.&lt;/p&gt;

&lt;p&gt;This work is licensed under the MIT License.&lt;/p&gt;

&lt;p&gt;If you build a page about your family's food, here is my one suggestion: find the order your house eats in, and let it be your information architecture. Every kitchen already has one.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>frontendchallenge</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>అమ్మ చేతి భోజనం - My Mother's Sunday Meal, All 16 Dishes, in CSS on a Banana Leaf.</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Mon, 03 Aug 2026 00:04:44 +0000</pubDate>
      <link>https://dev.to/snipforge/amm-ceeti-bhoojnn-my-mothers-sunday-meal-all-16-dishes-in-css-on-a-banana-leaf-2jcp</link>
      <guid>https://dev.to/snipforge/amm-ceeti-bhoojnn-my-mothers-sunday-meal-all-16-dishes-in-css-on-a-banana-leaf-2jcp</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;On a banana leaf, nothing is placed randomly. The leaf tip points left toward the guest. The wet dishes stay above the midrib, the rice below it. Perugu comes at the end of the meal but sits ready from the start. And before anyone touches anything, a spoon of neyyi goes over the hot rice. Ghee first, always. That is the rule in my mother's kitchen and in most Telugu homes.&lt;/p&gt;

&lt;p&gt;So I did not draw a dish for this challenge. I drew the whole system: my mother's Sunday meal, all sixteen items, each one sitting exactly where tradition puts it. Mango pappu, gongura pulihora, dondakaya pakodi, munakkaya jeedipappu kura, dappalam. If you grew up eating this, you already know where everything is on the leaf before you look.&lt;/p&gt;

&lt;p&gt;Tap any dish and it tells you its name in Telugu. Watch the rice for a few seconds and the ghee spoon does its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/editor/Vimala-Gandham/embed/019fc4bb-7d8d-7bfe-8235-b1800fcce2d4?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Things to try: tap (or Tab and Enter through) every dish for its Telugu name, wait for the neyyi pour over the rice, and turn on reduced motion to see the calm version. Everything you see is CSS. The JavaScript is about 35 lines and does exactly one thing: show you the names.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;Sixteen dishes sounds like sixteen problems, but it collapsed into three reusable patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mound.&lt;/strong&gt; A soft irregular shape via border-radius slash syntax, and then the texture trick that carries half the piece: every rice grain is one box-shadow. Two pseudo-elements act as grain sprayers at opposite rotations, and each shadow entry is a tiny pill in a slightly different shade. The same pattern became gongura pulihora in greens with peanut dots, and veg pulav in golds with peas and carrot bits. Three rices, one technique.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clay pot.&lt;/strong&gt; One .pot class draws the terracotta rim, and each bowl only supplies its liquid as stacked radial-gradients: cashews and raisins floating in paramannam, a raw mango wedge in the pappu, a striped drumstick piece in the sambar. Five bowls, one pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The flat fried disc.&lt;/strong&gt; Masala vada, aratikaya bajji, and the appadam beside the banana, gradients for the browning, scattered blister dots and darker patches for the uneven fry.&lt;/p&gt;

&lt;p&gt;Some things that fought back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;My leaf was a pill.&lt;/strong&gt; Early on the leaf rendered as a rounded rectangle because my vertical border-radius values were flattening the sides. A banana leaf is a soft ellipse, and it took embarrassingly long to see that the fix was smaller numbers, not more code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My vada was a cinnamon roll.&lt;/strong&gt; I textured it with a repeating-radial-gradient ring, and the concentric circles read as a swirl instead of a fry. Irregular radial patches fixed it. Making food look accidental is harder than making it look neat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unclosed brace ate a third of my stylesheet.&lt;/strong&gt; For two days my name tags rendered as unstyled text and I blamed my clipboard, my browser, and CodePen. It was one missing closing brace after a box-shadow list, silently swallowing every rule after it. Brace-balance checking is now part of my workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The steam was a metronome.&lt;/strong&gt; Three wisps on one shared animation looked mechanical. Splitting them into three keyframe variants with different drift directions and durations (3.4s, 4.1s, 3.7s) made the plume feel alive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The perugu has a small technique moment I like: a low-contrast conic-gradient masked into a soft ring, which reads as the swirl on the surface of curd. Subtle on purpose. Curd is quiet.&lt;/p&gt;

&lt;p&gt;And a late accessibility retrofit that another entry in this challenge shamed me into: my tap targets were divs with click handlers, invisible to a keyboard. Now every dish is focusable with a visible ring, Enter and Space work, each one carries an aria-label with both names, and the tab order walks the meal in serving order. The ghee animation also respects prefers-reduced-motion: the spoon rests, and a gentle permanent glisten stays on the rice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'm proud of:&lt;/strong&gt; Anyone can draw food. The thing I wanted to get right is that this leaf is arranged the way a real one is served, and the Telugu names are there because the dish and its name are the same memory. My mother has never written a line of CSS, but she art-directed this piece thirty years in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's next:&lt;/strong&gt; a second leaf for festival meals, where the sweet moves to the front. And maybe the sound of the appadam.&lt;/p&gt;

&lt;p&gt;This work is licensed under the &lt;a href="https://opensource.org/licenses/MIT" rel="noopener noreferrer"&gt;MIT License&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So, what is on your leaf, thali, or plate at home, and where does it sit? I have a feeling everyone's mother has rules.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Rivalry Radio: a fake late-night call-in show where two AI superfans argue about anything.</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Sun, 12 Jul 2026 01:35:17 +0000</pubDate>
      <link>https://dev.to/snipforge/rivalry-radio-a-fake-late-night-call-in-show-where-two-ai-superfans-argue-about-anything-59bn</link>
      <guid>https://dev.to/snipforge/rivalry-radio-a-fake-late-night-call-in-show-where-two-ai-superfans-argue-about-anything-59bn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built:
&lt;/h2&gt;

&lt;p&gt;Rivalry Radio is a fake late-night call-in radio show. You type in both sides of any rivalry, hit GO LIVE, and Roxy, a smooth radio veteran, introduces two superfans: Max, who is brash and overconfident, and Poppy, who is calm and devastatingly polite. They argue about whatever you typed while the transcript lights up line by line and an ON AIR sign glows with a bouncing equalizer. Gemini writes the debate, ElevenLabs voices all three characters, and the whole thing runs off a single Cloudflare Worker with no database, no storage, and no FFmpeg.&lt;/p&gt;

&lt;p&gt;I spend my nights building an AI video editor as a side project, so I already had opinions about audio pipelines. This weekend I wanted to find out how fast I could ship something with the same muscles but zero seriousness. A working version took one evening. A good one took the weekend, and most of that gap was two free tiers teaching me their fine print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo:
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://rivalry-radio.gandhamvimala.workers.dev/" rel="noopener noreferrer"&gt;https://rivalry-radio.gandhamvimala.workers.dev/&lt;/a&gt;&lt;br&gt;
Repo: &lt;a href="https://github.com/gandhamvimala/Rivalry-Radio" rel="noopener noreferrer"&gt;https://github.com/gandhamvimala/Rivalry-Radio&lt;/a&gt;&lt;br&gt;
The demo covers two broadcasts: working from home vs office, then window seat vs aisle seat, both on unhinged mode.&lt;/p&gt;

&lt;p&gt;Favorite lines so far, none of which any human wrote: Max claiming he sleeps against the airplane window "like a glorious, vibrating king," Poppy describing him as "a sad, hostage puppy staring at a boring wall of clouds," and Roxy closing the show by choosing the middle seat "just to watch them both squirm."&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Google AI:
&lt;/h2&gt;

&lt;p&gt;Gemini writes each debate as strict JSON: three speakers, 10 to 12 lines, instructed to start reasonable and escalate to absurd. The single biggest quality jump came from one line in the prompt: if you don't know real facts about the rivalry, invent oddly specific personal anecdotes instead. That instruction does more work than the rest of the prompt combined.&lt;br&gt;
Giving the speakers names and personalities was the second biggest jump. Once the prompt described Max as worked up and Poppy as composed but sharper, and told them to address each other by name, the output stopped sounding like two chatbots and started sounding like a show.&lt;br&gt;
Things that bit me. Gemini 2.5's internal thinking tokens count against maxOutputTokens, so my scripts kept truncating mid-JSON until I set thinkingBudget to zero, since a comedy script does not need chain of thought. Then I burned through the free tier's daily request quota in an afternoon of testing and discovered quotas are per model, so I moved to gemini-3.5-flash and got a fresh bucket plus, honestly, funnier scripts. It also occasionally wraps JSON in markdown fences even when told not to, so the Worker strips fences, validates every speaker, and retries up to  twice.&lt;br&gt;
Temperature matters more than I expected. At defaults the fans politely agreed to disagree, which is terrible radio. Unhinged mode runs at 1.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used ElevenLabs:
&lt;/h2&gt;

&lt;p&gt;Each character maps to a different ElevenLabs voice: Roxy the host, Max the worked-up superfan, and Poppy, whose velvety British delivery makes her polite takedowns land twice as hard. The mapping lives server side, so the client only ever sends a speaker name, never a voice ID or an API key.&lt;br&gt;
The free tier taught me lessons the docs make you earn. Community library voices are not usable via the API on the free plan, only the premade voices are, which I learned through a 402 after carefully auditioning three library voices. API keys have per-endpoint permission scopes, so a key without text_to_speech access fails even when the key itself is valid. The free tier allows 4 concurrent requests, so firing all 12 voice clips in parallel rate-limited my own app, and the frontend now fetches clips in batches of 3. And after a day of testing I hit the monthly credit ceiling entirely, so I upgraded to the Starter plan, which also unlocked the community voice library via the API and let me cast the sarcastic fan voice I had wanted from day one.&lt;/p&gt;

&lt;p&gt;The voices also sounded too composed at first. The fix was half prompt and half settings: I told Gemini to write for voice acting, with exclamation marks, ellipses for dramatic pauses, and the occasional ALL CAPS word, then dropped stability to 0.3 and raised style to 0.6. Punctuation is basically stage direction for TTS.&lt;/p&gt;

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

&lt;p&gt;One Cloudflare Worker serves the page and proxies both APIs. Keys live in Wrangler secrets and never touch the client or the repo. Input is capped at 60 characters per side and 12 lines per script before anything reaches an API, and there is per-IP rate limiting on top. The frontend fetches the clips in batches, then plays them back to back while the current transcript line glows and tiny speaking bars pulse next to whoever has the mic. I originally planned to stitch the audio into one file with FFmpeg, then realized sequential playback with a live transcript is a better effect and requires nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned:
&lt;/h2&gt;

&lt;p&gt;Most of the real work in an AI mashup is not the happy path. It is the tier limits, permission scopes, and token accounting of two platforms meeting in one request cycle. I hit five different platform limits in one weekend and every one of them made the app better: batching made loading feel more alive with a progress counter, the premade voice restriction forced a cast that fits the characters, and the quota wall pushed me onto a newer model that writes sharper jokes.&lt;br&gt;
Also, comedy prompting is real work. The distance between a flat script and a funny one is about four sentences of instructions and one personality each.&lt;/p&gt;

&lt;p&gt;Built solo over the challenge weekend. AI tools were used throughout, which the rules allow, and honestly that is the realistic scenario this challenge is testing.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>elevenlabs</category>
      <category>googleaichallenge</category>
    </item>
    <item>
      <title>Enigma Breaker - A Cipher Decoding Game Built as an Ode to Alan Turing published</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Fri, 05 Jun 2026 22:28:26 +0000</pubDate>
      <link>https://dev.to/snipforge/enigma-breaker-a-cipher-decoding-game-built-as-an-ode-to-alan-turingpublished-5hb2</link>
      <guid>https://dev.to/snipforge/enigma-breaker-a-cipher-decoding-game-built-as-an-ode-to-alan-turingpublished-5hb2</guid>
      <description>&lt;p&gt;This is a submission for the June Solstice Game Jam&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/ben"&gt;@ben&lt;/a&gt; &lt;a class="mentioned-user" href="https://dev.to/jess"&gt;@jess&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;What I Built:&lt;br&gt;
Enigma Breaker is a cipher decoding puzzle game set at Bletchley Park in 1942. Enemy messages have been intercepted and encrypted with a substitution cipher. Your job is to figure out the letter substitutions and decode the full message before the timer runs out.&lt;br&gt;
The game has 5 levels of increasing difficulty, a story card system that walks through Alan Turing's real biography between each level, a trivia quiz after each story card, and a final 5-question debrief at the end. Three difficulty modes, star ratings per level, hint system and high score tracking with localStorage.&lt;/p&gt;

&lt;p&gt;Play it here:&lt;br&gt;
&lt;iframe height="600" src="https://codepen.io/editor/Vimala-Gandham/embed/019e99d5-4435-75fd-8c6b-e71cf373804c?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Code:&lt;br&gt;
The full game is in a single HTML file with no external dependencies. Built entirely with vanilla HTML, CSS and JavaScript.&lt;/p&gt;

&lt;p&gt;How I Built It Cipher system:&lt;br&gt;
Each level generates a fresh random substitution cipher by shuffling the alphabet and ensuring no letter maps to itself. The player builds up a mapping between cipher letters and plain letters. The game checks for a complete correct solution after every input.&lt;/p&gt;

&lt;p&gt;How decoding works:&lt;br&gt;
The intercepted message appears in yellow at the top. The player clicks a cipher letter to select it, then clicks a letter in the alphabet row below or presses a keyboard key to map it. Correct mappings turn green. Wrong mappings flash red for 400ms and clear automatically so they do not clutter the decode panel. A progress bar fills as more letters are correctly mapped.&lt;/p&gt;

&lt;p&gt;Frequency hints:&lt;br&gt;
The five most common cipher letters in the message are shown as hints. Since E, T, A, O, I and N are the most frequent letters in English, the most common cipher letters likely map to one of those. This is the same frequency analysis technique used in real cryptanalysis.&lt;/p&gt;

&lt;p&gt;Audio:&lt;br&gt;
All sounds are synthesized with the Web Audio API. No audio files are loaded. There are distinct sounds for correct mappings, wrong guesses, the ticking clock, an alarm at 5 seconds remaining, level completion and the final win fanfare. The clock tick frequency increases as time gets lower.&lt;br&gt;
Story and quiz system&lt;br&gt;
After each level a story card appears covering a chapter of Turing's real biography. After reading it the player answers one multiple choice question for 10 bonus points. The game shows the correct answer and an explanation regardless of whether the player got it right. After all five levels there is a final quiz with five harder questions about his legacy.&lt;/p&gt;

&lt;p&gt;Message pool:&lt;br&gt;
There are 10 possible intercepted messages. Five are picked randomly each game so repeat playthroughs feel different.&lt;/p&gt;

&lt;p&gt;Prize Category:&lt;br&gt;
Best Ode to Alan Turing&lt;br&gt;
This game connects to the Alan Turing prize category in three ways.&lt;br&gt;
First, the mechanic. Substitution cipher decoding is a simplified version of the cryptanalysis work done at Bletchley Park. The frequency analysis hints mirror the actual technique used to crack substitution ciphers before automated machines took over.&lt;br&gt;
Second, the story system. The five story cards cover Turing's real biography in order, from his birth in June 1912 through the publication of the Turing Machine paper, his work breaking Enigma during World War II, the Imitation Game paper that launched the field of AI, and finally his prosecution and death in 1954. Each card is followed by a trivia question so the history is reinforced through interaction rather than passive reading.&lt;br&gt;
Third, the June connection. The final quiz question asks which modern celebration shares Turing's birth month. The answer is Pride Month. Turing was born in June 1912 and was a gay man who was prosecuted by his own government. That connection between his birth month and Pride Month is why this game exists and why it fits the June Solstice theme.&lt;/p&gt;

&lt;p&gt;Built with HTML5 and Web Audio API. No libraries or frameworks.&lt;br&gt;
June 2026&lt;/p&gt;

</description>
      <category>gamechallenge</category>
      <category>devchallenge</category>
      <category>javascript</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I Built a Juneteenth Game for the June Solstice Game Jam - March to Freedom published</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Fri, 05 Jun 2026 02:39:03 +0000</pubDate>
      <link>https://dev.to/snipforge/i-built-a-juneteenth-game-for-the-june-solstice-game-jam-march-to-freedompublished-nd4</link>
      <guid>https://dev.to/snipforge/i-built-a-juneteenth-game-for-the-june-solstice-game-jam-march-to-freedompublished-nd4</guid>
      <description>&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/ben"&gt;@ben&lt;/a&gt;  &lt;a class="mentioned-user" href="https://dev.to/jess"&gt;@jess&lt;/a&gt;  This is a submission for the June Solstice Game Jam Submission.&lt;/p&gt;

&lt;p&gt;Most survival games hand you a weapon. This one hands you a lantern.&lt;/p&gt;

&lt;p&gt;When I read the jam brief and saw Juneteenth named as one of the June celebrations, I knew I wanted to build around it. But it took me a while to find a mechanic that felt right. A holiday about freedom and community should not be a game about destroying things. I kept circling back to one idea: the goal should not be to fight. It should be to gather.&lt;/p&gt;

&lt;p&gt;So in March to Freedom, you are not a soldier. You are a leader moving across a dark field. Scattered marchers are waiting out there, and they join your group when you reach them. Shadow forces try to break the march apart. Your job is to bring 250 people home and keep them safe along the way.&lt;/p&gt;

&lt;p&gt;And every 50 marchers you rescue, the game pauses and shows you a card about real Juneteenth history, from the 1865 announcement in Galveston to the federal holiday signed in 2021.&lt;/p&gt;

&lt;p&gt;Play It Here:&lt;iframe height="600" src="https://codepen.io/editor/Vimala-Gandham/embed/019e959a-88d5-72ad-be99-8f0976c2119a?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Demo Video link :&lt;a href="https://youtu.be/XYqDxCJmCT0" rel="noopener noreferrer"&gt;https://youtu.be/XYqDxCJmCT0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick challenge before you start. My fastest run to 250 marchers is 249. If you beat it, post your result in the comments. And the mini-boss that shows up at 100 marchers has ended more of my runs than I want to admit, so if you take him down on your first try, I want to know.&lt;/p&gt;

&lt;p&gt;How it plays&lt;/p&gt;

&lt;p&gt;Your leader follows your mouse directly, with WASD as a fallback. Lanterns scattered across the field restore health and charge your abilities. Tab cycles through 5 of them, Space fires the selected one:&lt;/p&gt;

&lt;p&gt;Shield sends a ripple wave that pushes every enemy back&lt;br&gt;
Speed gives you a 3 second burst&lt;br&gt;
Torch fires projectiles outward in 8 directions&lt;br&gt;
Freeze slows all enemies for 5 seconds&lt;br&gt;
Rally teleports your scattered followers back to you&lt;/p&gt;

&lt;p&gt;The field cycles between day and night every 30 seconds. Enemies get faster in the dark, lanterns glow brighter, and when dawn breaks things ease up. That light and darkness rhythm is the solstice connection, and it changes how you play. At night you protect. At dawn you push forward.&lt;/p&gt;

&lt;p&gt;Three enemy types keep the pressure on: fast Scouts, tanky Heavies, and a Boss every 100 marchers who takes 3 torch hits and drops lanterns when he falls. Reach 250 and you get a full victory parade, confetti and all.&lt;/p&gt;

&lt;p&gt;How I built it:&lt;/p&gt;

&lt;p&gt;Single HTML file. HTML5 Canvas, Web Audio API, no libraries, no frameworks.&lt;/p&gt;

&lt;p&gt;The part that took the most work was the follower chain. Each marcher tracks the position of the person in front of them, and getting the spacing and speed right so 200 people read as one group moving together, instead of a snake or a blob, took several passes. When it finally clicked and I watched a long line of marchers curve around an enemy as a unit, that was the moment the game felt alive.&lt;/p&gt;

&lt;p&gt;The bug that taught me the most was mouse tracking. getBoundingClientRect() returns wrong numbers if you call it before the page finishes laying out, and my coordinates were off in ways that made no sense. The fix was to wrap canvas initialization in a 100ms setTimeout, read offsetWidth once after layout settled, and never recalculate. Same instinct as my QA day job: stop guessing, isolate, reproduce.&lt;/p&gt;

&lt;p&gt;A few other things under the hood: the game loop runs on delta time so speed stays consistent across frame rates, all audio is synthesized from oscillators and gain nodes with one named function per sound, and the day/night transition is driven by a sine curve over 30 seconds so it fades instead of snapping.&lt;/p&gt;

&lt;p&gt;The history cards:&lt;/p&gt;

&lt;p&gt;I spent real time making sure these are accurate. The five cards cover verified events: Gordon Granger arriving in Galveston with General Order No. 3 on June 19, 1865. The first organized celebrations in Texas in 1866. Juneteenth traveling north with the Great Migration. Al Edwards lobbying it into a Texas state holiday in 1980. And the federal holiday signed on June 17, 2021.&lt;/p&gt;

&lt;p&gt;They are short because they appear mid-game, but each one is there to be worth reading. If one of them covers something you did not know, tell me which one in the comments. For me it was Al Edwards. I knew about 1865 and 2021, but not about the 15 year stretch of work in between that made the rest possible.&lt;/p&gt;

&lt;p&gt;What I would add with more time:&lt;/p&gt;

&lt;p&gt;A marching drumbeat that builds as your group grows. More story cards for an endless mode past 250. An accessibility mode with a slower day/night cycle.&lt;/p&gt;

&lt;p&gt;Happy Juneteenth. If you play one game from this jam, I hope this is the one, and I hope one of the cards sends you down a rabbit hole.&lt;/p&gt;

&lt;p&gt;Built with HTML5 Canvas and Web Audio API by &lt;a class="mentioned-user" href="https://dev.to/snipforge"&gt;@snipforge&lt;/a&gt;&lt;br&gt;
snipforge.video, June 2026&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gamechallenge</category>
      <category>javascript</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Sushi Striker: A World Cup Sushi Goalkeeper Game for the June Solstice Game Jam.</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Thu, 04 Jun 2026 19:20:39 +0000</pubDate>
      <link>https://dev.to/snipforge/sushi-strikera-world-cup-sushi-goalkeeper-game-for-the-june-solstice-game-jam-4phj</link>
      <guid>https://dev.to/snipforge/sushi-strikera-world-cup-sushi-goalkeeper-game-for-the-june-solstice-game-jam-4phj</guid>
      <description>&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/ben"&gt;@ben&lt;/a&gt; &lt;a class="mentioned-user" href="https://dev.to/jess"&gt;@jess&lt;/a&gt;  This is a submission for the June Solstice Game Jam.&lt;/p&gt;

&lt;p&gt;I spend my work days making sure software does not break. Test plans, automation, edge cases. So when I saw a game jam themed around June and its many celebrations, I wanted to do the opposite of my day job for once and build something that exists purely to be fun.&lt;/p&gt;

&lt;p&gt;Then I learned International Sushi Day falls on June 18. Same month as the World Cup. The idea arrived almost fully formed: a goalkeeper, a stadium full of fans, and an endless barrage of flying sushi instead of footballs.&lt;/p&gt;

&lt;p&gt;So I built Sushi Striker, a browser reflex game where you defend your goal from nigiri, maki, curveballs, penalty kicks and the occasional bomb. No engine, no libraries. Every pixel and every sound is built from scratch with the HTML5 Canvas API and the Web Audio API.&lt;/p&gt;

&lt;p&gt;Play it here:&lt;iframe height="600" src="https://codepen.io/editor/Vimala-Gandham/embed/019e9383-cf95-7d9d-9b89-7c5a397ec100?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Demo video: &lt;a href="https://youtu.be/WurGymWGOJI" rel="noopener noreferrer"&gt;https://youtu.be/WurGymWGOJI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fair warning before you click. Round 3 is chaos. My best score so far is 220. If you beat it, drop your score in the comments. And if anyone manages an S grade on Hard mode with a single life, I genuinely want to hear about it, because I built this game and I still have not pulled that off.&lt;/p&gt;

&lt;p&gt;What you are up against:&lt;br&gt;
You control the goalkeeper glove with your mouse, or your finger on mobile. Block the sushi before it reaches the goal. Three rounds, 20 seconds each, and you need to keep your accuracy above the bar to advance. The game does not tell you the exact requirement upfront. You watch the live accuracy bar and the yellow target marker, and if you are slipping it turns red and warns you.&lt;/p&gt;

&lt;p&gt;Each sushi type is hand drawn on canvas with its own shape and its own synthesized sound. Nigiri lands with a soft plop, maki with a deep thud, and the shrimp has a high pitched ping because it is worth the most points. Golden sushi is rare, fast, and worth 5.&lt;/p&gt;

&lt;p&gt;Then the field starts fighting back Penalty kicks. From Round 2, a whistle blows, a red target marker appears on your goal line, and a football rockets toward it. Save it for +10 points. Miss it and you concede a goal, which costs a life.&lt;br&gt;
Curveball sushi. Some sushi swerves side to side on the way down, like a banana kick. Reading the arc matters more than raw speed.&lt;br&gt;
Wasabi. Catch the green blob and everything drops into slow motion for a few seconds. Save it mentally for when a penalty is incoming.&lt;br&gt;
Soy sauce. If the bottle reaches your goal, it splats and blacks out chunks of your view for a couple of seconds while sushi keeps falling. Catch it before it lands.&lt;br&gt;
Bombs. Do not catch these. Obviously I still do, constantly.&lt;br&gt;
Big Glove. Catch 5 in a row and your glove grows. Combos at 3+ catches double your points and the crowd starts pulsing when your streak hits 5.&lt;/p&gt;

&lt;p&gt;Beat all three rounds and you unlock Sudden Death: no timer, no accuracy bar, every single miss costs a life, and the speed keeps climbing until you break. This is where the real scores happen.&lt;/p&gt;

&lt;p&gt;Difficulty modes range from Easy with 5 lives to Hard with exactly one.&lt;/p&gt;

&lt;p&gt;How I built it:&lt;br&gt;
Pure HTML, CSS, and JavaScript in a single file. A few decisions that mattered: All sushi is drawn procedurally with canvas primitives instead of image files or emoji, so it renders identically on every browser and OS.&lt;br&gt;
Movement is delta time based, so the game runs at the same speed whether your machine pushes 30fps or 144fps.&lt;br&gt;
Every Web Audio call lives inside an isolated module wrapped in try/catch, because audio failures should never be able to touch the game loop.&lt;br&gt;
Canvas size is read once after a 100ms delay so layout has settled, then never recalculated. Game objects use a dead flag instead of splicing arrays mid loop.&lt;/p&gt;

&lt;p&gt;What I learned:&lt;br&gt;
This was my first time building a complete game from scratch without an engine, and the lesson that cost me the most time was audio. At one point the entire game silently died in one browser and I could not figure out why. No error, no broken visuals, just a frozen canvas. It turned out a single Web Audio call was throwing inside the game loop. My QA instincts eventually kicked in: isolate the problem, stop guessing, reproduce it reliably. Now every audio call is wrapped and quarantined, and the game keeps running even if sound fails completely.&lt;/p&gt;

&lt;p&gt;The other big one: game feel comes from juice. Screen shake, particle bursts, a crowd that reacts to your streak, a whistle before a penalty. None of it changes the mechanics, all of it changes how the game feels in your hands.&lt;/p&gt;

&lt;p&gt;What is next:&lt;/p&gt;

&lt;p&gt;I am deciding between a persistent leaderboard for Sudden Death survival times and a full World Cup bracket mode across multiple matches. Which would you add first? Genuinely deciding, and comments will sway me.&lt;/p&gt;

&lt;p&gt;Built with HTML5 Canvas and Web Audio API by &lt;a class="mentioned-user" href="https://dev.to/snipforge"&gt;@snipforge&lt;/a&gt;&lt;br&gt;
June 2026, June Solstice Game Jam&lt;/p&gt;

</description>
      <category>gamechallenge</category>
      <category>devchallenge</category>
      <category>gamedev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How I Built a 28-Tool AI Video SaaS Solo with Python, Flask and OpenAI</title>
      <dc:creator>snipforge</dc:creator>
      <pubDate>Wed, 27 May 2026 02:49:17 +0000</pubDate>
      <link>https://dev.to/snipforge/how-i-built-a-28-tool-ai-video-saas-solo-with-python-flask-and-openai-2iml</link>
      <guid>https://dev.to/snipforge/how-i-built-a-28-tool-ai-video-saas-solo-with-python-flask-and-openai-2iml</guid>
      <description>&lt;p&gt;I built SnipForge.video completely solo.No co-founder. No team. No VC money.Just Python, Flask, FFmpeg and OpenAI APIs.&lt;/p&gt;

&lt;p&gt;Here is exactly how I did it and what I learned.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Python 3 and Flask for backend&lt;/li&gt;
&lt;li&gt;FFmpeg for all video processing&lt;/li&gt;
&lt;li&gt;OpenAI Whisper for AI transcription&lt;/li&gt;
&lt;li&gt;GPT-4 for smart content generation&lt;/li&gt;
&lt;li&gt;Stripe for payments&lt;/li&gt;
&lt;li&gt;SQLite for database&lt;/li&gt;
&lt;li&gt;Vanilla HTML CSS JavaScript for frontend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire product runs in a single Python file. Backend, frontend, database, API routes and HTML templates all in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 28 Tools
&lt;/h2&gt;

&lt;p&gt;Every tool follows the same pattern. Receive &lt;br&gt;
parameters, run FFmpeg or AI API, save output, &lt;br&gt;
update job status.&lt;/p&gt;

&lt;p&gt;Tools include trim, compress, convert, crop, &lt;br&gt;
rotate, stabilize, watermark, add captions, &lt;br&gt;
transcribe, extract audio, remove background, &lt;br&gt;
create GIFs, merge videos and more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hardest Part
&lt;/h2&gt;

&lt;p&gt;Auto captions was the most complex feature.&lt;/p&gt;

&lt;p&gt;It uses three systems in sequence. FFmpeg extracts audio. Whisper transcribes it with timestamps. Then a custom ASS subtitle file &lt;br&gt;
gets burned back into the video with FFmpeg.&lt;/p&gt;

&lt;p&gt;Supporting 20 languages and 4 caption styles while keeping it fast was the real challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background Job Processing
&lt;/h2&gt;

&lt;p&gt;Every tool runs as a background thread so the browser never waits. The job ID is returned immediately and the browser polls &lt;br&gt;
every 1.5 seconds for progress updates.&lt;/p&gt;

&lt;p&gt;This was critical for large file processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;Start with fewer tools. I built 28 tools before getting my first user. In hindsight 5 great tools would have been better than &lt;br&gt;
28 average ones at launch.&lt;/p&gt;

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

&lt;p&gt;Free plan includes 3 videos.Pro is $5 per month for unlimited.&lt;/p&gt;

&lt;p&gt;snipforge.video&lt;/p&gt;

&lt;p&gt;Use code LAUNCH for 1 month free.&lt;/p&gt;

&lt;p&gt;Happy to answer any questions about the technical implementation in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>python</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
