← back to Glitterwalls

scripts/enrich-hex.mjs

123 lines

#!/usr/bin/env node
// Per-SKU dominant hex extraction.
// For each product, downloads the image, scales to 1x1 via sips, reads the
// pixel, writes back to data/products.json as `dominant_hex_real`.
//
// Usage:
//   node scripts/enrich-hex.mjs
//
// Idempotent — products already carrying `dominant_hex_real` are skipped
// unless `--force` is passed.

import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import os from 'node:os';

const DATA = path.resolve('data/products.json');
const FORCE = process.argv.includes('--force');
const TMP = await fs.mkdtemp(path.join(os.tmpdir(), 'gw-hex-'));

const sh = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();

function rgbToHex(r, g, b) {
  const h = (n) => Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0');
  return '#' + h(r) + h(g) + h(b);
}

// Use sips → tiff → parse pixel via plist.
// Faster + more reliable: use sips to scale to 1x1, then read raw RGBA from tiff.
async function dominantHexForUrl(url, idx) {
  const ext = (url.split('?')[0].split('.').pop() || 'jpg').slice(0, 4).toLowerCase();
  const inFile = path.join(TMP, `in-${idx}.${ext === 'png' ? 'png' : 'jpg'}`);
  const outFile = path.join(TMP, `out-${idx}.png`);
  try {
    sh(`curl -fsSL --max-time 15 -A 'Mozilla/5.0' '${url.replace(/'/g, "'\\''")}' -o '${inFile}'`);
    if (!existsSync(inFile)) return null;
    // sips: resample to 1x1, write png
    sh(`/usr/bin/sips -z 1 1 -s format png '${inFile}' --out '${outFile}'`);
    if (!existsSync(outFile)) return null;
    // Read first IDAT-decoded pixel via Node's built-in (no native deps): use
    // Image data via PNG parser. We'll piggyback on a minimal PNG read by
    // decoding through a small zlib + filter step. Simpler: use sips one more
    // time to spit out raw bytes via "sips -g all" giving width/height, then
    // ask sips to save as TIFF and read first 3 bytes.
    const tiff = path.join(TMP, `out-${idx}.tiff`);
    sh(`/usr/bin/sips -s format tiff '${outFile}' --out '${tiff}'`);
    const buf = await fs.readFile(tiff);
    // TIFF header: II*\0 (LE) or MM\0* (BE). Walk IFD to find StripOffsets +
    // BitsPerSample + SamplesPerPixel. Quick & dirty: scan for the first
    // StripOffsets tag (0x0111) and read pointer.
    return parseTiffFirstPixel(buf);
  } catch (e) {
    return null;
  } finally {
    for (const f of [inFile, outFile, path.join(TMP, `out-${idx}.tiff`)]) {
      try { await fs.unlink(f); } catch {}
    }
  }
}

function parseTiffFirstPixel(buf) {
  if (buf.length < 8) return null;
  const le = buf[0] === 0x49 && buf[1] === 0x49;
  const be = buf[0] === 0x4d && buf[1] === 0x4d;
  if (!le && !be) return null;
  const u16 = (o) => le ? buf.readUInt16LE(o) : buf.readUInt16BE(o);
  const u32 = (o) => le ? buf.readUInt32LE(o) : buf.readUInt32BE(o);
  const ifd0 = u32(4);
  const nEntries = u16(ifd0);
  let stripOffset = null, samplesPerPixel = 3, bitsPerSample = 8;
  for (let i = 0; i < nEntries; i++) {
    const o = ifd0 + 2 + i * 12;
    const tag = u16(o);
    const type = u16(o + 2);
    const count = u32(o + 4);
    const valOff = u32(o + 8);
    if (tag === 0x0111 /*StripOffsets*/) {
      stripOffset = (count === 1 && type === 4) ? valOff
                  : (count === 1 && type === 3) ? (valOff & 0xffff)
                  : valOff; // value is offset to array, but for 1x1 strip count=1
    } else if (tag === 0x0102 /*BitsPerSample*/) {
      bitsPerSample = (count === 1) ? (type === 3 ? (valOff & 0xffff) : valOff) : 8;
    } else if (tag === 0x0115 /*SamplesPerPixel*/) {
      samplesPerPixel = (type === 3 ? (valOff & 0xffff) : valOff);
    }
  }
  if (stripOffset == null || stripOffset + 3 > buf.length) return null;
  if (bitsPerSample !== 8) return null;
  const r = buf[stripOffset];
  const g = buf[stripOffset + 1];
  const b = buf[stripOffset + 2];
  return rgbToHex(r, g, b);
}

(async () => {
  const raw = await fs.readFile(DATA, 'utf8');
  const arr = JSON.parse(raw);
  let touched = 0, skipped = 0, failed = 0;
  for (let i = 0; i < arr.length; i++) {
    const p = arr[i];
    if (!FORCE && p.dominant_hex_real) { skipped++; continue; }
    const url = p.image_url || p.image || (p.images && p.images[0]) || null;
    if (!url) { failed++; continue; }
    process.stdout.write(`[${i + 1}/${arr.length}] ${p.sku || p.handle || ''} ... `);
    const hex = await dominantHexForUrl(url, i);
    if (hex) {
      p.dominant_hex_real = hex;
      touched++;
      console.log(hex);
    } else {
      failed++;
      console.log('FAIL');
    }
    if ((i + 1) % 10 === 0) {
      await fs.writeFile(DATA, JSON.stringify(arr, null, 2));
    }
  }
  await fs.writeFile(DATA, JSON.stringify(arr, null, 2));
  await fs.rm(TMP, { recursive: true, force: true }).catch(() => {});
  console.log(`\nDone. touched=${touched} skipped=${skipped} failed=${failed}`);
})();