<?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[jaspertech]]></title><description><![CDATA[jaspertech]]></description><link>https://jaspertech.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 14:24:42 GMT</lastBuildDate><atom:link href="https://jaspertech.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Credit packs + subscriptions in one wallet: expiring credits in SQLite without a cleanup cron]]></title><description><![CDATA[When I added monthly plans to my AI video app, I already had one-time credit packs. Users who buy a pack keep those credits forever. Users who subscribe…]]></description><link>https://jaspertech.hashnode.dev/credit-packs-subscriptions-in-one-wallet-expiring-credits-in-sqlite-without-a-cleanup-cron</link><guid isPermaLink="true">https://jaspertech.hashnode.dev/credit-packs-subscriptions-in-one-wallet-expiring-credits-in-sqlite-without-a-cleanup-cron</guid><category><![CDATA[SQLite]]></category><category><![CDATA[stripe]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Jasper Smith]]></dc:creator><pubDate>Mon, 26 Oct 2026 12:45:49 GMT</pubDate><content:encoded><![CDATA[<p>When I added monthly plans to my AI video app, I already had one-time credit packs. Users who buy a pack keep those credits forever. Users who subscribe get a fresh batch every billing period that expires at the end of it. And some users have both.</p>
<p>The obvious implementation — a <code>balance</code> column plus a nightly job that expires old credits — breaks in annoying ways: the job runs late, a webhook arrives twice, a refund lands after a renewal, and suddenly someone has credits they shouldn't or is missing ones they paid for.</p>
<p>This is how I ended up modelling it on Cloudflare D1 (SQLite) for <a href="https://aitrendvideo.net">AI Trend Video</a>, where every generated video costs 2 credits. The short version: <strong>store grants, not balances, and let the database compute what is spendable right now.</strong></p>
<h2>Rules first</h2>
<p>Before any SQL I wrote the rules down, because every bug I'd had before was a rule I hadn't decided:</p>
<ol>
<li>One-time credits never expire.</li>
<li>Each <em>paid</em> subscription invoice grants one period of credits. They expire at the end of that period. No rollover.</li>
<li>A future period (e.g. the remaining months of a yearly plan) is not spendable before it starts.</li>
<li>Spend subscription credits first, earliest expiry first, then permanent credits.</li>
<li>If a generation fails, every reserved credit goes back to <strong>the source it came from</strong> — and a refund into an expired period does not resurrect it.</li>
<li>Expiry must never depend on a scheduled job.</li>
</ol>
<h2>Two tables</h2>
<p>Permanent credits are a plain counter:</p>
<pre><code class="language-sql">CREATE TABLE credits (
  account_id TEXT PRIMARY KEY REFERENCES accounts(id),
  balance    INTEGER NOT NULL DEFAULT 0 CHECK (balance &gt;= 0)
);
</code></pre>
<p>Subscription credits are one row per paid invoice, with its own window:</p>
<pre><code class="language-sql">CREATE TABLE subscription_periods (
  invoice_id      TEXT PRIMARY KEY,          -- Stripe invoice: idempotency for free
  subscription_id TEXT NOT NULL,
  account_id      TEXT NOT NULL,
  starts_at       INTEGER NOT NULL,
  ends_at         INTEGER NOT NULL CHECK (ends_at &gt; starts_at),
  granted         INTEGER NOT NULL CHECK (granted &gt; 0),
  remaining       INTEGER NOT NULL CHECK (remaining &gt;= 0),
  UNIQUE (subscription_id, starts_at)
);
</code></pre>
<p>Using the invoice ID as the primary key means a duplicated <code>invoice.paid</code> webhook simply fails to insert. The <code>UNIQUE (subscription_id, starts_at)</code> catches the other path where the same period could be granted twice (checkout-return reconciliation racing the webhook).</p>
<p>A yearly plan doesn't get one big row: each paid yearly invoice creates <strong>12 one-month rows</strong>, so a yearly subscriber gets exactly the monthly allowance, and a partial refund can revoke months from the last one backwards.</p>
<h2>The wallet is a view</h2>
<p>Nothing ever "expires" credits. The view just doesn't count periods that aren't live at query time:</p>
<pre><code class="language-sql">CREATE VIEW wallet AS
SELECT c.account_id,
  c.balance AS permanent_credits,
  COALESCE((SELECT SUM(remaining) FROM subscription_periods p
            WHERE p.account_id = c.account_id
              AND p.starts_at &lt;= unixepoch() AND p.ends_at &gt; unixepoch()), 0)
    AS subscription_credits,
  (SELECT MIN(ends_at) FROM subscription_periods p
   WHERE p.account_id = c.account_id
     AND p.starts_at &lt;= unixepoch() AND p.ends_at &gt; unixepoch())
    AS subscription_expires_at
FROM credits c;
</code></pre>
<p>Rules 2, 3 and 6 are now just a <code>WHERE</code> clause. The UI reads <code>subscription_expires_at</code> to show "12 credits, expire Nov 26".</p>
<h2>Spending: a trigger with a window function</h2>
<p>Creating a job <em>is</em> spending. An <code>AFTER INSERT</code> trigger on <code>jobs</code> reserves the cost, and aborts the insert if the wallet can't cover it — so there is no way to create a job without paying for it, even under concurrent requests.</p>
<p>The interesting part is splitting the cost across several live periods in expiry order. A running sum does that:</p>
<pre><code class="language-sql">CREATE TRIGGER reserve_credits AFTER INSERT ON jobs BEGIN
  SELECT RAISE(ABORT, 'insufficient_credits')
  WHERE (SELECT permanent_credits + subscription_credits
         FROM wallet WHERE account_id = NEW.account_id) &lt; NEW.credits_cost;

  -- take from live periods, earliest expiry first
  INSERT INTO job_subscription_credits (job_id, invoice_id, credits)
  SELECT NEW.id, invoice_id, MIN(remaining, MAX(0, NEW.credits_cost - previous))
  FROM (
    SELECT invoice_id, remaining,
      COALESCE(SUM(remaining) OVER (ORDER BY ends_at, invoice_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS previous
    FROM subscription_periods
    WHERE account_id = NEW.account_id
      AND starts_at &lt;= unixepoch() AND ends_at &gt; unixepoch() AND remaining &gt; 0
  )
  WHERE NEW.credits_cost &gt; previous;

  UPDATE subscription_periods
  SET remaining = remaining - (SELECT credits FROM job_subscription_credits
                               WHERE job_id = NEW.id AND invoice_id = subscription_periods.invoice_id)
  WHERE invoice_id IN (SELECT invoice_id FROM job_subscription_credits WHERE job_id = NEW.id);

  -- whatever subscriptions didn't cover comes from permanent credits
  UPDATE credits
  SET balance = balance - NEW.credits_cost
      + COALESCE((SELECT SUM(credits) FROM job_subscription_credits WHERE job_id = NEW.id), 0)
  WHERE account_id = NEW.account_id;
END;
</code></pre>
<p><code>previous</code> is "how much the earlier-expiring periods already cover". Each period gives <code>min(remaining, cost - previous)</code> until the cost is met. The <code>CHECK (balance &gt;= 0)</code> is a last line of defence.</p>
<p>The <code>job_subscription_credits</code> table is the receipt: it records exactly which invoice paid for which part of which job. That receipt is what makes rule 5 possible.</p>
<h2>Refunding exactly once, to the right place</h2>
<p>Video generation fails sometimes. When a job flips to <code>failed</code>, a second trigger returns the credits — once:</p>
<pre><code class="language-sql">CREATE TRIGGER refund_failed AFTER UPDATE OF status ON jobs
WHEN NEW.status = 'failed' AND OLD.status != 'failed' AND OLD.refunded = 0 BEGIN
  -- subscription part goes back to its own period (still bound by that period's window)
  UPDATE subscription_periods SET remaining = remaining +
    (SELECT credits FROM job_subscription_credits
     WHERE job_id = NEW.id AND invoice_id = subscription_periods.invoice_id)
  WHERE invoice_id IN (SELECT invoice_id FROM job_subscription_credits WHERE job_id = NEW.id);

  -- permanent part goes back to permanent
  UPDATE credits SET balance = balance + NEW.credits_cost
    - COALESCE((SELECT SUM(credits) FROM job_subscription_credits WHERE job_id = NEW.id), 0)
  WHERE account_id = NEW.account_id;

  UPDATE jobs SET refunded = 1 WHERE id = NEW.id;
END;
</code></pre>
<p>Because credits return to their original period row, a failure that's discovered after the period ended returns credits into a row the wallet view no longer counts. No accidental conversion of expiring credits into permanent ones, no extending expiry.</p>
<p>The <code>OLD.refunded = 0</code> guard plus <code>refunded = 1</code> makes it idempotent: a late "failed" webhook arriving after a 5-minute recovery cron already handled the job does nothing.</p>
<p>(My production version recomputes <code>remaining</code> from <code>granted - revoked - spent</code> instead of adding back, so it also stays correct when a Stripe refund has revoked part of the same period in between. Same idea, more terms.)</p>
<h2>Money refunds</h2>
<p>I never issue refunds from code; they're done by hand in Stripe. The <code>charge.refunded</code> webhook then claws back credits: a monthly refund revokes a proportional share of that period (rounded down); a yearly refund revokes months from the last one backwards. Because grants are rows, this is an update on specific rows rather than guessing what to subtract from a single balance.</p>
<h2>Was it worth it?</h2>
<p>The schema is bigger than <code>balance INTEGER</code>. But:</p>
<ul>
<li>there is no expiry cron to forget, run late, or run twice;</li>
<li>every webhook is idempotent by primary key;</li>
<li>"why do I have 6 credits?" is answerable with one query over grants and receipts;</li>
<li>the app code can't overspend, because the database refuses the insert.</li>
</ul>
<p>If you're building anything with credits plus subscriptions — AI generation, API calls, exports — I'd start with grants + a view from day one.</p>
<p>You can see the wallet in action (account menu shows permanent credits, monthly credits and their expiry) on <a href="https://aitrendvideo.net/pricing">aitrendvideo.net</a>. Questions about the Stripe side are welcome in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[How to Inpaint Photos in the Browser Without Losing Resolution (Canvas Tutorial)]]></title><description><![CDATA[Generate small, composite big: merge AI-edited pixels back into the full-size original with ~60 lines of Canvas code.]]></description><link>https://jaspertech.hashnode.dev/how-to-inpaint-photos-in-the-browser-without-losing-resolution-canvas-tutorial</link><guid isPermaLink="true">https://jaspertech.hashnode.dev/how-to-inpaint-photos-in-the-browser-without-losing-resolution-canvas-tutorial</guid><dc:creator><![CDATA[Jasper Smith]]></dc:creator><pubDate>Tue, 29 Sep 2026 13:47:21 GMT</pubDate><content:encoded><![CDATA[<p>AI inpainting models are great at filling in a background after you erase something. They are much worse at respecting your photo's resolution. Send a 6000×4000 image to most inpainting APIs and you get back a 1024px or 2048px result: the whole frame resized and regenerated, including the 95% you never wanted changed.</p>
<p>The fix doesn't need a bigger model. It's a compositing step you can do entirely in the browser with the Canvas API. In this tutorial we'll build it piece by piece:</p>
<ol>
<li><p>A brush tool that records strokes in <em>image</em> coordinates, not screen coordinates</p>
</li>
<li><p>Two masks from the same strokes: one for the model, one for compositing</p>
</li>
<li><p>A compositing function that merges only the brushed pixels back into the full-resolution original</p>
</li>
<li><p>The practical limits: mobile memory, seams, and retries</p>
</li>
</ol>
<p>This is the approach I use in production at <a href="https://www.peopleremover.com/">peopleremover.com</a>, a browser tool to erase people from photos. (If you want the architecture story and the Cloudflare Workers side, I wrote that up <a href="https://dev.to/jaspersmith/i-built-an-ai-people-remover-that-never-downscales-your-photo-nextjs-cloudflare-workers-48ok">on DEV</a>. This post is only the front-end pixel work.)</p>
<h2>Step 1: Record strokes in image space</h2>
<p>The editor canvas is displayed at whatever size fits the screen, maybe 800px wide, while the image underneath is 6000px wide. If you draw the mask directly on the display canvas, you'd be stuck with an 800px mask.</p>
<p>Instead, store strokes as data in the original image's coordinate system and render them at any size you need later.</p>
<pre><code class="language-ts">type Point = { x: number; y: number };
type Stroke = { points: Point[]; radius: number }; // radius in image pixels

const strokes: Stroke[] = [];
let current: Stroke | null = null;

function toImageSpace(e: PointerEvent, canvas: HTMLCanvasElement, imgW: number): Point {
  const rect = canvas.getBoundingClientRect();
  const scale = imgW / rect.width; // display px -&gt; image px
  return {
    x: (e.clientX - rect.left) * scale,
    y: (e.clientY - rect.top) * scale,
  };
}

canvas.addEventListener('pointerdown', (e) =&gt; {
  const displayRadius = brushSize / 2;              // what the user sees
  const scale = image.width / canvas.getBoundingClientRect().width;
  current = { points: [toImageSpace(e, canvas, image.width)], radius: displayRadius * scale };
  strokes.push(current);
});

canvas.addEventListener('pointermove', (e) =&gt; {
  if (!current) return;
  current.points.push(toImageSpace(e, canvas, image.width));
  redrawOverlay(); // render strokes semi-transparent over the preview
});

canvas.addEventListener('pointerup', () =&gt; { current = null; });
</code></pre>
<p>Keeping strokes as data also gives you undo for free: <code>strokes.pop()</code> and redraw.</p>
<h2>Step 2: Render two masks from the same strokes</h2>
<p>You need the selection in two different formats:</p>
<ul>
<li><p><strong>Model mask</strong>: an opaque black-and-white image, where white means "regenerate" and black means "keep". This is what most inpainting APIs expect.</p>
</li>
<li><p><strong>Alpha mask</strong>: a transparent image where the brushed area is opaque. This is what Canvas compositing expects.</p>
</li>
</ul>
<p>A common mistake is making the black-and-white mask and then trying to use it for compositing. Canvas composite operations read the <strong>alpha channel</strong>, not brightness, so a black pixel counts as fully opaque. Rendering both from the stroke list avoids a slow pixel-by-pixel conversion.</p>
<pre><code class="language-ts">function renderStrokes(
  ctx: OffscreenCanvasRenderingContext2D,
  strokes: Stroke[],
  color: string
) {
  ctx.strokeStyle = color;
  ctx.fillStyle = color;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  for (const s of strokes) {
    ctx.lineWidth = s.radius * 2;
    ctx.beginPath();
    if (s.points.length === 1) {
      // a single tap: draw a dot
      ctx.arc(s.points[0].x, s.points[0].y, s.radius, 0, Math.PI * 2);
      ctx.fill();
      continue;
    }
    ctx.moveTo(s.points[0].x, s.points[0].y);
    for (const p of s.points.slice(1)) ctx.lineTo(p.x, p.y);
    ctx.stroke();
  }
}

function buildMasks(w: number, h: number, strokes: Stroke[]) {
  // Model mask: black background, white strokes
  const model = new OffscreenCanvas(w, h);
  const mctx = model.getContext('2d')!;
  mctx.fillStyle = '#000';
  mctx.fillRect(0, 0, w, h);
  renderStrokes(mctx, strokes, '#fff');

  // Alpha mask: transparent background, opaque strokes
  const alpha = new OffscreenCanvas(w, h);
  renderStrokes(alpha.getContext('2d')!, strokes, '#000');

  return { model, alpha };
}
</code></pre>
<p>Canvas antialiases stroke edges, so the alpha mask comes with a one- or two-pixel soft edge built in. That's usually enough to hide the seam without growing the selection.</p>
<h2>Step 3: Call the model</h2>
<p>The model gets the full frame plus the model mask. It will probably process at a lower resolution than your original, and that's fine. Send it whatever it accepts and don't try to fight it.</p>
<p>One detail matters more than it looks: <strong>give every request an idempotency key.</strong> Mobile networks drop requests constantly, and users double-tap buttons. Without a stable ID, a retry creates a second paid generation.</p>
<pre><code class="language-ts">// Create the ID once per edit and reuse it on every retry of that edit.
async function requestInpaint(image: Blob, mask: Blob, requestId: string): Promise&lt;string&gt; {
  const form = new FormData();
  form.append('image', image, 'image.png');
  form.append('mask', mask, 'mask.png');
  form.append('requestId', requestId);

  const res = await fetch('/api/inpaint', { method: 'POST', body: form });
  if (!res.ok) throw new Error(`Inpaint failed: ${res.status}`);
  const { jobId } = await res.json();
  return jobId; // poll this until the result is ready
}

const requestId = crypto.randomUUID();
const jobId = await requestInpaint(imageBlob, maskBlob, requestId);
</code></pre>
<p>On the server, if a <code>requestId</code> has already been seen, return the existing job instead of creating a new one.</p>
<h2>Step 4: Composite only the brushed pixels</h2>
<p>This is the core of the whole technique. Start from the untouched original, scale the model output up to full size, clip it to the alpha mask, and draw it on top.</p>
<pre><code class="language-ts">async function compositeResult(
  original: ImageBitmap,
  generated: ImageBitmap,
  alphaMask: OffscreenCanvas
): Promise&lt;Blob&gt; {
  const { width: w, height: h } = original;

  // Generated pixels, upscaled and clipped to the selection
  const patch = new OffscreenCanvas(w, h);
  const pctx = patch.getContext('2d')!;
  pctx.imageSmoothingQuality = 'high';
  pctx.drawImage(generated, 0, 0, w, h);
  pctx.globalCompositeOperation = 'destination-in';
  pctx.drawImage(alphaMask, 0, 0);

  // Original + patch
  const out = new OffscreenCanvas(w, h);
  const octx = out.getContext('2d')!;
  octx.drawImage(original, 0, 0);
  octx.drawImage(patch, 0, 0);

  return out.convertToBlob({ type: 'image/png' });
}
</code></pre>
<p><code>destination-in</code> keeps the existing content (the generated image) only where the new drawing (the mask) is opaque. Everything outside the brush comes straight from <code>original</code>, pixel for pixel.</p>
<p>For the next edit, use this composite as the new "original" so edits stack instead of resetting.</p>
<h3>Why the patch still looks sharp enough</h3>
<p>The brushed area is generated at, say, 1024px and stretched across a 6000px frame, so inside the selection you're looking at upscaled pixels. In practice this works better than you'd expect. Inpainted areas are usually background: sky, pavement, grass, walls, where low detail is less noticeable. On large photos where you'll zoom in or print, generating at 2K noticeably improves the patch. That's the reasoning behind offering an HD mode instead of upscaling the whole image.</p>
<h2>Step 5: The limits you will hit</h2>
<p><strong>Canvas memory on phones.</strong> A 25-megapixel image is about 100 MB of raw RGBA per canvas, and this pipeline holds several at once: original, patch, output and masks. iOS Safari will refuse to allocate large canvases and fail with no helpful error. Cap your input size (I use 25 MP / 20 MB), check that <code>getContext</code> actually returned a context, and show a clear error instead of producing a blank file.</p>
<p><strong>Color space and metadata.</strong> <code>convertToBlob</code> writes a PNG without EXIF or ICC profiles, and the pixels come from the browser's decoded canvas color space. For most photos nobody will notice. Photographers who work in wide-gamut profiles will, so say so in your UI.</p>
<p><strong>Shadows and reflections.</strong> This one is about the user, not the code, but it causes the most "the AI failed" complaints. If someone brushes a person but not their shadow, the model can't remove what it wasn't told about. Teaching users to include shadows solves more quality issues than any model upgrade. I put together a <a href="https://www.peopleremover.com/how-to-remove-people-from-photos">step-by-step guide to removing people from photos</a> with mask examples for exactly this reason.</p>
<h2>Beyond people</h2>
<p>Nothing in this pipeline is specific to people. The same strokes → masks → model → composite flow handles:</p>
<ul>
<li><p>Clutter like bins, wires, cars and signs, anything you want to <a href="https://www.peopleremover.com/remove-object-from-photo">remove unwanted objects from pictures</a></p>
</li>
<li><p>Date stamps, captions and overlaid words, for <a href="https://www.peopleremover.com/remove-text-from-image">erasing text from images</a> (thin strokes work best here)</p>
</li>
</ul>
<p>On mobile, the built-in tools on newer phones (iPhone Clean Up, Magic Eraser, Galaxy AI) run similar pipelines on-device. I compared them with browser-based options in a roundup of <a href="https://www.peopleremover.com/remove-people-from-photo-app">phone apps for removing people from photos</a> if you're curious how they stack up.</p>
<h2>Wrap-up</h2>
<p>The main takeaway: <strong>don't let the model's resolution become your output resolution.</strong> Generate small, composite big, and keep your original pixels as the source of truth. It takes about 60 lines of Canvas code and makes a bigger difference than switching models.</p>
<p>If you want to see the result, <a href="https://www.peopleremover.com/">People Remover</a> runs exactly this flow. Upload a photo, brush, and compare the full-size download against your original. Questions about the Canvas side, or better approaches to edge blending? Leave a comment.</p>
]]></content:encoded></item><item><title><![CDATA[Architecting a Multi-Model AI Video Pipeline: How I Unified 6+ Generative APIs in a Next.js App
]]></title><description><![CDATA[A few months ago, I found myself jumping between five different AI video platforms just to compare outputs. One model excelled at cinematic motion, another…]]></description><link>https://jaspertech.hashnode.dev/architecting-a-multi-model-ai-video-pipeline-how-i-unified-6-generative-apis-in-a-next-js-app</link><guid isPermaLink="true">https://jaspertech.hashnode.dev/architecting-a-multi-model-ai-video-pipeline-how-i-unified-6-generative-apis-in-a-next-js-app</guid><dc:creator><![CDATA[Jasper Smith]]></dc:creator><pubDate>Sun, 26 Jul 2026 16:28:46 GMT</pubDate><content:encoded><![CDATA[<p>A few months ago, I found myself jumping between five different AI video platforms just to compare outputs. One model excelled at cinematic motion, another was unbeatable for anime-style clips, and a third handled image-to-video with the best temporal consistency. The workflow was exhausting.</p>
<p>So I decided to build a unified layer on top of all of them — a single interface where users could pick the right model for the right job without managing multiple accounts, API keys, or pricing spreadsheets.</p>
<p>This post is a technical breakdown of how I architected that pipeline, the abstraction challenges I hit, and why I ended up with a Next.js + Supabase stack orchestrating models from Replicate, Runway, and others.</p>
<h2><strong>The Core Problem: Every API Speaks a Different Language</strong></h2>
<p>If you've ever integrated more than one generative video API, you know the pain. Request payloads differ wildly:</p>
<ul>
<li><p><strong>Model A</strong> wants a <code>prompt</code> string and a <code>num_frames</code> integer.</p>
</li>
<li><p><strong>Model B</strong> uses <code>text</code> instead of <code>prompt</code>, and measures duration in seconds, not frames.</p>
</li>
<li><p><strong>Model C</strong> requires a base64-encoded image for image-to-video, while <strong>Model D</strong> only accepts a public URL.</p>
</li>
<li><p>Some return a polling URL. Others return a webhook endpoint. Some just block for 90 seconds.</p>
</li>
</ul>
<p>My first instinct was to write thin adapters inside my route handlers. That lasted about three models before the code became a mess of <code>if/else</code> branches.</p>
<h2><strong>The Architecture I Landed On</strong></h2>
<p>I settled on a three-layer architecture:</p>
<p><strong>plain</strong></p>
<pre><code class="language-plain">User Request → Validation Layer → Orchestrator → Model Adapter → Provider API
                     ↓
            Supabase (Queue + Storage)
</code></pre>
<p><strong>Next.js (App Router)</strong> handles the frontend and API routes.<br /><strong>Supabase</strong> stores user jobs, credits, and generated asset metadata.<br /><strong>Vercel</strong> hosts everything with edge functions for low-latency validation.</p>
<p>The key insight was treating each model as a plugin that implements a common interface:</p>
<p><strong>TypeScript</strong></p>
<pre><code class="language-typescript">// lib/video-models/types.ts
interface VideoModelAdapter {
  id: string;
  name: string;
  supports: ('text-to-video' | 'image-to-video')[];
  
  // Normalize user input into provider-specific payload
  buildPayload(input: UnifiedInput): ProviderPayload;
  
  // Normalize provider response into our standard job format
  parseResponse(raw: unknown): JobResult;
  
  // Some models are fast, some are slow. We track this per adapter.
  estimatedDuration: number; // in minutes
}
</code></pre>
<p>This means adding a new model is just writing a new adapter file. No touching the core orchestrator.</p>
<h2><strong>Handling the Async Nature of Video Generation</strong></h2>
<p>Video generation isn't instant. Even the fastest models I work with take a minute or two. The slow ones can run for several minutes.</p>
<p>I initially tried synchronous API routes with <code>maxDuration</code> cranked up on Vercel. That was a mistake. Timeouts, cold starts, and failed retries made the UX terrible.</p>
<p>Now the flow looks like this:</p>
<ol>
<li><p>User submits a request.</p>
</li>
<li><p>We validate credits, run content moderation (more on that below), and enqueue a job in Supabase.</p>
</li>
<li><p>The API returns immediately with a <code>jobId</code>.</p>
</li>
<li><p>A Vercel Cron job polls pending jobs and dispatches them to the right adapter.</p>
</li>
<li><p>When the provider finishes, a webhook updates the job status and pushes the result URL to the client via Supabase Realtime.</p>
</li>
</ol>
<p><strong>TypeScript</strong></p>
<pre><code class="language-typescript">// app/api/video/generate/route.ts
export async function POST(req: Request) {
  const body = await req.json();
  
  // 1. Validate user credits via Supabase
  const { data: user } = await supabase
    .from('users')
    .select('credits')
    .eq('id', userId)
    .single();
    
  if (!user || user.credits &lt; modelCost) {
    return Response.json({ error: 'Insufficient credits' }, { status: 402 });
  }

  // 2. Moderation check before burning credits
  const moderation = await moderateContent(body.prompt);
  if (moderation.flagged) {
    // We don't deduct credits for policy violations
    return Response.json({ error: 'Content violates usage policy' }, { status: 400 });
  }

  // 3. Create job record
  const { data: job } = await supabase
    .from('jobs')
    .insert({
      user_id: userId,
      model: body.model,
      status: 'pending',
      input: body,
    })
    .select()
    .single();

  return Response.json({ jobId: job.id, status: 'pending' });
}
</code></pre>
<h2><strong>Content Moderation: Don't Let Users Burn Your Budget</strong></h2>
<p>This was a hard lesson. Early on, a few users submitted prompts that generated NSFW content. The upstream provider still charged me for the inference. I ate the cost, and the user got a free video.</p>
<p>Now I run moderation <strong>before</strong> the job enters the queue. I use Hive AI's moderation API to scan both text prompts and input images. If it flags something, we reject immediately — no credits deducted, no API call made.</p>
<p>It's a small latency hit on submission (a few hundred milliseconds), but it saves real money on the backend.</p>
<h2><strong>The Prompt Translation Problem</strong></h2>
<p>Here's something nobody tells you: the same prompt produces wildly different results across models.</p>
<p><code>"A cyberpunk cat walking through a neon-lit street, cinematic lighting, 4k"</code></p>
<ul>
<li><p>One model gives you a beautiful 5-second clip.</p>
</li>
<li><p>Another model ignores the "cinematic lighting" entirely and produces a flat render.</p>
</li>
<li><p>A third model interprets "4k" as a style token and breaks the composition.</p>
</li>
</ul>
<p>I don't try to "fix" prompts automatically — that feels like a black box users can't trust. Instead, I expose model-specific prompt tips in the UI and let users iterate. The adapter layer just passes the prompt through faithfully. The only transformation I do is stripping provider-incompatible parameters (like aspect ratios some models don't support).</p>
<h2><strong>Image-to-Video: The URL vs. Base64 Headache</strong></h2>
<p>Several providers require a publicly accessible image URL for image-to-video generation. But users upload images from their local machine.</p>
<p>My solution: when a user uploads an image, I immediately store it in Supabase Storage with a public URL, then pass that URL to the provider. The adapter doesn't care where the image came from — it just receives a <code>imageUrl</code> string in the normalized input.</p>
<p><strong>TypeScript</strong></p>
<pre><code class="language-typescript">// Inside the image-to-video adapter
async buildPayload(input: UnifiedInput): Promise&lt;ProviderPayload&gt; {
  return {
    image: input.imageUrl, // Already a public Supabase Storage URL
    prompt: input.prompt,
    num_frames: 81, // Provider-specific default
  };
}
</code></pre>
<h2><strong>What I'd Do Differently</strong></h2>
<ol>
<li><p><strong>Start with the queue architecture.</strong> I wasted time trying synchronous flows. Video generation is inherently async. Embrace it from day one.</p>
</li>
<li><p><strong>Track per-model costs obsessively.</strong> I now log every inference call with provider, duration, and cost. It made pricing my credit packs much easier when I moved to Creem for payments.</p>
</li>
<li><p><strong>Don't over-abstract too early.</strong> My first adapter interface had 12 methods. I trimmed it to 4. You only really need: <code>buildPayload</code>, <code>parseResponse</code>, <code>checkStatus</code>, and <code>cancelJob</code>.</p>
</li>
</ol>
<h2><strong>Wrapping Up</strong></h2>
<p>Building this pipeline taught me that the hard part of AI video tools isn't the AI — it's the orchestration. Normalizing APIs, handling failures gracefully, and keeping users informed while their videos render in minutes is where the engineering effort actually lives.</p>
<p>If you're building something similar and want to compare notes, feel free to reach out. And if you're curious to see the end result, I've been shipping it live at <a href="https://www.froging.ai">https://www.froging.ai</a> — a unified AI video generator where you can switch between models without switching tabs.</p>
]]></content:encoded></item><item><title><![CDATA[How I Built an AI Avatar Generator That Actually Works for Profile Pictures]]></title><description><![CDATA[Every major platform treats your profile picture differently. Twitter/X crops to a circle. Discord displays a tiny square. LinkedIn expects a professional…]]></description><link>https://jaspertech.hashnode.dev/how-i-built-an-ai-avatar-generator-that-actually-works-for-profile-pictures</link><guid isPermaLink="true">https://jaspertech.hashnode.dev/how-i-built-an-ai-avatar-generator-that-actually-works-for-profile-pictures</guid><dc:creator><![CDATA[Jasper Smith]]></dc:creator><pubDate>Tue, 30 Jun 2026 15:32:06 GMT</pubDate><content:encoded><![CDATA[<p>Every major platform treats your profile picture differently. Twitter/X crops to a circle. Discord displays a tiny square. LinkedIn expects a professional headshot. Steam and Roblox compress everything down to a postage stamp. Yet most <strong>AI Avatar Generator</strong> tools treat profile pictures as an afterthought — they output portrait or landscape images that look great in a gallery, but fall apart once they hit a 128×128 pixel container.</p>
<p>I ran into this repeatedly while generating avatars for side projects and team pages. The faces were always off-center, the shoulders got cropped out, or the detail vanished after platform compression. So I built <a href="https://www.squarefacegenerator.ai/"><strong>Square Face Generator</strong></a> — an <strong>AI Avatar Generator</strong> specifically designed for the constraints of profile pictures across every platform.</p>
<p>Here is why generic image generators fail at avatars, and how I fixed it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fe0c76f239332df4e155d6/f76df67f-648e-4b8a-8c5c-ed81713c5b0a.webp" alt="" style="display:block;margin:0 auto" />

<h2>The Square Crop Problem</h2>
<p>When you prompt a standard image model with "professional headshot, 1:1 ratio," two things usually go wrong:</p>
<ol>
<li><p><strong>Composition drift</strong>: The model was trained predominantly on 3:4 and 16:9 photography. It centers the body, not the face, leaving too much headroom or cutting off the chin after a square crop.</p>
</li>
<li><p><strong>Detail loss at small sizes</strong>: Avatars are often viewed as tiny circles or squares. A model optimized for 1024×1024 landscape art wastes tokens on background detail you will never see.</p>
</li>
</ol>
<p>The result? You generate ten images, pick the best one, open Photoshop, manually reframe it, and hope the face survives the compression. That is not a workflow; it is a chore.</p>
<hr />
<h2>Designing an AI Avatar Generator for Square Crops</h2>
<p>I approached <a href="https://www.squarefacegenerator.ai/"><strong>Square Face Generator</strong></a> as a specialized pipeline rather than a generic wrapper around Stable Diffusion or Flux. The core differences:</p>
<h3>1. Face-Centered Composition</h3>
<p>The prompt engineering and LoRA fine-tuning both bias toward tight facial framing. The model learns that "avatar" means "face fills 70% of the canvas," not "person standing in a field."</p>
<h3>2. Native 1:1 Output</h3>
<p>Instead of generating a large image and cropping down, we generate at the target square resolution. This preserves facial detail and avoids the "zoomed in" blur you get from aggressive down-cropping.</p>
<h3>3. Style Versatility</h3>
<p>Because avatars serve different contexts, the <strong>AI Avatar Generator</strong> supports multiple aesthetic modes:</p>
<ul>
<li><p><strong>Photorealistic</strong> — for LinkedIn and professional profiles</p>
</li>
<li><p><strong>Pixel art / 8-bit</strong> — for gaming communities and retro branding</p>
</li>
<li><p><strong>3D render</strong> — for Metaverse and VRChat identities</p>
</li>
<li><p><strong>Minimalist flat</strong> — for corporate team pages</p>
</li>
</ul>
<p>Each style maintains the same square-face composition rules, so your avatar looks intentional rather than accidental.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69fe0c76f239332df4e155d6/9af0d7ce-e311-4986-b600-9a9cef976156.webp" alt="" style="display:block;margin:0 auto" />

<h2>Real Use Cases Where Square Avatars Matter</h2>
<p>I originally thought this was a niche problem. It is not.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Display Size</th>
<th>Why a Dedicated <strong>AI Avatar Generator</strong> Helps</th>
</tr>
</thead>
<tbody><tr>
<td>Twitter/X</td>
<td>400×400 circle crop</td>
<td>Centered face prevents decapitation in the circular mask</td>
</tr>
<tr>
<td>Discord</td>
<td>128×128 square</td>
<td>Pixel art mode shines at low resolution</td>
</tr>
<tr>
<td>LinkedIn</td>
<td>400×400</td>
<td>Professional mode keeps shoulders in frame for credibility</td>
</tr>
<tr>
<td>Roblox / Steam</td>
<td>184×184</td>
<td>1:1 native output avoids platform compression artifacts</td>
</tr>
<tr>
<td>NFT Collections</td>
<td>512×512 or 1:1</td>
<td>Batch-generated square avatars fit marketplaces without re-processing</td>
</tr>
</tbody></table>
<p>One game developer used <a href="https://www.squarefacegenerator.ai/"><strong>this AI avatar generator</strong></a> to produce 500 NPC profile icons for a Steam game in a single afternoon. A marketing team generated consistent 1:1 team avatars for their "About Us" page without hiring a photographer.</p>
<hr />
<h2>The Technical Stack (For Fellow Builders)</h2>
<p>If you are curious how the pipeline works under the hood:</p>
<ul>
<li><p><strong>Next.js</strong> frontend for instant preview and drag-to-download</p>
</li>
<li><p><strong>Supabase</strong> for auth and lightweight usage tracking</p>
</li>
<li><p><strong>ComfyUI + FLUX.1-dev</strong> with a custom LoRA trained on tightly cropped portrait photography</p>
</li>
<li><p><strong>Vercel</strong> edge caching for sub-second generation previews</p>
</li>
<li><p>Output locked to 1:1 aspect ratio with face detection post-processing as a safety net</p>
</li>
</ul>
<p>The entire stack is optimized for one job: generate a square face that looks good everywhere. No bloat, no unrelated features.</p>
<hr />
<h2>Try It Yourself</h2>
<p>If you have ever uploaded an AI-generated portrait only to watch the platform crop off your eyebrows, you know the pain.</p>
<p>You can generate a perfect square avatar in seconds at <a href="https://www.squarefacegenerator.ai/"><strong>squarefacegenerator.ai</strong></a>. The <strong>AI Avatar Generator</strong> is free to try — no account required for your first few generations. Pick a style, describe the look you want, and download a 1:1 image ready for any platform.</p>
<hr />
<h2>What Is Next</h2>
<p>We are shipping two features based on early feedback:</p>
<ul>
<li><p><strong>Batch generation</strong> — upload a CSV of names/roles and get a matched set of team avatars</p>
</li>
<li><p><strong>API access</strong> — integrate the square-face pipeline into your own app or game</p>
</li>
</ul>
<p>If you have a use case that needs square avatars at scale, leave a comment. I am actively prioritizing the roadmap based on what builders actually need.</p>
<hr />
<h2>Resources</h2>
<ul>
<li><p><a href="https://www.squarefacegenerator.ai/"><strong>Square Face Generator</strong></a> — <strong>AI Avatar Generator</strong> optimized for 1:1 square profile pictures</p>
</li>
<li><p><a href="https://thispersondoesnotexist.com/">This Person Does Not Exist</a> — Classic random face generator (no control over style)</p>
</li>
<li><p><a href="https://aragon.ai/">Aragon AI</a> — Professional headshot generator (great for photos, not stylized avatars)</p>
</li>
</ul>
<hr />
<p><em>I build focused AI tools at LuTa Tech. Follow for more write-ups on generative media, Next.js architecture, and shipping fast.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Built an AI Image Detector That Spots Midjourney, DALL·E, and Flux ]]></title><description><![CDATA[As AI image generators like Midjourney v7, DALL·E 4, Stable Diffusion 3.5, and Flux become indistinguishable from real photography, the ability to detect…]]></description><link>https://jaspertech.hashnode.dev/how-i-built-an-ai-image-detector-that-spots-midjourney-dall-e-and-flux</link><guid isPermaLink="true">https://jaspertech.hashnode.dev/how-i-built-an-ai-image-detector-that-spots-midjourney-dall-e-and-flux</guid><dc:creator><![CDATA[Jasper Smith]]></dc:creator><pubDate>Tue, 30 Jun 2026 15:20:15 GMT</pubDate><content:encoded><![CDATA[<p>As AI image generators like Midjourney v7, DALL·E 4, Stable Diffusion 3.5, and Flux become indistinguishable from real photography, the ability to <strong>detect AI-generated images</strong> has shifted from a curiosity to a necessity. Journalists, content moderators, e-commerce platforms, and educators all need a reliable way to <strong>check if an image is AI-generated</strong> before making decisions based on it.</p>
<p>I spent the last few months building <a href="https://www.aipicturechecker.com"><strong>AI Picture Checker</strong></a> — an <strong>AI Image Detector</strong> that identifies not just <em>whether</em> an image is synthetic, but <em>which</em> model likely created it. Here is what I learned along the way, and how the tool works under the hood.</p>
<hr />
<h2>Why Existing Tools Fall Short</h2>
<p>Most <strong>AI image detectors</strong> on the market give you a simple "AI vs Real" percentage. That is useful, but not enough. When you are moderating a marketplace or verifying a news photo, you need context:</p>
<ul>
<li><p>Was this created by Midjourney (characteristic smoothing and texture repetition)?</p>
</li>
<li><p>Does it carry DALL·E's subtle color banding?</p>
</li>
<li><p>Or is it a Flux image with its distinct micro-texture patterns?</p>
</li>
</ul>
<p>Generic classifiers often miss these nuances because they are trained on broad "synthetic vs authentic" labels without model-specific fingerprints.</p>
<hr />
<h2>How Our AI Image Detector Works</h2>
<p>The core of <a href="https://www.aipicturechecker.com"><strong>AI Picture Checker</strong></a> is a multi-task deep learning pipeline trained on a curated dataset of millions of images:</p>
<ol>
<li><p><strong>Feature Extraction</strong>: We use a Vision Transformer (ViT) backbone to capture high-frequency artifacts — the telltale signs left by diffusion models and GANs.</p>
</li>
<li><p><strong>Binary Classification</strong>: A head predicts the probability that the image is AI-generated.</p>
</li>
<li><p><strong>Source Attribution</strong>: A second head classifies the likely source model across 80+ generators, from Midjourney and DALL·E to Gemini, Ideogram, and lesser-known tools.</p>
</li>
<li><p><strong>Deepfake Detection</strong>: A specialized face-analysis branch flags AI-manipulated portraits and face swaps.</p>
</li>
</ol>
<p>The result is not just a score. You get a structured report: confidence percentage, suspected model, and a plain-English summary.</p>
<hr />
<h2>Real-World Detection Examples</h2>
<p>Here is a real detection result from <a href="https://www.aipicturechecker.com"><strong>AI Picture Checker</strong></a> — an image generated by Gemini, analyzed in seconds:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fe0c76f239332df4e155d6/060ea414-bbbc-40b7-b053-19638df6c3e4.jpg" alt="" style="display:block;margin:0 auto" />

<p><strong>Result breakdown:</strong></p>
<ul>
<li><p><strong>AI Probability</strong>: 100.0%</p>
</li>
<li><p><strong>Source Attribution</strong>: Gemini (99.5% model match)</p>
</li>
<li><p><strong>Summary</strong>: Strong indicators of AI generation detected</p>
</li>
</ul>
<p>This is exactly what separates a real <strong>AI Image Detector</strong> from a simple binary classifier. It does not just say "AI" — it tells you <em>which</em> model.</p>
<p>Three more cases from recent usage:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Result</th>
<th>Confidence</th>
</tr>
</thead>
<tbody><tr>
<td>Product photo from a suspicious marketplace</td>
<td><strong>AI Generated</strong> — likely DALL·E</td>
<td>94%</td>
</tr>
<tr>
<td>Dating app profile photo</td>
<td><strong>Deepfake detected</strong></td>
<td>91%</td>
</tr>
<tr>
<td>Photography contest submission</td>
<td><strong>Likely Real</strong></td>
<td>8%</td>
</tr>
</tbody></table>
<p>In each case, the user saved hours of manual review by running the image through <a href="https://www.aipicturechecker.com"><strong>this online AI photo detector</strong></a> first.</p>
<hr />
<h2>Building for Scale and Privacy</h2>
<p>Two constraints drove the architecture:</p>
<ul>
<li><p><strong>Privacy</strong>: Images are analyzed in real-time and not stored permanently. We do not build user galleries or training datasets from uploads.</p>
</li>
<li><p><strong>Speed</strong>: The model runs on optimized inference endpoints. A 10MB JPG returns results in under 3 seconds.</p>
</li>
</ul>
<p>If you are building a content moderation pipeline, you can integrate the <strong>AI Picture Checker</strong> API to batch-process uploads automatically.</p>
<hr />
<h2>Try It Yourself</h2>
<p>Whether you are verifying a single suspicious photo or screening thousands of user uploads, an <strong>AI Image Detector</strong> should be part of your workflow.</p>
<p>You can <a href="https://www.aipicturechecker.com"><strong>check if an image is AI-generated instantly</strong></a> at <a href="https://www.aipicturechecker.com"><strong>aipicturechecker.com</strong></a>. No signup is required for the first few checks — just drag, drop, and get your report.</p>
<hr />
<h2>What Is Next</h2>
<p>We are expanding the model to detect:</p>
<ul>
<li><p>Partially AI-edited images (AI inpainting + real photo composites)</p>
</li>
<li><p>Video frame-level detection for short-form content</p>
</li>
<li><p>PDF and document image screening</p>
</li>
</ul>
<p>If you have a use case that is not covered yet, drop a comment. I read every piece of feedback and it directly shapes the roadmap.</p>
<hr />
<h2>Resources</h2>
<ul>
<li><a href="https://www.aipicturechecker.com"><strong>AI Picture Checker</strong></a> — Free online <strong>AI Image Detector</strong> with source attribution</li>
</ul>
]]></content:encoded></item></channel></rss>