#!/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(``), 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)`)