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. A Short uploads with its own vertical thumbnail (next section). The Shorts feed plays the video rather than showing a thumbnail, but the channel’s Shorts tab, search and the shelf show one.

The Short’s thumbnail (2026-10-13)

The media.package_short node composes a 9:16 thumbnail for the Short with compose_short_thumbnail. It uses the long thumbnail’s hook, colours and brand mark, so the pair shares one headline and no second model call is spent. The type sits in the top band (video_short_thumbnail_text_position=top) because the Shorts player lays the title, channel and buttons over the bottom and right of the frame. The background order is the long one’s, and a featured image is centre-cropped to the vertical frame. It is stored as a video_short_thumbnail asset, shown in the media drawer above the Short, and uploaded with it by media_distribute. The upload outcome is stamped on its row the same way as the long one’s. Partner Program only. YouTube accepts custom Shorts thumbnails only from YouTube Partner Program channels (rolled out from July 2026). On a channel outside the program the Short still uploads, keeps YouTube’s frame, and a youtube_thumbnail_failed finding says to set youtube_short_custom_thumbnail_enabled=false. The thumbnail is still composed and reviewable while that is off. Shorts already on the channel use the same command with --shorts:
The Short’s title and description are covered in youtube-metadata.md.

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.