A .NET CLI tool for generating blog and YouTube thumbnail images from HTML templates.
Install as a global tool:
dotnet tool install --global ImgForgeOr run directly from NuGet via DNX (requires -- before the arguments that should be sent to imgforge):
dnx -y imgforge -- generate --helpGenerate a blog post Open Graph image with a random background:
dnx -y imgforge -- generate --template blog --title "Getting Started with ImgForge" --bg random --format blogCreate a YouTube thumbnail:
dnx -y imgforge -- generate --template youtube --title "How to Build Better Software" --bg random --format youtubeGenerate a blog card with subtitle and headshot:
dnx -y imgforge -- generate --template blog-subtitle \
--title "Advanced .NET Patterns" \
--subtitle "A practical guide" \
--bg random \
--headshot ./photo.jpg \
--format blogAll three built-in templates (blog, youtube, blog-subtitle) work immediately without any additional setup. Chromium is downloaded automatically on first run.
ImgForge follows a three-stage pipeline:
- Template rendering — An HTML template (built-in or custom) is loaded and variable placeholders are replaced with the supplied values (title, background, dimensions, overlays) using Scriban.
- Browser rendering — The resulting HTML is loaded into a headless Chromium browser via PuppeteerSharp, which applies all CSS layout and styling exactly as a real browser would.
- Screenshot capture — PuppeteerSharp screenshots the rendered page at the specified viewport size and writes a PNG to the output path.
GenerateOptions
│
▼
TemplateRenderer (Scriban: injects title, bg, width, height, overlays into HTML)
│
▼
HTML string
│
▼
ImageGenerator (PuppeteerSharp: loads HTML in headless Chromium, screenshots to PNG)
│
▼
output.png
| Package | Role |
|---|---|
| Scriban | Liquid-syntax template engine — substitutes {{ title }}, {{ bg }}, {{ width }}, {{ height }}, and {% for img in overlays %} in HTML templates |
| PuppeteerSharp | Headless Chromium driver — renders the HTML with full CSS support and captures a pixel-perfect screenshot. Chromium is downloaded automatically on first run. |
| System.CommandLine | Parses CLI arguments and subcommands |
| SixLabors.ImageSharp | Used in tests to verify output PNG dimensions |
| xunit | Test framework |
imgforge generate \
--template blog \ # built-in name ("blog", "youtube") or path to a .html file or folder (with a template.html file in it)
--title "Modular Monoliths Done Right" \
--subtitle "A practical guide" \ # optional subtitle rendered below the title in templates that support it
--bg ./images/cover.jpg \ # local path, HTTP(S) URL, or "random" (fetches a random image from picsum.photos); optional
--overlay ./logo.png \ # optional; repeat for multiple overlays
--headshot ./guest.jpg \ # optional guest headshot; placed in a styled circle
--headshot-filter blue-mono \ # built-in: blue-mono (default), mono, none — or a raw CSS filter string
--var season=3 \ # arbitrary key=value pairs injected as {{ vars.season }} in templates; repeat for multiple
--format podcast-episode \ # sets width/height from a named preset (see formats below)
--out og.png \ # output file path; defaults to title slug in current directory
--out-dir ./output \ # output directory; filename derived from --title; ignored if --out is provided
--width 1200 \ # explicit width — overrides --format; prompted if neither is given
--height 630 # explicit height — overrides --format; prompted if neither is given| Flag | Required | Default | Description |
|---|---|---|---|
--template |
Yes | — | Built-in template name (blog, youtube, blog-subtitle) or path to a .html file or a directory containing template.html |
--title |
Yes | — | Main heading text injected into the template |
--subtitle |
No | — | Optional subtitle rendered below the title in templates that support it (e.g. blog-subtitle) |
--bg |
No | — | Background image: local file path, HTTP(S) URL, or random (fetches a random image from picsum.photos) |
--overlay |
No | — | Overlay image path. Repeatable for multiple overlays |
--headshot |
No | — | Guest headshot image path placed in the template's headshot slot |
--headshot-filter |
No | blue-mono |
Filter applied to the headshot. Built-in: blue-mono, mono, none. Or supply a raw CSS filter string |
--var |
No | — | Arbitrary template variable as key=value (e.g. --var episode=42). Accessible in templates as {{ vars.episode }}. Repeatable |
--format |
No | — | Output format preset that sets width and height. Choices: youtube, blog, github, podcast-show, podcast-episode. Explicit --width/--height override the preset |
--out |
No | title slug .png |
Output PNG file path |
--out-dir |
No | . |
Output directory. Filename is derived from --title. Ignored if --out is provided |
--width |
No | 1200 |
Viewport width in pixels. Overrides --format |
--height |
No | 630 |
Viewport height in pixels. Overrides --format |
Use --format <name> instead of --width/--height. Explicit dimensions always override the preset.
If neither --format nor explicit dimensions are provided, the tool prompts you interactively.
--format value |
Width | Height | Use case |
|---|---|---|---|
youtube |
1280 | 720 | YouTube video thumbnail |
blog / og |
1200 | 630 | Blog post / Open Graph social card |
github |
1280 | 640 | GitHub repository social preview |
podcast-show |
3000 | 3000 | Podcast show art |
podcast-episode |
3000 | 3000 | Podcast episode art |
Ideal dimensions vary by platform and use case. Use --width and --height to match:
| Use Case | Width | Height | Aspect Ratio | Built-in Template | Repository Template | Notes |
|---|---|---|---|---|---|---|
| YouTube Video Thumbnail | 1280 | 720 | 16:9 | youtube |
youtube-bold.html |
Minimum 640×360; displayed at up to 1280×720 |
| Blog / Open Graph (og:image) | 1200 | 630 | ~1.91:1 | blog, blog-subtitle |
blog-gradient.html |
Recommended by Facebook, LinkedIn, Slack, and most social crawlers. Twitter also accepts this size. |
| GitHub Repository Social Preview | 1280 | 640 | 2:1 | — | github-social.html |
Displayed at 640×320 on repository pages |
| Podcast Show Art | 3000 | 3000 | 1:1 | — | podcast-show.html |
Apple Podcasts minimum 1400×1400; Spotify and most platforms prefer 3000×3000, max 512 KB |
| Podcast Episode Art | 3000 | 3000 | 1:1 | — | podcast-episode.html |
Same requirements as show art; some hosts fall back to show art if omitted |
# YouTube thumbnail (using --format preset)
imgforge generate --template youtube --title "My Video Title" --bg ./bg.jpg --out thumb.png --format youtube
# YouTube thumbnail with a random background
imgforge generate --template youtube --title "My Video Title" --bg random --out thumb.png --format youtube
# YouTube thumbnail with guest headshot (blue monochrome filter)
imgforge generate --template youtube --title "Building Better APIs" \
--headshot ./guest.jpg --out thumb.png --format youtube
# Blog / Open Graph card
imgforge generate --template blog --title "My Post Title" --bg ./cover.jpg --out og.png --format blog
# NimblePros blog card with subtitle and headshot
imgforge generate --template blog-subtitle --title "Next Level AI Agents" \
--subtitle "MCP Servers" --bg ./cover.jpg \
--headshot ./logo.png --format blog --out og.png
# GitHub social preview
imgforge generate --template templates/github-social.html --title "my-repo" --bg ./cover.jpg --out social-preview.png --format github
# Podcast show art
imgforge generate --template templates/podcast-show.html --title "My Podcast" --out podcast-show.png --format podcast-show
# Podcast episode art with guest headshot, season/episode labels, and named show in footer
imgforge generate --template templates/podcast-episode.html \
--title "Build vs Buy with guest Ardalis" \
--headshot ./guest.jpg --headshot-filter blue-mono \
--var season=3 --var episode=42 --var show="It Depends by NimblePros" \
--out podcast-episode.png --format podcast-episode
# Podcast episode art — greyscale headshot instead of blue tint
imgforge generate --template templates/podcast-episode.html --title "My Episode Title" \
--headshot ./guest.jpg --headshot-filter mono --out podcast-episode.png --format podcast-episode
# Write output to a specific directory (filename derived from title)
imgforge generate --template blog --title "My Post Title" --bg ./cover.jpg --out-dir ./output --format blog
# Omit --format and --width/--height to be prompted interactively
imgforge generate --template templates/podcast-episode.html --title "My Episode Title" --out out.png
# Omit --out and --out-dir to use title as the output filename in the current directory
imgforge generate --template youtube --title "This is the title of the show" --format youtubeImgForge includes 3 embedded templates that are always available when you install the tool:
| Template Name | Default Dimensions | Description | Usage |
|---|---|---|---|
blog |
1200×630 | Open Graph / social preview card with centered title | --template blog |
youtube |
1280×720 | YouTube thumbnail with bold title styling | --template youtube |
blog-subtitle |
1200×630 | Open Graph card with faded background, optional subtitle, and headshot support | --template blog-subtitle |
These templates are embedded in the tool's DLL and work immediately after installation—no need to download template files.
# Simple blog Open Graph image
imgforge generate --template blog --title "My Blog Post" --bg random --format blog
# YouTube thumbnail
imgforge generate --template youtube --title "My Video Title" --bg random --format youtube
# Blog card with subtitle and headshot
imgforge generate --template blog-subtitle \
--title "Building Modular Monoliths" \
--subtitle "A Practical Guide" \
--bg ./cover.jpg \
--headshot ./author.jpg \
--format blogThe repository's templates/ folder contains additional example templates (like youtube-bold.html, podcast-episode.html, github-social.html) that are not embedded in the tool. To use these, either:
-
Copy them from the repository and reference by file path:
imgforge generate --template ./templates/youtube-bold.html --title "My Title" --format youtube -
Clone the repo and run from source to access all example templates
Any .html file (or a folder with a template.html file in it) can be used as a template. If you omit --template, ImgForge uses /.imgforge/index.html as the default custom template path. Scriban Liquid syntax is supported for variable injection:
<html>
<body style="width:{{ width }}px; height:{{ height }}px; margin:0;
background-image:url('{{ bg }}'); background-size:cover;
display:flex; align-items:center; justify-content:center;
font-family:sans-serif; color:white;">
<h1 style="font-size:64px; text-align:center;">{{ title }}</h1>
{% for img in overlays %}
<img src="{{ img.src }}" style="position:absolute; {{ img.style }}" />
{% endfor %}
{% if headshot %}
<img src="{{ headshot.src }}" style="filter:{{ headshot.filter_css }};" />
{% endif %}
<p>Season {{ vars.season }}, Episode {{ vars.episode }}</p>
</body>
</html>| Variable | Type | Description |
|---|---|---|
title |
string |
Main heading text |
subtitle |
string |
Optional subtitle text — empty string when --subtitle is not supplied; guard with {% if subtitle %} in templates |
bg |
string |
Background image URI — local file:/// paths, HTTP(S) URLs, and random picsum URLs are all resolved before injection |
width |
int |
Viewport width in pixels |
height |
int |
Viewport height in pixels |
overlays |
array | List of { src, style } overlay image objects |
headshot |
object or null |
Guest headshot — exposes headshot.src (file URI) and headshot.filter_css (CSS filter string). null when --headshot is not supplied |
vars |
object | Arbitrary key/value pairs supplied via --var key=value. Access as {{ vars.key }} |
Pass a name to --headshot-filter or supply any raw CSS filter string.
| Name | CSS applied | Effect |
|---|---|---|
blue-mono (default) |
grayscale(100%) sepia(100%) hue-rotate(190deg) saturate(300%) brightness(1.15) |
Light-blue monochrome — "black and white but in shades of blue" |
mono |
grayscale(100%) |
True greyscale |
none |
none |
No filter; original colours kept |
| (custom) | (your string) | Any valid CSS filter string, e.g. sepia(80%) hue-rotate(270deg) |
When you pass a directory path to --template, ImgForge looks for template.html inside it and injects a <base> tag so that relative image references (e.g. a watermark sitting next to template.html) resolve correctly.
my-template/
template.html
watermark.png ← referenced as just "watermark.png" inside the template
imgforge generate --template ./my-template --title "Hello" --format blogdotnet build
# Unit tests (no browser required)
dotnet test --filter "Category!=Integration"
# Full test suite including integration tests (Chromium is downloaded automatically on first run)
dotnet testdotnet tool install --global ImgForge
imgforge generate --template blog --title "Hello!" --bg random --format blogChromium is downloaded automatically on the first run — no separate install step needed.
You can run ImgForge without installing it globally:
# Simple example
dnx -y imgforge -- generate --template blog --title "Hello World" --bg random --format blog
# YouTube thumbnail
dnx -y imgforge -- generate --template youtube --title "My Video" --bg random --format youtube
# Blog post with subtitle
dnx -y imgforge -- generate --template blog-subtitle \
--title "Advanced Techniques" \
--subtitle "Learn the secrets" \
--bg random \
--format blogThe -y flag automatically accepts any prompts, making it perfect for quick one-off image generation.