<?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: Ryan - building OwnStack</title>
    <description>The latest articles on DEV Community by Ryan - building OwnStack (@ownstackhq).</description>
    <link>https://dev.to/ownstackhq</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%2F4030038%2Fa17d6fb6-67c8-4635-b532-ac8d69cfcd42.png</url>
      <title>DEV Community: Ryan - building OwnStack</title>
      <link>https://dev.to/ownstackhq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ownstackhq"/>
    <language>en</language>
    <item>
      <title>Character consistency isn't a seed trick: a 2-stage image pipeline that actually locks the face</title>
      <dc:creator>Ryan - building OwnStack</dc:creator>
      <pubDate>Thu, 23 Jul 2026 04:00:36 +0000</pubDate>
      <link>https://dev.to/ownstackhq/character-consistency-isnt-a-seed-trick-a-2-stage-image-pipeline-that-actually-locks-the-face-p76</link>
      <guid>https://dev.to/ownstackhq/character-consistency-isnt-a-seed-trick-a-2-stage-image-pipeline-that-actually-locks-the-face-p76</guid>
      <description>&lt;p&gt;If you're building an app that generates the &lt;em&gt;same&lt;/em&gt; character across many scenes, you've probably hit the wall already: seeds drift, LoRA training is heavy and slow, and "same character, new pose" prompting quietly changes the face. The approach that actually holds up in production is a &lt;strong&gt;2-stage pipeline&lt;/strong&gt; — generate one canonical &lt;strong&gt;base image&lt;/strong&gt;, then &lt;strong&gt;edit &lt;em&gt;from that base&lt;/em&gt; as the reference&lt;/strong&gt; for every new scene. Consistency comes from the reference, not the seed.&lt;/p&gt;

&lt;p&gt;Below: why the common approaches drift, how the 2-stage pipeline works, the async job queue that makes it deployable, and the serverless-GPU setup that keeps it affordable. There's a free, runnable slice of the whole transport layer at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious approaches drift
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Seeds.&lt;/strong&gt; A seed pins the &lt;em&gt;noise&lt;/em&gt;, not the &lt;em&gt;identity&lt;/em&gt;. Re-use a seed with the same prompt and you get the same image — but that's reproduction, not consistency. The moment you change the prompt ("now she's in a café"), the denoising path changes and the face re-rolls with it. Seeds give you determinism for identical inputs; they give you nothing for &lt;em&gt;new scenes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt-only ("the same woman as before").&lt;/strong&gt; The model has no memory. Every generation is a fresh sample from the distribution your words describe. "Same face as last time" isn't in the prompt vocabulary — there is no last time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LoRA per character.&lt;/strong&gt; This one actually works — that's why everyone suggests it — but look at what it costs in an app context: curate 15–40 images per character, run a training job per character, store and load adapter weights per character, and repeat all of it whenever a user creates someone new. For a personal project, fine. For an app where &lt;em&gt;users&lt;/em&gt; create characters on demand, you just signed up to run a training farm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2-stage pipeline
&lt;/h2&gt;

&lt;p&gt;The fix is embarrassingly direct once you see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 1 — CAST          Stage 2 — RE-SCENE (repeat forever)
text-to-image           image-edit model
"describe character" →  base image + "put them in a café" → scene 1
     = base image       base image + "walking in the rain" → scene 2
                        base image + "reading by a window"  → scene 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stage 1&lt;/strong&gt; runs a text-to-image model once (I use Z-Image-Turbo) to &lt;em&gt;cast&lt;/em&gt; the character — one clean, canonical image. This is the identity. Store it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2&lt;/strong&gt; runs an image-&lt;em&gt;edit&lt;/em&gt; model (Qwen-Image-Edit-2511) for every scene after that. The crucial part: the input isn't a text description of the character — it's the &lt;strong&gt;base image itself&lt;/strong&gt;, plus an instruction describing only &lt;em&gt;what should change&lt;/em&gt; (pose, setting, lighting). The model's job is no longer "imagine this person" but "keep this person, change the scene."&lt;/p&gt;

&lt;p&gt;Consistency stops being a property you &lt;em&gt;hope&lt;/em&gt; the sampler preserves and becomes a property of the &lt;strong&gt;conditioning&lt;/strong&gt;. The reference image is in the input every single time, so the face holds — across twenty scenes, fifty scenes, whatever.&lt;/p&gt;

&lt;p&gt;Two details that matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Describe only the change in Stage 2.&lt;/strong&gt; If your scene prompt re-describes the character ("a woman with long dark hair…"), you're inviting the edit model to re-interpret identity. Prompt the &lt;em&gt;delta&lt;/em&gt;: location, pose, lighting. Identity comes from the pixels, not the words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This isn't people-only.&lt;/strong&gt; The reference-edit mechanic doesn't care what the subject is — the same pipeline holds a consistent dog, robot, or anime character. Anything you can cast in Stage 1, you can re-scene in Stage 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part nobody tells you: you can't call a GPU from a web request
&lt;/h2&gt;

&lt;p&gt;Here's where most "it works in my notebook" projects die in deployment. A generation takes 10–40 seconds. An HTTP request does not want to live that long — serverless platforms cap request duration, browsers give up, and users retry and double-charge themselves.&lt;/p&gt;

&lt;p&gt;The pattern that survives production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → POST /api/generate
             └── create Job row (status=queued) → return job id immediately
worker   picks up job → calls the GPU endpoint → writes image to storage
             └── Job row: status=completed, resultUrl
client → GET /api/status/:id   (poll)
             └── completed? render the image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request that starts the job returns in milliseconds. The GPU work happens out-of-band. The client polls — a webhook or SSE is the usual alternative, but polling needs zero extra infrastructure (no public URL, identical local and deployed), so that's what I use. A few hard-won rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The job row is the source of truth&lt;/strong&gt;, not the GPU vendor's job id. Vendors lose webhooks; your DB shouldn't care.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make completion idempotent.&lt;/strong&gt; Two racing polls — or a retry after a timeout — will happily complete the same job twice unless the finalize path claims the row atomically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store results in object storage and serve URLs&lt;/strong&gt;, not bytes through your app server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hosted API or self-host? (honest version)
&lt;/h2&gt;

&lt;p&gt;Let me be straight before the cost section, because "self-hosting is cheaper" as a blanket claim is false and you'd be right to call it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For most low-volume projects, a hosted image API is the correct choice.&lt;/strong&gt; You skip GPU ops entirely, you pay per image, and at a few hundred generations a month the math favors the API — my setup would lose that comparison. If that's you, wrap the API and ship.&lt;/p&gt;

&lt;p&gt;Self-hosting wins in three specific situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Volume.&lt;/strong&gt; A hosted API's per-image price has the vendor's margin baked in — that margin is your cost floor, forever, on every image. Self-hosted, your unit cost is raw GPU-seconds; it doesn't inflate as you grow. Somewhere in the thousands-of-images-a-month range the lines cross, and past it the gap only widens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control.&lt;/strong&gt; The vendor picks your model, your price, your rate limits, and what content gets filtered — and can change any of them under you. Self-hosted, an upstream deprecation or price hike is someone else's news.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle cost.&lt;/strong&gt; Serverless GPU that scales to zero means a quiet app costs ~nothing to keep alive, which is what makes a side project survivable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade is real: you take on packaging, cold starts, and ops. The rest of this section is how to make that trade cheap. (A follow-up post works through the actual break-even math with public prices — API per-image rates vs GPU-seconds — so you can find your own crossover point.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it cheap: serverless GPU, scale-to-zero, bake the model
&lt;/h2&gt;

&lt;p&gt;Self-hosting the pipeline on serverless GPU (I run RunPod) means you pay GPU-seconds only while a job runs, and workers &lt;strong&gt;scale to zero&lt;/strong&gt; when idle.&lt;/p&gt;

&lt;p&gt;The catch is &lt;strong&gt;cold start&lt;/strong&gt;. When a worker wakes up, it has to get the model into VRAM before it can do anything. Three things cut that down hard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bake the model into the container image.&lt;/strong&gt; Downloading tens of GB of weights at boot is where cold starts go to die. Weights in the image = pull once, cached on the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slim the runtime.&lt;/strong&gt; Build toolchains and dev dependencies don't belong in an inference image. Every GB you remove is faster pulls and faster boots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One model per endpoint.&lt;/strong&gt; A fat "does everything" image pays everyone's cold start. Per-endpoint images only load what that endpoint runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic — it's just deliberate packaging. The difference between a hobby deployment and one you can put real users on is mostly these three decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the transport layer (free, zero infra)
&lt;/h2&gt;

&lt;p&gt;I extracted the whole async transport — queue, worker, polling, serving — into a free slice you can run on a laptop in about two minutes: &lt;strong&gt;SQLite instead of Postgres, a stub worker instead of the GPU&lt;/strong&gt;, and the real RunPod dispatch code included read-only so you can see exactly how the live version differs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ownstackhq/ai-image-job-queue
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npx prisma db push &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type a prompt, watch the job go &lt;code&gt;queued → processing → completed&lt;/code&gt; while the original request has long since returned. The 2-stage engine itself (the Stage 1/Stage 2 logic above, running on real GPU) lives in the full kit at &lt;a href="https://ownstackhq.com" rel="noopener noreferrer"&gt;ownstackhq.com&lt;/a&gt;, along with the deploy scripts and the cost-tuning chapter.&lt;/p&gt;

&lt;p&gt;I'd genuinely like to hear where this approach breaks for your use case — especially if you've pushed reference-edit consistency further than twenty scenes, or found a case where per-character LoRA is still worth the training farm.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why not just reuse a seed?&lt;/strong&gt;&lt;br&gt;
A seed fixes the sampling noise, so the same prompt reproduces the same image. Change the prompt to get a new scene and the whole denoising path changes with it — including the face. Seeds give reproducibility for identical inputs, not identity across different ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not train a LoRA per character?&lt;/strong&gt;&lt;br&gt;
LoRA holds identity well, but it's a training job per character: dataset curation, GPU training time, and adapter storage, repeated for every character your users create. Reference-based editing needs one generated image and no training, which is what makes it viable inside an app rather than a workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you keep GPU costs down?&lt;/strong&gt;&lt;br&gt;
Serverless GPU workers that scale to zero, model weights baked into the container image (no boot-time downloads), a slim runtime image, and one model per endpoint. Idle costs approach zero and cold starts drop dramatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work for non-human characters?&lt;/strong&gt;&lt;br&gt;
Yes. The pipeline conditions on a reference image, not on a human-specific representation — the same cast-then-edit loop holds a consistent animal, robot, or anime character across scenes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
