#!/usr/bin/env python3 """ Sketchfab model importer for Papaya-Shooter. Searches and downloads models via the official Sketchfab Download API, extracts the GLB/glTF, and stages it for the rigging pipeline. Auth (any one of): - env var SKETCHFAB_API_TOKEN - --token - a file named .sketchfab_token in the project root (gitignored) Usage: python tools/sketchfab_import.py search "anime character" [--limit 10] python tools/sketchfab_import.py download [--out assets/characters/incoming] python tools/sketchfab_import.py info Notes: - Only models marked "downloadable" can be fetched (license permitting). - License + attribution metadata is saved next to the model as .license.json. CC-BY requires crediting the author — keep those files! """ import argparse import io import json import os import re import sys import urllib.parse import urllib.request import zipfile API_BASE = "https://api.sketchfab.com/v3" PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_OUT = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming") def get_token(cli_token: str | None) -> str: if cli_token: return cli_token tok = os.environ.get("SKETCHFAB_API_TOKEN", "").strip() if tok: return tok token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token") if os.path.exists(token_file): with open(token_file, "rb") as f: raw = f.read() # PowerShell's `echo`/`>` write UTF-16 with a BOM by default on # Windows; tolerate that as well as plain UTF-8. if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"): tok = raw.decode("utf-16").strip() else: tok = raw.decode("utf-8-sig").strip() if tok: return tok print("ERROR: no Sketchfab API token found.") print("Set SKETCHFAB_API_TOKEN, pass --token, or create .sketchfab_token in the project root.") print("Your token: https://sketchfab.com/settings/password (API token section)") sys.exit(1) def api_get(path: str, token: str, params: dict | None = None) -> dict: url = API_BASE + path if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"}) try: with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace")[:500] print(f"ERROR: API request failed ({e.code}) for {url}\n{body}") sys.exit(1) def slugify(name: str) -> str: slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") return slug or "model" def cmd_search(args) -> None: token = get_token(args.token) params = { "type": "models", "q": args.query, "downloadable": "true", "count": str(args.limit), "sort_by": "-likeCount", } if args.animated: params["animated"] = "true" if args.rigged: params["rigged"] = "true" data = api_get("/search", token, params) results = data.get("results", []) if not results: print("No downloadable results.") return print(f"{'UID':<34} {'License':<18} {'Faces':>9} Name") print("-" * 90) for m in results: lic = (m.get("license") or {}).get("label", "?") faces = m.get("faceCount", 0) print(f"{m['uid']:<34} {lic:<18} {faces:>9} {m['name']}") print("\nDownload one with: python tools/sketchfab_import.py download ") def cmd_info(args) -> None: token = get_token(args.token) m = api_get(f"/models/{args.uid}", token) lic = m.get("license") or {} print(f"Name: {m.get('name')}") print(f"Author: {(m.get('user') or {}).get('displayName')}") print(f"License: {lic.get('label')} ({lic.get('slug')})") print(f"Downloadable:{m.get('isDownloadable')}") print(f"Faces: {m.get('faceCount')} Verts: {m.get('vertexCount')}") print(f"Animations: {m.get('animationCount')}") print(f"URL: {m.get('viewerUrl')}") def cmd_download(args) -> None: token = get_token(args.token) meta = api_get(f"/models/{args.uid}", token) name = args.name or slugify(meta.get("name", args.uid)) out_dir = os.path.abspath(args.out) os.makedirs(out_dir, exist_ok=True) dl = api_get(f"/models/{args.uid}/download", token) # Prefer the single-file GLB; fall back to the glTF zip. glb_path = os.path.join(out_dir, f"{name}.glb") if "glb" in dl and dl["glb"].get("url"): print(f"Downloading GLB ({dl['glb'].get('size', 0) / 1e6:.1f} MB)...") urllib.request.urlretrieve(dl["glb"]["url"], glb_path) elif "gltf" in dl and dl["gltf"].get("url"): print(f"Downloading glTF zip ({dl['gltf'].get('size', 0) / 1e6:.1f} MB)...") with urllib.request.urlopen(dl["gltf"]["url"], timeout=300) as resp: zdata = resp.read() extract_dir = os.path.join(out_dir, name) os.makedirs(extract_dir, exist_ok=True) with zipfile.ZipFile(io.BytesIO(zdata)) as zf: zf.extractall(extract_dir) gltfs = [f for f in os.listdir(extract_dir) if f.endswith((".gltf", ".glb"))] if not gltfs: print(f"ERROR: no .gltf/.glb found in archive at {extract_dir}") sys.exit(1) glb_path = os.path.join(extract_dir, gltfs[0]) else: print("ERROR: model has no downloadable GLB/glTF archive.") sys.exit(1) # Save license/attribution metadata — required for CC-BY credit. lic = meta.get("license") or {} license_info = { "name": meta.get("name"), "uid": args.uid, "author": (meta.get("user") or {}).get("displayName"), "author_url": (meta.get("user") or {}).get("profileUrl"), "license": lic.get("label"), "license_slug": lic.get("slug"), "source_url": meta.get("viewerUrl"), } license_path = os.path.splitext(glb_path)[0] + ".license.json" with open(license_path, "w", encoding="utf-8") as f: json.dump(license_info, f, indent=2) print(f"Saved: {glb_path}") print(f"License: {license_path} ({lic.get('label')})") print(f"\nNext step (auto-rig + animations + register in game):") print(f" python tools/pipeline.py --input \"{glb_path}\" --name {name}") def main() -> None: p = argparse.ArgumentParser(description="Sketchfab importer") p.add_argument("--token", help="Sketchfab API token (else SKETCHFAB_API_TOKEN)") sub = p.add_subparsers(dest="cmd", required=True) s = sub.add_parser("search", help="search downloadable models") s.add_argument("query") s.add_argument("--limit", type=int, default=10) s.add_argument("--animated", action="store_true", help="only models with animations") s.add_argument("--rigged", action="store_true", help="only rigged models") s.set_defaults(func=cmd_search) i = sub.add_parser("info", help="show model metadata") i.add_argument("uid") i.set_defaults(func=cmd_info) d = sub.add_parser("download", help="download a model by UID") d.add_argument("uid") d.add_argument("--out", default=DEFAULT_OUT) d.add_argument("--name", help="output base name (default: slugified model name)") d.set_defaults(func=cmd_download) args = p.parse_args() # Propagate the global --token even when given after the subcommand. args.func(args) if __name__ == "__main__": main()