John unblocked GODVERSE DB access (m1ultra native-Postgres clones, read-only SELECT, peer auth). All 3 packs rebuilt from real data: - record 350: discogs_full.inventory (real store stock) join release/release_image, real Discogs cover art. - book 311 + toy 273: dealgod.products by category_path, real product photos + prices. Metadata parody-transformed (deterministic, no source-token leak -> no real trademarks in pack text); cover images are real release/product art (read-only, non-economy, decision #2); book prices seeded (source is info-only, John's call). 15 atlases published to the depot; indexes committed (the C contract) + staged for ?localdepot; all pass the pack-QA gate. build_stock_pack.py gained --from-tsv (extract-on-DB-box -> build-here), portrait 176x256 book cell, seeded-price fallback. qa --strict GREEN 5/5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
13 KiB
Python
240 lines
13 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": (176, 256), "toy": (256, 256)} # book = portrait cover (2:3-ish)
|
|
GRID = {"record": 8, "book": 10, "toy": 8} # columns per atlas row
|
|
|
|
|
|
def seeded_price(kind, title, artist):
|
|
"""Some sources carry no price (books are info-only) — mint a deterministic, kind-plausible
|
|
price so the buy loop + price bands work. Same item → same price every build."""
|
|
s = _seed("price", kind, title or "", artist or "")
|
|
lo, hi = {"book": (3, 40), "toy": (4, 35), "record": (8, 60)}.get(kind, (5, 40))
|
|
return lo + s % (hi - lo + 1)
|
|
|
|
|
|
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_from_tsv(path):
|
|
"""Rows from a `artist\\ttitle\\tprice\\tcover_url` TSV extracted from GODVERSE (discogs_full
|
|
inventory JOIN release JOIN release_image — John's real store stock + saved Discogs covers).
|
|
Covers are fetched from the URL at build time; metadata is parody-transformed downstream."""
|
|
out = []
|
|
for line in open(path, encoding="utf-8"):
|
|
parts = line.rstrip("\n").split("\t")
|
|
if len(parts) < 4 or not parts[3]: # need a cover URL
|
|
continue
|
|
col0, col1, price, cover = parts[0], parts[1], parts[2], parts[3]
|
|
# record source is (artist, title); single-field sources (books/toys from products) put the
|
|
# title in col0 and leave col1 empty — carry the real title through so parody seeds off it.
|
|
if col1:
|
|
artist, title = col0, col1
|
|
elif col0:
|
|
artist, title = "", col0
|
|
else:
|
|
continue
|
|
out.append({"artist": artist, "title": title,
|
|
"price": int(price) if str(price).lstrip("-").isdigit() else None, "img": cover})
|
|
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
|
|
price = r["price"] if r.get("price") else seeded_price(kind, r["title"], r["artist"])
|
|
item = {"id": f"{kind[:3]}_{len(items):04d}", "title": ptitle, "artist": partist,
|
|
"price": int(price), "price_band": price_band(kind, 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 "--from-tsv" in a:
|
|
rows = rows_from_tsv(a[a.index("--from-tsv") + 1])
|
|
elif "--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()
|