← back to Wild Wallcoverings
initial scaffold: comfy+nano gen scripts, approval viewer
cd1a0ff8ae74f23a5ed48891ee0a5be82ff0b9e0 · 2026-07-22 12:13:39 -0700 · Steve Abrams
Files touched
A .gitignoreA public/index.htmlA scripts/comfy_gen.pyA scripts/nano_gen.pyA server.js
Diff
commit cd1a0ff8ae74f23a5ed48891ee0a5be82ff0b9e0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 12:13:39 2026 -0700
initial scaffold: comfy+nano gen scripts, approval viewer
---
.gitignore | 8 +++
public/index.html | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/comfy_gen.py | 65 ++++++++++++++++++++++
scripts/nano_gen.py | 34 ++++++++++++
server.js | 68 +++++++++++++++++++++++
5 files changed, 329 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5780cfe
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,154 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Wild Wallcoverings — Approval Viewer</title>
+<style>
+ :root { --cols: 3; --card-font: 13px; }
+ * { box-sizing: border-box; }
+ body { margin: 0; font-family: -apple-system, 'Helvetica Neue', sans-serif;
+ background: #111; color: #eee; }
+ header { padding: 18px 24px; border-bottom: 1px solid #2a2a2a;
+ display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
+ h1 { font-size: 20px; margin: 0; letter-spacing: .06em; font-weight: 600; }
+ h1 em { font-style: normal; color: #7ec8a9; }
+ .sub { color: #888; font-size: 13px; }
+ .controls { display: flex; gap: 14px; align-items: center; padding: 14px 24px; }
+ .controls label { font-size: 12px; color: #aaa; }
+ select, input[type=range] { accent-color: #7ec8a9; background: #1c1c1c;
+ color: #eee; border: 1px solid #333; border-radius: 6px; padding: 4px 8px; }
+ #board { padding: 0 24px 60px; }
+ .design { margin-bottom: 34px; border: 1px solid #262626; border-radius: 12px;
+ padding: 16px; background: #161616; }
+ .design h2 { margin: 0 0 2px; font-size: 16px; }
+ .design .theme { color: #8fa; font-size: 12px; margin-bottom: 2px; }
+ .design .desc { color: #999; font-size: 12px; margin-bottom: 12px; max-width: 760px; }
+ .variants { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 14px; }
+ .card { border: 1px solid #2c2c2c; border-radius: 10px; overflow: hidden;
+ background: #1b1b1b; font-size: var(--card-font); }
+ .card img { width: 100%; aspect-ratio: 24/25; object-fit: cover; display: block;
+ cursor: zoom-in; background: #0b0b0b; }
+ .card .meta { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
+ .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; width: fit-content; }
+ .badge.nano { background: #3b2f14; color: #f0c060; }
+ .badge.local { background: #14303b; color: #60c0f0; }
+ .when { color: #9a9a9a; font-size: 11px; }
+ .choose { border: 1px solid #3c6; background: transparent; color: #7ec8a9;
+ border-radius: 8px; padding: 7px 0; cursor: pointer; font-size: 13px; }
+ .choose:hover { background: #1e2e26; }
+ .card.chosen { border-color: #4caf7d; box-shadow: 0 0 0 2px #4caf7d55; }
+ .card.chosen .choose { background: #4caf7d; color: #08130d; font-weight: 600; }
+ .rowactions { margin-top: 10px; }
+ .skip { border: 1px solid #444; background: transparent; color: #999;
+ border-radius: 8px; padding: 6px 14px; cursor: pointer; font-size: 12px; }
+ .status { font-size: 12px; color: #f0c060; margin-left: 10px; }
+ #lightbox { position: fixed; inset: 0; background: #000d; display: none;
+ align-items: center; justify-content: center; z-index: 9; cursor: zoom-out; }
+ #lightbox img { max-width: 92vw; max-height: 92vh; }
+</style>
+</head>
+<body>
+<header>
+ <h1>WILD <em>WALLCOVERINGS</em></h1>
+ <span class="sub">24" × 25" repeat · pick ONE version per design to make live · <span id="count"></span></span>
+</header>
+<div class="controls">
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="title">Title A→Z</option>
+ <option value="theme">Theme</option>
+ <option value="undecided">Undecided first</option>
+ </select>
+ </label>
+ <label>Density <input type="range" id="density" min="1" max="4" value="3"></label>
+</div>
+<div id="board"></div>
+<div id="lightbox" onclick="this.style.display='none'"><img></div>
+<script>
+const fmtDate = iso => new Date(iso).toLocaleString(undefined,
+ { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+
+const sortSel = document.getElementById('sort');
+const density = document.getElementById('density');
+sortSel.value = localStorage.getItem('wildwc.sort') || 'newest';
+density.value = localStorage.getItem('wildwc.density') || '3';
+
+let designs = [];
+
+function applyDensity() {
+ document.documentElement.style.setProperty('--cols', density.value);
+ document.documentElement.style.setProperty('--card-font', (11 + (4 - density.value)) + 'px');
+ localStorage.setItem('wildwc.density', density.value);
+}
+
+function sorted() {
+ const d = [...designs];
+ const by = sortSel.value;
+ if (by === 'title') d.sort((a, b) => a.title.localeCompare(b.title));
+ else if (by === 'theme') d.sort((a, b) => a.theme.localeCompare(b.theme));
+ else if (by === 'undecided') d.sort((a, b) => (a.choice ? 1 : 0) - (b.choice ? 1 : 0));
+ else d.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
+ localStorage.setItem('wildwc.sort', by);
+ return d;
+}
+
+function render() {
+ const board = document.getElementById('board');
+ board.innerHTML = '';
+ const decided = designs.filter(d => d.choice).length;
+ document.getElementById('count').textContent =
+ `${designs.length} designs · ${decided} decided`;
+ for (const d of sorted()) {
+ const el = document.createElement('div');
+ el.className = 'design';
+ const chosenFile = d.choice && d.choice.variant;
+ el.innerHTML = `<h2>${d.title}</h2>
+ <div class="theme">${d.theme}</div>
+ <div class="desc">${d.description || ''}</div>
+ <div class="variants">` +
+ d.variants.map(v => `
+ <div class="card ${chosenFile === v.file ? 'chosen' : ''}">
+ <img src="/images/${v.file}" loading="lazy" onclick="zoom(this.src)">
+ <div class="meta">
+ <span class="badge ${v.engine === 'nano-banana' ? 'nano' : 'local'}">
+ ${v.engine === 'nano-banana' ? '🍌 nano banana' : '🖥 local SDXL'}</span>
+ <span class="when" title="${v.created_at}">🕓 ${fmtDate(v.created_at)}</span>
+ <button class="choose" onclick="choose('${d.id}','${v.file}')">
+ ${chosenFile === v.file ? '✓ CHOSEN — will go live' : 'Choose this version'}</button>
+ </div>
+ </div>`).join('') +
+ `</div>
+ <div class="rowactions">
+ <button class="skip" onclick="choose('${d.id}','none')">Skip — none of these</button>
+ ${chosenFile === 'none' ? '<span class="status">skipped</span>' : ''}
+ </div>`;
+ board.appendChild(el);
+ }
+}
+
+function zoom(src) {
+ const lb = document.getElementById('lightbox');
+ lb.querySelector('img').src = src;
+ lb.style.display = 'flex';
+}
+
+async function choose(id, variant) {
+ await fetch('/api/choose', { method: 'POST', body: JSON.stringify({ id, variant }) });
+ const d = designs.find(x => x.id === id);
+ d.choice = { variant };
+ render();
+}
+
+async function load() {
+ designs = await (await fetch('/api/designs')).json();
+ render();
+}
+sortSel.onchange = render;
+density.oninput = applyDensity;
+applyDensity();
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/comfy_gen.py b/scripts/comfy_gen.py
new file mode 100644
index 0000000..120bab6
--- /dev/null
+++ b/scripts/comfy_gen.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Submit an SDXL txt2img job to Mac1 ComfyUI and download the result.
+
+Usage: comfy_gen.py "<prompt>" <seed> <outfile.png>
+24"x25" repeat -> 1024x1064 px (closest /8-legal SDXL frame to 0.96 aspect).
+"""
+import json, sys, time, urllib.request, urllib.parse, uuid
+
+HOST = "http://192.168.1.133:8188"
+NEG = ("photograph, photo, 3d render, watermark, text, signature, frame, border, "
+ "banana, banana leaf, grapes, bird, butterfly, blurry, low quality")
+
+def workflow(prompt, seed):
+ return {
+ "4": {"class_type": "CheckpointLoaderSimple",
+ "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"}},
+ "5": {"class_type": "EmptyLatentImage",
+ "inputs": {"width": 1024, "height": 1064, "batch_size": 1}},
+ "6": {"class_type": "CLIPTextEncode",
+ "inputs": {"text": prompt, "clip": ["4", 1]}},
+ "7": {"class_type": "CLIPTextEncode",
+ "inputs": {"text": NEG, "clip": ["4", 1]}},
+ "3": {"class_type": "KSampler",
+ "inputs": {"seed": seed, "steps": 22, "cfg": 7.5,
+ "sampler_name": "dpmpp_2m", "scheduler": "karras",
+ "denoise": 1.0, "model": ["4", 0],
+ "positive": ["6", 0], "negative": ["7", 0],
+ "latent_image": ["5", 0]}},
+ "8": {"class_type": "VAEDecode",
+ "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
+ "9": {"class_type": "SaveImage",
+ "inputs": {"filename_prefix": "wildwc", "images": ["8", 0]}},
+ }
+
+def post(path, payload):
+ req = urllib.request.Request(HOST + path, data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json"})
+ return json.load(urllib.request.urlopen(req, timeout=30))
+
+def main():
+ prompt, seed, outfile = sys.argv[1], int(sys.argv[2]), sys.argv[3]
+ pid = post("/prompt", {"prompt": workflow(prompt, seed),
+ "client_id": str(uuid.uuid4())})["prompt_id"]
+ deadline = time.time() + 600
+ while time.time() < deadline:
+ time.sleep(4)
+ hist = json.load(urllib.request.urlopen(f"{HOST}/history/{pid}", timeout=30))
+ if pid in hist:
+ entry = hist[pid]
+ status = entry.get("status", {})
+ if status.get("status_str") == "error":
+ sys.exit(f"comfy error: {json.dumps(status)[:400]}")
+ outputs = entry.get("outputs", {})
+ for node in outputs.values():
+ for img in node.get("images", []):
+ url = (f"{HOST}/view?filename={urllib.parse.quote(img['filename'])}"
+ f"&subfolder={urllib.parse.quote(img.get('subfolder',''))}&type={img.get('type','output')}")
+ with urllib.request.urlopen(url, timeout=60) as r, open(outfile, "wb") as f:
+ f.write(r.read())
+ print(f"saved {outfile}")
+ return
+ sys.exit("timeout waiting for comfy job")
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/nano_gen.py b/scripts/nano_gen.py
new file mode 100644
index 0000000..93d0dc6
--- /dev/null
+++ b/scripts/nano_gen.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Generate one image via Gemini 2.5 Flash Image ("nano banana").
+
+Usage: GEMINI_API_KEY=... nano_gen.py "<prompt>" <outfile.png>
+Cost: ~$0.039 per image (1290 output tokens @ $30/1M).
+"""
+import base64, json, os, sys, urllib.request
+
+MODEL = "gemini-2.5-flash-image"
+KEY = os.environ["GEMINI_API_KEY"]
+URL = (f"https://generativelanguage.googleapis.com/v1beta/models/"
+ f"{MODEL}:generateContent?key={KEY}")
+
+def main():
+ prompt, outfile = sys.argv[1], sys.argv[2]
+ body = {
+ "contents": [{"parts": [{"text": prompt}]}],
+ "generationConfig": {"responseModalities": ["IMAGE"],
+ "imageConfig": {"aspectRatio": "1:1"}},
+ }
+ req = urllib.request.Request(URL, data=json.dumps(body).encode(),
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=120) as r:
+ resp = json.load(r)
+ for part in resp["candidates"][0]["content"]["parts"]:
+ if "inlineData" in part:
+ with open(outfile, "wb") as f:
+ f.write(base64.b64decode(part["inlineData"]["data"]))
+ print(f"saved {outfile}")
+ return
+ sys.exit("no image in response: " + json.dumps(resp)[:400])
+
+if __name__ == "__main__":
+ main()
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..3418c3d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,68 @@
+// Wild Wallcoverings — 3-variant approval viewer
+// Zero-dependency node server. Basic auth admin / DW2024!.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9787;
+const ROOT = __dirname;
+const DESIGNS = path.join(ROOT, 'data', 'designs.json');
+const CHOICES = path.join(ROOT, 'data', 'choices.json');
+
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+
+function readJson(p, fallback) {
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
+}
+
+const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.html': 'text/html',
+ '.json': 'application/json', '.css': 'text/css', '.js': 'text/javascript' };
+
+const server = http.createServer((req, res) => {
+ if (req.headers.authorization !== AUTH) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="wildwc"' });
+ return res.end('auth required');
+ }
+ const url = new URL(req.url, 'http://x');
+
+ if (url.pathname === '/api/designs') {
+ const designs = readJson(DESIGNS, []);
+ const choices = readJson(CHOICES, {});
+ designs.forEach(d => { d.choice = choices[d.id] || null; });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(designs));
+ }
+
+ if (url.pathname === '/api/choose' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const { id, variant } = JSON.parse(body); // variant: filename or "none"
+ const choices = readJson(CHOICES, {});
+ choices[id] = { variant, chosen_at: new Date().toISOString() };
+ fs.writeFileSync(CHOICES, JSON.stringify(choices, null, 2));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ } catch (e) {
+ res.writeHead(400); res.end(String(e));
+ }
+ });
+ return;
+ }
+
+ // static: /images/* -> output/designs/*, everything else -> public/
+ let file;
+ if (url.pathname.startsWith('/images/')) {
+ file = path.join(ROOT, 'output', 'designs', path.basename(url.pathname));
+ } else {
+ file = path.join(ROOT, 'public', url.pathname === '/' ? 'index.html' : path.basename(url.pathname));
+ }
+ fs.readFile(file, (err, data) => {
+ if (err) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
+ res.end(data);
+ });
+});
+
+server.listen(PORT, () => console.log(`wild-wallcoverings viewer on http://127.0.0.1:${PORT}`));
(oldest)
·
back to Wild Wallcoverings
·
briefs (settlement-gated 24/24 OK), batch driver, viewer liv 94741ef →