DEV Community

Cover image for What I learned about thumbnail brightness floors in a YouTube Shorts pipeline
MORINAGA
MORINAGA

Posted on

What I learned about thumbnail brightness floors in a YouTube Shorts pipeline

The outage lasted four days. Between July 25 and July 28, the YouTube Shorts publishing cron ran daily, exited zero, and produced nothing. No error alert, no Slack ping — just an empty queue and a growing gap in the schedule.

The cause: Undertale's Steam key art is nearly pure black. When my thumbnail generator composited it full-bleed behind text, the resulting card had a mean RGB brightness of 23.5. The media QC gate in scripts/qc-media.py rejects thumbnails below 24. The spec sat in the queue, rejected silently, day after day.

This post covers what I learned fixing it: why my first approach (multiplying pixel values) does not work, why additive lifting does, and what I would change before this happens again.

The gate and why it exists

The QC gate measures brightness with two lines of Pillow:

from PIL import ImageStat
brightness = sum(ImageStat.Stat(img).mean) / 3
Enter fullscreen mode Exit fullscreen mode

ImageStat.Stat(img).mean returns the per-channel mean pixel value for an RGB image — three numbers, each in the 0–255 range. Summing and dividing by 3 gives a single luminance proxy. The gate fails thumbnails below 24.

That floor is not arbitrary. In May I had a batch of generated thumbnails rejected by YouTube's upload pipeline with a vague "underexposed media" error. I measured mean brightness across passing and failing cards; the failing ones were all below roughly 23. I set the gate at 24 to catch them before upload.

The formula is not perceptually correct — a proper Rec. 709 luminance calculation would weight R, G, and B at 0.2126, 0.7152, and 0.0722 respectively. But I use the same simple formula in both the gate and the thumbnail generator, which is what actually matters. A mismatch between those two would have the gate and the renderer disagreeing on the same image.

Why multiplying pixel values does not work

My first idea: if brightness is 23.5 and I need 24, multiply all pixels by 24 / 23.5 ≈ 1.02. Cheap, one-line, obvious.

Except it does not move the mean. To see why, look at where the brightness budget lives on a typical thumbnail card.

build_thumb.py renders a dark cinematic background — composited game art, dimmed to 45% opacity, plus a dark gradient scrim that pushes the left side further toward black. On top of that it draws near-white text: a big numeric hook at font size 132, a two-to-three-line headline at 78px, a subtitle. On a 1280×720 card those text pixels are a significant fraction of the total, and they sit at (245, 245, 245) or higher.

Near-white means near-255. Multiply 250 by 1.02: you get 255, clipped. Multiply 255 by 1.02: still 255. The white text is already at the ceiling, so multiplying by any factor above 1 does nothing to it.

The dark background pixels are in the range 5–30. Multiply 15 by 1.02: you get 15.3, which rounds to 15 in integer pixel space. The shift is less than one unit.

Multiplication concentrates its force in the text region (where it is clipped away) and produces sub-unit changes in the dark region (where it needs to act). The mean barely moves. The gate still fails.

I confirmed this by computing the card's pixel histogram before applying the fix. The distribution was strongly bimodal: a large cluster from 0 to 30 (dark art background), and a smaller cluster from 230 to 255 (white text, accent underline). The mean of 23.5 reflects that the dark cluster is much larger. Multiplying by 1.02 moves the dark cluster by fractions of a unit and clips the light cluster entirely.

Additive lifting: why it concentrates in the right place

An additive offset applies v + k to every pixel. For dark background pixels at 5–30:

  • 5 + 8 = 13 — real lift
  • 15 + 8 = 23
  • 30 + 8 = 38

For the white text pixels at 245–255:

  • 255 + 8 = 255 (clipped, no change — same ceiling behavior as multiplication)
  • 245 + 8 = 253 (tiny shift, visually negligible)

The ceiling that killed multiplication is now doing useful work: the white text stays white, and all the effective lift concentrates in the dark region where the mean actually lives. The total mean rises in proportion to how much the dark cluster shifts.

The fix in build_thumb.py:

brightness = sum(ImageStat.Stat(img).mean) / 3
if brightness < 30:
    orig = brightness
    for _ in range(6):
        add = max(2, int(30 - brightness) + 2)
        img = img.point(lambda v, a=add: min(255, v + a))
        brightness = sum(ImageStat.Stat(img).mean) / 3
        if brightness >= 30:
            break
    print(f"brightness lift: {orig:.1f} -> {brightness:.1f}")
Enter fullscreen mode Exit fullscreen mode

Three design choices worth noting:

Target 30, not 24. The gate floor is 24, but targeting 24 exactly leaves no margin for rounding. Six points of headroom means a card that passes local CI will pass the gate downstream. Dark game art thumbnails look fine at a mean brightness of 30 — the background is still dark, the text is still bright.

Loop, don't pre-calculate. The correct offset is a function of the pixel distribution, which changes with each pass. Computing the number of passes algebraically requires knowing the full histogram in advance. Measuring inline after each application is exact and straightforward.

max(2, ...) floor on the offset. If the loop overshoots and re-enters with brightness already above 30, the computed offset int(30 - brightness) + 2 goes negative. The floor ensures each iteration makes positive progress.

In practice the loop exits after one pass. The Undertale card went from 23.5 to 31.2 in a single iteration with add = 8. The limit of 6 iterations is a backstop I have not needed yet.

What the log line is for

The print(f"brightness lift: {orig:.1f} -> {brightness:.1f}") goes to stdout, which GitHub Actions captures and stores in the workflow log:

brightness lift: 23.5 -> 31.2
thumbnail: thumb.jpg (198422 bytes, art=yes)
Enter fullscreen mode Exit fullscreen mode

When a publishing gap shows up — no videos for three days — the first thing to check is whether any thumbnails triggered the lift. That log line gives the before/after values without re-running anything. Silent fixes are the hardest to debug retroactively. The three-tier thumbnail fallback pipeline logs its fallback stages for the same reason; I applied the same principle here.

How it fits into the rest of the pipeline

build_thumb.py sits between the script renderer and the YouTube upload step. The two-host video pipeline generates narration audio, then video, then thumbnail, then uploads. If the thumbnail fails QC, the upload never runs — the job exits 0 and the spec stays in the queue unchanged. The YouTube Shorts thumbnail rules I derived from analytics assume a card that actually gets uploaded; dark art is a pre-upload failure that analytics can never surface.

The Pillow slide renderer uses a different render path — slide-format cards for long-form content rather than the cover-art composites used for Shorts. It does not composite dark game art today. If it starts doing so, it will need the same check.

What I would do differently

Make the QC gate emit a structured rejection. When qc-media.py rejects a thumbnail it should write a JSON log entry: {"spec": "undertale-review-count", "reason": "brightness", "value": 23.5, "threshold": 24}. Right now it exits non-zero and produces no record. The three approaches to silent failure detection I've used all depend on a structured log — a bare exit code tells you something failed, not what. I spent most of the four days looking at the wrong part of the pipeline because there was no log pointing at the QC gate.

Lower the gate floor or make it configurable. 23.5 is dark but not unusable. The current floor of 24 comes from a single rejection batch in May. I don't know if that threshold is stable across YouTube pipeline versions. A configurable floor in the QC config JSON would let me tune it without a code deploy, and having it separate from the build_thumb.py target makes the relationship explicit: gate at 22, target at 30, six points of margin on each side.

Pre-classify known-dark game art. If a spec has a Steam appid that maps to a known-dark game, flag it before compositing. The Jaccard spec dedup catches duplicate content before the pipeline runs; a similar pre-generation filter for known-dark appids would route those specs to manual art review rather than letting them silently repeat through the cron. The list is short: Undertale, Limbo, Darkest Dungeon, INSIDE, and a handful of others.

FAQ

Why not brighten the game art before compositing?
I tried this. Brightening the art before the dimming step produces muddier colors — you push it toward grey, then dim it back toward black. Applying additive lifting to the final card is cleaner: it operates on the finished layout and leaves the compositing logic untouched.

Does the brightness lift affect the video file?
Only the thumbnail. The video frames come from a separate renderer. The FFmpeg first-frame overlay uses the thumbnail as the video's cover frame, so the lift will appear there — but that is intentional.

What games are most likely to trigger this?
Dark survival and horror games: Undertale, Darkest Dungeon, Limbo, Into The Breach. Any Steam header that is dark-on-dark rather than colorful-on-dark. I now keep a short list of known-dark appids and expect the lift to fire when they appear in the queue.

Why does the loop target 30 and not 25 or 26?
The gate is at 24 and I want margin, but I also do not want to over-lift dark cards that look good as-is. At 30 the background is still visually dark on any monitor; at 40 it starts looking washed out. The YouTube analytics I track have not flagged CTR drops on dark cards, which makes me think 30 is a reasonable compromise.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)