#!/usr/bin/env node // Lays the looks out as a contact sheet: a grid with thin gutters and an optional caption line. // Use --reference to put the person's photo first, for the side-by-side identity check (not for delivery). // Usage: // node scripts/contact-sheet.mjs --images 01.jpg,02.jpg,03.jpg,04.jpg,05.jpg,06.jpg --output sheet.webp // [--cols 3] [--width 1800] [--gutter 12] [--margin 36] [--background "#f4f1ec"] [--caption "LABEL|COLLECTION ยท 6 LOOKS"] [--quality 90] // node scripts/contact-sheet.mjs --reference person.jpg --images 01.jpg,...,06.jpg --cols 4 --output identity-check.jpg // Needs Node 20.9 or newer and sharp (run `npm install sharp` in the Skill folder). Output type follows the extension: .webp, .jpg or .png. import { existsSync } from 'node:fs' import { extname } from 'node:path' const usage = 'Usage: node scripts/contact-sheet.mjs --images a.jpg,b.jpg,... --output sheet.webp [--cols 3] [--width 1800] [--gutter 12] [--margin 36] [--background "#f4f1ec"] [--caption "LEFT|RIGHT"] [--reference person.jpg]' const VALUE_FLAGS = new Set(['images', 'output', 'cols', 'width', 'gutter', 'margin', 'background', 'caption', 'reference', 'quality']) const fail = (message) => { console.error(`${message}\n${usage}`); process.exit(1) } const options = {} const args = process.argv.slice(2) for (let i = 0; i < args.length; i += 1) { const arg = args[i] if (!arg.startsWith('--')) fail(`Unexpected argument "${arg}".`) const key = arg.slice(2) if (!VALUE_FLAGS.has(key)) fail(`Unknown flag --${key}.`) const value = args[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) options[key] = value i += 1 } if (!options.images || !options.output) fail('--images and --output are required.') let sharp try { sharp = (await import('sharp')).default } catch { console.error('This script needs sharp: Node 20.9 or newer; run `npm install sharp` in the Skill folder.') process.exit(1) } const files = options.images.split(',').map((f) => f.trim()).filter(Boolean) if (options.reference) files.unshift(options.reference) for (const f of files) if (!existsSync(f)) fail(`Image not found: ${f}`) const num = (key, fallback, lo, hi) => { const value = Number(options[key] ?? fallback) if (!Number.isFinite(value) || value < lo || value > hi) fail(`--${key} must be a number from ${lo} to ${hi}.`) return value } const cols = Math.round(num('cols', 3, 1, 8)) const width = Math.round(num('width', 1800, 600, 4096)) const gutter = Math.round(num('gutter', 12, 0, 80)) const margin = Math.round(num('margin', 36, 0, 160)) const background = options.background ?? '#f4f1ec' if (!/^#[0-9a-f]{6}$/i.test(background)) fail('--background must be a #rrggbb hex colour, like #f4f1ec.') const MAX_FILES = 24 if (files.length > MAX_FILES) fail(`At most ${MAX_FILES} images per sheet.`) for (const f of files) if (!/\.(jpe?g|png|webp)$/i.test(f)) fail(`${f} is not a photo. Use the .jpg, .png or .webp looks.`) const rows = Math.ceil(files.length / cols) const tileW = Math.floor((width - 2 * margin - (cols - 1) * gutter) / cols) const tileH = Math.round(tileW * 5 / 4) const captionH = options.caption ? Math.round(margin * 1.4) : 0 const height = 2 * margin + rows * tileH + (rows - 1) * gutter + captionH const escapeXml = (value) => value.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]) const layers = [] for (let i = 0; i < files.length; i += 1) { let tile try { tile = await sharp(files[i]).rotate().resize(tileW, tileH, { fit: 'cover', position: 'attention' }).toBuffer() } catch (error) { fail(`Could not read ${files[i]} (${error.message}).`) } layers.push({ input: tile, left: margin + (i % cols) * (tileW + gutter), top: margin + Math.floor(i / cols) * (tileH + gutter) }) } const [r, g, b] = [1, 3, 5].map((i) => parseInt(background.slice(i, i + 2), 16)) const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '#2b2925' : '#ece8e1' const textSvg = [] if (options.reference) { textSvg.push(`REFERENCE`) } if (options.caption) { const [left, right = ''] = options.caption.split('|') const y = height - margin + Math.round(captionH * 0.1) const size = Math.max(14, Math.round(margin * 0.5)) textSvg.push(`${escapeXml(left.trim())}`) textSvg.push(`${escapeXml(right.trim())}`) } if (textSvg.length) layers.push({ input: Buffer.from(`${textSvg.join('')}`), left: 0, top: 0 }) let sheet = sharp({ create: { width, height, channels: 3, background } }).composite(layers) const ext = extname(options.output).toLowerCase() if (!['.webp', '.jpg', '.jpeg', '.png'].includes(ext)) fail('--output must end in .webp, .jpg or .png.') const quality = Math.round(num('quality', 90, 40, 100)) if (ext === '.webp') sheet = sheet.webp({ quality, smartSubsample: true }) else if (ext === '.png') sheet = sheet.png() else sheet = sheet.jpeg({ quality, mozjpeg: true }) await sheet.toFile(options.output) console.log(`${options.output} (${width} x ${height}, ${files.length} frames)`)