Files
SousChefAI/thumbnail.py

255 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)