<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: qcrao</title>
    <description>The latest articles on Forem by qcrao (@qcrao).</description>
    <link>https://forem.com/qcrao</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3895196%2Fdc8d29f7-4dbf-4ec7-a679-d9d3fa85ba0b.jpg</url>
      <title>Forem: qcrao</title>
      <link>https://forem.com/qcrao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/qcrao"/>
    <language>en</language>
    <item>
      <title>5 LoRA training pitfalls when you're trying to lock down a comic character</title>
      <dc:creator>qcrao</dc:creator>
      <pubDate>Thu, 07 May 2026 09:31:29 +0000</pubDate>
      <link>https://forem.com/qcrao/5-lora-training-pitfalls-when-youre-trying-to-lock-down-a-comic-character-43bl</link>
      <guid>https://forem.com/qcrao/5-lora-training-pitfalls-when-youre-trying-to-lock-down-a-comic-character-43bl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TLDR: Most "my LoRA works in test prompts but breaks the second I put it in a comic panel" problems are caused at training time, not at inference. Here are the five training-side mistakes that ate the most weekends for me.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I've spent the last eight months building &lt;a href="https://www.comicory.com" rel="noopener noreferrer"&gt;Comicory&lt;/a&gt;, an AI comic generator where the entire pitch is "your character looks the same on page 1 and page 12." That sentence is easy to say. It is &lt;em&gt;grindingly&lt;/em&gt; hard to ship.&lt;/p&gt;

&lt;p&gt;Almost every fix I shipped in those eight months traced back to LoRA training, not the prompt or the sampler or the seed. This post is the list I wish someone had given me on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Your training set has too many "same shot"
&lt;/h2&gt;

&lt;p&gt;The first character LoRA I trained had 32 images. 28 of them were 3/4 portrait, neutral lighting, looking slightly off-camera. It was the dataset I had, scraped from concept-art-style references.&lt;/p&gt;

&lt;p&gt;The LoRA trained beautifully. Then I tried to use it in an actual comic panel — wide shot, side profile, character mid-action — and the output looked nothing like the reference. The model had memorized the &lt;em&gt;pose&lt;/em&gt;, not the character.&lt;/p&gt;

&lt;p&gt;Fix: aim for &lt;strong&gt;pose, framing, and lighting diversity&lt;/strong&gt; before you aim for image count. My current target for a character is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30% close-up faces (multiple angles)&lt;/li&gt;
&lt;li&gt;30% medium shots (waist-up, multiple angles)&lt;/li&gt;
&lt;li&gt;25% full-body shots&lt;/li&gt;
&lt;li&gt;15% "weird" shots — back of head, dramatic angle, partial occlusion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quality of &lt;em&gt;coverage&lt;/em&gt; matters more than count. A 25-image set with this distribution beats a 70-image set of nothing-but-portraits, every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: You captioned the character into the wallpaper
&lt;/h2&gt;

&lt;p&gt;This one is sneaky. In my early datasets, every caption looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ck_character standing in a forest, anime style, soft lighting, high detail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model learned &lt;code&gt;ck_character&lt;/code&gt; as inseparable from "standing in a forest, soft lighting." When I prompted &lt;code&gt;ck_character on a spaceship bridge&lt;/code&gt;, the LoRA pulled in foliage and warm light because those concepts had been bound to the trigger token.&lt;/p&gt;

&lt;p&gt;Fix: &lt;strong&gt;caption away the things you want to vary&lt;/strong&gt;, leave only what is invariant about the character. If your character is supposed to be wearable in any setting, your caption should look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ck_character, red jacket, short black hair, freckles
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No setting, no lighting, no mood. Those are the variables you'll set at inference time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What I do during caption preprocessing now
&lt;/span&gt;&lt;span class="n"&gt;INVARIANT_TAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;red_jacket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;short_black_hair&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;freckles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;STRIPPED_TAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;soft_lighting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_detail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outdoor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indoor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean_caption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ck_character&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw_tags&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;INVARIANT_TAGS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This change alone gave me the single biggest jump in cross-scene consistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: You trained at one resolution and then panel-rendered at another
&lt;/h2&gt;

&lt;p&gt;Stable Diffusion 1.5 LoRAs trained at 512×512 fall apart at 768×1152 panel aspect ratios. SDXL is more forgiving but not immune. The model has not seen the character at the panel aspect ratio you actually need.&lt;/p&gt;

&lt;p&gt;Fix: &lt;strong&gt;bucketed training across the aspect ratios you'll actually render at.&lt;/strong&gt; kohya-ss supports this out of the box. My current bucket config covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;512×768 (portrait panel)&lt;/li&gt;
&lt;li&gt;768×512 (landscape panel)&lt;/li&gt;
&lt;li&gt;768×768 (splash square)&lt;/li&gt;
&lt;li&gt;1024×1536 (full-page hero)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Image counts in each bucket should roughly match how often you'll render at that aspect. If 70% of your panels are landscape, 70% of your training images should be landscape — even if it means cropping the same source image into multiple buckets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: Your learning rate is fighting your dataset size
&lt;/h2&gt;

&lt;p&gt;There is no universal "good" LR. Tiny datasets (15-25 images) want a &lt;em&gt;lower&lt;/em&gt; LR and &lt;em&gt;more&lt;/em&gt; steps so the model doesn't overfit on the handful of examples. Bigger sets (60+) tolerate a higher LR and fewer epochs.&lt;/p&gt;

&lt;p&gt;What I use as a starting point now (kohya-ss, SDXL LoRA, rank 16):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset size&lt;/th&gt;
&lt;th&gt;unet_lr&lt;/th&gt;
&lt;th&gt;text_encoder_lr&lt;/th&gt;
&lt;th&gt;epochs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;15-25 images&lt;/td&gt;
&lt;td&gt;1e-4&lt;/td&gt;
&lt;td&gt;5e-5&lt;/td&gt;
&lt;td&gt;12-15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25-50 images&lt;/td&gt;
&lt;td&gt;2e-4&lt;/td&gt;
&lt;td&gt;1e-4&lt;/td&gt;
&lt;td&gt;8-10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50-100 images&lt;/td&gt;
&lt;td&gt;3e-4&lt;/td&gt;
&lt;td&gt;1e-4&lt;/td&gt;
&lt;td&gt;6-8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are &lt;em&gt;starting points&lt;/em&gt;, not laws. But they will save you from the two failure modes I kept hitting: undertraining ("LoRA does nothing") and overcooking ("LoRA always renders the same expression").&lt;/p&gt;

&lt;p&gt;Check loss curves. If validation loss bottoms out around epoch 4 and rises after, your LR is too high or you have too few images. If it's still falling at the last epoch, train longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 5: You skipped regularization images and now the LoRA bleeds into everything
&lt;/h2&gt;

&lt;p&gt;You ship the LoRA. You prompt &lt;code&gt;a coffee shop, no characters, photorealistic&lt;/code&gt;. Your character shows up anyway, faintly haunting the espresso machine.&lt;/p&gt;

&lt;p&gt;This is the LoRA "leaking" into general concepts because it has no contrast set. The model has no examples of "what a person who is NOT this character looks like" during training, so the LoRA's identity bleeds into the base model's "person" concept.&lt;/p&gt;

&lt;p&gt;Fix: &lt;strong&gt;regularization images.&lt;/strong&gt; During training, alongside your character set, include a folder of generic "person" images (200-300, captioned simply as &lt;code&gt;person&lt;/code&gt;) generated by the base model itself. These tell the LoRA "this is what NOT-the-character looks like."&lt;/p&gt;

&lt;p&gt;In kohya-ss config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[datasets]]&lt;/span&gt;
  &lt;span class="nn"&gt;[[datasets.subsets]]&lt;/span&gt;
    &lt;span class="py"&gt;image_dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/data/ck_character"&lt;/span&gt;
    &lt;span class="py"&gt;class_tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ck_character"&lt;/span&gt;
    &lt;span class="py"&gt;num_repeats&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

  &lt;span class="nn"&gt;[[datasets.subsets]]&lt;/span&gt;
    &lt;span class="py"&gt;image_dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/data/reg_person"&lt;/span&gt;
    &lt;span class="py"&gt;class_tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"person"&lt;/span&gt;
    &lt;span class="py"&gt;num_repeats&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="py"&gt;is_reg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leaking effect drops to near-zero. Your background characters look like background characters again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Character consistency is, in practice, a checklist of these five training-time decisions plus a workflow that uses the resulting LoRA correctly. The inference side (ControlNet, IP-Adapter, reference-only) only matters once your LoRA is solid. If your LoRA is bad, no amount of inference scaffolding will save it.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://www.comicory.com" rel="noopener noreferrer"&gt;Comicory&lt;/a&gt; because I wanted a comic generator that didn't make me re-prompt the character on every panel. The five fixes above are the spine of how it works under the hood.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>stablediffusion</category>
      <category>sideprojects</category>
      <category>indie</category>
    </item>
    <item>
      <title>What I learned squeezing the YouTube Data API v3 quota for a side project</title>
      <dc:creator>qcrao</dc:creator>
      <pubDate>Thu, 07 May 2026 09:12:47 +0000</pubDate>
      <link>https://forem.com/qcrao/what-i-learned-squeezing-the-youtube-data-api-v3-quota-for-a-side-project-3304</link>
      <guid>https://forem.com/qcrao/what-i-learned-squeezing-the-youtube-data-api-v3-quota-for-a-side-project-3304</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TLDR: The default 10,000 unit/day quota will burn through in ~10 naive user requests. Three tricks pulled my per-user cost down 50× and let me ship TubeVocab on the free tier.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When I started building TubeVocab — an ESL learning tool that turns any YouTube video into a clickable, vocab-learning interactive transcript — I assumed the YouTube Data API v3 would be the cheap, easy part. "It's Google. It scales. The free tier is generous." That kind of gut feeling.&lt;/p&gt;

&lt;p&gt;I was wrong. The free tier &lt;em&gt;is&lt;/em&gt; generous, but only if you understand how quota math actually works. Most public tutorials skip this. Here's what I learned the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quota arithmetic nobody puts in the quickstart
&lt;/h2&gt;

&lt;p&gt;Default daily quota: &lt;strong&gt;10,000 units&lt;/strong&gt;. Sounds like a lot.&lt;/p&gt;

&lt;p&gt;Then you start reading the &lt;a href="https://developers.google.com/youtube/v3/determine_quota_cost" rel="noopener noreferrer"&gt;cost table&lt;/a&gt; and realize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search.list&lt;/code&gt; — &lt;strong&gt;100 units&lt;/strong&gt; per call. That's how you find a video by query.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;videos.list&lt;/code&gt; — &lt;strong&gt;1 unit&lt;/strong&gt; per call. That's how you fetch metadata once you have an ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;captions.list&lt;/code&gt; — &lt;strong&gt;50 units&lt;/strong&gt;. Thumbnails of available subtitles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;captions.download&lt;/code&gt; — &lt;strong&gt;200 units&lt;/strong&gt;. The actual subtitle data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your user-facing flow is "search a YouTube channel → pick a video → load subtitles → render the interactive player," you're looking at roughly &lt;code&gt;100 + 1 + 50 + 200 = 351 units&lt;/code&gt; per &lt;em&gt;single user session&lt;/em&gt;. The 10,000 free units evaporate in &lt;strong&gt;28 sessions/day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's not a side project. That's a 30-DAU launch and you're paying for quota expansion the next morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tricks that cut my per-user cost ~50×
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Don't use &lt;code&gt;search.list&lt;/code&gt; for known IDs
&lt;/h3&gt;

&lt;p&gt;This sounds obvious in hindsight but it took me a week to see. If a user pastes a YouTube URL, &lt;strong&gt;the video ID is right there in the URL&lt;/strong&gt;. Parse it. Skip search.list entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: 100 units per pasted URL&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pastedUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;snippet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Good: 0 units, regex the ID&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pastedUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;v=|youtu&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;be&lt;/span&gt;&lt;span class="se"&gt;\/)([\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{11})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;snippet,contentDetails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 1 unit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one change took the average pasted-URL flow from 351 units → 251 units.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Skip the official &lt;code&gt;captions.*&lt;/code&gt; endpoints entirely
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;captions.download&lt;/code&gt; endpoint costs 200 units per video AND requires OAuth (the user has to be the video owner). For non-owner subtitle access — i.e. the actual ESL use case — you need a different path.&lt;/p&gt;

&lt;p&gt;The trick: YouTube serves the auto-generated and uploader-provided subtitles through an undocumented but stable XML endpoint that doesn't count against your quota at all. You can get the timed transcript via &lt;code&gt;https://video.google.com/timedtext?lang=en&amp;amp;v=VIDEO_ID&lt;/code&gt;, parse the XML, and you're done. &lt;strong&gt;0 quota units.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;(Caveat: this endpoint is undocumented, so it can break. I have a fallback path that uses &lt;code&gt;youtube-transcript-api&lt;/code&gt; style scraping. The combined approach gets ~95% subtitle hit rate without touching the official caption quota.)&lt;/p&gt;

&lt;p&gt;After this, my "load subtitles" cost dropped from 250 → 1 unit per session.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cache aggressively at the video-ID level
&lt;/h3&gt;

&lt;p&gt;Every time someone watches a video on TubeVocab, the metadata + subtitle + thumbnail set is &lt;em&gt;the same&lt;/em&gt; until the video itself changes. I run a per-video-ID cache (just SQLite — overkill is fine) with no expiry. Subsequent views of the same video cost &lt;strong&gt;zero quota&lt;/strong&gt;, regardless of how many users watch it.&lt;/p&gt;

&lt;p&gt;Once I had ~500 popular videos cached, my marginal cost per session was effectively zero. The quota is now spent only on first-time-seen videos.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shipped
&lt;/h2&gt;

&lt;p&gt;After these three optimizations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average new-video session: &lt;strong&gt;~2 units&lt;/strong&gt; (videos.list + occasional fallback)&lt;/li&gt;
&lt;li&gt;Average cached-video session: &lt;strong&gt;0 units&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Daily ceiling on the free tier: ~5,000 unique new videos/day before I'd need to start budgeting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's enough headroom for the foreseeable lifetime of a side project.&lt;/p&gt;

&lt;p&gt;If you're building anything in the YouTube + content-analysis space — vocabulary tools, accessibility, search, analytics — the playbook is roughly: &lt;strong&gt;assume &lt;code&gt;search.list&lt;/code&gt; is poison, route around &lt;code&gt;captions.*&lt;/code&gt;, and cache by video ID forever&lt;/strong&gt;. The free tier becomes more than generous once you stop fighting it.&lt;/p&gt;




&lt;p&gt;For context: I built &lt;a href="https://www.tubevocab.com" rel="noopener noreferrer"&gt;TubeVocab&lt;/a&gt; using exactly this stack — it's a click-to-flashcard ESL tool that turns any YouTube video into vocabulary practice. The quota math was the single most underestimated technical risk of the whole project. Hope this saves someone a week.&lt;/p&gt;

</description>
      <category>youtube</category>
      <category>api</category>
      <category>sideprojects</category>
      <category>indie</category>
    </item>
    <item>
      <title>The Engineering Challenge of Turning YouTube Into an ESL Corpus</title>
      <dc:creator>qcrao</dc:creator>
      <pubDate>Fri, 24 Apr 2026 03:34:47 +0000</pubDate>
      <link>https://forem.com/qcrao/the-engineering-challenge-of-turning-youtube-into-an-esl-corpus-5bgi</link>
      <guid>https://forem.com/qcrao/the-engineering-challenge-of-turning-youtube-into-an-esl-corpus-5bgi</guid>
      <description>&lt;p&gt;Language learning apps have spent a decade chasing the same pattern: curate a 2,000-word "high-frequency vocabulary" list, wrap it in spaced repetition, ship. Users grind, retention looks great in the app, and then they meet an actual English speaker and freeze, because &lt;strong&gt;recognizing a word on a flashcard is not the same skill as catching it in running speech&lt;/strong&gt;. The information is in their head but it is not wired to sound, pace, register, or context.&lt;/p&gt;

&lt;p&gt;The intuition behind context-based acquisition — learning words &lt;em&gt;in situ&lt;/em&gt;, inside real discourse — is old and well supported in second-language acquisition research. The problem has always been that the "real discourse" part is hard to deliver at scale. Textbook dialogues are not real. Classroom tapes are not real. Even podcasts are a curated subset.&lt;/p&gt;

&lt;p&gt;YouTube is real. It is also the single largest corpus of native-speaker content in every register you care about: casual vlogs, lectures, interviews, comedy, news, gameplay commentary, technical talks. For ESL specifically, the fact that speakers vary in accent, speed, and slang is a feature, not a bug.&lt;/p&gt;

&lt;p&gt;The engineering question is: what would it take to turn YouTube into a usable ESL corpus?&lt;/p&gt;

&lt;h2&gt;
  
  
  The interactivity problem
&lt;/h2&gt;

&lt;p&gt;Watching YouTube with auto-subtitles on is already useful for listening comprehension. The gap is that &lt;strong&gt;subtitles are read-only&lt;/strong&gt;. A learner hits an unfamiliar word, pauses the video, tabs to a dictionary, types the word, gets a translation, tabs back, loses their place. After three such interruptions in a 10-minute video most learners give up and either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stop pausing (and therefore stop learning from the unfamiliar words), or&lt;/li&gt;
&lt;li&gt;abandon the video entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right interaction is &lt;strong&gt;click-a-word → instant translation + pronunciation + example sentence → optionally save as flashcard&lt;/strong&gt;, all without leaving the player. That turns a 10-minute video into a vocab-building session instead of a comprehension test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is harder than it looks
&lt;/h2&gt;

&lt;p&gt;A few things get in the way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Subtitle alignment.&lt;/strong&gt; YouTube auto-subs are word-timed for about 80% of videos; manual subs are sentence-timed. A click-a-word UI has to handle both gracefully, ideally highlighting the clicked word with &amp;lt;50ms latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokenization across languages.&lt;/strong&gt; Clicking "running" should map to the lemma "run" for dictionary lookup. Clicking "auf" in a German phrase should resolve to the correct sense given context. Clicking "不好意思" in Chinese should resolve as a multi-character idiom, not char-by-char.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disambiguation.&lt;/strong&gt; "Bank" in a finance video is different from "bank" in a kayaking video. A naive dictionary lookup gives the most common sense; a better system checks surrounding context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personalization.&lt;/strong&gt; A B2 learner does not want to be interrupted every time "the" appears. The system needs to model what the learner already knows and surface only likely-unknown words — ideally inferred from past clicks, not a placement test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashcard hygiene.&lt;/strong&gt; Saving raw dictionary entries produces terrible flashcards. The good ones include the word in its &lt;em&gt;original sentence&lt;/em&gt;, the speaker, optionally a short audio clip. This turns retention from "definition recall" into "episodic recall," which is massively stronger.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it looks like when it works
&lt;/h2&gt;

&lt;p&gt;I have been using &lt;a href="https://www.tubevocab.com" rel="noopener noreferrer"&gt;tubevocab.com&lt;/a&gt; for a month as a hosted implementation of the click-a-word-on-YouTube pattern. Drop in a video URL, watch with interactive subtitles, click a word to see the translation and an AI-generated example sentence, save it to a flashcard deck with the original sentence attached, let spaced repetition handle scheduling. UI is in 10 languages which matters for learners whose L1 is not English.&lt;/p&gt;

&lt;p&gt;What I noticed over the month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retention is visibly better&lt;/strong&gt; than flat Anki decks, because you remember the speaker and the scene along with the word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listening comprehension improves faster than raw vocab count&lt;/strong&gt;. You start catching phrases you would have missed before, including phrases you never actually &lt;em&gt;studied&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost of saving a card is near zero&lt;/strong&gt; — one click, inline — which is what makes the workflow stick. Anki's friction cost is why most learners quit it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free tier covers the dictionary, the flashcards, and the spaced repetition, which is enough to evaluate whether the loop works for a given learner without committing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am bringing this up
&lt;/h2&gt;

&lt;p&gt;From an engineering standpoint, "interactive learning layer on top of YouTube" is a genuinely interesting systems problem: you are doing real-time NLP on streaming caption data, building a personalized word-knowledge model, and rendering a low-latency overlay on a player you do not control. Most of the research attention in language-learning tech has gone to generative tutors and chatbots; the infrastructure for &lt;em&gt;exposure-driven&lt;/em&gt; acquisition is comparatively under-built.&lt;/p&gt;

&lt;p&gt;For ESL learners specifically, the payoff is pragmatic: the gap between "I studied 3,000 words" and "I can follow a normal conversation" closes a lot faster when the 3,000 words were learned from real speakers saying real things, and the sentences attached to them when you hit review.&lt;/p&gt;

&lt;p&gt;Not a pitch for any particular tool — mostly an argument that the "click-a-word-on-real-native-content" pattern is underbuilt in this space, and the tools that get it right are worth the 10 minutes to evaluate.&lt;/p&gt;

</description>
      <category>learning</category>
      <category>productivity</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Why Character Consistency Is Hard in AI Comic Generation</title>
      <dc:creator>qcrao</dc:creator>
      <pubDate>Fri, 24 Apr 2026 03:31:47 +0000</pubDate>
      <link>https://forem.com/qcrao/why-character-consistency-is-hard-in-ai-comic-generation-36ld</link>
      <guid>https://forem.com/qcrao/why-character-consistency-is-hard-in-ai-comic-generation-36ld</guid>
      <description>&lt;p&gt;When you feed a story prompt into a generic image AI — say, "a detective with a red scarf walks into a neon-lit bar, then sits down at the counter, then pulls out a notebook" — you will usually get three images back where the detective has three different faces, two different scarves, and in one panel the scarf has become a tie. This is the &lt;strong&gt;character consistency problem&lt;/strong&gt;, and it is the single biggest reason why text-to-image tools are bad at comics.&lt;/p&gt;

&lt;p&gt;This post is a short walk through &lt;em&gt;why&lt;/em&gt; it happens, what the current workarounds look like, and where the FLUX.1-Kontext-based approach fits in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do characters drift?
&lt;/h2&gt;

&lt;p&gt;Every text-to-image inference is in effect a &lt;strong&gt;fresh sample from a very high-dimensional distribution&lt;/strong&gt;. The model has no state between generations. Prompt A and prompt B may both say "detective with red scarf," but the specific pixel arrangement that the sampler lands on is governed by the noise seed, the scheduler, and a thousand tiny decisions inside the U-Net. Two calls that share a prompt but not a seed will produce two different people who both roughly match the description.&lt;/p&gt;

&lt;p&gt;Put differently: the model does not have a &lt;em&gt;character&lt;/em&gt;. It has a &lt;em&gt;prompt&lt;/em&gt;. Every panel is a new roll of the dice against the same loose description.&lt;/p&gt;

&lt;p&gt;Classical diffusion workflows try to fix this with three tricks, none of which are great:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Seed locking.&lt;/strong&gt; Use the same random seed for every panel. Works only if the prompt is essentially unchanged — the moment you add "sitting down" or "pulling out a notebook," the composition changes and the seed lock stops helping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Textual inversion / DreamBooth.&lt;/strong&gt; Fine-tune a small adapter on reference photos of the character. Effective but slow, expensive, and brittle — you are training a new adapter for every character in your comic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-image prompting.&lt;/strong&gt; Paste the previous panel into the prompt as a reference. Some models accept it; most do not; when they do, they often regress to the mean face after a few hops.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What FLUX.1-Kontext adds
&lt;/h2&gt;

&lt;p&gt;FLUX.1-Kontext is Black Forest Labs' image-to-image-conditioned variant of FLUX. The relevant design choice is that it treats the reference image not as "inspiration" (loose style transfer) but as &lt;strong&gt;hard conditioning&lt;/strong&gt; during the denoising process. You pass in a reference sheet — the character's face, outfit, key features — and the generation is pulled toward that reference, not just textually but pixel-wise, through cross-attention.&lt;/p&gt;

&lt;p&gt;For comics this is almost exactly the right primitive. The workflow becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a reference sheet for each character once (face, outfit, distinctive props).&lt;/li&gt;
&lt;li&gt;For every panel, pass the relevant character's sheet + the scene description.&lt;/li&gt;
&lt;li&gt;The model respects the sheet as a constraint, not a suggestion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same detective now has the same face, the same red scarf, and the scarf actually stays a scarf.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks and what does not
&lt;/h2&gt;

&lt;p&gt;In practice the approach works well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontal and three-quarter faces.&lt;/strong&gt; The reference sheet is usually a clean portrait; panels that echo that framing stay on-model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinctive clothing and props.&lt;/strong&gt; A red scarf, a specific hat, a tattoo — these get preserved reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short stories (6–12 panels).&lt;/strong&gt; Drift is minimal within a single story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It still struggles with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extreme poses.&lt;/strong&gt; A character leaping mid-air from behind is a composition the reference sheet does not cover, so the model extrapolates and sometimes loses the face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background characters.&lt;/strong&gt; Secondary characters without their own reference sheet still drift. You either sheet them too or accept drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-form continuity across chapters.&lt;/strong&gt; After 50+ panels the accumulated small variations become visible. Re-anchoring to the sheet every 10 panels helps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A practical note on tooling
&lt;/h2&gt;

&lt;p&gt;You can run this stack yourself — the FLUX.1-Kontext weights are open — but assembling the pipeline (reference sheet generator, scene scripter, panel renderer, single-panel regenerator, style picker) is a fair amount of plumbing.&lt;/p&gt;

&lt;p&gt;I have been using &lt;a href="https://www.comicory.com" rel="noopener noreferrer"&gt;comicory.com&lt;/a&gt; as a hosted implementation of roughly this architecture. Drop in a story paragraph, the system handles the scripting and reference-sheet step, and the multi-panel output keeps the same character recognizable. Eight art styles available (manga, Western comic, watercolor, ink wash, etc.), and critically, &lt;strong&gt;single-panel regeneration&lt;/strong&gt; is supported — if panel 4 drifts, you redo only that panel without rebuilding the rest of the story. Free tier is 30 images per month which is enough to evaluate the workflow.&lt;/p&gt;

&lt;p&gt;Not a pitch; mostly flagging it because I spent a couple of weeks trying to glue the same pipeline together locally and it was a lot of YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The character consistency problem is a nice example of how &lt;strong&gt;architectural fixes beat clever prompting&lt;/strong&gt;. For the first three years of diffusion-for-comics, the whole field was trying to solve consistency at the prompt level — longer prompts, locked seeds, character templates, multi-image prompting. None of it really worked. The real unlock was a model class that takes a reference image as first-class conditioning.&lt;/p&gt;

&lt;p&gt;When a generation problem resists prompt engineering for long enough, the answer is usually that the model architecture is wrong for the task, and someone will eventually ship a new one. FLUX.1-Kontext is that ship for multi-panel comics. I am curious what the equivalent "right architecture" looks like for the remaining hard cases — long-form continuity, multi-character scenes with physical interaction, and expressive pose variation.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
