<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Generative-Media | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/generative-media/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/generative-media/index.xml" rel="self" type="application/rss+xml"/><item><title>Seedance 2.0 Is Finally Out: First Impressions</title><link>https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</guid><pubDate>Wed, 15 Apr 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;ByteDance opened up the public release of &lt;a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener"&gt;Seedance 2.0&lt;/a&gt; today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.&lt;/p&gt;
&lt;p&gt;The pitch is strong. It&amp;rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &amp;ldquo;random meme&amp;rdquo; tier and start producing things that look professional.&lt;/p&gt;</description><content:encoded><![CDATA[<p>ByteDance opened up the public release of <a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener">Seedance 2.0</a> today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.</p>
<p>The pitch is strong. It&rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &ldquo;random meme&rdquo; tier and start producing things that look professional.</p>
<p>If you want a quick visual tour before going further, here&rsquo;s a great walkthrough from Stefan, of the <a href="https://www.youtube.com/channel/UCRW08KcTVjXEmBzBsVl7XjA"target="_blank" rel="noopener">Stefan 3D AI Lab</a> channel, which I already plugged in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D modeling</a>:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/fv9vA6RCNHU"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The main interface is Dreamina&rsquo;s &ldquo;Photo Studio,&rdquo; where you pick the generation mode and stack your references. So far I&rsquo;ve only tested the standard multi-reference mode.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/studio.png" alt="The Seedance 2.0 Photo Studio interface, with a reference upload panel and prompt configuration"  loading="lazy" /></p>
<h2>Test 1: lip sync with podcast audio<span class="hx:absolute hx:-mt-20" id="test-1-lip-sync-with-podcast-audio"></span>
    <a href="#test-1-lip-sync-with-podcast-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My first test was simple. I grabbed a few seconds of the opening bumper from my podcast <a href="/en/tags/the-m-akita-chronicles/">The M.Akita Chronicles</a>, which is generated by AI through an <a href="/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/">ElevenLabs v3 pipeline</a>, passed in my new anime-style avatar as a reference image, and asked Seedance to make the character lip-sync and gesture along with that audio.</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test1.mp4" type="video/mp4">
  </video>
  <em>Anime avatar lip-syncing to the M.Akita Chronicles bumper</em>
</div>
<p>The result is decent. All of that came from a single still image. The lip sync follows the speech reasonably well, the expression has some life to it, and the hand gesture lands at the right beat. But this is a YouTube short or Instagram clip at best. Not the kind of animation you&rsquo;d open a serious video with.</p>
<h2>The feature that actually matters: video reference<span class="hx:absolute hx:-mt-20" id="the-feature-that-actually-matters-video-reference"></span>
    <a href="#the-feature-that-actually-matters-video-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And here&rsquo;s what I think is the only real use of these models for actual work: feeding in <strong>video as a reference</strong>. Text alone is never going to be precise enough. You can describe a scene with whatever vocabulary you want, swap adjectives, specify lens, angle, framing, and every generation will still hand you back something subtly different. It&rsquo;s a slot machine. Great for memes, terrible for production.</p>
<p>Most amateurs see one random clip that comes out nice on the first try and go straight to flooding their X and Instagram feeds with it. That&rsquo;s not control. That&rsquo;s luck. For film, for ads, for show openers, for anything where the next shot has to match the previous one, you have to be able to tell the model: &ldquo;do exactly this motion, with exactly this camera.&rdquo; That&rsquo;s where reference video changes the game, because it replaces hours of 3D modeling, rigging, animation, and rendering that would cost a fortune in a studio.</p>
<p>For my test, I recycled the model I&rsquo;d already generated in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D with Hunyuan and Nano Banana</a>, where I tested how far you can take 3D modeling from a prompt and even hit &ldquo;print&rdquo; on the result. But for something more cinematic I wanted a model with a proper animation cycle. So I grabbed the <a href="https://sketchfab.com/3d-models/darth-talon-4338105e09704f359c6afcab7e5fce10"target="_blank" rel="noopener">Darth Talon</a> model from Sketchfab, which has a short, well-made motion sequence, just because it looked cool.</p>
<p>I imported the FBX into Blender, set up a basic camera and lighting, and did a quick Cycles render. Nothing pretty about it, just a sketch to feed in as motion and framing reference.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender.png" alt="The Darth Talon model imported into Blender with camera and lighting set up for a rough Cycles render"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender-render.mp4" type="video/mp4">
  </video>
  <em>The quick Cycles render that's about to become motion reference</em>
</div>
<p>I uploaded that render to Seedance as a video reference along with three t-pose images of the character (front, back, left side) for visual consistency:</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin: 1em 0; justify-content: center;">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/front.jpg" alt="Front t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/back.jpg" alt="Back t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/left.jpg" alt="Left side t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
</div>
<p>First attempt, with a generic prompt:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test2.mp4" type="video/mp4">
  </video>
  <em>First generation: the character stayed consistent, but the motion and camera missed the reference</em>
</div>
<p>Not bad. The character keeps its identity, the lighting got a serious upgrade over my raw render, and the clip is coherent. But the motion and camera position drifted noticeably from the reference. The model basically read it as &ldquo;there&rsquo;s a character here that looks like that doing something like that,&rdquo; and improvised the rest.</p>
<p>Then I remembered Stefan&rsquo;s tutorial: the trick is to literally drop something like <code>Follow exact motion and camera from reference video</code> into the prompt. Tried again:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test3.mp4" type="video/mp4">
  </video>
  <em>Second generation: explicit instruction to follow the reference's motion and camera</em>
</div>
<p>A lot better. It&rsquo;s clearly following the reference choreography, the camera angle matches in several beats, and the character looks more polished than anything I could hand-render in a quick Cycles sketch. But it&rsquo;s still not a frame-for-frame match. In some shots the model is still taking liberties with timing, position, and framing. There are other prompting tricks and parameters to play with, but I stopped here to write this up while the launch is still fresh.</p>
<h2>ComfyUI, Runway, and the rest of the ecosystem<span class="hx:absolute hx:-mt-20" id="comfyui-runway-and-the-rest-of-the-ecosystem"></span>
    <a href="#comfyui-runway-and-the-rest-of-the-ecosystem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 also already shipped <a href="https://blog.comfy.org/p/seedance-20-is-now-available-in-comfyui"target="_blank" rel="noopener">as a ComfyUI cloud node</a>, which is great for anyone who wants to drop the model into a bigger pipeline without bouncing between web UIs. If you&rsquo;ve already got an image-gen workflow built in ComfyUI, this is genuinely handy.</p>
<p>And for anyone who wants volume without doing the per-generation credit math, Runway is offering Seedance 2.0 as part of their <a href="https://www.mindstudio.ai/blog/seedance-2-0-runway-unlimited-plan"target="_blank" rel="noopener">Unlimited plan</a>, which runs around $76/month annual or $95/month monthly. &ldquo;Unlimited&rdquo; means no per-generation charge, but you do trade for lower queue priority at peak hours and an output storage cap. If you iterate a lot, the subscription model makes way more sense than buying credits one at a time.</p>
<h2>The price question<span class="hx:absolute hx:-mt-20" id="the-price-question"></span>
    <a href="#the-price-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance&rsquo;s own pricing is credit-based. The Standard plan is $24.90/month annual or $49.90/month monthly, and gets you 2,500 credits a month. Every 10 seconds of generated video burns 6 credits.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/pricing.png" alt="Seedance 2.0 pricing table, with plans from Standard up to the top tier and per-second credit costs"  loading="lazy" /></p>
<p>The math is brutal. With 2,500 credits on Standard, you get about 416 ten-second generations a month. Sounds like a lot, until you remember my own test: it took at least two tries to get close to what I wanted, and even then it wasn&rsquo;t a one-to-one match. In practice, plan on 3 to 5 regenerations per usable scene. That drops your real number to something like 80 to 140 short clips per month. Maybe enough for a solo creator doing short-form content. Nowhere near enough for production that needs minutes of polished, continuous video.</p>
<p>The higher tiers scale credits roughly linearly, but the same problem holds: at today&rsquo;s price, Seedance 2.0 is still a tool for short clips and well-made memes, or for pre-vis sketches before you send the real thing to an actual studio. It doesn&rsquo;t replace continuous professional production. For that use case, Runway with Unlimited is honestly the cheaper option in practice. <a href="https://seedance2.ai/pricing"target="_blank" rel="noopener">Full pricing table here</a>.</p>
<h2>What nobody wants to talk about: restrictions and deepfakes<span class="hx:absolute hx:-mt-20" id="what-nobody-wants-to-talk-about-restrictions-and-deepfakes"></span>
    <a href="#what-nobody-wants-to-talk-about-restrictions-and-deepfakes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Two things I don&rsquo;t want to leave out. First: Seedance 2.0 took its sweet time getting to the public partly because ByteDance is being a lot more conservative than the competition when it comes to moderation. The model blocks generation that uses real people as reference (celebrities, politicians, public figures) and filters strong-IP characters from Disney, Marvel/DC, Nintendo, and registered trademarks generally. If you want the details of what passes, what doesn&rsquo;t, and why ByteDance went that way, <a href="https://www.mindstudio.ai/blog/seedance-2-0-content-restrictions-workarounds"target="_blank" rel="noopener">this MindStudio piece breaks it down</a>. It makes sense: ByteDance operates across dozens of jurisdictions, lives under heavy regulatory scrutiny worldwide, and has the elephant in the room of Hollywood pushing the entire industry with copyright suits over training data and generation. The release got delayed and arrived with heavier filters precisely because of that.</p>
<p>Second point, and this is the one that matters more: for you, the average reader, the message is that <strong>deepfakes are no longer hypothetical</strong>. Photos on the internet stopped counting as proof a long time ago, because image models like Nano Banana, Hunyuan, and the rest deliver realism that fools anyone. Now video sits in the same bucket. What we just saw above, where a couple of references and two prompts get you a coherent animated scene in minutes, is just the start. The average person, with no training, isn&rsquo;t going to be able to tell generated video from real video at a glance anymore. And Seedance&rsquo;s filters only block above-board, legitimate use. Anyone with bad intent will always find a path through open source models, workarounds, or less strict platforms. That&rsquo;s the world we&rsquo;re in now, and the sooner we collectively accept it, the better off we are.</p>
<h2>No, this doesn&rsquo;t replace real artists<span class="hx:absolute hx:-mt-20" id="no-this-doesnt-replace-real-artists"></span>
    <a href="#no-this-doesnt-replace-real-artists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me cut off the usual speech before it shows up in the comments. No, Seedance doesn&rsquo;t replace VFX artists, directors, editors, none of that. This is a hammer. The tool is the same for everybody. What comes out the other end depends entirely on who&rsquo;s swinging it.</p>
<p>Anyone who&rsquo;s actually studied framing, pacing, continuity, art direction, editing, color, composition is going to use Seedance to 10x the work they were already capable of delivering. Anyone who hasn&rsquo;t studied any of that is going to crank out the same Instagram meme they were already cranking out, just 10x faster. <strong>The AI reflects who you are.</strong> If you&rsquo;re a real artist, the tool multiplies you. If you only think you are, the tool multiplies the mediocrity. There&rsquo;s no miracle here.</p>
<p>This is exactly why the whole &ldquo;now everyone can be a film director with AI&rdquo; wave is the same song we&rsquo;ve heard with the digital camera, with Photoshop, with After Effects, with Premiere, and with every powerful tool that&rsquo;s shown up in the last 30 years. The technical bar drops. The bar of taste, reference, and study sits exactly where it always has. Good people get more productive. Everyone else still needs to hire real ones to do real work.</p>
<h2>Where this goes next<span class="hx:absolute hx:-mt-20" id="where-this-goes-next"></span>
    <a href="#where-this-goes-next" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 is clearly a step in the right direction, but it&rsquo;s not the tool that replaces professional production yet. Text alone stays imprecise, video reference helps but still drifts, and the price per minute of polished output is high relative to what you actually get. The good news is that this story always rhymes: Google has Veo, Runway has its own model and is also reselling Seedance, Kling and Hailuo from China keep pushing, and the open source models are gaining ground. OpenAI had Sora, but they shut it down. As competition tightens, quality goes up and price comes down. A year or two from now we&rsquo;ll be looking back at this post and finding what I just showed pretty quaint. Same as it ever was.</p>
]]></content:encoded><category>generative-media</category><category>reviews</category><category>security</category></item><item><title>How ElevenLabs Was Not Killed by Qwen3 TTS</title><link>https://akitaonrails.github.io/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/</guid><pubDate>Thu, 09 Apr 2026 08:30:00 -0300</pubDate><description>&lt;p&gt;&lt;strong&gt;TL;DR — Listen to this and keep reading:&lt;/strong&gt;&lt;/p&gt;
&lt;audio controls preload="metadata" style="width: 100%; max-width: 640px;"&gt;
&lt;source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg"&gt;
Your browser doesn't support the audio element. &lt;a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3"&gt;Download the mp3 here.&lt;/a&gt;
&lt;/audio&gt;
&lt;p&gt;That&amp;rsquo;s the April 6th episode of the &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;The M.Akita Chronicles&lt;/a&gt; podcast, already generated with the new ElevenLabs v3 pipeline. Subscribe to the show on &lt;a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener"&gt;Spotify&lt;/a&gt; so you don&amp;rsquo;t miss new episodes like this one.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;When Qwen3 TTS dropped, back around January this year, everyone on Twitter/X and on the AI newsletter circuit was shouting &amp;ldquo;ElevenLabs killer&amp;rdquo;. There&amp;rsquo;s a &lt;a href="https://medium.com/@warpie/qwen3-tts-is-the-first-real-open-source-threat-to-elevenlabs-56ba200ab5ee"target="_blank" rel="noopener"&gt;Medium piece&lt;/a&gt; claiming it&amp;rsquo;s the first real open source threat to ElevenLabs. There&amp;rsquo;s a &lt;a href="https://byteiota.com/qwen3-tts-3-second-voice-cloning-beats-elevenlabs/"target="_blank" rel="noopener"&gt;post on byteiota&lt;/a&gt; saying 3-second voice cloning beats ElevenLabs. There&amp;rsquo;s an &lt;a href="https://www.analyticsvidhya.com/blog/2025/12/qwen3-tts-flash-review/"target="_blank" rel="noopener"&gt;analysis on Analytics Vidhya&lt;/a&gt; calling it the most realistic open source TTS released so far. The enthusiast internet consensus was: we finally have open source that can go toe-to-toe with ElevenLabs, the tables have turned, it&amp;rsquo;s just a matter of time.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>TL;DR — Listen to this and keep reading:</strong></p>
<audio controls preload="metadata" style="width: 100%; max-width: 640px;">
  <source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg">
  Your browser doesn't support the audio element. <a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3">Download the mp3 here.</a>
</audio>
<p>That&rsquo;s the April 6th episode of the <a href="/en/tags/the-m-akita-chronicles/">The M.Akita Chronicles</a> podcast, already generated with the new ElevenLabs v3 pipeline. Subscribe to the show on <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Spotify</a> so you don&rsquo;t miss new episodes like this one.</p>
<hr>
<p>When Qwen3 TTS dropped, back around January this year, everyone on Twitter/X and on the AI newsletter circuit was shouting &ldquo;ElevenLabs killer&rdquo;. There&rsquo;s a <a href="https://medium.com/@warpie/qwen3-tts-is-the-first-real-open-source-threat-to-elevenlabs-56ba200ab5ee"target="_blank" rel="noopener">Medium piece</a> claiming it&rsquo;s the first real open source threat to ElevenLabs. There&rsquo;s a <a href="https://byteiota.com/qwen3-tts-3-second-voice-cloning-beats-elevenlabs/"target="_blank" rel="noopener">post on byteiota</a> saying 3-second voice cloning beats ElevenLabs. There&rsquo;s an <a href="https://www.analyticsvidhya.com/blog/2025/12/qwen3-tts-flash-review/"target="_blank" rel="noopener">analysis on Analytics Vidhya</a> calling it the most realistic open source TTS released so far. The enthusiast internet consensus was: we finally have open source that can go toe-to-toe with ElevenLabs, the tables have turned, it&rsquo;s just a matter of time.</p>
<p>I decided to test it on my own production pipeline, as usual. I built out a whole pipeline on top of Qwen3 TTS 1.7B to generate the weekly podcast for <a href="/en/tags/the-m-akita-chronicles/">The M.Akita Chronicles</a>, and I documented the behind-the-scenes on the <a href="/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/">serving-AI-in-the-cloud post</a>. Anyone who wants the detail on cold starts, voice cloning, and sampling parameters that change from one mode to another, that&rsquo;s the place. I&rsquo;m not repeating it all here.</p>
<p>This post asks a different question. After almost two months running that setup in production, shipping an episode every Monday, last night I finally pulled the plug on Qwen3 and switched the whole thing to ElevenLabs v3. Let me tell you why.</p>
<h2>What didn&rsquo;t work on Qwen3<span class="hx:absolute hx:-mt-20" id="what-didnt-work-on-qwen3"></span>
    <a href="#what-didnt-work-on-qwen3" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Between February 15 and March 30, I made dozens of commits fine-tuning the podcast flow: prompts, sampling parameters, generation order, leading silence on the voice clone sample, volume normalization, pronunciation of tech acronyms. Fixing Marvin&rsquo;s voice, which was clipping the first syllable because the reference audio started without any lead-in silence. Tuning Akita&rsquo;s voice to sound more confident and assertive. Adding crossfades between section jingles. Sorting out the &ldquo;podcast&rdquo; pronunciation so it didn&rsquo;t come out as &ldquo;pódcast&rdquo;. Making the script generator prefer Portuguese over gratuitous anglicisms so the TTS wouldn&rsquo;t choke. Each of those fixes was a multi-hour session of listening to audio, regenerating, tweaking a parameter.</p>
<p>The result came out acceptable. &ldquo;Acceptable&rdquo; in the sense that I managed to ship every episode without re-recording anything by hand. But if you listen closely, the Qwen3 voice has that unmistakable AI-generating-audio quality. Flat intonation, uniform rhythm. Over long stretches you can feel it&rsquo;s a machine talking. Good enough to ship, but miles away from what you&rsquo;d hear on a professional podcast made by humans.</p>
<p>The worst problem was English pronunciation. My podcast covers tech news, so terms like &ldquo;MCP&rdquo;, &ldquo;RAG&rdquo;, &ldquo;Claude Opus&rdquo;, &ldquo;GPT-5&rdquo;, &ldquo;open source&rdquo; show up in every conversation. Qwen3 took those terms and pronounced them with a Brazilian accent, something like &ldquo;oh-pen-ee-sohrss-eh&rdquo;, that kind of thing. Unlistenable for the audience. The workaround I had to implement was manually mapping in the script-generation LLM prompt which English words to swap for Portuguese equivalents. The prompt today has a whole section split between &ldquo;keep in English&rdquo; (proper nouns, brands, terms already adopted into Brazilian tech lingo) and &ldquo;translate to Portuguese&rdquo; (gratuitous anglicisms), looking roughly like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**REPLACE with Portuguese** (common English words that have natural
</span></span><span class="line"><span class="cl">Brazilian Portuguese equivalents):
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;update&#34; → &#34;atualização&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;release&#34; → &#34;lançamento&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;feature&#34; → &#34;recurso/funcionalidade&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;deploy&#34; → &#34;implantação&#34; or just &#34;colocar em produção&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;trade-off&#34; → &#34;dilema&#34; or &#34;escolha&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;performance&#34; → &#34;desempenho&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;default&#34; → &#34;padrão&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;insight&#34; → &#34;percepção/sacada&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;skills&#34; → &#34;habilidades&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;approach&#34; → &#34;abordagem&#34;
</span></span><span class="line"><span class="cl">- &#34;highlights&#34; → &#34;destaques&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I call this &ldquo;scrubbing anglicisms so the TTS doesn&rsquo;t choke&rdquo;. The funny part is I didn&rsquo;t want that level of restriction on my script. I wanted the voice to just pronounce &ldquo;update&rdquo; when &ldquo;update&rdquo; was the natural word. Since the model couldn&rsquo;t, I had to mutilate the podcast&rsquo;s vocabulary so the final result was actually listenable. It&rsquo;s a band-aid, the kind you add hoping to rip it off later when the tech grows up.</p>
<h2>The experience with ElevenLabs v3<span class="hx:absolute hx:-mt-20" id="the-experience-with-elevenlabs-v3"></span>
    <a href="#the-experience-with-elevenlabs-v3" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Yesterday afternoon I opened an ElevenLabs account, bought the Pro plan ($99/month), and started experimenting with the <code>eleven_v3</code> model, which shipped in February of this year. Thirty minutes later I had a proof of concept running, and about two hours later the whole podcast system was migrated. The effort gap is an abyss.</p>
<p>The technical details of the migration ended up in an internal project doc, so here&rsquo;s the summary comparison table that actually matters:</p>
<table>
  <thead>
      <tr>
          <th>Dimension</th>
          <th>Qwen3 TTS 1.7B (old)</th>
          <th>ElevenLabs <code>eleven_v3</code> (current)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quality (Akita)</td>
          <td>Good, cloned from real audio</td>
          <td>Better, same clone but more natural prosody</td>
      </tr>
      <tr>
          <td>Inline emotion in script</td>
          <td>Not supported</td>
          <td><code>[sighs]</code>, <code>[sarcastically]</code>, <code>[excited]</code>, <code>[laughs]</code>, works in pt-BR</td>
      </tr>
      <tr>
          <td>Cold start</td>
          <td>5 to 15 min spinning up a RunPod GPU before each run</td>
          <td>Zero, HTTPS call with immediate response</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>~1× real time (serialized)</td>
          <td>~6× real time with concurrency 4</td>
      </tr>
      <tr>
          <td>Wall clock for a 28-min episode</td>
          <td>~25 to 30 min</td>
          <td><strong>~4 min</strong></td>
      </tr>
      <tr>
          <td>Operational surface</td>
          <td>RunPod, Docker, FastAPI, Qwen weights, GPU billing</td>
          <td>One env var (<code>ELEVENLABS_API_KEY</code>)</td>
      </tr>
      <tr>
          <td>Cost per episode</td>
          <td>~$0.08 of GPU</td>
          <td>~$2.70 in ElevenLabs credits</td>
      </tr>
  </tbody>
</table>
<p>Look at the last line. Qwen3 costs less than ten cents of a dollar per episode. ElevenLabs costs almost thirty times more. And it&rsquo;s still worth it. The other lines on the table solve problems that were eating hours of my week. I no longer need to write code to scale GPUs in the cloud, I don&rsquo;t need to wait for the machine to spin up every time, I don&rsquo;t need to babysit a model or rebuild a Docker image when the weights change. The operation became a single line of configuration.</p>
<p>And here&rsquo;s the interesting part: the inline emotion tags. The v3 model accepts markers like <code>[sighs]</code>, <code>[sarcastically]</code>, <code>[dryly]</code>, <code>[excited]</code>, and changes the delivery accordingly. This works in more than 70 languages, including Brazilian Portuguese. It transformed script generation, because now I can ask the LLM that writes the script to drop emotional tags at the right moments, which gives a liveliness Qwen3 couldn&rsquo;t deliver in a million years. A concrete example of what comes out of the script later:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>AKITA: Isso é simples. [dismissive] Quem ainda acredita que Bitcoin
vai morrer não tá prestando atenção.
MARVIN: [sighs] Mais uma semana, mais uma leva de devs confiando
cegamente em pacotes npm. Previsível.</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I even have two separate tag palettes, one for Akita (expressive but controlled, uses <code>[excited]</code>, <code>[dismissive]</code>, <code>[emphatic]</code>) and another for Marvin (stoic, just <code>[sighs]</code>, <code>[sarcastically]</code>, <code>[tired]</code>, <code>[dryly]</code>). That&rsquo;s all encoded in the script generation prompts so the LLM knows which character is allowed to use what.</p>
<h2>About Marvin&rsquo;s voice<span class="hx:absolute hx:-mt-20" id="about-marvins-voice"></span>
    <a href="#about-marvins-voice" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For listeners who are already used to Marvin&rsquo;s voice, don&rsquo;t worry: I cloned him on ElevenLabs using the same audio sample I had used to train on Qwen3. It&rsquo;s the same voice. It just sounds even better now, because the ElevenLabs model captures nuance and prosody that Qwen3 couldn&rsquo;t deliver.</p>
<h2>Listen to it and tell me<span class="hx:absolute hx:-mt-20" id="listen-to-it-and-tell-me"></span>
    <a href="#listen-to-it-and-tell-me" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To prove the talk is real, here&rsquo;s Monday&rsquo;s episode, April 6th, already generated with the new pipeline:</p>
<audio controls preload="metadata" style="width: 100%; max-width: 640px;">
  <source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg">
  Your browser doesn't support the audio element. <a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3">Download the mp3 here.</a>
</audio>
<p>If you&rsquo;re already a podcast listener on <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Spotify</a> and you&rsquo;ve heard previous episodes made with Qwen3, compare them and tell me in the comments whether you notice the difference or whether it&rsquo;s the same to you. I&rsquo;m genuinely curious how much of this is my trained ear after hours of listening to TTS audio and how much is an obvious difference for a casual listener.</p>
<p>Starting next week, every episode of the podcast will be generated by ElevenLabs v3. The newsletter is already plugged into the new pipeline, the scheduled jobs to pre-warm the RunPod GPU have been disabled, and the legacy Qwen3 code stays in the repo as plan B in case v3 starts acting up chronically. Two file edits and I can switch back. I probably never will.</p>
<h2>The part about dubbing the YouTube videos<span class="hx:absolute hx:-mt-20" id="the-part-about-dubbing-the-youtube-videos"></span>
    <a href="#the-part-about-dubbing-the-youtube-videos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the hook for the second half of this post. On the <a href="/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/">anniversary piece I published earlier today</a>, I told the story of how Claude Code translated nearly half my blog to English over a weekend. In the same spirit, I went after dubbing the videos on the <a href="https://www.youtube.com/@Akitando"target="_blank" rel="noopener">Akitando</a> channel.</p>
<p>The channel has 146 episodes, around 96 hours of technical content in Portuguese, and more than 500k subscribers. I already had the subtitles translated (one curated <code>.srt</code> per episode), and YouTube even offers automatic English dubbing. But the result is like Google Translate circa 2015: gets the idea across, nobody wants to listen for very long.</p>
<p>I tested ElevenLabs&rsquo; three voice approaches and only one worked. Speech-to-Speech converts a voice but doesn&rsquo;t translate. The Dubbing API does translate but creates the voice on its own, with no way to force a specific clone. Only Text-to-Speech could solve this: take the English <code>.srt</code> I already had, send each block to the TTS endpoint with my cloned voice, and assemble the audio aligned to the original video.</p>
<p>But having a translated <code>.srt</code> is not enough. Raw subtitle translation doesn&rsquo;t survive TTS. Passages with source code, URLs, hex hashes, lists of shell commands — the model switches to spelling mode and the audio comes out at twice the expected duration. Translations that run too long blow past the time window and need to be condensed so the voice doesn&rsquo;t sound like an auctioneer. Truncated SRTs need to be completed. And after each fix, you re-run the pipeline, listen, find the next problem, fix it, re-run. The whole thing was a cycle of interruptions, manual corrections, and re-runs — a far cry from the &ldquo;press a button and get a dub&rdquo; fantasy.</p>
<p>The big challenge was the accent. My cloned voice was trained on Brazilian Portuguese audio, so when it tries to speak English, the thick accent comes through. ElevenLabs has an <code>[American accent]</code> tag that works on v3, but on top of a voice trained in another language it&rsquo;s weak — the Brazilian accent still bleeds through. The fix was to train a second voice of mine, English only. I recorded a few minutes in my best American accent, uploaded it as a separate Instant Voice Clone in my account, named it &ldquo;Akita English&rdquo;, and set the pipeline to use that voice by default. The result comes out more natural, no tag needed, and the voice identity is still mine.</p>
<h3>The honest ceiling on my English voice quality<span class="hx:absolute hx:-mt-20" id="the-honest-ceiling-on-my-english-voice-quality"></span>
    <a href="#the-honest-ceiling-on-my-english-voice-quality" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Let&rsquo;s be frank. I&rsquo;m a Brazilian who speaks English, not a native speaker, and I don&rsquo;t have the time or patience to drill pronunciation for hours. A clone can only ever be as good as the source sample — if the source is a non-native reading a script, the clone inherits every imperfection. No clone is going to make me sound like &ldquo;Fabio speaking perfect English&rdquo; because that sound doesn&rsquo;t exist for the model to learn from.</p>
<p>To get to the version that&rsquo;s running today, I recorded five different training samples throughout the day, each with a different rhythm and script, and ran an A/B battery against the original Portuguese. None sounded native, and that was never the goal. The realistic bar was more modest: sound recognizably like me, read technical content without stumbling, and carry a full one-hour episode without wearing the listener out. The winner ended up being the first iteration, the original &ldquo;Akita English&rdquo;. Far from perfect, but good enough to ship. Swapping it out later is literally one line in the config file.</p>
<p>What surprised me most along the way was realizing that the rhythm of the training sample matters more than the timbre. The clone learns the cadence of whoever recorded it: iteration 5 came out 25% slower than iteration 2 reading the exact same text, purely because I recorded that particular sample more slowly. For dubbing tech videos, the right target is the rhythm of a &ldquo;YouTube tech explainer&rdquo; — brisk, energetic, around 150 words per minute. No audiobook pacing.</p>
<h3>How I align audio to the original video<span class="hx:absolute hx:-mt-20" id="how-i-align-audio-to-the-original-video"></span>
    <a href="#how-i-align-audio-to-the-original-video" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A non-obvious point: spoken English tends to be longer than equivalent Portuguese. If you just generate the audio for each subtitle and paste it at the original timestamp, the dub drifts later and later. With 30 minutes of video you&rsquo;re already several seconds out of sync.</p>
<p>My approach was to attack the problem in layers. The first version split the SRT into smaller blocks, called &ldquo;chunks&rdquo;. Each chunk maxed out at 700 characters (so v3 wouldn&rsquo;t hallucinate) and could only cut at a sentence boundary, never mid-sentence. A simple algorithm accumulated subtitles into a buffer until it was about to overflow, then walked back looking for the last cue whose text ended with <code>.</code>, <code>!</code>, or <code>?</code>, and flushed only up to there. The cues still in the buffer got rolled into the next chunk. Each chunk also stored the start and end timestamps of the subtitles it covered, so the assembly step knew exactly where to drop that piece of audio later.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># For every incoming cue, check whether adding it</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would push the buffer past the char cap.</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">cue</span> <span class="ow">in</span> <span class="n">cues</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">projected</span> <span class="o">=</span> <span class="n">buffer_char_len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">+</span> <span class="nb">len</span><span class="p">(</span><span class="n">cue</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">projected</span> <span class="o">&gt;</span> <span class="n">max_chars</span> <span class="ow">and</span> <span class="n">buf</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Walk back for the last cue in the buffer that</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># ends in &#39;.&#39;, &#39;!&#39;, &#39;?&#39;, or &#39;…&#39;.</span>
</span></span><span class="line"><span class="cl">        <span class="n">sentence_end</span> <span class="o">=</span> <span class="n">last_sentence_end_index</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">sentence_end</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">sentence_end</span><span class="p">,</span> <span class="n">chunks</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># Run-on sentence longer than max_chars with no</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># terminator. Rare, but happens. Warn and cut.</span>
</span></span><span class="line"><span class="cl">            <span class="n">log</span><span class="o">.</span><span class="n">warning</span><span class="p">(</span><span class="s2">&#34;run-on sentence &gt; </span><span class="si">%d</span><span class="s2"> chars — &#34;</span>
</span></span><span class="line"><span class="cl">                        <span class="s2">&#34;splitting mid-sentence as a last resort&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">max_chars</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">),</span> <span class="n">chunks</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">cue</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Soft-break: if the buffer is already at least 60% full</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># AND the current cue ended a sentence, flush now. Keeps</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># chunks balanced around 60-100% of max.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">cue</span><span class="o">.</span><span class="n">ends_sentence</span> <span class="ow">and</span> <span class="n">buffer_char_len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">max_chars</span> <span class="o">*</span> <span class="mf">0.6</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">),</span> <span class="n">chunks</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Technically, this was the turning point: instead of letting the dub follow the visual structure of the subtitle file, the chunker started following the real paragraph structure of the script. I won&rsquo;t retell the whole story of how I got there and why it eventually forced a mass rerender, because I come back to that later in the section about the problems that only showed up after the first upload. The technical point here is simple: once the window represented a real spoken paragraph, the audio started sounding natural, and the stretch target could move from 92% to 95%.</p>
<p>Once the chunking is done, there&rsquo;s still the problem that English runs longer than the Portuguese equivalent when spoken. The trick here is predicting whether a chunk is going to blow past its target window <strong>before</strong> generating the audio. If the ratio <code>characters / 16 chars-per-second</code> already exceeds the target duration by a margin, the pipeline passes the <code>speed</code> parameter directly to the ElevenLabs API, asking the model to natively generate faster speech. This preserves prosody way better than compressing afterwards. The ceiling is 1.15× (beyond that the voice starts sounding rushed).</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># before calling the API, predict if it will overrun</span>
</span></span><span class="line"><span class="cl"><span class="n">expected_sec</span> <span class="o">=</span> <span class="n">char_count</span> <span class="o">/</span> <span class="n">EXPECTED_CHARS_PER_SEC</span>  <span class="c1"># 16 chars/s</span>
</span></span><span class="line"><span class="cl"><span class="n">predicted_ratio</span> <span class="o">=</span> <span class="n">expected_sec</span> <span class="o">/</span> <span class="n">target_sec</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">voice_speed</span> <span class="o">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">predicted_ratio</span> <span class="o">&gt;</span> <span class="n">PREEMPTIVE_SPEED_THRESHOLD</span><span class="p">:</span>   <span class="c1"># 1.05</span>
</span></span><span class="line"><span class="cl">    <span class="n">voice_speed</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">predicted_ratio</span> <span class="o">*</span> <span class="mf">0.98</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">PREEMPTIVE_SPEED_MAX</span><span class="p">)</span>         <span class="c1"># cap at 1.15</span>
</span></span><span class="line"><span class="cl">    <span class="n">voice_settings</span><span class="p">[</span><span class="s2">&#34;speed&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">voice_speed</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Even with preemptive <code>speed</code> turned on, sometimes the generated audio still comes out a tiny bit longer than the target window. That&rsquo;s what the second safety net is for: measure the actual audio with <code>ffprobe</code> after it&rsquo;s generated, compare to the target window, and apply the <code>ffmpeg</code> <code>atempo</code> filter to compress in post if it exceeded the 2% tolerance, capped at 1.20×. Combining native <code>speed</code> (1.15×) with <code>atempo</code> (1.20×) gives an effective compression ceiling of 1.38×, enough to fit naturally even in the worst cases without breaking quality.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># after generating the chunk</span>
</span></span><span class="line"><span class="cl"><span class="n">actual</span> <span class="o">=</span> <span class="n">ffprobe_duration</span><span class="p">(</span><span class="n">chunk_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">ratio</span> <span class="o">=</span> <span class="n">actual</span> <span class="o">/</span> <span class="n">target_sec</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">ratio</span> <span class="o">&gt;</span> <span class="n">FIT_TOLERANCE</span><span class="p">:</span>       <span class="c1"># 1.02</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># compress the audio without touching pitch</span>
</span></span><span class="line"><span class="cl">    <span class="n">ffmpeg_atempo</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">ratio</span><span class="p">,</span> <span class="n">FIT_MAX_ATEMPO</span><span class="p">)</span>   <span class="c1"># 1.20</span>
</span></span><span class="line"><span class="cl">    <span class="n">apply_atempo</span><span class="p">(</span><span class="n">chunk_path</span><span class="p">,</span> <span class="n">ffmpeg_atempo</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When a generated chunk comes out shorter than the target window, the pipeline slightly stretches the audio (without affecting pitch) to reduce the ugly silence after it ends. The goal isn&rsquo;t to fill the whole window — a natural pause between sentences is fine — just to smooth over the worst offenders that would feel like &ldquo;cut audio&rdquo;.</p>
<p>On a big episode, 95 minutes, 194 chunks, the total cumulative drift came out to -0.7%. About 43 seconds of drift across the entire episode, imperceptible while you&rsquo;re watching.</p>
<p>What saved the budget on this architecture is that each chunk lives on disk as its own <code>.mp3</code>. The pipeline keeps a manifest with the normalized text of every chunk, and before calling the ElevenLabs API, it compares the current text against the cache. If the text hasn&rsquo;t changed, it reuses the existing audio without spending a single credit. If I rewrite a problematic cue, only the chunks affected by that cue get regenerated — the rest of the episode stays untouched.</p>
<p>This is what made the iterations viable. I&rsquo;d run the batch, listen to sections, spot a problem (translation too long, a code snippet the TTS couldn&rsquo;t pronounce, a chunk boundary that landed at a bad spot), fix the SRT or tweak the chunker parameters, and re-run. Each re-run consumed a fraction of the credits and time of the original run, because only the changed chunks were regenerated. Without this cache, every iteration would have cost nearly as much as the first pass, and the total batch cost would have been two or three times higher.</p>
<p>That chunking change also had a heavy cache and cost impact, but I&rsquo;ll leave that part for the later section where I explain the actual retracing and rerender cycle. Here, the important point is just that it was the right technical move.</p>
<h3>When <code>atempo</code> can&rsquo;t save you: rewriting cues before TTS<span class="hx:absolute hx:-mt-20" id="when-atempo-cant-save-you-rewriting-cues-before-tts"></span>
    <a href="#when-atempo-cant-save-you-rewriting-cues-before-tts" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Not everything is about window alignment. There&rsquo;s a kind of cue that breaks the whole pipeline, where neither preemptive <code>speed</code> nor post-process <code>atempo</code> can save it, and I only ran into it once I started running the batch over the more technical episodes of the channel.</p>
<p>The problem is this. The v3 reads at about 16 characters per second when you hand it normal conversational text. But when you hand it a cue stuffed with a literal URL, a hex hash, a binary string, a long sorted number list, or a shell code block, the model shifts into &ldquo;spelling it out letter by letter&rdquo; mode and drops to about 9 characters per second. A 500-character cue that was supposed to turn into 30 seconds of audio comes out at 55. The sanity check rejects it (because it&rsquo;s past 1.8×), the automatic retry tries the same 500 characters again, the five retries all fail in a row, and the chunk gets stuck.</p>
<p>And this did not happen by accident. I used to write those passages that way because I knew the same scripts would later become blog posts, so it made sense to leave the raw URL, the full command, the complete hash, the whole technical detail there for the reader. In Portuguese that worked fine because it matched my original workflow exactly: record the video, then publish the matching text on the blog. What I had never prepared for was the next step, turning that same material into automated English dubbing. I only realized that conflict when the pipeline started failing for real.</p>
<p>I hit this first on ep052, the Ubuntu beginner&rsquo;s guide, where two cues carried <code>github.com</code> URLs, <code>hkp://keyserver.ubuntu.com</code> URLs, and a 40-character GPG hash. Trying to fix that in post-processing is a waste of time. The 1.20× <code>atempo</code> ceiling will never compress 55 seconds into 30, and even if it did, the result would be an unlistenable chipmunk. The fix has to land upstream, in the text itself.</p>
<p>The solution was to rewrite the cue so it describes what the command does instead of showing the literal command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">- Run: apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 \
</span></span></span><span class="line"><span class="cl"><span class="gd">-   --recv-keys 0A6A3E7F79F93EF8AAB9E92BAEBB74C8B5A1E44D
</span></span></span><span class="line"><span class="cl"><span class="gi">+ Run the full command in the video description to import the
</span></span></span><span class="line"><span class="cl"><span class="gi">+ signing key from the Ubuntu keyserver.
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The on-screen English caption still shows the full command, so anyone reading the caption sees exactly what they need to type. The dubbed audio describes the intent in spoken language, which is what the TTS can deliver without choking. The instruction to the viewer stays intact, the characters-per-second ratio goes back to the expected 16, and the generated audio fits the original window without needing any stretch or compression in post.</p>
<p>I went hunting for cues like this across the whole batch. I scanned from ep052 through ep146 looking for cues with a high density of &ldquo;hard&rdquo; characters (digits, brackets, operators, long URLs, binary, hex) and ended up rewriting around 30 cues across 13 episodes:</p>
<ul>
<li>ep052 Ubuntu for devs: GitHub URLs and a 40-character GPG hash</li>
<li>ep091 Hello World in C: binary examples</li>
<li>ep095 640kB memory: address wrap, segment-offset</li>
<li>ep106 CodeMiner: spelled-out email address</li>
<li>ep113 Compression: pure binary strings, 75% hard characters</li>
<li>ep115 SQL Server: long sorted number list</li>
<li>ep120 Internet: dotted IP addresses</li>
<li>ep121 Sockets: two JavaScript code blocks</li>
<li>ep122 Proxies: Chrome User-Agent headers</li>
<li>ep123 Secure networking: shell one-liners, Docker flags</li>
<li>ep126 Gentoo: C <code>chroot</code> demo</li>
<li>ep136 Containers: GitHub release URL</li>
<li>ep144 Cryptography: signing key address</li>
</ul>
<p>The pattern was always the same: keep the caption showing the code, URL, or hash verbatim, and rewrite the spoken text so the narrator describes what it does. I could have automated the rewrite by letting an LLM read each cue and propose a substitution on the fly, but it felt safer to review them all by hand. It&rsquo;s exactly the kind of thing where the model decides to &ldquo;improve&rdquo; a command to make it cleaner and ships with broken shell. The manual sweep took about two hours. The automated version would have cost the same time in review, with the bonus anxiety of having shipped a mangled command by accident.</p>
<p>There was also one weird structural case on ep146, about Docker Compose. Two cues had glued themselves together because of a missing blank line in the SRT, and <code>pysrt</code> was treating them as one giant cue. The TTS never even got to process it — the chunker choked first. I fixed it by hand, adding the missing blank line. One-character fix, half an hour to track down.</p>
<h3>Reconstructing truncated SRTs<span class="hx:absolute hx:-mt-20" id="reconstructing-truncated-srts"></span>
    <a href="#reconstructing-truncated-srts" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Another trap showed up when I went to run the full batch: three episodes had English captions that were too short relative to the actual video length. Ep056, the Rails episode, was the most absurd case. Nineteen minutes of caption for an 80-minute video. Sixty-two minutes of content with no caption at all. Ep057 (WSL 2) was missing 14 minutes, and ep068 (Git Direito) was missing 2.</p>
<p>This is a leftover from my old translation workflow. At some point I started hand-revising the caption, stopped partway through, and the <code>.en.srt</code> file got saved truncated at the spot where I stopped. You can&rsquo;t dub an 80-minute video with a 19-minute caption. The chunker produces audio up to where it can read and then simply has no idea what to do with the rest of the video.</p>
<p>The fix became a new script. It diffs the truncated <code>.en.srt</code> against the <code>.pt-orig.srt</code> (the raw YouTube auto-caption, which always covers the whole video), picks up the cues that only exist in Portuguese, sends them to Claude Sonnet 4.6 with a strict JSON schema asking for cue-by-cue translation, and pastes the result back at the tail of the English file. The schema is the detail that matters. When I tried asking for the response as plain text in <code>N|text</code> format, Claude was dropping about 20% of the cues along the way. With a strict JSON schema, the drop rate fell to zero across the three repairs.</p>
<p>The numbers:</p>
<ul>
<li>ep068 Git Direito: +50 cues / 2 minutes reconstructed</li>
<li>ep057 WSL 2: +368 cues / 14 minutes</li>
<li>ep056 Rails: +1445 cues / 62 minutes (plus dropping 5 fake cues the old translator had invented at the tail to plug the gap)</li>
</ul>
<p>After the repairs, the three episodes joined the batch normally and got dubbed like any other. The kind of tool you hope you never need, but when you need it, it&rsquo;s worth writing once and closing the problem for good.</p>
<h3>Automatic emotion tags<span class="hx:absolute hx:-mt-20" id="automatic-emotion-tags"></span>
    <a href="#automatic-emotion-tags" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>While I was testing, I decided to add one more step to the pipeline: an &ldquo;emotion tagger&rdquo; that reads the English SRT before it goes to TTS and inserts tags like <code>[sarcastic]</code>, <code>[thoughtful]</code>, <code>[emphatic]</code>, <code>[deadpan]</code> at spots where a human narrator would naturally shift tone. The idea is to mimic what a professional voice actor would do — hit the emphasis at the right moments — without turning the video into a theater of overblown emotions.</p>
<p>This part is tricky for two reasons. First: letting an LLM loose on your SRT runs a real risk of it &ldquo;improving&rdquo; the text (swapping a word here, rewriting a sentence there) and you ending up with a dub that doesn&rsquo;t match the caption. Second: LLMs love to overdo it. Ask them to tag emotion and they&rsquo;ll put a tag on every other line. To guard against both, I pinned a small allow-list of tags and run a round-trip validation after Claude&rsquo;s response comes back:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ALLOWED_TAGS</span><span class="p">:</span> <span class="nb">frozenset</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[sarcastic]&#34;</span><span class="p">,</span> <span class="s2">&#34;[thoughtful]&#34;</span><span class="p">,</span> <span class="s2">&#34;[emphatic]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[deadpan]&#34;</span><span class="p">,</span> <span class="s2">&#34;[serious]&#34;</span><span class="p">,</span> <span class="s2">&#34;[amused]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[sighs]&#34;</span><span class="p">,</span> <span class="s2">&#34;[exasperated]&#34;</span><span class="p">,</span> <span class="s2">&#34;[confident]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[matter-of-fact]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">validate_tagged</span><span class="p">(</span><span class="n">original</span><span class="p">,</span> <span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Ensure the tagged SRT preserves the original with no drift.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">original</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="s2">&#34;cue count mismatch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">o</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">original</span><span class="p">,</span> <span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Index and timestamp must match byte for byte.</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">o</span><span class="o">.</span><span class="n">index</span> <span class="o">!=</span> <span class="n">t</span><span class="o">.</span><span class="n">index</span> <span class="ow">or</span> <span class="n">o</span><span class="o">.</span><span class="n">timestamp</span> <span class="o">!=</span> <span class="n">t</span><span class="o">.</span><span class="n">timestamp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: header drift&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1"># Any tag outside the allow-list invalidates the whole response.</span>
</span></span><span class="line"><span class="cl">        <span class="n">bad</span> <span class="o">=</span> <span class="n">find_disallowed_tags</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">bad</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">bad</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1"># The text with the tags stripped must be byte-identical to</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># the original. If the LLM swapped a word, rewrote anything,</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># validation fails and the response is rejected.</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">strip_tags</span><span class="p">(</span><span class="n">o</span><span class="o">.</span><span class="n">text</span><span class="p">)</span> <span class="o">!=</span> <span class="n">strip_tags</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">text</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: text drift&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When validation fails, the pipeline throws the response away and re-requests (or, if it keeps failing, ships the SRT untagged). On tag density, the prompt sent to Claude explicitly says: aim for <code>N/10</code> tags in a SRT with N cues, hard floor of 1 tag per 12 cues, hard ceiling of 1 tag per 7 cues, distribution balanced across the four quarters of the episode, and no two tagged cues within 4 cues of each other. That rule set was the sixth iteration — the five before it either blanketed everything in tags or loaded them all into the first quarter.</p>
<h3>Don&rsquo;t let v3 hallucinate<span class="hx:absolute hx:-mt-20" id="dont-let-v3-hallucinate"></span>
    <a href="#dont-let-v3-hallucinate" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A critical detail that only shows up in production: ElevenLabs v3 tends to hallucinate when you send very long blocks of text. I&rsquo;ve seen blocks where the input text was around 1,500 characters and the model generated <strong>nine minutes</strong> of audio when the expected duration was a minute and a half. The model just decides to keep talking on its own, inventing content.</p>
<p>The official ElevenLabs docs recommend keeping each call under 800 characters to avoid this. I went more conservative and cut at 700, always at sentence end (never mid-sentence, because a mid-sentence split sounds horrible when concatenated). On top of that, I bumped <code>stability</code> to 0.9 (Robust mode, more stable but less responsive to emotion tags), turned on <code>apply_text_normalization</code> so the model pronounces numbers and acronyms correctly, and added a sanity check that rejects any generated audio longer than 1.8× the expected duration.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">DEFAULT_VOICE_SETTINGS</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;stability&#34;</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span>          <span class="c1"># Robust mode</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;similarity_boost&#34;</span><span class="p">:</span> <span class="mf">0.95</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;style&#34;</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;use_speaker_boost&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">EXPECTED_CHARS_PER_SEC</span> <span class="o">=</span> <span class="mf">16.0</span>
</span></span><span class="line"><span class="cl"><span class="n">CHUNK_SANITY_MAX_FACTOR</span> <span class="o">=</span> <span class="mf">1.8</span>   <span class="c1"># reject if actual &gt; 1.8× expected</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With those mitigations in place, a 95-minute episode (194 blocks) ran start to finish with zero hallucinations. Exactly one block failed mid-run on a transient 502 error from the API, which the automatic retry caught on the next attempt.</p>
<h3>Mastering for YouTube<span class="hx:absolute hx:-mt-20" id="mastering-for-youtube"></span>
    <a href="#mastering-for-youtube" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>YouTube doesn&rsquo;t publish an official number, but industry consensus is that its playback loudness normalization targets -14 LUFS. Deliver audio louder than that and YouTube turns it down; deliver it quieter and it leaves it alone. To land on the target exactly, the pipeline runs <code>ffmpeg</code>&rsquo;s <code>loudnorm</code> filter in two passes. The first pass measures the entire audio and prints the stats (<code>input_i</code>, <code>input_tp</code>, <code>input_lra</code>, <code>input_thresh</code>) as a JSON blob on stderr. The second pass reads those stats back in and applies a linear static gain to land precisely at -14 LUFS, -1.5 dBTP:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Pass 1: measure</span>
</span></span><span class="line"><span class="cl">ffmpeg -i final_en.mp3 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -af <span class="s2">&#34;highpass=f=80,loudnorm=I=-14:TP=-1.5:LRA=9:print_format=json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -f null -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Pass 2: apply linear gain with the measured values</span>
</span></span><span class="line"><span class="cl">ffmpeg -i final_en.mp3 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -af <span class="s2">&#34;highpass=f=80,loudnorm=I=-14:TP=-1.5:LRA=9\
</span></span></span><span class="line"><span class="cl"><span class="s2">:measured_I=-18.3:measured_TP=-3.1:measured_LRA=5.4\
</span></span></span><span class="line"><span class="cl"><span class="s2">:measured_thresh=-28.7:offset=1.2:linear=true&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -ar <span class="m">48000</span> -ac <span class="m">2</span> -c:a pcm_s16le final_en_mastered.wav</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Two passes instead of one because single-pass mode runs in dynamic compression and ends up &ldquo;pumping&rdquo; the gain during silent stretches. With <code>linear=true</code> on pass 2, the gain stays static on top of the pass-1 measurements, so no pumping. The result lands within ±0.1 LU of the target, which is effectively inaudible. The <code>highpass=f=80</code> filter in front kills HVAC rumble and mains hum below 80 Hz, which the human ear doesn&rsquo;t catch but will move peak measurements around.</p>
<h3>The point where I thought it was done. And it wasn&rsquo;t.<span class="hx:absolute hx:-mt-20" id="the-point-where-i-thought-it-was-done-and-it-wasnt"></span>
    <a href="#the-point-where-i-thought-it-was-done-and-it-wasnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where the most annoying, and most educational, part of the project starts.</p>
<p>I really thought I had nailed it on the first big pass. The pipeline closed all 146 episodes, I uploaded everything, and it looked done. The durations matched, the files were all there, and the whole process had finally crossed the complete batch of nearly 96 hours of video. But it was exactly after that upload that I started noticing problems I simply had not anticipated.</p>
<p>The first scare was the jingle situation. I already knew many Akitando episodes begin with that short instrumental intro before I start talking, but I underestimated how much trouble it would cause. YouTube&rsquo;s <code>.srt</code> is almost useless here, because a jingle is not speech. Sometimes it marks <code>[Music]</code>, sometimes it gives you nothing useful, sometimes the window is just wrong. In the first assembly pass, some episodes lost the jingle entirely, some got it doubled, and in others the source-fill from the original audio already contained the right jingle and my splice came in on top of it, pushing everything else forward. This was the kind of bug you only catch when you sit down and actually listen to the final mastered file, not when you just look at total duration.</p>
<p>The fix was to stop hoping the subtitle would tell me where the jingle was and go straight to the source. I built a detector using the original video audio itself plus reference jingles, with an audit and repair pass on top of the mastered outputs already generated. That eventually became a small subsystem of its own: detector, audit, cached reassembly, exact-window repair. At the end of that detour, the set of changed episodes collapsed into a well-defined reupload bucket and the jingle problem finally came under control. It was one of those classic production moments: the first solution looked good enough until it hit the whole archive.</p>
<p>Then came the second punch, and this one was more expensive: I had forgotten a basic fact about YouTube subtitles. SRT is built to be read on screen, not to become spoken script. So an idea that was a full paragraph in my original text showed up split into several small cues, each with its own micro time window. In the first version of the pipeline I was using those cues as the generation unit for ElevenLabs. The result was TTS audio that was coherent locally, but the final assembly inserted artificial silences in the middle of sentences. Technically aligned, humanly weird.</p>
<p>Fixing that hurt because it meant walking several steps back. Instead of respecting the cue structure of the SRT, I had to go back to my original scripts and rebuild chunking around the paragraphs I had actually read when recording the videos. Almost every Akitando episode has a matching blog post with a <code>## Script</code> section, and that is what saved the project. Because I had been meticulous back when I made those videos — writing the scripts first, reading exactly that on camera, and archiving both the videos and the scripts — I had a source of truth. That made it possible to map the script paragraphs onto the timings in the <code>.pt-BR.srt</code>, regroup the <code>.en.srt</code> on top of that, and regenerate the chunks in the right structure.</p>
<p>The problem is that this destroys the previous cache. Even when the text barely changes, if the chunk boundary changes then the TTS call changes too: different context, different prosody, different beginning, different ending. So this was not a touch-up. It was a full regeneration of everything again. That was the point where the cost started escaping my original estimate.</p>
<p>And just when I thought now it really was over, the third hit landed: external clips. Many videos on the channel include inserted footage from other sources. The Ruby on Rails episode, for example, has 37signals clips, Apple material, commercials, external inserts in the middle of the narrative. YouTube&rsquo;s <code>.srt</code> did not know what to do with that. In some places it simply hallucinated text, in others the timings were completely wrong, and in others the original speech had nothing to do with the subtitle I was using as ground truth. Again: if you only look at the automation, it looks fine; if you actually listen to the final master, it isn&rsquo;t.</p>
<p>The fix, again, was to leave the subtitle behind and go back to the original material. I built an external-clip window detector based on alignment gaps between transcript, <code>.pt-BR.srt</code>, speechless cues and chunks that crossed those windows. Then, instead of trying to dub what made no sense to dub, the process started recovering the original audio from the source video exactly in those stretches and filling the master with the correct source audio there. That&rsquo;s what fixed the 37signals, Apple Education, Mac vs PC commercial and other inserted segments I had forgotten were even there.</p>
<p>That was the biggest lesson from the second half of this project: automation takes you very far, but the full archive always has more dark corners than you imagine on the first pass.</p>
<h2>The dubbing batch numbers (and why it hurt more than I expected)<span class="hx:absolute hx:-mt-20" id="the-dubbing-batch-numbers-and-why-it-hurt-more-than-i-expected"></span>
    <a href="#the-dubbing-batch-numbers-and-why-it-hurt-more-than-i-expected" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I need to correct myself again. I didn&rsquo;t just miscalculate the first bill. I also overestimated how confident I should have been after the first full run.</p>
<p>When I upgraded to ElevenLabs Business, I thought I would finally have plenty of headroom. In practice, I didn&rsquo;t. The latest dashboard screenshot shows <strong>10,046,279 credits consumed out of a limit of 11,000,000</strong>, leaving <strong>953,721</strong>:</p>
<p><img src="/2026/04/09/como-a-elevenlabs-nao-foi-morta-pelo-qwen3-tts/elevenlabs-credits-dashboard.png" alt="ElevenLabs dashboard showing 10,046,279 credits consumed out of 11,000,000"  loading="lazy" /></p>
<p>In plain English: I burned through more than 91% of the Business workspace limit. And that helps explain what &ldquo;10 million credits&rdquo; really means in this context. It does not mean &ldquo;I rendered 146 episodes once&rdquo;. It means rendering 146 episodes, discovering a structural problem, going back, fixing it, regenerating chunks, discovering another structural problem, going back again, rebuilding masters, auditing jingles, repairing external clip windows, uploading again. Every extra round costs money. And when the change invalidates the cache, it costs a lot.</p>
<p>The cost also doesn&rsquo;t stop at ElevenLabs. All the subtitle curation, problematic cue rewrites, truncated SRT reconstruction, jingle audits, external clip detector work and the rest ran through Claude Code on the Claude Max 20× plan. Combined with the mass blog translation to English that I described in the <a href="/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/">anniversary post</a>, the Claude Max 20× weekly limit hit 100%, with more than BRL 300 in extra usage on top. If you think generative AI becomes free once you&rsquo;ve signed up for the plan, the invoices disagree.</p>
<p>Even so, I still think it&rsquo;s worth it. We&rsquo;re talking about 146 episodes, almost 96 hours of technical content, more than 5 years of channel archive. Doing that manually in a studio, with an actor, direction, pickups and human review on everything, would cost a completely different order of magnitude.</p>
<p>What changed was how I think about the project. At the start I was looking at this as &ldquo;one very large batch&rdquo;. In the end it was something else. It was digging through a whole archive, finding hidden exceptions, fixing old rot, rebuilding masters, rerendering, relistening.</p>
<p>The first full batch took a little under two days and made me think I had solved everything. I hadn&rsquo;t. The second pass, with paragraph-aware chunking, was already expensive. The third, with jingle repairs and mastered-output fixes, cost more again. And the fourth hit came from the external clips I had not modeled correctly in the first automation pass.</p>
<p>But now, finally, I think it&rsquo;s done. Or rather: I hope it&rsquo;s done. Everything has been uploaded again, for the last time. It isn&rsquo;t perfect in the absolute sense of the word. It&rsquo;s as good as I can get it with scripts, audits and automation, without descending into manual-intervention hell episode by episode.</p>
<p>And if all of this was possible, it&rsquo;s because years ago I had the discipline to produce the videos the right way: write first, read the script when recording, and archive the materials afterward. Without the original videos and without the original scripts, I would have had no way to discover where the <code>.srt</code> was lying, where the timing was wrong, where the external clip started, where the original paragraph began and ended. That old organization is what made it possible to repair the project now.</p>
<p>So yes: the dub for all 146 episodes is done. Not perfect. But closed. At least, I sincerely hope this time it really is.</p>
<h2>The first test, watch it<span class="hx:absolute hx:-mt-20" id="the-first-test-watch-it"></span>
    <a href="#the-first-test-watch-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I made a test video to prove the thing works. Two caveats up front: first, the embed below already loads with English captions on by default, so that side is solvable via URL. Second, the audio is annoying: YouTube removed the audio track selector from embedded players back around March of this year. So inside the embed you won&rsquo;t find that option in the gear menu at all, it just plays in Portuguese. To actually hear the English dub, click <strong><a href="https://www.youtube.com/watch?v=QNLd8TZ_JQc"target="_blank" rel="noopener">watch directly on YouTube</a></strong> — the main YouTube watch page still has the audio track switcher in the gear menu.</p>
<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/QNLd8TZ_JQc?cc_load_policy=1&amp;cc_lang_pref=en&amp;hl=en"
    title="Akitando dubbed in English (test)"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>
<p>If you&rsquo;re used to YouTube&rsquo;s automatic dubbing or to the AI dubbing on TikTok, compare them. The difference is glaring. The voice is mine, with a worked-over American accent, and the sync with the original video stays within 1 to 2% cumulative drift, practically imperceptible over 95 minutes of video.</p>
<h2>And the missing piece: translating the thumbnails<span class="hx:absolute hx:-mt-20" id="and-the-missing-piece-translating-the-thumbnails"></span>
    <a href="#and-the-missing-piece-translating-the-thumbnails" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>While I was closing this post, the penny dropped: I&rsquo;d left a loose end. All 146 thumbnails on my channel are in Portuguese, many of them with big all-caps titles like &ldquo;9 DICAS PARA PALESTRANTES&rdquo; or &ldquo;7 RECOMENDAÇÕES DE SHOWS PARA PESSOAS DE TECH&rdquo;. Perfect English audio doesn&rsquo;t help if the image in the search results is an illegible block of text to anyone who doesn&rsquo;t read Portuguese. YouTube lets you upload an alternative thumbnail per language, so I needed a way to generate English versions, keeping the rest of the art identical and swapping only the text.</p>
<p>The tool uses two pieces. <strong><code>yt-dlp</code></strong> is the modern fork of the old <code>youtube-dl</code>, a Python CLI that downloads anything from YouTube (videos, audio, subtitles, thumbnails) without needing an API key. <strong>Nano Banana Pro</strong> (<code>nano-banana-pro-preview</code> on the API) is Google Gemini&rsquo;s latest image-editing model — you feed it an input image with a prompt and it returns an edited image, preserving the rest of the composition when you ask it to touch only a specific part.</p>
<p>The pipeline has two steps. Step 1: <code>yt-dlp</code> grabs the thumbnail as a <code>.jpg</code>, no video download:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">yt-dlp --skip-download <span class="se">\
</span></span></span><span class="line"><span class="cl">       --write-thumbnail <span class="se">\
</span></span></span><span class="line"><span class="cl">       --convert-thumbnails jpg <span class="se">\
</span></span></span><span class="line"><span class="cl">       -o <span class="s2">&#34;thumbnails/originals/&lt;slug&gt;/&lt;video_id&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">       <span class="s2">&#34;https://www.youtube.com/watch?v=&lt;video_id&gt;&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Step 2: send each image to Gemini Nano Banana Pro with a prompt that&rsquo;s a rigid contract of hard requirements, not just a &ldquo;translate this&rdquo;:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">TASK:
</span></span><span class="line"><span class="cl">Detect every piece of Portuguese text visible on this image and
</span></span><span class="line"><span class="cl">translate it to clear, natural American English. Replace the
</span></span><span class="line"><span class="cl">Portuguese text with the English translation in the same visual
</span></span><span class="line"><span class="cl">position.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">STRICT REQUIREMENTS — follow every one:
</span></span><span class="line"><span class="cl">- Preserve EVERY non-text element identically: face, pose,
</span></span><span class="line"><span class="cl">  expression, background, color palette, lighting, icons, logos,
</span></span><span class="line"><span class="cl">  decorative shapes, borders, layout. Only the text changes.
</span></span><span class="line"><span class="cl">- The English translation must be IDIOMATIC and CONFIDENT — not
</span></span><span class="line"><span class="cl">  a literal word-for-word rewrite. It&#39;s a YouTube thumbnail for a
</span></span><span class="line"><span class="cl">  tech audience, so use punchy phrasing a native English-speaking
</span></span><span class="line"><span class="cl">  tech YouTuber would write.
</span></span><span class="line"><span class="cl">- Match the original text&#39;s font family, weight, size, color,
</span></span><span class="line"><span class="cl">  stroke outline, drop shadow, and any decorative treatment.
</span></span><span class="line"><span class="cl">- If there is no Portuguese text at all on the image, return the
</span></span><span class="line"><span class="cl">  original image unchanged.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The &ldquo;return the original image unchanged&rdquo; clause is what saves the earliest episodes, which don&rsquo;t have overlay text, just my face. Without it, the model would invariably try to &ldquo;improve&rdquo; the composition, swap the lighting, and so on.</p>
<p>Here&rsquo;s the result on two examples (episodes 10 and 11):</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0;">
<div><strong>Original (PT)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep010_pt.jpg" alt="Original Portuguese thumbnail: 7 Recomendações de Shows para pessoas de Tech" style="width: 100%;"></div>
<div><strong>Translated (EN)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep010_en.jpg" alt="Translated English thumbnail: 7 TV Shows You Must Watch If You're In Tech" style="width: 100%;"></div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0;">
<div><strong>Original (PT)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep011_pt.jpg" alt="Original Portuguese thumbnail: 9 Dicas para Palestrantes: venda sua caneta" style="width: 100%;"></div>
<div><strong>Translated (EN)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep011_en.jpg" alt="Translated English thumbnail: 9 Tips for Speakers: sell your pen" style="width: 100%;"></div>
</div>
<p>Notice the detail on episode 10. The model didn&rsquo;t translate &ldquo;7 Recomendações de Shows para Pessoas de Tech&rdquo; literally into &ldquo;7 Show Recommendations for Tech People&rdquo; the way Google Translate would. It rewrote it as &ldquo;7 TV Shows You Must Watch If You&rsquo;re In Tech&rdquo;, the way an actual American tech YouTuber would headline the video. The rest of the image stays pixel-for-pixel identical. On episode 11, &ldquo;9 DICAS PARA PALESTRANTES: venda sua caneta&rdquo; becomes &ldquo;9 TIPS FOR SPEAKERS: sell your pen&rdquo;, with the font, all-caps treatment and position preserved. If you didn&rsquo;t know the original was in Portuguese, you couldn&rsquo;t guess the second image is an automatic AI edit.</p>
<p>Cost on the Gemini side: a few cents per image, a handful of dollars total for the whole batch. Trivial next to the $1,500+ on the audio dub. With the thumbnail sorted, the channel&rsquo;s English conversion is finally buttoned up — ElevenLabs v3 cloning the voice over hand-curated English <code>.srt</code> files, and Nano Banana Pro editing the image so the title matches the audio.</p>
<h2>The conclusion, which is the title of this post<span class="hx:absolute hx:-mt-20" id="the-conclusion-which-is-the-title-of-this-post"></span>
    <a href="#the-conclusion-which-is-the-title-of-this-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When Qwen3 TTS dropped, the hype was calling it an &ldquo;ElevenLabs killer&rdquo;. I spent weeks trying to live with that premise in practice, with real content shipping every week. And what I found is that open source TTS is still miles behind ElevenLabs. The gap is large, and it&rsquo;s a gap that shows up precisely when you step off the 5-second demo tweet and put the model to work on a real 30-minute weekly podcast.</p>
<p>In practice, Qwen3 doesn&rsquo;t even beat ElevenLabs&rsquo; v2 model. v3, which is the current one, is still a step above v2. The prosody comes out better, the inline emotion tags work in Portuguese and English without effort, and the API stays up without you maintaining a server. The cost per character is a bit higher, but for my current volume it sits comfortably within budget and gives me back the hours I was spending on GPU babysitting.</p>
<p>The lesson here is the same one the LLM crowd is learning slowly. A 30-second demo tweet is one thing, production is a completely different thing. Open source has its niche, mainly when you have sensitive data that can&rsquo;t leave the house, or when you have tons of idle GPU and little recurring budget. But for serious TTS use in a commercial product, here in April 2026, ElevenLabs is still untouchable. Qwen3 didn&rsquo;t kill anyone.</p>
<p>And don&rsquo;t forget: subscribe to <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">The M.Akita Chronicles on Spotify</a> so you don&rsquo;t miss new episodes like this one, made with the new pipeline.</p>
]]></content:encoded><category>generative-media</category><category>local-models</category><category>reviews</category></item><item><title>Serving AI in the Cloud: My Personal TTS | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 15:28:46 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 3.&lt;/p&gt;
&lt;p&gt;And make sure to subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll talk about the podcast today; remember that the &lt;a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener"&gt;pilot episode is already live&lt;/a&gt; (transcripts and RSS to subscribe) and on the channel on Spotify.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Every AI tutorial starts with &lt;em&gt;&amp;ldquo;install the model, run inference, done!&amp;rdquo;&lt;/em&gt;. None of them tell you what happens when you need to run this in production with real uptime, controlled cost, and consistent quality.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 3.</p>
<p>And make sure to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>I&rsquo;ll talk about the podcast today; remember that the <a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener">pilot episode is already live</a> (transcripts and RSS to subscribe) and on the channel on Spotify.</p>
<p>&ndash;</p>
<p>Every AI tutorial starts with <em>&ldquo;install the model, run inference, done!&rdquo;</em>. None of them tell you what happens when you need to run this in production with real uptime, controlled cost, and consistent quality.</p>
<p>I&rsquo;ll tell you how it was to put a TTS (Text-to-Speech) model into production on a GPU in the cloud. Because if you&rsquo;re thinking about serving AI models, you need to know what you&rsquo;re getting yourself into.</p>
<h2>The Scenario<span class="hx:absolute hx:-mt-20" id="the-scenario"></span>
    <a href="#the-scenario" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><blockquote>
  <p>The goal: generate a ~30-minute podcast from text, with two distinct voices, broadcast quality, ready for Spotify. Every week, automatically, with no human intervention.</p>

</blockquote>
<p>The chosen model: <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener"><strong>Qwen3-TTS</strong></a>, a text-to-speech model that supports voice cloning and fine-tuning. It runs on GPU — needs at least 6GB of VRAM for inference.</p>
<p>The platform: <a href="https://runpod.io/"target="_blank" rel="noopener"><strong>RunPod</strong></a>, which offers serverless GPUs with billing by the second of usage.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-33-45.jpg" alt="runpod"  loading="lazy" /></p>
<p>Sounds simple, right? Well, hold on.</p>
<h2>Problem 1: Cold Start — The Silent Killer<span class="hx:absolute hx:-mt-20" id="problem-1-cold-start--the-silent-killer"></span>
    <a href="#problem-1-cold-start--the-silent-killer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Serverless GPU works like this: when there&rsquo;s no request, the worker shuts down. When a request comes in, it boots up, loads the model into VRAM, and processes.</p>
<p>The time to &ldquo;load the model into VRAM&rdquo; is the <em>cold start</em>. For a ~4GB model, we&rsquo;re talking <strong>30 to 90 seconds</strong>. Your HTTP request sits there hanging, waiting. If your timeout is 30 seconds, you don&rsquo;t even get to see the model work — you just see a timeout.</p>
<p>The naive solution: <em>&ldquo;bump the timeout!&rdquo;</em>. The real solution: <strong>health check with polling</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@app.get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">health</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">model_loaded</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JSONResponse</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">status_code</span><span class="o">=</span><span class="mi">503</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;loading&#34;</span><span class="p">,</span> <span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Model loading...&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ready&#34;</span><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The client (in this case, a Rails job) polls <code>/health</code> before sending the actual request:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">wait_for_server</span><span class="p">(</span><span class="ss">max_wait</span><span class="p">:</span> <span class="mi">180</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">deadline</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">+</span> <span class="n">max_wait</span>
</span></span><span class="line"><span class="cl">  <span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">http_get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">response</span><span class="o">.</span><span class="n">code</span> <span class="o">==</span> <span class="mi">200</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="s2">&#34;Server não iniciou a tempo&#34;</span> <span class="k">if</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">&gt;</span> <span class="n">deadline</span>
</span></span><span class="line"><span class="cl">    <span class="nb">sleep</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sounds primitive? It is. But it works better than any sophisticated &ldquo;warm pool&rdquo; solution that costs 10x more.</p>
<h2>Problem 2: Where Do the Model Weights Live?<span class="hx:absolute hx:-mt-20" id="problem-2-where-do-the-model-weights-live"></span>
    <a href="#problem-2-where-do-the-model-weights-live" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A 4GB model needs to be available when the worker starts. Three options:</p>
<ol>
<li><strong>Bake into Docker</strong>: 4GB+ image. Slow build. Slow push. Slow pull.</li>
<li><strong>Download from HuggingFace on boot</strong>: Network-dependent. Can fail. Adds minutes to the cold start.</li>
<li><strong>Network Volume</strong>: Persistent disk mounted on the worker.</li>
</ol>
<p>Option 3 is the correct one. RunPod offers Network Volumes that persist between runs. The model sits there, always ready. The cold start is just the time to load from disk to VRAM, no download.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-35-43.jpg" alt="network storage"  loading="lazy" /></p>
<p>But there&rsquo;s a catch: the volume may or may not be mounted, depending on how the worker is configured. So the setup code needs to be resilient:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">NETWORK_VOLUME</span> <span class="o">=</span> <span class="s2">&#34;/runpod-volume&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCAL_FALLBACK</span> <span class="o">=</span> <span class="s2">&#34;/workspace/models&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_model_path</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">isdir</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">,</span> <span class="s2">&#34;huggingface&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;HF_HOME&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">base</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s2">&#34;hub&#34;</span><span class="p">,</span> <span class="s2">&#34;models--Qwen--Qwen3-TTS&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Fallback: local download</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">LOCAL_FALLBACK</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Auto-detection. If the volume exists, use it. If not, it works the same way (slower on the first boot). <strong>Never assume your infrastructure is perfect.</strong></p>
<h2>Problem 3: Two Voice Modes, Two Sets of Parameters<span class="hx:absolute hx:-mt-20" id="problem-3-two-voice-modes-two-sets-of-parameters"></span>
    <a href="#problem-3-two-voice-modes-two-sets-of-parameters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s something no documentation will tell you: TTS models with voice cloning and fine-tuning need <strong>completely different sampling parameters</strong> depending on the mode.</p>
<p>With a fine-tuned voice (trained specifically on the model), you want conservative parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.5
top_p: 0.8
repetition_penalty: 1.1</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With voice cloning (base voice + reference audio), the same parameters produce <strong>white noise</strong>. Literally. White noise. Because the clone model needs more &ldquo;creativity&rdquo; to reconstruct the vocal characteristics:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.9
top_p: 0.95
repetition_penalty: 1.05</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I discovered this after hours generating unusable audio. The model&rsquo;s documentation doesn&rsquo;t mention it. The academic papers don&rsquo;t mention it. It&rsquo;s the kind of knowledge that only comes from <strong>experimentation in production</strong>.</p>
<h2>Problem 4: The Reference Audio Matters More Than the Model<span class="hx:absolute hx:-mt-20" id="problem-4-the-reference-audio-matters-more-than-the-model"></span>
    <a href="#problem-4-the-reference-audio-matters-more-than-the-model" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Voice cloning uses an audio clip as a reference to replicate the voice. Sounds simple: record 10 seconds, feed it to the model, done.</p>
<p>Except if the reference audio starts with speech immediately — without even 200 milliseconds of silence at the beginning — the model learns that &ldquo;the audio starts with no onset&rdquo;. And <strong>every subsequent generation</strong> will clip the first syllable.</p>
<p>This is not a model bug. It&rsquo;s the model doing exactly what you asked for: <em>&ldquo;generate audio that starts just like this reference&rdquo;</em>. If the reference starts clipped, the generation starts clipped.</p>
<p>The fix is not post-processing (the audio has already been generated without the syllable). It&rsquo;s <strong>fixing the reference</strong>: ensuring at least 300ms of silence before the speech.</p>
<p>For those coming from the software world, this is counterintuitive. We&rsquo;re used to treating input data as immutable and fixing things on the output. With generative models, input quality is <strong>everything</strong>.</p>
<h2>Problem 5: Timeout and Generation Limits<span class="hx:absolute hx:-mt-20" id="problem-5-timeout-and-generation-limits"></span>
    <a href="#problem-5-timeout-and-generation-limits" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A TTS model generates audio token by token. If you don&rsquo;t cap it, it can generate indefinitely — and on RunPod, you pay by the second of GPU. A 30-word turn of dialogue shouldn&rsquo;t generate 3 minutes of audio, but without a limit, it can.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MAX_NEW_TOKENS</span> <span class="o">=</span> <span class="mi">720</span>  <span class="c1"># ~60 seconds of audio at 12Hz</span>
</span></span><span class="line"><span class="cl"><span class="n">GENERATION_TIMEOUT</span> <span class="o">=</span> <span class="mi">180</span>  <span class="c1"># 3 absolute minutes</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">future</span> <span class="o">=</span> <span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">model</span><span class="o">.</span><span class="n">generate</span><span class="p">,</span> <span class="o">**</span><span class="n">params</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">future</span><span class="o">.</span><span class="n">result</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">GENERATION_TIMEOUT</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Double protection: token limit on generation and absolute timeout on the process. If either one trips, the turn fails and can be retried with adjusted parameters.</p>
<h2>Problem 6: Post-Processing Is Half the Work<span class="hx:absolute hx:-mt-20" id="problem-6-post-processing-is-half-the-work"></span>
    <a href="#problem-6-post-processing-is-half-the-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The model generates raw WAV. To get to podcast-quality audio, the path is long:</p>
<ol>
<li><strong>Loudness normalization</strong>: Spotify requires -14 LUFS. Without normalization, every turn has a different volume.</li>
<li><strong>Re-encoding</strong>: The model&rsquo;s WAVs can have subtly different formats (sample rate, bit depth). Concatenating with <code>-c copy</code> causes <strong>silent segments</strong>. You need to re-encode everything to the same format.</li>
<li><strong>Conversion to MP3</strong>: CBR 192kbps, 44.1kHz, with ID3 metadata.</li>
</ol>
<p>And loudness normalization needs to be <strong>two-pass</strong>: first measure, then apply. Single-pass is a guess — and guessing in production generates audio with inconsistent volume across episodes.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Pass 1: analysis</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:print_format<span class="o">=</span>json -f null -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Pass 2: applying the measured values</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:measured_I<span class="o">=</span>-18.5:measured_TP<span class="o">=</span>-2.1:... output.wav</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this runs on the server, after TTS generation. And it needs <code>ffmpeg</code> installed in the container — one more dependency to manage.</p>
<h2>Problem 7: The Script Matters More Than the Voice Model<span class="hx:absolute hx:-mt-20" id="problem-7-the-script-matters-more-than-the-voice-model"></span>
    <a href="#problem-7-the-script-matters-more-than-the-voice-model" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After solving GPU, cold start, voice cloning, sampling, normalization — the podcast comes out. And it sounds&hellip; robotic. Not because of the TTS. The TTS is excellent. It sounds robotic because the <strong>text</strong> is robotic.</p>
<p>This is the problem nobody mentions in TTS pipelines: audio quality starts in the script, not in the voice model. A perfect TTS reading a dry, formal text produces dry, formal audio. Garbage in, garbage out — but at a level you wouldn&rsquo;t expect.</p>
<h3>Two Passes: Draft and Refine<span class="hx:absolute hx:-mt-20" id="two-passes-draft-and-refine"></span>
    <a href="#two-passes-draft-and-refine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The solution is to generate the script in two LLM passes:</p>
<p><strong>Pass 1 (Draft)</strong>: Turns the newsletter into dialogue. Here the LLM gets the full content and creates the structure — who says what, in what order, with what transitions. <strong>Without changing the content, only the form!</strong></p>
<p><strong>Pass 2 (Refine)</strong>: Takes the draft and makes it natural. Adds hesitations, reactions, interruptions, self-corrections. Turns &ldquo;read script&rdquo; into &ldquo;real conversation&rdquo;.</p>
<p>Why not do it all in a single pass? Because these are cognitively different tasks. Structuring content and adding naturalness at the same time results in a text that does neither properly. Two specialized passes consistently produce a better result.</p>
<h3>Personality Needs Brutally Specific Instructions<span class="hx:absolute hx:-mt-20" id="personality-needs-brutally-specific-instructions"></span>
    <a href="#personality-needs-brutally-specific-instructions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>&ldquo;Make Akita sound confident&rdquo; doesn&rsquo;t work. The LLM will produce generic confidence — motivational phrases, positive tone. That&rsquo;s not it. Akita is confident because he&rsquo;s <strong>assertive and impatient</strong>. The difference is in the prompt details:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Akita Delivery Style
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Declarative, not tentative**: &#34;Isso é óbvio&#34; not &#34;Eu acho que talvez isso seja...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Dismissive of nonsense**: &#34;Isso é bobagem&#34;, &#34;Não tem nenhum mistério aqui&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Owns his track record**: &#34;Eu avisei&#34;, &#34;Quem assistiu meu canal já sabe&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Never hedges**: No &#34;talvez&#34;, &#34;quem sabe&#34;, &#34;pode ser que&#34;
</span></span><span class="line"><span class="cl">- <span class="gs">**Impatient with the obvious**</span>: &#34;Vocês já sabem como funciona&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And for Marvin (the robot co-host):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="k">-</span> **Filler words** (Marvin only): &#34;bom...&#34;, &#34;olha...&#34;, &#34;enfim&#34;, &#34;pois é&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Self-corrections** (Marvin only): &#34;quer dizer...&#34;, &#34;ou melhor...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> Akita doesn&#39;t second-guess himself — if he corrects, it&#39;s
</span></span><span class="line"><span class="cl">  &#34;Não, peraí&#34; and he restates with even MORE conviction</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without this level of specificity, both characters sound the same. The LLM needs concrete examples of <strong>how</strong> each one talks, not abstract personality descriptions.</p>
<h3>Normalization for Speech: What Looks Right in Text Sounds Wrong in Audio<span class="hx:absolute hx:-mt-20" id="normalization-for-speech-what-looks-right-in-text-sounds-wrong-in-audio"></span>
    <a href="#normalization-for-speech-what-looks-right-in-text-sounds-wrong-in-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Acronyms. The invisible enemy of TTS. &ldquo;EUA&rdquo; in text is perfectly readable. Spoken by TTS, it comes out &ldquo;éu-á&rdquo; — incomprehensible. The prompt needs explicit normalization rules:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Spoken Text Normalization (MANDATORY)
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`EUA`</span> → <span class="sb">`Estados Unidos`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`UE`</span> → <span class="sb">`União Europeia`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`IA`</span> → <span class="sb">`inteligência artificial`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`LLM`</span> → <span class="sb">`modelo de linguagem`</span>
</span></span><span class="line"><span class="cl">- <span class="sb">`API`</span> → <span class="sb">`interface de programação`</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This needs to be in the prompt for <strong>both</strong> passes (draft and refine), because the LLM tends to reintroduce acronyms it removed in the previous pass.</p>
<h3>The LLM Will Rewrite Your Opinions (If You Let It)<span class="hx:absolute hx:-mt-20" id="the-llm-will-rewrite-your-opinions-if-you-let-it"></span>
    <a href="#the-llm-will-rewrite-your-opinions-if-you-let-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This was the most subtle lesson. The newsletter has opinion commentary from Akita and Marvin in each section. When the LLM turns it into dialogue, it <strong>substitutes</strong> the opinions with more &ldquo;balanced&rdquo; and &ldquo;nuanced&rdquo; versions. Akita, who said &ldquo;isso é bobagem&rdquo;, becomes Akita saying &ldquo;isso tem seus méritos, mas&hellip;&rdquo;. Pessimistic Marvin becomes &ldquo;mildly skeptical&rdquo; Marvin.</p>
<p>The LLM does this because it was trained to be helpful and balanced. Strong opinions seem &ldquo;unhelpful&rdquo; to the model, so it softens them. The original instruction — &ldquo;use the newsletter comments as seeds for the conversation&rdquo; — was too vague. It gave the model freedom to reinterpret.</p>
<p>The fix is to be explicit and inflexible:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**PRESERVE THE ORIGINAL OPINIONS**: The newsletter contains Akita and
</span></span><span class="line"><span class="cl">Marvin commentary on each topic. These opinions are the author&#39;s real
</span></span><span class="line"><span class="cl">voice — you MUST preserve their substance, stance, and conclusions.
</span></span><span class="line"><span class="cl">Make the delivery conversational, but do NOT change what they actually
</span></span><span class="line"><span class="cl">think. If Akita&#39;s comment says &#34;isso é bobagem&#34;, the podcast version
</span></span><span class="line"><span class="cl">must convey the same dismissal. Rephrase for spoken delivery, but
</span></span><span class="line"><span class="cl">never substitute a different opinion.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It&rsquo;s the difference between &ldquo;adapt it for conversation&rdquo; (LLM understands: &ldquo;rewrite however you want&rdquo;) and &ldquo;change the HOW, never the WHAT&rdquo; (LLM understands: &ldquo;keep the opinion, swap the packaging&rdquo;).</p>
<p>And this applies to any prompt engineering with authored content: <strong>the LLM will homogenize your voice if you&rsquo;re not explicit about preserving it</strong>. And in a podcast where the hosts&rsquo; personality IS the product, losing the voice means losing everything.</p>
<h2>The Real Cost of AI in Production<span class="hx:absolute hx:-mt-20" id="the-real-cost-of-ai-in-production"></span>
    <a href="#the-real-cost-of-ai-in-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s get practical about costs on RunPod:</p>
<table>
  <thead>
      <tr>
          <th>Resource</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GPU L40 (serverless)</td>
          <td>~$0.39/hour</td>
      </tr>
      <tr>
          <td>Network Volume 20GB</td>
          <td>$1.40/month</td>
      </tr>
      <tr>
          <td>Generation of 1 episode (~30 min audio)</td>
          <td>~$0.50–1.50</td>
      </tr>
  </tbody>
</table>
<p>The cost per episode is low. The cost of <strong>figuring out how to make it work</strong> is high. It took dozens of hours of experimentation, incomprehensible CUDA logs, failed generations, and unusable-quality audio before landing on a stable pipeline.</p>
<p>And the cognitive cost: keeping two ecosystems (Python for ML, Ruby for the application) in sync, with HTTP interfaces between them, and debugging that crosses language boundaries.</p>
<p>Anyway, for those who thought <em>&ldquo;Why not Elevenlabs?&rdquo;</em>, well, I got similar quality at a fraction of the cost, and I learned a lot in the process (and so did you, reading this).</p>
<h2>Lessons<span class="hx:absolute hx:-mt-20" id="lessons"></span>
    <a href="#lessons" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>
<p><strong>Cold start is a requirement, not a bug.</strong> Design your system expecting that the model will take time to load.</p>
</li>
<li>
<p><strong>Network volumes are mandatory</strong> for large models. Don&rsquo;t bake weights into Docker, don&rsquo;t download on boot.</p>
</li>
<li>
<p><strong>Sampling parameters are not universal.</strong> Each operation mode of the same model may need completely different configuration.</p>
</li>
<li>
<p><strong>Input quality dominates output quality.</strong> Both for the TTS reference audio and for the script. If the reference clips the first syllable, every generation clips. If the script is generic, the audio is generic.</p>
</li>
<li>
<p><strong>Timeouts at every layer.</strong> Token limit, process timeout, HTTP timeout. If you don&rsquo;t cap it, cost scales and generation hangs.</p>
</li>
<li>
<p><strong>Post-processing is half the pipeline.</strong> The model generates raw material. Normalization, encoding, metadata — all of that is real work.</p>
</li>
<li>
<p><strong>Separate the ML server from the application.</strong> Python for inference, Ruby for orchestration. Trying to do everything in one language is a recipe for frustration.</p>
</li>
<li>
<p><strong>Prompts need two passes for complex content.</strong> Structuring and naturalizing are different tasks. Forcing both into one pass produces mediocre results in both.</p>
</li>
<li>
<p><strong>The LLM will homogenize your voice.</strong> Strong opinions turn into &ldquo;nuanced takes&rdquo; if you&rsquo;re not explicit about preserving them. Instruct the model to change the packaging, never the content.</p>
</li>
</ol>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI in production is not a 10-line HuggingFace tutorial. It&rsquo;s infrastructure, it&rsquo;s experimentation, it&rsquo;s debugging problems that don&rsquo;t exist in traditional software. But when it works — when your system generates an entire podcast automatically every week, with broadcast quality, no human intervention — the feeling is that it was worth every hour of headache.</p>
<p>The trick is not to underestimate the complexity. And to have a robust system (like the Rails jobs I mentioned in the first post) orchestrating it all — because AI without reliable orchestration is just an expensive script that hangs.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>generative-media</category><category>cloud</category></item><item><title>AI 3D: Can You Actually Model 3D With Prompts Now?</title><link>https://akitaonrails.github.io/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/</guid><pubDate>Fri, 23 Jan 2026 12:13:58 -0300</pubDate><description>&lt;p&gt;A few days ago I wrote a post about using Blender with local AIs through Blender MCP. The conclusion was that quality was bad.&lt;/p&gt;
&lt;p&gt;I jumped to that conclusion too early. Open source options still vary a lot in quality, most aren&amp;rsquo;t great, but the commercial options are very good and quite stable. I&amp;rsquo;m not going to list everything out there, so here are my two YouTube channel recommendations to stay up to date:&lt;/p&gt;</description><content:encoded><![CDATA[<p>A few days ago I wrote a post about using Blender with local AIs through Blender MCP. The conclusion was that quality was bad.</p>
<p>I jumped to that conclusion too early. Open source options still vary a lot in quality, most aren&rsquo;t great, but the commercial options are very good and quite stable. I&rsquo;m not going to list everything out there, so here are my two YouTube channel recommendations to stay up to date:</p>
<ul>
<li><a href="https://www.youtube.com/@PixelArtistry_"target="_blank" rel="noopener"><strong>PixelArtistry_</strong></a></li>
<li><a href="https://www.youtube.com/channel/UCRW08KcTVjXEmBzBsVl7XjA"target="_blank" rel="noopener"><strong>Stefan 3D AI Lab</strong></a></li>
</ul>
<p>In practice, there are several ComfyUI workflows to play with, like this tutorial showing how to use the <a href="https://www.youtube.com/watch?v=H7gqMnK7wUc"target="_blank" rel="noopener">recently released Trellis2 model</a> from Microsoft. It doesn&rsquo;t work perfectly like in the video every time. It depends heavily on the image you feed it. In my tests I only got deformed &ldquo;blobs&rdquo;.</p>
<p>But on the commercial side, there are 3 tools worth signing up for:</p>
<ul>
<li><a href="https://3d.hunyuanglobal.com/"target="_blank" rel="noopener">Hunyuan Global</a>, by Tencent. There&rsquo;s the Chinese platform - which is more complete - and the global English version which is more limited. With it you can create 3D models from images with exceptional quality.</li>
<li><a href="https://www.hitem3d.ai/home"target="_blank" rel="noopener">Hitem3D</a> - competes with Hunyuan, also excellent for generating 3D models from images. Worth testing both because depending on the images you feed them, one may turn out better than the other.</li>
<li><a href="https://yvo3d.com/"target="_blank" rel="noopener">Yvo3D</a> - one of the pricier ones, but specifically for generating high-quality textures, it seems to be the best by far.</li>
</ul>
<p>There are plenty of other platforms and to stay current there&rsquo;s the site <a href="https://www.top3d.ai/"target="_blank" rel="noopener"><strong>Top3D.ai</strong></a> with a ranking of the best tools. Most of them usually have free plans, trials, or similar, so it&rsquo;s worth testing as many as you can. The pricing isn&rsquo;t &ldquo;expensive&rdquo; if you&rsquo;re a professional using this for client work. It&rsquo;s only expensive for guys like me, who use it as a hobby.</p>
<p>Some others worth testing are Hyper3D Rodin, Tripo AI, Prism, and SAM 3D. The YouTube channels I recommended have videos about them, so at least go watch those.</p>
<h3>The Ace Up the Sleeve: Nano Banana<span class="hx:absolute hx:-mt-20" id="the-ace-up-the-sleeve-nano-banana"></span>
    <a href="#the-ace-up-the-sleeve-nano-banana" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The big trick for any platform, open source or commercial, is starting with high-quality images. Images from Google Images, or your badly taken photos, will always result in bad 3D models.</p>
<p>To clean up images, the easiest way is to go to <a href="https://gemini.google/br/overview/image-generation/?hl=pt-BR"target="_blank" rel="noopener"><strong>Google Nano Banana</strong></a>. It&rsquo;s by far the best AI platform for editing and generating images.</p>
<p>As an example, I decided to start from an image my girlfriend commissioned from an artist on <strong>Fiverr</strong> that we turned into stickers for our arcade. This image:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123123341_TOMIKO_NAGAI%20left.png" alt="arcade original left"  loading="lazy" /></p>
<p>The first thing I asked Nano Banana was to remove the background and isolate just the character. It&rsquo;s the kind of thing that used to require Photoshop. Look how it turns out now:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123123457_no-background.png" alt="arcade no background"  loading="lazy" /></p>
<p>(ignore that it&rsquo;s mirrored, I was too lazy to grab the right image.)</p>
<p>Anyway, that&rsquo;s still not enough to generate 3D. Specifically for generating character models, the ideal is to have images in <strong>&ldquo;T-Pose&rdquo;</strong>, meaning the person standing upright with arms spread out like Jesus. And having photos from exactly the front, back, left, and right.</p>
<p>And you can ask Nano Banana to do exactly that: <em>&ldquo;generate a front photo of this character in T-Pose, for 3D modeling&rdquo;</em> and it generates this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124015_front.png" alt="t-pose front"  loading="lazy" /></p>
<p>Half the job is done at this step. Without this you can&rsquo;t generate good models. Now just ask it to generate the other angles, like the back:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124004_back.png" alt="t-pose back"  loading="lazy" /></p>
<p>Same thing, images from the left angle, the right, and done. At least 4 <strong>consistent, coherent, clean</strong> images.</p>
<h2>Hunyuan Global<span class="hx:absolute hx:-mt-20" id="hunyuan-global"></span>
    <a href="#hunyuan-global" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now comes the simple part. Just pick Hunyuan or Hitem3D. In this case, I picked Hunyuan:</p>
<p><a href="https://3d.hunyuanglobal.com/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124227_screenshot-2026-01-23_12-42-16.png" alt="Hunyuan Generate"  loading="lazy" /></a></p>
<p>Just pick the new 3.0 version model and generate with 1.5 million polygons (yes, it&rsquo;s quite heavy, but you can just remesh afterwards) and optionally generate textures (knowing upfront that Hunyuan isn&rsquo;t great with textures).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124359_screenshot-2026-01-18_13-35-13.png" alt="Hunyuan model"  loading="lazy" /></p>
<p>And look at the result. Perfect! As is, it&rsquo;s already usable. Like I said, it&rsquo;s not great with textures, but since my goal for this test was to print on my 3D printer, I didn&rsquo;t worry about textures.</p>
<p>Opening it in Blender, I can apply a remesh modifier to lower the polygon resolution and convert everything into quads, and it ends up like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124605_screenshot-2026-01-18_13-46-35.png" alt="Blender model"  loading="lazy" /></p>
<p>And right off the bat I tried to print it and out it came:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124749_20260123_124442.png" alt="Bambulab 3d Print"  loading="lazy" /></p>
<p>I was particularly impressed by the details: nothing crooked, nothing out of place, hands with 5 fingers, correct proportions, perfect face details, perfect hair.</p>
<blockquote>
  <p>Think about this: I went from nothing, to AI, to a physical 3D print!</p>

</blockquote>
<p>And now that I have this model, I can send it to <a href="https://www.mixamo.com/#/"target="_blank" rel="noopener"><strong>Adobe Mixamo</strong></a>, which is an online tool to reuse pre-made poses.</p>
<p><a href="https://www.mixamo.com/#/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123124934_screenshot-2026-01-18_14-12-07.png" alt="mixamo pose"  loading="lazy" /></a></p>
<p>I can upload my model, calibrate the armature/skeleton, and put it in whatever pose I want. Here&rsquo;s an example:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260123125208_screenshot-2026-01-23_12-51-46.png" alt="model sitting"  loading="lazy" /></p>
<p>In Blender I can calibrate the weight of parts of the mesh and other details to improve the pose, but 80% of the work is already done automatically. With this I can do animations and much more.</p>
<p>So yes, 3D modeling is already a reality with AI. There are dozens of tools for different use cases and specialties, like texture generation, skeletons, animation.</p>
<p>The main thing: start with a good foundation using Nano Banana like I said, that&rsquo;s the main trick to make everything else work properly. And keep an eye on Tencent: Hunyuan is the most interesting model.</p>
<p>On the open source side there&rsquo;s small Hunyuan to run locally and now Trellis2. Also keep an eye on new workflows that use both together in ComfyUI.</p>
<p>On my end, for doing simple things I want to 3D print on my Bambulab X1C, this is already more than excellent!</p>
]]></content:encoded><category>generative-media</category><category>hardware</category></item><item><title>Changing Clothes Using A.I. (ComfyUI)</title><link>https://akitaonrails.github.io/en/2025/04/23/changing-clothes-using-ai-comfyui/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/23/changing-clothes-using-ai-comfyui/</guid><pubDate>Wed, 23 Apr 2025 17:45:00 -0300</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; this post is now obsolete. I now use &lt;a href="https://github.com/akitaonrails/aitrepreneur-docker"target="_blank" rel="noopener"&gt;&lt;strong&gt;akitaonrails/aitrepreneur-docker&lt;/strong&gt;&lt;/a&gt;, which runs Aitrepreneur&amp;rsquo;s stacks locally in Docker with pre-loaded models and workflows. Check the README for details.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In the &lt;a href="https://www.akitaonrails.com/en/2025/04/23/using-ai-comfyui-to-generate-npcs-for-game-development/"target="_blank" rel="noopener"&gt;previous post&lt;/a&gt; I showed how to generate character sheets for game development. But some folks asked me &amp;ldquo;what if I want specific clothes?&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hold my Beer&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a way. It&amp;rsquo;s a separate workflow that you can later add to the previous workflow if you want (homework assignment). Just take the character generated in the first group of nodes and pass it through this other workflow that swaps clothes. (Or do the extra work of generating loras and activating them with keywords in the prompt as well, there isn&amp;rsquo;t only one way.)&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p><strong>Update:</strong> this post is now obsolete. I now use <a href="https://github.com/akitaonrails/aitrepreneur-docker"target="_blank" rel="noopener"><strong>akitaonrails/aitrepreneur-docker</strong></a>, which runs Aitrepreneur&rsquo;s stacks locally in Docker with pre-loaded models and workflows. Check the README for details.</p>

</blockquote>
<p>In the <a href="https://www.akitaonrails.com/en/2025/04/23/using-ai-comfyui-to-generate-npcs-for-game-development/"target="_blank" rel="noopener">previous post</a> I showed how to generate character sheets for game development. But some folks asked me &ldquo;what if I want specific clothes?&rdquo;.</p>
<p><strong>Hold my Beer</strong></p>
<p>There&rsquo;s a way. It&rsquo;s a separate workflow that you can later add to the previous workflow if you want (homework assignment). Just take the character generated in the first group of nodes and pass it through this other workflow that swaps clothes. (Or do the extra work of generating loras and activating them with keywords in the prompt as well, there isn&rsquo;t only one way.)</p>
<p>Unlike Mick&rsquo;s workflow - which is paid - this other one is open. I grabbed it from some Reddit post and dropped it into the workflows folder of my Docker project as &ldquo;garmente-replacement-idm-vton.json&rdquo;. Just load it in ComfyUI and play around. Let&rsquo;s see the result. Here are two images. The result isn&rsquo;t perfect, but see if you can figure out which of the two is the original and which one was generated by A.I.:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/6b56mdoiwf82xdh7cdhdc7tg0ic2" alt="Hemsworth 1"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/w0s9nthw6jt86en8wl09m67ryzpc" alt="Hemsworth 2"  loading="lazy" /></p>
<p>Answer at the end of the post lol</p>
<p>Anyway, the complete workflow looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/xbgu7dzrjzflhfsld6jtrl5jc3ia" alt="workflow"  loading="lazy" /></p>
<p>In the current state of my setup there&rsquo;s a glitch I haven&rsquo;t been able to solve. I think it&rsquo;s the JNodes extension that&rsquo;s opening an infernal popup at the top left of the interface, which won&rsquo;t close no matter what. For now I&rsquo;m opening the Inspector and deleting the Node from the HTML directly lol it&rsquo;s a hack but it works:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/p8q4hmwjmi4opsu8u045jxrpma2v" alt="JNode bug"  loading="lazy" /></p>
<p>If anyone knows how to fix it, I&rsquo;d really appreciate it. There&rsquo;s an alert from an extension that seems to be outdated, this front-end something, but just ignore it. Of course it had to be front-end-something&hellip;</p>
<p>Anyway. In this first group it&rsquo;s simple, just load an image as clean and clear as possible of the new garment you want to use. And then the original photo where you want this clothing to be applied. It doesn&rsquo;t perform miracles. If the photo is of someone in a blazer and you want to change it to a tank top, sometimes it manages, but don&rsquo;t expect the arms and torso to look good. It&rsquo;s going to redraw everything as it sees fit, or it&rsquo;ll end up putting the tank top on top of the blazer. Anyway, it&rsquo;s A.I., the clearer the goal, the better the result. If the new garment is a blazer, ideally the original was another blazer, it&rsquo;ll fit better.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2owpldebsc91y6vfsayscdkuqtui" alt="Load Images"  loading="lazy" /></p>
<p>Heads up that the IDM-VTON models really weigh a <strong>TON</strong>. It alone downloads no less than <strong>30GB</strong> of models (already included in my setup) and will demand more than 20GB of VRAM, so anything below an RTX 3090 with 24GB just won&rsquo;t run. You can run it online, on the Hugging Face site. Watch this tutorial that shows how:</p>
<!-- youtube video unavailable (deleted/blocked): WL59FqL0L-s -->
<p>This is the <strong>Aiconomist</strong> channel. He shows how to run it online and how to set it up on your machine in <a href="https://youtu.be/pFYqPf1Z7x8?si=Ryu5vxPmKGr4zhWx"target="_blank" rel="noopener">this other video</a> which I used as reference to build my workflow. Subscribe to his channel, it&rsquo;s worth it. The tutorials are very well explained and have more details that I won&rsquo;t cover in this post.</p>
<p>That said, there&rsquo;s a bug I haven&rsquo;t solved yet. After running the workflow, the model stays hanging in the GPU&rsquo;s VRAM and if I try to run again, it FREEZES. Then I have to shut down the Docker container and restart it to recover the memory. I tried a Clear VRAM GPU node but it didn&rsquo;t work. If anyone knows how to fix it, I&rsquo;d appreciate that too.</p>
<p>The next step is recurring across many workflows: extracting layers of information from the original photo. Getting a mask of the garment we want to replace and information about the original pose:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/kgs8jj5m7cgt2nx4kv8yazfpbfg5" alt="mask and pose"  loading="lazy" /></p>
<p>This is why ComfyUI workflows are powerful once you start understanding how the process is broken down. These parts are reusable and different models give different results depending on which controlnets you add.</p>
<p>Then we get to the main part: wiring all this information into the IDM-VTON node, which will reposition the new garment on top of the original photo:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/7xnxbrcy22trdctbf5a1dhh0rqjw" alt="IDM-VTON"  loading="lazy" /></p>
<p>Notice that the result isn&rsquo;t very good, it looks pretty &ldquo;fake&rdquo;. And there are parameters you can tweak to improve it. But the use case isn&rsquo;t to swap for a <strong>specific</strong> garment but rather for a <strong>similar</strong> one. I&rsquo;ll explain in a bit, but first, a tangent.</p>
<p>The repository for the original extension of this Node is <a href="https://github.com/TemryL/ComfyUI-IDM-VTON"target="_blank" rel="noopener">this one</a>. But if you try to install it, it breaks. It seems to be a bit out of date with ComfyUI and needs to run on an older version. But that&rsquo;s a pain. So I went ahead and made a <a href="https://github.com/akitaonrails/ComfyUI-IDM-VTON"target="_blank" rel="noopener"><strong>FORK</strong></a> with the necessary fixes to run on the newer version (the newer versions of the Python packages diffusers, transformers, huggingface_hub, break IDM-VTON).</p>
<p>By the way, Python dependency management is pretty BAD. But ComfyUI&rsquo;s is even worse, because different extensions (which install Python dependencies globally) can be at different stages of development. Every time ComfyUI itself changes, everyone needs to update fast. That&rsquo;s why the ideal is not to install the master branch version of ComfyUI, always a stable version (marked with a tag) or two versions back, just to be safe. But then there are new extensions that require you to be on the newest version. As I said, it&rsquo;s a nightmare to manage. Being a programmer, I can &ldquo;hack around&rdquo; when errors happen, but normal users will suffer a bit.</p>
<p>Rant over. In the previous step the first part of the flow ends. When it hits this Image Sender node, the process stops - I don&rsquo;t know why. And you have to click the &ldquo;RUN&rdquo; button a second time. Then the other node, Image Receiver, will receive the new image. I think I&rsquo;ll just delete those two nodes and connect the &ldquo;image&rdquo; output of the Run IDM-VTON Inference node directly to the next node, which are the Image Composite Masked and the VAE Encode.</p>
<p>Let&rsquo;s go back a step:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/jj950u7842rkc9lxniz3uoztbszv" alt="New photo, new garment"  loading="lazy" /></p>
<p>In this next step, on the second &ldquo;RUN&rdquo;, it takes the image with the new garment, but low quality, and loads a new checkpoint (in this case juggernaultXL but it could be any other like SDXL itself, FLUX or dreamsharper). The model will determine the new photo, which is what gets generated at the end, on the right.</p>
<p>Notice that the new photo has much better garment quality, but the face is completely different. It used the information from the original photo, kept the pose, the body proportions, even the background, but it can&rsquo;t preserve the face. No problem, because we can help.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/bw9sram1maspiob2ubo4r904oriy" alt="mix photos"  loading="lazy" /></p>
<p>Remember that in the previous step we got the mask (black and white) of the exact position of the garment? We can enlarge the mask and apply a Gaussian Blur (so it later blends nicely into the original photo) and use that to crop from the generated photo with the different face, using the <a href="https://github.com/tencent-ailab/IP-Adapter/"target="_blank" rel="noopener"><strong>IPAdapter</strong></a> model (which alone is another 30GB of models - I told you it&rsquo;s heavy).</p>
<p>If I understood correctly, IP Adapter is responsible for being able to generate new images that are COHERENT with each other, with the same face, or same clothes, but in other positions, for example, a kind of conditioned generation. With it you can control the coherence of new images A LOT, generate a character in different positions, in different backgrounds and always preserving the characteristics of the original character.</p>
<p>IP Adapter will use the original photo, keep the face, use the masks and blend with the photo with the different face, taking only the garment, and then assemble everything in the Image Composite Masked which is the final Node of the process.</p>
<p>In the end, we can compare the first attempt at putting on the new garment (which came out ok but ugly, and the new garment, which came out different but prettier) and everything integrated into the original photo, preserving the same face.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/uawsqhhr6d8kii8getzkq6ebxgh9" alt="image compare"  loading="lazy" /></p>
<p>As I said, the qualities are affected by the choice of model, the KSampler settings, number of steps, cfg and everything else. You have to experiment and generate several until you reach the result you like best. That&rsquo;s also how you learn more. You don&rsquo;t just click once and magically have the result. A good artist experiments dozens of times.</p>
<p>The final image might not be suitable for a Vogue cover (although I&rsquo;ve already seen plenty of official covers that were VERY questionable). It depends on the need. In particular it&rsquo;s good for prototyping. &ldquo;I wonder how this garment looks on my model?&rdquo; Instead of calling the model in, having her change clothes, or spending an hour Photoshopping, in minutes you can try several different garments and see whether the model fits the garment, without wasting time. So a photographer can just show it to the client before they spend money on studio, assistants, equipment, etc. You can visualize quickly and discuss details much faster.</p>
<p>This is the kind of thing where tools like this can help a lot and differentiate one artist from another, not by replacing the artist, but by creating more options. And the main thing: all these workflows are modifiable, you reprogram them and combine them with others however you want. The limit is your creativity.</p>
]]></content:encoded><category>generative-media</category><category>local-models</category></item><item><title>Using A.I. (ComfyUI) to Generate NPCs in Game Development</title><link>https://akitaonrails.github.io/en/2025/04/23/using-ai-comfyui-to-generate-npcs-for-game-development/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/23/using-ai-comfyui-to-generate-npcs-for-game-development/</guid><pubDate>Wed, 23 Apr 2025 16:25:00 -0300</pubDate><description>&lt;p&gt;I have been updating my little ComfyUI with Docker Compose project A LOT. I think it is the most complete and tested setup out there, with the advantage of being in Docker, so it is repeatable with zero setup. Read &lt;a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded"target="_blank" rel="noopener"&gt;my post&lt;/a&gt; for more details on how it works.&lt;/p&gt;
&lt;p&gt;I decided to document in this post one of the most complex workflows I have tested that runs on my setup: how to develop Character Sheets of random characters, in any style, whether realistic, Pixar style, cartoon style, whatever you want. The result is a sheet like the example below. In several poses, several lighting setups, several emotional expressions, with a color index, all generated from absolute scratch by &amp;ldquo;A.I.&amp;rdquo; In half an hour of processing on my RTX 4090.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I have been updating my little ComfyUI with Docker Compose project A LOT. I think it is the most complete and tested setup out there, with the advantage of being in Docker, so it is repeatable with zero setup. Read <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded"target="_blank" rel="noopener">my post</a> for more details on how it works.</p>
<p>I decided to document in this post one of the most complex workflows I have tested that runs on my setup: how to develop Character Sheets of random characters, in any style, whether realistic, Pixar style, cartoon style, whatever you want. The result is a sheet like the example below. In several poses, several lighting setups, several emotional expressions, with a color index, all generated from absolute scratch by &ldquo;A.I.&rdquo; In half an hour of processing on my RTX 4090.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/469eyg4a4pl3ry0z12twg74nlbc8" alt="NPC Design Sheet"  loading="lazy" /></p>
<p>Imagine you are an intern at a game studio. Your job: create 100 background characters (NPCs). In the end you have to deliver 100 of them. But just because they are secondary characters does not mean you can do them any old way, they all have to be coherent, in every pose. Imagine how many days you would take. What if you could automate it? This is the kind of scenario where A.I. is interesting. Spend more time on the main characters, do them manually, but do not waste time on the secondary ones and do not lower the final quality of everything.</p>
<p>I did not make this workflow, it was Mickmumpitz. Watch his entire video to learn how this workflow works, I will not repeat what he already explains:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/grtmiWbmvv0"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>I will also not share his workflow, because he has a paid Patreon (it is super cheap, only USD 10 per month), which gives you access to download his entire workflow pack <a href="https://www.patreon.com/posts/free-workflows-120405048"target="_blank" rel="noopener">at this link</a></p>
<p>But that alone is not enough. The requirements you need:</p>
<ul>
<li>a beefy machine with at least an RTX 3090 with 24GB of VRAM. The important thing here is VRAM, the models used easily exceed 20GB of usage. CPU is less important, I use a 7950X3D but a 7800 would already handle it.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/730safwxqkrsgawu4liw42scoul3" alt="GPU VRAM"  loading="lazy" /></p>
<ul>
<li>if you do not have such a machine, it takes more work but you can rent one on <strong>RUNPOD</strong>. They have several GPU configurations, but you will need to know how to configure Linux a bit or choose Windows and do Mickmumpitz&rsquo;s tutorial all by hand (trust me, you will suffer if you do it that way).</li>
<li>you need to have at least <strong>500GB</strong> of available space and good internet to download. The first time my Docker runs, it automatically downloads all ComfyUI extensions and all models for everything. You can edit the <code>models.conf</code> file beforehand and remove models that you know you will never use. But just this workflow alone requires at least about 30GB of models, there is no way around it.</li>
<li>this workflow is particularly <strong>HEAVY</strong> because it has several features (parts of which we can disable, to customize for our workflow). But for 1 character, the entire flow can take <strong>30 MINUTES</strong>. Even on an RTX 4090 like mine.</li>
</ul>
<p>If you have done everything right up to this point, you have ComfyUI running with all dependencies already downloaded and you bought the Workflow. When you load it you will have to fix some things manually.</p>
<p>The first thing is that you will see an error in several Nodes in red, all of them <strong>Ultimate SD Upscale</strong>. Just right-click on each one and choose &ldquo;Fix Recreate&rdquo; and it will fix automatically:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/djblur5g36mw9denng0ht0vvksym" alt="Fix Upscale"  loading="lazy" /></p>
<p>ComfyUI requires that each output slot of a Node connects to the input slot of another compatible Node, but often the workflows get so big that there are little wires connecting things scattered all over the place and it gets really hard to know what connects to what. It literally becomes <strong>spaghetti</strong>.</p>
<p>But there is an extension (which I have already pre-installed) called <a href="https://github.com/chrisgoringe/cg-use-everywhere"target="_blank" rel="noopener">cg-use-everywhere</a>. Every extension is a little GitHub project, cloned into the <code>custom_nodes</code> subdirectory. It lets you connect a Node&rsquo;s output to this <strong>Anything Elsewhere</strong> node, say the &ldquo;clip&rdquo; output of the &ldquo;Load Clip&rdquo; Node. Now, every other Node that has &ldquo;clip&rdquo; as an input field and does not have anything explicitly connected to it will use this Anything that was created. It works super well.</p>
<p>Only in this Mick workflow, who knows why, all the Anywheres are broken. But it is easy to fix. Just find these Nodes, delete them, create new ones and reconnect. There are several right at the beginning, write down who connects to each one, delete them and create a new one to reconnect:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/kjjo7u6g58drww1v5f8v88h1iis1" alt="anywhere initial"  loading="lazy" /></p>
<p>There are also some in the Upscale group 02:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qrkdxs6qjdtmdjs3c463oxetbgyy" alt="anywhere upscale"  loading="lazy" /></p>
<p>Small Nodes, with only the title showing, are COLLAPSED. Just right-click and choose EXPAND to see the details. It is another way to organize the mess.</p>
<p>Lastly, you have to recheck the correct name of the models in all the &ldquo;Load&rdquo; something Nodes right at the beginning of the workflow (luckily he organized these nodes all together):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/y1u93ujz3kq4hz3pik5q4wcgapoa" alt="model path"  loading="lazy" /></p>
<p>In his workflow, the Node comes looking for &ldquo;FLUX/flux1-dev-fp8.safetensors&rdquo; but then you go to the list (which pulls from what is actually in the &ldquo;models/checkpoints&rdquo; directory on your machine) and choose the one with the same name. This happens often because different people organize different subdirectories for the models. But after configuring, just save and you are done. You have to do this for the checkpoint, controlnet, ic-light, etc.</p>
<p>ComfyUI errors are not friendly. They are Python stacktraces, which are not friendly because they show which line of code blew up, but they do not show what was in the variables passed to the function that blew up. So there is no way to know where it went wrong. But normally on the web interface, the Node turns red, then you check it and usually it is because you forgot to download the model or pick the right one from the list.</p>
<p>The last thing in this workflow is to download the OpenPose image from his Patreon page, which I linked at the beginning of the post, and load it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/uajcc6ocnr5llzv8wzoowl85c5o3" alt="openpose image"  loading="lazy" /></p>
<p><strong>OpenPose</strong> is a standard file format that describes poses, an open format that can be edited in several tools. And there are several pre-trained models to extract poses in this format from images, so we do not even need to edit it by hand. We can download several pre-made open poses from this site:</p>
<p><a href="https://openposes.com/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/ntvaf9vba1rugg11qn3n14u2t69e" alt="Poses Sites"  loading="lazy" /></a>
I am going to repeat this several times, but <strong>watch Mick&rsquo;s video tutorial</strong> where he explains how to generate characters from scratch or use <strong>your own photos</strong> with your own poses to continue the flow from there. His workflow, after running, looks like this (with zoom way down below):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/jqu4o2c5cenci6au2g8bbny064mm" alt="complete workflow"  loading="lazy" /></p>
<p>In the first part, there is the manual configuration and checking of the models. You only have to fix it once and save. After that it will always work the same way. In this first menu you can also turn on and off some of the Node groups, for example, to skip the first stage of generating a character from scratch and be able to use your own photos:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/s72mrc1ib5o173c9l74vmpsn3js3" alt="menu and setup"  loading="lazy" /></p>
<p>If you choose to generate a character from scratch, there is a green prompt box to type how you want your character to be, in what style. Text Prompts are decoded by <strong>CLIP</strong> (Contrastive Language Image Pre-Training) models, which are specific to certain UNET categories (like SDXL for Stable Diffusion or the now best and most popular FLUX). Part of the learning is starting to figure out which sub-models (clips, clip_vision, vaes, loras, etc.) are compatible with which unets (unet, checkpoints, diffusion_models). You cannot mix just anything. Roughly speaking, it is a bit like, if you choose to do a text in German, all plugins have to be in German.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/37oe4dyqr5t0in9ad98bocli6u3w" alt="Character Generation"  loading="lazy" /></p>
<p>I will not show the complete workflow because it does not even fit, but you can see the prompts in green, in the middle where we choose which pose we want (it is on his Patreon), and on top the image generated by the <strong>flux1-dev-fp8</strong> model, one of the best and most popular for having excellent quality and not being so heavy (fp8 instead of fp32, for example).</p>
<p>This is one of the advantages of using tools outside the Dalle or Midjourney of the world: we can control all aspects of image generation not only with text prompts, but also with image prompts, like OpenPose.</p>
<p>Another detail. I explained in my previous post about KSampler or Samplers in general, that they are the engine that does all the work of joining all the models, images, prompts, controlnet, vaes and produces the final image. KSampler parameters like steps, cfg, denoise, scheduler and more <strong>DEPEND</strong> on the chosen model. There are well-documented models, there are poorly documented models.</p>
<p>For example, say you read on a Reddit thread about this other more optimized model called <a href="https://civitai.com/models/215418?modelVersionId=273102"target="_blank" rel="noopener">&ldquo;TurboVisionXL&rdquo;</a>, which is available on the Civit.ai site:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/hpcj5e8g0hxvefelvszwf48vsnpf" alt="TurboVisionXL"  loading="lazy" /></p>
<p>It really is a good model, but now what? What do I configure in the KSampler for it? Luckily if you scroll the page (always read the original documentation!!) you will find these recommendations:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/57f9vyfh4w9j8bduaomc3aow2sks" alt="TurboVisionXL documentation"  loading="lazy" /></p>
<p>See? He explains what to configure and what the known limitations are. Further down there will be comments from other people who can give more tips or share errors that you have already seen. It is a huge community, you need to learn how to participate in it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/7avk9tl1pop1eexkuu3h8epgs2t5" alt="MultiView"  loading="lazy" /></p>
<p>But back from the tangent, in Mick&rsquo;s second group of nodes, he takes the first image in &ldquo;Pose T&rdquo; and runs it through a &ldquo;Multi View&rdquo; process using Diffuser MV which does this automatically. It is the same pose, but as if the camera went 360 degrees around it, capturing different angles and, most importantly, generating new images coherent with the original (see how the model is even used to invent the character&rsquo;s back).</p>
<p>In this step he also extracts only the face from the first image to do other treatments in parallel. Face recognition and extraction is super common. Every security system uses it, Google Photos uses it to organize dynamic albums. And it is worth learning to use in your own products. It is a reasonably light and fast process these days and there are several pre-made algorithms and models to use. In this case I do not think it even uses A.I. as such, it is just an algorithm.</p>
<p>Another detail: image generation models normally work in <strong>LOW RESOLUTION</strong>. Usually below 1024x1024. Do not expect to generate 4K images right off the bat. But that is no problem, because the solution is to first generate a low resolution image and then run it through another A.I. model specialized in <strong>UPSCALING</strong> like the RealESRGAN that I already talked about in my other post about <a href="https://www.akitaonrails.com/en/2025/04/19/aumentando-resolucao-de-anime-velho-pra-4k-com-i-a"target="_blank" rel="noopener">upscaling old anime</a></p>
<p>Different from what Photoshop or any image editor does, if you try to enlarge an image, it just takes a pixel and tries to duplicate, quadruplicate the surrounding pixels. But that usually only makes the image bigger but blurred, because it has no way of knowing the details, and it will not even try to &ldquo;invent&rdquo; details.</p>
<p>To &ldquo;invent&rdquo; details, you need a pre-trained model that learned what to draw. It is also how NVIDIA&rsquo;s DLSS 3 works for games, where the game renders in low resolution, like 720p, and DLSS, in real time, redraws the new screen in 4K. It is not perfect, but playing at 60fps, you will rarely notice the difference. You will only notice that it got more <strong>CRISP</strong>.</p>
<p>That is also why, if you use your own photo, or photos of people you know, you will see that the new photo after upscaling looks <strong>slightly different</strong>, it is no longer the same person, because the model <strong>REDRAWS</strong> details that did not exist. It is a process of interpretation and not of discovering &ldquo;hidden&rdquo; details (that does not exist, only in CSI fiction).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2he2fgmus8vmp3wfp95s3qbh7ztf" alt="Upscale 2"  loading="lazy" /></p>
<p>Mick&rsquo;s workflow goes one step further and has a second upscaling pass, focusing on fixing only the face with a Face Detailer Node, made specifically to fix and improve faces. And here we enter plastic surgery and harmonization territory for real lol the final face will be different from the original. But for NPCs and secondary characters it does not matter, nobody will know the difference anyway, as long as it is not deformed, cross-eyed or something else that jumps out at you.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/3d40a8n9b32mmkub5l8c04oou37h" alt="Emotions"  loading="lazy" /></p>
<p>There are Nodes specialized in expressions, like the Expression Editor (PHM) which starts from a single face photo and manages to generate new photos in the most varied emotions - which we can configure exactly, numerically, however we want. I will not show it, but note that the generated images are in low quality, but the next step in the workflow is precisely to upscale them too.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/cxdua4dfalu6d68tpaleljie5y1e" alt="Lighting"  loading="lazy" /></p>
<p>This step is interesting. First the top part, with the various scenarios. They were all generated via the green prompts below them, prompts that Mick left but that we can edit however we want OR swap the generation node and put a Load Image node to load our own background images, from real photos.</p>
<p>The objective of this step is to generate the faces under different light configurations. Many people think that &ldquo;light&rdquo; in games is just &ldquo;turn on the red light&rdquo; and everyone will automatically turn red. In new games, with <strong>RAY TRACING</strong> yes, that is why it is SUPER HEAVY. But in games made for old machines, indie games, you cannot demand this from everyone.</p>
<p>So &ldquo;tricks&rdquo; are used to fake things like lights and shadows. It is the <strong>BAKING</strong> process where we literally &ldquo;stamp the light onto the texture&rdquo; of objects and characters. With global illumination on or off, they will always be &ldquo;painted&rdquo; with the colored light of the scene. To switch scenes, new textures are loaded with other &ldquo;baked&rdquo; lights. For programmers, think of baking as <strong>Caching</strong>. Why recalculate everything dynamically every time, if the scene&rsquo;s light never changes? Save that step and leave it &ldquo;cached&rdquo;.</p>
<p>Going back, this step uses the <a href="https://github.com/lllyasviel/IC-Light"target="_blank" rel="noopener"><strong>IC-Light</strong></a> technology which is another open source project worth learning, even if you are not interested in A.I. It can manipulate the light of any image or photo. You, as a photographer, can change the light if you have changed your mind, in post-production. Adobe or BlackMagic tools, I think they already include similar technology, but it is good to know that there is open source and you can integrate it into your product.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qup2n6vadpc7qrtgokbqbfkpvmjg" alt="final sheet"  loading="lazy" /></p>
<p>After generating the faces with different lights, obviously there is another upscaling step - which I am skipping showing - and it goes to the final group of nodes that joins everything into a &ldquo;sheet&rdquo;, a &ldquo;character sheet&rdquo;. The game developer can use it directly in a 2D game (think of a visual novel game), or it can serve as a basis for UV Mapping textures of a simple 3D model with Armature to animate and you already have some emotional faces. Good for placing in the background scenery, pedestrians walking on the street, shopping, sitting in the square or something like that.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/cgv0qp3slipgw4nrlgnrserl1n8s" alt="Loras"  loading="lazy" /></p>
<p>In addition, Mick&rsquo;s tutorial describes how it is possible to create <strong>LORAS</strong> (Low-Rank Adaptation). If you want to be able to generate characters with a specific style or even specific faces (without &ldquo;artistic&rdquo; intervention from the A.I.), you choose a model like FLUX or SDXL and &ldquo;on top&rdquo; of it you add a second compatible sub-model with information about that person or character.</p>
<p>For this you take several clean and well-lit photos of the person, from many different angles. You do not even need much, just a dozen is enough. For each image you create prompts that describe it well, and that is it. There are tools to generate these Loras locally or sites that do this (watch the video).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/tk8tbtrr1ealm4c4eu26alk63xlh" alt="Creating Prompts"  loading="lazy" /></p>
<p>Speaking of prompts, in several workflows there are steps to describe detailed prompts of dynamically generated images. Instead of requiring the user (you) to do this manually, you can use a decoding model, which decodes images into text prompts. The most used model for this is Microsoft&rsquo;s <strong>FLORENCE 2</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/dwr87ajtkj7w15ftsy86l5u4hz44" alt="Florence 2"  loading="lazy" /></p>
<p>That text box at the bottom is read-only, you do not need to type it manually. The Florence 2 Run Node will read the image and generate this text. See how it described the first image of the woman in the &ldquo;T&rdquo; pose. Doing tests, for your case, you determine if just this is enough; if it is not, just put a second prompt Node and concatenate the two before sending them to other nodes.</p>
<p>Anyway, there is A LOT to explore just in this workflow. Mick did an excellent job both on the workflow and on the video to explain it, and it is worth paying the 10 dollars to contribute. There are dozens of workflows like this available on the Web, just search. And my setup is getting more and more complex. I plan to keep adjusting it for each new interesting workflow I run into. There are already several pre-tested ones in the &ldquo;workflows&rdquo; folder of my project for you to play with.</p>
]]></content:encoded><category>generative-media</category><category>gaming</category><category>local-models</category></item><item><title>Understanding the Basics of ComfyUI to Generate AI Images</title><link>https://akitaonrails.github.io/en/2025/04/20/understanding-the-basics-of-comfyui-to-generate-ai-images/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/20/understanding-the-basics-of-comfyui-to-generate-ai-images/</guid><pubDate>Sun, 20 Apr 2025 19:30:00 -0300</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; this post is now obsolete. I replaced the manual setup with a cleaner containerized project: &lt;a href="https://github.com/akitaonrails/aitrepreneur-docker"target="_blank" rel="noopener"&gt;&lt;strong&gt;akitaonrails/aitrepreneur-docker&lt;/strong&gt;&lt;/a&gt;. It runs Aitrepreneur&amp;rsquo;s stacks locally in Docker, with pinned versions, persistent models, and pre-loaded workflows. Check the README for details.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In the &lt;a href="https://www.akitaonrails.com/en/2025/04/20/generating-ai-images-even-ghibli-style-with-docker-and-cuda/"target="_blank" rel="noopener"&gt;previous post&lt;/a&gt; I explained my little project to spin up ComfyUI pre-configured and pre-loaded automatically using Docker Compose. Assuming you already have everything up and running, now it&amp;rsquo;s time to actually understand this beast called ComfyUI a little.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p><strong>Update:</strong> this post is now obsolete. I replaced the manual setup with a cleaner containerized project: <a href="https://github.com/akitaonrails/aitrepreneur-docker"target="_blank" rel="noopener"><strong>akitaonrails/aitrepreneur-docker</strong></a>. It runs Aitrepreneur&rsquo;s stacks locally in Docker, with pinned versions, persistent models, and pre-loaded workflows. Check the README for details.</p>

</blockquote>
<p>In the <a href="https://www.akitaonrails.com/en/2025/04/20/generating-ai-images-even-ghibli-style-with-docker-and-cuda/"target="_blank" rel="noopener">previous post</a> I explained my little project to spin up ComfyUI pre-configured and pre-loaded automatically using Docker Compose. Assuming you already have everything up and running, now it&rsquo;s time to actually understand this beast called ComfyUI a little.</p>
<p>I&rsquo;m no expert either, but I thought it would be cool to explain a few concepts that many people overlook.</p>
<h3>Pre-Trained Models<span class="hx:absolute hx:-mt-20" id="pre-trained-models"></span>
    <a href="#pre-trained-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Every GPT out there, or Stable Diffusion or others, loads a vector database into the GPU&rsquo;s memory, into VRAM, in order to process. They are HUGE models, called 7B, 70B, 100B, etc. The &ldquo;B&rdquo; stands for &ldquo;Billions of Parameters&rdquo;. Parameters have no usable definition outside of math, they are simply numbers. Those numbers represent &ldquo;something&rdquo; inside millions of dimensions inside that giant file.</p>
<p>In a VERY ROUGH way, I think of them as compression artifacts. You take PETABYTES of books, images, texts, posts, etc, break it all into tokens and find &ldquo;relationships&rdquo; between one token and many other tokens, and you keep &ldquo;positioning&rdquo; them in space, but not in 3 dimensions, in millions of dimensions. AI models are &ldquo;Vector Space Models&rdquo; or VSMs, more or less like the index of an Elasticsearch if you&rsquo;ve ever played with relevance-based text search, or any &ldquo;Full Text Search&rdquo; plugin in Postgres or MS-SQL. They&rsquo;re all VSMs.</p>
<p>Here&rsquo;s how I think about it: it&rsquo;s as if the training process - the SUPER EXPENSIVE part, which takes MONTHS - takes all the information that&rsquo;s fed in and compiles a new &ldquo;dictionary&rdquo; for a new &ldquo;language&rdquo;. Now, when you &ldquo;talk&rdquo; to a GPT, first it needs to transform your text from your language into this internal &ldquo;language&rdquo; of its own, which is what we call an <strong>&ldquo;Embedding&rdquo;</strong>.</p>
<p>Any data can be transformed. Audio can be converted into text and from text into embeddings. Videos can be broken down into series of images with temporal information. Images can be deconstructed into elements like luminance, chrominance, etc. In the end, everything can be deconstructed into data structures, there&rsquo;s no magic.</p>
<p>Image generation models are trained differently. There are several focuses, focus on facial sharpness, focus on pose accuracy, focus on different styles like cartoon or painting, focus on categories like animals or objects. That&rsquo;s why there are dozens of models out there and lots of &ldquo;loras&rdquo;.</p>
<p>To generate an image from a prompt, first you need to decode your prompt into an embedding, and that&rsquo;s what things like a <strong>TEXT ENCODER</strong> in ComfyUI are for.</p>
<p>In the specific case of AI for images, the way I understand it is that they use something called a &ldquo;DIFFUSION&rdquo; model both to deconstruct the original image and to reconstruct a new image. It&rsquo;s not intuitive, but the reconstruction doesn&rsquo;t start on a blank canvas, the way it would for a human illustrator. It starts with an image filled with random &ldquo;noise&rdquo; like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/rcvhb8m0boe3jetpy72kww2wmucw" alt="noise"  loading="lazy" /></p>
<p>The model starts from this noise and through a process called DIFFUSION it begins to redraw the image backwards, until it emerges from the chaos and arrives at an image.</p>
<p>What I &ldquo;THINK&rdquo;, what I &ldquo;GUESS&rdquo; is happening - without having read any paper, pure laziness - is that we try to influence this initial noise. For example, by extracting features from the image like a depth map, which looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qj0ba39q5xbs7xyei52h4qmmpr2d" alt="Depth Map"  loading="lazy" /></p>
<p>The original photo looked like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/6zionu0khn01gpudd9z0ws2mm8zx" alt="Original"  loading="lazy" />.jpg)</p>
<p>Extracting a Depth Map is an <strong>ALGORITHM</strong>, it&rsquo;s not &ldquo;AI&rdquo;. Any Photoshop or Premiere out there can do this with one hand tied behind its back. But the point is to explain that there&rsquo;s much more information in an image than what you - untrained as you are - can see is there.</p>
<p>Another well-known type of ALGORITHM is <strong>Canny Edge Detection</strong>, which produces an edge map, making it easier to know where something starts and ends in the image:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/std6jaroftohqjlc22mii9m7oso6" alt="Canny"  loading="lazy" /></p>
<p>So it goes more or less like this:</p>
<ul>
<li>just as a text is &ldquo;converted&rdquo; into an <strong>Embedding</strong>, vectors that capture the &ldquo;essence&rdquo; of the meaning of the text, dependent on the VSM of the pre-trained model, an image is &ldquo;converted&rdquo; internally into a <strong>LATENT</strong>, which because it&rsquo;s an image is a &ldquo;space&rdquo; (that&rsquo;s why people talk about Latent Space). We can&rsquo;t &ldquo;visualize&rdquo; Latents, because it&rsquo;s an internal representation that depends on the U-NET, the pre-trained model, the neural network that learns to &ldquo;denoise&rdquo; the Latent.</li>
<li>An image U-NET is roughly similar to a text model like GPT or LLaMA, the pre-trained, read-only model that will be used to produce the final result.</li>
<li>A GPT response is a continuation of the context of the previous conversation. It&rsquo;s not &ldquo;answering&rdquo; you directly, it&rsquo;s just &ldquo;continuing&rdquo; the text of the conversation and, by chance, due to training, the result &ldquo;looks like&rdquo; a response. In the case of images, we start with a randomly noisy image and through a process of removing and adding noise, it gradually &ldquo;rearranges&rdquo; the Latent into an image we can recognize.</li>
<li>There is a &ldquo;Scheduler&rdquo; that decides how much noise is added or removed at each time step. And there is the concept of a <strong>DIFFUSION MODEL</strong>, which is more or less the processing &ldquo;pipeline&rdquo;.</li>
<li>We can control the process by adding a text prompt, which is decoded into an &ldquo;embedding&rdquo; using a <strong>Text Decoder</strong> that usually has names like &ldquo;CLIP&rdquo; or properly a Transformer (such as LLava). These embeddings go into the U-NET via cross-attention layers, steering the &ldquo;denoising&rdquo; toward the meaning of the prompt.</li>
<li>On top of that we have <strong>CONTROLNET</strong>, which is a second parallel network that adapts the process to extra conditions. It&rsquo;s a copy of the U-NET architecture (with frozen weights) that receives extra inputs, like Canny Edges, or Depth Maps, pose skeletons (via OpenPose), etc, and learns small adjustments to <strong>INJECT</strong> into the main maps of the U-NET.</li>
<li>During the denoising process, ControlNet activations are merged with those of the U-NET, forcing the process to respect the conditions we passed in.</li>
</ul>
<p>So the U-NET is the &ldquo;generator&rdquo;, the Diffusion Scheduler orchestrates the process, text encoder and controlnet both influence the U-NET, and a VAE is used at the end to convert a latent space into an actual image that we humans can actually see.</p>
<p>VERY, VERY ROUGHLY. That&rsquo;s more or less how I understand the process. And the way I see it, ComfyUI exposes several of these steps in the form of &ldquo;PROGRAMMABLE NODES&rdquo; that we can parameterize and tweak to influence how we want the final image to come out.</p>
<p>Beyond numerical parameters, these Nodes also let us choose which pre-trained model files we want to use for each stage. It&rsquo;s all binary VSMs, but for different uses, which is why many of them have the same extension as I explained, but they live in different sub-directories. So each Node can find only what matters to it.</p>
<p>Taking my photo as an example, this isn&rsquo;t exactly it but just to illustrate, it helps me think that those maps I showed influence the &ldquo;noisy&rdquo; image in a non-random direction, like this one I grabbed from an intermediate stage of the workflow I&rsquo;m running:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/yy0yrlrcel015bucfr3z60izt42p" alt="noise"  loading="lazy" /></p>
<p>If you strain your eyes, you can see the influence of the previous images, and the Diffusion model keeps doing the &ldquo;denoising&rdquo;, being &ldquo;interrupted&rdquo; by the ControlNet. It&rsquo;s as if you were drawing and had a person next to you who every now and then erases what you did, or draws on top, and you keep adapting. So in the end it becomes a &ldquo;collaborative&rdquo; job between the two. If it were only the U-NET, it would generate a brand new image quite different from the original, but the ControlNet is the &ldquo;client&rdquo; that keeps nagging in your ear &ldquo;that&rsquo;s not how I want it, change it&rdquo;, until it arrives at an acceptable result.</p>
<p>On top of that, a U-NET isn&rsquo;t great at generating very high resolution images. The ideal is to generate small images, 512 pixels, 1024 pixels, but nothing bigger. But that would be useless for professional use. That&rsquo;s what <strong>UPSCALE MODELS</strong> are for. If you read my article from yesterday about <a href="https://www.akitaonrails.com/en/2025/04/19/aumentando-resolucao-de-anime-velho-pra-4k-com-i-a"target="_blank" rel="noopener">Video2K</a> I explain that I pick a model there that&rsquo;s good for temporal coherence in video. But there were other models that are better for &ldquo;STATIC IMAGES&rdquo;, or photos. And we can use them in ComfyUI.</p>
<p>After the new small image is generated by the U-NET, we can then use it as input to a second &ldquo;UPSCALE&rdquo; pass using a model like RealERSGAN, and go from a 1024 pixel image to 2160 pixels, essentially 4K. It&rsquo;s different from enlarging the image in Photoshop, where everything ends up &ldquo;soft&rdquo; and blurred. Since it&rsquo;s also another AI model, it&rsquo;s trained to &ldquo;redraw&rdquo; at higher resolution. And we can add this to the ComfyUI workflow as well.</p>
<p>More than that, ComfyUI also supports Nodes with models like Tencent&rsquo;s HunyuanVideo, which I introduced in another article from yesterday, about <a href="https://www.akitaonrails.com/en/2025/04/19/gerando-videos-de-ate-2-min-a-partir-de-uma-foto-com-i-a"target="_blank" rel="noopener">FramePack</a>. In fact, we can do the same thing FramePack does but from a ComfyUI workflow: generate a video from an image, using the same model. FramePack is infinitely easier to use, but ComfyUI is the Swiss Army knife of AI: it does <strong>EVERYTHING</strong>, which is also why it&rsquo;s infinitely more complicated and you need to put in serious effort to learn how to use it.</p>
<p>With what we&rsquo;ve covered so far, take a look at the workflow I used (it&rsquo;s zoomed out, you can dynamically zoom in):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/rrtdjr0sq5vsl3xnm08w3sz459w8" alt="workflow"  loading="lazy" /></p>
<p>You can see these intermediate steps using <strong>PREVIEW</strong> Nodes, so this is more or less how we can use ComfyUI to do a lot of advanced stuff. As I said, I myself haven&rsquo;t even started scratching the surface.</p>
<p>Let&rsquo;s now summarize some of the concepts used in ComfyUI:</p>
<h3><strong>1. Checkpoints (Base Models)</strong><span class="hx:absolute hx:-mt-20" id="1-checkpoints-base-models"></span>
    <a href="#1-checkpoints-base-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
These are the main Stable Diffusion models (e.g.: SD 1.5, SDXL, Flux). They contain the entire model architecture:</p>
<ul>
<li>
<p>Text Encoder (CLIP)</p>
</li>
<li>
<p>Diffusion Model (U-Net)</p>
</li>
<li>
<p>VAE (Image Decoder)</p>
</li>
</ul>
<p><strong>What they&rsquo;re for</strong>:<br>
Generating images from scratch. Each checkpoint has a different &ldquo;style&rdquo; (realistic, anime, etc).</p>
<p><strong>Examples</strong>:<br>
<code>flux.safetensors</code>, <code>realisticVisionV60.safetensors</code>, <code>anythingV5.safetensors</code></p>
<h3><strong>2. VAE (Variational Autoencoders)</strong><span class="hx:absolute hx:-mt-20" id="2-vae-variational-autoencoders"></span>
    <a href="#2-vae-variational-autoencoders" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Component that decodes images from the latent space (the format the model understands) into visible pixels.</p>
<p><strong>What they&rsquo;re for</strong>:</p>
<ul>
<li>
<p>Improving details and colors</p>
</li>
<li>
<p>Some checkpoints require specific VAEs</p>
</li>
</ul>
<p><strong>Examples</strong>:<br>
<code>vae-ft-mse-840000.safetensors</code>, <code>animevae.pt</code></p>
<h3><strong>3. Text Encoders</strong><span class="hx:absolute hx:-mt-20" id="3-text-encoders"></span>
    <a href="#3-text-encoders" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Models that convert your text prompt into numerical representations the AI understands.</p>
<p><strong>Common types</strong>:</p>
<ul>
<li>
<p><strong>CLIP</strong>: Standard for most models</p>
</li>
<li>
<p><strong>OpenCLIP</strong>: Alternative version used in SDXL</p>
</li>
<li>
<p><strong>T5</strong>: Larger models for complex prompts</p>
</li>
</ul>
<p><strong>Examples</strong>:<br>
<code>clip_vision.safetensors</code>, <code>openclip.safetensors</code></p>
<h3><strong>4. CLIP Vision</strong><span class="hx:absolute hx:-mt-20" id="4-clip-vision"></span>
    <a href="#4-clip-vision" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Models specialized in visual analysis (as opposed to normal CLIP, which is text→image).</p>
<p><strong>What they&rsquo;re for</strong>:</p>
<ul>
<li>
<p>Creating descriptions of existing images</p>
</li>
<li>
<p>Tools like &ldquo;Image Prompt&rdquo; (which use the image as input)</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>clip_vision_g.safetensors</code></p>
<h3><strong>5. Diffusion Models</strong><span class="hx:absolute hx:-mt-20" id="5-diffusion-models"></span>
    <a href="#5-diffusion-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
A specific part of the architecture (U-Net) responsible for the diffusion process (adding/removing &ldquo;noise&rdquo;).</p>
<p><strong>What they&rsquo;re for</strong>:</p>
<ul>
<li>
<p>Training LoRAs</p>
</li>
<li>
<p>Advanced reuse workflows</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>unet.safetensors</code></p>
<h3><strong>6. LoRAs (Lightweight Adapters)</strong><span class="hx:absolute hx:-mt-20" id="6-loras-lightweight-adapters"></span>
    <a href="#6-loras-lightweight-adapters" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Small files that modify the behavior of the main checkpoint without replacing it.</p>
<p><strong>What they&rsquo;re for</strong>:</p>
<ul>
<li>
<p>Adding specific styles (e.g.: anime)</p>
</li>
<li>
<p>Creating consistent characters</p>
</li>
<li>
<p>Adjusting body proportions</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>epiNoiseOffset.safetensors</code>, <code>add_detail.safetensors</code></p>
<h3><strong>7. ControlNet</strong><span class="hx:absolute hx:-mt-20" id="7-controlnet"></span>
    <a href="#7-controlnet" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Models that impose control over the composition of the image (poses, depth, edges).</p>
<p><strong>Common types</strong>:</p>
<ul>
<li>
<p>OpenPose (human poses)</p>
</li>
<li>
<p>Canny (edges)</p>
</li>
<li>
<p>Depth (depth)</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>control_v11p_sd15_openpose.safetensors</code></p>
<h3><strong>8. Upscale Models</strong><span class="hx:absolute hx:-mt-20" id="8-upscale-models"></span>
    <a href="#8-upscale-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Models for increasing the resolution and detail of generated images.</p>
<p><strong>Types</strong>:</p>
<ul>
<li>
<p><strong>ESRGAN</strong>: For realistic details</p>
</li>
<li>
<p><strong>Anime6B</strong>: Specialized in anime art</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>4x_NMKD-Superscale-SP_178000_G.pth</code></p>
<h3><strong>9. Embeddings (Textual Inversions)</strong><span class="hx:absolute hx:-mt-20" id="9-embeddings-textual-inversions"></span>
    <a href="#9-embeddings-textual-inversions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are</strong>:<br>
Small files that add new concepts to the model&rsquo;s vocabulary.</p>
<p><strong>What they&rsquo;re for</strong>:</p>
<ul>
<li>
<p>Specific artistic styles</p>
</li>
<li>
<p>Custom objects</p>
</li>
<li>
<p>Artifact corrections</p>
</li>
</ul>
<p><strong>Example</strong>:<br>
<code>bad_prompt.pt</code>, <code>easynegative.safetensors</code></p>
<h3><strong>10. Style Models</strong><span class="hx:absolute hx:-mt-20" id="10-style-models"></span>
    <a href="#10-style-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are:</strong><br>
Models specialized in applying visual styles or art &ldquo;filters&rdquo; on top of the generated image, without actually altering the basic structure of the content.</p>
<p><strong>What they&rsquo;re for:</strong></p>
<ul>
<li>
<p><strong>Thematic colorization</strong> (e.g.: watercolor palette, sepia, chroma-key)</p>
</li>
<li>
<p><strong>Simulating artistic techniques</strong> (oil, gouache, manga linework)</p>
</li>
<li>
<p><strong>Unifying aesthetics</strong> across series of images (same color and light &ldquo;mood&rdquo;)</p>
</li>
</ul>
<p><strong>Example files:</strong></p>
<ul>
<li>
<p><code>flux1-redux-dev.safetensors</code> (&ldquo;Flux Redux&rdquo; style)</p>
</li>
<li>
<p><code>watercolor_v2.safetensors</code> (watercolor brushwork)</p>
</li>
<li>
<p><code>film_grain_vintage.safetensors</code></p>
</li>
</ul>
<h3><strong>11. SAMs (Segment Anything Models)</strong><span class="hx:absolute hx:-mt-20" id="11-sams-segment-anything-models"></span>
    <a href="#11-sams-segment-anything-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>What they are:</strong><br>
General segmentation networks capable of isolating objects or regions of interest in an image, producing binary or weighted masks.</p>
<p><strong>What they&rsquo;re for:</strong></p>
<ul>
<li>
<p><strong>Creating masks of objects/people</strong> for selective application of filters or compositions</p>
</li>
<li>
<p><strong>Guiding ControlNets</strong> (e.g.: applying diffusion only inside or outside a region)</p>
</li>
<li>
<p><strong>Pre-processing references</strong> (cropping background, separating layers)</p>
</li>
</ul>
<p><strong>Example file:</strong></p>
<ul>
<li><code>sam_vit_b_01ec64.pth</code> (SAM ViT-B, base segmentation model)</li>
</ul>
<h3>Visual Summary<span class="hx:absolute hx:-mt-20" id="visual-summary"></span>
    <a href="#visual-summary" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>models/
├── checkpoints/        ➔ Main models (Stable Diffusion)
│     └── *.safetensors, *.ckpt
├── diffusion_models/   ➔ Alternative diffusion models
│     └── *.safetensors, *.gguf
├── loras/              ➔ Style adapters (LoRAs)
│     └── *.safetensors
├── controlnet/         ➔ Composition controls (ControlNets)
│     └── *.pth, *.safetensors
├── vae/                ➔ VAE decoders / encoders
│     └── *.pt, *.safetensors
├── clip/               ➔ CLIP text encoders
│     └── *.safetensors, *.pt
├── clip_vision/        ➔ CLIP vision encoders
│     └── *.safetensors
├── text_encoders/      ➔ Advanced text encoders
│     └── *.safetensors, *.bin
├── upscale_models/     ➔ Super-resolution models
│     └── *.pth
├── sams/               ➔ Segment Anything (SAM) models
│     └── *.pth, *.onnx
├── style_models/       ➔ Visual style models
│     └── *.safetensors
└── embeddings/         ➔ Embeddings / custom concepts
      └── *.pt, *.safetensors</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You have to download the right things into the right places. And to make matters worse, as I already said, the files usually have the same extension. So it&rsquo;s very easy to get confused and drop a text encoder into the clip directory or vice versa. Once again, that&rsquo;s why I encourage you to update the <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded/blob/master/models.conf"target="_blank" rel="noopener">&ldquo;models.conf&rdquo;</a> file and put the right URLs into the right lists, that way it stays much more organized and my Docker setup will pull things into the right places at startup.</p>
<p>Even so, you may still have a few other doubts that I had at the beginning too. For example <strong>&ldquo;Why is the U-NET called a Checkpoint?&rdquo;</strong></p>
<h3><strong>Checkpoints ≈ A Complete Operating System</strong><span class="hx:absolute hx:-mt-20" id="checkpoints--a-complete-operating-system"></span>
    <a href="#checkpoints--a-complete-operating-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>
<p><strong>Similarity</strong>: Just as a commit in Git captures a specific state of the code, a checkpoint is a complete &ldquo;snapshot&rdquo; of a model trained at a specific stage.</p>
</li>
<li>
<p><strong>Difference</strong>: Checkpoints are <strong>self-contained</strong> (they aren&rsquo;t &ldquo;diffs&rdquo; like commits), each one is a complete model that can work independently.</p>
</li>
<li>
<p><strong>Better Metaphor</strong>: Think of checkpoints as different versions of Windows (XP, 7, 10) - each one is a complete operating system, not incremental.</p>
</li>
</ul>
<p>So checkpoints kind of resemble, conceptually, a &ldquo;git commit&rdquo; - if you&rsquo;re a developer. Or BTRFS or ZFS snapshots - if you&rsquo;re an infra/devops person.</p>
<h3><strong>LoRAs ≈ Plugins/Extensions</strong><span class="hx:absolute hx:-mt-20" id="loras--pluginsextensions"></span>
    <a href="#loras--pluginsextensions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>
<p><strong>Similarity</strong>: Just as a PR adds functionality to a base codebase, a LoRA modifies the behavior of the base model.</p>
</li>
<li>
<p><strong>Difference</strong>: LoRAs aren&rsquo;t &ldquo;merged&rdquo; into the original checkpoint, they are <strong>applied dynamically</strong> during inference.</p>
</li>
<li>
<p><strong>Better Metaphor</strong>: They&rsquo;re like Chrome extensions (e.g.: AdBlock) - they add specific behaviors without modifying the browser itself.</p>
</li>
</ul>
<p>Think of it this way: a model has Checkpoints, like commits, so you can &ldquo;continue&rdquo; training on top if you need to. At the same time, we don&rsquo;t have to mess with the main model, we can create separate layers, the so-called LoRas. For example, let&rsquo;s say a model is having trouble generating your face, no matter what prompt you use or what Nodes you configure.</p>
<p>Instead of that we can use the &ldquo;dictionary&rdquo; of the pre-trained model, like Flux or SDXL, and do training with our personal photos and produce a LoRa specific to me. Then we can add a LoRa Node into the workflow and that will result in much better images with your particular face.</p>
<p>There are entire online communities dedicated to this. One example. On the <a href="https://civitai.com/"target="_blank" rel="noopener">Civit AI</a> site we&rsquo;ll find lots of models based on existing models, like this <a href="https://civitai.com/models/24149/mistoonanime"target="_blank" rel="noopener">Mistoon</a> which is based on SDXL.</p>
<p><a href="https://civitai.com/models/24149/mistoonanime"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/a4s6zs7oq6rtwto79lqk5itgd4or" alt="Mistoon Civit.ai"  loading="lazy" /></a></p>
<p>It&rsquo;s a checkpoint. We can sign up on the site and download the file <code>mistoonAnime_v10Noobai.safetensor</code> and put it in the <code>models/checkpoints</code> directory.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/17z9fbwbzkg5kwoxzrcivpwwd74c" alt="Mistoon Checkpoint Node"  loading="lazy" /></p>
<p>Now, in any workflow that has the &ldquo;Load Checkpoint&rdquo; Node we have the option to choose this model. But let&rsquo;s say this model isn&rsquo;t able to generate a character with a school uniform like in many anime. Browsing the site, we find this <strong>LoRa</strong>:</p>
<p><a href="https://civitai.com/models/115968/mistoonanime-school-uniform"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/xlvhozw5u90xx9130a1kin5dl0da" alt="Mistoon LoRa"  loading="lazy" /></a>
The Mistoon model is large, around 6.6GB, but this LoRa - called <code>Mistoon_Anime\ school\ uniform.safetensor</code> which we should move to <code>models/loras</code> - is less than 290 MB, much smaller because it&rsquo;s a specific training only on images of school uniforms.</p>
<p>Now we can wire the &ldquo;Load Checkpoint&rdquo; Node to this other &ldquo;Load LoRa&rdquo; Node and choose this LoRa that we know is compatible:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/8ahr2bgdxe5jg3xg4y2wcie3qzvs" alt="LoRa Node"  loading="lazy" /></p>
<p>LoRas have to be picked to be used with the models they were based on. As I said before, think of the model as a &ldquo;language&rdquo;, if we make a LoRa in German, there&rsquo;s no point mixing it with a Chinese model, roughly speaking. But this is one way to do fine-tuning so the model you like delivers results no other one can.</p>
<p>One problem I have with the Civit AI site is that you can only download things while logged in. That&rsquo;s why I didn&rsquo;t include any of their models or loras in my automatic install script. Unfortunately, in their case you have to manually download the files and move them to the right directories, write it down somewhere, so on another machine you can download and move everything all over again. It&rsquo;s a pain that I haven&rsquo;t sat down to fix yet. If anyone wants to contribute a solution for this (without exposing anyone&rsquo;s login cookies, send a Pull Request).</p>
<h3>A Real Example<span class="hx:absolute hx:-mt-20" id="a-real-example"></span>
    <a href="#a-real-example" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Your day-to-day with Comfy, being a newbie like me, will go more or less like this: you start by going to Google and searching for &ldquo;best workflow image to anime ComfyUI&rdquo;. The first links are usually some Reddit thread:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/zhnvm94r6ze0279obqq9k0wd6t9c" alt="Reddit"  loading="lazy" /></p>
<p>Sub-Reddits like <a href="https://www.reddit.com/r/comfyui"target="_blank" rel="noopener">r/comfyui</a> or <a href="https://www.reddit.com/r/StableDiffusion"target="_blank" rel="noopener">r/StableDiffusion</a> usually have news and workflows like this one. Then we go straight to the first comments:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/n3y2qd1vxw4uldxlrs5bjsewt9vn" alt="Reddit Comment Links"  loading="lazy" /></p>
<p>Either that, or we open the workflow directly in ComfyUI and inspect the Nodes, one by one:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/b6hu6k4ygjpjdcq0chzv6mt1kx9w" alt="Nodes Check"  loading="lazy" /></p>
<p>We may already have a problem: the Node came pre-configured to load a LoRa called &ldquo;SDXL_aidma-niji_jini.safetensors&rdquo; but the link in the Reddit comment downloads an &ldquo;SDXL_Niji_V6_DLC_LoRa_V4.safetensors&rdquo;. Searching specifically for the previous one on Google, I can&rsquo;t find it. But it seems &ldquo;safe&rdquo; to use this other one, at least the names are very similar, both are derived from SDXL so theoretically they&rsquo;re at least compatible. This is the page of that model on Civit AI, so you have to download manually and send it to the <code>models/loras</code> directory, same thing for the AniToon Checkpoint in the Node above. Download manually and move it to <code>models/checkpoints</code>.</p>
<p><a href="https://civitai.com/models/541460/sdxlnijiv6dlclora"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/m8u6e9hc0vyrd41pdru0c26akolr" alt="LoRa Niji"  loading="lazy" /></a></p>
<p>Note that on this page it explains that this LoRa was made to work with the SDXL_Niji_V6 model and not with AniToon, which is what the Workflow I downloaded suggests in its Load Checkpoint Node. It&rsquo;s worth testing with both. Either way, since we&rsquo;re already on the site, let&rsquo;s also download the suggested model:</p>
<p><a href="https://civitai.com/models/120765"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/zrkk8wr9v1z24s5f5fgyv01uui4g" alt="SDXL Niji"  loading="lazy" /></a></p>
<p>File <code>sdxlNijiSeven_sdxlNijiSeven.safetensors</code>, 6.5GB, move it to <code>models/checkpoints</code>, are you all getting the process? Download, move it to the right place, reload the workflow in the browser and now it shows up, we select it:</p>
<p>By the way, I think it&rsquo;s intuitive but for those who haven&rsquo;t figured it out after opening the ComfyUI interface:</p>
<ul>
<li>left mouse button selects Nodes</li>
<li>middle mouse button drags the canvas</li>
<li>scroll wheel zooms the interface</li>
</ul>
<p>Now then, let&rsquo;s pick these two to test:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/m7bzo57hfez23vazar5ct4j5kndq" alt="Nodes Niji"  loading="lazy" /></p>
<p>Taking advantage of this same image, notice in the left corner a Florence2 Node, which is a Microsoft LLM (and my Docker script already installs it for you). I can&rsquo;t show all the related Nodes but these are the main ones explaining what it&rsquo;s for:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/8pjx9pr8x0jsmz7bhouz5zcft1zt" alt="Florence"  loading="lazy" /></p>
<p>The ideal in image generation is to write the longest and most detailed prompt about the original image that you can, but most people just write some garbage like &ldquo;person smiling standing up&rdquo; and think that&rsquo;s enough. The Florence2 model is built to read an image and describe it in text.</p>
<p>Now there&rsquo;s another detail: many checkpoints or Loras are trained with some activation keyword. So they only kick in if you type those words into your prompt. So this workflow doesn&rsquo;t need you to type a prompt manually, Florence2 will do that, and every time its text starts with &ldquo;The image &hellip; bla bla bla&rdquo;, so we have a &ldquo;Text Find and Replace&rdquo; Node that swaps that &ldquo;The Image&rdquo; for the keywords of Anitoon or Niji: &ldquo;aidma-niji, niji&rdquo;.</p>
<p>Are these the right words? <strong>I HAVE NO IDEA</strong> lol</p>
<p>The workflow already brought these words, but on the Civit AI pages - where it should be documented, it&rsquo;s not!! So we&rsquo;re really <strong>GUESSING</strong> that it should be &ldquo;niji&rdquo; since that&rsquo;s the name of the model and the lora. Pay attention to the keyword, it&rsquo;s important, otherwise you add the lora and nothing changes and that&rsquo;s because you forgot it in the prompt!!</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/4i1n0fc3uvvy85nr17x6imee33bh" alt="ControlNet"  loading="lazy" /></p>
<p>There&rsquo;s more, you have to check that the Nodes for things like VAE, ControlNet, Canny and everything else are populated with files that we have. Usually you use the same half-dozen and I&rsquo;ve already pre-installed the main ones in Docker. In the image, see that the ControlNet is <code>contronet-union-sdxl-1.0-promax.safetensors</code>, compatible with SDXL, and we&rsquo;re using Niji which is built on top of SDXL. We just keep guessing like that.</p>
<p>Also notice that it uses Zoe Depth Map to extract the Depth Map and Canny to extract the Edge Map as in the other workflow. You can configure it, in this workflow notice that the Edge Map picked up some weird artifacts in the background, you can adjust it to remove that. That&rsquo;s why in the previous example the final image also had a window appearing, the model thought it was a window! But for now let&rsquo;s leave it as is.</p>
<p>Now we can run it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/29nzuo5t1nr6mn4wss0t77at94no" alt="Run Comfy"  loading="lazy" /></p>
<p>There&rsquo;s a huge &ldquo;RUN&rdquo; button in blue down at the bottom. If everything is configured correctly, you&rsquo;ll see that a job has been added to the queue. The interface is smart enough to let you work on other workflows and just keep queueing jobs while the GPU is processing. You don&rsquo;t have to sit waiting, just leave several queued up and go to sleep.</p>
<p>When it runs, in the interface the Nodes that are processing at any given moment will get a green border, which is good so you know what stage of the process it&rsquo;s in. And if any Node has an error - because a file is missing, for example - it&rsquo;ll get a red border, so you have to fix it and run again.</p>
<p>And in the end, here&rsquo;s one of the results:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/7mbzamhj8c5sc2fesp8kkz6cvjhu" alt="Niji Result"  loading="lazy" /></p>
<p>Notice how the controlnet maps guaranteed the correct pose. The model interpreted those background artifacts from the edge map as lighting from some window, but in the original photo we can see it&rsquo;s like the concrete brick of the wall lol but that&rsquo;s how it is, without knowing, it guesses. And if you run it several times, it&rsquo;ll always give you a quite different result. Look at another one:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/xbxu131ivlqu4endtdf6j5ayxzxt" alt="Niji 2"  loading="lazy" /></p>
<p>Way off lol But we can swap the SDXL-Niji model for Anitoon. Let&rsquo;s see one of the results:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/w7fsjrui29pbp8e98ka4ubuvtl6f" alt="Anitoon"  loading="lazy" /></p>
<p>Switching to the waiNSFIllustrious model:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2un0xtv7g210xrqc74nbceiwg7t4" alt="waiNSFIllustrious"  loading="lazy" /></p>
<p>This one came out even more off. Something in the maps is making the model assume that I have curly hair, no idea why. But that&rsquo;s it: you have to tweak the parameters. The result of the workflow I showed at the beginning of the article gave a better result, using the same models.</p>
<p>Of all the Nodes, one of the most important is the &ldquo;engine&rdquo; of the process, the <strong>KSampler</strong>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/n6875915bhqq39vxw0kfripyyj3d" alt="KSampler"  loading="lazy" /></p>
<p>This is the guy who runs the show. We give it the model, the prompt, control-net outputs, and it&rsquo;s the one that iteratively (steps) does the &ldquo;denoise&rdquo; of the Latent. Notice that there&rsquo;s always a random factor (&ldquo;seed&rdquo;), a number of steps (25 to 50 is the average), &ldquo;cfg&rdquo; (classifier-free guidance) or &ldquo;guidance_scale&rdquo;, where higher values pull the image more toward your positive prompt. Denoise is how much noise the scheduler applies (1.0 is full noise, below 1.0 may produce more &ldquo;artistic&rdquo; results, you have to test).</p>
<p>Sampler Name is annoying because you&rsquo;d need to understand the math behind it, but they are the step algorithms, for example <code>euler</code>, <code>ddim</code>, <code>plms</code> and each one has a trade-off between speed and fidelity. In practice: test them one by one and see the results. Scheduler has <code>normal</code>, <code>klms</code>, <code>dpmsolver</code>. Same thing: test them one by one.</p>
<p>As you can see, there are PLENTY of parameters you can play with. But the main flow is more or less this. From there you have to keep learning Node by Node what each one can do and download lots of pre-made workflows to see how people have been solving various problems, which models and loras are the most popular for each kind of application. Anyway, Reddit is your friend. I hope this gave you some sense of direction, I&rsquo;m also still learning.</p>
]]></content:encoded><category>generative-media</category><category>machine-learning</category><category>tutorials</category></item><item><title>Generating AI Images - even Ghibli style 😂 - with Docker and CUDA</title><link>https://akitaonrails.github.io/en/2025/04/20/generating-ai-images-even-ghibli-style-with-docker-and-cuda/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/20/generating-ai-images-even-ghibli-style-with-docker-and-cuda/</guid><pubDate>Sun, 20 Apr 2025 17:30:00 -0300</pubDate><description>&lt;p&gt;This topic will be split into two posts. This one is purely technical, about how to get it running. In the other one I&amp;rsquo;ll explain what the heck ComfyUI is and roughly how to use it.&lt;/p&gt;
&lt;p&gt;More than a year ago, when I made my last videos about A.I. and on the podcasts, I always said I ran everything on my own machine. But I never detailed how. So today I fixed that with several blog posts and little projects on GitHub with Dockerfiles so you can run it on your own machine, without configuring anything difficult.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This topic will be split into two posts. This one is purely technical, about how to get it running. In the other one I&rsquo;ll explain what the heck ComfyUI is and roughly how to use it.</p>
<p>More than a year ago, when I made my last videos about A.I. and on the podcasts, I always said I ran everything on my own machine. But I never detailed how. So today I fixed that with several blog posts and little projects on GitHub with Dockerfiles so you can run it on your own machine, without configuring anything difficult.</p>
<p>I finally made a <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded"target="_blank" rel="noopener">Dockerfile</a> for ComfyUI, which is the best graphical interface for editing image generation workflows. If you&rsquo;ve ever worked with Davinci Resolve Fusion or Blender Geometry Nodes and the like, workflows are basically node configurations to process your images, audio, video and pipe them through processing steps, not just A.I. ones. It&rsquo;s like programming visually. That&rsquo;s why it&rsquo;s a much more complicated tool than the FramePack I showed in the previous post. You don&rsquo;t just click and go.</p>
<p>Recently everyone went hysterical with the ChatGPT updates and how it <em>&ldquo;OH MY GOD&rdquo;</em> can generate Ghibli anime style images, and they kept spamming the world with idiotic anime-style memes. Online hysteria is really annoying. But as I said, as impressive as it is, that&rsquo;s all it is: a meme generator. You can&rsquo;t use it professionally.</p>
<p>I&rsquo;m tired of trying to upload my own photos, or even generic photos, fictional characters, sometimes drawings of mine, and randomly ChatGPT just refuses to generate the image. Claiming privacy, security, copyright and whatever else it can invent as an excuse. You can&rsquo;t rely on this for work. Anyone who&rsquo;s a professional should have full control over the process, and that&rsquo;s what I&rsquo;m going to teach today.</p>
<p>To start, clone my <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded"target="_blank" rel="noopener">GitHub repository</a> and follow the README instructions or do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git clone https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded.git
cd ComfyUI-Docker-CUDA-preloaded
docker compose build</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Since it&rsquo;s more complex, this time I had to put together a Docker Compose. So to bring it up, after the build finishes, just do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>docker compose up</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now it&rsquo;s going to take a while, because by default it will download almost <strong>500GB</strong> of pre-trained models so it can do almost anything image-related. Yes, to play with A.I. locally you need infrastructure. Otherwise, rent a beefy machine with lots of storage on a RunPod or similar and run it remotely. You can download fewer models, I&rsquo;ll explain that next.</p>
<p>On Docker Compose. I recommend avoiding the &ldquo;-d&rdquo; option to bring it up as a daemon, because it&rsquo;s good to reserve a terminal to keep watching the outputs (you can run <code>docker compose logs --follow</code> if you want, but it&rsquo;s easier directly).</p>
<p>ComfyUI is a more complex program than average, much more so than something like FramePack. It supports installing &ldquo;extensions&rdquo; (plugins, addons, call them what you want). And honestly on its own it doesn&rsquo;t do much, you need to install several extensions for it to start being really useful. Every now and then one of these extensions doesn&rsquo;t handle errors properly and blows up in the console, something like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/n7w5oxun51elrsv2fra46f684xre" alt="Console Error"  loading="lazy" /></p>
<p>It&rsquo;s always useful to keep an eye on the console to find out if it tried to load a file that doesn&rsquo;t exist. Then you can grab the file name and search Google, download it and put it in the right place. Or if it&rsquo;s a really buggy extension that&rsquo;s better off removed from the &ldquo;custom_nodes&rdquo; directory, and so on. Without watching the console, it doesn&rsquo;t show up in the web interface and you&rsquo;ll be left in the dark.</p>
<p>A lot of the models and extensions I based on the <strong>Ultimate ComfyUI version 3</strong> configuration recommended by <strong>Aitrepreneur</strong>, again. Watch <a href="https://youtu.be/q5kpr84uyzc"target="_blank" rel="noopener">this video of his</a> to get all the details and everything that&rsquo;s possible to do. He has the scripts for Linux, Windows, and the workflows in his paid Patreon version and, of course, I won&rsquo;t share the workflows because they&rsquo;re not mine. In theory, my config should be able to do what he teaches.</p>
<p>Aitrepreneur&rsquo;s V3 workflow is really badass. But I can&rsquo;t share it because it&rsquo;s closed material on his Patreon, but here&rsquo;s a screenshot of my ComfyUI with all of his stuff loaded:</p>
<p><a href="https://www.patreon.com/posts/ultimate-all-in-121355089"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/1p3xfs0ecfimbw4yg55qz17buti6" alt="Ultimate V3"  loading="lazy" /></a></p>
<p>But the config itself, I redid it entirely to work in this Docker. You can load any other open workflow you find on a Reddit or wherever.</p>
<p>Here&rsquo;s what I did:</p>
<h2>Models<span class="hx:absolute hx:-mt-20" id="models"></span>
    <a href="#models" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In the project there has to be a &ldquo;models&rdquo; directory, if there isn&rsquo;t one, create it, because docker compose will map it inside the container. When it starts, it&rsquo;ll run the file <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded/blob/master/init_models.sh"target="_blank" rel="noopener">init_models.sh</a>. This script will load a configuration file you can edit later: <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded/blob/master/models.conf"target="_blank" rel="noopener">models.conf</a>. It&rsquo;s huge and a piece of it looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>...
[CONTROLNET]
https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-canny-controlnet-v3.safetensors
https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-depth-controlnet-v3.safetensors
https://huggingface.co/brad-twinkl/controlnet-union-sdxl-1.0-promax/resolve/main/diffusion_pytorch_model.safetensors|controlnet-union-sdxl-1.0-promax.safetensors
https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_canny.pth
https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11f1p_sd15_depth.pth
https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_openpose.pth
https://huggingface.co/Aitrepreneur/FLX/resolve/main/Shakker-LabsFLUX1-dev-ControlNet-Union-Pro.safetensors

[STYLE_MODELS]
https://huggingface.co/Aitrepreneur/FLX/resolve/main/flux1-redux-dev.safetensors

[SAMS]
https://huggingface.co/Aitrepreneur/FLX/resolve/main/sam_vit_b_01ec64.pth</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each section maps to a sub-directory, so &ldquo;[SAMS]&rdquo; will map to &ldquo;models/sams&rdquo; and download the binaries to the right places. There are some special ones in the &ldquo;[GIT_REPOS]&rdquo; section, for those that can be cloned via git, or &ldquo;[CUSTOM]&rdquo; for files you need to download later inside those cloned projects, for example. I don&rsquo;t yet know if it can be simplified, but I picked this up from Aitrepreneur&rsquo;s config.</p>
<p>The ideal is to always keep this list organized with models you find later. If you want to contribute, that would also be cool. The reason is that everything is scattered across all kinds of sites out there, you download manually, then move to the directory inside &ldquo;models&rdquo;. Tomorrow, if you reinstall your machine, how are you supposed to remember all the sites? There are dozens of available models.</p>
<p>In the next post I&rsquo;ll try to summarize what the hell &ldquo;checkpoints&rdquo;, &ldquo;diffusion models&rdquo;, &ldquo;loras&rdquo;, &ldquo;controlnet&rdquo; and all the rest are. But for now, the file extension doesn&rsquo;t say much. &ldquo;.pt&rdquo; for example only means it&rsquo;s a file PyTorch can open, but it doesn&rsquo;t tell you whether it&rsquo;s a clip or a text encoder, for example. Same thing with &ldquo;.safetensors&rdquo;, it can be anything, and ComfyUI needs each file to be in the correct sub-directory in order to use it, you can&rsquo;t just go around moving files randomly.</p>
<p>The &ldquo;init_models.sh&rdquo; script runs every time Docker is restarted and is smart enough to only download URLs that aren&rsquo;t already downloaded in the &ldquo;models&rdquo; directory. That&rsquo;s why it always runs when the container comes up, in the entrypoint. That&rsquo;s one of the advantages of my config: it has automatic maintenance.</p>
<h3>Extensions and Dependencies<span class="hx:absolute hx:-mt-20" id="extensions-and-dependencies"></span>
    <a href="#extensions-and-dependencies" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I made a second script called <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded/blob/master/init_extensions.sh"target="_blank" rel="noopener">init_extensions.sh</a>, which also runs at the container&rsquo;s entrypoint and which you shouldn&rsquo;t touch, but it loads another configuration file called <a href="https://github.com/akitaonrails/ComfyUI-Docker-CUDA-preloaded/blob/master/extensions.conf"target="_blank" rel="noopener">extensions.conf</a> and that one, yes, you can mess with and add new extensions later. A piece of it looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>[EXTENSIONS]
https://github.com/ltdrdata/ComfyUI-Manager.git
https://codeberg.org/Gourieff/comfyui-reactor-node.git
https://github.com/ssitu/ComfyUI_UltimateSDUpscale.git
https://github.com/kijai/ComfyUI-LivePortraitKJ.git
https://github.com/PowerHouseMan/ComfyUI-AdvancedLivePortrait.git
...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Here I declare all the extensions that add new functionality on top of ComfyUI, in particular new <strong>NODES</strong>, which is why they live in the &ldquo;custom_nodes&rdquo; directory, which is a volume external to Docker and therefore persistent, meaning every new extension survives container restarts.</p>
<p>But there&rsquo;s still a BIG catch: <strong>Python Dependencies</strong>. A nightmare.</p>
<p>The Docker image build does a bunch of <code>pip install</code> and since I&rsquo;m using Virtual ENV, everything gets installed in <code>/venv</code> inside the container, as it should.</p>
<p>However, the extensions that get installed outside the build - every time the container restarts - tend to be Python code and also have more dependencies. Extensions are GitHub projects and may be changing right this minute. If they change and you do a <code>git pull</code> to update, you need to run <code>pip install -r requirements.txt</code>. But if you have dozens of extensions, running <code>pip install</code> for each one on every Docker restart takes a long time to do almost nothing. As I said, a nightmare.</p>
<p>My solution - to brag a bit - fixes a good chunk of this maintenance. When the <code>init_extensions.sh</code> script runs, if an extension from the list is already installed, it&rsquo;ll skip it. Then it&rsquo;ll write to the <code>./custom_nodes/.last_commit/*.commit</code> directory the latest commit of each extension. When Docker restarts, it&rsquo;ll do a <code>git fetch</code> and check whether the latest commit hash has changed.</p>
<p>Only if it has changed will it run <code>pip install</code> for that extension that got an update, and skip all the others that haven&rsquo;t changed since the last restart, saving A LOT of time and avoiding having to manually update things. Since I&rsquo;m using VENV, everything will stay persisted in the &ldquo;/venv&rdquo; volume. Yes, it&rsquo;s a VOLUME, because it changes after the build with each extension update.</p>
<p>This way, everything in the image gets copied into that volume, and everything the extensions install on the side also goes into that volume. Every time you restart, it all comes back the way it was. That was the solution I found and so far it seems to work just fine.</p>
<p>But it&rsquo;s only a matter of time until some poorly made extension installs dependencies that break other dependencies, or an actual case I went through: there was an extension that crashes with no error message if you&rsquo;re using Python 3.11, the Zoe Depth Map from the <code>comfyui_controlnet_aux</code> extension, one of the packages that seems to be popular.</p>
<p>I hadn&rsquo;t realized I&rsquo;d started using a Docker base image of ubuntu22 instead of ubuntu24, I was afraid of using something too new and breaking things, but I ended up using something too old and it broke just the same. Managing versioning is a pain. Anyway, I changed the Dockerfile to ubuntu24.</p>
<p>But in the external VENV volume now there were a bunch of dependencies pinned to Python 3.11. No problem, just delete the volume:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>docker compose down # make sure it&#39;s stopped
docker volume rm comfyui-venv
rm ./custom_nodes/.last_commits/*.commit</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And done, the VENV will start over empty, fill up with the correct Python 3.12 dependencies from the image, and when the container starts my <code>init_extensions.sh</code> script won&rsquo;t find &ldquo;.last_commits&rdquo; and will force a <code>pip install -r requirements.txt</code> of all the extensions, now with Python 3.12. Super clean and reliable.</p>
<p>If even so you get an error, then it might be a bug in a specific extension, just go to its directory in &ldquo;./custom_nodes&rdquo; and delete it manually before restarting the container.</p>
<p>I think that&rsquo;s the least complicated thing you can do in this case. This docker wasn&rsquo;t built to run on its own on a server, it&rsquo;s meant to run on your own local machine, where you&rsquo;re monitoring the terminal output anyway.</p>
<p>Oh yes, if a new version of ComfyUI comes out then you have to rebuild the image and delete the VENV volume. The image is static, don&rsquo;t forget. Inside, the Dockerfile does a new git clone and grabs the newest commit. Then it&rsquo;ll install newer python packages, so you need to wipe the VENV volume and have the extensions reinstall their dependencies afterwards too. It&rsquo;s a huge pain, but that&rsquo;s just how it works.</p>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With all that done, with a simple <code>docker compose up</code> I can have a stable ComfyUI environment isolated from the rest of my system, without dirtying anything with thousands of dubious dependencies. When I&rsquo;m done I just do <code>docker compose down</code> or &ldquo;ctrl-c&rdquo; in its console and that&rsquo;s it, my system stays clean.</p>
<p>On top of that, directories like &ldquo;models&rdquo; and their 500GB of models stay separate, you can back them up elsewhere if you need to. And if you want to bring it up on another machine, just take only those directories and bring up Docker Compose there and it has to come up exactly the same way, zero-setup. That was my goal in Dockerizing ComfyUI, FramePack, Video2K: to have everything portable, isolated and clean to bring up on any machine later without having to run sketchy installation scripts directly on my machine.</p>
<p><a href="https://x.com/AkitaOnRails/status/1914030815962411367"target="_blank" rel="noopener">On my X</a> I already showed my ComfyUI working. I loaded some random workflow I found on the Web and look at this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/jhx6v5va7qac4vocp17ku5o6ha51" alt="ComfyUI"  loading="lazy" /></p>
<p>In this workflow I upload a reference image and I can write a prompt for how I want to transform it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20xq72nojtvb887mebeksr6q32cw" alt="Prompt"  loading="lazy" /></p>
<p>With ComfyUI I have control over every step of the process, for example, this workflow can separate things like Depth Map and Edge Contour from the original image, to use as context before making the final image, ensuring a much more precise result:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/4r1en3a557znzmy0zojk8ei9tflu" alt="Depth Map"  loading="lazy" /></p>
<p>And here&rsquo;s a result I already got using the &ldquo;waiNSFIllustrious&rdquo; model that this workflow suggests, which is a model trained to generate anime-style images (there are dozens, for various different styles).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/m7amy8gmp5y4snztatjjlwlr0wye" alt="anime"  loading="lazy" /></p>
<p>Switching the model to <code>Mistoon</code>, based on Stable Diffusion (SDXL), here&rsquo;s another result, with the same prompt, in the same workflow:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/lkj86wdlvh5fy8h78a8ml5io8t6g" alt="anime 2"  loading="lazy" /></p>
<p>And I can keep tuning dozens of parameters inside this workflow, swap vaes, text encoders, controlnets and much more until I get exactly the result I want. That&rsquo;s the power of ComfyUI and its Custom Nodes and Workflows. That&rsquo;s what I&rsquo;ll try to explain the basics of in the next post.</p>
]]></content:encoded><category>generative-media</category><category>containers</category><category>tutorials</category></item><item><title>Generating Up to 2-Minute Videos from a Photo with A.I.</title><link>https://akitaonrails.github.io/en/2025/04/19/generating-up-to-2-minute-videos-from-a-photo-with-ai/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/19/generating-up-to-2-minute-videos-from-a-photo-with-ai/</guid><pubDate>Sat, 19 Apr 2025 20:00:00 -0300</pubDate><description>&lt;p&gt;By far the best channel for tutorials on open source A.I. tools is &lt;a href="https://www.youtube.com/watch?v=GywyMij88rY"target="_blank" rel="noopener"&gt;Aitrepreneur&lt;/a&gt;. And in this link he introduces a new tool called &lt;a href="https://github.com/lllyasviel/FramePack"target="_blank" rel="noopener"&gt;FramePack&lt;/a&gt;, which uses Tencent&amp;rsquo;s famous &lt;a href="https://github.com/Tencent/HunyuanVideo"target="_blank" rel="noopener"&gt;HunyuanVideo&lt;/a&gt; model to take a single image, a photo, and generate excellent quality videos up to 2 minutes long. It&amp;rsquo;s truly impressive! Watch the video, but right below is a 10-second clip generated from a photo I took of one of the action figures in my collection.&lt;/p&gt;</description><content:encoded><![CDATA[<p>By far the best channel for tutorials on open source A.I. tools is <a href="https://www.youtube.com/watch?v=GywyMij88rY"target="_blank" rel="noopener">Aitrepreneur</a>. And in this link he introduces a new tool called <a href="https://github.com/lllyasviel/FramePack"target="_blank" rel="noopener">FramePack</a>, which uses Tencent&rsquo;s famous <a href="https://github.com/Tencent/HunyuanVideo"target="_blank" rel="noopener">HunyuanVideo</a> model to take a single image, a photo, and generate excellent quality videos up to 2 minutes long. It&rsquo;s truly impressive! Watch the video, but right below is a 10-second clip generated from a photo I took of one of the action figures in my collection.</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/link-animado.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/link-animado.mp4)
    </video>
</div>
<p>Since I was already on a roll packaging this kind of tool in Docker, I decided to make another little project with a Dockerfile to run on my Manjaro Linux with an RTX 4090. The good thing about Aitrepreneur is that he has a Patreon you can subscribe to in order to contribute (it&rsquo;s cheap) and he always uploads scripts to run on <a href="https://www.runpod.io/"target="_blank" rel="noopener">RunPod</a> in case you don&rsquo;t have a beefy machine or GPU, or .BAT scripts to run on your Windows.</p>
<p>My little project is <a href="https://github.com/akitaonrails/FramePack-Docker-CUDA"target="_blank" rel="noopener">on this GitHub</a> and using it is very simple:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>
git clone https://github.com/akitaonrails/FramePack-Docker-CUDA.git
cd FramePack-Docker-CUDA
mkdir outputs
mkdir hf_download

# Build the image
docker build -t framepack-torch26-cu124:latest .

# Run mapping the directories outside:
docker run -it --rm --gpus all -p 7860:7860 \
  -v ./outputs:/app/outputs \
  -v ./hf_download:/app/hf_download \
  framepack-torch26-cu124:latest</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This will build the Docker image and run it. And the first time you can see it will download a TON of models, HunyuanVideo, Flux and more. Get ready for over 30GB. But it&rsquo;s only the first time because I map the download directory outside, so if you restart the container it will already have everything next time.</p>
<p>When it&rsquo;s done, just go to <strong>http://localhost:7860</strong> and you&rsquo;ll see this super simple interface:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/x5nwxu25vlb9ox5kda5xiz6b4fwf" alt="FramePack Web UI"  loading="lazy" /></p>
<p>The controls are very simple:</p>
<ul>
<li>Disable the &ldquo;Use TeaCache&rdquo; option which makes it faster but then you risk getting those A.I. defects like hands with extra fingers and stuff like that.</li>
<li>Total Video length can go up to 2 minutes, but only do that if you really have patience because it takes A LOT of time! I think it&rsquo;s better to start by testing with short 5 to 10 second videos first.</li>
<li>If your GPU has little VRAM you&rsquo;ll be forced to mess with &ldquo;GPU Inference Preserved Memory (GB) (larger means slower)&rdquo;. In my case with 24GB of VRAM I left it as is, but look how it consumes resources while running:</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/5purckhhytuqd7bwp2wjqhyxq66p" alt="btop"  loading="lazy" /></p>
<p>Yes! 100% of the GPU and almost 100% of the VRAM, pulling more than 350W from the wall! (screw the environment! lol) The CPU stays chill, because this process is made to really MASSACRE the GPU. If you don&rsquo;t have a good GPU, rent a machine on RunPod like I said before.</p>
<p>The 10-second video at the start of the post is a photo of one of my favorite action figures in my collection, Link from Breath of the Wild. And this is something that got me excited. This is the original photo that served as the basis for FramePack to make the video:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/g3dm9u1ij8r6gl0dk90qool0htyw" alt="Link Foto"  loading="lazy" /></p>
<p>Making videos of photos of people is dull and tedious, but now that I&rsquo;m learning to model 3D to print on my Bambulab, imagine then being able to make it animated too?</p>
<p>To animate in Blender and render also takes a lot of processing and time. But with this I can quickly prototype an animation and see how it looks, to decide if it&rsquo;s worth making a better version in Blender afterwards. It opens up many possibilities for experimentation!</p>
<p>As impressive as this seems, it doesn&rsquo;t do <strong>JUST ANYTHING</strong>, I had several experiments that went wrong and the movement it makes is quite limited, after all it only has a single photo to use as a reference and you can&rsquo;t make movements that are too abrupt, there are limits. Even so it&rsquo;s very fun to play with. The fact that it can identify more than just human beings is a big &ldquo;plus&rdquo; in my opinion, because that&rsquo;s what got me more interested.</p>
<p><strong>OH YES!</strong> Remember the <a href="https://www.akitaonrails.com/en/2025/04/19/aumentando-resolucao-de-anime-velho-pra-4k-com-i-a"target="_blank" rel="noopener">other blog post</a> I published today about doing <strong>UPSCALE TO 4K</strong>?? You can use it here too, take a video that FramePack generated that you really liked and upscale it to 4K to have even more definition! Then it&rsquo;s at quality you can even use in video editing.</p>
<p>2-minute videos must take hours to make. 30 seconds took almost 1 hour. The best thing is to stick with 5-second videos to play and experiment, just that already takes nearly half an hour. Here are some more examples taken from photos of items in my collection (the ones you used to see in the background of my videos). Have fun!</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/ultraseven-animado.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/ultraseven-animado.mp4)
    </video>
</div>
<p>This is an Ultraseven over 30cm tall that I found and brought directly from a used goods shop in Tokyo last year, one of my favorites especially because I watched it a lot in the early 80s when I was a kid.</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/jiraya-animado.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/jiraya-animado.mp4)
    </video>
</div>
<p>Speaking of nostalgia, this is another awesome item in my collection, a super detailed Jiraya made by Iron Studios. And animated it actually turned out pretty cool. What did you think?</p>
<p>But now my <strong>FAVORITE</strong>, those who follow me on <a href="https://www.instagram.com/p/DIAe3pNOFgffFZ5J32fWlc-8Kmy3Lhap5Vm58U0/?img_index=1"target="_blank" rel="noopener">Instagram</a> have seen that for the past few weeks I&rsquo;ve been dedicating myself to improving my 3D techniques and also drawing and I made a concept of Mandalorian + Judge Dredd:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/01qz4daelmm671m5ytgua036373h" alt="Boba Dredd"  loading="lazy" />.png?disposition=attachment&amp;locale=en)</p>
<p>Check out my other drawings on my Instagram afterwards, but anyway, I needed to know if FramePack was capable of animating drawings too and YES, IT CAN!! LOOK HOW B4D4SS!!! This one really impressed me, it gives a new dimension to my stuff!</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/boba-dredd-animado_upscaled.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/boba-dredd-animado_upscaled.mp4)
    </video>
</div>
<p>Yes, all of this is quite heavy, but the advantage is that I can experiment INFINITELY, because everything runs locally. On someone&rsquo;s commercial product in the &ldquo;cloud&rdquo;, I&rsquo;d have to pay some subscription and they&rsquo;d limit me, it would be impossible to keep experimenting whenever I wanted. Now I can do everything I want, whenever I want, however I want, without anyone or anything bothering me.</p>
<p>By the way, if you can, watch that &ldquo;Boba Dredd&rdquo; clip in full screen on a big monitor. Did you notice how sharp it is? That&rsquo;s because I ran it through Video2K and upscaled to 4x the resolution that FramePack gave me. Combining these tools you can do REALLY interesting things, let me know later if you managed to use it and what cool stuff you made!</p>
]]></content:encoded><category>generative-media</category><category>containers</category></item><item><title>Upscaling Old Anime to 4K with AI</title><link>https://akitaonrails.github.io/en/2025/04/19/upscaling-old-anime-to-4k-with-ai/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/19/upscaling-old-anime-to-4k-with-ai/</guid><pubDate>Sat, 19 Apr 2025 15:30:00 -0300</pubDate><description>&lt;p&gt;More than a year ago I was playing around with ErsGAN, generative adversarial networks for the task of &amp;ldquo;upscaling&amp;rdquo; (raising resolution) old anime files I have.&lt;/p&gt;
&lt;p&gt;For collectors, the problem is that a lot of anime from the 90s and earlier never came out, and never will, on Blu-Ray (1080p) or UHD (4K). Only the most famous ones get the &amp;ldquo;remaster&amp;rdquo; treatment (taking the original master tapes and recapturing them at higher resolution). So a ton of old anime is stuck in the DVD era (480p) or VHS (480i).&lt;/p&gt;</description><content:encoded><![CDATA[<p>More than a year ago I was playing around with ErsGAN, generative adversarial networks for the task of &ldquo;upscaling&rdquo; (raising resolution) old anime files I have.</p>
<p>For collectors, the problem is that a lot of anime from the 90s and earlier never came out, and never will, on Blu-Ray (1080p) or UHD (4K). Only the most famous ones get the &ldquo;remaster&rdquo; treatment (taking the original master tapes and recapturing them at higher resolution). So a ton of old anime is stuck in the DVD era (480p) or VHS (480i).</p>
<p>Not every old anime can be upscaled, it can end up looking too &ldquo;washed out&rdquo;. Anyone who downloads torrents has probably seen badly done upscales. But I wanted to be able to test it myself, on my local machine, with my old files. Here&rsquo;s a small example clip:</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_old_clip1.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_old_clip1.mp4)
    </video>
</div>
<p>It&rsquo;s a piece of the 1st episode of the legendary Rurouni Kenshin from 1996. Yes, today we have a remake, which they&rsquo;re redoing from scratch. But for those of us who feel nostalgic about the original, there&rsquo;s no replacement. The DVD quality isn&rsquo;t horrendous, you can still watch it just fine today as is. But I&rsquo;m curious to see how it looks in 4K.</p>
<p>I didn&rsquo;t do any long research, but a year ago I had stumbled onto <a href="https://github.com/xinntao/Real-ESRGAN"target="_blank" rel="noopener"><strong>Real ERSGAN</strong></a>. That project has been discontinued for about 4 years, so I don&rsquo;t know what the most modern option is today. But their pretrained models are still useful.</p>
<p>There are still projects that use this model, the one I know of is <a href="https://github.com/k4yt3x/video2x"target="_blank" rel="noopener"><strong>Video2K</strong></a>. On Windows it apparently even has a nice little graphical interface, but the advantage for Linux is that it runs in Docker. An example would be like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>docker run --rm --gpus all \
  -v &#34;$PWD/videos_in&#34;:/input \
  -v &#34;$PWD/videos_out&#34;:/output \
  ghcr.io/k4yt3x/video2x:6.4.0 \
  -i /input/old_anime.mp4 -o /output/old_anime_4K.mkv \
  -p realesrgan -s 4 --realesrgan-model realesr-animevideov3</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you have your NVIDIA set up with the Container Toolkit, CUDA and all that (ask ChatGPT), Docker can map the device straight into the container. This makes it much easier to keep my Linux from getting dirty with dependencies and loose configs scattered around. I don&rsquo;t do anything of this kind outside of containers anymore.</p>
<p>But I ran into a problem. If you&rsquo;re using an AMD GPU, just doing this should already work, because this Docker image actually uses Vulkan and not CUDA. In my case I have a primary integrated AMD GPU and a secondary NVIDIA RTX 4090. I tried passing it directly:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ docker run --gpus all --privileged \
  --device=/dev/nvidia0 \
  --device=/dev/nvidiactl \
  --device=/dev/nvidia-uvm \
  --device=/dev/nvidia-modeset \
  --runtime=nvidia \
  -e VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/nvidia_icd.json \
  -e VK_LAYER_PATH=/usr/share/vulkan/explicit_layer.d \
  -v &#34;$HOME/Downloads/Video2K/videos_in&#34;:/input \
  -v &#34;$HOME/Downloads/Video2K/videos_out&#34;:/output \
  --rm -it \
  ghcr.io/k4yt3x/video2x:6.4.0 \
  -i &#34;/input/(B-A)Rurouni_Kenshin_-_01_(A5D3CB57).mkv&#34; \
  -o &#34;/output/(B-A)Rurouni_Kenshin_-_01_(A5D3CB57)_upscaled.mkv&#34; \
  -p realesrgan -s 4 --realesrgan-model realesr-animevideov3</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>But it just won&rsquo;t work. If I only say &ldquo;&ndash;gpus all&rdquo; it grabs the iGPU by default. Upscaling a video ends up consuming 100% of my CPU, all 32 threads, but my NVIDIA GPU just sits there watching, doing nothing. If I try to pass &ldquo;/dev/dri/card0&rdquo; or &ldquo;/dev/nvidia0&rdquo;, it errors out and can&rsquo;t even start.</p>
<p>So from here on the solution is only for those who have NVIDIA:</p>
<h3>CUDA Dockerfile<span class="hx:absolute hx:-mt-20" id="cuda-dockerfile"></span>
    <a href="#cuda-dockerfile" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This time I decided not to be lazy and organize everything into a little project on Github. Clone it directly from there <a href="https://github.com/akitaonrails/Batch-Anime-Upscaler-Video2K-Docker-CUDA"target="_blank" rel="noopener">Batch Anime Upscaler Video2K Docker CUDA</a>. Just do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git clone git@github.com:akitaonrails/Batch-Anime-Upscaler-Video2K-Docker-CUDA.git
cd Batch-Anime-Upscaler-Video2K-Docker-CUDA
mkdir input # put your old videos here
mkdir output

docker build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t anime-upscaler:latest .</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If nothing goes wrong, this will build a Docker image with everything you need, then it&rsquo;s just a matter of placing your videos in the subdirectory I mentioned, or any other you want, and configuring the run command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code> docker run --gpus all --rm \
  -v &#34;./input&#34;:/input \
  -v &#34;./output&#34;:/output \
  -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) \
  anime-upscaler:latest</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And that&rsquo;s it. It takes a while. With my Ryzen 7950X3D plus RTX 4090, it processes at a rate of around 13 frames per second. So less than half the playback speed. If a video is 20 minutes long, it&rsquo;ll take more than 40 minutes to process each one. It&rsquo;s a good idea to set aside plenty of time, and that&rsquo;s also why this is best reserved for videos you really want to watch.</p>
<p>The original Video2K script supports several parameters, which I left exposed as environment variables in the Dockerfile:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>MODEL=&#34;${MODEL:-realesr-animevideov3}&#34;
SCALE=&#34;${SCALE:-4}&#34;
TILE=&#34;${TILE:-0}&#34;
DENOISE=&#34;${DENOISE:-1.0}&#34;
NUM_PROC=&#34;${NUM_PROC:-1}&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then you can change them like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>docker run --gpus all --rm \
  -e DENOISE=0.5 \
  -v ... \
  anime-upscaler:latest</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As you can see, by default we use the pretrained model &ldquo;realesr-animevideov3&rdquo;, which is less detailed than the 6 billion parameter model but is much more stable between frames, which matters more for video. Most of the other models it supports, like &ldquo;RealESRGAN_x4plus&rdquo; or &ldquo;ESRGAN_x4&rdquo;, are meant for photos and images and not for video. Video demands temporal coherence, which is quite hard.</p>
<p>At most it&rsquo;s worth trying the &ldquo;realesr-general-x4v3&rdquo; model, which they say is a bit lighter, in case your machine is struggling too much to run the default. But then you&rsquo;ll have to modify the Dockerfile to download that model and rebuild:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># === OPTIONAL: Preload additional Real-ESRGAN models ===
# Uncomment any models you want available in the container at build time

# Anime 6-block model (better for anime stills, line art)
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/RealESRGAN_x4plus_anime_6B.pth \
#     -O /opt/Real-ESRGAN/weights/RealESRGAN_x4plus_anime_6B.pth

# Original RealESRGAN 4x general-purpose model
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/RealESRGAN_x4plus.pth \
#     -O /opt/Real-ESRGAN/weights/RealESRGAN_x4plus.pth

# RealESRGAN 2x model (lighter upscale)
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/RealESRGAN_x2plus.pth \
#     -O /opt/Real-ESRGAN/weights/RealESRGAN_x2plus.pth

# ESRNet (non-GAN model, ultra-smooth)
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/RealESRNet_x4plus.pth \
#     -O /opt/Real-ESRGAN/weights/RealESRNet_x4plus.pth

# General-purpose lightweight video-friendly model (with denoise control)
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.3.0/realesr-general-x4v3.pth \
#     -O /opt/Real-ESRGAN/weights/realesr-general-x4v3.pth

# Anime video model (default in your setup)
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth \
#     -O /opt/Real-ESRGAN/weights/realesr-animevideov3.pth

# Original ESRGAN (2018) model
# RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/ESRGAN_x4.pth \
#     -O /opt/Real-ESRGAN/weights/ESRGAN_x4.pth</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I haven&rsquo;t tested all of them, but for anyone who wants to, I think those are the links. Change it in the Dockerfile, run the build, change the MODEL variable, and test on your videos.</p>
<p>What&rsquo;s worth tweaking are things like DENOISE, which is set to &ldquo;1.0&rdquo;, the maximum, but that can leave the image too &ldquo;stretched&rdquo;. It&rsquo;s worth trying smaller values, like &ldquo;0.5&rdquo;. It varies case by case, only by testing.</p>
<p>Another factor is the scale, SCALE, where the default is &ldquo;4x&rdquo;, which turns 1080p into 4K or 720p into 1440p. But sometimes you don&rsquo;t need to go that high, and there&rsquo;s no point because the original image already has very little detail anyway. Maybe 2x is enough. The script supports 2x, 3x, 4x but it caps at 4K resolution, so there&rsquo;s no point trying to turn a video that&rsquo;s already 4K into 8K, for example.</p>
<p>My RTX 4090 and my Ryzen 9 are quite powerful. Processing just one video, I don&rsquo;t see them breaking a sweat at all:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2dz1abq9b1wteho3r1onowmij14v" alt="btop"  loading="lazy" /></p>
<p>Clearly you could run more videos in parallel. You have to be careful when it&rsquo;s unbalanced. If the CPU climbs close to 100% just to separate the frames, having spare GPU capacity doesn&rsquo;t help. With both having headroom, you can make the script spin up more than 1 process on the GPU and send frames in parallel with the &ldquo;-e NUM_PROC=2&rdquo; option. Look at how it now uses more of the GPU and the CPU still has room to spare. Maybe 3 is the maximum, but you have to test, it&rsquo;s not constant the whole time.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qur7lw4ox5s3nymh025u6q446831" alt="btop x2"  loading="lazy" /></p>
<p>This can dramatically reduce the total time. You can choose between increasing the GPU processes via the script or having two separate video directories and spinning up two parallel containers of this program to run at the same time, whichever works best for you.</p>
<h2>Final Result<span class="hx:absolute hx:-mt-20" id="final-result"></span>
    <a href="#final-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s see how that first clip turned out with 4x upscale and 1.0 denoise:</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_clip1_upscaled_jp.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_clip1_upscaled_jp.mp4)
    </video>
</div>
<p>The difference is HUGE, but whether it ended up &ldquo;GOOD&rdquo; is going to be VERY subjective. Personally I didn&rsquo;t find the original bad to watch. Maybe tweaking DENOISE down a bit, or SCALE to 2x instead of 4x, would already be good enough, I don&rsquo;t know.</p>
<p>For comparison, here&rsquo;s another clip in the original old version:</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_old_clip2.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_old_clip2.mp4)
    </video>
</div>
<p>And again, how it looks after the 4x upscale:</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_clip2_upscaled_jp.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_clip2_upscaled_jp.mp4)
    </video>
</div>
<p>At least it&rsquo;s working as it should, from here on it&rsquo;s a matter of doing some tuning. If anyone has more tips or knows of newer open source upscaling models or projects, send them in the comments below!</p>
<p>Before someone asks, why not use NVIDIA&rsquo;s DLSS 3, which they use to upscale game resolution? Answer: because they don&rsquo;t expose that functionality as an API you can access, it&rsquo;s only for their proprietary drivers in game profiles. They turn it on and tune it case by case. There&rsquo;s no general API in the CUDA lib.</p>
<h2>Post-publication Update<span class="hx:absolute hx:-mt-20" id="post-publication-update"></span>
    <a href="#post-publication-update" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I decided to test the RealERSGAN_x4plus_anime_6B model, which is much heavier than the realesr-animevideov3 I uploaded to the repository.</p>
<p>Change the Dockerfile and add this after the wget for the other model:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>RUN wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth \
     -O /opt/Real-ESRGAN/weights/RealESRGAN_x4plus_anime_6B.pth</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This model can&rsquo;t be run in parallel even on my RTX 4090, which stays constantly at 100%. If before it ran at around 13 frames per second, with this one it only runs at 3 frames per second. It&rsquo;s 4x heavier and only for really beefy GPUs. If you don&rsquo;t have something like that, stick with &ldquo;realesr-animevideov3&rdquo;, or with the even lighter (lower quality) &ldquo;realesr-general-x4v3&rdquo;. These models depend heavily on GPU power and especially on the amount of VRAM.</p>
<p>To run it, add &ldquo;-e MODEL=RealESRGAN_x4plus_anime_6B&rdquo; to the &ldquo;docker run&rdquo; command.</p>
<p>See how the same clip from before turned out using this model:</p>
<div class="video-container">
    <video controls>
        <source src="https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_upscaled_clip1_x4plus.mp4">
        Your browser does not support the video tag. [Direct Link](https://akitaonrails-videos.s3.us-east-2.amazonaws.com/kenshin_upscaled_clip1_x4plus.mp4)
    </video>
</div>
<p>For having spent 4x more of my machine&rsquo;s resources to process it, I didn&rsquo;t find the final result that different, and in some parts I think it even has more defects. So that&rsquo;s it, the result will vary, you have to test. It depends a lot on the quality of the original anime, but just because a bigger and theoretically &ldquo;better&rdquo; model exists doesn&rsquo;t mean every result will turn out better.</p>
]]></content:encoded><category>generative-media</category><category>performance</category><category>containers</category></item><item><title>Colorizing Black and White Images with A.I.</title><link>https://akitaonrails.github.io/en/2025/04/19/colorizing-black-and-white-images-with-ai/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/19/colorizing-black-and-white-images-with-ai/</guid><pubDate>Sat, 19 Apr 2025 01:40:00 -0300</pubDate><description>&lt;p&gt;My girlfriend gave me a challenge today: she had old black and white photos and wanted to know if I could colorize them. If you go searching the Web you bump into a few sites for this, like &lt;a href="https://palette.fm/"target="_blank" rel="noopener"&gt;Palette&lt;/a&gt;. But it is paid, it is not cheap, and I do not think it can do something she wanted: use another colored image as a reference to pull the colors instead of letting the model guess.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My girlfriend gave me a challenge today: she had old black and white photos and wanted to know if I could colorize them. If you go searching the Web you bump into a few sites for this, like <a href="https://palette.fm/"target="_blank" rel="noopener">Palette</a>. But it is paid, it is not cheap, and I do not think it can do something she wanted: use another colored image as a reference to pull the colors instead of letting the model guess.</p>
<p>I went poking around GitHub and there is an <a href="https://github.com/MarkMoHR/Awesome-Image-Colorization"target="_blank" rel="noopener">Awesome Image Colorization</a> page with a bunch of research papers. Very useful for researchers but totally useless for me, since I am not going to build one from scratch lol. There were links to projects like ChromaGAN, but that one has been discontinued for about 5 years. And several other projects I saw were also discontinued ages ago. I do not know why there is so little open source still actively maintained.</p>
<p>Luckily I bumped into a promising one: <a href="https://github.com/piddnad/DDColor"target="_blank" rel="noopener">DDColor</a>.</p>
<p>Let&rsquo;s clone the repo. And to keep going, I prefer to do everything inside Docker. Downloading Python dependencies always messes up my system and leaves a bunch of garbage behind, so the best thing is to isolate it all. Another thing, you need to download the binaries of the pre-trained models. Just for that I will use a bit of Python because of the &ldquo;modelscope&rdquo; lib that handles it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/piddnad/DDColor.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> DDColor
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">mkdir modelscope
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># create a new venv inside the DDColor project</span>
</span></span><span class="line"><span class="cl">python -m venv venv
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">venv/bin/pip3 install modelscope
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">venv/bin/python3 -c <span class="s2">&#34;from modelscope.hub.snapshot_download import snapshot_download; snapshot_download(&#39;damo/cv_ddcolor_image-colorization&#39;, cache_dir=&#39;./modelscope&#39;)&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Good Python practice is to do everything inside a VENV, right? Anyway, this should download the model binary, which weighs almost 900MB. Not huge.</p>
<p>Now we need a Dockerfile:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># Use NVIDIA CUDA base image with cuDNN 8 and Python support
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04

# Install Python and system dependencies
RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    python3 python3-pip git libgl1 libglib2.0-0 &amp;&amp; \
    apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Copy DDColor source code into the image
COPY . /app

# Install PyTorch (CUDA 11.8 compatible) and matching torchvision/torchaudio
RUN pip3 install torch==2.2.0 torchvision==0.17.0 torchaudio==2.2.0 \
    --index-url https://download.pytorch.org/whl/cu118

# Install Python dependencies, skipping dlib (not needed for inference)
RUN sed &#39;/dlib/d&#39; requirements.txt &gt; temp-req.txt &amp;&amp; pip3 install -r temp-req.txt &amp;&amp; rm temp-req.txt

# Optional: install modelscope if you want to run snapshot_download manually inside the container
RUN pip3 install modelscope</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Too lazy to open a pull request for this, if anyone wants to, send it over there.</p>
<p>Now we need to put a black and white image somewhere:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>mkdir input
mkdir results
mv ~/Downloads/bw.jpg input/</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Since my girlfriend&rsquo;s photos are family and private, obviously I am not going to share them, so I grabbed a random one from Google Images:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qgt3lynkp1f5uzkbysyvio2kmq04" alt="B&W Photo"  loading="lazy" /></p>
<p>Now we drop it in &ldquo;./input&rdquo; and run Docker with this command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>docker run --rm --gpus all \
  -v &#34;$PWD/input&#34;:/workspace/input_images:ro \
  -v &#34;$PWD/results&#34;:/workspace/results:rw \
  -v &#34;$PWD/modelscope&#34;:/app/modelscope:ro \
  ddcolor:latest \
  python3 infer.py \
    --model_path /app/modelscope/damo/cv_ddcolor_image-colorization/pytorch_model.pt \
    --input /workspace/input_images \
    --output /workspace/results</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Pay attention to the directory mapping. And note the &ldquo;&ndash;gpus&rdquo; option, which only works in my case because I have an RTX 4090 installed here. I have no idea if it is the same with AMD, but since the image is based on cuDNN/CUDA, I would say no. Ask ChatGPT.</p>
<p>If everything is right and it runs successfully, it will take just 1 second and produce this output in the terminal:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>==========
== CUDA ==
==========

CUDA Version 11.8.0

Container image Copyright (c) 2016-2023, NVIDIA CORPORATION &amp; AFFILIATES. All rights reserved.

This container image and its contents are governed by the NVIDIA Deep Learning Container License.
By pulling and using the container, you accept the terms and conditions of this license:
https://developer.nvidia.com/ngc/nvidia-deep-learning-container-license

A copy of this license is made available in this container at /NGC-DL-CONTAINER-LICENSE for your convenience.

Output path: /workspace/results
100%|██████████| 1/1 [00:00&lt;00:00,  4.09it/s]</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And here is the result that shows up in the <code>./results</code> directory:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/o2r085pzzpsavbyr39btg1vq0hwp" alt="Re-colorized photo"  loading="lazy" /></p>
<p>Yes, I was impressed. Even with the image being from the Web, with watermark and all, it did everything properly and worked!!</p>
<p>The only problem left is being able to use a reference image to try to pull the colors. That is a harder problem and I asked ChatGPT, which answered me this:</p>
<p><strong>No native support for a reference image:</strong></p>
<p>The DDColor model, both in the official version hosted on ModelScope and in the <code>piddnad/DDColor</code> GitHub repo, was designed only for <strong>automatic colorization of black and white images</strong>, with no option to accept a reference image as input. The official description itself makes it clear that the model takes <strong>a single B&amp;W image</strong> and returns a colored image.</p>
<p>The model&rsquo;s <code>forward</code> method expects <strong>only one tensor</strong>, the B&amp;W image converted into artificial RGB (actually the luminance expanded into 3 channels). In other words, <strong>there is no place in the model to pass a second image as a color reference</strong>.</p>
<p>This is also evident in the original <code>predict.py</code> from the Cog library, which only accepts the <code>image</code> parameter as input — with no support for <code>reference</code>, <code>ref_image</code> or anything of the kind.</p>
<h3>How to solve the problem: two possible approaches<span class="hx:absolute hx:-mt-20" id="how-to-solve-the-problem-two-possible-approaches"></span>
    <a href="#how-to-solve-the-problem-two-possible-approaches" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Since DDColor does not natively support a reference image to guide the colors, we have two alternatives:</p>
<h3>🔁 1. Use a model specialized in reference-based colorization<span class="hx:absolute hx:-mt-20" id="-1-use-a-model-specialized-in-reference-based-colorization"></span>
    <a href="#-1-use-a-model-specialized-in-reference-based-colorization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>There are models developed specifically for this, such as:</p>
<ul>
<li>
<p><strong>Deep Exemplar-based Colorization</strong> (Zhang et al., CVPR 2018)</p>
</li>
<li>
<p><strong>ChromaGAN</strong> (2019)</p>
</li>
<li>
<p><strong>Exemplar-Transformer Colorization</strong> (Yin et al., ACM MM 2021)</p>
</li>
<li>
<p><strong>UniColor</strong> (Huang et al., 2022)</p>
</li>
</ul>
<p>These models accept <strong>two images</strong>:</p>
<ul>
<li>
<p>The B&amp;W image (main input)</p>
</li>
<li>
<p>A colored image as a <strong>color reference</strong></p>
</li>
</ul>
<p>These models map semantic similarities (e.g., &ldquo;skin here should have the same skin color as over there&rdquo;) and do a transfer <strong>based on the image content</strong>, getting much more detailed and specific results. However, this would require:</p>
<ul>
<li>
<p>Downloading different weights</p>
</li>
<li>
<p>Completely changing the architecture used</p>
</li>
<li>
<p>Adapting the pipeline and inference</p>
</li>
</ul>
<h3>🎯 2. Use global color transfer after the DDColor output (our current approach)<span class="hx:absolute hx:-mt-20" id="-2-use-global-color-transfer-after-the-ddcolor-output-our-current-approach"></span>
    <a href="#-2-use-global-color-transfer-after-the-ddcolor-output-our-current-approach" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The practical and lightweight solution we used was:
➡️ Run DDColor normally to generate the colored image automatically
➡️ Then <strong>globally</strong> adjust the colors of the result to look more like the reference image.</p>
<p>This adjustment is done with the <strong>Reinhard et al. (2001)</strong> algorithm:</p>
<ol>
<li>
<p>We convert both the output image and the reference to the Lab color space.</p>
</li>
<li>
<p>We compute the <strong>mean and standard deviation</strong> of the <code>a</code> and <code>b</code> channels (the ones that represent color).</p>
</li>
<li>
<p>We adjust the result to have <strong>the same statistical distribution</strong> of color as the reference image.</p>
</li>
<li>
<p>This tends to leave the image with the same overall &ldquo;mood&rdquo; or tone (warmer, cooler, more vibrant).</p>
</li>
</ol>
<p>This method is fast, lightweight, and works <strong>without altering the DDColor model</strong>.</p>
<h2>Continuing<span class="hx:absolute hx:-mt-20" id="continuing"></span>
    <a href="#continuing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I used the modification it suggested to the predict.py file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">cv2</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="nn">np</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">torch</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">torch.nn.functional</span> <span class="k">as</span> <span class="nn">F</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">cog</span> <span class="kn">import</span> <span class="n">BasePredictor</span><span class="p">,</span> <span class="n">Input</span><span class="p">,</span> <span class="n">Path</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">basicsr.archs.ddcolor_arch</span> <span class="kn">import</span> <span class="n">DDColor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">color_transfer</span><span class="p">(</span><span class="n">reference_bgr</span><span class="p">:</span> <span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">,</span> <span class="n">target_bgr</span><span class="p">:</span> <span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Transfer color palette of reference image to target image using Lab mean/std.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Convert BGR images to Lab color space (float32 for precision)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ref_lab</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">reference_bgr</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_BGR2LAB</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;float32&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">tgt_lab</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">target_bgr</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_BGR2LAB</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;float32&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Split channels</span>
</span></span><span class="line"><span class="cl">    <span class="n">L_ref</span><span class="p">,</span> <span class="n">a_ref</span><span class="p">,</span> <span class="n">b_ref</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">ref_lab</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">L_tgt</span><span class="p">,</span> <span class="n">a_tgt</span><span class="p">,</span> <span class="n">b_tgt</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">tgt_lab</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Compute mean and std for reference and target (a and b channels)</span>
</span></span><span class="line"><span class="cl">    <span class="n">a_ref_mean</span><span class="p">,</span> <span class="n">a_ref_std</span> <span class="o">=</span> <span class="n">a_ref</span><span class="o">.</span><span class="n">mean</span><span class="p">(),</span> <span class="n">a_ref</span><span class="o">.</span><span class="n">std</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">b_ref_mean</span><span class="p">,</span> <span class="n">b_ref_std</span> <span class="o">=</span> <span class="n">b_ref</span><span class="o">.</span><span class="n">mean</span><span class="p">(),</span> <span class="n">b_ref</span><span class="o">.</span><span class="n">std</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">a_tgt_mean</span><span class="p">,</span> <span class="n">a_tgt_std</span> <span class="o">=</span> <span class="n">a_tgt</span><span class="o">.</span><span class="n">mean</span><span class="p">(),</span> <span class="n">a_tgt</span><span class="o">.</span><span class="n">std</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">b_tgt_mean</span><span class="p">,</span> <span class="n">b_tgt_std</span> <span class="o">=</span> <span class="n">b_tgt</span><span class="o">.</span><span class="n">mean</span><span class="p">(),</span> <span class="n">b_tgt</span><span class="o">.</span><span class="n">std</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Subtract target means, scale by reference/target std, add reference means</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># (Avoid division by zero in case of zero std)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">a_tgt_std</span> <span class="o">&gt;</span> <span class="mf">1e-6</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">a_tgt</span> <span class="o">=</span> <span class="p">((</span><span class="n">a_tgt</span> <span class="o">-</span> <span class="n">a_tgt_mean</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="n">a_ref_std</span> <span class="o">/</span> <span class="n">a_tgt_std</span><span class="p">))</span> <span class="o">+</span> <span class="n">a_ref_mean</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">a_tgt</span> <span class="o">=</span> <span class="n">a_tgt</span> <span class="o">-</span> <span class="n">a_tgt_mean</span> <span class="o">+</span> <span class="n">a_ref_mean</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">b_tgt_std</span> <span class="o">&gt;</span> <span class="mf">1e-6</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">b_tgt</span> <span class="o">=</span> <span class="p">((</span><span class="n">b_tgt</span> <span class="o">-</span> <span class="n">b_tgt_mean</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="n">b_ref_std</span> <span class="o">/</span> <span class="n">b_tgt_std</span><span class="p">))</span> <span class="o">+</span> <span class="n">b_ref_mean</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">b_tgt</span> <span class="o">=</span> <span class="n">b_tgt</span> <span class="o">-</span> <span class="n">b_tgt_mean</span> <span class="o">+</span> <span class="n">b_ref_mean</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Clip values to valid Lab range [0,255] after transfer</span>
</span></span><span class="line"><span class="cl">    <span class="n">a_tgt</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">clip</span><span class="p">(</span><span class="n">a_tgt</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">b_tgt</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">clip</span><span class="p">(</span><span class="n">b_tgt</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Merge channels back (use original L from target to preserve brightness)</span>
</span></span><span class="line"><span class="cl">    <span class="n">merged_lab</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">merge</span><span class="p">([</span><span class="n">L_tgt</span><span class="p">,</span> <span class="n">a_tgt</span><span class="p">,</span> <span class="n">b_tgt</span><span class="p">])</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;uint8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Convert back to BGR color space</span>
</span></span><span class="line"><span class="cl">    <span class="n">result_bgr</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">merged_lab</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_LAB2BGR</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">result_bgr</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ImageColorizationPipeline</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Helper pipeline to load DDColor model and process images.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">model_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">input_size</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">256</span><span class="p">,</span> <span class="n">model_size</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;large&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">device</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">device</span><span class="p">(</span><span class="s2">&#34;cuda&#34;</span> <span class="k">if</span> <span class="n">torch</span><span class="o">.</span><span class="n">cuda</span><span class="o">.</span><span class="n">is_available</span><span class="p">()</span> <span class="k">else</span> <span class="s2">&#34;cpu&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Choose encoder backbone based on model size</span>
</span></span><span class="line"><span class="cl">        <span class="n">encoder_name</span> <span class="o">=</span> <span class="s2">&#34;convnext-t&#34;</span> <span class="k">if</span> <span class="n">model_size</span> <span class="o">==</span> <span class="s2">&#34;tiny&#34;</span> <span class="k">else</span> <span class="s2">&#34;convnext-l&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Initialize DDColor model</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">model</span> <span class="o">=</span> <span class="n">DDColor</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">encoder_name</span><span class="o">=</span><span class="n">encoder_name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">decoder_name</span><span class="o">=</span><span class="s2">&#34;MultiScaleColorDecoder&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">input_size</span><span class="o">=</span><span class="p">[</span><span class="n">input_size</span><span class="p">,</span> <span class="n">input_size</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="n">num_output_channels</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>    <span class="c1"># model predicts ab channels</span>
</span></span><span class="line"><span class="cl">            <span class="n">last_norm</span><span class="o">=</span><span class="s2">&#34;Spectral&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">do_normalize</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">num_queries</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">num_scales</span><span class="o">=</span><span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">dec_layers</span><span class="o">=</span><span class="mi">9</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Load weights</span>
</span></span><span class="line"><span class="cl">        <span class="n">state</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">model_path</span><span class="p">,</span> <span class="n">map_location</span><span class="o">=</span><span class="s2">&#34;cpu&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Some checkpoints store weights under &#39;params&#39; key</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">load_state_dict</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;params&#34;</span><span class="p">,</span> <span class="n">state</span><span class="p">),</span> <span class="n">strict</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">eval</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">input_size</span> <span class="o">=</span> <span class="n">input_size</span>  <span class="c1"># store for processing</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nd">@torch.no_grad</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">process</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">img_bgr</span><span class="p">:</span> <span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;&#34;&#34;Colorize a BGR image (numpy array) using the loaded DDColor model.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Preserve original resolution L channel</span>
</span></span><span class="line"><span class="cl">        <span class="n">orig_h</span><span class="p">,</span> <span class="n">orig_w</span> <span class="o">=</span> <span class="n">img_bgr</span><span class="o">.</span><span class="n">shape</span><span class="p">[:</span><span class="mi">2</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Convert to Lab and extract L channel at original size</span>
</span></span><span class="line"><span class="cl">        <span class="n">img_lab</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">img_bgr</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_BGR2LAB</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;float32&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">orig_L</span> <span class="o">=</span> <span class="n">img_lab</span><span class="p">[:,</span> <span class="p">:,</span> <span class="mi">0</span><span class="p">:</span><span class="mi">1</span><span class="p">]</span>  <span class="c1"># shape (H, W, 1)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Prepare grayscale input at model resolution</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Resize input to model expected size</span>
</span></span><span class="line"><span class="cl">        <span class="n">inp</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">resize</span><span class="p">(</span><span class="n">img_bgr</span><span class="p">,</span> <span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">input_size</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">input_size</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">inp_lab</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">inp</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_BGR2LAB</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;float32&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">L</span> <span class="o">=</span> <span class="n">inp_lab</span><span class="p">[:,</span> <span class="p">:,</span> <span class="mi">0</span><span class="p">:</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Create grayscale Lab image by zeroing AB channels</span>
</span></span><span class="line"><span class="cl">        <span class="n">gray_lab</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">concatenate</span><span class="p">([</span><span class="n">L</span><span class="p">,</span> <span class="n">np</span><span class="o">.</span><span class="n">zeros_like</span><span class="p">(</span><span class="n">L</span><span class="p">),</span> <span class="n">np</span><span class="o">.</span><span class="n">zeros_like</span><span class="p">(</span><span class="n">L</span><span class="p">)],</span> <span class="n">axis</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Convert back to RGB (now it&#39;s a gray RGB image of size input_size)</span>
</span></span><span class="line"><span class="cl">        <span class="n">gray_rgb</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">gray_lab</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;uint8&#34;</span><span class="p">),</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_LAB2RGB</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Prepare tensor and run model</span>
</span></span><span class="line"><span class="cl">        <span class="n">tensor</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">from_numpy</span><span class="p">(</span><span class="n">gray_rgb</span><span class="o">.</span><span class="n">transpose</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span><span class="o">.</span><span class="n">float</span><span class="p">()</span><span class="o">.</span><span class="n">unsqueeze</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_ab</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="p">(</span><span class="n">tensor</span><span class="p">)</span>  <span class="c1"># output is (1,2,H_out,W_out) in Lab AB</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_ab</span> <span class="o">=</span> <span class="n">out_ab</span><span class="o">.</span><span class="n">cpu</span><span class="p">()</span><span class="o">.</span><span class="n">float</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Resize output AB to original image size</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_ab_resized</span> <span class="o">=</span> <span class="n">F</span><span class="o">.</span><span class="n">interpolate</span><span class="p">(</span><span class="n">out_ab</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="p">(</span><span class="n">orig_h</span><span class="p">,</span> <span class="n">orig_w</span><span class="p">),</span> <span class="n">mode</span><span class="o">=</span><span class="s2">&#34;bilinear&#34;</span><span class="p">,</span> <span class="n">align_corners</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_ab_resized</span> <span class="o">=</span> <span class="n">out_ab_resized</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">numpy</span><span class="p">()</span><span class="o">.</span><span class="n">transpose</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># (H, W, 2)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Combine original L and predicted AB, then convert to BGR</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_lab</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">concatenate</span><span class="p">([</span><span class="n">orig_L</span><span class="p">,</span> <span class="n">out_ab_resized</span><span class="p">],</span> <span class="n">axis</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="s2">&#34;uint8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_bgr</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">cvtColor</span><span class="p">(</span><span class="n">out_lab</span><span class="p">,</span> <span class="n">cv2</span><span class="o">.</span><span class="n">COLOR_LAB2BGR</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">out_bgr</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Predictor</span><span class="p">(</span><span class="n">BasePredictor</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">setup</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;&#34;&#34;Load models into memory for efficient multiple predictions.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Determine paths for large and tiny model weights</span>
</span></span><span class="line"><span class="cl">        <span class="n">large_model_path</span> <span class="o">=</span> <span class="s2">&#34;checkpoints/ddcolor_modelscope.pth&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">tiny_model_path</span>  <span class="o">=</span> <span class="s2">&#34;checkpoints/ddcolor_paper_tiny.pth&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># (Ensure the above files exist. If not, download from ModelScope or HuggingFace as described.)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Initialize pipelines for large and tiny models</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">colorizer_large</span> <span class="o">=</span> <span class="n">ImageColorizationPipeline</span><span class="p">(</span><span class="n">model_path</span><span class="o">=</span><span class="n">large_model_path</span><span class="p">,</span> <span class="n">input_size</span><span class="o">=</span><span class="mi">512</span><span class="p">,</span> <span class="n">model_size</span><span class="o">=</span><span class="s2">&#34;large&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">colorizer_tiny</span>  <span class="o">=</span> <span class="n">ImageColorizationPipeline</span><span class="p">(</span><span class="n">model_path</span><span class="o">=</span><span class="n">tiny_model_path</span><span class="p">,</span>  <span class="n">input_size</span><span class="o">=</span><span class="mi">512</span><span class="p">,</span> <span class="n">model_size</span><span class="o">=</span><span class="s2">&#34;tiny&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">predict</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">image</span><span class="p">:</span> <span class="n">Path</span> <span class="o">=</span> <span class="n">Input</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;Grayscale input image.&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">reference</span><span class="p">:</span> <span class="n">Path</span> <span class="o">=</span> <span class="n">Input</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;Optional reference image to guide colors&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">None</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">model_size</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">Input</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;Model size to use (large = highest quality, tiny = faster).&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                                <span class="n">choices</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;large&#34;</span><span class="p">,</span> <span class="s2">&#34;tiny&#34;</span><span class="p">],</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;large&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Path</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;&#34;&#34;Run a single prediction. Colorizes the image, optionally using a reference image for color bias.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Read input image</span>
</span></span><span class="line"><span class="cl">        <span class="n">img</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">imread</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">image</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">img</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Failed to load input image&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Select model pipeline</span>
</span></span><span class="line"><span class="cl">        <span class="n">pipeline</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">colorizer_tiny</span> <span class="k">if</span> <span class="n">model_size</span> <span class="o">==</span> <span class="s2">&#34;tiny&#34;</span> <span class="k">else</span> <span class="bp">self</span><span class="o">.</span><span class="n">colorizer_large</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Colorize the image using DDColor</span>
</span></span><span class="line"><span class="cl">        <span class="n">output_bgr</span> <span class="o">=</span> <span class="n">pipeline</span><span class="o">.</span><span class="n">process</span><span class="p">(</span><span class="n">img</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># If a reference image is provided, transfer its color style to the output</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">reference</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ref_img</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">imread</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">reference</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">ref_img</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Failed to load reference image&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># Only apply transfer if reference is a color image</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">ref_img</span><span class="o">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">==</span> <span class="mi">3</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">output_bgr</span> <span class="o">=</span> <span class="n">color_transfer</span><span class="p">(</span><span class="n">ref_img</span><span class="p">,</span> <span class="n">output_bgr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Save result</span>
</span></span><span class="line"><span class="cl">        <span class="n">out_path</span> <span class="o">=</span> <span class="s2">&#34;/tmp/out.png&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">cv2</span><span class="o">.</span><span class="n">imwrite</span><span class="p">(</span><span class="n">out_path</span><span class="p">,</span> <span class="n">output_bgr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Path</span><span class="p">(</span><span class="n">out_path</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With this modification I can run with a new parameter, note the &ldquo;&ndash;ref&rdquo; option that exists now and place your image in the right spot:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ docker run --rm --gpus all \
  -v &#34;$PWD/input&#34;:/workspace/input_images:ro \
  -v &#34;$PWD/results&#34;:/workspace/results:rw \
  -v &#34;$PWD/ref_images&#34;:/workspace/ref_images:ro \
  -v &#34;$PWD/modelscope&#34;:/app/modelscope:ro \
  -v &#34;$PWD/infer.py&#34;:/app/infer.py:ro \
  ddcolor:latest \
  python3 /app/infer.py \
    --model_path /app/modelscope/damo/cv_ddcolor_image-colorization/pytorch_model.pt \
    --input /workspace/input_images \
    --ref /workspace/ref_images/images.jpg \
    --output /workspace/results</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I passed in another colored reference photo but saw no difference in the result. Once again, I asked ChatGPT:</p>
<h3>Why didn&rsquo;t you notice a difference in the result?<span class="hx:absolute hx:-mt-20" id="why-didnt-you-notice-a-difference-in-the-result"></span>
    <a href="#why-didnt-you-notice-a-difference-in-the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <strong>global color transfer</strong> only changes the &ldquo;overall palette&rdquo; — and not the colors of each object.</p>
<p>If the reference image has soft tones similar to what DDColor would already apply automatically, the final effect <strong>will look identical</strong>.</p>
<p>But if you use as reference a heavily saturated image (for example, an orange sky, or a deep blue environment), you will see DDColor&rsquo;s output <strong>visually pulled</strong> toward that style.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I followed the suggestion and downloaded another totally unrelated image, colored, super saturated with vibrant colors to see if it made a difference. This is the reference image I passed to Docker:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/ryptql9u8mk04o3r7ah01fk1qz5q" alt="Reference Image"  loading="lazy" /></p>
<p>And here is the new result applying it to the previous image in post-processing (this step is no longer A.I., it really is just image post-processing).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/cxcwwnj2ncszvmv4whj4qesafgnm" alt="Post-processed photo"  loading="lazy" /></p>
<p>Compare it with the other re-colorized photo, I will put it down here again so you can compare:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/o2r085pzzpsavbyr39btg1vq0hwp" alt="Repeating the first re-colorization"  loading="lazy" /></p>
<p>Got it? Now you have to keep testing with different references to see how they influence the final result, but indeed, it manages to apply the Reinhard algorithm and do a global color transfer. As the name says, it is &ldquo;global&rdquo;, not &ldquo;per object&rdquo;, so it is hard to control only parts of the image — it acts on the whole image. But in theory I think you could do a manual colorization in Photoshop with saturated colors, near where you want to influence, to try to nudge it, but I have not tried that yet. Later, if anyone does, drop it in the comments.</p>
<p>But that is it. Now at least I can have fun grabbing old family photos and re-colorizing them.</p>
]]></content:encoded><category>generative-media</category><category>containers</category><category>tutorials</category></item></channel></rss>