Files
jezweb__claude-skills/bin/img-process
marcusjezweb 7ba8be1409 feat: add capture-screenshots and img-process executables (#72)
Two bin/ executables replacing runtime script generation. capture-screenshots (Node/Playwright) handles product showcase captures with dark mode, mobile, multi-page support. img-process (Python/Pillow) handles resize, convert, trim, thumbnail, optimise, OG cards, and batch operations. Relates to #70.
2026-04-03 22:56:50 +11:00

317 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""img-process — Image processing for web development.
Resize, crop, trim, convert, optimise, and generate OG cards.
Uses Pillow (Python). No ImageMagick needed.
Usage:
img-process resize <input> [--width W] [--height H] [-o output]
img-process convert <input> --format <fmt> [-o output] [--quality Q]
img-process trim <input> [-o output] [--padding P]
img-process thumbnail <input> --size <S> [-o output]
img-process optimise <input> [-o output] [--quality Q] [--max-width W]
img-process og-card -o <output> [--title T] [--subtitle S] [--bg-color C] [--bg-image I]
img-process batch <dir> --action <action> [--format fmt] [--width W] [--quality Q] [-o outdir]
Options:
-o, --output <path> Output file or directory
--width <px> Target width (maintains aspect ratio)
--height <px> Target height (maintains aspect ratio)
--size <px> Max dimension for thumbnails
--format <fmt> Output format: webp, png, jpg
--quality <Q> Compression quality 1-100 (default: 85 webp, 90 jpg)
--max-width <px> Resize if wider than this (default: 1920)
--padding <px> Padding after trim (default: 0)
--title <text> OG card title text
--subtitle <text> OG card subtitle text
--bg-color <hex> OG card background colour (default: #1a1a2e)
--bg-image <path> OG card background image
--action <name> Batch action: resize, convert, optimise, thumbnail
Examples:
img-process resize hero.png --width 1920
img-process convert logo.png --format webp
img-process trim logo-raw.jpg -o logo-clean.png --padding 10
img-process thumbnail photo.jpg --size 200
img-process optimise hero.jpg --quality 85 --max-width 1920
img-process og-card -o og.png --title "My App" --subtitle "The best app ever"
img-process batch ./images --action convert --format webp -o ./optimised
"""
import sys
import os
import glob
import argparse
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: Pillow is required. Install with: pip install Pillow")
sys.exit(1)
def get_font(size):
"""Find a system font, falling back to Pillow default."""
font_paths = [
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/SFNSText.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
]
for p in font_paths:
if os.path.exists(p):
try:
return ImageFont.truetype(p, size)
except Exception:
continue
return ImageFont.load_default()
def save_image(img, output_path, quality=None):
"""Save with format-specific settings, handling RGBA→JPG conversion."""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
kwargs = {}
ext = output_path.lower().rsplit(".", 1)[-1]
if ext == "webp":
kwargs = {"quality": quality or 85, "method": 6}
elif ext in ("jpg", "jpeg"):
kwargs = {"quality": quality or 90, "optimize": True}
if img.mode == "RGBA":
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif ext == "png":
kwargs = {"optimize": True}
img.save(output_path, **kwargs)
size_kb = os.path.getsize(output_path) / 1024
print(f" ✓ {output_path} ({img.width}x{img.height}, {size_kb:.0f}KB)")
return output_path
def auto_output(input_path, output, new_ext=None):
"""Generate output path if not specified."""
if output:
return output
base, ext = os.path.splitext(input_path)
if new_ext:
ext = f".{new_ext}"
return f"{base}-processed{ext}"
def cmd_resize(args):
img = Image.open(args.input)
if args.width and args.height:
img = img.resize((args.width, args.height), Image.LANCZOS)
elif args.width:
ratio = args.width / img.width
img = img.resize((args.width, int(img.height * ratio)), Image.LANCZOS)
elif args.height:
ratio = args.height / img.height
img = img.resize((int(img.width * ratio), args.height), Image.LANCZOS)
else:
print("Error: specify --width and/or --height")
sys.exit(1)
save_image(img, auto_output(args.input, args.output), args.quality)
def cmd_convert(args):
if not args.format:
print("Error: --format is required (webp, png, jpg)")
sys.exit(1)
img = Image.open(args.input)
out = auto_output(args.input, args.output, args.format)
save_image(img, out, args.quality)
def cmd_trim(args):
img = Image.open(args.input)
if img.mode != "RGBA":
img = img.convert("RGBA")
bbox = img.getbbox()
if bbox:
padding = args.padding or 0
bbox = (
max(0, bbox[0] - padding),
max(0, bbox[1] - padding),
min(img.width, bbox[2] + padding),
min(img.height, bbox[3] + padding),
)
img = img.crop(bbox)
save_image(img, auto_output(args.input, args.output))
def cmd_thumbnail(args):
if not args.size:
print("Error: --size is required")
sys.exit(1)
img = Image.open(args.input)
img.thumbnail((args.size, args.size), Image.LANCZOS)
save_image(img, auto_output(args.input, args.output))
def cmd_optimise(args):
img = Image.open(args.input)
max_w = args.max_width or 1920
if img.width > max_w:
ratio = max_w / img.width
img = img.resize((max_w, int(img.height * ratio)), Image.LANCZOS)
out = auto_output(args.input, args.output, "webp")
save_image(img, out, args.quality or 85)
def cmd_og_card(args):
if not args.output:
print("Error: -o output path is required for og-card")
sys.exit(1)
width, height = 1200, 630
if args.bg_image:
img = Image.open(args.bg_image).resize((width, height), Image.LANCZOS)
img = img.convert("RGBA")
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 128))
img = Image.alpha_composite(img, overlay)
else:
color = args.bg_color or "#1a1a2e"
img = Image.new("RGB", (width, height), color)
img = img.convert("RGBA")
draw = ImageDraw.Draw(img)
if args.title:
font_title = get_font(52)
bbox = draw.textbbox((0, 0), args.title, font=font_title)
tw = bbox[2] - bbox[0]
y = height // 2 - 50
if args.subtitle:
y = height // 2 - 70
draw.text(((width - tw) // 2, y), args.title, fill="white", font=font_title)
if args.subtitle:
font_sub = get_font(28)
bbox = draw.textbbox((0, 0), args.subtitle, font=font_sub)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) // 2, height // 2 + 10), args.subtitle, fill="#a0aec0", font=font_sub)
img = img.convert("RGB")
save_image(img, args.output)
def cmd_batch(args):
if not args.action:
print("Error: --action is required (resize, convert, optimise, thumbnail)")
sys.exit(1)
input_dir = args.input
output_dir = args.output or f"{input_dir}-processed"
os.makedirs(output_dir, exist_ok=True)
extensions = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "*.bmp")
files = []
for ext in extensions:
files.extend(glob.glob(os.path.join(input_dir, ext)))
files.sort()
if not files:
print(f"No image files found in {input_dir}/")
sys.exit(1)
print(f"Batch {args.action}: {len(files)} files from {input_dir}/ → {output_dir}/")
for f in files:
basename = os.path.basename(f)
img = Image.open(f)
if args.action == "resize" and args.width:
ratio = args.width / img.width
img = img.resize((args.width, int(img.height * ratio)), Image.LANCZOS)
out = os.path.join(output_dir, basename)
save_image(img, out, args.quality)
elif args.action == "convert" and args.format:
name = os.path.splitext(basename)[0]
out = os.path.join(output_dir, f"{name}.{args.format}")
save_image(img, out, args.quality)
elif args.action == "optimise":
max_w = args.max_width or 1920
if img.width > max_w:
ratio = max_w / img.width
img = img.resize((max_w, int(img.height * ratio)), Image.LANCZOS)
name = os.path.splitext(basename)[0]
out = os.path.join(output_dir, f"{name}.webp")
save_image(img, out, args.quality or 85)
elif args.action == "thumbnail" and args.size:
img.thumbnail((args.size, args.size), Image.LANCZOS)
out = os.path.join(output_dir, basename)
save_image(img, out, args.quality)
else:
print(f" ? Skipped {basename} — missing required option for {args.action}")
print(f"\nDone: {len(files)} images processed → {output_dir}/")
def main():
parser = argparse.ArgumentParser(
description="img-process — Image processing for web development",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command")
# Shared args
for name, help_text in [
("resize", "Resize an image"),
("convert", "Convert image format"),
("trim", "Trim whitespace from image"),
("thumbnail", "Generate a thumbnail"),
("optimise", "Optimise for web (resize + compress to WebP)"),
("og-card", "Generate an OG card image (1200x630)"),
("batch", "Process multiple images"),
]:
p = sub.add_parser(name, help=help_text)
if name != "og-card":
p.add_argument("input", help="Input file or directory")
p.add_argument("-o", "--output", help="Output file or directory")
p.add_argument("--quality", type=int, help="Compression quality (1-100)")
p.add_argument("--width", type=int, help="Target width")
p.add_argument("--height", type=int, help="Target height")
p.add_argument("--size", type=int, help="Max dimension (thumbnail)")
p.add_argument("--format", help="Output format: webp, png, jpg")
p.add_argument("--max-width", type=int, help="Max width before resize")
p.add_argument("--padding", type=int, help="Padding after trim")
p.add_argument("--title", help="OG card title")
p.add_argument("--subtitle", help="OG card subtitle")
p.add_argument("--bg-color", help="OG card background colour")
p.add_argument("--bg-image", help="OG card background image")
p.add_argument("--action", help="Batch action: resize, convert, optimise, thumbnail")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
commands = {
"resize": cmd_resize,
"convert": cmd_convert,
"trim": cmd_trim,
"thumbnail": cmd_thumbnail,
"optimise": cmd_optimise,
"og-card": cmd_og_card,
"batch": cmd_batch,
}
print(f"img-process {args.command}")
print("=" * 40)
commands[args.command](args)
if __name__ == "__main__":
main()