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