Files
SousChefAI/thumbnail.py

255 lines
8.7 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
thumbnail.py generate a link-preview card thumbnail from a screenshot.
Usage:
python3 thumbnail.py <screenshot> <url> <title> [-o output.png] [--width 800]
Example:
python3 thumbnail.py page.png "souschef.wahwa.com" "Chana Masala" -o thumb.png
"""
import argparse
import sys
try:
from PIL import Image, ImageDraw, ImageFont, ImageFilter
except ImportError:
sys.exit("Install Pillow: pip install Pillow")
# Render at 2× then downsample — gives crisp text and sharp edges.
RENDER_SCALE = 2
# ── visual constants (all at 1× display size) ─────────────────────────────────
BG_COLOR = (255, 251, 235) # warm cream
CARD_COLOR = (255, 255, 255)
URL_COLOR = (37, 99, 235) # blue-600
TITLE_COLOR = (17, 24, 39) # gray-900
DIVIDER_COLOR = (229, 231, 235) # gray-200
ICON_BG_COLOR = (240, 241, 243) # gray-100
ICON_FG_COLOR = (100, 108, 120) # gray-500
CORNER_RADIUS = 16
CARD_PADDING = 24
HEADER_V_PAD = 20
ICON_SIZE = 44 # circle diameter
TEXT_GAP = 14
CANVAS_PAD = 44
MAX_CONTENT_H = 640
# ── fonts ─────────────────────────────────────────────────────────────────────
def _load_font(paths, size):
for p in paths:
try:
return ImageFont.truetype(p, size)
except (IOError, OSError):
pass
return ImageFont.load_default()
def get_font(size, bold=False):
if bold:
paths = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
else:
paths = [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
return _load_font(paths, size)
# ── text measurement ──────────────────────────────────────────────────────────
def tw(f, s):
bb = f.getbbox(s)
return bb[2] - bb[0]
def th(f, s="Ag"):
bb = f.getbbox(s)
return bb[3] - bb[1]
# ── paperclip icon ────────────────────────────────────────────────────────────
def draw_paperclip_icon(draw, cx, cy, r, color, lw):
"""
Two concentric rounded-rectangle outlines sharing the same top arc
the classic paperclip silhouette.
"""
# Outer pill
ow = int(r * 0.50) # half-width
oh = int(r * 0.82) # half-height
draw.rounded_rectangle(
[cx - ow, cy - oh, cx + ow, cy + oh],
radius=ow, # = half-width → perfect semicircular ends
outline=color,
width=lw,
)
# Inner clip: same top, ends ~55% down
iw = int(r * 0.21)
i_top = cy - oh + lw + max(2, lw // 2) # just clear of outer top arc
i_bot = cy + int(r * 0.46)
draw.rounded_rectangle(
[cx - iw, i_top, cx + iw, i_bot],
radius=iw,
outline=color,
width=lw,
)
def draw_icon(draw, cx, cy, r):
"""Paperclip inside a light circle."""
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=ICON_BG_COLOR)
lw = max(2, r // 9)
draw_paperclip_icon(draw, cx, cy, r, ICON_FG_COLOR, lw)
# ── external-link glyph ───────────────────────────────────────────────────────
def draw_ext_link(draw, x, y, s, color, lw):
"""
Classic external-link indicator: small box (lower-left) with an arrow
exiting through the upper-right corner.
"""
m = lw
# Box occupies the lower-left ~60% of the bounding square
bx2 = x + int(s * 0.62)
by1 = y + int(s * 0.38)
draw.rounded_rectangle(
[x + m, by1, bx2, y + s - m],
radius=max(1, s // 7),
outline=color,
width=lw,
)
# Diagonal shaft from inside the box toward upper-right
ax1, ay1 = x + int(s * 0.34), y + int(s * 0.66)
ax2, ay2 = x + s - m, y + m
draw.line([(ax1, ay1), (ax2, ay2)], fill=color, width=lw)
# Arrowhead (two orthogonal tails at the tip)
aw = int(s * 0.30)
draw.line([(ax2 - aw, ay2), (ax2, ay2)], fill=color, width=lw)
draw.line([(ax2, ay2), (ax2, ay2 + aw)], fill=color, width=lw)
# ── main ──────────────────────────────────────────────────────────────────────
def create_thumbnail(screenshot_path, url, title,
output_path="thumbnail.png", card_width=800):
S = RENDER_SCALE
screenshot = Image.open(screenshot_path).convert("RGB")
url_f = get_font(17 * S)
title_f = get_font(22 * S, bold=True)
uh = th(url_f, url)
th_ = th(title_f, title)
text_block_h = uh + 8 * S + th_
# Scaled geometry
CW = card_width * S
CP = CARD_PADDING * S
HVP = HEADER_V_PAD * S
IS = ICON_SIZE * S
TG = TEXT_GAP * S
CR = CORNER_RADIUS * S
CNVP = CANVAS_PAD * S
MCH = MAX_CONTENT_H * S
header_h = HVP + max(IS, text_block_h) + HVP
# Scale screenshot to fill card width; crop height
raw_h = int(screenshot.height * CW / screenshot.width)
content_h = min(raw_h, MCH)
ss = screenshot.resize((CW, raw_h), Image.LANCZOS).crop((0, 0, CW, content_h))
card_h = header_h + S + content_h
canvas_w = CW + CNVP * 2
canvas_h = card_h + CNVP * 2
canvas = Image.new("RGBA", (canvas_w, canvas_h), BG_COLOR + (255,))
# Drop shadow
shadow = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
sd = ImageDraw.Draw(shadow)
off = 6 * S
sd.rounded_rectangle(
[CNVP + off, CNVP + off, CNVP + CW + off, CNVP + card_h + off],
radius=CR, fill=(0, 0, 0, 38),
)
shadow = shadow.filter(ImageFilter.GaussianBlur(11 * S))
canvas.alpha_composite(shadow)
# White card
card_l = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ImageDraw.Draw(card_l).rounded_rectangle(
[CNVP, CNVP, CNVP + CW, CNVP + card_h],
radius=CR, fill=CARD_COLOR + (255,),
)
canvas.alpha_composite(card_l)
draw = ImageDraw.Draw(canvas)
# Icon (vertically centred in header)
icx = CNVP + CP + IS // 2
icy = CNVP + header_h // 2
draw_icon(draw, icx, icy, IS // 2)
# URL text
tx = CNVP + CP + IS + TG
ty_url = CNVP + (header_h - text_block_h) // 2
draw.text((tx, ty_url), url, font=url_f, fill=URL_COLOR)
# External-link glyph (same height as URL text, a little larger)
uw = tw(url_f, url)
glyph_s = int(uh * 1.05)
glyph_lw = max(2, S)
draw_ext_link(
draw,
tx + uw + 6 * S,
ty_url + (uh - glyph_s) // 2,
glyph_s, URL_COLOR, glyph_lw,
)
# Title
draw.text((tx, ty_url + uh + 8 * S), title, font=title_f, fill=TITLE_COLOR)
# Divider
div_y = CNVP + header_h
draw.line([(CNVP, div_y), (CNVP + CW, div_y)], fill=DIVIDER_COLOR, width=S)
# Screenshot (edge-to-edge inside card)
rgb = canvas.convert("RGB")
rgb.paste(ss, (CNVP, div_y + S))
# Clip to rounded card shape
mask = Image.new("L", (canvas_w, canvas_h), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[CNVP, CNVP, CNVP + CW, CNVP + card_h], radius=CR, fill=255,
)
bg = Image.new("RGB", (canvas_w, canvas_h), BG_COLOR)
bg.paste(rgb, mask=mask)
# Downsample to 1× for crisp output
out_w, out_h = canvas_w // S, canvas_h // S
bg.resize((out_w, out_h), Image.LANCZOS).save(output_path)
print(f"Saved: {output_path} ({out_w} × {out_h})")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Generate a link-preview thumbnail card")
ap.add_argument("screenshot", help="Path to the input screenshot")
ap.add_argument("url", help='URL label, e.g. "souschef.wahwa.com"')
ap.add_argument("title", help="Page title displayed in bold")
ap.add_argument("-o", "--output", default="thumbnail.png",
help="Output image path (default: thumbnail.png)")
ap.add_argument("--width", type=int, default=800,
help="Card width in pixels (default: 800)")
args = ap.parse_args()
create_thumbnail(args.screenshot, args.url, args.title, args.output, args.width)