[object Object]

← 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

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 →