<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Milostu]]></title><description><![CDATA[Milostu]]></description><link>https://milostu.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae0764287f3dfa3c6293c23/17f2bb51-44f8-4985-87b2-113b5114e0ec.jpg</url><title>Milostu</title><link>https://milostu.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:03:10 GMT</lastBuildDate><atom:link href="https://milostu.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My first paying customer uploaded a dog photo and got a stranger back]]></title><description><![CDATA[Routing photos between AI video pipelines with a face check in the browser]]></description><link>https://milostu.hashnode.dev/my-first-paying-customer-uploaded-a-dog-photo-and-got-a-stranger-back</link><guid isPermaLink="true">https://milostu.hashnode.dev/my-first-paying-customer-uploaded-a-dog-photo-and-got-a-stranger-back</guid><category><![CDATA[AI]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Milos Tu]]></dc:creator><pubDate>Thu, 05 Nov 2026 08:18:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae0764287f3dfa3c6293c23/b09605dd-12bf-43cb-993c-edf66a1114e6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I run a small AI video and image studio. One of its tools recasts a short dance clip with whoever is in your photo. In late October my first paying customer tried it with a photo of their dog.</p>
<p>The job succeeded. There was no error and no retry, just a valid MP4 of a blonde woman in a pink shirt. The dog wasn't in it, and the full price had been charged.</p>
<p>The same customer also tried a dog photo on another of my tools, built on a different pipeline. That one came out right, with the correct markings and the correct moves, apart from the dog facing away for the first second.</p>
<p>So neither pipeline had a bug. I'd picked the model behind the first tool because, in my tests, it kept a person's face better than the others. It's only built for people, and it doesn't refuse when you hand it something else. The model that handled the dog well had lost people's faces in those same tests.</p>
<p>One model couldn't do both, so photos needed routing. The fix went live the same day. Here's how it works, and where it still gets things wrong.</p>
<h2>A face isn't always a person</h2>
<p>The plan was to look for a human face in the browser before the job starts, so the check needs no server call and no GPU time. I used an open-source short-range face detector that runs through WebAssembly. It's about 10 MB, so it only loads on first use.</p>
<p>Then I looked at the scores:</p>
<table>
<thead>
<tr>
<th>Photo</th>
<th>Face score</th>
</tr>
</thead>
<tbody><tr>
<td>The two dog photos</td>
<td>0.53 and 0.55 (it read a snout as a face)</td>
</tr>
<tr>
<td>A cartoon gnome</td>
<td>0.66</td>
</tr>
<tr>
<td>Every sample photo of a person</td>
<td>0.79 or higher</td>
</tr>
</tbody></table>
<p>With the detector's minimum set to 0.5, the rule "found a face" would have sent both dogs straight back to the pipeline that failed them. So a face only counts as human at 0.7 or above. That line comes from a small set of photos, not a proper calibration, and I'll come back to what that costs.</p>
<h2>Look twice, because it's a selfie model</h2>
<p>A short-range model expects a face close to the camera. In a head-to-toe photo, the face can be too small to find. So if the whole photo has no confident face, I crop the upper middle (70% of the width, the top 55% of the height) and run the detector again.</p>
<h2>"No face" and "couldn't look" are different answers</h2>
<p>The detector can fail to load, or take too long on a slow connection. I give up after 15 seconds. If that returned an empty list, it would look the same as "no face", and every visitor on a bad connection would be pushed to the pet route.</p>
<p>So a pass returns <code>null</code> when it couldn't run, and the decision has three outcomes:</p>
<pre><code class="language-ts">type Subject = 'person' | 'character';

const PERSON_MIN_SCORE = 0.7;

function subjectFromFacePasses(
  passes: Array&lt;FoundFace[] | null&gt;
): Subject | null {
  const human = (faces: FoundFace[] | null) =&gt;
    Boolean(faces?.some((face) =&gt; face.score &gt;= PERSON_MIN_SCORE));

  if (passes.some(human)) return 'person';
  if (passes.length === 0 || passes.some((faces) =&gt; faces === null)) {
    return null;
  }
  return 'character';
}
</code></pre>
<p>A confident face on any pass means a person. If a pass couldn't run, <code>null</code> leaves the current choice alone. Only clean passes with no confident face mean a pet or character. It's also a pure function, so I can test it without a browser or a model.</p>
<h2>It suggests, and the visitor decides</h2>
<p>The result only preselects an option on the form. When it picks the pet route, the page says no human face was found and asks the visitor to switch if that's wrong.</p>
<p>Because detection is async, that creates three timing problems: the visitor can choose before the check finishes, they can swap the photo, or they can click Generate while it's still running. Here's a simplified version of how the component handles them:</p>
<pre><code class="language-ts">function detectSubject(file: File) {
  const token = ++detectToken.current;
  pickedByVisitor.current = false;

  detecting.current = detectSubjectFromPhoto(file).then((found) =&gt; {
    if (token !== detectToken.current) return; // a newer photo replaced this one
    if (!found || pickedByVisitor.current) return; // no verdict, or they already chose
    subjectRef.current = found;
    setSubject(found);
  });
}

// When they click Generate:
await detecting.current; // on a slow phone the check may still be running
const subject = subjectRef.current;
</code></pre>
<p>The last line reads a ref rather than React state. After the <code>await</code>, the click handler's closure still holds the value from before the check finished.</p>
<h2>The route changes more than the model</h2>
<p>Each route has its own inputs, reference clip and prompt. A person sends a face crop plus the full photo, and a pet or character sends only the photo.</p>
<p>The server doesn't run the face check. It treats the subject as ordinary user input, checks it against an allowlist, and uses it to work out how many images the job should carry.</p>
<p>Keeping the choice as one <code>subject</code> value paid off later. I've changed the person route's pipeline several times since then, and the check didn't have to change. Two more tools now use the same check.</p>
<h2>Where it's still wrong</h2>
<p>A later real order showed the limit. The photo was of someone in a costume, with a hat, sunglasses and a small face in the frame. The detector found nothing on the whole photo and scored the head region 0.52, so it most likely went to the pet route by preselection. My logs show the route, not whether the visitor picked it by hand.</p>
<p>That 0.52 sits right next to the dog's 0.53. No threshold can separate those two photos.</p>
<p>The video still came out well. It kept the outfit, hat and sunglasses, and none of the original dancer was left, with some small flaws in the clothing. That's only one order, but it suggests wrong guesses don't cost the same. A dog on the person route was a total loss. A person on the pet route, at least once, was a usable video.</p>
<p>A photo taken from behind goes to the pet route too, since there's no face to find.</p>
<p>I also can't give you an accuracy number. I don't log whether visitors change the suggestion, so I can't see how often it's wrong. I should add that.</p>
<h2>What I'd keep from this</h2>
<ul>
<li><strong>A success status only means the model returned something.</strong> A video of the wrong subject looks the same as a good one in your logs.</li>
<li><strong>Check the input before you pay for the output.</strong> A check in the browser costs no GPU time.</li>
<li><strong>Read the scores before you pick a rule.</strong> "Found a face" would have failed on the exact photos that caused the problem.</li>
<li><strong>Keep "couldn't check" apart from "checked and found nothing".</strong> They need different behavior.</li>
<li><strong>If a check can be wrong, make it a suggestion people can see and change.</strong></li>
</ul>
<p>If you're interested in the cost side of running a tool like this, I wrote about <a href="https://milostu.hashnode.dev/letting-visitors-try-my-ai-video-generator-without-an-account-without-going-broke">letting visitors try the generator without an account</a>.</p>
<p>You can see the choice on the <a href="https://aimakevideo.com/dime-dime-baby-ai">Dime Dime Baby AI tool</a>: add a photo and watch which option gets selected.</p>
<p>Have you had to route inputs between models? What did you use to decide? I'd like to hear about it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Letting visitors try my AI video generator without an account, without going broke]]></title><description><![CDATA[Every free generation costs me real money. Here's how I let strangers try it anyway]]></description><link>https://milostu.hashnode.dev/letting-visitors-try-my-ai-video-generator-without-an-account-without-going-broke</link><guid isPermaLink="true">https://milostu.hashnode.dev/letting-visitors-try-my-ai-video-generator-without-an-account-without-going-broke</guid><category><![CDATA[SaaS]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[Security]]></category><category><![CDATA[indie hackers]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Milos Tu]]></dc:creator><pubDate>Tue, 27 Oct 2026 11:32:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae0764287f3dfa3c6293c23/279f8c95-f674-413b-bb63-6f20618cb8cc.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I run <a href="https://aimakevideo.com">aiMakeVideo</a>, a small AI video and image studio. Very early on I noticed the same thing everyone notices: the sign-up wall is where most visitors leave. People want to see what the tool makes before they hand over an email address. That's fair. I do the same.</p>
<p>The problem is that an AI video generator isn't a to-do app. Every time someone clicks Generate, I pay a model provider for the GPU time. A free trial with no account means I'm handing out money to people I can't identify. If I get it wrong, one bored person with a script can burn through a month of margin in an afternoon.</p>
<p>So I built a no-signup trial in layers. None of them is clever on its own, and each one can be walked around. Together they make farming the trial slow and boring enough that almost nobody bothers, and they put a hard cap on the worst day I can have.</p>
<p>Here are the six layers, roughly in the order a request hits them.</p>
<h2>1. Lock the cost first, then worry about counts</h2>
<p>Before thinking about who gets a free generation, I made each free generation as cheap as possible.</p>
<p>The trial locks exactly the settings that cost money:</p>
<ul>
<li>the lowest resolution</li>
<li>the shortest clip length</li>
<li>one clip per request (and one image per request for images)</li>
<li>a cheap, fast model instead of the premium ones</li>
</ul>
<p>Everything that doesn't change the bill stays the visitor's choice. Aspect ratio, for example, costs the same either way, so a guest can still make a vertical video for TikTok or a widescreen one for YouTube. A trial that's cheap <em>and</em> useless doesn't convert anyone.</p>
<p>The server enforces all of this. It clamps whatever it has to and ignores the rest. The client mirrors the same limits so the locked options are greyed out with a short note explaining why, instead of failing after the visitor clicks Generate.</p>
<p>The lesson I'd keep from this layer: <strong>before you limit how many times someone can try, limit how much each try costs you.</strong> It's the cheapest protection you'll ever add.</p>
<h2>2. A cookie as the first identity</h2>
<p>A guest gets an HttpOnly cookie with a random ID. That ID points to its own trial table: how much allowance is left, when the window started, and the jobs it ran.</p>
<p>None of this touches the real user tables. A guest has no row in <code>user</code>, no credits, and nothing in the history feed. That separation has paid off more than once: I can change the trial rules, reset it, or turn it off completely without any risk to paying customers' data.</p>
<p>Of course, a cookie can be cleared in one click. It's the front door, not the lock.</p>
<h2>3. A device signature, without a fingerprinting library</h2>
<p>This is the layer that does most of the work.</p>
<p>Clearing cookies and switching from wifi to mobile data is the classic way to get a fresh trial. To make that pointless, the server derives a signature from the headers a browser already sends about itself:</p>
<pre><code class="language-ts">import { md5 } from './hash';

export function getDeviceId(request: Request): string {
  const h = request.headers;
  const userAgent = (h.get('user-agent') || '').trim();
  // No user agent (curl, scripts): no signature. Fall back to IP limits
  // instead of lumping every script onto one "device".
  if (!userAgent) return '';

  const languages = (h.get('accept-language') || '')
    .toLowerCase()
    .split(',')
    .map((part) =&gt; part.split(';')[0].trim())
    .filter(Boolean)
    .sort()
    .join(',');

  return md5(
    [
      userAgent.toLowerCase(),
      languages,
      (h.get('sec-ch-ua') || '').toLowerCase(),
      (h.get('sec-ch-ua-mobile') || '').toLowerCase(),
      (h.get('sec-ch-ua-platform') || '').toLowerCase(),
    ].join('|')
  );
}
</code></pre>
<p>The user agent, client hints and language list stay the same for a given browser on a given machine. Clearing the cookie doesn't change them. Switching networks doesn't change them. Meanwhile a genuinely different visitor on the same office wifi usually has a different browser version, platform or language list, so they still get their own trial.</p>
<p>I deliberately did <strong>not</strong> use a canvas or WebGL fingerprint. That would need a client-side library, would collect more about visitors than I actually need, and would deserve its own privacy notice. The server already has these headers on every request. The hash is one-way and never leaves the server.</p>
<p>Two details that matter:</p>
<ul>
<li><strong>An empty user agent gets an empty signature.</strong> Otherwise every request without one (curl, a stripped-down script) would share a single "device" and a single set of limits. Those requests fall back to the IP limits instead, so they're still counted per address.</li>
<li><strong>It's not meant to be perfect.</strong> Someone who switches browsers or spoofs headers can get past it. That's fine. The goal is to make casual farming boring, not impossible.</li>
</ul>
<h2>4. Three rolling 24-hour limits: per IP, per device, and for everyone</h2>
<p>Each signal gets its own limits over a rolling 24 hours:</p>
<ul>
<li><strong>Per IP:</strong> how many jobs, and how many <em>new</em> guests can appear from one address.</li>
<li><strong>Per device:</strong> the same pair, keyed on the signature above. The device limits are deliberately stricter than the IP ones, because the device is the layer that still holds after the cookie is cleared and the network changes.</li>
<li><strong>For the whole trial:</strong> one global ceiling on free jobs per day, across every visitor.</li>
</ul>
<p>Only guests that actually generated something count toward the "new guests" limits. Someone who just loads the page and leaves doesn't use up anything.</p>
<p>All of these are settings in my admin panel, not constants in the code, so I can tighten them in a minute if I see a spike. I'm not going to share the actual numbers here, for the obvious reason.</p>
<p>That last limit, the global one, is the most important line in this whole system. More on that below.</p>
<h2>5. Cloudflare Turnstile, and fail closed</h2>
<p>Every free generation has to carry a fresh <a href="https://www.cloudflare.com/products/turnstile/">Cloudflare Turnstile</a> token, which filters out most of the simple bots before they reach any of the counting logic.</p>
<p>The detail I'm glad I thought about: <strong>if the Turnstile secret is missing in production, the trial refuses every request.</strong> It doesn't quietly skip the check. A wiped or broken config should close the trial, not open it to every bot on the internet. In local development the check is skipped, so it doesn't get in the way while I'm building.</p>
<h2>6. Refund failures, but only once</h2>
<p>The last layer is about being fair to real visitors, which matters as much as stopping abuse.</p>
<p>The order of operations is:</p>
<ol>
<li><strong>Spend the allowance and create the job first</strong>, in one atomic write, before calling the model provider. Every attempt counts, even ones that fail later.</li>
<li>Call the provider.</li>
<li>Settle the result.</li>
</ol>
<p>If the provider rejects the request or reports the job as failed or cancelled, the guest gets the allowance back. Losing your only free try to someone else's outage feels terrible, and that visitor is gone for good.</p>
<p>But the refund has rules:</p>
<ul>
<li><strong>It happens once.</strong> A <code>refunded</code> flag is set in the same atomic write as the refund itself, so a retry or a second status check can't hand the allowance back twice. (My database is Cloudflare D1, which has no interactive transactions, so "atomic" here means a single batch with a guard condition.)</li>
<li><strong>Unknown outcomes aren't refunded.</strong> If I can't tell whether the job ran, I don't refund it.</li>
<li><strong>Content-policy rejections aren't refunded.</strong> If the input was refused by moderation, that attempt was the visitor's own doing.</li>
</ul>
<p>Each guest can also only have one job in flight at a time, so nobody can fire twenty requests in parallel and race the counters. And because people close tabs mid-generation, jobs that have sat in "processing" for too long are closed out the next time that guest tries again. Without it, a guest who closed the tab could be locked out of their own trial forever.</p>
<h2>What I learned</h2>
<p><strong>Make farming boring, not impossible.</strong> Every layer here can be beaten by someone determined enough. That's fine. Most people who try to farm a free trial give up after the second wall. The few who don't would never have paid anyway.</p>
<p><strong>The global daily cap is the only layer that actually bounds the worst case.</strong> Detection can always be fooled. A hard ceiling on free jobs per day can't. Whatever else goes wrong, I know the maximum I can lose on the trial in a single day, and that's what lets me sleep.</p>
<p><strong>Lock cost, not experience.</strong> The trial is cheap because of resolution, length and model choice, not because it's crippled. Letting guests keep the settings that don't cost anything makes the result something they'd actually want to post.</p>
<p><strong>Fail closed on configuration.</strong> Anything that protects money should refuse to run when its config is missing, not skip itself.</p>
<p><strong>Refund rules decide whether the trial feels fair.</strong> A visitor who loses their free try to a provider error doesn't care whose fault it was. Getting refunds right turned failures into "try again" instead of "this site is broken".</p>
<p><strong>Keep trial data away from your real user data.</strong> Separate tables made it easy to change the rules over and over without worrying about paying users.</p>
<h2>Wrapping up</h2>
<p>The whole system, in one breath: make each free try cheap, identify guests with a cookie and a header-based device signature, cap them per IP, per device and for the whole site each day, stop bots with Turnstile that fails closed, and refund real failures exactly once.</p>
<p>If you want to see what the visitor side looks like, the <a href="https://aimakevideo.com/image-to-video">image to video tool</a> runs on this same trial: upload a photo and generate without an account.</p>
<p>How do you handle free trials for something that costs you money per use? I'd love to hear what's worked for you in the comments.</p>
]]></content:encoded></item></channel></rss>