PROCITY/pipeline/publish.py
m3ultra dafb079ff4 Lane E round 4 (E2): fix record_crate + ship counter-with-till, both live on depot
- record_crate: R3 normalize left a sourceless texture (packed AO/roughness map
  didn't survive WebP export; metallicRoughness+occlusion pointed at it) → three.js
  'undefined uri' crash. New pipeline/fix_glb_textures.py strips material slots that
  reference a sourceless texture. Verified loads in vendored GLTFLoader; re-published.
- counter_till (new fitting): ~2m timber counter + period till, flux_local→trellis_mac
  on-device, 1.61x0.64m / 1.15m tall, 32k tris. Verified loads; published. Lane C maps
  kind counter -> counter_till (balcao 4m 'counter' kept for long-bar use).
- publish.py: --only <name> filter + passwordless tailnet detection (GOD3_DEPOT override).
- manifest rebuilt (15 fittings, alias counter->counter_till); validate --depot 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:07:15 +10:00

112 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Publish the normalized PROCITY GLBs + thumbnails to the shared 3GOD depot.
Mirrors the house push contract (meshgod/server.py:_push_3god) exactly:
* auth cookie: 3g = hmac_sha256(GOD3_PW, "3god-ok").hexdigest()
* GLB POST {depot}/api/upload?name=<file> body=glb model/gltf-binary
* thumb POST {depot}/api/thumb?name=<file> body=png image/png
Credentials come from the environment — this script never contains or logs the secret:
export GOD3_PW=... # the shared depot password (ask John)
export GOD3_DEPOT=https://digalot.fyi/3god # optional; this is the default
python3 pipeline/publish.py # publish every GLB in _normalized/
python3 pipeline/publish.py --dry-run # list what WOULD upload, no network
This is the one outward-facing step in the lane; run it deliberately. Uploads are
idempotent (same name overwrites) and reversible via the depot's own delete.
"""
import os, sys, glob, hmac, hashlib, json, urllib.request, urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NORM = os.path.join(ROOT, "pipeline", "_normalized")
THUMBS = os.path.join(ROOT, "web", "assets", "thumbs")
DEPOT = os.environ.get("GOD3_DEPOT", "https://digalot.fyi/3god").rstrip("/")
PW = os.environ.get("GOD3_PW", "")
DRY = "--dry-run" in sys.argv
# assets intentionally excluded from publish (see AUDIT.md)
SKIP = {"procity_fit_cube_shelf_01.glb"}
def cookie():
tok = hmac.new(PW.encode(), b"3god-ok", hashlib.sha256).hexdigest()
return f"3g={tok}"
def to_jpeg(png_path):
"""PNG thumb → JPEG bytes (the depot's /api/thumb accepts jpeg only).
Uses macOS sips (no deps); falls back to PIL if available elsewhere."""
import subprocess, tempfile
if sys.platform == "darwin":
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as t:
tmp = t.name
try:
subprocess.run(["sips", "-s", "format", "jpeg", "-s", "formatOptions", "85",
png_path, "--out", tmp], check=True, capture_output=True)
return open(tmp, "rb").read()
finally:
os.unlink(tmp)
from PIL import Image # non-mac fallback
import io
buf = io.BytesIO()
Image.open(png_path).convert("RGB").save(buf, "JPEG", quality=85)
return buf.getvalue()
def post(path, body, ctype):
req = urllib.request.Request(DEPOT + path, data=body, method="POST",
headers={"Content-Type": ctype, "Cookie": cookie()})
with urllib.request.urlopen(req, timeout=120) as r:
return r.status, r.read().decode("utf-8", "replace")[:200]
def main():
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
# Passwordless tailnet path (round-3 decision #4): GOD3_DEPOT overridden to an allowlisted
# tailnet endpoint (e.g. http://100.94.195.115:8788) trusts this box — no cookie needed.
tailnet = DEPOT != "https://digalot.fyi/3god"
glbs = sorted(g for g in glob.glob(NORM + "/*.glb")
if os.path.basename(g) not in SKIP and (not only or only in os.path.basename(g)))
if not glbs:
print(f"no GLBs in {NORM} matching {only!r} — run normalize.py first"); sys.exit(1)
print(f"depot: {DEPOT} assets: {len(glbs)} {'(DRY RUN)' if DRY else ''}"
+ (" [tailnet passwordless]" if tailnet else ""))
if not DRY and not PW and not tailnet:
print("ERROR: GOD3_PW not set and no tailnet GOD3_DEPOT. Either:\n"
" export GOD3_DEPOT=http://100.94.195.115:8788 # passwordless from an allowlisted box\n"
" or export GOD3_PW=... # public depot (ask John)")
sys.exit(2)
published = []
for g in glbs:
name = os.path.basename(g)
thumb = os.path.join(THUMBS, name.replace(".glb", ".png"))
size = os.path.getsize(g)
if DRY:
print(f" would upload {name:38} {size//1024:>5}KB thumb={os.path.exists(thumb)}")
continue
try:
st, msg = post("/api/upload?name=" + urllib.parse.quote(name),
open(g, "rb").read(), "model/gltf-binary")
# thumb is cosmetic — never fail the publish over it (house contract:
# /api/thumb is keyed by the stored GLB name and wants JPEG)
tstat = ""
if os.path.exists(thumb):
try:
ts, _ = post("/api/thumb?name=" + urllib.parse.quote(name),
to_jpeg(thumb), "image/jpeg")
tstat = f" thumb:{ts}"
except Exception as te:
tstat = f" thumb:ERR({te})"
ok = st in (200, 201)
print(f" {'OK ' if ok else 'ERR'} {name:38} glb:{st}{tstat}")
if ok:
published.append(name)
except Exception as e:
print(f" ERR {name:38} {e}")
if not DRY:
json.dump(published, open(os.path.join(ROOT, "pipeline", "_published.json"), "w"), indent=2)
print(f"\npublished {len(published)}/{len(glbs)} → depot. verify: curl -s {DEPOT}/api/list")
if __name__ == "__main__":
main()