DEV Community

Cover image for What I learned building a three-tier thumbnail fallback for a CI YouTube longform pipeline
MORINAGA
MORINAGA

Posted on

What I learned building a three-tier thumbnail fallback for a CI YouTube longform pipeline

The conclusion first: designed thumbnails outperform auto-rendered slide frames for click-through, but the chain that gets you there — and what happens when any tier fails — matters more than the preferred tier. Here's the three-tier system I wired into my CI pipeline, the ffmpeg mechanics behind the rescale, and the stale-queue edge case I didn't anticipate until it bit me.

What I started with

My YouTube longform pipeline renders 2-host dialogue specs into 16:9 MP4s in a GitHub Actions workflow. The render step calls build_longform.py, which generates a slide per dialogue beat using Pillow and Mermaid, synthesizes audio with edge-tts, and stitches everything with ffmpeg. The full rendering stack is covered in how I built a YouTube slide renderer in Python and the two-host video pipeline.

For the first month of publishing, my thumbnail strategy was: capture a frame at 0.6 seconds into the rendered video. That frame is always the opening cover slide — a full-bleed title card with the video title, subtitle, and a one-line hook. It's technically a valid thumbnail.

The problem is that it reads like a slide. The opening frame was designed for people who've already clicked; the thumbnail has to perform in browse, where it's competing against channel art, styled type treatments, and faces. My slide frames were informative and visually identical across every video in the channel — same gradient background, same Pillow text layout, same brand corner. In a playlist or search result, they disappeared into each other.

Analytics eventually made this obvious. After a few weeks the pattern was clear: videos with a more distinct visual opening were getting higher impressions even with similar topic quality. I'd removed the only obvious differentiator — the thumbnail — by standardizing it.

The unlock: phone verification for custom thumbnails went through in late June. With custom thumbnails available, I needed to wire a designed image into the pipeline without making it a mandatory step that could halt a publish.

The three tiers

The fallback chain, in priority order:

Tier 1: designed cover image. A cover_image field in the queue JSON spec points to an AI-generated PNG committed to content/yt-covers/. When the field is present and the file exists, this becomes the thumbnail.

Tier 2: rendered opening cover frame. Extract a frame at 0.6 seconds from the rendered MP4. This is always available because the render step runs before thumbnail selection. The opening frame carries the video title, so it's still better than YouTube's auto-frame.

Tier 3: YouTube auto-thumbnail. If both tier 1 and tier 2 produce nothing, log a warning, omit --thumbnail from the upload call, and let YouTube pick. The video still goes up.

The constraint that shaped this: thumbnail failure must not kill the job. By the time thumbnail selection runs, the 15-minute render step has already produced an MP4. Re-running that render just to retry thumbnail logic is expensive and unnecessary. Every tier in the chain exits cleanly.

The CI mechanics

The relevant section from the workflow's upload step:

COVER=$(python3 -c "import json;print(json.load(open('${{ steps.pick.outputs.file }}')).get('cover_image',''))")
if [ -n "$COVER" ] && [ -f "$COVER" ] && ffmpeg -y -loglevel error -i "$COVER" \
     -vf "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720" \
     -q:v 3 /tmp/lf/thumb.jpg; then
  THUMB_ARGS=(--thumbnail /tmp/lf/thumb.jpg)
  echo "Thumbnail = designed cover_image ($COVER)"
elif ffmpeg -y -loglevel error -ss 0.6 -i "${{ steps.render.outputs.mp4 }}" -frames:v 1 \
     -vf "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720" \
     -q:v 3 /tmp/lf/thumb.jpg; then
  THUMB_ARGS=(--thumbnail /tmp/lf/thumb.jpg)
  echo "Thumbnail = rendered opening cover frame (no cover_image set)"
else
  echo "WARN: no thumbnail produced — YouTube auto-thumbnail"
fi
python3 scripts/yt-publish/upload.py "$MP4" "$TITLE" /tmp/lf/description.txt \
  --tags "$TAGS" --privacy "$PRIVACY" "${THUMB_ARGS[@]}"
Enter fullscreen mode Exit fullscreen mode

Three things worth noting.

THUMB_ARGS=() (empty Bash array) means "${THUMB_ARGS[@]}" expands to nothing when not populated, so the upload call compiles correctly with zero thumbnail arguments. The same pattern appears in the Shorts first-frame overlay for conditionally-applied ffmpeg filters — empty arrays compose cleanly without special-casing.

The cover_image path is read directly from the queue JSON spec at runtime. The render step already consumed the same file to extract the dialogue segments; reading it again for cover_image is cheap and means the thumbnail field lives alongside the content that generated it. Adding a designed thumbnail to an existing spec is a one-field addition with no workflow changes.

The if clause on the tier 1 branch covers two failure modes simultaneously: file path not found on disk, and ffmpeg conversion failure (wrong format, corrupt PNG). Either way, the elif fires and grabs the rendered frame. A path pointing to a file that doesn't exist is not a fatal condition — it's a configuration gap, logged and moved past.

The ffmpeg rescale

YouTube's custom thumbnail spec: max 2MB, 1280×720 recommended, JPG or PNG. AI-generated images tend to be 1920×1080 PNG at 3-5MB, which exceeds the size limit before conversion.

The ffmpeg filter chain scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720 handles both dimensions and size in one pass. force_original_aspect_ratio=increase scales the source up to the smallest size where neither dimension is less than the target. For a 1920×1080 source at a 1280×720 target — same aspect ratio — this hits 1280×720 exactly. For sources with different ratios, it scales up until both sides meet or exceed the target, then crop=1280:720 trims the overflow.

YouTube's custom thumbnail spec says max 2MB, recommended resolution 1280×720, accepted formats JPG, GIF, or PNG. The 2MB constraint is the binding one for AI-generated images; resolution is a recommendation, not a hard limit, but thumbnails rendered below 1280×720 look soft on Retina screens.

The alternative is force_original_aspect_ratio=decrease followed by pad, which produces letterbox bars on mismatched ratios. Letterboxed thumbnails look smaller in browse because the bars reduce the effective image area. I used this approach for auto-generated thumbnails in the Shorts pipeline before switching — the crop approach is more aggressive but produces thumbnails that fill the entire space.

-q:v 3 controls JPG quality. Values 2-5 produce 80-300KB for a 1280×720 frame, well within YouTube's limit after conversion from a multi-megabyte PNG source.

Designing the keyart

I generate designed thumbnails with Higgsfield's nano_banana_pro model. The brief: clean compositional art with strong foreground subject, no text, and a palette that contrasts with YouTube's white background in browse.

Deliberately no text baked in. If a video title changes — which happens when I'm testing a title before publishing — I update the spec JSON without regenerating the image. YouTube overlays the video title as a channel card in browse anyway; the thumbnail's job is to stop the scroll, not duplicate the title. Baking text into the source image also makes the ffmpeg crop unpredictable: text near an edge might be clipped depending on the source aspect ratio.

Files go in content/yt-covers/ named <slug>_keyart.png. The queue spec references the path:

{
  "title": "4 Open-Source Firebase Alternatives Worth the Migration (2026)",
  "cover_image": "content/yt-covers/firebase_keyart.png",
  "archetype": "product_ossfind",
  "segments": [...]
}
Enter fullscreen mode Exit fullscreen mode

The cover_image path is relative to the repo root, which is the working directory when the Actions workflow runs. Keeping keyart in the repo alongside the spec it belongs to means the link is always checkable with [ -f "$COVER" ] — no external CDN or object storage required.

The two-host AI dialogue spec format treats cover_image as one optional metadata field among several (archetype, privacy, tags). The render step ignores it; only the upload step reads it. This separation means the keyart path doesn't need to exist for the render to succeed — it's a publish-time concern, not a build-time one.

The stale-queue problem

This is the part I didn't anticipate.

Queue files include a date prefix: YYYY-MM-DD-<slug>.json. The picker expires files whose date is more than 21 days old before selecting a file to publish. The logic exists because a spec written three weeks ago may reference outdated model stats, pricing, or GitHub star counts:

MAX_AGE_DAYS="${QUEUE_MAX_AGE_DAYS:-21}"
CUTOFF=$(date -u -d "${MAX_AGE_DAYS} days ago" +%Y-%m-%d)
for f in content/yt-longform-queue/*.json; do
  D=$(basename "$f" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}' || true)
  if [[ -n "$D" && "$D" < "$CUTOFF" ]]; then
    echo "Expiring stale long-form queue file (${D} < ${CUTOFF}): $f"
    rm -f "$f"
  fi
done
Enter fullscreen mode Exit fullscreen mode

When I added designed keyart to two queue files that had been waiting for three and four weeks respectively — longer than usual because those specs had been marked unlisted for review — both files were expired by the picker on its next run, before they published. I'd done the design work; the specs never aired.

The fix: rename the spec file to today's date after reviewing its content, and simultaneously update the privacy field from unlisted to public if the review is done. Renaming the file from 2026-06-05-dropbox-oss-alternatives.json to 2026-07-02-dropbox-oss-alternatives.json resets the expiry clock to today.

Designed thumbnail plus re-dating are now coupled in my mental model. If a spec is worth the effort of a designed thumbnail, it's worth a content review and a fresh date. The cron timing considerations that sometimes delay workflows mean a file can sit longer than expected even with a recent date — the 21-day window is a safeguard, not a guarantee of publication cadence.

FAQ

Q: Why not use YouTube's built-in thumbnail editor instead of CI automation?
YouTube Studio's thumbnail tools work for one-off videos but aren't scriptable. My queue publishes multiple times per week; each video needs a thumbnail without manual login. The CI fallback chain keeps the pipeline headless.

Q: Does the designed keyart need to be exactly 1280×720?
No. The ffmpeg rescale handles other sizes. I render keyart at 1792×1008 (16:9) so the crop filter never touches it, but 1920×1080 works the same way. Very tall or very wide source images will lose content at the edges after crop; for those, pad with a 16:9 canvas makes more sense than crop.

Q: What if cover_image is set but the file was never committed to the repo?
The [ -f "$COVER" ] check catches this before ffmpeg runs. The tier 2 fallback fires, the upload uses the rendered frame, and the step log shows which tier was used. Nothing fails. You'll see a note in the Actions summary, which is usually enough to catch the missing file on the next review.

Q: Should designed keyart have branding or text?
I keep mine brand-free and text-free. Adding channel text to keyart couples the image to the channel name — if the channel rebrands, every piece of keyart with baked-in text becomes outdated. YouTube applies its own text overlays in browse. The keyart's only job is the visual hook; let the channel card and title handle identification.

Q: How do I know which queue specs have designed thumbnails?
I don't maintain a separate index. grep -l cover_image content/yt-longform-queue/*.json gives the list in seconds. A missing result means that spec will use the rendered opening frame, which is fine.


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)