| name | social-image |
| description | Generate social media images (Facebook Stories & Post) with photo backgrounds fetched from free sources (Unsplash, Pexels, Pixabay) by keyword, or generated abstract patterns (wave, mesh, geometric). Centers title + excerpt/ quote on top of the background with contrast overlay. |
| version | 1.1.0 |
| category | delivery |
Social Image Generator Skill
Generate share-worthy social media images with photo or abstract backgrounds
and centered text content — perfect for sharing article excerpts, quotes, or
key insights on Facebook.
Supported Formats
| Format | Size | Use Case |
|---|
| Facebook Stories | 1080 × 1920 | Vertical story, full-bleed look |
| Facebook Post | 1080 × 1080 | Square feed post |
Background: Photo Search (preferred)
Search free stock photo APIs by keyword. Priority order:
- Unsplash —
UNSPLASH_ACCESS_KEY env var required (free tier: 50 req/hr)
- Pexels —
PEXELS_API_KEY env var required (free tier: 200 req/hr)
- Pixabay —
PIXABAY_API_KEY env var required (free tier: 100 req/hr)
- Fallback — generated abstract pattern if no API key is set
Default keywords
When the user does not supply bg_keyword, auto-derive from context:
| Style hint | Search keyword |
|---|
wave | abstract wave dark background |
nature | nature bokeh landscape |
tech | technology dark circuit abstract |
minimal | minimal gradient background |
ink | ink splash colorful abstract |
| (none) | abstract dark background wallpaper |
The skill always appends wallpaper or background to improve result quality.
Photo selection
- Fetch first 10 results, pick the one with the widest aspect ratio closest
to target format (portrait for Stories, square for Post).
- Download to a temp file, then resize/crop to exact canvas size (cover mode).
- Apply a dark semi-transparent overlay (
rgba(0,0,0,0.55)) to ensure
text contrast ratio ≥ 4.5:1 regardless of photo brightness.
Background: Generated Abstract Patterns (fallback / explicit)
Set bg=generated to skip photo search and use procedural patterns.
| Style | Description |
|---|
wave | Sinusoidal layered waves, editorial feel (default) |
mesh | Multi-point radial gradient blobs, vibrant modern |
geometric | Scattered polygons + connecting lines, tech/data |
ink | Organic blob shapes with soft edges, artistic |
Default palette: deep indigo → violet → rose (#1a1a2e → #16213e → #e94560)
Content Layout
┌──────────────────────────────┐
│ │
│ ░░░ Photo / Pattern ░░░ │
│ ░░░ + dark overlay ░░░ │
│ │
│ ┌──────────────┐ │
│ │ [Logo/Icon] │ │ ← optional, top-center
│ └──────────────┘ │
│ │
│ ╔══════════════════════╗ │
│ ║ Title / Quote ║ │ ← bold, white, centered
│ ║ ║ │
│ ║ Excerpt / TOC ║ │ ← smaller, 80% white
│ ╚══════════════════════╝ │
│ │
│ ── source / url ── │ ← optional footer
│ │
└──────────────────────────────┘
Text Zones
- Title: Max 60 chars, ~72–80px bold, white + drop shadow
- Excerpt / TOC: Max 4 lines × 80 chars, ~36px, 80% opacity white
- Footer: Domain or author, 24px, 60% opacity white
Input Parameters
title [required] Article/post title or quote
excerpt [optional] Short description, TOC bullet points, or key insight
format [optional] "stories" | "post" (default: "post")
bg_keyword [optional] Search keyword for background photo
e.g. "abstract wave dark", "forest fog morning"
(default: auto-derived from style)
bg [optional] "photo" | "generated" (default: "photo")
style [optional] "wave"|"mesh"|"geometric"|"ink" (used when bg=generated
or as keyword hint when bg=photo, default: "wave")
palette [optional] "dark"|"light"|"brand" (default: "dark")
overlay [optional] Overlay opacity 0.0–1.0 (default: 0.55)
output [optional] Output file path (default: ./social-image.png)
Example calls
/social-image
title="5 nguyên tắc thiết kế UX hiệu quả"
excerpt="• Hierarchy rõ ràng\n• Whitespace đúng chỗ\n• Contrast tương phản cao"
format=stories
bg_keyword="abstract wave purple dark"
/social-image
title="Why MikroTik beats Cisco for home labs"
excerpt="Cost, community support, and RouterOS flexibility make it the clear winner."
format=post
bg_keyword="dark technology circuit"
overlay=0.6
/social-image
title="Design is not how it looks. It's how it works."
format=post
bg=generated
style=mesh
palette=dark
Process Steps
- Parse inputs — title, excerpt, format, bg mode, keyword, style, output
- Select canvas — 1080×1920 (stories) or 1080×1080 (post)
- Fetch/generate background:
bg=photo: search APIs in priority order → download best match → resize/crop
bg=generated: draw procedural pattern with selected style + palette
- Apply overlay — semi-transparent dark layer for text legibility
- Compose text layers — title (bold, centered), excerpt (smaller), footer
- Export PNG — save to
output path; print absolute path to stdout
- Show attribution — print photo credit (photographer + source URL) if photo used
Implementation Script
"""
social_image.py — Social image generator with photo search + abstract fallback
Requires: pip install Pillow numpy requests
"""
import argparse
import math
import os
import sys
import tempfile
import urllib.request
from pathlib import Path
import numpy as np
import requests
from PIL import Image, ImageDraw, ImageFilter, ImageFont
SIZES = {
"post": (1080, 1080),
"stories": (1080, 1920),
}
PALETTES = {
"dark": [(26, 26, 46), (22, 33, 62), (233, 69, 96)],
"light": [(240, 240, 255),(200, 200, 240),(80, 80, 160)],
"brand": [(10, 10, 30), (30, 10, 60), (255, 165, 0)],
}
STYLE_KEYWORDS = {
"wave": "abstract wave dark background wallpaper",
"mesh": "abstract gradient mesh colorful background wallpaper",
"geometric": "geometric dark abstract technology background",
"ink": "ink splash colorful abstract background",
"nature": "nature bokeh landscape background",
"minimal": "minimal gradient background wallpaper",
}
def search_unsplash(keyword: str, orientation: str) -> tuple[str, str] | None:
"""Returns (image_url, attribution) or None."""
key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not key:
return None
orient = "portrait" if orientation == "stories" else "squarish"
url = (
f"https://api.unsplash.com/search/photos"
f"?query={urllib.parse.quote(keyword)}&orientation={orient}&per_page=5"
)
try:
r = requests.get(url, headers={"Authorization": f"Client-ID {key}"}, timeout=10)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return None
photo = results[0]
img_url = photo["urls"]["regular"]
credit = f"Photo by {photo['user']['name']} on Unsplash"
return img_url, credit
except Exception as e:
print(f"[unsplash] {e}", file=sys.stderr)
return None
def search_pexels(keyword: str, orientation: str) -> tuple[str, str] | None:
key = os.environ.get("PEXELS_API_KEY")
if not key:
return None
orient = "portrait" if orientation == "stories" else "square"
url = (
f"https://api.pexels.com/v1/search"
f"?query={urllib.parse.quote(keyword)}&orientation={orient}&per_page=5"
)
try:
r = requests.get(url, headers={"Authorization": key}, timeout=10)
r.raise_for_status()
photos = r.json().get("photos", [])
if not photos:
return None
photo = photos[0]
img_url = photo["src"]["large2x"]
credit = f"Photo by {photo['photographer']} on Pexels"
return img_url, credit
except Exception as e:
print(f"[pexels] {e}", file=sys.stderr)
return None
def search_pixabay(keyword: str, orientation: str) -> tuple[str, str] | None:
key = os.environ.get("PIXABAY_API_KEY")
if not key:
return None
orient = "vertical" if orientation == "stories" else "horizontal"
url = (
f"https://pixabay.com/api/"
f"?key={key}&q={urllib.parse.quote(keyword)}"
f"&orientation={orient}&per_page=5&image_type=photo"
)
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
hits = r.json().get("hits", [])
if not hits:
return None
photo = hits[0]
img_url = photo["largeImageURL"]
credit = f"Photo by {photo['user']} on Pixabay"
return img_url, credit
except Exception as e:
print(f"[pixabay] {e}", file=sys.stderr)
return None
def fetch_photo_background(keyword: str, fmt: str, w: int, h: int) -> tuple[Image.Image, str] | None:
"""Search APIs in priority order, return (resized_image, credit) or None."""
for search_fn in [search_unsplash, search_pexels, search_pixabay]:
result = search_fn(keyword, fmt)
if result:
img_url, credit = result
break
else:
return None
import urllib.request as ur
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp_path = tmp.name
try:
ur.urlretrieve(img_url, tmp_path)
img = Image.open(tmp_path).convert("RGB")
img_ratio = img.width / img.height
target_ratio = w / h
if img_ratio > target_ratio:
new_h = h
new_w = int(h * img_ratio)
else:
new_w = w
new_h = int(w / img_ratio)
img = img.resize((new_w, new_h), Image.LANCZOS)
left = (new_w - w) // 2
top = (new_h - h) // 2
img = img.crop((left, top, left + w, top + h))
return img, credit
except Exception as e:
print(f"[fetch] {e}", file=sys.stderr)
return None
finally:
Path(tmp_path).unlink(missing_ok=True)
def draw_wave(w: int, h: int, palette) -> Image.Image:
c1, c2, c3 = [np.array(p) for p in palette]
arr = np.zeros((h, w, 3), dtype=np.uint8)
for y in range(h):
t = y / h
arr[y] = (c1 * (1 - t) + c2 * t).astype(np.uint8)
img = Image.fromarray(arr, "RGB")
draw = ImageDraw.Draw(img, "RGBA")
for i in range(6):
amplitude = w * 0.08
freq = 2 + i * 0.5
phase = i * 0.7
alpha = int(50 - i * 5)
pts = [(x, int(h * (0.3 + 0.1 * i) + amplitude * math.sin(freq * x / w * 2 * math.pi + phase)))
for x in range(0, w + 4, 4)]
pts += [(w, h), (0, h)]
draw.polygon(pts, fill=(*c3, alpha))
return img
def draw_mesh(w: int, h: int, palette) -> Image.Image:
import random
c1, c2, c3 = palette
img = Image.new("RGB", (w, h), c1)
draw = ImageDraw.Draw(img, "RGBA")
random.seed(42)
colors = [c2, c3, (180, 60, 200), (60, 120, 255)]
for _ in range(8):
cx = random.randint(0, w)
cy = random.randint(0, h)
r = random.randint(w // 4, w)
col = random.choice(colors)
for ring in range(0, r, 40):
alpha = max(0, int(80 * (1 - ring / r)))
draw.ellipse([cx - ring, cy - ring, cx + ring, cy + ring],
fill=(*col, alpha))
return img.filter(ImageFilter.GaussianBlur(radius=60))
def generated_background(style: str, w: int, h: int, palette) -> Image.Image:
if style == "mesh":
return draw_mesh(w, h, palette)
return draw_wave(w, h, palette)
def load_fonts():
candidates = [
"/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
for path in candidates:
if Path(path).exists():
try:
return (
ImageFont.truetype(path, 72),
ImageFont.truetype(path, 36),
ImageFont.truetype(path, 24),
)
except Exception:
continue
f = ImageFont.load_default()
return f, f, f
def wrap_text(text: str, font, max_width: int, draw: ImageDraw.ImageDraw) -> list[str]:
lines, line = [], ""
for word in text.split():
test = f"{line} {word}".strip()
if draw.textlength(test, font=font) <= max_width:
line = test
else:
if line:
lines.append(line)
line = word
if line:
lines.append(line)
return lines
def generate(
title: str,
excerpt: str = "",
fmt: str = "post",
bg: str = "photo",
bg_keyword: str = "",
style: str = "wave",
palette: str = "dark",
overlay: float = 0.55,
output: str = "social-image.png",
):
w, h = SIZES.get(fmt, SIZES["post"])
pal = PALETTES.get(palette, PALETTES["dark"])
credit = ""
if bg == "photo":
keyword = bg_keyword or STYLE_KEYWORDS.get(style, "abstract dark background wallpaper")
result = fetch_photo_background(keyword, fmt, w, h)
if result:
img, credit = result
print(f"[bg] {credit}")
else:
print("[bg] No photo API key found or fetch failed — using generated pattern", file=sys.stderr)
img = generated_background(style, w, h, pal)
else:
img = generated_background(style, w, h, pal)
overlay_layer = Image.new("RGBA", (w, h), (0, 0, 0, int(255 * overlay)))
img = img.convert("RGBA")
img = Image.alpha_composite(img, overlay_layer).convert("RGB")
draw = ImageDraw.Draw(img)
font_title, font_body, font_footer = load_fonts()
pad = 80
text_w = w - pad * 2
title_lines = wrap_text(title, font_title, text_w, draw)
excerpt_lines = wrap_text(excerpt, font_body, text_w, draw) if excerpt else []
title_h = len(title_lines) * 88
excerpt_h = len(excerpt_lines) * 48
gap = 24 if excerpt_lines else 0
total_h = title_h + gap + excerpt_h
y = (h - total_h) // 2
for line in title_lines:
lw = draw.textlength(line, font=font_title)
x = (w - lw) // 2
draw.text((x + 3, y + 3), line, font=font_title, fill=(0, 0, 0, 140))
draw.text((x, y ), line, font=font_title, fill=(255,255,255))
y += 88
if excerpt_lines:
y += gap
for line in excerpt_lines:
lw = draw.textlength(line, font=font_body)
x = (w - lw) // 2
draw.text((x, y), line, font=font_body, fill=(220,220,255))
y += 48
if credit:
short = credit[:60]
lw = draw.textlength(short, font=font_footer)
draw.text(((w - lw) // 2, h - 48), short, font=font_footer, fill=(200,200,200))
img.save(output)
print(f"Saved → {Path(output).resolve()} ({w}×{h})")
if __name__ == "__main__":
import urllib.parse
p = argparse.ArgumentParser(description="Generate social media images")
p.add_argument("--title", required=True, help="Title or quote text")
p.add_argument("--excerpt", default="", help="Excerpt or TOC bullets")
p.add_argument("--format", default="post", choices=["post","stories"])
p.add_argument("--bg", default="photo", choices=["photo","generated"])
p.add_argument("--bg-keyword", default="", help="Photo search keyword")
p.add_argument("--style", default="wave", choices=["wave","mesh","geometric","ink"])
p.add_argument("--palette", default="dark", choices=["dark","light","brand"])
p.add_argument("--overlay", default=0.55, type=float)
p.add_argument("--output", default="social-image.png")
args = p.parse_args()
generate(
title = args.title,
excerpt = args.excerpt,
fmt = args.format,
bg = args.bg,
bg_keyword = args.bg_keyword,
style = args.style,
palette = args.palette,
overlay = args.overlay,
output = args.output,
)
Environment Variables (Photo Search)
Set at least one to enable photo background fetching:
export UNSPLASH_ACCESS_KEY="..."
export PEXELS_API_KEY="..."
export PIXABAY_API_KEY="..."
If none are set, the skill falls back to generated abstract patterns automatically.
Quality Gates
- Background fills full canvas (cover crop, no letterbox)
- Dark overlay applied → text contrast ratio ≥ 4.5:1
- Title fully visible, not truncated
- Excerpt wraps cleanly, ≤ 4 lines
- Photo credit shown in footer and printed to stdout
- Output file exists, valid PNG, correct dimensions
Dependencies
pip install Pillow numpy requests