Bulk Thumbnail Generation — Process 100 Videos in One Script
You have a spreadsheet of video titles — a backlog import, a content-farm sprint, a scheduled batch for a faceless channel — and every row needs a thumbnail. Opening the editor 100 times is not the answer. This page is the working script.
One CSV in, one folder of PNGs out. It hits https://api.thumbapi.dev/v1/generatewith the plan's concurrency, backs off on 429, and writes a small manifest so you can resume a half-finished run without paying for the same title twice.
Grab a free API key and run your first batch tonight
Get Started FreeThe Problem
Bulk work is where thumbnail tooling either scales or dies. Three common paths, three familiar failure modes. If your bulk problem is actually a continuous multi-channel one (an operator running a YouTube automation channel network at scale), the webhook loop covered there is a better fit than the one-shot batch script below:
- Editor per thumbnail. Even at three minutes each, 100 videos is a five-hour afternoon that a real person has to sit through. Multiply by every re-brand.
- A single template with the title swapped in. Cheap and fast, but every card on the channel page looks the same and CTR flatlines by video 20.
- A hand-rolled batch script that hammers the API.No concurrency limit, no retry — the first 429 kills the run, half the folder is empty, and there's no way to tell which titles finished.
The fix is not more people; it is a small script that respects the rate limit and keeps a manifest.
The Workflow
The pipeline is the same whether you are shipping 100 videos or 10,000. Only the concurrency number changes.
- Read the input. A
titles.csvwith at leastid,title. Addcategoryper row if the niches differ. - Check the manifest. Skip any
idalready written toresults.csv. Reruns cost nothing. - Dispatch with concurrency.Fire N requests in parallel where N matches the plan's per-second rate limit. Business is 20; Pro is 5.
- Handle 429. On per-second limits, back off with jitter and retry. On generation-limit 429, stop — the plan quota is exhausted.
- Write the PNG + manifest row. Save the decoded image to
out/{id}.pngand append{id, generationId, path}toresults.csv. Fsync so a crash doesn't lose the record.
Because everything routes through the manifest, resuming after a crash (or after ratcheting the concurrency up on plan upgrade) is a matter of restarting the same command.
Code Example — Python Bulk Runner
Python is the natural fit for CSV-in, PNG-out. The script below uses httpx for HTTP/2, an asyncio.Semaphore for the concurrency cap, and exponential backoff with jitter for 429s. Drop it on any box that can reach the internet.
import asyncio, base64, csv, os, random, sys
from pathlib import Path
import httpx
API_URL = "https://api.thumbapi.dev/v1/generate"
API_KEY = os.environ["THUMBAPI_API_KEY"]
CONCURRENCY = int(os.environ.get("CONCURRENCY", "5")) # Pro plan = 5 rps
OUT_DIR = Path("out"); OUT_DIR.mkdir(exist_ok=True)
MANIFEST = Path("results.csv")
def load_todo(input_csv: str) -> list[dict]:
done = set()
if MANIFEST.exists():
with MANIFEST.open() as f:
done = {row["id"] for row in csv.DictReader(f)}
with open(input_csv) as f:
return [row for row in csv.DictReader(f) if row["id"] not in done]
async def generate(client: httpx.AsyncClient, sem: asyncio.Semaphore, row: dict):
payload = {
"title": row["title"],
"format": "youtube",
"category": row.get("category") or "auto",
"outputFormat": "png",
}
for attempt in range(5):
async with sem:
r = await client.post(
API_URL,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json=payload,
timeout=90,
)
if r.status_code == 429:
body = r.text.lower()
if "generation limit" in body:
print("QUOTA EXHAUSTED — upgrade plan or wait for reset")
sys.exit(2)
await asyncio.sleep((2 ** attempt) + random.random())
continue
r.raise_for_status()
data = r.json()
png = base64.b64decode(data["image"].split(",", 1)[1])
path = OUT_DIR / f"{row['id']}.png"
path.write_bytes(png)
with MANIFEST.open("a", newline="") as f:
csv.writer(f).writerow([row["id"], data.get("generationId", ""), str(path)])
print(f"[ok] {row['id']}")
return
print(f"[fail] {row['id']} — max retries")
async def main(input_csv: str):
if not MANIFEST.exists():
with MANIFEST.open("w", newline="") as f:
csv.writer(f).writerow(["id", "generationId", "path"])
todo = load_todo(input_csv)
print(f"processing {len(todo)} titles at concurrency={CONCURRENCY}")
sem = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient(http2=True) as client:
await asyncio.gather(*(generate(client, sem, row) for row in todo))
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "titles.csv"))Run it with CONCURRENCY=5 python bulk_thumbnails.py titles.csv. The manifest makes reruns idempotent — kill the process, tweak the input, restart, and it picks up on the next unfinished row. For the full walkthrough of the async pattern, see the batch thumbnail generation in Python deep dive.
Code Example — Node.js Bulk Runner
Same shape in TypeScript, using p-limit for the concurrency cap. Useful if the batch runs inside a larger Node pipeline (e.g. a Next.js build script or a queue worker).
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
import { parse } from "csv-parse/sync";
import pLimit from "p-limit";
const API_URL = "https://api.thumbapi.dev/v1/generate";
const API_KEY = process.env.THUMBAPI_API_KEY!;
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 5);
const OUT_DIR = "out";
const MANIFEST = "results.csv";
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR);
if (!existsSync(MANIFEST)) writeFileSync(MANIFEST, "id,generationId,path\n");
const done = new Set(
parse(readFileSync(MANIFEST), { columns: true }).map((r: { id: string }) => r.id),
);
type Row = { id: string; title: string; category?: string };
const rows: Row[] = parse(readFileSync(process.argv[2] ?? "titles.csv"), { columns: true });
const todo = rows.filter((r) => !done.has(r.id));
const limit = pLimit(CONCURRENCY);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function generate(row: Row) {
for (let attempt = 0; attempt < 5; attempt++) {
const r = await fetch(API_URL, {
method: "POST",
headers: { "x-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
title: row.title,
format: "youtube",
category: row.category ?? "auto",
outputFormat: "png",
}),
});
if (r.status === 429) {
const body = (await r.text()).toLowerCase();
if (body.includes("generation limit")) throw new Error("QUOTA_EXHAUSTED");
await sleep(2 ** attempt * 1000 + Math.random() * 1000);
continue;
}
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
const { image, generationId = "" } = (await r.json()) as {
image: string;
generationId?: string;
};
const png = Buffer.from(image.split(",", 2)[1], "base64");
const path = `${OUT_DIR}/${row.id}.png`;
writeFileSync(path, png);
appendFileSync(MANIFEST, `${row.id},${generationId},${path}\n`);
console.log(`[ok] ${row.id}`);
return;
}
console.error(`[fail] ${row.id} — max retries`);
}
console.log(`processing ${todo.length} titles at concurrency=${CONCURRENCY}`);
await Promise.all(todo.map((row) => limit(() => generate(row))));The Node version mirrors the Python one on purpose — same manifest, same retry rules, same concurrency semantics — so a team running one stack can port the other in an afternoon. For the standalone Node/JS pattern (single call, no batching), see the Node.js generation guide.
Match Concurrency to Your Plan
The CONCURRENCY knob is not cosmetic. ThumbAPI enforces a per-second rate limit per key — go over it and every extra call returns 429 "Rate limit exceeded". The safe setting is exactly your plan's rps:
| Plan | Requests / second | Safe CONCURRENCY | ~100 videos take |
|---|---|---|---|
| Free | 1 | 1 | ~4–5 min (also blocked by 50-credit quota at ~5 covers) |
| Creator | 2 | 2 | ~2–3 min |
| Pro | 5 | 5 | ~45–60 sec |
| Business | 20 | 20 | ~10–15 sec |
Numbers assume ~2–3 seconds of end-to-end generation per thumbnail and no add-ons. Full rules are in docs → rate limits.
Customization Per Row
The batch is only useful if each thumbnail looks different. Every parameter on the /v1/generate endpoint can be set per-row from the CSV. The ones that actually move the needle for bulk work:
| Parameter | Why it matters in bulk |
|---|---|
format | youtube, blogpost, instagram, x, linkedin. Mix in a single run if you are shipping to multiple channels from the same title. |
category | Bias the style toward one of 13 niches (tech-saas, gaming, education-tutorial, …). Adds acategory column to the CSV so different sub-brands in the same batch stay on tone. |
useLogo | One saved logo asset, one true flag — every image carries the brand without a re-upload per call. |
customAssetsId | Points at a saved reference set (1–6 sample thumbnails). The AI mirrors palette and layout, so a 1,000-item batch reads as one channel. Details in custom assets. |
model | sd (1K, default) for volume; hd (2K, Pro+) for the ~10% of covers that get the most eyeballs. Save credits by only switching to hd for hero content. |
Cost at Scale
A standard 1K thumbnail on YouTube format costs 10 credits. Add-ons add +2 credits each; a 2K (hd) render costs 20 credits. Do the math up-front so the batch does not stall halfway.
| Batch size | Standard 1K covers | 1K + logo (12 credits) | Cheapest plan that fits |
|---|---|---|---|
| 100 videos | 1,000 credits | 1,200 credits | Pro — $49/mo, 2,500 credits |
| 1,000 videos | 10,000 credits | 12,000 credits | Business — $199/mo, 10,000 credits (2 months, or top-up) |
| 10,000 videos | 100,000 credits | 120,000 credits | Custom — talk to us via the contact page |
Credits reset monthly and do not roll over. For a one-shot backlog import that overflows the plan, the cleanest path is to upgrade for the month the batch runs and downgrade afterward — dashboard changes take effect immediately.
Handling Failures Without Losing the Run
Two failure modes account for almost every crash in a batch that size:
- Per-second 429s from your own concurrency. Fixed by matching
CONCURRENCYto the plan rps. The exponential backoff in the script covers the edge cases (bursty clock, retries from a previous run still in flight). - Generation-limit 429 when the monthly quota runs out. The script exits with a distinct code so cron / CI can page you instead of silently retrying. The already-written manifest means the next run resumes on the missing rows.
The full error taxonomy — 400 validation, 401 auth, 403 plan-gated, 404 missing asset — is in docs → errors. For truly async volumes, ThumbAPI also supports webhook callbacks (Business plan)so the client doesn't need to hold a connection per generation.
FAQ
Can I bulk-generate for multiple formats in one script?
Yes. Every row can carry its own format. A common pattern for a video release is one row for youtube, one for x, one for linkedin — same title, three differently sized covers, all in the same run.
How do I keep every thumbnail on-brand across 1,000 rows?
Upload 1–6 sample thumbnails as a reference set in the dashboard and pass the resulting customAssetsId on every request. Combined with useLogo: true, every generated image matches the palette, layout, and logo of the reference set. See the branded thumbnails use case for the full setup.
What happens if the box running the script crashes at row 47?
Nothing you have to fix by hand. The manifest results.csv only contains rows that finished writing their PNG, so restarting the script skips 1–46 and picks up on 47. Credits are only charged on successful responses.
Do I need webhooks for a 100-video batch?
No — synchronous calls with concurrency are simpler and fast enough at this size. Webhooks (Business plan) become worth it above the 1,000-videos-in-one-shot range or when you want to decouple the generator entirely from the client that requested it.
Can I run this on GitHub Actions or a cron?
Yes. The script only depends on THUMBAPI_API_KEY and the input CSV. Wire the key into repo secrets, upload titles.csvas an artifact or check it in, and schedule a nightly workflow. The manifest keeps state between runs so a re-run is a no-op if the input hasn't changed.
Ship 100 covers before your coffee gets cold
Get Your Free API KeyRelated Pages
The long-form Python walkthrough — async fundamentals, retry patterns, and cost tuning.
Node.js Thumbnail GenerationThe single-call Node.js pattern this bulk runner is built on top of.
Automated Blog CoversThe event-driven cousin — one webhook fires one generation instead of a scheduled batch.
Custom Reference AssetsKeep 1,000 batch covers on-brand with a saved reference set.
Rate LimitsPer-second and monthly limits — the numbers behind the CONCURRENCY knob.
QuickstartGet a key, make your first call, see the response shape. Under 5 minutes.