#!/usr/bin/env node // Checks a finished pack shot against marketplace rules: pure-white border, product fill, shadow, square or 4:5, size. // Usage: node scripts/check-spec.mjs shot.jpg // Needs the `sharp` package. Exits 1 when a check fails. // If shot.jpg.json exists (written by compose-white.mjs), fill is measured from the real product box, without the shadow. // Without it, fill is estimated from pixels. That estimate is unreliable for white-on-white products: judge fill by eye. import { existsSync, readFileSync } from 'node:fs' let sharp try { sharp = (await import('sharp')).default } catch { console.error('This script needs the sharp package. Run `npm install sharp` in the Skill folder (Node 20.9 or newer).') process.exit(1) } const usage = 'Usage: node scripts/check-spec.mjs shot.jpg' const args = process.argv.slice(2) if (args.length !== 1 || args[0].startsWith('--')) { console.error(usage) process.exit(1) } const file = args[0] if (!existsSync(file)) { console.error(`File not found: ${file}`) process.exit(1) } const { data, info } = await sharp(file).removeAlpha().raw().toBuffer({ resolveWithObject: true }) const { width, height } = info const pixel = (x, y) => { const i = (y * width + x) * 3; return [data[i], data[i + 1], data[i + 2]] } const isWhite = ([r, g, b], tolerance = 3) => r >= 255 - tolerance && g >= 255 - tolerance && b >= 255 - tolerance // Border: every pixel in a 10 px frame must be white. let borderBad = 0 const edge = 10 for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { if (x < edge || y < edge || x >= width - edge || y >= height - edge) if (!isWhite(pixel(x, y), 2)) borderBad += 1 } // Product box: from the sidecar when present, otherwise pixels clearly darker or more coloured than a shadow. let box, fill, method const sidecarPath = `${file}.json` if (existsSync(sidecarPath)) { const side = JSON.parse(readFileSync(sidecarPath, 'utf8')) box = { minX: side.left, minY: side.top, maxX: side.left + side.productWidth - 1, maxY: side.top + side.productHeight - 1 } fill = side.fill method = 'from the cut-out box' } else { let minX = width, minY = height, maxX = 0, maxY = 0 for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { const [r, g, b] = pixel(x, y) if (Math.min(r, g, b) < 225 || Math.max(r, g, b) - Math.min(r, g, b) > 14) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y } } box = { minX, minY, maxX, maxY } fill = Math.max((maxX - minX + 1) / width, (maxY - minY + 1) / height) method = 'estimated from pixels, includes shadow, unreliable for white products' } // Shadow: some soft grey must exist just under the product box. let shadowPixels = 0, shadowSample = 0 const startY = Math.min(height - 1, box.maxY - Math.round(height * 0.02)) const endY = Math.min(height - 1, box.maxY + Math.round(height * 0.06)) for (let y = startY; y <= endY; y += 1) for (let x = box.minX; x <= box.maxX; x += 1) { const [r, g, b] = pixel(x, y) shadowSample += 1 if (r < 252 && Math.max(r, g, b) - Math.min(r, g, b) < 12) shadowPixels += 1 } const hasShadow = shadowSample > 0 && shadowPixels / shadowSample > 0.02 const aspect = width / height const results = [ ['Aspect 1:1 or 4:5', Math.abs(aspect - 1) < 0.005 || Math.abs(aspect - 0.8) < 0.005, true], ['At least 2000 px on the long side', Math.max(width, height) >= 2000, true], ['Pure white border (#FFFFFF)', borderBad === 0, true], [`Product fills 80-92% of the frame (is ${(fill * 100).toFixed(1)}%, ${method})`, fill >= 0.8 && fill <= 0.92, true], ['Product not touching the edge', box.minX > edge && box.minY > edge && box.maxX < width - edge && box.maxY < height - edge, true], ['Soft shadow under the product (only a note when shadow was set to 0 on purpose)', hasShadow, false], ] for (const [label, ok, required] of results) console.log(`${ok ? 'PASS' : required ? 'FAIL' : 'NOTE'} ${label}`) process.exit(results.every(([, ok, required]) => ok || !required) ? 0 : 1)