How to Build a Thumbnail Generator
I've honestly lost count of how many times I've built this machine — a standalone generator, two production generations inside the content platform I run for a real publishing operation, and a half-dozen image-generation pipelines and skills orbiting them. This page is what survived all of it: the architecture, the seven principles behind it, and the mistakes I already made so you don't have to.
It's written to be platform-agnostic. The principles hold whether you build on Next.js, Rails, a Python script, or a spreadsheet with a button. I wrote it for a friend who's about to build one for a real channel — but nothing here is specific to him.
A thumbnail generator is not "an AI that makes images." It's a pipeline that turns one video into many candidate ideas, lets a human pick, renders the picks, and makes iteration cheap. The image API call is the smallest part of the system.
Two things worth knowing up front: every section below carries a demo gallery — 28 images generated with the exact techniques being described, click any of them and arrow through — and the end of the page has copy-paste scaffolds (prompt templates, a planner schema, config shapes) you can lift straight into your own build.
00 · Orientation
The mental model: a pipeline with a human in the middle
Most people's first thumbnail generator is a text box wired to an image API. Mine was too. It works for about a day, and then you notice the real problems: every image looks different, the face is never your face, the title text is garbled, and when something finally looks good you have no idea how to get that again.
The system that actually holds up looks like this:
Read the diagram left to right and you get the seven principles this page walks through: a planner and a renderer are different models with different jobs (01); the prompt each render receives is assembled from layers (02) whose pieces live in editable configuration (03); the face comes from reference images (04); the title is handled deliberately (05); revision notes make iteration an edit, not a reroll (06); and every arrow in that picture gets logged, with a human making the final call (07).
Principle 01
Two models, two jobs: the planner writes, the renderer draws
Across every version of this I've built, the single biggest upgrade was the same one: splitting the work across two different AI calls. A language model (the planner) reads the video's actual content — the script or draft, the candidate titles — and writes a slate of ten distinct thumbnail concepts, each with a complete, render-ready image prompt. Then an image model (the renderer) draws only the concepts a human selected.
Why this matters: image models are good at rendering and bad at ideation. If you ask an image model for "a thumbnail for my video about X," you get one interchangeable, generic guess. The planner is where the thinking happens — it has read your content, it knows what the video is actually arguing, and it can deliberately vary framing, gesture, props, and emotion across ten concepts so you're choosing between genuinely different ideas, not ten rolls of the same dice.
Ten per video is not an arbitrary number — it's roughly what the big channels test per video, and it's the point where selection starts feeling like editing instead of gambling.
Go deeperHow my production planner works, in detail
In my system the planner is a frontier text model called with strict structured output — a JSON schema the response must match, so a malformed answer fails loudly instead of half-parsing. Per video it receives: the project record, the actual post/script content, the candidate YouTube titles, up to a dozen inspiration screenshots (thumbnails from other channels, with an explicit instruction to borrow composition and hierarchy but never branding, faces, or text), the editable prompt-harness config, and any manual direction the operator typed.
It returns exactly ten candidates. Every candidate has five fields, and the split matters:
- concept_name — a human handle ("The kill switch"), so people can discuss candidates without reading prompts.
- summary — 30–40 words, written for the human scanning the slate: what the subject is doing, the props, the setting, one layout note. No lighting jargon, no negative constraints — those live in the prompt.
- prompt — 120–220 words, written for the image model: concrete composition, expression, gesture, environment, lighting, text handling, negative constraints.
- title_text — the proposed overlay title, kept separate from the image prompt (see Principle 05).
- rationale — one sentence on why this concept might win, which turns the review step into an argument instead of a vibe check.
Writing summary and prompt as separate artifacts for separate readers was one of those small decisions that made the whole tool feel professional. Humans stopped skimming 200-word prompts; the image model stopped receiving human shorthand.
Go deeperWhy "title-first" beats "image-first"
A thumbnail is one half of a package; the title is the other half. My planner receives the candidate titles before it invents concepts, because the thumbnail's job is to complete the title's sentence, not repeat it. If the title says "I Let an AI Run My Channel," the thumbnail shouldn't say it again — it should show the consequence, the emotion, or the proof. Build your generator so the title (or at least the working title) is an input to concept planning, not an afterthought stamped on at the end.
Principle 02
Prompts are assembled, not written
No render in my system ever receives a hand-typed blob. Every final prompt is joined from layers, in a fixed order, by code: a base system prompt (the house rules), the concept (what this specific image shows), a style preset (a named look), a color palette (hex values as guidance), a title instruction (exactly one of two variants — Principle 05), and a reference instruction (how to use the attached images). Empty layers are skipped; present layers are joined with blank lines. That's the whole assembler — it's ten lines of code, and it's the backbone of the system.
The payoff is consistency you can steer. When every image shares the same base layers, a hundred renders look like one photographer shot them — and when you want to change the look, you change one layer instead of rewriting every prompt you've ever saved.
Go deeperThe assembler, as pseudocode
function assemblePrompt(cfg, req):
layers = [
cfg.system_prompt, # house rules, always first
"User concept: " + req.concept, # what THIS image shows
stylePreset(req.style_id)?.prompt, # named look, from config
paletteLine(req.palette_id), # "Use this color palette as visual guidance: #.."
titleInstruction(req.title_text), # exact-text OR reserve-space variant
referenceInstruction(req.ref_count), # how to use the attached images
]
return join(nonEmpty(layers), "\n\n")Notes that earn their keep: keep the order fixed (models weight early text more — rules before details); make every layer optional so a blank config never crashes a render; and store the assembled prompt alongside the layers that produced it, every time (Principle 07).
Principle 03
Style is configuration, not vibes — and configuration lives in a database
Every layer from Principle 02 is data, not code. In my system the system prompt, the style presets, the color palettes, the title-instruction templates, and the runtime defaults (model, size, quality, format, max reference images) all live in a settings table. The admin surface for editing them is gloriously boring: five text areas and a save button. That's not laziness — it's the feature.
Here's why: your prompt language will change weekly, especially in the first months. If prompts are hardcoded, every tweak is a deploy and the only person who can tune the look is a developer. If they're config, the person with taste — who is often not the person with commit access — iterates in minutes. My model IDs are config too, which means when a better image model ships (they ship constantly), switching is an edit, not a release.
A named preset is a contract: "Editorial Drama" means the same matte-black, front-left-key-light look today, next week, and across every video. Named looks are how a channel gets a visual identity out of a generative model.
Go deeperThe config shape I actually use
One table, one category of rows, each row a key holding JSON: settings (defaults), system_prompt (text), style_presets (array of {id, label, prompt}), color_palettes (array of {id, label, colors[]}), title_templates (overlay layouts, Principle 05). The reading code merges each row over hardcoded defaults, so a missing or malformed row degrades gracefully instead of breaking renders. Full JSON shapes are in the scaffolds.
If you don't have a database yet, a checked-in config.json gets you 80% of the value. The line you're drawing is the same either way: people with taste edit the look without touching the pipeline.
Principle 04
Likeness comes from reference images, not from prompts
You cannot describe your way to a consistent face. No prompt, however detailed, renders the same person twice — and a channel's thumbnails live or die on the audience recognizing the person in them. Modern image models accept reference images alongside the prompt, and this is the only reliable way to keep a face, a wardrobe, or a prop consistent across hundreds of images.
So treat references as infrastructure: a small curated library, not a file-upload field. In my system references are categorized (the creator's likeness, brand objects, inspiration screenshots), go through an approval step before the generator will use them, and — this is the part I'd underline twice — an approved likeness reference is attached automatically to every render. Nobody has to remember the most important input. Forgetting it is the number-one silent failure of homemade generators.
Go deeperReference library rules that took me months to learn
- Use real photos for real people. For an actual channel, the likeness references should be genuine photos (or crops from published thumbnails) — never a generated approximation of a real person. My fictional creator here is generated because she's fictional.
- Approval is a workflow state. References enter as candidates and must be approved before the generator uses them. It keeps one bad upload from quietly degrading a week of renders.
- Auto-attach the likeness; cap the total. If a render request includes no likeness reference, the system silently adds the newest approved one. And cap attachments (I allow six) — more references dilute each other.
- Inspiration is a separate category with a separate rule. Screenshots of other channels' thumbnails go to the planner as composition studies with an explicit instruction: borrow framing, hierarchy, and design patterns; never copy branding, faces, logos, layouts, or text. They never go to the renderer.
- Keep the instruction paired with the attachment. Attaching images without telling the model what they're for ("use the reference for likeness, wardrobe, and continuity; preserve the face accurately") gets you style bleed instead of likeness.
- If you're already processing the video, pull references from the video. Extract a handful of frames during transcode and use the sharpest as a second reference alongside the approved portrait — the thumbnail then matches what the creator is actually wearing in that video. Picking the frame is solvable a bunch of ways (deterministic sharpness scoring, random-sample-then-score, or simplest of all: let the creator click a sharp moment in the player) — don't over-engineer the picker before the pipeline works.
Principle 05
Title text is a first-class decision with two lanes
Text is where image models embarrass you — a beautiful render with "CHANNNEL" in forty-point type. My system treats the title as its own input, separate from the image prompt, and every render goes down exactly one of two lanes:
- Lane 1 — the model renders the title. The assembler injects a strict instruction: render exactly this text, no added or paraphrased words, large, legible at thumbnail size, placed in natural negative space, no fake banner panels. Modern image models can do this — and you verify every character before shipping.
- Lane 2 — reserve the space, add text later. The assembler injects the opposite instruction: render no words at all and keep clean negative space where a headline will go. Then deterministic code — not a model — composites the title at export: real font, exact position, stroke for contrast. Same pixels every time.
Lane 2 is the one people skip, and it's the one that scales. When the text layer is code, changing a title (or A/B testing five) is instant and free — no regeneration, no new roll of the dice, no risk to an image you already love. My export step stores overlay layouts as named templates ("left stack," "bottom bar") with percentage-based position, size, color, and stroke, all editable config.
Go deeperThe exact instruction wording, and why every clause exists
My production title instruction, lightly generalized (copyable version in the scaffolds):
"Render exactly this title text: '{title}'. Do not add, remove, paraphrase, or invent words. Make it large, premium, editorial, legible at thumbnail size, and placed in natural negative space without covering the subject's face. No title panel, banner, color wash, rectangle, or fake poster layout."
Every clause is a scar. "Exactly / do not paraphrase" — models love to improve your title. "Legible at thumbnail size" — thumbnails are judged at 300 pixels wide, not full screen. "Without covering the face" — the two most important elements fight for the same real estate. "No panel / banner / poster layout" — left alone, models bolt on a lower-third graphic like it's a movie poster.
The reserve-space variant matters just as much: "Reserve clean negative space for a later headline overlay. Render no text, captions, labels, or words." Without the second sentence, models sneak words onto laptop screens, books, and posters in the background. Say no words anywhere, and say where the space goes — composition planned around the text beats text squeezed in afterwards.
Principle 06
Iterate by editing, not re-rolling
The worst button in a homemade generator is "regenerate." It throws away everything that worked to fix the one thing that didn't. When an image is 80% right, you don't want a new image — you want that image, adjusted.
The mechanism: attach the current image as a reference for the next render, and build a revision prompt that says, in effect, "keep what works, change exactly this." My regeneration prompt is explicit about it: preserve the original's composition, subject placement, style, and readability unless the notes ask for a change; use the attached image as the primary visual reference; apply these revision notes exactly. The editor types one sentence — "make her look more skeptical" — and the system does the rest, keeping the full paper trail of which image was revised from which, with what notes.
Go deeperThe 80% rule, and when to actually re-roll
My working rule: if an image is 80% right, edit; below that, kill it and pick a different slate concept instead of grinding. Edits are for expression, a prop, a color, a crop. Wholesale "make it better" notes produce wholesale different images — if you're writing a paragraph of notes, what you actually want is a new concept, and the slate already has nine more.
Chain revisions deliberately: each edit references the previous revision, not the original, so improvements accumulate. And keep likeness references attached during revisions too — edits can drift a face just like fresh renders can.
Principle 07
Log everything; a human makes the last call
Every generation in my system writes a record before anyone looks at the image: the assembled final prompt and every layer that built it, the model used, size, quality, format, which references were attached (and which were auto-attached), timing, cost metadata, the raw API usage block, and a status. Failures get logged with their error message. Exports get their own records. It sounds like bureaucracy until the first time you ask, "why do this week's thumbnails look worse than last week's?" — and the snapshots answer in five minutes what would otherwise be a vibes argument.
The prompt snapshot is the piece I'd insist on. Not just the final prompt string — the components: which system prompt version, which preset ID, which palette, which title lane, which reference IDs. When a winner emerges, you can reproduce the recipe. When quality drifts, you can diff what changed.
And the last principle inside the principle: the generator proposes; a person disposes. Nothing in my pipeline auto-publishes. The terminal step of every run is a human looking at a review gallery and choosing. That's not a limitation of the tool — it's the design. The machine's job is to make the human's ten-minute review incredibly well-fed.
Go deeperWhat a generation record looks like
{
"id": "…",
"project_id": "…", // which video this belongs to
"status": "generated", // generating | generated | exported | failed
"model_used": "…",
"size": "1536x864", "quality": "medium", "output_format": "jpeg",
"prompt": "…", // the operator's concept, as typed
"prompt_snapshot": { // the receipt — every layer, by ID
"final_prompt": "…",
"components": {
"system_prompt": "…",
"user_concept": "…",
"style_preset_id": "editorial_drama",
"color_palette_id": "high_contrast",
"title_text": "AI RAN MY CHANNEL", // or null → reserve-space lane
"reference_image_ids": ["…"]
}
},
"reference_image_ids": ["…"], // requested + auto-attached, both recorded
"revision_of": null, // set on edits — the chain is queryable
"cost_metadata": { "usage": {…}, "duration_ms": 41000 },
"error_message": null
}Separately, log every planner call too — full prompt in, full response out, model, duration, error. Text calls are cheap; the log is how you tune the planner's instructions over time.
08 · Infrastructure
The model layer: route through an aggregator, own an adapter
Practical advice for the actual API calls. First: put an aggregator between you and the model vendors. Services like OpenRouter — or Vercel's AI Gateway, they serve the same base function — give you one API key, one bill, and one endpoint in front of every major image model. The win isn't convenience, it's optionality: image models leapfrog each other every few months, and behind an aggregator, "switch models" is a one-line config change (Principle 03) instead of a new vendor integration. It also gives you per-model cost visibility for free.
Second: normalize responses behind your own adapter. Different providers return images differently — base64 in one shape, URLs in another, different field names, different error formats. My system has one small adapter per provider family, each returning the same thing: {image_bytes, media_type, raw_response}. Everything downstream — storage, records, UI — touches only that shape. When a new model shows up, I write one adapter and nothing else changes.
Third: assign models to jobs, not one model to everything. The planner wants your best text model. Renders want a cheap-fast workhorse for slate exploration and a typography-strong or premium model where it counts:
| Job | What you want | Typical cost |
|---|---|---|
| Planner (slate writing) | Frontier text model with structured-output support | pennies per slate |
| Workhorse renders (exploring a slate) | Fast photoreal model — cheap enough to render all ten without thinking | ≈ $0.05–0.07 / image |
| Text-critical renders (Lane-1 titles, diagrams) | A typography-strong model tier | ≈ $0.13 / image |
| Finals / hero frames | The best image model available to you, at higher quality settings | ≈ $0.22 / image |
Prices move, so treat the numbers as ratios: the premium tier costs about four times the workhorse. The strategy that falls out: explore cheap, finish expensive. A full ten-image slate on the workhorse costs well under a dollar; re-render only the winner at the premium tier.
A corollary that comes up whenever this gets grafted onto an existing platform: none of this needs your own GPU. Every call above is an HTTPS request, which means the whole generator runs happily from serverless functions or edge workers — if you already have a transcoding tier for video, frame extraction rides it, and everything else is API calls from the infrastructure you have. Spin up GPU compute only if you're hosting models yourself, which for thumbnails you shouldn't be. And generate at 1K, not 2K — YouTube effectively takes 1080 anyway, and the 2K tier roughly doubles the per-image spend for pixels nobody sees.
Go deeperField notes: gotchas that cost me real time
- Aspect-ratio menus differ per model. One model 400s on
21:9but accepts2:1; another takes both. Publish each model's supported ratios in your config and validate before calling — a mid-batch 400 is a dumb way to lose twenty minutes. - Some endpoints change shape with references. One provider uses a different API path for "generate" versus "edit with reference images." The adapter hides this; your callers should never know.
- Seeds are weaker than they look. Don't build reproducibility on seed values — some models ignore them across resolution changes. Reproducibility lives in the prompt snapshot + reference set (Principle 07).
- Refusals are findings, not bugs. Models occasionally decline a prompt. Log it, show it as "declined" in the gallery, and move on. A clean reword is fine; grinding against a refusal is not worth your afternoon.
- Watch for tiny files. An API error payload saved as an "image" is a classic silent failure. Enforce a minimum byte size (I use 40KB) before a file counts as a render.
09 · Operations
Batch discipline: cheap, resumable, verified, then eyeballed
Once the architecture is right, day-to-day quality comes from boring operational habits:
- Make batches idempotent. Every render gets a stable name; the batch runner skips files that already exist and pass the size floor. A failed image is one cheap re-run, never a restart.
- Verify with code, judge with eyes. Automated checks prove a file exists and has substance. They cannot see a mangled hand, an accidental logo, or a dead expression. Review at actual thumbnail size — around 300–560 pixels wide — because that's the size the audience votes at. If a region might contain readable text, zoom into that crop specifically.
- Reserve the text zone at generation time. It bears repeating out of Principle 05: composition planned around the headline beats scrims and shadows fighting a busy image later.
- Quote the cost before you spend it. Images-per-run × price-per-image, said out loud before the run. And probe a new model with two or three test images to learn its cost, speed, and refusal behavior before you point a hundred-image batch at it.
- Expect 30–120 seconds per image and run renders concurrently with a sensible cap. A ten-image slate lands in a few minutes; that's fast enough that the human review loop — not the API — sets your pace, which is exactly how it should be.
10 · Roadmap
Build it in this order (I know, because I built it out of order)
You do not need the whole pipeline on day one. You need a version that ships value this week and grows toward the diagram. This is the ladder I'd climb — it's roughly the order my own system evolved, minus the detours:
- v1 — the afternoon build. One form: concept, title text, aspect ratio. One aggregator account, one workhorse model. The layered prompt assembler with a hardcoded system prompt and two or three style presets in a config file. Save every image plus its prompt snapshot to storage. Even at v1, do not put a model dropdown in the UI — read the model ID from config. You'll thank yourself the first time you switch models without redeploying.
- v1.5 — references. Add reference-image attachment with the paired instruction line, then the small library with categories and approval, then auto-attach for the likeness. This is the single biggest visible quality jump on the whole ladder.
- v2 — the review surface. A gallery of renders per video with keep/kill, revision notes (Principle 06), and export. The moment two people can look at the same slate and leave notes, the tool stops being a toy.
- v2.5 — deterministic titles. The overlay compositor and named layout templates. Now Lane 2 exists and title changes are free.
- v3 — the planner. The structured-output slate call, fed by the video's real content and inspiration references. This is the piece that turns "an image tool" into "a packaging pipeline," and it's much easier to build once the render-review-iterate loop already works.
- Ongoing — move config to a database with an admin surface the moment a non-developer wants to tune prompts. Log relentlessly throughout.
The through-line: every rung keeps a human in the middle and makes their judgment cheaper to apply. That's the product. The models are just staff.
11 · Take these with you
Copy-paste scaffolds
Everything below is platform-agnostic and lifted from the working system, lightly generalized. Copy buttons on each block.
The prompt harness (title + reference instructions)
{
"title_instruction": "Render exactly this title text: \"{title_text}\". Do not add, remove, paraphrase, or invent words. Make it large, premium, editorial, legible at thumbnail size, and placed in natural negative space without covering the subject's face. No title panel, banner, color wash, rectangle, or fake poster layout.",
"no_title_instruction": "Reserve clean negative space for a later headline overlay. Render no text, captions, labels, or words.",
"reference_instruction": "Use the {reference_count} reference image{reference_plural} for likeness, wardrobe, environment, logo, or style continuity as applicable. Preserve the subject's face and signature features accurately."
}Style presets & palettes
{
"style_presets": [
{ "id": "editorial_drama",
"label": "Editorial Drama",
"prompt": "dark-editorial thumbnail, deep matte black or near-black environment, clean front-left studio key light, sharp realistic face, one obvious focal point" },
{ "id": "clean_analysis",
"label": "Clean Analysis",
"prompt": "clean analytical editorial thumbnail, restrained composition, crisp object hierarchy, realistic surfaces, high contrast without harsh drama" },
{ "id": "artifact_anchor",
"label": "Artifact Anchor",
"prompt": "single recognizable artifact or brand anchor, simple editorial composition, clear foreground-background separation, no crowded scene" }
],
"color_palettes": [
{ "id": "high_contrast", "label": "House High Contrast",
"colors": ["#111111", "#f4efe5", "#20b7c9", "#7a624d"] }
],
"settings": {
"default_model": "…", "default_aspect_ratio": "16:9",
"default_size": "1536x864", "default_quality": "medium",
"default_output_format": "jpeg", "max_reference_images": 6
}
}The planner instruction (slate of ten)
You are creating a 10-candidate thumbnail prompt slate for {image_model}.
Use only the provided video content, candidate titles, and inspiration
screenshots as source material. The inspiration screenshots are from other
channels: use them for composition, hierarchy, framing, and design-pattern
ideas only. Do not copy branding, faces, logos, exact layouts, or text.
Each candidate has two human-facing parts, written for different readers:
- "summary": 30-40 words for the human scanning the slate. Lead with what
the subject is doing. Name every prop. Note layout only when it affects
the image (e.g. "clean upper-right for headline"). No lighting/camera
jargon, no negative constraints.
- "prompt": 120-220 words for the image model. Specific visible
composition, expression, gesture, environment or anchor object,
lighting, text handling, and negative constraints where helpful.
Every prompt features {subject_description} as the exact referenced person.
House style: {style_summary}. No fake UI, no neon, no tech-blue/purple
gradient panels, no clickbait shock face, no title-safe color blocks.
If title_text is present, the render layer injects title instructions
separately — do not duplicate the title words inside the prompt. If
title_text is blank, reserve clean negative space and render no words.
Vary concepts across the slate: change framing, expression, gesture,
anchor object, and title placement. Return exactly 10 candidates.
VIDEO CONTENT:
{content}
CANDIDATE TITLES:
{titles}
OPERATOR DIRECTION (optional):
{direction}The candidate schema (strict structured output)
{
"type": "object",
"required": ["slate_title", "rationale", "candidates"],
"properties": {
"slate_title": { "type": "string" },
"rationale": { "type": "string" },
"candidates": {
"type": "array", "minItems": 1, "maxItems": 10,
"items": {
"type": "object",
"required": ["title_text", "concept_name", "summary", "prompt", "rationale"],
"properties": {
"title_text": { "type": "string" },
"concept_name": { "type": "string" },
"summary": { "type": "string" },
"prompt": { "type": "string" },
"rationale": { "type": "string" }
}
}
}
}
}The revision prompt (edit, don't re-roll)
Regenerate the attached thumbnail image as a new version. Preserve the
original image's successful composition, subject placement, style, and
thumbnail readability unless the notes explicitly ask for a change.
Use the attached thumbnail as the primary visual reference. Create a
fresh revised image.
Apply these revision notes exactly:
{notes}
Original concept prompt:
{original_prompt}Seven principles, one product
Split the planner from the renderer. Assemble prompts from layers. Keep the layers in editable config. Lock likeness with reference images. Decide the title's lane on purpose. Edit instead of re-rolling. Log everything, and keep a person on the last button.
Get those right and the rest — stack, framework, database, even which image model — is genuinely interchangeable. Build the smallest version this week, put real thumbnails through it, and let the review loop tell you what to build next.
If you're reading this because you're about to build one: send me what you make. I want to see the slate. — Jonathan