← back to Japan Enrich
viewer: serve japan. Sangetsu cards from local /img/sangetsu/ swatches (256px, CDN-independent) + make_sangetsu_swatches.py downscaler
c966b6431829e54498c4e509d823c49602026a18 · 2026-07-01 14:28:09 -0700 · Steve Abrams
Files touched
A make_sangetsu_swatches.pyM viewer-local/server.js
Diff
commit c966b6431829e54498c4e509d823c49602026a18
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 14:28:09 2026 -0700
viewer: serve japan. Sangetsu cards from local /img/sangetsu/ swatches (256px, CDN-independent) + make_sangetsu_swatches.py downscaler
---
make_sangetsu_swatches.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++
viewer-local/server.js | 21 +++++++++++++++++++--
2 files changed, 65 insertions(+), 2 deletions(-)
diff --git a/make_sangetsu_swatches.py b/make_sangetsu_swatches.py
new file mode 100644
index 0000000..56c6585
--- /dev/null
+++ b/make_sangetsu_swatches.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""
+Downscale the full-res Sangetsu colorway images (images/sangetsu/, ~2.1 GB, fetched
+by enrich_sangetsu.py for hex accuracy) into small 256px card swatches
+(images/sangetsu-sw/) so the viewer can serve /img/sangetsu/<sku> self-contained
+and ship disk-safe to Kamatera — same pattern as the Greenland swatches.
+
+$0 local (Pillow). Skip-existing + concurrency. Originals are left untouched.
+"""
+import os, concurrent.futures as cf
+from PIL import Image
+
+SRC = "images/sangetsu"
+DST = "images/sangetsu-sw"
+MAX = 256
+os.makedirs(DST, exist_ok=True)
+
+def one(fn):
+ if not fn.lower().endswith((".jpg", ".jpeg")):
+ return 0
+ dst = os.path.join(DST, fn)
+ if os.path.exists(dst) and os.path.getsize(dst) > 0:
+ return 0
+ try:
+ with Image.open(os.path.join(SRC, fn)) as im:
+ im = im.convert("RGB")
+ im.thumbnail((MAX, MAX))
+ im.save(dst, "JPEG", quality=82, optimize=True)
+ return 1
+ except Exception:
+ return 0
+
+def main():
+ files = [f for f in os.listdir(SRC) if f.lower().endswith((".jpg", ".jpeg"))]
+ print(f"sangetsu swatches: {len(files)} source images -> {DST} (max {MAX}px)", flush=True)
+ made = skipped = 0
+ with cf.ThreadPoolExecutor(max_workers=8) as ex:
+ for i, r in enumerate(ex.map(one, files), 1):
+ made += r; skipped += (1 - r)
+ if i % 2000 == 0:
+ print(f" {i}/{len(files)} ({made} made)", flush=True)
+ total = len([f for f in os.listdir(DST) if f.lower().endswith((".jpg", ".jpeg"))])
+ print(f"DONE: {made} made this run; {total} swatches in {DST}", flush=True)
+
+if __name__ == "__main__":
+ main()
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 676482c..46f3d71 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -19,6 +19,7 @@ const GREEN = path.join(DIR, 'greenland-staging.jsonl'); // china. scope: Green
const PUBLIC = path.join(__dirname, 'public');
const HENRY_IMG = '/Volumes/Henry/dw-lily-images'; // selectively-fetched pattern swatches
const GREEN_IMG = path.join(__dirname, '..', 'images', 'greenland'); // downscaled colorway swatches (enrich_greenland.py)
+const SANG_IMG = path.join(__dirname, '..', 'images', 'sangetsu-sw'); // downscaled colorway swatches (make_sangetsu_swatches.py)
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)
@@ -49,6 +50,15 @@ const loadGreenSwatches = () => {
greenSwatches = s;
};
loadGreenSwatches();
+// Sangetsu downscaled swatches (images/sangetsu-sw/<normSku>.jpg) — same self-contained
+// pattern as Greenland so japan. cards serve /img/sangetsu/<sku> instead of the vendor CDN.
+let sangSwatches = new Set();
+const loadSangSwatches = () => {
+ const s = new Set();
+ try { if (fs.existsSync(SANG_IMG)) for (const fn of fs.readdirSync(SANG_IMG)) if (/\.jpe?g$/i.test(fn)) s.add(fn.replace(/\.jpe?g$/i, '')); } catch {}
+ sangSwatches = s;
+};
+loadSangSwatches();
const ENRICH_FILES = [
process.env.ENRICH_FILE, // explicit override
path.join(DIR, 'lilycolor-enriched.jsonl'), // post-deploy: beside staging
@@ -160,7 +170,8 @@ function buildRows() {
// per-colorway enrichment (hex/family per colorway; style/material fanned from the pattern-level vision pass)
const e = ENRICH[normSku(sku)] || null;
const siblings = cw.filter((s) => s !== sku).map(siblingChip);
- const sImg = imgs[sku] || null;
+ // prefer the self-contained local swatch; fall back to the vendor CDN url
+ const sImg = (sangSwatches.has(normSku(sku)) ? `/img/sangetsu/${sku}` : (imgs[sku] || null)) || null;
rows.push({
source: 'sangetsu',
sku,
@@ -239,7 +250,7 @@ function buildRows() {
}
let ROWS = buildRows();
-const reload = () => { loadGreenSwatches(); ENRICH = loadEnrich(); ROWS = buildRows(); };
+const reload = () => { loadGreenSwatches(); loadSangSwatches(); ENRICH = loadEnrich(); ROWS = buildRows(); };
fs.watchFile(SANG, { interval: 4000 }, reload); // pick up Sangetsu staging as it grows
fs.watchFile(LILY, { interval: 8000 }, reload);
try { if (fs.existsSync(GREEN)) fs.watchFile(GREEN, { interval: 8000 }, reload); } catch {}
@@ -395,6 +406,12 @@ const server = http.createServer((req, res) => {
if (f.startsWith(GREEN_IMG) && 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');
}
+ if (u.pathname.startsWith('/img/sangetsu/')) {
+ const sku = decodeURIComponent(u.pathname.slice('/img/sangetsu/'.length));
+ const f = path.join(SANG_IMG, `${normSku(sku)}.jpg`);
+ if (f.startsWith(SANG_IMG) && 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(/^(\.\.[/\\])+/, ''));
← e53e2b2 ledger: shipped Greenland swatches to Kamatera (china. CDN-i
·
back to Japan Enrich
·
deploy: ship Sangetsu color+spec enrichment to Kamatera japa df192e5 →