ShitboxInfinity/tools/cc_to_glb.py
m3ultra 324dbdf625 Cashies tool library: dealgod photos -> TRELLIS -> shuntable paddock props
The dealgod.pro products database holds ~82k secondhand tool listings
with photos (69k cached on the VPS). This turns the good ones into game
assets: 15 tool classes curated from 45 candidates (socket set, air
compressor, chain block, car ramp, OBD reader, trolley jack, engine
crane, engine stand, toolbox, angle grinder, welder, jerry can, battery
charger, jack stands, grease gun), each run through the MODELBEAST farm
by the new tools/cc_to_glb.py: bg_remove_local cutout, then trellis_mac
image->3D (1024 tier). Retries with backoff after a transient queue 401;
the jerry can made trellis_mac exit 2 on two different photos and went
through hunyuan3d_mlx instead (--operator flag), which ate it happily.

Raw TRELLIS output is ~20MB per prop, so tools/slim_glb.py (headless
Blender) decimates to 18k tris and re-exports with 1024 JPEG textures:
the library lands at 27MB total for 15 props instead of ~300MB.

The Willowbank paddock strews the whole library as RigidBodies --
TOOL_SPECS in dragway.gd gives each class its real-world size (TRELLIS
output is unit-ish; the AABB is measured live and scaled) and an honest
mass, so clipping the 70kg engine crane is a different event to punting
the grease gun. The loader is directory-driven: drop a new GLB into
assets/tools/ and it appears in the paddock next boot.

Textures inherit whatever branding was in the source photos -- fine for
secondhand background clutter, flagged in the README for anything that
gets promoted to hero status. Smoke suite asserts every GLB in the
library spawns as a prop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:25:48 +10:00

170 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Cashies photo -> game prop: bg_remove_local then trellis_mac on MODELBEAST.
Usage: cc_to_glb.py <name=image> [name=image ...] [--out DIR] [--workers N]
For each input image: upload -> bg_remove_local (clean cutout; the op's own
docs call this the big quality jump before image->3D) -> trellis_mac
(1024 tier, 1024 texture: prop-grade, keeps GLBs a few MB) -> download the
GLB to <out>/<name>.glb. Progress lines are grep-friendly: "DONE name" /
"FAIL name reason". Queue + token per ~/.claude/skills/fleet/SKILL.md; the
guest token allows 4 active jobs, so keep --workers at 2 (each worker holds
one job at a time, bg+trellis are sequential per item).
Job log JSON carries raw control chars -- scrub before json.loads.
"""
import json
import mimetypes
import os
import re
import sys
import threading
import time
import urllib.request
import uuid
HOST = os.environ.get("MB_HOST", "http://100.89.131.57:8777")
ENVS = ["~/Documents/fluxgod-work/.env", "~/Documents/backnforth/.env"]
def token():
for p in ENVS:
p = os.path.expanduser(p)
if not os.path.exists(p):
continue
for line in open(p):
if line.startswith("MB_TOKEN"):
return line.split("=", 1)[1].strip().strip("'\"")
sys.exit("no MB_TOKEN found on disk")
TOK = token()
def call(path, data=None, headers=None, raw=False, tries=4):
# the queue occasionally throws a transient 401/5xx mid-batch (seen live:
# one 401 between a working bg job and a working trellis submit) -- retry
# with backoff before declaring an item dead
h = {"Authorization": "Bearer " + TOK}
h.update(headers or {})
last = None
for attempt in range(tries):
try:
req = urllib.request.Request(HOST + path, data=data, headers=h)
blob = urllib.request.urlopen(req, timeout=180).read()
if raw:
return blob
return json.loads(re.sub(r"[\x00-\x1f]", "", blob.decode("utf-8", "replace")))
except Exception as e:
last = e
time.sleep(4 * (attempt + 1))
raise last
def upload(path):
boundary = uuid.uuid4().hex
ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
body = (
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
f'filename="{os.path.basename(path)}"\r\nContent-Type: {ctype}\r\n\r\n'
).encode() + open(path, "rb").read() + f"\r\n--{boundary}--\r\n".encode()
a = call("/api/assets", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
return a.get("id") or (a.get("items") or [a])[0].get("id")
def run_job(operator, asset_id, params, timeout_s):
j = call("/api/jobs", data=json.dumps(
{"operator": operator, "asset_id": asset_id, "params": params}).encode(),
headers={"Content-Type": "application/json"})
jid = j["id"]
t0 = time.time()
while time.time() - t0 < timeout_s:
time.sleep(10)
st = call("/api/jobs/%s" % jid)
if st.get("status") in ("done", "error", "cancelled"):
if st["status"] != "done":
raise RuntimeError("%s job %s: %s (%s)" % (
operator, jid, st["status"], str(st.get("error", ""))[:160]))
return jid
raise RuntimeError("%s job %s timed out after %ds" % (operator, jid, timeout_s))
def job_asset(jid, suffix=None):
# jobs don't back-link outputs: list assets, match parent_job (mb_recon contract)
a = call("/api/assets?limit=100")
items = a if isinstance(a, list) else a.get("items", [])
mine = [x for x in items if x.get("parent_job") == jid]
if suffix:
mine = [x for x in mine
if str(x.get("filename", x.get("name", ""))).endswith(suffix)]
if not mine:
raise RuntimeError("job %s produced no %s asset" % (jid, suffix or ""))
return mine[0]["id"]
MESH_OP = "trellis_mac"
MESH_PARAMS = {
# jerry cans made trellis_mac exit 2 on two different photos; hunyuan3d_mlx
# chewed the same cutout happily -- keep both dialled in
"trellis_mac": {"pipeline_type": "1024", "texture_size": 1024},
"hunyuan3d_mlx": {"texture_size": 1024},
}
def one(name, img, outdir):
try:
aid = upload(img)
print("UPLOADED %s -> %s" % (name, aid), flush=True)
bj = run_job("bg_remove_local", aid, {"resolution": 1024}, 600)
cut = job_asset(bj)
print("CUTOUT %s" % name, flush=True)
tj = run_job(MESH_OP, cut, MESH_PARAMS.get(MESH_OP, {}), 3600)
gid = job_asset(tj, ".glb")
out = os.path.join(outdir, name + ".glb")
open(out, "wb").write(call("/api/assets/%s/file" % gid, raw=True))
print("DONE %s (%dKB)" % (name, os.path.getsize(out) // 1024), flush=True)
except Exception as e:
print("FAIL %s %s" % (name, str(e)[:200]), flush=True)
def main():
args = sys.argv[1:]
outdir = "assets/tools"
workers = 2
if "--out" in args:
i = args.index("--out")
outdir = args[i + 1]
del args[i:i + 2]
if "--workers" in args:
i = args.index("--workers")
workers = int(args[i + 1])
del args[i:i + 2]
if "--operator" in args:
i = args.index("--operator")
global MESH_OP
MESH_OP = args[i + 1]
del args[i:i + 2]
os.makedirs(outdir, exist_ok=True)
queue = [a.split("=", 1) for a in args if "=" in a]
lock = threading.Lock()
def worker():
while True:
with lock:
if not queue:
return
name, img = queue.pop(0)
one(name, img, outdir)
threads = [threading.Thread(target=worker) for _ in range(workers)]
for t in threads:
t.start()
for t in threads:
t.join()
print("BATCH_COMPLETE", flush=True)
if __name__ == "__main__":
main()