mirror of
https://github.com/jezweb/claude-skills.git
synced 2026-09-19 01:07:27 +08:00
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.
This commit is contained in:
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
// capture-screenshots — Playwright-based screenshot capture for product showcases
|
||||
//
|
||||
// Usage:
|
||||
// capture-screenshots <url> [options]
|
||||
//
|
||||
// Options:
|
||||
// --output, -o <dir> Output directory (default: ./screenshots)
|
||||
// --width, -w <px> Viewport width (default: 1280)
|
||||
// --height, -h <px> Viewport height (default: 720)
|
||||
// --mobile Also capture at 375px width
|
||||
// --dark Also capture in dark mode (prefers-color-scheme)
|
||||
// --full-page Capture full scrollable page
|
||||
// --wait <ms> Wait after load before capture (default: 1000)
|
||||
// --prefix <name> Filename prefix (default: "screen")
|
||||
// --format <fmt> png or webp (default: png)
|
||||
// --pages <urls> Comma-separated list of paths to capture (e.g. /,/features,/about)
|
||||
// --auth <user:pass> Basic auth credentials
|
||||
// --cookie <name=value> Set a cookie before capture
|
||||
//
|
||||
// Examples:
|
||||
// capture-screenshots http://localhost:5173
|
||||
// capture-screenshots https://app.example.com --pages /,/dashboard,/settings --mobile --dark
|
||||
// capture-screenshots http://localhost:3000 -o showcase/screenshots --prefix hero --full-page
|
||||
//
|
||||
// Requires: npx playwright install chromium (one-time setup)
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// --- Parse args ---
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args[0] === '--help') {
|
||||
console.log(`capture-screenshots — Playwright screenshot capture for product showcases
|
||||
|
||||
Usage: capture-screenshots <url> [options]
|
||||
|
||||
Options:
|
||||
--output, -o <dir> Output directory (default: ./screenshots)
|
||||
--width, -w <px> Viewport width (default: 1280)
|
||||
--height <px> Viewport height (default: 720)
|
||||
--mobile Also capture at 375px width
|
||||
--dark Also capture in dark mode
|
||||
--full-page Capture full scrollable page
|
||||
--wait <ms> Wait after load before capture (default: 1000)
|
||||
--prefix <name> Filename prefix (default: "screen")
|
||||
--format <fmt> png or webp (default: png)
|
||||
--pages <paths> Comma-separated URL paths (default: /)
|
||||
--auth <user:pass> Basic auth credentials
|
||||
--cookie <name=value> Set cookie before capture
|
||||
|
||||
Examples:
|
||||
capture-screenshots http://localhost:5173
|
||||
capture-screenshots https://app.example.com --pages /,/dashboard,/settings --mobile --dark
|
||||
capture-screenshots http://localhost:3000 -o showcase/screenshots --prefix hero --full-page`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function getArg(names, defaultVal) {
|
||||
if (!Array.isArray(names)) names = [names];
|
||||
for (const name of names) {
|
||||
const idx = args.indexOf(name);
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
return args[idx + 1];
|
||||
}
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
function hasFlag(names) {
|
||||
if (!Array.isArray(names)) names = [names];
|
||||
return names.some(n => args.includes(n));
|
||||
}
|
||||
|
||||
const baseUrl = args[0].startsWith('--') ? null : args[0];
|
||||
if (!baseUrl) {
|
||||
console.error('Error: URL is required as first argument');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = {
|
||||
output: getArg(['--output', '-o'], './screenshots'),
|
||||
width: parseInt(getArg(['--width', '-w'], '1280')),
|
||||
height: parseInt(getArg(['--height'], '720')),
|
||||
mobile: hasFlag(['--mobile']),
|
||||
dark: hasFlag(['--dark']),
|
||||
fullPage: hasFlag(['--full-page']),
|
||||
wait: parseInt(getArg(['--wait'], '1000')),
|
||||
prefix: getArg(['--prefix'], 'screen'),
|
||||
format: getArg(['--format'], 'png'),
|
||||
pages: getArg(['--pages'], '/').split(',').map(p => p.trim()),
|
||||
auth: getArg(['--auth'], null),
|
||||
cookie: getArg(['--cookie'], null),
|
||||
};
|
||||
|
||||
// --- Main ---
|
||||
async function captureScreenshots() {
|
||||
fs.mkdirSync(config.output, { recursive: true });
|
||||
|
||||
console.log(`capture-screenshots`);
|
||||
console.log(`==================`);
|
||||
console.log(`URL: ${baseUrl}`);
|
||||
console.log(`Pages: ${config.pages.join(', ')}`);
|
||||
console.log(`Viewport: ${config.width}x${config.height}`);
|
||||
console.log(`Output: ${config.output}/`);
|
||||
console.log(`Format: ${config.format}`);
|
||||
if (config.mobile) console.log(`Mobile: yes (375px)`);
|
||||
if (config.dark) console.log(`Dark mode: yes`);
|
||||
if (config.fullPage) console.log(`Full page: yes`);
|
||||
console.log('');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const captured = [];
|
||||
|
||||
try {
|
||||
// Desktop captures
|
||||
for (const pagePath of config.pages) {
|
||||
const url = new URL(pagePath, baseUrl).href;
|
||||
const pageName = pagePath === '/' ? 'home' : pagePath.replace(/^\//, '').replace(/\//g, '-');
|
||||
|
||||
// Light mode
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: config.width, height: config.height },
|
||||
colorScheme: 'light',
|
||||
httpCredentials: config.auth ? {
|
||||
username: config.auth.split(':')[0],
|
||||
password: config.auth.split(':').slice(1).join(':')
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
if (config.cookie) {
|
||||
const [name, value] = config.cookie.split('=');
|
||||
const urlObj = new URL(baseUrl);
|
||||
await context.addCookies([{ name, value, domain: urlObj.hostname, path: '/' }]);
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(config.wait);
|
||||
|
||||
const filename = `${config.prefix}-${pageName}.${config.format}`;
|
||||
const filepath = path.join(config.output, filename);
|
||||
await page.screenshot({ path: filepath, fullPage: config.fullPage });
|
||||
console.log(` ✓ ${filename} (${config.width}x${config.height})`);
|
||||
captured.push(filepath);
|
||||
|
||||
// Dark mode variant
|
||||
if (config.dark) {
|
||||
const darkCtx = await browser.newContext({
|
||||
viewport: { width: config.width, height: config.height },
|
||||
colorScheme: 'dark',
|
||||
httpCredentials: config.auth ? {
|
||||
username: config.auth.split(':')[0],
|
||||
password: config.auth.split(':').slice(1).join(':')
|
||||
} : undefined,
|
||||
});
|
||||
if (config.cookie) {
|
||||
const [name, value] = config.cookie.split('=');
|
||||
const urlObj = new URL(baseUrl);
|
||||
await darkCtx.addCookies([{ name, value, domain: urlObj.hostname, path: '/' }]);
|
||||
}
|
||||
const darkPage = await darkCtx.newPage();
|
||||
await darkPage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await darkPage.waitForTimeout(config.wait);
|
||||
|
||||
const darkFilename = `${config.prefix}-${pageName}-dark.${config.format}`;
|
||||
const darkFilepath = path.join(config.output, darkFilename);
|
||||
await darkPage.screenshot({ path: darkFilepath, fullPage: config.fullPage });
|
||||
console.log(` ✓ ${darkFilename} (dark mode)`);
|
||||
captured.push(darkFilepath);
|
||||
await darkCtx.close();
|
||||
}
|
||||
|
||||
// Mobile variant
|
||||
if (config.mobile) {
|
||||
const mobileCtx = await browser.newContext({
|
||||
viewport: { width: 375, height: 812 },
|
||||
colorScheme: 'light',
|
||||
isMobile: true,
|
||||
httpCredentials: config.auth ? {
|
||||
username: config.auth.split(':')[0],
|
||||
password: config.auth.split(':').slice(1).join(':')
|
||||
} : undefined,
|
||||
});
|
||||
if (config.cookie) {
|
||||
const [name, value] = config.cookie.split('=');
|
||||
const urlObj = new URL(baseUrl);
|
||||
await mobileCtx.addCookies([{ name, value, domain: urlObj.hostname, path: '/' }]);
|
||||
}
|
||||
const mobilePage = await mobileCtx.newPage();
|
||||
await mobilePage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await mobilePage.waitForTimeout(config.wait);
|
||||
|
||||
const mobileFilename = `${config.prefix}-${pageName}-mobile.${config.format}`;
|
||||
const mobileFilepath = path.join(config.output, mobileFilename);
|
||||
await mobilePage.screenshot({ path: mobileFilepath, fullPage: config.fullPage });
|
||||
console.log(` ✓ ${mobileFilename} (375x812)`);
|
||||
captured.push(mobileFilepath);
|
||||
await mobileCtx.close();
|
||||
}
|
||||
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Done: ${captured.length} screenshots captured to ${config.output}/`);
|
||||
return captured;
|
||||
}
|
||||
|
||||
captureScreenshots().catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+316
@@ -0,0 +1,316 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user