[object Object]

← back to CelebritySignatures

murals: add 5 consensus components (graphic-designer ∩ four-horsemen)

86bfd8c7ae05d162d0417e197acaa16bad61c516 · 2026-06-18 18:35:24 -0700 · SteveStudio2

Both design panels independently endorsed these 5 — built client-side from
existing data ($0, no API):
- Name finder ('is your icon on a wall?') — search all usable signatures → which mural
- Per-mural roster drawer — 'see who's on this wall', names + signature thumbs
- Per-signature provenance modal — license/source/deceased/risk receipts (Wikidata link)
- Named-PNG toggle on each card — Show/Hide names using the existing -named collages
- Room-scene presets in the wall studio (Plain/Study/Lounge/Gallery), CSS-only

Verified headless: 6 cards, finder→Lincoln/Politics, roster→56, provenance→Jefferson
(Public domain), named toggle swaps src, scene applies. JS node --check clean; only
404 is /favicon.ico. Single-panel ideas (custom builder, PDF spec, price estimator,
story strip) deliberately NOT built — no cross-panel consensus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 86bfd8c7ae05d162d0417e197acaa16bad61c516
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Thu Jun 18 18:35:24 2026 -0700

    murals: add 5 consensus components (graphic-designer ∩ four-horsemen)
    
    Both design panels independently endorsed these 5 — built client-side from
    existing data ($0, no API):
    - Name finder ('is your icon on a wall?') — search all usable signatures → which mural
    - Per-mural roster drawer — 'see who's on this wall', names + signature thumbs
    - Per-signature provenance modal — license/source/deceased/risk receipts (Wikidata link)
    - Named-PNG toggle on each card — Show/Hide names using the existing -named collages
    - Room-scene presets in the wall studio (Plain/Study/Lounge/Gallery), CSS-only
    
    Verified headless: 6 cards, finder→Lincoln/Politics, roster→56, provenance→Jefferson
    (Public domain), named toggle swaps src, scene applies. JS node --check clean; only
    404 is /favicon.ico. Single-panel ideas (custom builder, PDF spec, price estimator,
    story strip) deliberately NOT built — no cross-panel consensus.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .../portrait-declaration-of-independence-named.png | Bin 0 -> 5124382 bytes
 output/portrait-declaration-of-independence.png    | Bin 0 -> 5022692 bytes
 output/portrait-politics-named.png                 | Bin 0 -> 6391981 bytes
 output/portrait-politics.png                       | Bin 0 -> 6294263 bytes
 output/portrait-sports-named.png                   | Bin 0 -> 3350966 bytes
 output/portrait-sports.png                         | Bin 0 -> 3283761 bytes
 public/murals.html                                 | 206 +++++++++++++++-
 scripts/build-portrait-murals.py                   | 270 +++++++++++++++++++++
 8 files changed, 468 insertions(+), 8 deletions(-)

diff --git a/output/portrait-declaration-of-independence-named.png b/output/portrait-declaration-of-independence-named.png
new file mode 100644
index 0000000..a2ee018
Binary files /dev/null and b/output/portrait-declaration-of-independence-named.png differ
diff --git a/output/portrait-declaration-of-independence.png b/output/portrait-declaration-of-independence.png
new file mode 100644
index 0000000..d144d8f
Binary files /dev/null and b/output/portrait-declaration-of-independence.png differ
diff --git a/output/portrait-politics-named.png b/output/portrait-politics-named.png
new file mode 100644
index 0000000..7b6257d
Binary files /dev/null and b/output/portrait-politics-named.png differ
diff --git a/output/portrait-politics.png b/output/portrait-politics.png
new file mode 100644
index 0000000..11cc61b
Binary files /dev/null and b/output/portrait-politics.png differ
diff --git a/output/portrait-sports-named.png b/output/portrait-sports-named.png
new file mode 100644
index 0000000..acde353
Binary files /dev/null and b/output/portrait-sports-named.png differ
diff --git a/output/portrait-sports.png b/output/portrait-sports.png
new file mode 100644
index 0000000..3afbc55
Binary files /dev/null and b/output/portrait-sports.png differ
diff --git a/public/murals.html b/public/murals.html
index e3bdf6c..d0c9d9f 100644
--- a/public/murals.html
+++ b/public/murals.html
@@ -80,6 +80,50 @@
   .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}}
+  /* — name finder (consensus component) — */
+  .finder{margin-top:18px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px 17px}
+  .finder .lab{font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
+  .finder .row{display:flex;gap:10px;flex-wrap:wrap}
+  .finder input{flex:1;min-width:230px;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px}
+  .finder .res{margin-top:11px;display:flex;flex-direction:column;gap:7px;font-size:14px}
+  .finder .hit{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+  .finder .hit img{height:26px;max-width:96px;object-fit:contain}
+  .finder .hit b{font-family:Georgia,serif;font-weight:600}
+  .finder .miss{color:var(--muted)}
+  /* — named-toggle pill on card image — */
+  .name-toggle{position:absolute;top:10px;right:10px;font-size:11.5px;padding:4px 11px;border-radius:999px;background:rgba(255,255,255,.93);border:1px solid var(--line);cursor:pointer;color:var(--ink);z-index:2}
+  .name-toggle:hover{background:#fff}
+  /* — roster drawer — */
+  .roster-trig{background:none;border:none;color:var(--accent);font:inherit;font-size:12.5px;cursor:pointer;padding:0;text-decoration:underline;align-self:flex-start}
+  .roster{margin-top:6px;border-top:1px dashed var(--line);padding-top:10px;display:none}
+  .roster.open{display:block}
+  .roster .r-search{width:100%;font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;margin-bottom:8px}
+  .roster .r-list{display:grid;grid-template-columns:1fr 1fr;gap:5px;max-height:260px;overflow:auto}
+  .roster .r-item{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:4px 6px;border-radius:6px;cursor:pointer;text-align:left;background:none;border:1px solid transparent;width:100%;font-family:inherit;color:var(--ink)}
+  .roster .r-item:hover{background:#f4f0e8;border-color:var(--line)}
+  .roster .r-item img{height:20px;width:50px;object-fit:contain;flex:none}
+  .roster .r-item span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  /* — provenance modal — */
+  .modal{position:fixed;inset:0;background:rgba(20,18,14,.55);display:none;align-items:center;justify-content:center;z-index:60;padding:20px}
+  .modal.open{display:flex}
+  .modal .box{background:#fff;border-radius:14px;max-width:440px;width:100%;padding:22px 24px;position:relative}
+  .modal .x{position:absolute;top:10px;right:14px;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+  .modal .sig{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px;display:flex;align-items:center;justify-content:center;min-height:90px;margin-bottom:14px}
+  .modal .sig img{max-width:100%;max-height:120px;object-fit:contain}
+  .modal h3{margin:0 0 4px;font-size:21px}
+  .modal .why{color:var(--muted);font-size:13px;margin:0 0 14px}
+  .modal .prov{display:grid;grid-template-columns:auto 1fr;gap:7px 14px;font-size:13px;margin:0}
+  .modal .prov dt{color:var(--muted)}
+  .modal .prov dd{margin:0}
+  .modal .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;background:#eaf5ec;color:#1d7a36;border:1px solid #cfe8d5}
+  .modal .badge.medium{background:#fdf4e3;color:#9a6b00;border-color:#f0e2c0}
+  .modal .badge.high{background:#fdecea;color:#b3261e;border-color:#f3cdc8}
+  /* — scene presets in studio — */
+  .scene-presets{display:flex;gap:6px;flex-wrap:wrap}
+  .scene-presets button{font:inherit;font-size:12.5px;padding:6px 11px;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer;color:var(--muted)}
+  .scene-presets button.on{border-color:var(--ink);color:var(--ink);background:#f4f0e8}
+  .scene .swindow{position:absolute;top:8%;background:linear-gradient(160deg,#dCE7f0,#eef3f7);border:3px solid #fff;box-shadow:0 0 0 1px #c9bda9, 0 8px 20px rgba(0,0,0,.12)}
+  .scene .sart{position:absolute;top:14%;background:#fff;border:4px solid #fff;box-shadow:0 0 0 1px #c9bda9,0 6px 16px rgba(0,0,0,.14)}
 </style>
 </head>
 <body>
@@ -100,6 +144,13 @@
        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>
+    <div class="finder">
+      <p class="lab">Is your icon on a wall?</p>
+      <div class="row">
+        <input id="finderInput" type="search" placeholder="Search any name — e.g. Lincoln, Chaplin, Babe Ruth…" autocomplete="off">
+      </div>
+      <div class="res" id="finderRes"></div>
+    </div>
   </section>
 
   <div class="gallery" id="gallery"></div>
@@ -120,6 +171,9 @@
         <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">Room scene
+          <div class="scene-presets" id="scenePresets"></div>
+        </div>
         <div class="f">&nbsp;
           <button class="btn ghost" id="btnCenter" type="button">Center on wall</button>
         </div>
@@ -147,6 +201,17 @@
   </section>
 </main>
 
+<!-- provenance modal (consensus component) -->
+<div class="modal" id="provModal">
+  <div class="box">
+    <button class="x" id="provX" type="button" aria-label="Close">×</button>
+    <div class="sig" id="provSig"></div>
+    <h3 id="provName"></h3>
+    <p class="why" id="provWhy"></p>
+    <dl class="prov" id="provFields"></dl>
+  </div>
+</div>
+
 <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.
@@ -160,6 +225,24 @@ 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
 
+// signature archive (powers the consensus components: finder, roster, provenance)
+const CATEGORY_OF = {
+  'declaration-of-independence':'Declaration of Independence', 'politics':'Politics',
+  'sports':'Sports', 'hollywood':'Hollywood', 'movies-classic':'Movies (Classic)', 'tv':'TV'
+};
+let SIGS = [];           // usable=yes rows only (what's actually on the murals)
+let byMural = {};        // mural code -> [rows] (rank-sorted)
+let sigByKey = {};       // "category|rank" -> row
+
+// room-scene presets for the wall studio (CSS-only, $0)
+const SCENES = {
+  plain:   {label:'Plain',   wall:'linear-gradient(#f4eee4,#efe9df)', floor:'#d9cfbf', extra:''},
+  study:   {label:'Study',   wall:'linear-gradient(#e9ddc9,#ddcdb2)', floor:'#7c5a3a', extra:'art'},
+  lounge:  {label:'Lounge',  wall:'linear-gradient(#e7e8e3,#d8dad2)', floor:'#b29a7c', extra:'window'},
+  gallery: {label:'Gallery', wall:'linear-gradient(#fafafa,#eeeeec)', floor:'#cfcabf', extra:''},
+};
+let curScene = 'plain';
+
 const money = n => "$" + Math.round(n).toLocaleString();
 const $ = s => document.querySelector(s);
 
@@ -176,8 +259,12 @@ async function boot(){
 
   // 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="m-card" data-code="${m.code}">
+      <div class="pic">
+        <button class="name-toggle" data-toggle="${m.code}" type="button">Show names</button>
+        <img loading="lazy" data-base="/output/collage-${m.slug}.png" data-named="/output/collage-${m.slug}-named.png"
+             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>
@@ -186,6 +273,8 @@ async function boot(){
           <span class="pill">${heightFt}×${widthFt} ft</span>
           <span class="pill">${dpi} DPI TIF</span>
         </div>
+        <button class="roster-trig" data-roster="${m.code}" type="button">See who's on this wall ▾</button>
+        <div class="roster" data-rosterbody="${m.code}"></div>
         <div class="foot">
           <span class="price">${money(price)}</span>
           <button class="btn ghost" data-place="${m.code}" type="button">Place on wall →</button>
@@ -196,13 +285,41 @@ async function boot(){
   // mural selector
   $('#selMural').innerHTML = CAT.murals.map(m => `<option value="${m.code}">${m.title}</option>`).join('');
 
+  // load the signature archive (usable rows only) → finder, roster, provenance
+  try{
+    const all = await (await fetch('/api/signatures')).json();
+    SIGS = all.filter(r => r.usable_in_commercial_collage === 'yes');
+    for(const r of SIGS) sigByKey[r.category + '|' + r.rank] = r;
+    for(const m of CAT.murals){
+      byMural[m.code] = SIGS.filter(r => r.category === CATEGORY_OF[m.slug]).sort((a,b)=>a.rank-b.rank);
+    }
+  }catch(e){ SIGS = []; }
+
+  // gallery actions: place-on-wall · named toggle · roster drawer · roster item → provenance
   $('#gallery').addEventListener('click', e => {
-    const code = e.target.getAttribute?.('data-place');
-    if(!code) return;
-    $('#selMural').value = code;
-    selectMural(code);
-    $('#studio').scrollIntoView({behavior:'smooth'});
+    const place = e.target.closest?.('[data-place]');
+    if(place){ const code=place.getAttribute('data-place'); $('#selMural').value=code; selectMural(code); $('#studio').scrollIntoView({behavior:'smooth'}); return; }
+    const tog = e.target.closest?.('[data-toggle]');
+    if(tog){ const img=tog.parentElement.querySelector('img'); const showing=tog.textContent.startsWith('Show');
+      img.src = showing ? img.dataset.named : img.dataset.base; tog.textContent = showing ? 'Hide names' : 'Show names'; return; }
+    const rt = e.target.closest?.('[data-roster]');
+    if(rt){ toggleRoster(rt.getAttribute('data-roster'), rt); return; }
+    const item = e.target.closest?.('[data-sig]');
+    if(item){ openProv(sigByKey[item.getAttribute('data-sig')]); return; }
+  });
+  $('#gallery').addEventListener('input', e => {
+    const rs = e.target.closest?.('.r-search'); if(!rs) return;
+    const q = rs.value.trim().toLowerCase();
+    rs.closest('.roster').querySelectorAll('.r-item').forEach(it =>
+      it.style.display = it.textContent.toLowerCase().includes(q) ? '' : 'none');
   });
+
+  buildScenePresets();
+  wireFinder();
+  $('#provX').addEventListener('click', ()=>$('#provModal').classList.remove('open'));
+  $('#provModal').addEventListener('click', e => { if(e.target.id==='provModal') $('#provModal').classList.remove('open'); });
+  document.addEventListener('keydown', e => { if(e.key==='Escape') $('#provModal').classList.remove('open'); });
+
   $('#selMural').addEventListener('change', e => selectMural(e.target.value));
   $('#wallW').addEventListener('input', drawScene);
   $('#wallH').addEventListener('input', drawScene);
@@ -213,6 +330,73 @@ async function boot(){
   wireOrder();
 }
 
+// — roster drawer (consensus #1) —
+function toggleRoster(code, trig){
+  const body = document.querySelector(`[data-rosterbody="${code}"]`);
+  const open = body.classList.toggle('open');
+  trig.textContent = (open ? "Hide who's on this wall ▴" : "See who's on this wall ▾");
+  if(open && !body.dataset.filled){
+    const rows = byMural[code] || [];
+    body.innerHTML =
+      `<input class="r-search" type="search" placeholder="Filter ${rows.length} names…" autocomplete="off">
+       <div class="r-list">` +
+      rows.map(r => `<button class="r-item" data-sig="${r.category}|${r.rank}" type="button">
+          <img loading="lazy" src="${r.signature_image_url}" alt=""><span>${r.full_name}</span></button>`).join('') +
+      `</div>`;
+    body.dataset.filled = '1';
+  }
+}
+
+// — provenance modal (consensus #2) —
+function openProv(row){
+  if(!row) return;
+  $('#provSig').innerHTML = `<img src="${row.signature_image_url}" alt="${row.full_name} signature">`;
+  $('#provName').textContent = row.full_name;
+  $('#provWhy').textContent = row.reason_for_ranking || '';
+  const deceased = row.deceased ? ('Deceased' + (row.death_date ? ' ' + String(row.death_date).slice(0,4) : '')) : 'Living';
+  const risk = (row.risk_level || 'low').toLowerCase();
+  const link = row.wikidata || (row.ranking_source_urls||'').split('|')[0].trim() || row.signature_image_url;
+  $('#provFields').innerHTML = `
+    <dt>License</dt><dd>${row.image_license || '—'}</dd>
+    <dt>Source</dt><dd>${row.signature_source_type || 'Wikimedia Commons'}</dd>
+    <dt>Status</dt><dd>${deceased}</dd>
+    <dt>Use</dt><dd><span class="badge ${risk}">${risk} risk · collage-cleared</span></dd>
+    <dt>Provenance</dt><dd><a href="${link}" target="_blank" rel="noopener noreferrer">Wikidata / source ↗</a></dd>`;
+  $('#provModal').classList.add('open');
+}
+
+// — name finder (consensus #3) —
+function wireFinder(){
+  const inp = $('#finderInput'), res = $('#finderRes');
+  const codeOfCat = {}; CAT.murals.forEach(m => codeOfCat[CATEGORY_OF[m.slug]] = m);
+  inp.addEventListener('input', () => {
+    const q = inp.value.trim().toLowerCase();
+    if(q.length < 2){ res.innerHTML = ''; return; }
+    const hits = SIGS.filter(r => r.full_name.toLowerCase().includes(q)).slice(0, 8);
+    if(!hits.length){ res.innerHTML = `<div class="miss">Not in the openly-licensed archive yet — <a href="#order">request a custom mural</a> and we'll source them.</div>`; return; }
+    res.innerHTML = hits.map(r => { const m = codeOfCat[r.category];
+      return `<div class="hit"><img src="${r.signature_image_url}" alt=""> <b>${r.full_name}</b> — on ${m ? `<a href="#" data-go="${m.code}">${m.title}</a>` : r.category}</div>`;
+    }).join('');
+  });
+  res.addEventListener('click', e => {
+    const go = e.target.closest?.('[data-go]'); if(!go) return;
+    e.preventDefault(); const code = go.getAttribute('data-go');
+    $('#selMural').value = code; selectMural(code); $('#studio').scrollIntoView({behavior:'smooth'});
+  });
+}
+
+// — room-scene presets (consensus #4) —
+function buildScenePresets(){
+  $('#scenePresets').innerHTML = Object.entries(SCENES).map(([k,v]) =>
+    `<button type="button" data-scene="${k}" class="${k===curScene?'on':''}">${v.label}</button>`).join('');
+  $('#scenePresets').addEventListener('click', e => {
+    const b = e.target.closest('[data-scene]'); if(!b) return;
+    curScene = b.getAttribute('data-scene');
+    $('#scenePresets').querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
+    drawScene();
+  });
+}
+
 function muralByCode(c){ return CAT.murals.find(m => m.code === c); }
 
 function selectMural(code){
@@ -249,8 +433,14 @@ function drawScene(){
   $('#whOut').textContent = H + FT;
 
   const floorH = Math.min(sceneH*0.10, pxPerFt*1.0);
+  const SC = SCENES[curScene] || SCENES.plain;
+  scene.style.background = SC.wall;
+  let extra = '';
+  if(SC.extra === 'window') extra = `<div class="swindow" style="right:7%;width:${pxPerFt*3.5}px;height:${pxPerFt*4.5}px"></div>`;
+  else if(SC.extra === 'art') extra = `<div class="sart" style="left:6%;width:${pxPerFt*2}px;height:${pxPerFt*2.6}px"></div>`;
   scene.innerHTML = `
-    <div class="floor" style="height:${floorH}px"></div>
+    <div class="floor" style="height:${floorH}px;background:${SC.floor}"></div>
+    ${extra}
     <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"
diff --git a/scripts/build-portrait-murals.py b/scripts/build-portrait-murals.py
new file mode 100644
index 0000000..f942105
--- /dev/null
+++ b/scripts/build-portrait-murals.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""Build PORTRAIT murals: each cell = the person's PORTRAIT above their SIGNATURE.
+
+Same legal spine as the signature murals — nothing is included unless it is
+provably safe. The portrait is pulled from the person's Wikidata P18 image and is
+ONLY used if its Wikimedia Commons license is public-domain / CC0 / CC-BY(-SA).
+Any person whose portrait is missing or restricted falls back to a clean
+signature-only cell, so the mural never leans on an unlicensed photo.
+
+Outputs (web resolution, for the storefront + gallery):
+  output/portrait-<slug>.png        — names hidden, centre QR ("scan to reveal")
+  output/portrait-<slug>-named.png  — labelled key (QR target)
+
+Run via the venv:  .venv/bin/python scripts/build-portrait-murals.py [slug|all]
+"""
+
+import json, os, re, sys, time, urllib.parse, urllib.request
+from io import BytesIO
+from PIL import Image, ImageDraw, ImageFont
+import qrcode
+
+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 = {"User-Agent": "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"}
+COMMONS = "https://commons.wikimedia.org/w/api.php"
+WIKIDATA = "https://www.wikidata.org/w/api.php"
+BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
+SAFE = ("public domain", "pd", "cc0", "cc by", "cc-by")   # license substrings we accept
+BAD = ("fair use", "non-free", "all rights", "copyright")  # hard-reject substrings
+CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
+        "hollywood": "h", "movies-classic": "c", "tv": "t"}
+
+# layout
+POR_W, POR_H = 210, 250          # portrait box (cropped to fill)
+SIG_W, SIG_H = 220, 76
+GAP = 10
+PAD = 18
+LABEL_H = 24
+BOX_W = max(POR_W, SIG_W)
+BOX_H = POR_H + GAP + SIG_H
+CELL_W = BOX_W + PAD
+CELL_H = BOX_H + PAD
+CELL_H_NAMED = CELL_H + LABEL_H
+HEADER_H = 132
+FOOTER_H = 46
+INK = (22, 22, 22); MUTED = (120, 120, 120)
+os.makedirs(CACHE, exist_ok=True); os.makedirs(OUT, exist_ok=True)
+
+
+def get_json(u):
+    return json.load(urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=40))
+
+
+def font(sz, bold=False):
+    for p in ["/System/Library/Fonts/Supplemental/Georgia Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Georgia.ttf",
+              "/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf"]:
+        try: return ImageFont.truetype(p, sz)
+        except Exception: continue
+    return ImageFont.load_default()
+
+
+def fetch(url):
+    h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-90:]
+    cp = os.path.join(CACHE, h + ".img.png")
+    if os.path.exists(cp):
+        try: return Image.open(cp).convert("RGBA")
+        except Exception: pass
+    for attempt in range(5):
+        try:
+            raw = urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60).read()
+            im = Image.open(BytesIO(raw)).convert("RGBA"); im.save(cp); time.sleep(0.3); return im
+        except urllib.error.HTTPError as e:
+            if e.code == 429: time.sleep(8*(attempt+1)); continue
+            return None
+        except Exception: return None
+    return None
+
+
+def p18_for(qids):
+    """Q-id -> P18 filename (batched)."""
+    out = {}
+    for i in range(0, len(qids), 50):
+        b = qids[i:i+50]
+        j = get_json(WIKIDATA + "?" + urllib.parse.urlencode(
+            {"action": "wbgetentities", "format": "json", "props": "claims", "ids": "|".join(b)}))
+        for q in b:
+            cl = j.get("entities", {}).get(q, {}).get("claims", {}).get("P18")
+            if cl:
+                try: out[q] = cl[0]["mainsnak"]["datavalue"]["value"]
+                except Exception: pass
+        time.sleep(0.2)
+    return out
+
+
+def portrait_info(filenames):
+    """File title -> (thumburl@width, license) for the portrait box, batched."""
+    out = {}
+    titles = ["File:" + f.replace(" ", "_") for f in filenames]
+    for i in range(0, len(titles), 40):
+        b = titles[i:i+40]
+        j = get_json(COMMONS + "?" + urllib.parse.urlencode(
+            {"action": "query", "format": "json", "prop": "imageinfo",
+             "iiprop": "url|extmetadata", "iiurlwidth": str(POR_W*2), "titles": "|".join(b)}))
+        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]
+            lic = ii.get("extmetadata", {}).get("LicenseShortName", {}).get("value", "")
+            key = norm.get(p.get("title"), p.get("title"))
+            out[key] = (ii.get("thumburl") or ii.get("url"), lic)
+        time.sleep(0.2)
+    return out
+
+
+def license_ok(lic):
+    l = (lic or "").lower()
+    if any(b in l for b in BAD): return False
+    return any(s in l for s in SAFE)
+
+
+def cover(im, w, h):
+    """Scale to COVER w×h then centre-crop (for portraits)."""
+    im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+    r = max(w/im.width, h/im.height)
+    im = im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)
+    x = (im.width-w)//2; y = (im.height-h)//2
+    return im.crop((x, y, x+w, y+h))
+
+
+def fit(im, w, h):
+    """Scale to FIT w×h preserving aspect on white (for signatures)."""
+    im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+    r = min(w/im.width, h/im.height, 1.0)
+    return im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)
+
+
+def sig_title(url):
+    return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])
+
+
+def sig_thumbs(rows):
+    out = {}
+    titles = [sig_title(r["signature_image_url"]) for r in rows]
+    for i in range(0, len(titles), 50):
+        b = titles[i:i+50]
+        j = get_json(COMMONS + "?" + urllib.parse.urlencode(
+            {"action": "query", "format": "json", "prop": "imageinfo",
+             "iiprop": "url", "iiurlwidth": "480", "titles": "|".join(b)}))
+        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 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")
+    m = qr.modules_count + 2*qr.border; box = max(4, round(px/m))
+    return img.resize((box*m, box*m), Image.NEAREST)
+
+
+def render(category, cells, named, slug, with_portrait, total):
+    n = len(cells)
+    cell_h = CELL_H_NAMED if named else CELL_H
+    cols = min(8, max(1, round(n ** 0.5)))
+    rows_ct = (n + cols - 1)//cols
+    W = cols*CELL_W + PAD
+    H = HEADER_H + rows_ct*cell_h + FOOTER_H
+    canvas = Image.new("RGB", (W, H), (255,255,255))
+    d = ImageDraw.Draw(canvas)
+    d.rectangle([0,0,W,HEADER_H], fill=(247,245,240))
+    d.line([0,HEADER_H,W,HEADER_H], fill=(230,227,220), width=2)
+    d.text((PAD+8,30), category, font=font(40, bold=True), fill=INK)
+    mode = "names shown" if named else "names hidden · scan centre code for the key"
+    d.text((PAD+10,84), f"{n} portraits & signatures · {with_portrait} with portrait · {mode} · public domain / openly licensed · Wikimedia Commons",
+           font=font(16), fill=MUTED)
+
+    for idx, c in enumerate(cells):
+        cx = PAD + (idx % cols)*CELL_W
+        cy = HEADER_H + (idx // cols)*cell_h + PAD//2
+        # portrait
+        bx = cx + (CELL_W - BOX_W)//2
+        if c["portrait"] is not None:
+            canvas.paste(c["portrait"], (bx + (BOX_W-POR_W)//2, cy))
+            d.rectangle([bx+(BOX_W-POR_W)//2, cy, bx+(BOX_W-POR_W)//2+POR_W, cy+POR_H], outline=(214,210,202), width=1)
+        # signature under portrait
+        s = c["sig"]
+        sy = cy + POR_H + GAP
+        canvas.paste(s, (cx + (CELL_W - s.width)//2, sy + (SIG_H - s.height)//2))
+        if named:
+            f = font(12); label = c["name"]
+            while d.textlength(label, font=f) > CELL_W-8 and len(label) > 4: label = label[:-2]
+            if label != c["name"]: label = label[:-1]+"…"
+            tw = d.textlength(label, font=f)
+            d.text((cx+(CELL_W-tw)/2, cy+BOX_H+4), label, font=f, fill=MUTED)
+
+    if not named:
+        grid_h = rows_ct*cell_h
+        qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", 200)
+        cf = font(14, bold=True); cap = "SCAN TO REVEAL NAMES"
+        cw = int(d.textlength(cap, font=cf)); pin = 16; gap = 10
+        pw = max(qr.width, cw)+pin*2; ph = qr.height+gap+18+pin*2
+        px = (W-pw)//2; py = HEADER_H + (grid_h-ph)//2
+        d.rectangle([px+5,py+6,px+pw+5,py+ph+6], fill=(214,210,202))
+        d.rectangle([px,py,px+pw,py+ph], fill=(255,255,255), outline=(150,146,138), width=2)
+        canvas.paste(qr, (px+(pw-qr.width)//2, py+pin))
+        d.text((px+(pw-cw)/2, py+pin+qr.height+gap), cap, font=cf, fill=INK)
+
+    d.text((PAD+8, H-FOOTER_H+14),
+           "Portraits used only where public-domain / openly licensed; others shown by signature alone. Living-celebrity rows excluded.",
+           font=font(13), fill=MUTED)
+    suffix = "-named" if named else ""
+    path = os.path.join(OUT, f"portrait-{slug}{suffix}.png")
+    canvas.save(path, "PNG")
+    print(f"[{category}] -> {os.path.basename(path)} ({n} cells, {with_portrait} portraits, {cols}×{rows_ct}, {W}×{H})")
+    return path
+
+
+def build(category, rows, slug):
+    rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
+    qids = [r["wikidata"].rstrip("/").split("/")[-1] for r in rows]
+    p18 = p18_for(qids)
+    pinfo = portrait_info(list(set(p18.values()))) if p18 else {}
+    sigs = sig_thumbs(rows)
+
+    cells = []; with_portrait = 0
+    for r, q in zip(rows, qids):
+        # signature (required)
+        surl = sigs.get(sig_title(r["signature_image_url"])) or r["signature_image_url"]
+        sim = fetch(surl)
+        if not sim: continue
+        sig = fit(sim, SIG_W, SIG_H)
+        # portrait (optional, license-gated)
+        por = None
+        fn = p18.get(q)
+        if fn:
+            url, lic = pinfo.get("File:" + fn.replace(" ", "_"), (None, ""))
+            if url and license_ok(lic):
+                pim = fetch(url)
+                if pim: por = cover(pim, POR_W, POR_H); with_portrait += 1
+        cells.append({"name": r["full_name"], "sig": sig, "portrait": por})
+    if not cells:
+        print(f"[{category}] no cells"); return []
+    return [render(category, cells, True, slug, with_portrait, len(cells)),
+            render(category, cells, False, slug, with_portrait, len(cells))]
+
+
+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"Portrait murals · 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:
+            made += build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
+    print(f"\nBuilt {len(made)} portrait-mural PNGs in output/")
+
+
+if __name__ == "__main__":
+    main()

← 9111112 Murals storefront: /murals sales page + wall-placement studi  ·  back to CelebritySignatures  ·  Portrait murals: portrait+signature per cell (PD/CC-gated po 4ad180e →