← back to CelebritySignatures
Murals storefront: /murals sales page + wall-placement studio + order endpoint + 150dpi 24x12ft TIF print-master generator (verified)
9111112bc80730725e5b3fa29cb1b289c7b3f66a · 2026-06-18 18:15:42 -0700 · Steve
Files touched
M .gitignoreA data/murals-catalog.jsonA public/murals.htmlA scripts/build-tifs.pyM server.js
Diff
commit 9111112bc80730725e5b3fa29cb1b289c7b3f66a
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 18 18:15:42 2026 -0700
Murals storefront: /murals sales page + wall-placement studio + order endpoint + 150dpi 24x12ft TIF print-master generator (verified)
---
.gitignore | 2 +
data/murals-catalog.json | 13 ++
public/murals.html | 345 +++++++++++++++++++++++++++++++++++++++++++++++
scripts/build-tifs.py | 188 ++++++++++++++++++++++++++
server.js | 21 ++-
5 files changed, 568 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 2f40e25..fe8c64e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ tmp_*.log
tmp_collage_cache/
.venv/
+data/mural-orders.json
+output/*.tif
diff --git a/data/murals-catalog.json b/data/murals-catalog.json
new file mode 100644
index 0000000..5d67bdc
--- /dev/null
+++ b/data/murals-catalog.json
@@ -0,0 +1,13 @@
+{
+ "pricePerSqFt": 24,
+ "size": { "widthFt": 24, "heightFt": 12, "dpi": 150, "pxW": 43200, "pxH": 21600, "sqft": 288 },
+ "material": "Archival matte non-woven wallcovering, printed to order",
+ "murals": [
+ { "code": "d", "slug": "declaration-of-independence", "title": "Declaration of Independence", "blurb": "All 56 signers of the 1776 Declaration — the safest, fully public-domain edition.", "sigs": 56 },
+ { "code": "p", "slug": "politics", "title": "Politics & Statecraft", "blurb": "Presidents, premiers and world leaders across two centuries.", "sigs": 74 },
+ { "code": "s", "slug": "sports", "title": "Sports Legends", "blurb": "Hall-of-fame athletes, every signature openly licensed.", "sigs": 42 },
+ { "code": "h", "slug": "hollywood", "title": "Hollywood", "blurb": "Classic and iconic screen stars of the silver age.", "sigs": 96 },
+ { "code": "c", "slug": "movies-classic", "title": "Movies — Classic", "blurb": "Golden-age film greats, hand-verified signatures.", "sigs": 97 },
+ { "code": "t", "slug": "tv", "title": "Television", "blurb": "Beloved small-screen icons.", "sigs": 14 }
+ ]
+}
diff --git a/public/murals.html b/public/murals.html
new file mode 100644
index 0000000..e3bdf6c
--- /dev/null
+++ b/public/murals.html
@@ -0,0 +1,345 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Celebrity Signature Murals — made to order</title>
+<style>
+ :root{
+ --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e3dc; --bg:#f7f5f0; --card:#fff;
+ --accent:#1d7a36; --gold:#9a6b00; --wall:#efe9df; --floor:#d9cfbf;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
+ h1,h2,h3{font-family:Georgia,"Times New Roman",serif;font-weight:600;letter-spacing:.01em}
+ a{color:var(--accent)}
+ header{padding:24px 30px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
+ header h1{margin:0;font-size:24px}
+ header .sub{color:var(--muted);font-size:13px}
+ header nav{margin-left:auto;display:flex;gap:18px;font-size:14px}
+ header nav a{color:var(--muted);text-decoration:none}
+ header nav a:hover{color:var(--ink)}
+ main{max-width:1180px;margin:0 auto;padding:30px 30px 80px}
+ .intro{max-width:760px;margin-bottom:30px}
+ .intro h2{font-size:30px;margin:0 0 10px}
+ .intro p{color:#3c3c3c;margin:0 0 8px}
+ .spec{display:flex;gap:26px;flex-wrap:wrap;margin-top:16px;padding:14px 18px;background:#fff;border:1px solid var(--line);border-radius:12px}
+ .spec div{font-size:13px}
+ .spec b{display:block;font-size:18px;font-family:Georgia,serif}
+ /* gallery */
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:22px;margin-top:10px}
+ .m-card{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
+ .m-card .pic{background:#fff;border-bottom:1px solid var(--line);padding:14px;display:flex;align-items:center;justify-content:center;aspect-ratio:2/1;overflow:hidden}
+ .m-card .pic img{max-width:100%;max-height:100%;object-fit:contain}
+ .m-card .body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:8px;flex:1}
+ .m-card h3{margin:0;font-size:19px}
+ .m-card .blurb{color:var(--muted);font-size:13.5px;flex:1}
+ .m-card .facts{display:flex;gap:8px;flex-wrap:wrap;font-size:11.5px;color:#555}
+ .pill{padding:3px 10px;border-radius:999px;background:#f0ece3;border:1px solid var(--line)}
+ .m-card .foot{display:flex;align-items:center;gap:10px;margin-top:4px}
+ .price{font-family:Georgia,serif;font-size:22px}
+ .btn{appearance:none;border:1px solid var(--ink);background:var(--ink);color:#fff;font:inherit;font-size:14px;padding:9px 16px;border-radius:9px;cursor:pointer;text-decoration:none;display:inline-block}
+ .btn.ghost{background:#fff;color:var(--ink)}
+ .btn:hover{opacity:.9}
+ .btn.full{width:100%;text-align:center}
+ /* studio */
+ .studio{margin-top:46px;border-top:1px solid var(--line);padding-top:34px;scroll-margin-top:90px}
+ .studio h2{font-size:26px;margin:0 0 4px}
+ .studio .lead{color:var(--muted);margin:0 0 18px;max-width:680px}
+ .stage-wrap{background:#fff;border:1px solid var(--line);border-radius:14px;padding:18px}
+ .ctrls{display:flex;gap:22px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
+ .ctrls .f{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)}
+ .ctrls select,.ctrls input[type=range]{font:inherit}
+ .ctrls select{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font-size:14px}
+ .ctrls output{font-family:Georgia,serif;color:var(--ink);font-size:15px}
+ .scene{position:relative;width:100%;background:linear-gradient(#f4eee4,#efe9df);border:1px solid var(--line);border-radius:10px;overflow:hidden;user-select:none;touch-action:none}
+ .floor{position:absolute;left:0;right:0;bottom:0;background:var(--floor);border-top:2px solid #c9bda9}
+ .baseboard{position:absolute;left:0;right:0;background:#fff;border-top:1px solid #cfc4b2;border-bottom:1px solid #cfc4b2}
+ .human{position:absolute;bottom:0;width:14px;background:#cdbfa9;border-radius:7px 7px 0 0;opacity:.85}
+ .human::after{content:"6 ft";position:absolute;top:-18px;left:50%;transform:translateX(-50%);font-size:10px;color:#8a7c63;white-space:nowrap}
+ .mural{position:absolute;cursor:grab;border:1px solid rgba(0,0,0,.18);box-shadow:0 6px 22px rgba(60,50,30,.22);background-size:cover;background-position:center}
+ .mural.drag{cursor:grabbing;box-shadow:0 10px 30px rgba(60,50,30,.32)}
+ .mural .grip{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;letter-spacing:.08em;text-shadow:0 1px 3px rgba(0,0,0,.5);opacity:0;transition:.15s}
+ .mural:hover .grip{opacity:.9}
+ .dims{position:absolute;font-size:11px;color:#8a7c63;pointer-events:none}
+ .readout{display:flex;gap:24px;flex-wrap:wrap;margin-top:14px;font-size:13px}
+ .readout b{font-family:Georgia,serif;font-weight:600;font-size:15px}
+ .hint{font-size:12px;color:var(--muted);margin-top:8px}
+ /* order */
+ .order{margin-top:40px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:22px 24px;max-width:680px}
+ .order h2{font-size:22px;margin:0 0 6px}
+ .order p{color:var(--muted);margin:0 0 16px;font-size:14px}
+ .order form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+ .order label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--muted)}
+ .order label.wide{grid-column:1/3}
+ .order input,.order textarea{font:inherit;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:#fff}
+ .order textarea{min-height:64px;resize:vertical}
+ .order .summary{grid-column:1/3;background:var(--bg);border:1px dashed var(--line);border-radius:10px;padding:12px 14px;font-size:13px}
+ .order .summary b{font-family:Georgia,serif}
+ .msg{grid-column:1/3;font-size:14px}
+ .msg.ok{color:var(--accent)} .msg.err{color:#b3261e}
+ footer{max-width:1180px;margin:0 auto;padding:0 30px 50px;color:var(--muted);font-size:12.5px;line-height:1.7}
+ @media(max-width:620px){.order form{grid-template-columns:1fr}.order label.wide,.order .summary,.msg{grid-column:1}}
+</style>
+</head>
+<body>
+<header>
+ <h1>Celebrity Signature Murals</h1>
+ <span class="sub">made to order · Designer Wallcoverings</span>
+ <nav>
+ <a href="/">Signature archive</a>
+ <a href="#studio">Wall studio</a>
+ <a href="#order">Order</a>
+ </nav>
+</header>
+
+<main>
+ <section class="intro">
+ <h2>A wall of history, in their own hand.</h2>
+ <p>Each mural is a curated wall of authentic signatures — every name traced to a
+ public-domain or openly licensed source, no signature invented. Printed to order
+ as a single seamless piece for a full feature wall.</p>
+ <div class="spec" id="spec"><!-- filled by JS --></div>
+ </section>
+
+ <div class="gallery" id="gallery"></div>
+
+ <!-- WALL PLACEMENT STUDIO -->
+ <section class="studio" id="studio">
+ <h2>Place it on your wall</h2>
+ <p class="lead">Pick a mural, set your wall width, then <b>drag the mural</b> to exactly
+ where you want it. The 6 ft figure is there for scale.</p>
+ <div class="stage-wrap">
+ <div class="ctrls">
+ <div class="f">Mural
+ <select id="selMural"></select>
+ </div>
+ <div class="f">Wall width — <output id="wwOut"></output>
+ <input id="wallW" type="range" min="24" max="60" step="1" value="32">
+ </div>
+ <div class="f">Wall height — <output id="whOut"></output>
+ <input id="wallH" type="range" min="12" max="20" step="1" value="14">
+ </div>
+ <div class="f">
+ <button class="btn ghost" id="btnCenter" type="button">Center on wall</button>
+ </div>
+ </div>
+ <div class="scene" id="scene"></div>
+ <div class="readout" id="readout"></div>
+ <div class="hint">Mural is fixed at 24 ft wide × 12 ft tall (150 DPI print master). Drag to set horizontal & vertical placement; it can't leave the wall.</div>
+ </div>
+ </section>
+
+ <!-- ORDER -->
+ <section class="order" id="order">
+ <h2>Order this mural</h2>
+ <p>Tell us your wall and we'll prepare your 150 DPI print master and a quote.</p>
+ <form id="orderForm">
+ <label>Your name<input name="name" required></label>
+ <label>Email<input name="email" type="email" required></label>
+ <label>Wall width (ft)<input name="wall_w" type="number" min="1" step="0.5" required></label>
+ <label>Wall height (ft)<input name="wall_h" type="number" min="1" step="0.5" required></label>
+ <label class="wide">Notes (room, install date, anything)<textarea name="notes"></textarea></label>
+ <div class="summary" id="orderSummary"></div>
+ <div style="grid-column:1/3"><button class="btn" type="submit">Request this mural</button></div>
+ <div class="msg" id="orderMsg"></div>
+ </form>
+ </section>
+</main>
+
+<footer>
+ Every signature on these murals is sourced from Wikimedia Commons under a public-domain or
+ open license and is rendered at uniform size — no signature is emphasized over another.
+ Living-celebrity signatures are excluded (right of publicity). Print masters are produced to
+ order at 150 DPI; final clarity scales with viewing distance for large-format installs.
+</footer>
+
+<script>
+const FT = " ft";
+let CAT = null; // catalog
+let cur = null; // current mural in studio
+let pos = {x:0, y:0}; // mural top-left in FEET on the wall
+
+const money = n => "$" + Math.round(n).toLocaleString();
+const $ = s => document.querySelector(s);
+
+async function boot(){
+ CAT = await (await fetch('/api/murals-catalog')).json();
+ const {widthFt,heightFt,dpi,pxW,pxH,sqft} = CAT.size;
+ const price = CAT.pricePerSqFt * sqft;
+
+ $('#spec').innerHTML = `
+ <div><b>${heightFt} × ${widthFt} ft</b>height × width</div>
+ <div><b>${dpi} DPI</b>${pxH.toLocaleString()} × ${pxW.toLocaleString()} px master</div>
+ <div><b>${sqft} sq ft</b>seamless feature wall</div>
+ <div><b>${money(price)}</b>from, ${CAT.material.toLowerCase()}</div>`;
+
+ // gallery
+ $('#gallery').innerHTML = CAT.murals.map(m => `
+ <div class="m-card">
+ <div class="pic"><img loading="lazy" src="/output/collage-${m.slug}.png" alt="${m.title} signature mural"></div>
+ <div class="body">
+ <h3>${m.title}</h3>
+ <div class="blurb">${m.blurb}</div>
+ <div class="facts">
+ <span class="pill">${m.sigs} signatures</span>
+ <span class="pill">${heightFt}×${widthFt} ft</span>
+ <span class="pill">${dpi} DPI TIF</span>
+ </div>
+ <div class="foot">
+ <span class="price">${money(price)}</span>
+ <button class="btn ghost" data-place="${m.code}" type="button">Place on wall →</button>
+ </div>
+ </div>
+ </div>`).join('');
+
+ // mural selector
+ $('#selMural').innerHTML = CAT.murals.map(m => `<option value="${m.code}">${m.title}</option>`).join('');
+
+ $('#gallery').addEventListener('click', e => {
+ const code = e.target.getAttribute?.('data-place');
+ if(!code) return;
+ $('#selMural').value = code;
+ selectMural(code);
+ $('#studio').scrollIntoView({behavior:'smooth'});
+ });
+ $('#selMural').addEventListener('change', e => selectMural(e.target.value));
+ $('#wallW').addEventListener('input', drawScene);
+ $('#wallH').addEventListener('input', drawScene);
+ $('#btnCenter').addEventListener('click', centerMural);
+ window.addEventListener('resize', drawScene);
+
+ selectMural(CAT.murals[0].code);
+ wireOrder();
+}
+
+function muralByCode(c){ return CAT.murals.find(m => m.code === c); }
+
+function selectMural(code){
+ cur = muralByCode(code);
+ centerMural(); // also draws
+}
+
+function wallDims(){ return { W:+$('#wallW').value, H:+$('#wallH').value }; }
+
+function centerMural(){
+ const {W,H} = wallDims();
+ const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+ pos.x = Math.max(0,(W-mw)/2);
+ pos.y = Math.max(0,(H-mh)/2);
+ drawScene();
+}
+
+function drawScene(){
+ if(!cur) return;
+ const scene = $('#scene');
+ const {W,H} = wallDims();
+ const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+ // scale: fit wall width to scene width; cap scene height sensibly
+ const sw = scene.clientWidth || 900;
+ let pxPerFt = sw / W;
+ const sceneH = H * pxPerFt;
+ scene.style.height = sceneH + "px";
+
+ // clamp position to wall
+ pos.x = Math.min(Math.max(0,pos.x), Math.max(0,W-mw));
+ pos.y = Math.min(Math.max(0,pos.y), Math.max(0,H-mh));
+
+ $('#wwOut').textContent = W + FT;
+ $('#whOut').textContent = H + FT;
+
+ const floorH = Math.min(sceneH*0.10, pxPerFt*1.0);
+ scene.innerHTML = `
+ <div class="floor" style="height:${floorH}px"></div>
+ <div class="baseboard" style="bottom:${floorH}px;height:${Math.max(6,pxPerFt*0.4)}px"></div>
+ <div class="human" style="left:${pxPerFt*1.2}px;height:${6*pxPerFt - floorH}px;bottom:${floorH}px"></div>
+ <div class="mural" id="muralEl"
+ style="width:${mw*pxPerFt}px;height:${mh*pxPerFt}px;
+ left:${pos.x*pxPerFt}px;top:${(H-mh-pos.y)*pxPerFt}px;
+ background-image:url('/output/collage-${cur.slug}.png')">
+ <div class="grip">⟵ drag ⟶</div>
+ </div>`;
+ enableDrag(pxPerFt);
+
+ const floorGap = pos.y; // ft from mural bottom to floor
+ const leftGap = pos.x; // ft from left wall to mural
+ const rightGap = Math.max(0, W - mw - pos.x);
+ $('#readout').innerHTML = `
+ <div>From left wall: <b>${leftGap.toFixed(1)}${FT}</b></div>
+ <div>From right wall: <b>${rightGap.toFixed(1)}${FT}</b></div>
+ <div>Bottom off floor: <b>${floorGap.toFixed(1)}${FT}</b></div>
+ <div>Mural: <b>${mh}×${mw}${FT}</b></div>`;
+ syncOrderSummary();
+}
+
+function enableDrag(pxPerFt){
+ const el = $('#muralEl');
+ let start=null;
+ el.addEventListener('pointerdown', e => {
+ start = {mx:e.clientX, my:e.clientY, px:pos.x, py:pos.y};
+ el.setPointerCapture(e.pointerId); el.classList.add('drag');
+ });
+ el.addEventListener('pointermove', e => {
+ if(!start) return;
+ const dxFt = (e.clientX-start.mx)/pxPerFt;
+ const dyFt = (e.clientY-start.my)/pxPerFt;
+ pos.x = start.px + dxFt;
+ pos.y = start.py - dyFt; // up = increase floor gap
+ const {W,H}=wallDims(), mw=CAT.size.widthFt, mh=CAT.size.heightFt;
+ pos.x = Math.min(Math.max(0,pos.x), Math.max(0,W-mw));
+ pos.y = Math.min(Math.max(0,pos.y), Math.max(0,H-mh));
+ el.style.left = pos.x*pxPerFt + "px";
+ el.style.top = (H-mh-pos.y)*pxPerFt + "px";
+ liveReadout();
+ });
+ const end = e => { if(start){ start=null; el.classList.remove('drag'); syncOrderSummary(); } };
+ el.addEventListener('pointerup', end);
+ el.addEventListener('pointercancel', end);
+}
+
+function liveReadout(){
+ const {W}=wallDims(), mw=CAT.size.widthFt;
+ $('#readout').children[0].querySelector('b').textContent = pos.x.toFixed(1)+FT;
+ $('#readout').children[1].querySelector('b').textContent = Math.max(0,W-mw-pos.x).toFixed(1)+FT;
+ $('#readout').children[2].querySelector('b').textContent = pos.y.toFixed(1)+FT;
+}
+
+function syncOrderSummary(){
+ if(!cur) return;
+ const {W,H}=wallDims();
+ const price = CAT.pricePerSqFt * CAT.size.sqft;
+ $('#orderSummary').innerHTML =
+ `<b>${cur.title}</b> · ${CAT.size.heightFt}×${CAT.size.widthFt}${FT} · ${money(price)}<br>
+ Placement on a ${W}×${H}${FT} wall: ${pos.x.toFixed(1)}${FT} from left, ${pos.y.toFixed(1)}${FT} off floor.`;
+ // mirror into order form if blank
+ const f = $('#orderForm');
+ if(!f.wall_w.value) f.wall_w.value = W;
+ if(!f.wall_h.value) f.wall_h.value = H;
+}
+
+function wireOrder(){
+ $('#orderForm').addEventListener('submit', async e => {
+ e.preventDefault();
+ const msg = $('#orderMsg'); msg.className='msg'; msg.textContent='Sending…';
+ const f = e.target;
+ const payload = {
+ mural: cur?.slug, mural_title: cur?.title,
+ name: f.name.value, email: f.email.value,
+ wall_w: +f.wall_w.value, wall_h: +f.wall_h.value,
+ placement: { from_left_ft:+pos.x.toFixed(2), off_floor_ft:+pos.y.toFixed(2) },
+ size: CAT.size, price: CAT.pricePerSqFt*CAT.size.sqft,
+ notes: f.notes.value
+ };
+ try{
+ const r = await fetch('/api/mural-order',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ const j = await r.json();
+ if(j.ok){ msg.className='msg ok'; msg.textContent='Thank you — request #'+j.id+' received. We’ll email you a proof and quote.'; f.reset(); syncOrderSummary(); }
+ else { msg.className='msg err'; msg.textContent = j.error || 'Something went wrong.'; }
+ }catch(err){ msg.className='msg err'; msg.textContent='Network error — please email steve@designerwallcoverings.com.'; }
+ });
+}
+
+boot();
+</script>
+</body>
+</html>
diff --git a/scripts/build-tifs.py b/scripts/build-tifs.py
new file mode 100644
index 0000000..d62229f
--- /dev/null
+++ b/scripts/build-tifs.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Build 150-DPI TIF PRINT MASTERS of the signature murals at 24 ft wide × 12 ft
+tall — the file a client orders for a full feature wall.
+
+ 24 ft × 150 dpi = 43,200 px wide
+ 12 ft × 150 dpi = 21,600 px tall → ~933 megapixels, 2:1
+
+The mural is RE-RENDERED at print scale (not upscaled from the small web PNG):
+every signature is fetched as a large raster straight from Wikimedia's SVG
+thumbnailer, so the ink stays crisp. Output is LZW-compressed TIF with the DPI
+tag pinned to 150 — because the art is mostly white, the files stay modest
+despite the pixel count.
+
+Honest resolution note: signatures are vector SVGs but Commons caps the raster
+width it will render, so very large cells are mild upscales of a ~2–3k px raster.
+For a 12-ft wall viewed at room distance this is well past visually-sharp; for
+true vector-perfect output we'd rasterize the SVGs locally (needs cairosvg).
+
+Usage (via the venv that has Pillow + qrcode):
+ .venv/bin/python scripts/build-tifs.py tv # one category (by slug)
+ .venv/bin/python scripts/build-tifs.py all # every usable category
+Set PUBLIC_BASE_URL to match the deployed QR host (defaults to the live site).
+"""
+
+import json, os, re, sys, time, urllib.parse, urllib.request
+from io import BytesIO
+from PIL import Image, ImageDraw
+import qrcode
+
+Image.MAX_IMAGE_PIXELS = None # 933 MP is intentional, not a decompression bomb
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA = os.path.join(ROOT, "data", "celebrity_signatures.json")
+CACHE = os.path.join(ROOT, "tmp_collage_cache")
+OUT = os.path.join(ROOT, "output")
+UA = "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"
+COMMONS = "https://commons.wikimedia.org/w/api.php"
+BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
+
+DPI = 150
+W_FT, H_FT = 24, 12
+TARGET_W, TARGET_H = W_FT * 12 * DPI, H_FT * 12 * DPI # 43200 x 21600
+MARGIN = 90 # white gutter inside each cell (px @150dpi ≈ 0.6")
+FETCH_W = 2600 # raster width requested per signature from Commons
+QR_IN = 9 # printed QR size in inches on the wall
+CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
+ "hollywood": "h", "movies-classic": "c", "tv": "t"}
+os.makedirs(CACHE, exist_ok=True)
+os.makedirs(OUT, exist_ok=True)
+
+
+def file_title(url):
+ return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])
+
+
+def resolve_thumbs(titles, width):
+ out = {}
+ for i in range(0, len(titles), 50):
+ q = urllib.parse.urlencode({
+ "action": "query", "format": "json", "prop": "imageinfo",
+ "iiprop": "url", "iiurlwidth": str(width), "titles": "|".join(titles[i:i+50]),
+ })
+ try:
+ j = json.load(urllib.request.urlopen(
+ urllib.request.Request(COMMONS + "?" + q, headers={"User-Agent": UA}), timeout=30))
+ except Exception as e:
+ print(" api batch failed:", e); time.sleep(1); continue
+ norm = {n["to"]: n["from"] for n in j.get("query", {}).get("normalized", [])}
+ for p in j.get("query", {}).get("pages", {}).values():
+ ii = (p.get("imageinfo") or [{}])[0]
+ out[norm.get(p.get("title"), p.get("title"))] = ii.get("thumburl") or ii.get("url")
+ time.sleep(0.2)
+ return out
+
+
+def fetch_image(url):
+ h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-80:] + f"_{FETCH_W}"
+ cp = os.path.join(CACHE, h + ".png")
+ if os.path.exists(cp):
+ try: return Image.open(cp).convert("RGBA")
+ except Exception: pass
+ for attempt in range(6):
+ try:
+ raw = urllib.request.urlopen(
+ urllib.request.Request(url, headers={"User-Agent": UA}), timeout=60).read()
+ im = Image.open(BytesIO(raw)).convert("RGBA")
+ im.save(cp); time.sleep(0.35); return im
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ w = 8 * (attempt + 1); print(f" 429 backoff {w}s"); time.sleep(w); continue
+ print(" img fail:", url[:60], e); return None
+ except Exception as e:
+ print(" img fail:", url[:60], e); return None
+ return None
+
+
+def signature_like(im):
+ rgb = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+ s = rgb.resize((72, 72)); g = list(s.convert("L").getdata()); n = len(g)
+ light = sum(1 for v in g if v >= 200) / n
+ dark = sum(1 for v in g if v <= 90) / n
+ sat = sum(p[1] for p in s.convert("HSV").getdata()) / n
+ return sat < 60 and light >= 0.30 and dark <= 0.30
+
+
+def fit_cell(im, cw, ch):
+ """Flatten onto white, scale to fit (cw,ch) preserving aspect (upscaling ok)."""
+ im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+ r = min(cw / im.width, ch / im.height)
+ return im.resize((max(1, int(im.width*r)), max(1, int(im.height*r))), Image.LANCZOS)
+
+
+def make_qr(data, px):
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
+ qr.add_data(data); qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
+ modules = qr.modules_count + 2 * qr.border
+ box = max(4, round(px / modules))
+ return img.resize((box * modules, box * modules), Image.NEAREST)
+
+
+def build(category, rows, slug):
+ rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
+ n = len(rows)
+ if not n:
+ print(f"[{category}] no usable rows"); return None
+ cols = min(10, max(1, round(n ** 0.5)))
+ rows_ct = (n + cols - 1) // cols
+ cw, ch = TARGET_W // cols, TARGET_H // rows_ct
+ print(f"[{category}] {n} sigs · {cols}×{rows_ct} grid · cell {cw}×{ch}px · canvas {TARGET_W}×{TARGET_H}")
+
+ titles = [file_title(r["signature_image_url"]) for r in rows]
+ thumbs = resolve_thumbs(titles, FETCH_W)
+
+ canvas = Image.new("RGB", (TARGET_W, TARGET_H), (255, 255, 255))
+ placed = 0
+ for r in rows:
+ url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
+ im = fetch_image(url)
+ if not im or not signature_like(im):
+ continue
+ sig = fit_cell(im, cw - 2*MARGIN, ch - 2*MARGIN)
+ cx = (placed % cols) * cw
+ cy = (placed // cols) * ch
+ canvas.paste(sig, (cx + (cw - sig.width)//2, cy + (ch - sig.height)//2))
+ placed += 1
+ if placed % 20 == 0:
+ print(f" placed {placed}/{n}")
+
+ # centred print QR — same "scan to reveal names" feature, sized for the wall
+ qpx = QR_IN * DPI
+ qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", qpx)
+ qx, qy = (TARGET_W - qr.width)//2, (TARGET_H - qr.height)//2
+ pad = int(0.6 * DPI)
+ ImageDraw.Draw(canvas).rectangle(
+ [qx-pad, qy-pad, qx+qr.width+pad, qy+qr.height+pad], fill="white", outline=(150,146,138), width=6)
+ canvas.paste(qr, (qx, qy))
+
+ path = os.path.join(OUT, f"mural-{slug}-24x12ft-150dpi.tif")
+ print(f" encoding LZW TIF ({placed} sigs placed)…")
+ canvas.save(path, format="TIFF", compression="tiff_lzw", dpi=(DPI, DPI))
+ mb = os.path.getsize(path) / 1e6
+ print(f"[{category}] ✅ {os.path.basename(path)} ({mb:.1f} MB, {TARGET_W}×{TARGET_H} @ {DPI}dpi)\n")
+ return path
+
+
+def main():
+ which = (sys.argv[1] if len(sys.argv) > 1 else "all").lower()
+ data = json.load(open(DATA))
+ by = {}
+ for r in data:
+ if r["usable_in_commercial_collage"] == "yes":
+ by.setdefault(r["category"], []).append(r)
+ order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "TV"]
+ print(f"Print masters → {W_FT}ft × {H_FT}ft @ {DPI}dpi ({TARGET_W}×{TARGET_H}px) QR host: {BASE_URL}\n")
+ made = []
+ for cat in order:
+ slug = re.sub(r"[^a-z0-9]+", "-", cat.lower()).strip("-")
+ if which not in ("all", slug):
+ continue
+ if cat in by:
+ p = build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
+ if p: made.append(p)
+ print(f"Built {len(made)} TIF print master(s) in output/")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server.js b/server.js
index 0812131..d3d6f97 100644
--- a/server.js
+++ b/server.js
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Zero-dependency static server for the CelebritySignatures grid.
import { createServer } from 'node:http';
-import { readFile } from 'node:fs/promises';
+import { readFile, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -14,11 +14,30 @@ const TYPES = {
'.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
};
+const ORDERS = join(ROOT, 'data', 'mural-orders.json');
+
createServer(async (req, res) => {
try {
let path = decodeURIComponent(req.url.split('?')[0]);
+
+ // POST: capture a made-to-order mural request → append to data/mural-orders.json
+ if (req.method === 'POST' && path === '/api/mural-order') {
+ let raw = '';
+ for await (const c of req) { raw += c; if (raw.length > 1e5) { res.writeHead(413).end('too large'); return; } }
+ let body; try { body = JSON.parse(raw || '{}'); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"bad json"}'); return; }
+ if (!body.name || !body.email) { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"name and email required"}'); return; }
+ let list = []; try { list = JSON.parse(await readFile(ORDERS, 'utf8')); } catch {}
+ const id = (list.at(-1)?.id || 1000) + 1;
+ list.push({ id, at: new Date().toISOString(), ...body });
+ await writeFile(ORDERS, JSON.stringify(list, null, 2));
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, id }));
+ return;
+ }
+
if (path === '/') path = '/public/index.html';
+ if (path === '/murals') path = '/public/murals.html';
if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
+ if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
// Short QR target: /k/<code> -> the named "key" mural. Single-letter codes keep
// the encoded URL tiny so the centre QR stays low-density and scans cleanly off a
// printed wall. Full-slug (/k/<cat>) still works as a fallback.
← 84da440 Mural QR: B+C — functional-size centre QR + short /k/<code>
·
back to CelebritySignatures
·
murals: add 5 consensus components (graphic-designer ∩ four- 86bfd8c →