Skip to main content

Custom YouTube thumbnails

Every long-form video gets a composed thumbnail when it is rendered. You review it with the video, and it uploads with the video. Before 2026-09-25 no upload carried one: all 13 long videos and 10 Shorts on the channel showed a frame YouTube picked, which can be a transition, a presenter mid-blink, or a held frame from the end of a render. A thumbnail needs words, and a diffusion model cannot set type. So the thumbnail is composed, not generated: headless chromium lays real HTML type over a text-free background and screenshots it, the same way the brand hero image is made. It uses no GPU and cannot trip the OCR gate. Code: services/video_thumbnail.py (compose + store), modules/content/atoms/media_render_thumbnail.py (the media-pipeline node), services/youtube_thumbnail_backfill.py (videos already on the channel).

What goes into one

Background: the first source in video_thumbnail_background_order that yields an image. A person (presenter_portrait, presenter_frame) sits on the right with the text beside it (video_thumbnail_person_layout=right). The first test render put full-bleed type over a mid-speech frame, and it read as a paused video. Hook: a few words that add to the title rather than repeat it. The director model writes it from the video.thumbnail_hook section of skills/content/video-director/SKILL.md, and code checks it. The hook must:
  • fit in video_thumbnail_hook_max_chars, so it reads at thumbnail size;
  • carry no number that the post and the narration do not contain;
  • not be made only of the title’s words;
  • not open with the same word as video_thumbnail_hook_opener_max_repeats or more of the last video_thumbnail_hook_opener_window thumbnails. The first backfill opened six of thirteen with “STOP”, and a channel page of those reads as a template. In a backfill each stored thumbnail counts toward the next, so a batch spreads itself out.
A rejected hook gets one corrective retry that carries the reason (the video.thumbnail_hook_fix prompt, in the same SKILL file). If the retry is also rejected, the thumbnail ships with no text, because no text is better than bad text. video_thumbnail_hook_enabled=false skips the model call and gives image-only thumbnails. Look: size, typeface, colours, scrim, text position and brand mark. All of them are settings (table below). The type shrinks until it fits, measured in the page itself: text measured anywhere but the rendering chromium is measured wrong, and the worker ships only JetBrains Mono and Liberation.

Where you review it

The media pipeline’s render_thumbnail node runs after the renders and before media QA. media.persist stores the result as a video_thumbnail row in media_assets beside its video, so it waits for approval with the video. In the console’s media drawer the thumbnail sits above the player, captioned “YouTube thumbnail — uploads with this video”, and it is the player’s poster frame. The route behind it is GET /api/media-approval/{post_id}/video/thumbnail. A re-render replaces the thumbnail. If a run is replayed without re-rendering its video, the stored thumbnail is kept, because it may already be on YouTube.

How it reaches YouTube

When an approved long-form video is distributed, media_distribute hands the stored thumbnail to the YouTube adapter. The adapter calls thumbnails.set right after videos.insert. The outcome is stamped on the thumbnail’s row:
A thumbnail failure never fails the upload. The video goes up with YouTube’s own frame, and a youtube_thumbnail_failed finding names the video and the reason, so you can retry with the backfill command below. Shorts keep YouTube’s frame. The Shorts feed, where most Short views start, plays the video instead of showing a thumbnail. Long-form videos are chosen from their thumbnail in search, browse and suggested, so that is where a composed one pays off.

Channel eligibility

YouTube only accepts custom thumbnails from a verified channel. An unverified channel gets a 403, which the adapter reports with the fix: YouTube Studio → Settings → Channel → Feature eligibility → verify a phone number. No new OAuth scope is needed, because thumbnails.set works with the upload scope.

Videos already on the channel

Run this inside the worker container, which has the renderer and its fonts:
It works in two passes, so what gets uploaded is exactly what you looked at:
  1. Dry run (default): composes and stores a thumbnail for each published long video that has none, then prints each file’s path, hook and background. Nothing leaves the machine. Stored thumbnails are listed, not recomposed, unless you add --recompose.
  2. --apply: uploads each video’s stored thumbnail and stamps the outcome. It never recomposes a stored thumbnail, because a second model call could write different words from the ones you reviewed. Videos stamped set are skipped.
Add --limit N to cap a run. The command exits non-zero if any compose or upload fails.

Changing one video’s thumbnail

--post <post id | slug | task id | YouTube video id> narrows any run to one video, and it also reaches a video still awaiting approval. Two ways to change what that video’s thumbnail says:
  • --recompose re-rolls it: a fresh hook from the model and a fresh render.
  • --hook "TEXT" sets the text yourself. The model is skipped, and so are the checks on its output; the type still shrinks to fit. The stored row records hook_note: "set by operator".
Both are dry runs that replace the stored thumbnail. Look at it, then:
  • video on YouTube: --apply --post <id> uploads it, replacing the one there.
  • video awaiting approval: approve the video. media_distribute uploads the latest stored thumbnail with it.
--hook refuses to run with --apply, or without a --post that matches exactly one video, since either would upload text nobody reviewed.

Measuring whether it works

The youtube_reach tap lands thumbnail impressions and thumbnail click-through rate per video per day in external_metrics. To compare CTR before and after a video got its custom thumbnail:
Impressions are the denominator, so read a CTR only once the video has a few hundred impressions on each side of the change. The tap doc also covers the open question of whether the CTR arrives as a fraction or a percentage.

Settings

All in app_settings, seeded from settings_defaults.py.