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]>
202 lines
7.4 KiB
Python
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()
|