Files
Papay-Shooter/tools/sketchfab_import.py
Nicholas ButzkeandClaude Fable 5 64bbbf93c6 fix: make the character pipeline work end-to-end in Blender 5.1 + bundle CC0 animation library
Verified the full chain on a real Sketchfab model (Miku UID -> rigged ->
13 clips -> loads and animates in-engine, 24/24 smoke checks pass).

Fixes found while running it for real:
- sketchfab_import: read .sketchfab_token as UTF-16/BOM too (PowerShell's
  `echo >` writes UTF-16LE, which crashed the UTF-8 reader)
- autorig: rig to the animation library's OWN skeleton when _library.glb
  is present (zero retargeting); robust binding — fall back to rigid
  nearest-bone weights when Blender bone-heat fails on complex meshes
  (Miku's joined hair/clothing gave 0 weighted verts); export as
  parent_type=ARMATURE with no modifier (Blender 5.x only emits a glTF
  skin in that exact form — a lingering Armature modifier gave skins:0);
  export_apply=False (applying modifiers baked away the skin); flatten
  the Sketchfab empty hierarchy before binding
- merge_animations: support a single multi-clip library GLB via
  LIBRARY_CLIP_MAP; version-safe fcurve access for Blender 4.4+/5.x
  slotted actions (Action.fcurves was removed)

Assets/tooling:
- bundle assets/characters/animations/_library.glb — CC0 Quaternius
  Universal Animation Library (13 game clips), + .gdignore so Godot
  skips the pipeline-input folder
- requirements.txt (stdlib-only; documents Blender-python separation)
- pipeline docs updated for the bundled library + swap instructions

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 12:42:31 -04:00

202 lines
7.4 KiB
Python

#!/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 <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 <model-uid> [--out assets/characters/incoming]
python tools/sketchfab_import.py info <model-uid>
Notes:
- Only models marked "downloadable" can be fetched (license permitting).
- License + attribution metadata is saved next to the model as <name>.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 <UID>")
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()