← 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()