E1: build_stock_pack.py generalized over kinds — record (256x256 sleeves), book (96x256 tall-thin spines), toy (256x256 boxes), per-kind parody word banks, same build-time/parody/atlas/index laws. Book+toy sample packs built + staged (?localdepot) + indexes committed to pipeline/packs/ so Lane C wires book/toy shelves now. Parody deterministic + no distinctive-token leak across all 3 kinds. E2: validate_pack.py QA gate (wired into validate_manifest -> qa gate 3) — bad UV / empty title / bad price_band / dangling atlas / dup id fails qa (proven). qa strict GREEN 5/5. Real 150-250-item packs blocked on GODVERSE_DSN (flagged to Fable); samples are placeholders in the real index format. No fal spend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
11 KiB
Python
206 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY GODVERSE stock packs — real stock for C's shelves + dig (?stock=real).
|
|
|
|
Build-time only (nothing queries the datasets at runtime): read a local GODVERSE dataset
|
|
(dealgod Postgres), curate items, **parody-transform metadata deterministically** (no-real-
|
|
trademarks law covers titles/artists/authors/brands), fetch cover images, bake atlas WebPs + a
|
|
JSON index to C's schema (`stock_<kind>_index.json`), stage for ?localdepot / publish to the depot.
|
|
|
|
Generalized over KINDS (round-8 E1): `record` (square sleeves), `book` (TALL-thin spines),
|
|
`toy` (square boxes). C reads `uv`, not `cell`, so each kind just bakes its native cell aspect;
|
|
the index carries cell_w/cell_h informationally.
|
|
|
|
python3 build_stock_pack.py --kind record --from-db --count 300 # real (needs GODVERSE_DSN)
|
|
python3 build_stock_pack.py --kind book --sample 32 # synthetic pipeline test
|
|
python3 build_stock_pack.py --kind toy --sample 32
|
|
python3 build_stock_pack.py --publish # stage all built packs for ?localdepot
|
|
|
|
Index: { version, kind, atlas_px, cell_w, cell_h, atlases[], count,
|
|
items:[ {id,title,artist,price,price_band,atlas,uv:[u0,v0,u1,v1]} ] }
|
|
(For book/toy, `artist` carries author/brand — C's field is source-agnostic per LANE_C_NOTES.)
|
|
"""
|
|
import json, os, sys, hashlib, urllib.request
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
OUT = os.path.join(ROOT, "pipeline", "_stockpack")
|
|
STAGE = os.path.join(ROOT, "web", "assets", "models")
|
|
os.makedirs(OUT, exist_ok=True)
|
|
INDEX_FIELDS = ("id", "title", "artist", "price", "price_band", "atlas", "uv")
|
|
|
|
# ── parody word banks per kind (period-AU-plausible; no real acts/authors/brands) ──────────
|
|
BANKS = {
|
|
"record": dict(
|
|
adj=["Midnight", "Electric", "Velvet", "Golden", "Crimson", "Neon", "Sunburnt", "Hazy",
|
|
"Concrete", "Paper", "Silver", "Restless", "Quiet", "Broken", "Endless", "Salt"],
|
|
noun=["Sessions", "Highway", "Ballroom", "Suburbs", "Tide", "Radio", "Static", "Harvest",
|
|
"Verandah", "Jetty", "Milk Bar", "Cassette", "Reunion", "Departure", "Anthems", "Blue"],
|
|
band_a=["The", "Big", "Lonesome", "Riptide", "Sunset", "Regional", "Mullet", "Neon"],
|
|
band_b=["Galahs", "Dropbears", "Hills Hoists", "Milk Crates", "Southerlies", "Panelvans",
|
|
"Rissoles", "Servos", "Bottlebrush", "Sandgropers", "Cockatoos", "Utes"]),
|
|
"book": dict(
|
|
adj=["The Lost", "A Quiet", "The Last", "Beneath the", "The Long", "Shadows of the",
|
|
"The Secret", "Return to", "The Hollow", "Whispers of the", "The Broken", "Under the"],
|
|
noun=["Verandah", "Paddock", "Lighthouse", "Estuary", "Homestead", "Reef", "Gully",
|
|
"Terminus", "Foreshore", "Escarpment", "Siding", "Inlet", "Ridge", "Jetty"],
|
|
band_a=["J.", "M.", "R.", "E.", "P.", "K.", "D.", "A.", "T.", "C."],
|
|
band_b=["Halloran", "Fenwick", "Marchetti", "Delacroix", "Ngata", "Ashcombe", "Voss",
|
|
"Pemberton", "Corrigan", "Blaxland", "Underwood", "Rourke"]),
|
|
"toy": dict(
|
|
adj=["Turbo", "Mega", "Cosmic", "Wonder", "Rescue", "Galaxy", "Jungle", "Speedy",
|
|
"Sparkle", "Thunder", "Micro", "Super", "Dino", "Robo", "Magic", "Aqua"],
|
|
noun=["Racers", "Rangers", "Buddies", "Cruisers", "Squad", "Friends", "Blasters", "Ponies",
|
|
"Bots", "Beasts", "Patrol", "Tots", "Voyagers", "Crew", "Pals", "Force"],
|
|
band_a=["Bushland", "Coral Coast", "Redgum", "Southern Cross", "Boomerang", "Kanga",
|
|
"Wattle", "Billabong", "Outback", "Reef City"],
|
|
band_b=["Toys", "Playsystems", "Games", "Fun Co.", "Novelties", "Workshop", "Mfg.", "Play Lab"]),
|
|
}
|
|
CELL = {"record": (256, 256), "book": (96, 256), "toy": (256, 256)} # book = tall-thin spine
|
|
GRID = {"record": 8, "book": 16, "toy": 8} # columns per atlas row
|
|
|
|
|
|
def _seed(*p):
|
|
return int(hashlib.sha256("|".join(str(x) for x in p).encode()).hexdigest()[:12], 16)
|
|
|
|
|
|
def parody(kind, title, artist):
|
|
"""Deterministic parody; output shares no source token → zero real-trademark leakage."""
|
|
b = BANKS[kind]; s = _seed(kind, title or "", artist or "")
|
|
ptitle = f"{b['adj'][s % len(b['adj'])]} {b['noun'][(s // 7) % len(b['noun'])]}"
|
|
if kind == "record" and (s // 13) % 5 == 0:
|
|
ptitle += f", Vol. {1 + (s // 3) % 3}"
|
|
if kind == "book": # "The Lost Verandah" reads as a title already
|
|
partist = f"{b['band_a'][(s // 17) % len(b['band_a'])]} {b['band_b'][(s // 19) % len(b['band_b'])]}"
|
|
elif (s % 2 == 0) or kind == "toy":
|
|
partist = f"{b['band_a'][(s // 5) % len(b['band_a'])]} {b['band_b'][(s // 11) % len(b['band_b'])]}".strip()
|
|
else:
|
|
partist = f"{b['band_a'][(s // 17) % len(b['band_a'])]} {b['band_b'][(s // 19) % len(b['band_b'])]}"
|
|
return ptitle, partist
|
|
|
|
|
|
def price_band(kind, price):
|
|
p = price if price is not None else 4
|
|
if kind == "book":
|
|
return "bargain" if p < 5 else "standard" if p < 15 else "collector" if p < 40 else "grail"
|
|
if kind == "toy":
|
|
return "bargain" if p < 6 else "standard" if p < 20 else "collector" if p < 50 else "grail"
|
|
return "bargain" if p < 8 else "standard" if p < 25 else "collector" if p < 60 else "grail"
|
|
|
|
|
|
# ── data sources ────────────────────────────────────────────────────────────────────────
|
|
DB_QUERY = { # (table, price-source) per kind; images column holds cover URLs (discogs_parse pattern)
|
|
"record": "discogs_release",
|
|
"book": "book_edition", # bookgod / OpenLibrary editions (title, author, cover)
|
|
"toy": "toy_listing", # toygod
|
|
}
|
|
|
|
|
|
def rows_from_db(kind, count):
|
|
import psycopg2
|
|
dsn = os.environ.get("GODVERSE_DSN") or os.environ.get("DB_DSN")
|
|
if not dsn:
|
|
raise SystemExit("GODVERSE_DSN not set — the dealgod Postgres dsn is required for --from-db "
|
|
"(nothing queries it at runtime). Ask John. Postgres is up on :5432.")
|
|
con = psycopg2.connect(dsn); cur = con.cursor()
|
|
tbl = DB_QUERY[kind]
|
|
cur.execute(f"""SELECT id, artist, title, price, images FROM {tbl}
|
|
WHERE artist<>'' AND title<>'' AND images IS NOT NULL AND images<>'[]'
|
|
ORDER BY md5(id::text) LIMIT %s""", (count,))
|
|
out = []
|
|
for _id, artist, title, price, images in cur.fetchall():
|
|
imgs = images if isinstance(images, list) else json.loads(images or "[]")
|
|
url = imgs[0].get("uri") if imgs and isinstance(imgs[0], dict) else (imgs[0] if imgs else None)
|
|
if url:
|
|
out.append({"artist": artist, "title": title, "price": price, "img": url})
|
|
con.close()
|
|
return out
|
|
|
|
|
|
def rows_sample(kind, n):
|
|
"""Synthetic rows (solid-colour cells at the kind's aspect) — proves the pipeline with no DB."""
|
|
from PIL import Image, ImageDraw
|
|
cw, ch = CELL[kind]; rows = []
|
|
os.makedirs(os.path.join(OUT, "_imgs"), exist_ok=True)
|
|
for i in range(n):
|
|
s = _seed(kind, "sample", i)
|
|
col = ((s >> 0) & 255, (s >> 8) & 255, (s >> 16) & 255)
|
|
im = Image.new("RGB", (cw, ch), col); d = ImageDraw.Draw(im)
|
|
d.rectangle([2, 2, cw - 3, ch - 3], outline=(255, 255, 255), width=2)
|
|
if kind == "record":
|
|
d.ellipse([cw * .34, ch * .34, cw * .66, ch * .66], fill=(20, 20, 20))
|
|
elif kind == "book":
|
|
d.line([(cw // 2, 8), (cw // 2, ch - 8)], fill=(255, 255, 255), width=1) # spine crease
|
|
p = os.path.join(OUT, "_imgs", f"{kind}{i}.png"); im.save(p)
|
|
rows.append({"artist": f"Src {kind} {i}", "title": f"Src Title {i}",
|
|
"price": 3 + (s % 55), "img": p})
|
|
return rows
|
|
|
|
|
|
# ── atlas builder ───────────────────────────────────────────────────────────────────────
|
|
def build_pack(kind, rows):
|
|
from PIL import Image
|
|
cw, ch = CELL[kind]; cols = GRID[kind]; rowspp = max(1, 2048 // ch)
|
|
per = cols * rowspp; items, atlases = [], []
|
|
for ai in range((len(rows) + per - 1) // per):
|
|
chunk = rows[ai * per:(ai + 1) * per]
|
|
W, H = cols * cw, rowspp * ch
|
|
sheet = Image.new("RGB", (W, H), (28, 28, 32))
|
|
aname = f"stock_{kind}_atlas_{ai:02d}.webp"
|
|
for j, r in enumerate(chunk):
|
|
gx, gy = j % cols, j // cols
|
|
try:
|
|
cov = _load_img(r["img"]).resize((cw, ch))
|
|
except Exception:
|
|
continue
|
|
sheet.paste(cov, (gx * cw, gy * ch))
|
|
ptitle, partist = parody(kind, r["title"], r["artist"])
|
|
u0, v0 = gx * cw / W, gy * ch / H
|
|
item = {"id": f"{kind[:3]}_{len(items):04d}", "title": ptitle, "artist": partist,
|
|
"price": int(r["price"] or 4), "price_band": price_band(kind, r["price"]),
|
|
"atlas": aname, "uv": [round(u0, 4), round(v0, 4),
|
|
round(u0 + cw / W, 4), round(v0 + ch / H, 4)]}
|
|
items.append({k: item[k] for k in INDEX_FIELDS})
|
|
sheet.save(os.path.join(OUT, aname), "WEBP", quality=88)
|
|
atlases.append(aname)
|
|
index = {"version": 1, "kind": kind, "atlas_px": 2048, "cell_w": cw, "cell_h": ch,
|
|
"count": len(items), "atlases": atlases, "items": items}
|
|
json.dump(index, open(os.path.join(OUT, f"stock_{kind}_index.json"), "w"), indent=1)
|
|
return index
|
|
|
|
|
|
def _load_img(src):
|
|
from PIL import Image
|
|
import io
|
|
if src.startswith("http"):
|
|
req = urllib.request.Request(src, headers={"User-Agent": "procity-stockpack/1"})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
|
return Image.open(src).convert("RGB")
|
|
|
|
|
|
def main():
|
|
a = sys.argv
|
|
if "--publish" in a:
|
|
import shutil
|
|
os.makedirs(STAGE, exist_ok=True); n = 0
|
|
for f in os.listdir(OUT):
|
|
if f.startswith("stock_") and (f.endswith(".webp") or f.endswith("_index.json")):
|
|
shutil.copy(os.path.join(OUT, f), os.path.join(STAGE, f)); n += 1
|
|
print(f"staged {n} pack file(s) → {STAGE} (?localdepot). Depot: publish.py-style POST of the "
|
|
f".webp atlases + *_index.json via /api/upload.")
|
|
return
|
|
kind = a[a.index("--kind") + 1] if "--kind" in a else "record"
|
|
if "--sample" in a:
|
|
rows = rows_sample(kind, int(a[a.index("--sample") + 1]))
|
|
elif "--from-db" in a:
|
|
rows = rows_from_db(kind, int(a[a.index("--count") + 1]) if "--count" in a else 250)
|
|
else:
|
|
print(__doc__); return
|
|
idx = build_pack(kind, rows)
|
|
print(f"built {kind} pack: {idx['count']} items, {len(idx['atlases'])} atlas(es), "
|
|
f"cell {idx['cell_w']}x{idx['cell_h']} → {OUT}")
|
|
print(f" sample: {[(i['title'], i['artist'], i['price_band']) for i in idx['items'][:3]]}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|