From social-image
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.
npx claudepluginhub leolionart/claude-skills --plugin social-imageThis skill uses the workspace's default tool permissions.
Generate share-worthy social media images with photo or abstract backgrounds
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
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.
| Format | Size | Use Case |
|---|---|---|
| Facebook Stories | 1080 × 1920 | Vertical story, full-bleed look |
| Facebook Post | 1080 × 1080 | Square feed post |
Search free stock photo APIs by keyword. Priority order:
UNSPLASH_ACCESS_KEY env var required (free tier: 50 req/hr)PEXELS_API_KEY env var required (free tier: 200 req/hr)PIXABAY_API_KEY env var required (free tier: 100 req/hr)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.
rgba(0,0,0,0.55)) to ensure
text contrast ratio ≥ 4.5:1 regardless of photo brightness.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)
┌──────────────────────────────┐
│ │
│ ░░░ Photo / Pattern ░░░ │
│ ░░░ + dark overlay ░░░ │
│ │
│ ┌──────────────┐ │
│ │ [Logo/Icon] │ │ ← optional, top-center
│ └──────────────┘ │
│ │
│ ╔══════════════════════╗ │
│ ║ Title / Quote ║ │ ← bold, white, centered
│ ║ ║ │
│ ║ Excerpt / TOC ║ │ ← smaller, 80% white
│ ╚══════════════════════╝ │
│ │
│ ── source / url ── │ ← optional footer
│ │
└──────────────────────────────┘
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)
/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
bg=photo: search APIs in priority order → download best match → resize/cropbg=generated: draw procedural pattern with selected style + paletteoutput path; print absolute path to stdout#!/usr/bin/env python3
"""
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
# ── Canvas sizes ──────────────────────────────────────────────────────────────
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",
}
# ── Photo search ──────────────────────────────────────────────────────────────
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")
# Cover-crop to target size
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)
# ── Generated abstract backgrounds ───────────────────────────────────────────
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) # wave / geometric / ink all fallback to wave
# ── Text helpers ──────────────────────────────────────────────────────────────
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
# ── Main composer ─────────────────────────────────────────────────────────────
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"])
# ── Background ────────────────────────────────────────────────────────────
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)
# ── Dark overlay for contrast ─────────────────────────────────────────────
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
# ── Measure text blocks ───────────────────────────────────────────────────
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
# ── Draw title ────────────────────────────────────────────────────────────
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
# ── Draw excerpt ──────────────────────────────────────────────────────────
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
# ── Footer ────────────────────────────────────────────────────────────────
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})")
# ── CLI ───────────────────────────────────────────────────────────────────────
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,
)
Set at least one to enable photo background fetching:
export UNSPLASH_ACCESS_KEY="..." # https://unsplash.com/developers (free)
export PEXELS_API_KEY="..." # https://www.pexels.com/api/ (free)
export PIXABAY_API_KEY="..." # https://pixabay.com/api/docs/ (free)
If none are set, the skill falls back to generated abstract patterns automatically.
pip install Pillow numpy requests