mirror of
https://github.com/jezweb/claude-skills.git
synced 2026-09-19 01:07:27 +08:00
7ba8be1409
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.
317 lines
11 KiB
Python
Executable File
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()
|