#!/usr/bin/env node // Places a cut-out product (transparent PNG) on pure white with a soft contact shadow. Local, no API. // Usage: node scripts/compose-white.mjs --input cutout.png --output shot.jpg [--ratio 1:1|4:5] [--size 2000] [--fill 0.85] [--shadow 0.45] [--normalise] // --normalise lifts a dull exposure with one gain on all channels (levels only, hue preserved). Check the product colour afterwards. // Also writes .json with the real product box, so check-spec.mjs can measure fill without the shadow. // Needs the `sharp` package (npm i sharp). The product pixels are never repainted: they are only scaled. 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/compose-white.mjs --input cutout.png --output shot.jpg [--ratio 1:1|4:5] [--size 2000] [--fill 0.85] [--shadow 0.45] [--normalise]' const allowed = new Set(['input', 'output', 'ratio', 'size', 'fill', 'shadow', 'normalise']) const options = {} const args = process.argv.slice(2) for (let index = 0; index < args.length; index += 1) { const key = args[index] if (!key.startsWith('--') || !allowed.has(key.slice(2))) { console.error(`Unknown argument: ${key}`) console.error(usage) process.exit(1) } if (key === '--normalise') { options.normalise = true continue } const value = args[index + 1] if (value === undefined || value.startsWith('--')) { console.error(`${key} needs a value.`) console.error(usage) process.exit(1) } options[key.slice(2)] = value index += 1 } if (!options.input || !options.output) { console.error(usage) process.exit(1) } const number = (name, fallback, min, max) => { const value = options[name] === undefined ? fallback : Number(options[name]) if (!Number.isFinite(value) || value < min || value > max) { console.error(`--${name} must be a number from ${min} to ${max}.`) process.exit(1) } return value } if (options.ratio !== undefined && !['1:1', '4:5'].includes(options.ratio)) { console.error('--ratio must be 1:1 or 4:5.') process.exit(1) } const ratio = options.ratio === '4:5' ? 4 / 5 : 1 const width = Math.round(number('size', 2000, 500, 4000)) const height = Math.round(width / ratio) const fill = number('fill', 0.85, 0.5, 0.92) const shadowStrength = number('shadow', 0.45, 0, 0.8) // 1. Crop the cut-out to the product's bounding box. const inputMeta = await sharp(options.input).metadata() if (!inputMeta.hasAlpha) { console.error(`${options.input} has no transparent background. Run the background-removal step first (step 2) and pass its PNG cut-out here.`) process.exit(1) } const trimmed = await sharp(options.input).ensureAlpha().trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 }).toBuffer({ resolveWithObject: true }) const { width: srcWidth, height: srcHeight } = trimmed.info let cutout = trimmed.data let gainUsed = 1 if (options.normalise) { // Levels only: one gain on R, G and B together, so hue and saturation ratios are unchanged. const raw = await sharp(cutout).raw().toBuffer() const histogram = new Uint32Array(256) for (let i = 0; i < raw.length; i += 4) if (raw[i + 3] > 200) histogram[Math.max(raw[i], raw[i + 1], raw[i + 2])] += 1 const total = histogram.reduce((sum, value) => sum + value, 0) let seen = 0, high = 255 for (let v = 0; v < 256; v += 1) { seen += histogram[v]; if (seen >= total * 0.9) { high = v; break } } // Only light products are lifted: a dark product is dark on purpose. gainUsed = high >= 150 ? Math.min(1.25, Math.max(1, 232 / high)) : 1 for (let i = 0; i < raw.length; i += 4) for (let c = 0; c < 3; c += 1) raw[i + c] = Math.min(255, Math.round(raw[i + c] * gainUsed)) cutout = await sharp(raw, { raw: { width: srcWidth, height: srcHeight, channels: 4 } }).png().toBuffer() } // 2. Scale so the product's bounding box fills `fill` of the canvas along its limiting side. const scale = Math.min((width * fill) / srcWidth, (height * fill) / srcHeight) const productWidth = Math.round(srcWidth * scale) const productHeight = Math.round(srcHeight * scale) const product = await sharp(cutout).resize(productWidth, productHeight, { kernel: 'lanczos3' }).png().toBuffer() // Keep a little room below for the shadow: centre the product slightly above the middle. const left = Math.round((width - productWidth) / 2) const top = Math.round((height - productHeight) / 2 - height * 0.012) // 3. Shadows follow the underside of the product: for every column, a band just below its lowest opaque pixel, then blurred. const { data: alphaRaw } = await sharp(product).extractChannel('alpha').raw().toBuffer({ resolveWithObject: true }) const unit = width / 2000 const makeShadow = async ({ band, blur, opacity, drop, shrink }) => { const maskHeight = productHeight + Math.round(band * 1.5) const mask = Buffer.alloc(productWidth * maskHeight) for (let x = 0; x < productWidth; x += 1) { let bottom = -1 for (let y = productHeight - 1; y >= 0; y -= 1) if (alphaRaw[y * productWidth + x] > 128) { bottom = y; break } if (bottom < 0) continue const edgeFade = Math.min(1, Math.min(x, productWidth - 1 - x) / (productWidth * shrink)) for (let y = Math.max(0, bottom - Math.round(band * 0.5)); y < Math.min(maskHeight, bottom + Math.round(band)); y += 1) { mask[y * productWidth + x] = Math.round(255 * edgeFade) } } // Pad the canvas so the blur has room, then blur and tint. const pad = Math.round(blur * 4) const padded = await sharp(mask, { raw: { width: productWidth, height: maskHeight, channels: 1 } }) .extend({ top: pad, bottom: pad, left: pad, right: pad, background: { r: 0, g: 0, b: 0 } }).blur(Math.max(0.3, blur)).extractChannel(0).raw().toBuffer({ resolveWithObject: true }) const rgba = Buffer.alloc(padded.info.width * padded.info.height * 4) for (let i = 0; i < padded.data.length; i += 1) rgba[i * 4 + 3] = Math.round(padded.data[i] * opacity) const input = await sharp(rgba, { raw: { width: padded.info.width, height: padded.info.height, channels: 4 } }).png().toBuffer() return { input, left: left - pad, top: top - pad + Math.round(drop) } } const ambient = await makeShadow({ band: 60 * unit, blur: 34 * unit, opacity: shadowStrength * 0.7, drop: 22 * unit, shrink: 0.06 }) const contact = await makeShadow({ band: 14 * unit, blur: 7 * unit, opacity: shadowStrength * 1.5, drop: 2 * unit, shrink: 0.03 }) // 4. Composite on pure white. Flatten last so the background is exactly #FFFFFF. const out = sharp({ create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } } }) .composite([ambient, contact, { input: product, left, top }]) .flatten({ background: '#ffffff' }) if (/\.png$/i.test(options.output)) await out.png().toFile(options.output) else await out.jpeg({ quality: 93, chromaSubsampling: '4:4:4' }).toFile(options.output) const { writeFileSync } = await import('node:fs') writeFileSync(`${options.output}.json`, JSON.stringify({ width, height, left, top, productWidth, productHeight, fill: Math.max(productWidth / width, productHeight / height), shadow: shadowStrength, gain: gainUsed }, null, 2)) console.log(`${options.output}: ${width}x${height}, product ${productWidth}x${productHeight}${gainUsed > 1 ? `, exposure gain ${gainUsed.toFixed(2)}` : ''}`)