[object Object]

← back to Designer Wallcoverings

Lily: selective ZIP-range swatch fetcher -> Henry + viewer serves local pattern swatches (/img/lily/:sku), live-reload as they land

698dfc9dbc6ca9611708d67f8324e3c330c1ad71 · 2026-06-30 05:32:25 -0700 · Steve

Files touched

Diff

commit 698dfc9dbc6ca9611708d67f8324e3c330c1ad71
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 05:32:25 2026 -0700

    Lily: selective ZIP-range swatch fetcher -> Henry + viewer serves local pattern swatches (/img/lily/:sku), live-reload as they land
---
 onboarding/sangetsu-lilycolor/lily-image-fetch.cjs | 92 ++++++++++++++++++++++
 onboarding/sangetsu-lilycolor/viewer/server.js     | 15 +++-
 2 files changed, 106 insertions(+), 1 deletion(-)

diff --git a/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs b/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs
new file mode 100644
index 00000000..c14cb0b9
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * Selective Lilycolor swatch fetcher — random-access ONE image out of a remote
+ * 100–400MB catalog zip via HTTP Range, using offsets from the zip central directory.
+ * Pulls the PATTERN swatch (_C, else _P/_SP) per SKU. JPEGs are STORED (method 0) in
+ * these zips, so we write the bytes straight out (deflate path handled just in case).
+ *
+ * Lands images on the HENRY 2TB drive (internal disk is tight) — idempotent (skips
+ * already-fetched). Read-only network; no Shopify/dw_unified/publish.
+ *   node lily-image-fetch.cjs [limit]   (default: all manifest SKUs lacking a fetched swatch)
+ */
+const https = require('https');
+const zlib = require('zlib');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = path.join(__dirname, 'staging');
+const MANIFEST = path.join(DIR, 'lilycolor-image-manifest.json');
+const OUT_DIR = '/Volumes/Henry/dw-lily-images';
+const TAIL = 4 * 1024 * 1024;
+const KIND_PREF = { C: 0, P: 1, SP: 2, R: 3 };
+
+function getBuf(url, headers) {
+  return new Promise((resolve, reject) => {
+    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 Chrome/120', ...headers } }, (res) => {
+      if (res.statusCode >= 400) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
+      const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks)));
+    }).on('error', reject);
+  });
+}
+
+// Parse the central directory tail -> map filename -> {offset, compSize, method}
+function parseCentralDir(buf) {
+  const map = {};
+  for (let i = 0; i + 46 <= buf.length; i++) {
+    if (buf.readUInt32LE(i) !== 0x02014b50) continue;
+    const method = buf.readUInt16LE(i + 10);
+    const compSize = buf.readUInt32LE(i + 20);
+    const nameLen = buf.readUInt16LE(i + 28);
+    const extraLen = buf.readUInt16LE(i + 30);
+    const commentLen = buf.readUInt16LE(i + 32);
+    const offset = buf.readUInt32LE(i + 42);
+    if (nameLen === 0 || nameLen > 300 || i + 46 + nameLen > buf.length) continue;
+    const name = buf.slice(i + 46, i + 46 + nameLen).toString('utf8').split('/').pop();
+    if (/\.jpe?g$/i.test(name)) map[name] = { offset, compSize, method };
+    i += 46 + nameLen + extraLen + commentLen - 1;
+  }
+  return map;
+}
+
+async function fetchFile(zipUrl, rec) {
+  // local header: 30 fixed + nameLen + extraLen, then data (compSize). Range over the lot (+slack for extra).
+  const start = rec.offset;
+  const end = rec.offset + 30 + 300 + 64 + rec.compSize; // generous slack
+  const buf = await getBuf(zipUrl, { Range: `bytes=${start}-${end}` });
+  if (buf.readUInt32LE(0) !== 0x04034b50) throw new Error('bad local header');
+  const nameLen = buf.readUInt16LE(26), extraLen = buf.readUInt16LE(28);
+  const dataStart = 30 + nameLen + extraLen;
+  const data = buf.slice(dataStart, dataStart + rec.compSize);
+  return rec.method === 8 ? zlib.inflateRawSync(data) : data;
+}
+
+(async () => {
+  if (!fs.existsSync('/Volumes/Henry')) { console.error('Henry drive not mounted at /Volumes/Henry'); process.exit(1); }
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
+  const limit = parseInt(process.argv[2] || '999999', 10);
+
+  // group target files by zip so we read each central directory once
+  const cdCache = {};
+  const targets = [];        // {sku, zipUrl, name}
+  for (const [sku, imgs] of Object.entries(manifest)) {
+    if (fs.existsSync(path.join(OUT_DIR, `${sku}.jpg`))) continue;       // idempotent
+    const best = [...imgs].sort((a, b) => (KIND_PREF[a.kind] ?? 9) - (KIND_PREF[b.kind] ?? 9))[0];
+    if (best) targets.push({ sku, zipUrl: best.zipUrl, name: best.name });
+    if (targets.length >= limit) break;
+  }
+  console.error(`fetching ${targets.length} swatches -> ${OUT_DIR}`);
+  let ok = 0, fail = 0;
+  for (const t of targets) {
+    try {
+      if (!cdCache[t.zipUrl]) cdCache[t.zipUrl] = parseCentralDir(await getBuf(t.zipUrl, { Range: `bytes=-${TAIL}` }));
+      const rec = cdCache[t.zipUrl][t.name];
+      if (!rec) { fail++; continue; }
+      const bytes = await fetchFile(t.zipUrl, rec);
+      fs.writeFileSync(path.join(OUT_DIR, `${t.sku}.jpg`), bytes);
+      ok++;
+      if (ok % 50 === 0) process.stderr.write(`  ${ok}/${targets.length}\n`);
+    } catch (e) { fail++; if (fail <= 5) process.stderr.write(`FAIL ${t.sku}: ${e.message}\n`); }
+  }
+  console.log(JSON.stringify({ fetched: ok, failed: fail, dir: OUT_DIR }, null, 2));
+})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/onboarding/sangetsu-lilycolor/viewer/server.js b/onboarding/sangetsu-lilycolor/viewer/server.js
index 5e3f810e..1039a41b 100644
--- a/onboarding/sangetsu-lilycolor/viewer/server.js
+++ b/onboarding/sangetsu-lilycolor/viewer/server.js
@@ -16,6 +16,7 @@ const DIR = path.join(__dirname, '..', 'staging');
 const LILY = path.join(DIR, 'lilycolor-unified-staging.jsonl');   // trade pricebook + specs + price + image manifest
 const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
 const PUBLIC = path.join(__dirname, 'public');
+const HENRY_IMG = '/Volumes/Henry/dw-lily-images';   // selectively-fetched pattern swatches
 
 const readJsonl = (f) => (fs.existsSync(f)
   ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
@@ -37,7 +38,8 @@ function buildRows() {
       fire: (r.fire_ratings && r.fire_ratings[0]) || null,
       price_yen: r.list_price_yen || null,                 // Japan LIST price (¥), not confirmed net cost
       repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
-      image: (r.preview_images && r.preview_images[0]) || null,  // displayable now (shop CDN)
+      // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
+      image: (fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`)) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null,
       image_count: r.image_count || 0,
       image_kinds: r.image_kinds || [],
       url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
@@ -69,6 +71,10 @@ let ROWS = buildRows();
 const reload = () => { ROWS = buildRows(); };
 fs.watchFile(SANG, { interval: 4000 }, reload);   // pick up Sangetsu staging as it grows
 fs.watchFile(LILY, { interval: 8000 }, reload);
+let lastImgCount = -1;                              // re-resolve primary images as swatches land on Henry
+setInterval(() => {
+  try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
+}, 6000);
 
 const sorters = {
   newest: null, // server natural order
@@ -97,6 +103,13 @@ const server = http.createServer((req, res) => {
     res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
     return;
   }
+  // locally-fetched Lily pattern swatches from Henry
+  if (u.pathname.startsWith('/img/lily/')) {
+    const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
+    const f = path.join(HENRY_IMG, `${sku}.jpg`);
+    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
+    res.writeHead(404); return res.end('no image');
+  }
   // static
   let p = u.pathname === '/' ? '/index.html' : u.pathname;
   const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));

← 700c3531 auto-save: 2026-06-30T05:28:32 (4 files) — onboarding/sanget  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-30T05:58:45 (5 files) — pending-approval/ fc41898a →