← back to Wallco Ai

scripts/demote-lowres-murals.py

102 lines

#!/usr/bin/env python3
"""
demote-lowres-murals.py — REPORT-ONLY scan of mural-kind rows that aren't a
real hi-res print master, so they can be removed from PHYSICAL print and kept
as DIGITAL-only sales.

Steve's brief (2026-06-03): "any murals created that were not high res need to
be removed and made available for digital sales." A mural is physical-print-
viable only if its image hits 150 DPI across the full 12ft wall (21,600 px
wide). Everything below that (the whole current catalog tops out at ~4096 px)
is digital-only until rebuilt via build-mural-master.py.

DEFAULT = report only. It writes a plan JSONL + a summary and touches NOTHING in
the DB. Applying the demotion (unpublish-from-physical + keep-digital) is a
customer-facing change on hundreds of live rows → it stays Steve-gated and the
exact "digital-only" surfacing mechanism is confirmed with Steve before --apply
is implemented. Per the destructive-defaults rule: soft-flag + JSONL ledger,
never DELETE.

Usage:
  python3 scripts/demote-lowres-murals.py                 # report (all mural kinds)
  python3 scripts/demote-lowres-murals.py --published     # only currently-published rows
  python3 scripts/demote-lowres-murals.py --min-px 21600  # hi-res threshold (default 21600 = 12ft@150dpi)
"""
import argparse
import importlib.util
import json
from pathlib import Path

from psycopg2.extras import RealDictCursor

ROOT = Path(__file__).resolve().parents[1]
LEDGER_DIR = ROOT / "data"
MURAL_KINDS = ("mural", "mural_panel")

# Reuse build-tif.py's db() (hyphenated filename → importlib) so this script
# carries no DB credentials of its own.
_bt_spec = importlib.util.spec_from_file_location(
    "buildtif", str(ROOT / "scripts" / "build-tif.py"))
_bt = importlib.util.module_from_spec(_bt_spec)
_bt_spec.loader.exec_module(_bt)
db = _bt.db


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--min-px", type=int, default=21600,
                   help="hi-res threshold on tif_w_px (default 21600 = 12ft @ 150 DPI)")
    p.add_argument("--published", action="store_true",
                   help="restrict to currently-published rows")
    a = p.parse_args()

    conn = db()
    with conn.cursor(cursor_factory=RealDictCursor) as cur:
        cur.execute(f"""
            SELECT id, category, kind, is_published,
                   COALESCE(tif_w_px, 0) AS tif_w_px,
                   COALESCE(tif_h_px, 0) AS tif_h_px,
                   digital_file_at
              FROM all_designs
             WHERE kind IN %s
               AND COALESCE(tif_w_px, 0) < %s
               {"AND is_published" if a.published else ""}
             ORDER BY is_published DESC, category, id
        """, (MURAL_KINDS, a.min_px))
        rows = cur.fetchall()
    conn.close()

    pub = [r for r in rows if r["is_published"]]
    by_cat = {}
    for r in rows:
        c = str(r["category"] or "?").split(" · ")[0]
        by_cat[c] = by_cat.get(c, 0) + 1

    print(f"=== demote-lowres-murals (REPORT ONLY) ===")
    print(f"hi-res threshold: tif_w_px >= {a.min_px} (12ft @ 150 DPI)\n")
    print(f"mural rows below threshold : {len(rows):,}")
    print(f"  of which PUBLISHED (live): {len(pub):,}  ← these leave the physical grid")
    print(f"  unpublished              : {len(rows)-len(pub):,}\n")
    print("top categories affected:")
    for c, n in sorted(by_cat.items(), key=lambda kv: -kv[1])[:15]:
        print(f"  {n:5,}  {c}")

    # Plan JSONL — exactly what an --apply would touch. No DB writes here.
    plan_path = LEDGER_DIR / "mural-demotion-plan.jsonl"
    with plan_path.open("w") as f:
        for r in rows:
            f.write(json.dumps({
                "id": r["id"], "category": r["category"], "kind": r["kind"],
                "is_published": r["is_published"], "tif_w_px": r["tif_w_px"],
                "action": "unpublish_physical_keep_digital",
                "reason": f"tif_w_px {r['tif_w_px']} < {a.min_px} (not 12ft-master hi-res)",
            }) + "\n")
    print(f"\nplan written → {plan_path} ({len(rows):,} rows)")
    print("NO DB changes made. Applying is Steve-gated (customer-facing, hundreds of")
    print("live rows) and needs the digital-only surfacing mechanism confirmed first.")
    return 0


if __name__ == "__main__":
    main()