[object Object]

← back to Designer Wallcoverings

Add Tres Tintas room-setting generator (Replicate SDXL, pattern-locked)

1f14df1493f94f29137ef5955a04e1e803494efa · 2026-06-23 11:04:44 -0700 · Steve

Generates 1 photorealistic room per Tres Tintas product lacking one (557 of
592). Approach: build a one-point-perspective room with the EXACT catalog
pattern tiled on the back wall (molding frame + baseboard), mask-LOCK that wall
(black=keep), and let Replicate SDXL inpainting (lucataco/sdxl-inpainting)
render furniture/floor/light around it — so SDXL can never alter the pattern.
~$0.02/img (~$11 for 557). Idempotent ledger, error-tolerant (one failure
skipped not fatal), running cost total. Proven on geometric (Ebano) + floral
(Pond) patterns. Steve-approved: Replicate SDXL, 1 room each.

Files touched

Diff

commit 1f14df1493f94f29137ef5955a04e1e803494efa
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 23 11:04:44 2026 -0700

    Add Tres Tintas room-setting generator (Replicate SDXL, pattern-locked)
    
    Generates 1 photorealistic room per Tres Tintas product lacking one (557 of
    592). Approach: build a one-point-perspective room with the EXACT catalog
    pattern tiled on the back wall (molding frame + baseboard), mask-LOCK that wall
    (black=keep), and let Replicate SDXL inpainting (lucataco/sdxl-inpainting)
    render furniture/floor/light around it — so SDXL can never alter the pattern.
    ~$0.02/img (~$11 for 557). Idempotent ledger, error-tolerant (one failure
    skipped not fatal), running cost total. Proven on geometric (Ebano) + floral
    (Pond) patterns. Steve-approved: Replicate SDXL, 1 room each.
---
 shopify/scripts/build-room-composite.py      |  57 ++++++++++
 shopify/scripts/tres-tintas-room-settings.js | 156 +++++++++++++++++++++++++++
 2 files changed, 213 insertions(+)

diff --git a/shopify/scripts/build-room-composite.py b/shopify/scripts/build-room-composite.py
new file mode 100644
index 00000000..113c94e4
--- /dev/null
+++ b/shopify/scripts/build-room-composite.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""
+Build a one-point-perspective room with the EXACT pattern tiled on the back
+wall (framed by white crown molding + baseboard), plus an inpaint mask that
+LOCKS the pattern wall (black=keep) and opens floor/side-walls/ceiling
+(white=generate). SDXL inpainting then renders a photorealistic room around
+the preserved pattern.
+
+  build-room-composite.py PATTERN OUT_INIT OUT_MASK [REPEAT_INCHES]
+"""
+import sys
+from PIL import Image, ImageDraw
+
+PATTERN = sys.argv[1]
+OUT_INIT = sys.argv[2]
+OUT_MASK = sys.argv[3]
+REPEAT_IN = float(sys.argv[4]) if len(sys.argv) > 4 else 19.7
+W, H = 1024, 1024
+
+# back wall quad (frontal, straight-on one-point perspective)
+BW_L, BW_R = 256, 768
+BW_T, BW_B = 160, 720
+MOLD = 14  # white molding frame thickness around the pattern
+
+# pattern tiled on the wall. NOTE: Tres Tintas swatches are "repeated-patterns"
+# previews that already contain several motif repeats, so we tile only ~3 across
+# (the proven-good look from the approved pilot) — NOT the physical repeat count,
+# which would multiply an already-repeated image into mush.
+pat = Image.open(PATTERN).convert("RGB")
+inner_l, inner_t = BW_L + MOLD, BW_T + MOLD
+inner_w, inner_h = (BW_R - BW_L) - 2 * MOLD, (BW_B - BW_T) - 2 * MOLD
+repeats_across = 3.0
+tile_px = max(24, int(inner_w / repeats_across))
+pat_t = pat.resize((tile_px, tile_px))
+wall = Image.new("RGB", (inner_w, inner_h))
+for y in range(0, inner_h, tile_px):
+    for x in range(0, inner_w, tile_px):
+        wall.paste(pat_t, (x, y))
+
+# base room canvas — neutral gray for the regions SDXL will generate
+init = Image.new("RGB", (W, H), (180, 178, 175))
+d = ImageDraw.Draw(init)
+d.polygon([(0, H), (W, H), (BW_R, BW_B), (BW_L, BW_B)], fill=(150, 145, 140))   # floor
+d.polygon([(0, 0), (W, 0), (BW_R, BW_T), (BW_L, BW_T)], fill=(205, 203, 200))   # ceiling
+d.polygon([(0, 0), (BW_L, BW_T), (BW_L, BW_B), (0, H)], fill=(165, 162, 158))   # left wall
+d.polygon([(W, 0), (BW_R, BW_T), (BW_R, BW_B), (W, H)], fill=(165, 162, 158))   # right wall
+# white molding frame, then the exact pattern inside it
+d.rectangle([BW_L, BW_T, BW_R, BW_B], fill=(245, 244, 242))
+init.paste(wall, (inner_l, inner_t))
+init.save(OUT_INIT)
+
+# mask — white=inpaint(generate), black=keep. Keep ONLY the inner pattern (let
+# SDXL blend the molding edges naturally).
+mask = Image.new("L", (W, H), 255)
+ImageDraw.Draw(mask).rectangle([inner_l, inner_t, inner_l + inner_w, inner_t + inner_h], fill=0)
+mask.save(OUT_MASK)
+print(f"init+mask built. wall=({BW_L},{BW_T})-({BW_R},{BW_B}) tile_px={tile_px} repeats~{repeats_across:.1f} repeat_in={REPEAT_IN}")
diff --git a/shopify/scripts/tres-tintas-room-settings.js b/shopify/scripts/tres-tintas-room-settings.js
new file mode 100644
index 00000000..a13b6142
--- /dev/null
+++ b/shopify/scripts/tres-tintas-room-settings.js
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+/**
+ * tres-tintas-room-settings.js
+ *
+ * Generates ONE photorealistic room-setting image per Tres Tintas product that
+ * lacks one, using the EXACT catalog pattern (Replicate SDXL inpainting with the
+ * pattern wall mask-LOCKED so SDXL can never alter the pattern — it only renders
+ * the furniture/floor/light around it).
+ *
+ * Pipeline per product:
+ *   1. Pull the product's pattern image + repeat (custom.pattern_repeat) from Shopify.
+ *   2. Build a one-point-perspective room: exact pattern tiled on the back wall
+ *      (with molding frame + baseboard), neutral floor/ceiling/side walls.
+ *      Mask = black over the pattern wall (keep), white elsewhere (generate).
+ *      -> delegates to build-room-composite.py (PIL).
+ *   3. Replicate SDXL inpainting renders the room around the locked pattern.
+ *   4. Upload the result as a 2nd product image (alt "Room Setting").
+ *
+ * Idempotent (ledger), error-tolerant (one product's failure is skipped, not
+ * fatal), shows a running $ total. DRY RUN by default. APPLY=1 to write.
+ *
+ *   LIMIT=1 node tres-tintas-room-settings.js          # dry run, 1
+ *   LIMIT=1 APPLY=1 node tres-tintas-room-settings.js  # write 1 (pilot)
+ *   APPLY=1 node tres-tintas-room-settings.js          # full ~557 run
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-01';
+const APPLY = process.env.APPLY === '1';
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : Infinity;
+const ROOM = process.env.ROOM || 'living_room';
+const REPLICATE_VERSION = 'a5b13068cc81a89a4fbeefeccc774869fcb34df4dbc92c1555e0f2771d49dde7';
+const COST_PER_IMG = 0.02;            // ~$0.02 per SDXL inpaint run (observed)
+const PY = fs.existsSync('/opt/homebrew/bin/python3') ? '/opt/homebrew/bin/python3' : 'python3';
+const COMPOSITE_PY = path.join(__dirname, 'build-room-composite.py');
+
+function envval(key) {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.match(new RegExp(`^${key}=["']?([^"'\\n]+)`, 'm'));
+  return m ? m[1].trim() : null;
+}
+const TOKEN = envval('SHOPIFY_DRAFT_TOKEN');
+const RTOKEN = envval('REPLICATE_API_TOKEN');
+const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+
+const TMP = '/tmp/tt-rooms';
+fs.mkdirSync(TMP, { recursive: true });
+const BACKUP_DIR = path.join(__dirname, '..', 'tres-tintas-room-backups');
+const LEDGER = path.join(BACKUP_DIR, '_done.ledger');
+fs.mkdirSync(BACKUP_DIR, { recursive: true });
+const done = new Set(fs.existsSync(LEDGER) ? fs.readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean) : []);
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+async function fetchRetry(url, opts, tries = 3) {
+  let e; for (let i = 0; i < tries; i++) { try { return await fetch(url, opts); } catch (err) { e = err; await sleep(800 * (i + 1)); } } throw e;
+}
+async function gql(query, variables) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
+  const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data;
+}
+
+// inches from "23.6 in / 60 cm" -> 23.6 ; default 19.7
+function repeatInches(v) { const m = /([\d.]+)\s*in/i.exec(v || ''); return m ? parseFloat(m[1]) : 19.7; }
+
+async function replicateInpaint(initPath, maskPath, outPath) {
+  const dataUri = (p) => 'data:image/png;base64,' + fs.readFileSync(p).toString('base64');
+  const body = { version: REPLICATE_VERSION, input: {
+    image: dataUri(initPath), mask: dataUri(maskPath),
+    prompt: 'photorealistic modern living room interior, comfortable fabric sofa, wooden coffee table, area rug, floor lamps, large window with soft natural daylight, the back feature wall has geometric patterned wallcovering framed by white crown molding and baseboard, warm professional architectural interior photography, high detail, realistic shadows and lighting',
+    negative_prompt: 'blurry, low quality, distorted, cartoon, painting, sketch, oversaturated, warped walls, extra walls, text, watermark',
+    strength: 0.99, steps: 30, guidance_scale: 8, num_outputs: 1 } };
+  let j = await (await fetchRetry('https://api.replicate.com/v1/predictions', { method: 'POST', headers: { Authorization: `Bearer ${RTOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
+  if (j.error) throw new Error('replicate create: ' + JSON.stringify(j.error));
+  const url = j.urls.get;
+  for (let i = 0; i < 90; i++) {
+    await sleep(2000);
+    const pr = await (await fetchRetry(url, { headers: { Authorization: `Bearer ${RTOKEN}` } })).json();
+    if (pr.status === 'succeeded') { const out = Array.isArray(pr.output) ? pr.output[0] : pr.output; const img = await (await fetchRetry(out, {})).arrayBuffer(); fs.writeFileSync(outPath, Buffer.from(img)); return; }
+    if (pr.status === 'failed' || pr.status === 'canceled') throw new Error('replicate ' + pr.status + ': ' + JSON.stringify(pr.error));
+  }
+  throw new Error('replicate poll timeout');
+}
+
+async function uploadImage(productId, filePath, alt) {
+  const b64 = fs.readFileSync(filePath).toString('base64');
+  const r = await fetchRetry(`https://${STORE}/admin/api/${API}/products/${productId}/images.json`, {
+    method: 'POST', headers: H, body: JSON.stringify({ image: { attachment: b64, filename: `${productId}-room-${ROOM}.png`, alt } }) });
+  const j = await r.json();
+  if (!j.image) throw new Error('shopify upload: ' + JSON.stringify(j.errors || j));
+  return j.image.id;
+}
+
+async function main() {
+  if (!TOKEN || !RTOKEN) throw new Error('missing SHOPIFY_DRAFT_TOKEN or REPLICATE_API_TOKEN');
+  console.log(`MODE: ${APPLY ? 'APPLY (live)' : 'DRY RUN'}  room=${ROOM}${LIMIT !== Infinity ? `  LIMIT=${LIMIT}` : ''}  est $${COST_PER_IMG}/img`);
+  const q = `query($c:String){products(first:60,query:"vendor:'Tres Tintas'",after:$c){pageInfo{hasNextPage endCursor}
+    edges{node{id title images(first:10){edges{node{url}}}} } }}`;
+
+  let cursor = null, scanned = 0, need = 0, processed = 0, ok = 0, errs = 0, spend = 0;
+  while (true) {
+    const conn = (await gql(q, { cursor })).products;
+    for (const e of conn.edges) {
+      const n = e.node; scanned++;
+      const id = n.id.split('/').pop();
+      const imgs = n.images.edges.map(x => x.node.url);
+      const hasRoom = imgs.some(u => /interior|_int|room|ambiente|setting/i.test(u));
+      if (imgs.length > 1 || hasRoom) continue;   // already has a 2nd/room image
+      need++;
+      if (done.has(id)) continue;
+      if (processed >= LIMIT) continue;
+
+      const patternUrl = imgs[0];
+      if (!patternUrl) { console.log(`  !! ${id} no pattern image — SKIP`); continue; }
+
+      try {
+        // fetch repeat for tile scale
+        const mfRes = await fetchRetry(`https://${STORE}/admin/api/${API}/products/${id}/metafields.json`, { headers: H });
+        const mfs = (await mfRes.json()).metafields || [];
+        const rep = repeatInches((mfs.find(m => m.namespace === 'custom' && m.key === 'pattern_repeat') || {}).value);
+
+        // download pattern
+        const patPath = `${TMP}/${id}-pat.jpg`;
+        const pbuf = await (await fetchRetry(patternUrl, {})).arrayBuffer();
+        fs.writeFileSync(patPath, Buffer.from(pbuf));
+
+        const initPath = `${TMP}/${id}-init.png`, maskPath = `${TMP}/${id}-mask.png`, outPath = `${TMP}/${id}-room.png`;
+        execFileSync(PY, [COMPOSITE_PY, patPath, initPath, maskPath, String(rep)], { stdio: 'pipe' });
+
+        processed++;
+        if (!APPLY) { console.log(`  [dry] ${id} ${n.title}  repeat=${rep}in  -> would inpaint + upload`); continue; }
+
+        await replicateInpaint(initPath, maskPath, outPath);
+        spend += COST_PER_IMG;
+        const imgId = await uploadImage(id, outPath, `Room Setting - ${n.title.replace(/\s*\|.*$/, '')}`);
+        ok++;
+        fs.appendFileSync(LEDGER, id + '\n'); done.add(id);
+        console.log(`  ✓ ${id} ${n.title}  img=${imgId}  [${ok} done · ~$${spend.toFixed(2)}]`);
+        await sleep(300);
+      } catch (err) {
+        errs++;
+        console.log(`  ✗ ${id} ${n.title}: ${err.message} — SKIP (retry next run)`);
+        await sleep(500);
+      }
+    }
+    if (conn.pageInfo.hasNextPage && processed < LIMIT) cursor = conn.pageInfo.endCursor; else break;
+  }
+
+  console.log(`\n--- SUMMARY ---`);
+  console.log(`scanned: ${scanned} | need-room: ${need} | processed: ${processed} | uploaded: ${ok} | errors: ${errs}`);
+  console.log(`Replicate spend: ~$${spend.toFixed(2)} (${ok} imgs × $${COST_PER_IMG})`);
+  if (APPLY) console.log(`ledger -> ${LEDGER}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });

← 31f09baa PDP fix cluster: breadcrumbs grey-bar, Download PDF (inline  ·  back to Designer Wallcoverings  ·  Cadence-out full Tres Tintas backlog: unflag 569 stranded ne 14c964fd →