← back to Wallco Ai
backfill-rooms: idempotent free-local PIL room backfill for published-roomless designs + nightly cron wrapper (closes the gap where the auto-publish path ships designs with no room_mockups)
9952d41d959c21777a08a68ba82ff4611ff05e93 · 2026-06-03 14:00:20 -0700 · Steve Abrams
Files touched
A scripts/backfill-rooms.pyA scripts/cron-backfill-rooms.sh
Diff
commit 9952d41d959c21777a08a68ba82ff4611ff05e93
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 3 14:00:20 2026 -0700
backfill-rooms: idempotent free-local PIL room backfill for published-roomless designs + nightly cron wrapper (closes the gap where the auto-publish path ships designs with no room_mockups)
---
scripts/backfill-rooms.py | 80 ++++++++++++++++++++++++++++++++++++++++++
scripts/cron-backfill-rooms.sh | 16 +++++++++
2 files changed, 96 insertions(+)
diff --git a/scripts/backfill-rooms.py b/scripts/backfill-rooms.py
new file mode 100644
index 0000000..2abc23b
--- /dev/null
+++ b/scripts/backfill-rooms.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+# Free-local, idempotent room-mockup backfill. Finds PUBLISHED designs whose
+# room_mockups is empty/null, renders a PIL living-room composite (NO cloud spend —
+# Steve's hard rule: rooms are free+local, never the $0.014/call room API), sets
+# room_mockups=['living_room'] in data/designs.json, and hot-reloads the server.
+#
+# Safety: idempotent (skips designs that already have a room), per-run cap, and it
+# PATCHES designs.json in place (sets one field on known ids) — it never rebuilds the
+# snapshot from a DB, so it cannot trigger the catastrophic-shrink catalog-clobber.
+#
+# Usage: python3 scripts/backfill-rooms.py [--limit N] [--dry-run] [--no-reload]
+import sys, os, json, argparse, importlib.util
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DESIGNS_JSON = ROOT / 'data' / 'designs.json'
+PORT = os.environ.get('PORT', '9905')
+
+# Reuse the canonical PIL compositor (hyphenated filename → importlib).
+spec = importlib.util.spec_from_file_location("prc", ROOT / "scripts" / "pil-room-composite.py")
+prc = importlib.util.module_from_spec(spec); spec.loader.exec_module(prc)
+
+def is_published(r):
+ v = r.get('is_published')
+ return v is True or v == 1 or v == 't' or v == 'true'
+
+def has_room(r):
+ rm = r.get('room_mockups')
+ if not rm: return False
+ if isinstance(rm, list): return len(rm) > 0
+ if isinstance(rm, dict): return len(rm) > 0
+ return bool(rm)
+
+def tile_on_disk(r):
+ fn = r.get('filename') or (r.get('image_url') or '').split('/')[-1]
+ if not fn: return False
+ return (ROOT / 'data' / 'generated' / fn).exists()
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--limit', type=int, default=200)
+ ap.add_argument('--dry-run', action='store_true')
+ ap.add_argument('--no-reload', action='store_true')
+ args = ap.parse_args()
+
+ d = json.load(open(DESIGNS_JSON))
+ rows = d if isinstance(d, list) else d.get('designs', [])
+ todo = [r for r in rows if is_published(r) and not has_room(r) and not r.get('user_removed') and tile_on_disk(r)]
+ skipped_no_tile = [r for r in rows if is_published(r) and not has_room(r) and not r.get('user_removed') and not tile_on_disk(r)]
+ print(f'candidates: {len(todo)} renderable, {len(skipped_no_tile)} published-roomless w/ no local tile (skipped)')
+ if args.dry_run:
+ for r in todo[:args.limit]:
+ print(f' would render #{r.get("id")} {r.get("category")}')
+ return
+
+ done = 0
+ for r in todo[:args.limit]:
+ rid = r.get('id')
+ try:
+ prc.composite_one(rid) # writes data/rooms/design_<id>_living_room.png
+ r['room_mockups'] = ['living_room'] # patch the in-memory row
+ done += 1
+ print(f' #{rid} rendered')
+ except Exception as e:
+ print(f' #{rid} FAIL {e}')
+ if done:
+ json.dump(d, open(DESIGNS_JSON, 'w'))
+ print(f'patched designs.json: {done} rows now have room_mockups')
+ if not args.no_reload:
+ import urllib.request
+ try:
+ req = urllib.request.Request(f'http://127.0.0.1:{PORT}/admin/reload-designs', method='POST')
+ urllib.request.urlopen(req, timeout=20).read()
+ print('hot-reloaded /admin/reload-designs')
+ except Exception as e:
+ print(f'reload failed (designs.json still patched): {e}')
+ print(f'\nbackfilled {done} rooms (free-local PIL, $0)')
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/cron-backfill-rooms.sh b/scripts/cron-backfill-rooms.sh
new file mode 100755
index 0000000..0ea76fd
--- /dev/null
+++ b/scripts/cron-backfill-rooms.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Nightly free-local room backfill for wallco.ai. flock-guarded (no overlap),
+# capped per run (idempotent — chips away at the roomless backlog), $0 (PIL only).
+# Mirrors scripts/cron-build-tifs.sh. Install on PROD (where tiles live in
+# data/generated and the server serves data/rooms):
+# crontab line → 20 3 * * * /root/public-projects/wallco-ai/scripts/cron-backfill-rooms.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+LOCK=/tmp/wallco-backfill-rooms.lock
+LOG=/tmp/wallco-backfill-rooms.log
+exec 9>"$LOCK"
+if ! flock -n 9; then echo "$(date '+%F %T') already running — skip" >>"$LOG"; exit 0; fi
+echo "=== $(date '+%F %T') backfill-rooms start ===" >>"$LOG"
+# Cap each run so a fresh batch can't peg the box; idempotent so it catches up over nights.
+python3 scripts/backfill-rooms.py --limit "${ROOM_BACKFILL_LIMIT:-150}" >>"$LOG" 2>&1
+echo "=== $(date '+%F %T') backfill-rooms done ===" >>"$LOG"
← a0b6407 render-live-rooms.py: PIL rooms for prod-only designs (pull
·
back to Wallco Ai
·
dogs-curator: fix greedy edges parse (split_part slurped app 8282219 →