<?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: AI Video</title>
    <description>The latest articles on DEV Community by AI Video (@aivideotrends).</description>
    <link>https://dev.to/aivideotrends</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%2F4162045%2Feea34390-bb5d-4e5d-a9b3-fafde16f504e.png</url>
      <title>DEV Community: AI Video</title>
      <link>https://dev.to/aivideotrends</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aivideotrends"/>
    <language>en</language>
    <item>
      <title>Billing AI video by the rendered second (without double-charging when webhooks race)</title>
      <dc:creator>AI Video</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:15:26 +0000</pubDate>
      <link>https://dev.to/aivideotrends/billing-ai-video-by-the-rendered-second-without-double-charging-when-webhooks-race-j7d</link>
      <guid>https://dev.to/aivideotrends/billing-ai-video-by-the-rendered-second-without-double-charging-when-webhooks-race-j7d</guid>
      <description>&lt;p&gt;At &lt;a href="https://www.talkpix.ai/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto_billing" rel="noopener noreferrer"&gt;TalkPix&lt;/a&gt; you upload a portrait, type a script or upload your own audio, and get back a lip-synced talking video. Users pay with credits, and the price depends on how long the video is.&lt;/p&gt;

&lt;p&gt;That sounds simple until you notice that &lt;strong&gt;you don't know how long the video is until it exists.&lt;/strong&gt; A typed script has an estimated length. An uploaded recording can run for minutes. The provider renders asynchronously and tells you it's done through a webhook, which may arrive late, twice, or never.&lt;/p&gt;

&lt;p&gt;This is the billing flow we ended up with, and the bugs that shaped it.&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%2Fnay7mzdlj8hjlp3j3zg9.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%2Fnay7mzdlj8hjlp3j3zg9.png" alt=" " width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: nothing renders before it's paid for
&lt;/h2&gt;

&lt;p&gt;The first version had a free short preview. It cost real inference money and, when we measured it, didn't convert. Now there is exactly one rule in the generate endpoint: if the wallet can't cover the estimate, return &lt;code&gt;402&lt;/code&gt; and don't call the provider.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/generate (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateCredits&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;estimatedSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolution&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reserveCredits&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;insufficient_credits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startPrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/webhooks/provider`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserving &lt;em&gt;before&lt;/em&gt; the provider call matters. If you charge after, a user can start ten renders with credits for one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: balances only change inside Postgres functions
&lt;/h2&gt;

&lt;p&gt;Every balance change is a &lt;code&gt;security definer&lt;/code&gt; function that locks the wallet row. Application code never does &lt;code&gt;update credit_wallets set balance = balance - x&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reserve_credits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_amount&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_job&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;credit_wallets&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_amount&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'insufficient_credits'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;credit_wallets&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p_amount&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt;
    &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;credit_transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_job_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'generation_reserve'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;for update&lt;/code&gt; serializes two renders started by the same user at the same moment. The transaction row gives you an audit trail for every support question that starts with "where did my credits go?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: bill the video you actually delivered
&lt;/h2&gt;

&lt;p&gt;When the render finishes, the finalizer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;copies the MP4 into our own storage (provider URLs expire),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;measures the real duration of the file&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;computes the true price, compares it with what was reserved,&lt;/li&gt;
&lt;li&gt;refunds the difference, or debits the extra.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;measuredSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reservedCredits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refundCredits&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duration_reconcile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reconcileGenerationDebit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duration_reconcile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting case is &lt;code&gt;delta &amp;gt; 0&lt;/code&gt;. Reservations block overdrafts, which is what you want &lt;em&gt;before&lt;/em&gt; a render. But for a long uploaded recording, the reservation was based on an estimate, the render has already been delivered, and the wallet may no longer cover the difference. Refusing to record that would make a delivered video impossible to bill.&lt;/p&gt;

&lt;p&gt;So exactly one function is allowed to take a balance below zero: the reconcile for a &lt;strong&gt;completed, wallet-funded&lt;/strong&gt; job. Everything else still blocks. A negative balance stops new renders until it's topped up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- only a completed job may create debt&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;done_job&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;generation_jobs&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_job&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;done_job&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'A completed job is required.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule 4: the webhook and the poller will both finish the job
&lt;/h2&gt;

&lt;p&gt;The provider's webhook is the fast path. But webhooks get lost, so the result page also polls, and a background sweep asks about anything stuck. That means &lt;strong&gt;three different code paths can finalize the same job, sometimes at the same moment.&lt;/strong&gt; Every one of them has to be safe to run twice.&lt;/p&gt;

&lt;p&gt;Three things make that true:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One function finalizes.&lt;/strong&gt; Webhook, poll and sweep all call the same &lt;code&gt;finalizeJob()&lt;/code&gt;. There is no second implementation to drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim the transition atomically.&lt;/strong&gt; The status change is a conditional update, and only the caller that actually changed the row continues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generation_jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;video_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration_seconds&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;// only from an unfinished state&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// another finalizer already won; do not bill again&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make each money function idempotent on its own.&lt;/strong&gt; Even if two callers get past the claim (say, a retry after a timeout), the database refuses to apply the same adjustment twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- inside reconcile_generation_debit, after locking the wallet row&lt;/span&gt;
&lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;credit_transactions&lt;/span&gt;
   &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;related_job_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_job&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'reason'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'duration_reconcile'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;current_balance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- already applied; retries are free&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refunds work the same way: &lt;code&gt;refund_credits&lt;/code&gt; flips a &lt;code&gt;credits_refunded&lt;/code&gt; flag on the job in the same transaction, and does nothing if it's already set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: a completed job never becomes a failed one
&lt;/h2&gt;

&lt;p&gt;The failure mode to design against: the reconcile call times out &lt;em&gt;after&lt;/em&gt; the video was delivered, the error bubbles up through a generic &lt;code&gt;catch&lt;/code&gt;, and the job gets marked failed and refunded. The user would get the video &lt;strong&gt;and&lt;/strong&gt; their credits back.&lt;/p&gt;

&lt;p&gt;So once a job is complete, a billing error is logged loudly for retry, and the job stays complete. Because the reconcile function is idempotent, retrying it later is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user sees
&lt;/h2&gt;

&lt;p&gt;All of this exists so the product can make simple promises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you see the credit price before anything renders,&lt;/li&gt;
&lt;li&gt;a render that fails returns its credits automatically,&lt;/li&gt;
&lt;li&gt;you're billed for the video you received, at 720p or 1080p,&lt;/li&gt;
&lt;li&gt;credits are one-time packs that never expire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same flow now backs everything on the platform: talking and singing photos, product video ads, and the AI video templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist if you're building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reserve before you call the provider; return &lt;code&gt;402&lt;/code&gt; if you can't.&lt;/li&gt;
&lt;li&gt;Change balances only inside row-locking database functions, and log every change as a transaction.&lt;/li&gt;
&lt;li&gt;Measure the delivered file, then settle the difference.&lt;/li&gt;
&lt;li&gt;Allow debt in exactly one place: a completed, paid job.&lt;/li&gt;
&lt;li&gt;Assume webhook, poll and sweep all finalize the same job. One finalizer, an atomic claim, idempotent money functions.&lt;/li&gt;
&lt;li&gt;Never let a billing error undo a successful delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the result from the user's side, the &lt;a href="https://www.talkpix.ai/create?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto_billing" rel="noopener noreferrer"&gt;talking photo studio&lt;/a&gt; shows the credit estimate before you render, and &lt;a href="https://www.talkpix.ai/pricing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto_billing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; lists the packs.&lt;/p&gt;

&lt;p&gt;Questions about any of this are welcome in the comments.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I built an app that replaces the whole person in a video. Here's what was hard.</title>
      <dc:creator>AI Video</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:06:26 +0000</pubDate>
      <link>https://dev.to/aivideotrends/i-built-an-app-that-replaces-the-whole-person-in-a-video-heres-what-was-hard-j1g</link>
      <guid>https://dev.to/aivideotrends/i-built-an-app-that-replaces-the-whole-person-in-a-video-heres-what-was-hard-j1g</guid>
      <description>&lt;p&gt;Most "face swap" results you've seen paste one face onto the same head. The hair, the body and the clothes stay, and it ends up looking like makeup.&lt;/p&gt;

&lt;p&gt;I wanted the opposite: &lt;strong&gt;keep the performance&lt;/strong&gt; from a clip (gestures, timing, camera) and &lt;strong&gt;rebuild the person&lt;/strong&gt; completely, from one photo or from a sentence describing a character that doesn't exist. That became &lt;a href="https://actlikeai.video/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto_build" rel="noopener noreferrer"&gt;ActLike AI&lt;/a&gt;, an iPhone and Android app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l3dgycfhpv0l24x4ecc.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l3dgycfhpv0l24x4ecc.gif" alt=" " width="540" height="474"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated example: same gestures and kitchen, a completely different person from one photo.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post is about the parts that turned out to be harder than the "AI" itself.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upload&lt;/strong&gt; a short clip of one person (capped at 20 seconds) and either a photo or a text description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grab a reference frame&lt;/strong&gt; from the start of the clip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the character image.&lt;/strong&gt; An image model takes the reference frame plus the photo (or the description) and produces the new person in the clip's exact pose, framing and light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace the person in the video.&lt;/strong&gt; A video model takes the clip and that character image and renders the clip with the new person, keeping the original motion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the result&lt;/strong&gt; and hand the phone a short-lived signed URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 3 and 4 are where the quality comes from, but almost every bug I shipped lived somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part 1: the person looked pasted on
&lt;/h2&gt;

&lt;p&gt;The first results were technically correct and visually wrong. A photo taken in soft studio light, dropped into a rainy street at night, looked like a sticker.&lt;/p&gt;

&lt;p&gt;The fix was to stop feeding the user's photo straight into the video step. Instead, step 3 re-creates the new person &lt;strong&gt;inside the first frame of the clip&lt;/strong&gt;: same pose, same head position, same lighting and shadows, with the face, hair and clothing from the photo. The video model then animates &lt;em&gt;that&lt;/em&gt; image. In the app this is the "Scene Match" toggle.&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%2F0q8t9mn2rp9hwyfgq38i.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0q8t9mn2rp9hwyfgq38i.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same photo, Scene Match off vs on: with it, the rain and neon reach her hair and clothes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two lessons from tuning it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock the prompt.&lt;/strong&gt; Free-form prompts drifted: the background changed, the camera "zoomed", clothing got restyled. A fixed prompt that lists what must &lt;em&gt;not&lt;/em&gt; change (background, camera, framing, depth of field) made results consistent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big features transfer, small ones don't.&lt;/strong&gt; Age, hair, beard and clothing come through reliably. Eye colour, teeth and scars often don't, because the video model follows the performer's own eyes and mouth. I now say this in the app instead of pretending otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hard part 2: renders take minutes, and users close apps
&lt;/h2&gt;

&lt;p&gt;A render takes a few minutes. People don't stare at a loading screen for that long; they switch apps, lock the phone, or swipe the app away. My first version kept the job alive only while the screen that started it existed, so a killed app meant a lost video that the user had already paid for.&lt;/p&gt;

&lt;p&gt;What fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The server owns the job.&lt;/strong&gt; Each render is a job document with a status (&lt;code&gt;queued → processing → completed/failed&lt;/code&gt;). The app only reads it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The device keeps a small ledger of every job it started.&lt;/strong&gt; Not just the latest one. On launch, on returning to the foreground and on a notification tap, the app walks that list, asks the server for each job's status, and files any finished video in History.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A notification tap carries the job id.&lt;/strong&gt; Even if the device lost track of a job, the tap is enough to fetch the result and open it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts never delete work.&lt;/strong&gt; A render that runs long is shown as "still preparing, it'll appear in History", and the ledger picks it up later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general rule I took away: &lt;strong&gt;any long-running job on mobile needs a recovery path that doesn't depend on a screen being alive.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part 3: money and failures
&lt;/h2&gt;

&lt;p&gt;The app uses one-time credit packs, no subscription: 1 credit per second at 480p, 2 per second at 720p, plus 1 if the character image has to be generated (Scene Match or a described character; they share that one credit).&lt;/p&gt;

&lt;p&gt;The rule that saved me the most support email: &lt;strong&gt;the server is the only place that decides the price, and a failed render refunds its credits automatically.&lt;/strong&gt; The client shows an estimate, but the charge, the refund and the reason all live in a server-side ledger. When I briefly let my website calculate costs differently from the server, it was wrong by one credit in exactly the combination I hadn't tested. Same formula, one source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part 4: making examples honest
&lt;/h2&gt;

&lt;p&gt;For the website I wanted every guide to show a real result, not a mock-up. So every example is made with the app's own pipeline and settings, the performers and photos are AI-generated people (no real faces), and each video is labelled with its settings and credit cost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj7bqibaaidg2hdyt3pfj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj7bqibaaidg2hdyt3pfj.gif" alt=" " width="540" height="474"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated example: a typed description turned a young maker into an old sea captain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One unexpected design rule came out of this: &lt;strong&gt;the new person has to differ from the performer&lt;/strong&gt; (gender or age plus hair and clothing). When I used a character too similar to the performer, viewers read it as makeup on the same face, which undersold what the model actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat generation as a &lt;strong&gt;server job with a status&lt;/strong&gt;, never as a request the phone waits on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock your prompts&lt;/strong&gt; and list what must stay the same, not just what should change.&lt;/li&gt;
&lt;li&gt;Put &lt;strong&gt;pricing and refunds on the server&lt;/strong&gt;, and test every combination of options.&lt;/li&gt;
&lt;li&gt;Be explicit about &lt;strong&gt;limits&lt;/strong&gt; (one person per clip, the face must be visible, shaky footage glitches more). Users forgive limits; they don't forgive surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the results with their exact settings and costs, they're here: &lt;a href="https://actlikeai.video/examples?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto_build" rel="noopener noreferrer"&gt;before-and-after examples&lt;/a&gt;. The app is on the &lt;a href="https://apps.apple.com/us/app/actlike-ai-face-swap-video/id6761327996?ct=devto_build&amp;amp;mt=8&amp;amp;pt=127005750" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.lcdmediagames.actlikeai&amp;amp;referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Ddevto_build" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;

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

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