<?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: Aleister</title>
    <description>The latest articles on DEV Community by Aleister (@alei_05c2878d6554e9e).</description>
    <link>https://dev.to/alei_05c2878d6554e9e</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%2F4159294%2Feced37e4-8071-4a0d-a72d-05345ed400d3.jpg</url>
      <title>DEV Community: Aleister</title>
      <link>https://dev.to/alei_05c2878d6554e9e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alei_05c2878d6554e9e"/>
    <language>en</language>
    <item>
      <title>Two photos in, one duet out: building an AI video generator</title>
      <dc:creator>Aleister</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:28:42 +0000</pubDate>
      <link>https://dev.to/alei_05c2878d6554e9e/two-photos-in-one-duet-out-building-an-ai-video-generator-2llf</link>
      <guid>https://dev.to/alei_05c2878d6554e9e/two-photos-in-one-duet-out-building-an-ai-video-generator-2llf</guid>
      <description>&lt;p&gt;I recently shipped &lt;a href="https://lobbybooth.com/?utm_source=devto&amp;amp;utm_medium=post" rel="noopener noreferrer"&gt;LobbyBooth&lt;/a&gt;. You upload two photos, it returns a 10-second 720p clip of those two subjects performing side by side in an orange studio, under a single hanging microphone, lips moving to the same track.&lt;/p&gt;

&lt;p&gt;There are &lt;a href="https://lobbybooth.com/?utm_source=devto&amp;amp;utm_medium=post#examples" rel="noopener noreferrer"&gt;six examples on the landing page&lt;/a&gt; — two people in yellow coveralls, a couple dancing, a cat and a golden retriever, raccoons in streetwear, anime characters, an elderly couple. Same room, same mic, same camera, same music every time. Only the performers change.&lt;/p&gt;

&lt;p&gt;Three things were much harder than I expected:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Consistency.&lt;/strong&gt; Getting the model to keep the room &lt;em&gt;and&lt;/em&gt; the characters instead of re-inventing both every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long jobs.&lt;/strong&gt; A 10s 720p clip takes minutes to render — the normal case is under three, and I've watched one take thirteen. You can't hold an HTTP request open that long, and you can't lose the job — or charge for it twice — when the user refreshes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio.&lt;/strong&gt; The vendor model doesn't preserve the reference track, so the music had to be muxed back afterwards. ffmpeg can't run in a Cloudflare Worker, so that piece lives somewhere less fashionable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what each one looks like in practice.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Consistency is a reference-video problem, not a prompt problem
&lt;/h2&gt;

&lt;p&gt;The naive version is a text prompt: "two people singing in an orange studio with a hanging microphone." That gets you a different room every time — different orange, different mic height, camera drifting between runs. It's not a style problem you can talk your way out of.&lt;/p&gt;

&lt;p&gt;What actually works: &lt;strong&gt;stop describing the scene and start handing it over.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I keep a &lt;em&gt;plate&lt;/em&gt; — a 10-second clip of the booth with the camera move, the mic, the lighting and the music already locked in, stored in R2. At generation time the plate goes in as a &lt;strong&gt;video reference&lt;/strong&gt;, and the user's two photos go in as &lt;strong&gt;image references&lt;/strong&gt;. The model's job is reduced to "keep everything, replace the performers":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Keep everything the same in the video - the same deeply saturated orange studio
backdrop with its warm center glow, the same hanging microphone, the same camera
movement, lighting and music. Keep the orange rich and vivid rather than pale or
washed out. The two people from the two reference images are the performers, the
first image on the left and the second image on the right.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire prompt. One string for the whole product — people, pets, anime characters, all of it. Request shape (Seedance 2.0 mini, through an aggregator API):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"seedance-2.0-mini"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;the plate prompt above&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"720p"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16:9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"generate_audio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_urls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;left photo&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;right photo&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"video_urls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;plate.mp4&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order is load-bearing: reference image #1 is the left performer, #2 is the right one.&lt;/p&gt;

&lt;p&gt;A few things I learned the hard way, with dates, because each one cost real renders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Keep everything the same" is not filler.&lt;/strong&gt; Without that sentence the model quietly drops the hanging mic and drifts the composition. With it, the plate holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never describe the operation as "replacing faces."&lt;/strong&gt; &lt;code&gt;replace the faces&lt;/code&gt; / &lt;code&gt;face swapping&lt;/code&gt; phrasing gets the task rejected by the content safety system — no output, and the vendor still bills for it. "Preserve both faces and outfits" passes fine. Same intent, opposite outcome, purely lexical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put third-party music in the reference audio.&lt;/strong&gt; It gets blocked by an audio-copyright policy. Use a plate you made yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same prompt covers pets.&lt;/strong&gt; Identity comes entirely from the reference images; there's no separate animal template.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What holds and what drifts&lt;/strong&gt; (ten seconds of social video, not a portrait studio): outfits, hairstyles, colors and pose energy are stable. Pet fur markings survive. A flat 2D anime illustration comes back with 3D-ish shading — recognizable, but it &lt;em&gt;is&lt;/em&gt; a style shift, and I say so on the site rather than pretending otherwise. Fine facial detail can drift on a fast head turn.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing if you build something similar: &lt;strong&gt;the vendor does not return the plate's audio.&lt;/strong&gt; I measured the correlation between the reference track and the output audio at roughly 0.01 — it's a new, unrelated audio generation. So &lt;code&gt;generate_audio: false&lt;/code&gt; and mux the music back in yourself. Which brings us to the least glamorous part of the stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Long jobs: leases, slots, and a unique index doing a queue's job
&lt;/h2&gt;

&lt;p&gt;The architecture is small on purpose: the API submits to the vendor and &lt;strong&gt;persists a task row&lt;/strong&gt;; the task row is the source of truth; the browser polls &lt;em&gt;our&lt;/em&gt; endpoint, never the vendor's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/ai/generate ──► consume credits ──► submit to vendor ──► ai_task row (pending)
                                                                        │
   browser polls GET /api/ai/task ◄────────────── our API ◄────────────┘
                                                        │  poll lease + singleflight
                                                        ▼
                                                   vendor GET /tasks/:id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three production problems, in the order they showed up:&lt;/p&gt;

&lt;h3&gt;
  
  
  a) Concurrency without adding a queue
&lt;/h3&gt;

&lt;p&gt;Every user gets 10 concurrent slots. No Redis, no job runner — just a partial unique index:&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="nf"&gt;uniqueIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uq_ai_task_user_active_slot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&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="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeSlot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert with slot &lt;code&gt;0&lt;/code&gt;…&lt;code&gt;9&lt;/code&gt;, retry on conflict, and the database is the lock. What this buys is honesty under load: a user can be running ten renders and the eleventh gets a clear answer instead of a pile of unbounded vendor calls.&lt;/p&gt;

&lt;p&gt;The failure mode is a &lt;em&gt;ghost slot&lt;/em&gt;: a request that dies between claiming a slot and finishing leaves it taken forever. Any task that hasn't changed state in 20 minutes is expired and its slot released. (I have a test suite that simulates exactly this — ghost reclaim, lease renewal, tab takeover — because these are the bugs you can't reproduce by clicking around.)&lt;/p&gt;

&lt;h3&gt;
  
  
  b) Two tabs, one vendor task
&lt;/h3&gt;

&lt;p&gt;With a browser doing the polling, a second tab (or a refresh) means two pollers hammering the same vendor task, doubling the API cost for a task you already paid for once.&lt;/p&gt;

&lt;p&gt;Fix: a &lt;strong&gt;poll lease&lt;/strong&gt;. A poller writes &lt;code&gt;poll_lease_id&lt;/code&gt; + a 30-second expiry, and renews every 10 seconds while it's working. A second poller sees a live lease and returns the cached state instead of calling the vendor — singleflight, enforced in the database rather than in the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  c) Charging people correctly
&lt;/h3&gt;

&lt;p&gt;This is the part that keeps you up at night, because it's other people's money.&lt;/p&gt;

&lt;p&gt;Credits are consumed &lt;strong&gt;when the job is accepted&lt;/strong&gt;, FIFO across the user's credit batches (which have expirations). If the render fails, the consumption record is revoked — the user gets their credit back.&lt;/p&gt;

&lt;p&gt;The exception is deliberate: &lt;strong&gt;content-policy refusals are not refunded&lt;/strong&gt;, because the vendor bills me for them regardless. That rule is written into the code next to the refund call, because in six months it looks exactly like a bug.&lt;/p&gt;

&lt;p&gt;And a related bug I did ship, briefly: credit cost used to depend on the request parameters (&lt;code&gt;model + duration + resolution&lt;/code&gt; had to match exactly). One entry point passed a default 5s duration instead of the configured 10s, so the server charged a &lt;em&gt;different tier&lt;/em&gt; — users paid for 10 seconds and got 5. The fix wasn't a patch on the arithmetic; it was &lt;strong&gt;decoupling cost from parameters entirely&lt;/strong&gt;. One render = one credit, regardless of what's in the payload. When pricing depends on a parameter that a caller can silently default, the price is wrong and you find out from a support email.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. ffmpeg does not belong in a Cloudflare Worker
&lt;/h2&gt;

&lt;p&gt;The app runs on Workers, and Workers can't host ffmpeg. But every clip needs the same background music swapped in.&lt;/p&gt;

&lt;p&gt;So there's a small mux worker on a plain server. It polls the app for work over two internal endpoints, muxes, and posts the result back. It holds &lt;strong&gt;no database credentials and no R2 keys&lt;/strong&gt; — just a bearer secret for those two endpoints. If that box is compromised, the blast radius is "someone can mux videos."&lt;/p&gt;

&lt;p&gt;The actual command is four lines and every flag matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-stream_loop&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; bgm.m4a &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-map&lt;/span&gt; 0:v:0 &lt;span class="nt"&gt;-map&lt;/span&gt; 1:a:0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v copy &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 192k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-shortest&lt;/span&gt; &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart out.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-stream_loop -1&lt;/code&gt; on the BGM is &lt;strong&gt;required&lt;/strong&gt;. Without it, &lt;code&gt;-shortest&lt;/code&gt; ends the output at the length of the music — a 10-second music bed silently truncates a 30-second clip to 10 seconds. Looping the audio makes &lt;code&gt;-shortest&lt;/code&gt; resolve to the video's length instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-map 0:v:0 -map 1:a:0&lt;/code&gt; drops every source audio track, whether the model produced one, several, or none.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-c:v copy&lt;/code&gt; leaves the video bitstream untouched — no re-encode, no generation loss, and the whole pass takes under a second.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front, which also fixes vendor MP4s that ship without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson generalizes: serverless is great until you need a real binary. The fix isn't to bend the runtime — it's to put a very dumb, credential-free worker where the binary lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack, for the curious
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Start&lt;/strong&gt; (React 19, Vite) on &lt;strong&gt;Cloudflare Workers&lt;/strong&gt;; &lt;strong&gt;D1&lt;/strong&gt; for data, &lt;strong&gt;R2&lt;/strong&gt; for media&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drizzle ORM&lt;/strong&gt; — the schema is multi-dialect, production is D1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;better-auth&lt;/strong&gt; for accounts; an inlined payment layer with Stripe, PayPal, Creem, Alipay and WeChat Pay adapters; a FIFO credit ledger with expirations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paraglide&lt;/strong&gt; for i18n — English and Chinese, same routes, &lt;code&gt;/zh&lt;/code&gt; prefix on one side only&lt;/li&gt;
&lt;li&gt;Video: &lt;strong&gt;Seedance 2.0 mini&lt;/strong&gt; via an aggregator (APIMart), 10s / 720p / 16:9, 1 credit per render&lt;/li&gt;
&lt;li&gt;Pricing is deliberately boring: $4.90 for one video, $9.90 for three, $19.90 for ten&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prompt is the smallest part.&lt;/strong&gt; The plate (a fixed reference clip) does more for quality than any wording. If your format has a fixed scene — kitchen, car, gym mirror — record it once and reference it forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't build a queue until the database stops being enough.&lt;/strong&gt; A unique index and a lease took this surprisingly far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the vendor layer thin.&lt;/strong&gt; Model names and tiers change monthly; every provider gets one adapter that normalizes to a single task shape. Swapping models should be a config value, not a refactor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the money rules in code comments.&lt;/strong&gt; "Policy refusals are non-refundable" is a business decision that reads like a bug to anyone who didn't make it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it, or tell me how you'd solve it better
&lt;/h2&gt;

&lt;p&gt;Two photos in, one duet out: &lt;strong&gt;&lt;a href="https://lobbybooth.com/?utm_source=devto&amp;amp;utm_medium=post" rel="noopener noreferrer"&gt;lobbybooth.com&lt;/a&gt;&lt;/strong&gt; — the &lt;a href="https://lobbybooth.com/?utm_source=devto&amp;amp;utm_medium=post#examples" rel="noopener noreferrer"&gt;six examples&lt;/a&gt; are the best way to see what the pipeline actually produces before spending anything.&lt;/p&gt;

&lt;p&gt;If you're working on character consistency for video models, I'd genuinely like to compare notes in the comments — especially if you've solved hair and accessory drift across longer clips, or found a better way to keep an illustration's original style instead of letting the model re-shade it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloudflarechallenge</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
