[object Object]

← back to Shopify Room Mockup

Repoint room render off the dead Kamatera :3075 service to room-engines' local $0 compositor (TK-12096)

37d416c98fb9ede07648d0d0bb38fb99685ac5f8 · 2026-09-23 14:43:11 -0700 · Steve Abrams

:3075 is confirmed dead — the port is now bound by tailscaled and the real
room-setting-app pm2 process moved to :3076 and is crash-looping (50
restarts/2h). render_room() now shells out locally to room-engines'
compose_real.py (the $0 patch-quilt "real-swatch" engine, one of the
engines room-engines' own judge/apply_winners pipeline already picks
per-SKU), bootstrapping its expected /tmp scratch from the viewer's
permanently-preserved bakeoff-assets/. CLI surface kept identical
(--box now a deprecated no-op); this tool's distinct value — generic
--ids/--query/--collection Shopify-side selection pulling a product's own
live featured image — is preserved and not duplicated by room-engines,
which is wired to a single book's local catalog table + manifest. Verified
locally against 3 different swatches (bamboo/grasscloth/silk), all render
valid 1024x1024 PNGs in <1s, $0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY

Files touched

Diff

commit 37d416c98fb9ede07648d0d0bb38fb99685ac5f8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 14:43:11 2026 -0700

    Repoint room render off the dead Kamatera :3075 service to room-engines' local $0 compositor (TK-12096)
    
    :3075 is confirmed dead — the port is now bound by tailscaled and the real
    room-setting-app pm2 process moved to :3076 and is crash-looping (50
    restarts/2h). render_room() now shells out locally to room-engines'
    compose_real.py (the $0 patch-quilt "real-swatch" engine, one of the
    engines room-engines' own judge/apply_winners pipeline already picks
    per-SKU), bootstrapping its expected /tmp scratch from the viewer's
    permanently-preserved bakeoff-assets/. CLI surface kept identical
    (--box now a deprecated no-op); this tool's distinct value — generic
    --ids/--query/--collection Shopify-side selection pulling a product's own
    live featured image — is preserved and not duplicated by room-engines,
    which is wired to a single book's local catalog table + manifest. Verified
    locally against 3 different swatches (bamboo/grasscloth/silk), all render
    valid 1024x1024 PNGs in <1s, $0.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
 .gitignore             |   2 +
 shopify-room-mockup.py | 122 ++++++++++++++++++++++++++++++++++++-------------
 2 files changed, 92 insertions(+), 32 deletions(-)

diff --git a/.gitignore b/.gitignore
index 8b02591..5b24a0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ tmp/
 *.log
 .DS_Store
 *.png
+__pycache__/
+out/
diff --git a/shopify-room-mockup.py b/shopify-room-mockup.py
index bc95ed6..67133f2 100644
--- a/shopify-room-mockup.py
+++ b/shopify-room-mockup.py
@@ -3,9 +3,26 @@
 shopify-room-mockup — add photoreal room-setting mockups to Shopify products.
 
 For each selected product it: pulls the featured image (the pattern/swatch),
-renders a photoreal room via the wallco room-setting service (:3075 on the
-Kamatera box, reached over SSH), and uploads the result as a product image
-(Shopify staged-upload + productCreateMedia).
+renders a photoreal room LOCALLY via the room-engines $0 compose_real.py
+patch-quilt compositor (no network dependency, no API key), and uploads the
+result as a product image (Shopify staged-upload + productCreateMedia).
+
+REPOINTED 2026-09-23 (TK-12096, Lane G of TK-12090) — the original :3075
+wallco room-setting service on Kamatera is DEAD: the port is now bound by
+tailscaled, and the actual `room-setting-app` pm2 process moved to :3076 and
+is crash-looping (50 restarts / 2h uptime). This script now shells out to
+room-engines' compose_real.py (the "real-swatch quilt" engine — one of the
+engines room-engines' own judge/apply_winners pipeline already selects
+per-SKU) instead of SSHing to the dead service. room-engines' heavier
+FLUX-Kontext / SDXL engines need their model weights re-cached before they
+can run again; compose_real.py needs none and is deterministic + fast.
+room-engines lives at:
+  ~/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/carl-robinson-viewer/room-engines/
+This script's distinct value vs. room-engines' own pipeline is unchanged:
+generic Shopify-side product SELECTION (--ids/--query/--collection across the
+WHOLE store) pulling each product's OWN live featured image — room-engines'
+scripts are wired to a single book's local catalog table + a pre-built
+manifest.tsv, not to arbitrary Shopify products.
 
 Built 2026-05-29 from the Crowley's Crocodile batch (designer-laboratory-sandbox).
 
@@ -16,10 +33,17 @@ SELECT (choose one):
 
 OPTIONS:
   --store designer-laboratory-sandbox    store handle (default) or full *.myshopify.com
-  --room-type living_room                living_room|bedroom|dining_room|office (default living_room)
+  --room-type living_room                kept for CLI compat / alt-text only — the
+                                         one preserved base-room asset is a living
+                                         room, so all renders use it regardless
   --pattern-width 39                     pattern repeat width in inches (scale; default 39)
   --pattern-height 39
-  --box root@45.61.58.125                SSH host running the :3075 room service
+  --tile-px N                            compose_real.py patch size on the wall in px
+                                         (default derived from --pattern-width)
+  --room-engines-dir PATH                room-engines/ directory (default: the DW
+                                         carl-robinson-viewer/room-engines checkout)
+  --box root@45.61.58.125                DEPRECATED / no-op (kept for CLI compat —
+                                         the :3075 SSH room service is retired)
   --skip-existing                        skip products that already have a room mockup
                                          (detected by media alt containing "in a room")
   --alt-suffix "shown in a living room"  alt-text suffix for the new image
@@ -32,10 +56,17 @@ Examples:
   python3 shopify-room-mockup.py --ids 7797773500467 --room-type bedroom
   python3 shopify-room-mockup.py --collection 480239649 --dry-run
 """
-import argparse, base64, json, os, re, subprocess, sys, tempfile, urllib.request
+import argparse, base64, json, os, re, shutil, subprocess, sys, tempfile, urllib.request
 
 API_VERSION = '2024-10'
 
+DEFAULT_ROOM_ENGINES_DIR = os.path.expanduser(
+    '~/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/carl-robinson-viewer/room-engines')
+# Bootstrap dir compose_real.py's hardcoded BASE/MASK paths expect (its own
+# /tmp scratch convention from the original Carl Robinson bake-off). The real,
+# permanently-preserved assets live one level up in the viewer's bakeoff-assets/.
+BAKEOFF_SCRATCH = '/tmp/cr-room-bakeoff'
+
 
 def load_token(env_name):
     if os.environ.get(env_name):
@@ -101,34 +132,53 @@ def select_products(sh, args):
     return out
 
 
-REMOTE_RENDER = """import base64,json,urllib.request,sys
-url=%(url)r
-jpg='/tmp/_srm_pattern.img'
-urllib.request.urlretrieve(url,jpg)
-b64=base64.b64encode(open(jpg,'rb').read()).decode()
-body=json.dumps({'patternBase64':b64,'roomType':%(room)r,'angle':'straight_on',
-  'cameraDistance':5,'patternWidth':%(pw)d,'patternHeight':%(ph)d}).encode()
-req=urllib.request.Request('http://127.0.0.1:3075/api/generate-room',data=body,
-  headers={'Content-Type':'application/json'})
-r=json.load(urllib.request.urlopen(req,timeout=180))
-img=r.get('image') or r.get('imageBase64') or ''
-if img.startswith('data:'): img=img.split(',',1)[1]
-sys.stdout.write(img or '')
-"""
+def _bootstrap_bakeoff_assets(room_engines_dir):
+    """compose_real.py hardcodes BASE=/tmp/cr-room-bakeoff/00-empty-room.png and
+    MASK=/tmp/cr-room-bakeoff/wall-mask.png (its own scratch convention from the
+    original bake-off run). Those two source assets are permanently preserved one
+    level up at <viewer>/bakeoff-assets/{empty-room.png,wall-mask.png} — copy them
+    into the expected scratch path once (idempotent, $0, purely local)."""
+    os.makedirs(BAKEOFF_SCRATCH, exist_ok=True)
+    dst_room = os.path.join(BAKEOFF_SCRATCH, '00-empty-room.png')
+    dst_mask = os.path.join(BAKEOFF_SCRATCH, 'wall-mask.png')
+    viewer_dir = os.path.dirname(os.path.normpath(room_engines_dir))
+    src_room = os.path.join(viewer_dir, 'bakeoff-assets', 'empty-room.png')
+    src_mask = os.path.join(viewer_dir, 'bakeoff-assets', 'wall-mask.png')
+    if not os.path.exists(dst_room) and os.path.exists(src_room):
+        shutil.copy2(src_room, dst_room)
+    if not os.path.exists(dst_mask) and os.path.exists(src_mask):
+        shutil.copy2(src_mask, dst_mask)
+    return os.path.exists(dst_room) and os.path.exists(dst_mask)
 
 
-def render_room(box, image_url, room_type, pw, ph):
-    """Render on the box via SSH; returns raw PNG bytes (or None)."""
-    script = REMOTE_RENDER % {'url': image_url, 'room': room_type, 'pw': pw, 'ph': ph}
-    p = subprocess.run(['ssh', box, 'python3', '-'], input=script, capture_output=True, text=True, timeout=240)
-    b64 = (p.stdout or '').strip()
-    if not b64:
+def render_room(image_url, room_engines_dir, tile_px):
+    """Render LOCALLY via room-engines' compose_real.py $0 patch-quilt compositor
+    (no network service, no API key). Downloads the product's own pattern/swatch
+    image, then shells out to compose_real.py <swatch> <tile_px> <out>.
+    Returns raw PNG bytes (or None)."""
+    compose_script = os.path.join(room_engines_dir, 'compose_real.py')
+    if not os.path.isfile(compose_script):
+        print(f'  ✗  room-engines compose_real.py not found at {compose_script}', file=sys.stderr)
         return None
-    try:
-        buf = base64.b64decode(b64)
-        return buf if buf[:8] == bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) else None
-    except Exception:
+    if not _bootstrap_bakeoff_assets(room_engines_dir):
+        print('  ✗  base-room/wall-mask assets missing (bakeoff-assets/) — cannot compose', file=sys.stderr)
         return None
+    with tempfile.TemporaryDirectory() as td:
+        swatch = os.path.join(td, 'swatch.img')
+        try:
+            urllib.request.urlretrieve(image_url, swatch)
+        except Exception as e:
+            print(f'  ✗  swatch download failed: {e}', file=sys.stderr)
+            return None
+        out = os.path.join(td, 'room.png')
+        p = subprocess.run([sys.executable, compose_script, swatch, str(tile_px), out],
+                            capture_output=True, text=True, timeout=120)
+        if p.returncode != 0 or not os.path.exists(out):
+            print(f'  ✗  compose_real.py failed: {(p.stderr or p.stdout or "").strip()[:200]}', file=sys.stderr)
+            return None
+        with open(out, 'rb') as f:
+            buf = f.read()
+        return buf if buf[:8] == bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) else None
 
 
 def upload_media(sh, product_gid, png_bytes, alt):
@@ -165,7 +215,10 @@ def main():
     ap.add_argument('--room-type', default='living_room')
     ap.add_argument('--pattern-width', type=int, default=39)
     ap.add_argument('--pattern-height', type=int, default=39)
-    ap.add_argument('--box', default='root@45.61.58.125')
+    ap.add_argument('--tile-px', type=int, default=0,
+                     help='compose_real.py wall-patch size in px (0 = derive from --pattern-width)')
+    ap.add_argument('--room-engines-dir', default=DEFAULT_ROOM_ENGINES_DIR)
+    ap.add_argument('--box', default=None, help=argparse.SUPPRESS)  # deprecated no-op, kept for CLI compat
     ap.add_argument('--skip-existing', action='store_true')
     ap.add_argument('--alt-suffix', default='shown in a room')
     ap.add_argument('--limit', type=int, default=0)
@@ -173,6 +226,11 @@ def main():
     ap.add_argument('--token-env', default='SHOPIFY_ADMIN_TOKEN')
     args = ap.parse_args()
 
+    if args.box:
+        print('  (note) --box is deprecated/no-op — the :3075 SSH room service is retired; '
+              'rendering now happens locally via room-engines/compose_real.py', file=sys.stderr)
+    tile_px = args.tile_px or max(20, min(120, args.pattern_width))
+
     sh = Shopify(store_host(args.store), load_token(args.token_env))
     prods = select_products(sh, args)
     if args.limit:
@@ -186,7 +244,7 @@ def main():
             print(f'  ⏭  SKIP (has room): {tag}'); skipped += 1; continue
         if not p['image_url']:
             print(f'  ⚠  no featured image: {tag}'); failed += 1; continue
-        buf = render_room(args.box, p['image_url'], args.room_type, args.pattern_width, args.pattern_height)
+        buf = render_room(p['image_url'], args.room_engines_dir, tile_px)
         if not buf:
             print(f'  ✗  render failed: {tag}'); failed += 1; continue
         if args.dry_run:

← f470b56 shopify-room-mockup: reusable tool to add photoreal room moc  ·  back to Shopify Room Mockup  ·  TK-12090 Fix 1: ledger the media id upload_media() already r 3a3e31e →