[object Object]

← back to Dw Fleet Registry

fleet viewer: proxy-side image resize via optional sharp (/img?w=) — caps non-Shopify heroes to webp; sharp is an optionalDependency w/ graceful fallback

f1cf3385a98996ae246d4d6aa39ee8ad3273fb74 · 2026-06-01 15:48:51 -0700 · SteveStudio2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f1cf3385a98996ae246d4d6aa39ee8ad3273fb74
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon Jun 1 15:48:51 2026 -0700

    fleet viewer: proxy-side image resize via optional sharp (/img?w=) — caps non-Shopify heroes to webp; sharp is an optionalDependency w/ graceful fallback
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package.json      |  8 ++++++++
 viewer/index.html |  4 ++--
 viewer/server.mjs | 20 ++++++++++++++++----
 3 files changed, 26 insertions(+), 6 deletions(-)

diff --git a/package.json b/package.json
new file mode 100644
index 0000000..749b4cb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "dw-fleet-registry-viewer",
+  "private": true,
+  "description": "DW fleet registry viewer — zero hard deps; sharp is optional for /img proxy-side image resizing.",
+  "optionalDependencies": {
+    "sharp": "^0.33.5"
+  }
+}
diff --git a/viewer/index.html b/viewer/index.html
index 5864ddd..bae179c 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -155,8 +155,8 @@ const heroSrc = u => !u ? '' : (u.charAt(0)==='/' ? u : '/img?u='+encodeURICompo
 const thumbSrc = (u, w) => {
   if(!u) return '';
   if(u.charAt(0)==='/') return u;                       // local /static, /img — already small
-  if(/cdn\.shopify\.com/i.test(u)){ const [base,qs]=u.split('?'); const ps=new URLSearchParams(qs||''); ps.set('width', String(w||400)); u=base+'?'+ps.toString(); }
-  return '/img?u='+encodeURIComponent(u);
+  if(/cdn\.shopify\.com/i.test(u)){ const [base,qs]=u.split('?'); const ps=new URLSearchParams(qs||''); ps.set('width', String(w||400)); return '/img?u='+encodeURIComponent(base+'?'+ps.toString()); }
+  return '/img?u='+encodeURIComponent(u)+'&w='+(w||400);   // non-Shopify → server-side resize
 };
 let SITES = [];
 const hueOf = hex => { if(!/^#?[0-9a-f]{6}$/i.test(hex||''))return 999; const n=parseInt(hex.replace('#',''),16); const r=(n>>16&255)/255,g=(n>>8&255)/255,b=(n&255)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; let h=0; if(d){ if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; } return Math.round(h*60+360)%360; };
diff --git a/viewer/server.mjs b/viewer/server.mjs
index 6f76413..8cea4db 100644
--- a/viewer/server.mjs
+++ b/viewer/server.mjs
@@ -14,6 +14,9 @@ const DIR = path.dirname(new URL(import.meta.url).pathname);
 const REG = path.join(DIR, '..', 'dw-fleet-registry.json');
 const CATALOG = path.join(DIR, '..', 'catalog-hires.json');
 const PORT = process.env.PORT || 9774;
+// sharp is OPTIONAL — if it's not installed the proxy just serves full-res (graceful fallback).
+let sharp = null;
+try { sharp = (await import('sharp')).default; } catch { /* no sharp → /img?w= serves full-res */ }
 let rescrapeRunning = false;   // single-flight guard for /api/rescrape-heroes
 const heroCache = new Map();   // domain -> {image, at}
 const imgCache = new Map();    // url -> {ct, buf}  (proxy cache; many fleet images send CORP same-origin and can't be hotlinked cross-origin)
@@ -132,17 +135,26 @@ const server = http.createServer(async (req, res) => {
   if (u.pathname === '/img') {
     const target = u.searchParams.get('u') || '';
     if (!/^https?:\/\//i.test(target)) return send(res, 400, 'text/plain', 'bad url');
-    const hit = imgCache.get(target);
+    // Optional server-side downscale: /img?u=…&w=400 resizes non-resizable (non-Shopify)
+    // images to webp so a 3MB hero becomes ~40KB. Shopify URLs already arrive small via ?width=.
+    const w = Math.min(2000, Math.max(64, parseInt(u.searchParams.get('w') || '0', 10) || 0));
+    const key = target + '|' + w;
+    const hit = imgCache.get(key);
     if (hit) { res.writeHead(200, { 'Content-Type': hit.ct, 'Cache-Control': 'public, max-age=86400' }); return res.end(hit.buf); }
     try {
       const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 12000);
       const r = await fetch(target, { signal: ctrl.signal, redirect: 'follow', headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': target } });
       clearTimeout(t);
       if (!r.ok) return send(res, 502, 'text/plain', 'upstream ' + r.status);
-      const ct = r.headers.get('content-type') || 'image/jpeg';
-      const buf = Buffer.from(await r.arrayBuffer());
+      let ct = r.headers.get('content-type') || 'image/jpeg';
+      let buf = Buffer.from(await r.arrayBuffer());
+      // Downscale to webp when a width is requested, sharp is available, and it's a raster image.
+      if (w && sharp && /image\/(jpe?g|png|webp|avif)/i.test(ct)) {
+        try { buf = await sharp(buf).rotate().resize({ width: w, withoutEnlargement: true }).webp({ quality: 80 }).toBuffer(); ct = 'image/webp'; }
+        catch { /* keep original buffer on any resize failure */ }
+      }
       if (imgCache.size > 400) imgCache.clear();   // crude cap
-      imgCache.set(target, { ct, buf });
+      imgCache.set(key, { ct, buf });
       res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'public, max-age=86400' });
       return res.end(buf);
     } catch { return send(res, 502, 'text/plain', 'proxy error'); }

← af278f4 dw-header: hide ANY pre-existing <header> + propagate to fle  ·  back to Dw Fleet Registry  ·  rooms: gen-hero-rooms.mjs (generate room-setting heroes via ab9e050 →