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 invideo_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_repeatsor more of the lastvideo_thumbnail_hook_opener_windowthumbnails. 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.
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’srender_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:
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 a403, 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:- 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. --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 stampedsetare skipped.
--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:
--recomposere-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 recordshook_note: "set by operator".
- video on YouTube:
--apply --post <id>uploads it, replacing the one there. - video awaiting approval: approve the video.
media_distributeuploads 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
Theyoutube_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:
Settings
All inapp_settings, seeded from settings_defaults.py.