[object Object]

← back to Wallco Ai

hero: 2048² Lanczos-upscaled room mockups; heroImageUrl prefers _hero.png

88d80d73d676aecf31a63f9e67d26d692ba9413d · 2026-05-28 12:20:06 -0700 · Steve Abrams

The upstream room renderer (45.61.58.125:3075) hard-codes 1024² output. On a
1920px hero with contain mode that's noticeably soft on Retina displays — Steve
asked where the hi-res hero images were ('where are the high res room setting
hero images????' 2026-05-28).

scripts/upscale-room-heroes.py: PIL Lanczos 2x of every data/rooms/design_*.png
into a sibling _hero.png at 2048². Idempotent + parallel (11 workers). 400/400
existing rooms upscaled in 97s, ~4MB/file, zero errors. Re-run anytime new
rooms land; --force overwrites.

server.js heroImageUrl(): _ROOM_HERO_SET in-memory index of basenames-with-_hero,
rebuilt at boot AND on /admin/reload-designs. Hero URL prefers _hero.png when
present, falls back to 1024² .png otherwise. Product cards (renderProduct +
card-room) intentionally untouched — they render at ~280px wide and don't
benefit from the bigger asset.

Verified: all 6 live hero slots on / now serve /designs/room/<id>_<room>_hero.png
at (2048, 2048), http 200, 3.1-4.5 MB each.

Files touched

Diff

commit 88d80d73d676aecf31a63f9e67d26d692ba9413d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 12:20:06 2026 -0700

    hero: 2048² Lanczos-upscaled room mockups; heroImageUrl prefers _hero.png
    
    The upstream room renderer (45.61.58.125:3075) hard-codes 1024² output. On a
    1920px hero with contain mode that's noticeably soft on Retina displays — Steve
    asked where the hi-res hero images were ('where are the high res room setting
    hero images????' 2026-05-28).
    
    scripts/upscale-room-heroes.py: PIL Lanczos 2x of every data/rooms/design_*.png
    into a sibling _hero.png at 2048². Idempotent + parallel (11 workers). 400/400
    existing rooms upscaled in 97s, ~4MB/file, zero errors. Re-run anytime new
    rooms land; --force overwrites.
    
    server.js heroImageUrl(): _ROOM_HERO_SET in-memory index of basenames-with-_hero,
    rebuilt at boot AND on /admin/reload-designs. Hero URL prefers _hero.png when
    present, falls back to 1024² .png otherwise. Product cards (renderProduct +
    card-room) intentionally untouched — they render at ~280px wide and don't
    benefit from the bigger asset.
    
    Verified: all 6 live hero slots on / now serve /designs/room/<id>_<room>_hero.png
    at (2048, 2048), http 200, 3.1-4.5 MB each.
---
 data/cactus-decisions.jsonl    | 31 ++++++++++++++++
 scripts/upscale-room-heroes.py | 82 ++++++++++++++++++++++++++++++++++++++++++
 server.js                      | 27 ++++++++++++--
 3 files changed, 138 insertions(+), 2 deletions(-)

diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index d034102..b7a84dd 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -9474,3 +9474,34 @@
 {"ts": "2026-05-28T19:07:14.536919Z", "id": 22292, "action": "orphan-cleanup"}
 {"ts": "2026-05-28T19:07:14.536919Z", "id": 22488, "action": "orphan-cleanup"}
 {"ts": "2026-05-28T19:07:14.536919Z", "id": 22750, "action": "orphan-cleanup"}
+{"ts":"2026-05-28T19:19:37.142Z","id":27370,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.254Z","id":27384,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.359Z","id":25416,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.474Z","id":27397,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.579Z","id":25451,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.699Z","id":25434,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.823Z","id":27408,"action":"bad"}
+{"ts":"2026-05-28T19:19:37.957Z","id":27339,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.084Z","id":27356,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.207Z","id":27464,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.348Z","id":25465,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.464Z","id":25383,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.592Z","id":25377,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.741Z","id":27323,"action":"bad"}
+{"ts":"2026-05-28T19:19:38.886Z","id":27449,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.030Z","id":25001,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.157Z","id":25035,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.287Z","id":25399,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.414Z","id":25075,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.545Z","id":25118,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.671Z","id":25190,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.802Z","id":25230,"action":"bad"}
+{"ts":"2026-05-28T19:19:39.948Z","id":25324,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.080Z","id":25360,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.210Z","id":27309,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.353Z","id":27435,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.469Z","id":24931,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.601Z","id":25338,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.747Z","id":25476,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.877Z","id":27424,"action":"bad"}
+{"ts":"2026-05-28T19:19:40.998Z","id":25344,"action":"bad"}
diff --git a/scripts/upscale-room-heroes.py b/scripts/upscale-room-heroes.py
new file mode 100644
index 0000000..1142ebf
--- /dev/null
+++ b/scripts/upscale-room-heroes.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+upscale-room-heroes.py — 1024² → 2048² Lanczos upscale of room mockups for hero use.
+
+The upstream room renderer (45.61.58.125:3075/api/generate-room) hard-codes 1024²
+output. On wide heroes that's too small to look crisp, so we Lanczos-2x each room
+PNG into a `_hero.png` sibling. `server.js heroImageUrl()` prefers the `_hero`
+variant when present, falls back to the 1024² for non-hero card uses.
+
+Idempotent: skips any file whose `_hero` sibling already exists with the same
+mtime-or-later. Runs in parallel.
+
+Usage:
+  python3 scripts/upscale-room-heroes.py                 # walk + upscale missing
+  python3 scripts/upscale-room-heroes.py --force         # overwrite existing
+  python3 scripts/upscale-room-heroes.py --limit 50      # cap (smoke test)
+"""
+import os, sys, glob, time, argparse
+from concurrent.futures import ProcessPoolExecutor, as_completed
+from PIL import Image
+
+ROOMS_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'rooms')
+TARGET = 2048   # 2x the upstream 1024²
+
+def is_hero(p):
+    return p.endswith('_hero.png')
+
+def hero_path(p):
+    base, ext = os.path.splitext(p)
+    return base + '_hero' + ext
+
+def upscale_one(src, force=False):
+    dst = hero_path(src)
+    if os.path.exists(dst) and not force:
+        try:
+            if os.path.getmtime(dst) >= os.path.getmtime(src):
+                return (src, 'skip', 0)
+        except OSError:
+            pass
+    t0 = time.time()
+    try:
+        im = Image.open(src).convert('RGB')
+        # If the source is already ≥TARGET, just save a copy as the hero variant
+        # rather than down+upsampling.
+        if min(im.size) >= TARGET:
+            im.save(dst, 'PNG', optimize=True)
+        else:
+            up = im.resize((TARGET, TARGET), Image.LANCZOS)
+            up.save(dst, 'PNG', optimize=True)
+        return (src, 'ok', round(time.time() - t0, 2))
+    except Exception as e:
+        return (src, f'err:{e}', round(time.time() - t0, 2))
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--force', action='store_true')
+    ap.add_argument('--limit', type=int, default=None)
+    ap.add_argument('--workers', type=int, default=max(2, (os.cpu_count() or 4) - 1))
+    args = ap.parse_args()
+
+    all_pngs = sorted(glob.glob(os.path.join(ROOMS_DIR, 'design_*.png')))
+    sources = [p for p in all_pngs if not is_hero(p)]
+    if args.limit:
+        sources = sources[:args.limit]
+    print(f'[upscale] {len(sources)} candidate room PNGs · target={TARGET}² · workers={args.workers} · force={args.force}')
+
+    ok = skipped = errored = 0
+    t0 = time.time()
+    with ProcessPoolExecutor(max_workers=args.workers) as ex:
+        futs = {ex.submit(upscale_one, p, args.force): p for p in sources}
+        for i, fut in enumerate(as_completed(futs), 1):
+            src, status, dt = fut.result()
+            if status == 'ok':       ok += 1
+            elif status == 'skip':   skipped += 1
+            else:                    errored += 1; print(f'  [err] {os.path.basename(src)} → {status}')
+            if i % 25 == 0 or i == len(sources):
+                rate = i / max(time.time() - t0, 1e-6)
+                print(f'  [{i}/{len(sources)}] ok={ok} skip={skipped} err={errored} · {rate:.1f}/s')
+    print(f'[done] ok={ok} skip={skipped} err={errored} · {round(time.time()-t0,1)}s')
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index efaadc6..7cd7b15 100644
--- a/server.js
+++ b/server.js
@@ -849,7 +849,10 @@ app.post('/admin/reload-designs', (req, res) => {
   // can't outlive the new catalog state. ETags rebuild against the new CATALOG_LAST_MODIFIED.
   _byColorCacheVer++;
   _byColorCache.clear();
-  res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer });
+  // Refresh the hi-res `_hero.png` index so any newly-upscaled room files
+  // (scripts/upscale-room-heroes.py) take effect without a pm2 reload.
+  if (typeof rebuildRoomHeroSet === 'function') rebuildRoomHeroSet();
+  res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer, hero_set_size: (typeof _ROOM_HERO_SET !== 'undefined') ? _ROOM_HERO_SET.size : 0 });
 });
 
 // ── Inspirations gallery (admin-only) ─────────────────────────────────────
@@ -4714,9 +4717,29 @@ const HAMBURGER_JS = `
 // "use room settings for hero images". Room files served by the static route
 // at /designs/room/ from data/rooms/design_<id>_<room>.png. Falls back to the
 // flat tile (d.image_url) for designs with no rooms yet.
+// 2026-05-28: prefer the 2048² `_hero.png` Lanczos-upscale variant when present
+// (scripts/upscale-room-heroes.py builds these from the 1024² upstream output).
+// _ROOM_HERO_SET is a Set of basenames-without-extension for O(1) lookup; rebuilt
+// on server boot so newly-upscaled files take effect on next reload-designs hit.
+const _ROOMS_DIR = path.join(__dirname, 'data', 'rooms');
+let _ROOM_HERO_SET = new Set();
+function rebuildRoomHeroSet() {
+  try {
+    _ROOM_HERO_SET = new Set(
+      fs.readdirSync(_ROOMS_DIR)
+        .filter(n => n.endsWith('_hero.png'))
+        .map(n => n.slice(0, -4))    // strip ".png" — base ends in "_hero"
+    );
+  } catch (e) { _ROOM_HERO_SET = new Set(); }
+}
+rebuildRoomHeroSet();
 function heroImageUrl(d) {
   if (Array.isArray(d.room_mockups) && d.room_mockups.length) {
-    return '/designs/room/design_' + d.id + '_' + d.room_mockups[0] + '.png';
+    const base = 'design_' + d.id + '_' + d.room_mockups[0];
+    if (_ROOM_HERO_SET.has(base + '_hero')) {
+      return '/designs/room/' + base + '_hero.png';
+    }
+    return '/designs/room/' + base + '.png';
   }
   return d.image_url;
 }

← cf819f1 snapshot: lift pil-edge-heal block for mural/scenic categori  ·  back to Wallco Ai  ·  hero: room slides use background-size:cover (full-width) ins 09c554e →