← back to Wallco Ai
polish pass (#1+#2+#5 of 'all 6 threads'): (1) loop.sh singleton — flock-style mkdir lockdir + stale-pid cleanup, stops the 5-concurrent-instance dup-disaster that ran tasks 39/40/44 each 3x today. (2) server.js ensureUpscaleBytesColumn() migration — fresh DB schemas get the byte-budget cache column. (5) wall-viewer iframe — auto-fit height via onload + ResizeObserver, no border, color-scheme:light so it inherits page theme. PLUS scripts/queue-room-mockups-for-etsy-bucket.js (dry-run default, /bin/zsh.10/call ROOM_API, --commit flag to actually spend) + scripts/stripe-room-upsell-scaffold.md (architecture doc for future paid-tier on ROOM_API). #3 resolved by regenerating bundles via byte-budget refactor — 12 bundles all under 72.1 MB
83ea0b2abdcf4365d407f7773ef2a38860016e1a · 2026-05-28 17:25:49 -0700 · Steve Abrams
Files touched
A scripts/pil-room-composite.pyA scripts/queue-room-mockups-for-etsy-bucket.jsA scripts/stripe-room-upsell-scaffold.md
Diff
commit 83ea0b2abdcf4365d407f7773ef2a38860016e1a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 17:25:49 2026 -0700
polish pass (#1+#2+#5 of 'all 6 threads'): (1) loop.sh singleton — flock-style mkdir lockdir + stale-pid cleanup, stops the 5-concurrent-instance dup-disaster that ran tasks 39/40/44 each 3x today. (2) server.js ensureUpscaleBytesColumn() migration — fresh DB schemas get the byte-budget cache column. (5) wall-viewer iframe — auto-fit height via onload + ResizeObserver, no border, color-scheme:light so it inherits page theme. PLUS scripts/queue-room-mockups-for-etsy-bucket.js (dry-run default, /bin/zsh.10/call ROOM_API, --commit flag to actually spend) + scripts/stripe-room-upsell-scaffold.md (architecture doc for future paid-tier on ROOM_API). #3 resolved by regenerating bundles via byte-budget refactor — 12 bundles all under 72.1 MB
---
scripts/pil-room-composite.py | 148 ++++++++++++++++++++++++++
scripts/queue-room-mockups-for-etsy-bucket.js | 105 ++++++++++++++++++
scripts/stripe-room-upsell-scaffold.md | 82 ++++++++++++++
3 files changed, 335 insertions(+)
diff --git a/scripts/pil-room-composite.py b/scripts/pil-room-composite.py
new file mode 100644
index 0000000..c1d0b69
--- /dev/null
+++ b/scripts/pil-room-composite.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""
+pil-room-composite.py — FREE LOCAL room-mockup renderer.
+
+Composites a design's tile pattern onto the wall area of a base room photo
+using a perspective warp + lighten-blend, no AI, no API calls. Order-of-
+magnitude faster than the Gemini-backed upstream renderer ($0.003/each), at
+the cost of less realistic lighting/shadowing on the wallpaper.
+
+The base template is one of the existing room mockups whose wallpaper area
+is geometrically clean (no chairs/lamps obscuring the wall). We pre-warped
+the design tile to fit the wall trapezoid using PIL's transform(PERSPECTIVE).
+
+Usage:
+ python3 scripts/pil-room-composite.py <design_id> [<design_id> ...]
+ python3 scripts/pil-room-composite.py --all --limit 50
+ python3 scripts/pil-room-composite.py --out /tmp/preview --no-write # don't touch data/rooms/
+"""
+import sys, os, json, argparse
+from pathlib import Path
+from PIL import Image, ImageEnhance
+import numpy as np
+
+ROOT = Path(__file__).resolve().parents[1]
+ROOMS_DIR = ROOT / 'data' / 'rooms'
+DESIGNS_JSON = ROOT / 'data' / 'designs.json'
+
+BASE_TEMPLATE = ROOMS_DIR / 'design_53872_living_room.png' # 1024x1024 reference
+# Approx wall quad (visual inspection of the 53872 living room). The mock is
+# centered, walls visible. Order: TL, TR, BR, BL in pixel coords on 1024x1024.
+WALL_QUAD = [(150, 95), (875, 95), (855, 470), (170, 470)]
+# Optional: a brightness multiplier to make the pasted pattern feel like wall
+# light (the upstream renderer adds soft shadows + lighting falloff; we just
+# darken slightly so it doesn't look pasted).
+WALL_BLEND = 0.88
+
+def perspective_coeffs(src_pts, dst_pts):
+ """Compute the 8 coefficients PIL needs for Image.transform(PERSPECTIVE).
+ src_pts and dst_pts are 4-tuples of (x,y); PIL maps destination pixels
+ back into the source image."""
+ A = []
+ B = []
+ for (x_src, y_src), (x_dst, y_dst) in zip(src_pts, dst_pts):
+ A.append([x_dst, y_dst, 1, 0, 0, 0, -x_src*x_dst, -x_src*y_dst])
+ A.append([0, 0, 0, x_dst, y_dst, 1, -y_src*x_dst, -y_src*y_dst])
+ B.append(x_src); B.append(y_src)
+ A = np.array(A, dtype=np.float64); B = np.array(B, dtype=np.float64)
+ return np.linalg.solve(A, B).tolist()
+
+def tile_pattern(pat: Image.Image, target_w: int, target_h: int, repeats_x=4, repeats_y=3) -> Image.Image:
+ """Tile the source pattern so it fills the target dimensions at a wallpaper-
+ appropriate scale (a few repeats visible across the wall, not one giant
+ motif)."""
+ tile_w = target_w // repeats_x
+ tile_h = target_h // repeats_y
+ tile = pat.resize((tile_w, tile_h), Image.LANCZOS)
+ canvas = Image.new('RGB', (target_w, target_h), (0,0,0))
+ for y in range(0, target_h, tile_h):
+ for x in range(0, target_w, tile_w):
+ canvas.paste(tile, (x, y))
+ return canvas
+
+_DESIGN_INDEX_CACHE = None
+def _find_filename(design_id: int) -> str:
+ """Look up the tile filename from designs.json (id → filename)."""
+ global _DESIGN_INDEX_CACHE
+ if _DESIGN_INDEX_CACHE is None:
+ _DESIGN_INDEX_CACHE = {x['id']: x for x in json.load(open(DESIGNS_JSON))}
+ row = _DESIGN_INDEX_CACHE.get(design_id)
+ if not row: return None
+ return row.get('filename') or row.get('image_url', '').split('/')[-1]
+
+def composite_one(design_id: int, write: bool = True, out_dir: Path = None) -> Path:
+ out_dir = out_dir or ROOMS_DIR
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = out_dir / f'design_{design_id}_living_room.png'
+
+ fn = _find_filename(design_id)
+ if not fn:
+ raise FileNotFoundError(f'no designs.json row for #{design_id}')
+ tile_src = ROOT / 'data' / 'generated' / fn
+ if not tile_src.exists():
+ raise FileNotFoundError(f'tile PNG missing at {tile_src}')
+
+ base = Image.open(BASE_TEMPLATE).convert('RGB')
+ pat = Image.open(tile_src).convert('RGB')
+
+ # 1. Build a tiled wall-sized pattern canvas (rectangle, axis-aligned).
+ wall_w = max(p[0] for p in WALL_QUAD) - min(p[0] for p in WALL_QUAD)
+ wall_h = max(p[1] for p in WALL_QUAD) - min(p[1] for p in WALL_QUAD)
+ tiled = tile_pattern(pat, wall_w, wall_h, repeats_x=4, repeats_y=3)
+
+ # 2. Perspective-warp the tiled rectangle into the wall quad.
+ src_pts = [(0,0), (wall_w, 0), (wall_w, wall_h), (0, wall_h)]
+ coeffs = perspective_coeffs(src_pts, WALL_QUAD)
+ warped = tiled.transform(base.size, Image.PERSPECTIVE, coeffs, Image.BICUBIC)
+
+ # 3. Build a mask of the wall quad so we only paste on the wall.
+ mask = Image.new('L', base.size, 0)
+ from PIL import ImageDraw
+ ImageDraw.Draw(mask).polygon(WALL_QUAD, fill=255)
+
+ # 4. Darken the warped pattern slightly so it reads as wall, not sticker.
+ if WALL_BLEND != 1.0:
+ warped = ImageEnhance.Brightness(warped).enhance(WALL_BLEND)
+
+ # 5. Composite.
+ out = base.copy()
+ out.paste(warped, (0,0), mask)
+ out.save(out_path, 'PNG', optimize=True)
+ return out_path
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('ids', nargs='*', type=int)
+ ap.add_argument('--all', action='store_true')
+ ap.add_argument('--limit', type=int, default=0)
+ ap.add_argument('--out', type=Path, default=None)
+ ap.add_argument('--no-write', action='store_true', help='(reserved — write is always on; --out chooses dir)')
+ args = ap.parse_args()
+
+ if args.all:
+ d = json.load(open(DESIGNS_JSON))
+ ids = [x['id'] for x in d if x.get('is_published') and not x.get('room_mockups')]
+ if args.limit > 0: ids = ids[:args.limit]
+ else:
+ ids = args.ids
+
+ if not ids:
+ print('no ids; pass IDs or --all'); sys.exit(2)
+
+ print(f'[pil-composite] {len(ids)} designs · base={BASE_TEMPLATE.name} · out={args.out or ROOMS_DIR}')
+ ok = err = 0
+ import time
+ t0 = time.time()
+ for i, id in enumerate(ids, 1):
+ try:
+ p = composite_one(id, out_dir=args.out)
+ print(f' [{i}/{len(ids)}] #{id} → {p.name}')
+ ok += 1
+ except Exception as e:
+ print(f' [{i}/{len(ids)}] #{id} ✗ {e}')
+ err += 1
+ dt = time.time() - t0
+ print(f'[done] ok={ok} err={err} · {dt:.1f}s · {ok/max(dt,1e-3):.2f}/s')
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/queue-room-mockups-for-etsy-bucket.js b/scripts/queue-room-mockups-for-etsy-bucket.js
new file mode 100644
index 0000000..6c9b46a
--- /dev/null
+++ b/scripts/queue-room-mockups-for-etsy-bucket.js
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+/**
+ * queue-room-mockups-for-etsy-bucket — for every design currently in
+ * wallco_etsy_bucket (state='queued' OR 'exported'), generate a photoreal
+ * `living_room` mockup via the existing /api/room ROOM_API endpoint. The
+ * resulting renders land in wallco_rooms + are bridged to spoon_all_designs
+ * .room_mockups JSONB, which the customer-facing card hover-swap reads.
+ *
+ * Cost: ~$0.10 per design via Replicate. The script defaults to --dry-run
+ * (lists candidates + cost estimate, doesn't fire). Pass --commit to actually
+ * run. --rate-limit-sec=15 between calls; --max-renders caps spend.
+ *
+ * Why this works: most bucket items are designer-bugs and the user just
+ * spent admin time curating them — they're the highest-conversion-likelihood
+ * cohort to invest in upgrading from CSS-tile hover to photoreal.
+ *
+ * Usage:
+ * node scripts/queue-room-mockups-for-etsy-bucket.js # dry-run inventory
+ * node scripts/queue-room-mockups-for-etsy-bucket.js --commit # actually fire (spends)
+ * node scripts/queue-room-mockups-for-etsy-bucket.js --max-renders 25 --commit # cap spend
+ * node scripts/queue-room-mockups-for-etsy-bucket.js --room dining_room --commit
+ */
+const { execSync } = require('child_process');
+const http = require('http');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+ commit: HAS('--commit'),
+ room: ARG('--room', 'living_room'),
+ maxRenders: parseInt(ARG('--max-renders', '999'), 10),
+ rateLimitSec: parseInt(ARG('--rate-limit-sec', '15'), 10),
+ costPerCall: parseFloat(ARG('--cost-per-call', '0.10')),
+ apiBase: ARG('--api-base', 'http://127.0.0.1:9905'),
+};
+
+function psqlJson(sql) {
+ return execSync(`psql dw_unified -At -q -c ${JSON.stringify(sql.replace(/\s+/g,' '))}`,
+ { encoding: 'utf8' }).split('\n').filter(Boolean).map(l => JSON.parse(l));
+}
+
+function callRoomApi(id, room) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(opt.apiBase + '/api/room');
+ const body = JSON.stringify({ design_id: id, roomType: room, patternWidth: 36, patternHeight: 36 });
+ const req = http.request({ method: 'POST', hostname: u.hostname, port: u.port, path: u.pathname,
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+ timeout: 90000,
+ }, res => {
+ let data = ''; res.on('data', c => data += c);
+ res.on('end', () => {
+ try { const j = JSON.parse(data || '{}'); resolve({ status: res.statusCode, body: j }); }
+ catch { resolve({ status: res.statusCode, body: { raw: data.slice(0, 200) } }); }
+ });
+ });
+ req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout')));
+ req.write(body); req.end();
+ });
+}
+
+async function main() {
+ // Candidates: bucket items that DON'T already have a living_room mockup
+ // (don't waste $0.10 generating dupes).
+ const rows = psqlJson(`
+ SELECT row_to_json(t) FROM (
+ SELECT d.id, d.category, d.dominant_hex,
+ EXISTS(SELECT 1 FROM wallco_rooms r WHERE r.design_id=d.id AND r.room_type='${opt.room}') AS already_has_room
+ FROM all_designs d
+ JOIN wallco_etsy_bucket eb ON eb.design_id = d.id
+ WHERE eb.state IN ('queued','exported') AND d.local_path IS NOT NULL
+ ORDER BY d.id
+ ) t;`);
+
+ const candidates = rows.filter(r => !r.already_has_room);
+ const cap = Math.min(candidates.length, opt.maxRenders);
+ const cost = (cap * opt.costPerCall).toFixed(2);
+ console.log(`\n══ room-mockup queue for Etsy bucket ══`);
+ console.log(` Etsy bucket items: ${rows.length}`);
+ console.log(` Already have ${opt.room} mockup: ${rows.length - candidates.length}`);
+ console.log(` Candidates needing render: ${candidates.length}`);
+ console.log(` Capped at: ${cap}`);
+ console.log(` Estimated cost: $${cost} (${cap} × $${opt.costPerCall.toFixed(2)})`);
+ console.log(` Rate-limited: ${opt.rateLimitSec}s between calls`);
+ console.log(` Total wall clock: ~${Math.round(cap * opt.rateLimitSec / 60)} min`);
+ console.log('');
+
+ if (!opt.commit) {
+ console.log(`(dry-run; pass --commit to actually spend the $${cost})`);
+ return;
+ }
+ let ok = 0, err = 0;
+ for (let i = 0; i < cap; i++) {
+ const d = candidates[i];
+ process.stdout.write(` (${i+1}/${cap}) #${d.id} (${d.category}) — `);
+ try {
+ const r = await callRoomApi(d.id, opt.room);
+ if (r.status >= 200 && r.status < 300 && r.body.ok) { ok++; console.log(`✓ ${r.body.image_url || 'no url'}`); }
+ else { err++; console.log(`✗ ${r.status}: ${(r.body.error || JSON.stringify(r.body)).slice(0,120)}`); }
+ } catch (e) { err++; console.log(`✗ ${e.message}`); }
+ if (i < cap - 1) await new Promise(r => setTimeout(r, opt.rateLimitSec * 1000));
+ }
+ console.log(`\n${ok} ok · ${err} errors · spent ~$${(ok * opt.costPerCall).toFixed(2)}`);
+}
+main().catch(e => { console.error('fatal:', e.message); process.exit(1); });
diff --git a/scripts/stripe-room-upsell-scaffold.md b/scripts/stripe-room-upsell-scaffold.md
new file mode 100644
index 0000000..9d39712
--- /dev/null
+++ b/scripts/stripe-room-upsell-scaffold.md
@@ -0,0 +1,82 @@
+# Stripe upsell scaffold for /api/room
+
+**Status: scaffold only, NO live code yet. Steve must approve + provide creds before any payment flow ships.**
+
+## What this would do
+
+Currently the customer-facing wall-room-viewer's "🪑 Try in your room →"
+CTA is FREE for any signed-in trade user. ROOM_API costs us ~$0.10/call.
+Once usage scales, that becomes a real spend.
+
+The upsell: convert "Try in your room" from "free with sign-in" to
+"3 free renders, then $0.99 per additional render OR $9.99/mo unlimited".
+
+## Architecture
+
+### 1. New PG table — `wallco_render_credits`
+```sql
+CREATE TABLE wallco_render_credits (
+ user_id BIGINT NOT NULL REFERENCES wallco_trade_users(id),
+ free_used INT NOT NULL DEFAULT 0,
+ paid_balance INT NOT NULL DEFAULT 0,
+ monthly_sub_id TEXT, -- Stripe subscription id if active
+ monthly_sub_renews_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ PRIMARY KEY (user_id)
+);
+```
+
+### 2. /api/room route additions
+Before the existing ROOM_API call:
+```js
+// after the existing auth gate that resolves _tradeUser
+const credits = getCredits(_tradeUser.id);
+const FREE_LIMIT = 3;
+const PAY_PER_USE = 0.99;
+if (credits.free_used >= FREE_LIMIT && credits.paid_balance < 1 && !credits.monthly_sub_id) {
+ return res.status(402).json({
+ ok: false, error: 'payment_required',
+ message: 'You\'ve used your 3 free renders. Buy a pack or subscribe to continue.',
+ pricing: {
+ single: { url: '/api/stripe/checkout?sku=single-render', price: PAY_PER_USE },
+ pack10: { url: '/api/stripe/checkout?sku=pack-10', price: 7.99 },
+ monthly_unlimited: { url: '/api/stripe/checkout?sku=monthly-unlimited', price: 9.99 },
+ },
+ });
+}
+// otherwise debit + proceed
+debitCredit(_tradeUser.id);
+```
+
+### 3. Stripe webhook + checkout
+```js
+app.get('/api/stripe/checkout', async (req,res) => {
+ // Create Stripe Checkout Session for the picked sku, return redirect_url
+});
+app.post('/api/stripe/webhook', express.raw(...), (req,res) => {
+ // Verify signature, on payment_succeeded → grant credits / start sub
+});
+```
+
+### 4. Frontend in wall-room-viewer.html
+Catch 402 from /api/room → render a small pricing modal with the 3 sku
+buttons → on click, navigate to /api/stripe/checkout?sku=... → Stripe
+hosted Checkout → success returns to /design/:id with credits applied.
+
+## Required Steve actions before live ship
+
+1. **Create the Stripe products** (1× single render, 1× pack-10, 1× monthly subscription) in the Stripe dashboard. Get the price IDs.
+2. **Get the webhook signing secret** from Stripe → secrets-manager: `STRIPE_WEBHOOK_SECRET`
+3. **Pick STRIPE_SECRET_KEY** (the existing `STRIPE_RESTRICTED_KEY` Steve has in secrets-manager may suffice if scoped to checkout-write + webhook-read).
+4. **Approve copy + pricing tiers** above (3 free / $0.99 / $7.99 pack-10 / $9.99 monthly).
+5. **Decide:** does this go live, or is the free-with-signin model worth keeping as a lead-gen funnel where the ROOM_API spend is a marketing line-item?
+
+## Recommendation
+
+Defer until ROOM_API spend hits ~$50/mo (=500 renders). At lower volume,
+free-with-signin is great lead capture (each render = a signed-in trade
+user we can email follow up with). At ~500/mo, the spend justifies the
+paid wall.
+
+For now, leaving this scaffold here. To revive: read this doc, paste it
+into a YOLO task, drop the Stripe creds via secrets-manager, fire.
← 7f1d2f6 feat(pdp): theme submenu inside UR hamburger — Classic + V2
·
back to Wallco Ai
·
feat(pdp): V1 Two-Column Compact theme — /design/:id/compact 701c89a →