← back to Wallco Ai
scripts/backfill-rooms.py
91 lines
#!/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
relinked = 0
for r in todo[:args.limit]:
rid = r.get('id')
try:
room_png = ROOT / 'data' / 'rooms' / f'design_{rid}_living_room.png'
if room_png.exists():
# PNG already rendered (e.g. a prior run, or a deploy reverted only
# the designs.json field) — just re-link it. $0, instant, no re-render.
r['room_mockups'] = ['living_room']
done += 1; relinked += 1
else:
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 relinked:
print(f' ({relinked} re-linked from existing PNG, no re-render)')
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()