assetcat 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. assetcat/__init__.py +3 -0
  2. assetcat/ai.py +330 -0
  3. assetcat/classify.py +171 -0
  4. assetcat/cli.py +1063 -0
  5. assetcat/config.py +223 -0
  6. assetcat/curation.py +145 -0
  7. assetcat/db.py +119 -0
  8. assetcat/edit.py +352 -0
  9. assetcat/embed.py +417 -0
  10. assetcat/extract.py +310 -0
  11. assetcat/grids.py +115 -0
  12. assetcat/indexer.py +607 -0
  13. assetcat/init.py +219 -0
  14. assetcat/pull.py +405 -0
  15. assetcat/scan.py +268 -0
  16. assetcat/server.py +914 -0
  17. assetcat/sheets.py +98 -0
  18. assetcat/starter/licenses.toml +42 -0
  19. assetcat/starter/tags.toml +97 -0
  20. assetcat/web/app.js +1418 -0
  21. assetcat/web/index.html +17 -0
  22. assetcat/web/logo.svg +20 -0
  23. assetcat/web/model3d.js +309 -0
  24. assetcat/web/style.css +324 -0
  25. assetcat/web/vendor/README.md +11 -0
  26. assetcat/web/vendor/preact-htm.js +1 -0
  27. assetcat/web/vendor/three/LICENSE +21 -0
  28. assetcat/web/vendor/three/addons/controls/OrbitControls.js +1972 -0
  29. assetcat/web/vendor/three/addons/curves/NURBSCurve.js +155 -0
  30. assetcat/web/vendor/three/addons/curves/NURBSUtils.js +532 -0
  31. assetcat/web/vendor/three/addons/environments/RoomEnvironment.js +185 -0
  32. assetcat/web/vendor/three/addons/libs/fflate.module.js +2672 -0
  33. assetcat/web/vendor/three/addons/loaders/FBXLoader.js +4656 -0
  34. assetcat/web/vendor/three/addons/loaders/GLTFLoader.js +4925 -0
  35. assetcat/web/vendor/three/addons/loaders/MTLLoader.js +593 -0
  36. assetcat/web/vendor/three/addons/loaders/OBJLoader.js +955 -0
  37. assetcat/web/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  38. assetcat/web/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  39. assetcat/web/vendor/three/three.core.js +60586 -0
  40. assetcat/web/vendor/three/three.module.js +19719 -0
  41. assetcat-1.0.0.dist-info/METADATA +419 -0
  42. assetcat-1.0.0.dist-info/RECORD +45 -0
  43. assetcat-1.0.0.dist-info/WHEEL +4 -0
  44. assetcat-1.0.0.dist-info/entry_points.txt +2 -0
  45. assetcat-1.0.0.dist-info/licenses/LICENSE +21 -0
assetcat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AssetCat (assetcat): index, search and browse a game asset library."""
2
+
3
+ __version__ = "1.0.0"
assetcat/ai.py ADDED
@@ -0,0 +1,330 @@
1
+ """AI pack tags: a vision-language model behind an OpenAI-compatible chat API (LM Studio, Ollama,
2
+ llama.cpp's server, vLLM or a cloud API) looks at each pack and answers with tags from the
3
+ profile's `tags.toml` vocabulary.
4
+
5
+ - **Input per pack:** its cover and contact sheet, plus text the index already knows: name,
6
+ bundle, asset types, file formats, common image sizes, folders and a readme excerpt.
7
+ - **Results** go in the profile, one JSON Lines shard per model and machine, append-only:
8
+ `ai/<model>/pack-tags-<machine>.jsonl`. Readers merge every shard and take the newest result
9
+ per pack, so a second machine never recomputes and a newer model's results win.
10
+ - **Fresh** means the result was made with the current prompt (a hash of it, so editing the
11
+ vocabulary re-tags) and the pack still has the same number of files.
12
+ - **Human overrides** live in curation, not here: `tags` adds, `tags_remove` removes, at group or
13
+ pack level (see `apply_layers`). Re-running a model never undoes them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import datetime as dt
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import os
24
+ import re
25
+ import socket
26
+ import tomllib
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+
32
+ from .config import STARTER_DIR, Config
33
+
34
+ DEFAULT_URL = "http://127.0.0.1:1234/v1" # LM Studio's local server
35
+ RECOMMENDED_MODEL = "google/gemma-4-26b-a4b-qat"
36
+ KEY_ENV = "ASSETCAT_AI_KEY" # sent as a bearer token when set (cloud APIs)
37
+ IMAGE_SIDE = 1024
38
+
39
+
40
+ class AIError(Exception):
41
+ pass
42
+
43
+
44
+ # --- vocabulary --------------------------------------------------------------------------------
45
+
46
+ @dataclass
47
+ class Vocab:
48
+ namespaces: dict[str, dict]
49
+ aliases: dict[str, str] = field(default_factory=dict)
50
+ by_value: dict[str, list[str]] = field(default_factory=dict) # value -> namespaces that have it
51
+
52
+ def norm(self, tag: str) -> str:
53
+ """Canonical form of a tag: lowercase, aliases applied, and a bare value that belongs to
54
+ exactly one namespace gets it ("pixel-art" -> "style:pixel-art"). Free-form tags stay."""
55
+ t = str(tag).strip().lower()
56
+ t = self.aliases.get(t, t)
57
+ if ":" not in t and len(self.by_value.get(t, [])) == 1:
58
+ t = f"{self.by_value[t][0]}:{t}"
59
+ return t
60
+
61
+ def pack_namespaces(self) -> list[tuple[str, dict]]:
62
+ """Namespaces the pack classification answers: level "pack" with a list of values."""
63
+ return [(ns, s) for ns, s in self.namespaces.items() if s.get("level") == "pack" and s.get("values")]
64
+
65
+ def facets(self) -> list[str]:
66
+ return [ns for ns, s in self.namespaces.items() if s.get("facet")]
67
+
68
+
69
+ def load_vocab(cfg: Config) -> Vocab:
70
+ p = cfg.profile / "tags.toml"
71
+ if not p.exists():
72
+ p = STARTER_DIR / "tags.toml"
73
+ data = tomllib.loads(p.read_text(encoding="utf-8"))
74
+ nss = data.get("namespaces", {})
75
+ by_value: dict[str, list[str]] = {}
76
+ for ns, s in nss.items():
77
+ for v in s.get("values", []):
78
+ by_value.setdefault(v, []).append(ns)
79
+ return Vocab(nss, {k.lower(): v for k, v in data.get("aliases", {}).items()}, by_value)
80
+
81
+
82
+ def apply_layers(ai_tags: list[str], layers: list[dict], vocab: Vocab) -> list[str]:
83
+ """Effective tags: the AI's, then each curation layer in turn (group, parent packs, the pack)
84
+ removes its `tags_remove` and adds its `tags`. A later layer can re-add what an earlier one
85
+ removed."""
86
+ out = list(dict.fromkeys(vocab.norm(t) for t in ai_tags))
87
+ for layer in layers:
88
+ removed = {vocab.norm(t) for t in layer.get("tags_remove", [])}
89
+ out = [t for t in out if t not in removed]
90
+ for t in layer.get("tags", []):
91
+ n = vocab.norm(t)
92
+ if n and n not in out:
93
+ out.append(n)
94
+ return out
95
+
96
+
97
+ # --- prompt ------------------------------------------------------------------------------------
98
+
99
+ INTRO = """You tag game asset packs for a searchable catalog. The images show one pack: its cover (if it has one) and a contact sheet of its files. The text below them is what the file index knows about the pack.
100
+ Tag what the pack IS, not every object that appears in it: a UI pack with a potion icon is kind "ui", not "props".
101
+ Only use a value if it clearly applies. Leave a list empty when unsure or when the namespace doesn't apply."""
102
+
103
+
104
+ def build_prompt(vocab: Vocab) -> tuple[str, dict, str]:
105
+ """(instructions, JSON schema, prompt version). The version is a hash of both, so a change
106
+ to the vocabulary or the wording makes earlier results stale."""
107
+ lines = [INTRO, ""]
108
+ for ns, s in vocab.pack_namespaces():
109
+ lines.append(f"{ns} (at most {s.get('max', 2)}): {s.get('about', '')}".rstrip())
110
+ for v, h in s.get("hints", {}).items():
111
+ lines.append(f' "{v}" = {h}')
112
+ lines.append('description: one sentence, under 25 words, saying what the pack contains.')
113
+ lines.append("")
114
+ lines.append("Answer with only a JSON object: {" + ", ".join(f'"{ns}": [...]' for ns, _ in vocab.pack_namespaces())
115
+ + ', "description": "..."}, using only these values: '
116
+ + "; ".join(f"{ns}: {', '.join(s['values'])}" for ns, s in vocab.pack_namespaces()))
117
+ text = "\n".join(lines)
118
+ props = {ns: {"type": "array", "maxItems": int(s.get("max", 2)), "items": {"type": "string", "enum": list(s["values"])}}
119
+ for ns, s in vocab.pack_namespaces()}
120
+ props["description"] = {"type": "string", "maxLength": 200}
121
+ schema = {"type": "object", "additionalProperties": False, "required": list(props), "properties": props}
122
+ version = hashlib.blake2b((text + json.dumps(schema, sort_keys=True)).encode(), digest_size=4).hexdigest()
123
+ return text, schema, version
124
+
125
+
126
+ def parse_answer(out: dict, vocab: Vocab) -> dict:
127
+ """Tags from the model's JSON, checked against the vocabulary (a server without schema
128
+ support may answer anything), deduplicated and capped."""
129
+ tags = []
130
+ for ns, s in vocab.pack_namespaces():
131
+ vals = out.get(ns) or []
132
+ if isinstance(vals, str):
133
+ vals = [vals]
134
+ ok = [v for v in dict.fromkeys(str(v).strip().lower() for v in vals) if v in s["values"]]
135
+ tags += [f"{ns}:{v}" for v in ok[: int(s.get("max", 2))]]
136
+ desc = out.get("description")
137
+ return {"tags": tags, "description": desc.strip()[:300] if isinstance(desc, str) and desc.strip() else None}
138
+
139
+
140
+ # --- input -------------------------------------------------------------------------------------
141
+
142
+ def pack_input(con, cfg: Config, p: dict) -> tuple[str, list[str]]:
143
+ """The text and images (base64 PNG) the model gets for a pack."""
144
+ from .indexer import descendants
145
+
146
+ ids = descendants(con, p["id"])
147
+ q = ",".join("?" * len(ids))
148
+ group = con.execute("SELECT name FROM groups WHERE id=?", (p["group_id"],)).fetchone()
149
+ parent = con.execute("SELECT name FROM packs WHERE id=?", (p["parent_id"],)).fetchone() if p["parent_id"] else None
150
+ exts = con.execute(f"SELECT ext, COUNT(*) FROM files WHERE pack_id IN ({q}) AND category IN ('image','vector','source','model','audio','font') "
151
+ "GROUP BY ext ORDER BY 2 DESC LIMIT 8", ids).fetchall()
152
+ sizes = con.execute(f"SELECT width || 'x' || height, COUNT(*) FROM assets WHERE pack_id IN ({q}) AND width > 0 AND type != 'preview' "
153
+ "GROUP BY 1 ORDER BY 2 DESC LIMIT 3", ids).fetchall()
154
+ folders: dict[str, int] = {}
155
+ for (path,) in con.execute(f"SELECT path FROM assets WHERE pack_id IN ({q})", ids):
156
+ parts = path[len(p["path"]) + 1:].split("/")[:-1][:2]
157
+ if parts:
158
+ k = "/".join(parts)
159
+ folders[k] = folders.get(k, 0) + 1
160
+ readme = con.execute("SELECT text FROM docs WHERE pack_id=? AND role='readme' ORDER BY length(path) LIMIT 1", (p["id"],)).fetchone()
161
+ shown = sorted(folders.items())[:30]
162
+ lines = [f"Pack name: {p['name']}",
163
+ f"In: {group[0] if group else p['group_id']}" + (f" / {parent[0]}" if parent else ""),
164
+ f"Asset types (from file analysis): {p['type_counts']}"]
165
+ if exts:
166
+ lines.append("Files by format: " + ", ".join(f"{e} {n}" for e, n in exts))
167
+ if sizes:
168
+ lines.append("Most common image sizes in pixels: " + ", ".join(f"{s} ({n})" for s, n in sizes))
169
+ if shown:
170
+ lines.append("Folders (assets in each): " + ", ".join(f"{k} ({n})" for k, n in shown)
171
+ + (f", and {len(folders) - len(shown)} more" if len(folders) > len(shown) else ""))
172
+ if readme and readme[0].strip():
173
+ lines.append("Readme excerpt: " + " ".join(readme[0].split())[:600])
174
+
175
+ images = []
176
+ if p["cover_kind"] == "bundled" and p["cover"]:
177
+ images.append(cfg.fs(p["cover"]))
178
+ if p["sheet"] and Path(p["sheet"]).exists():
179
+ images.append(p["sheet"])
180
+ return "\n".join(lines), [b for b in (_image_b64(f) for f in images) if b]
181
+
182
+
183
+ def _image_b64(path: str) -> str | None:
184
+ from PIL import Image
185
+
186
+ try:
187
+ with Image.open(path) as im:
188
+ im.load()
189
+ im = im.convert("RGBA")
190
+ except Exception:
191
+ return None # a placeholder that isn't synced yet, or not an image
192
+ bg = Image.new("RGBA", im.size, (128, 128, 128, 255))
193
+ im = Image.alpha_composite(bg, im).convert("RGB")
194
+ im.thumbnail((IMAGE_SIDE, IMAGE_SIDE))
195
+ buf = io.BytesIO()
196
+ im.save(buf, "PNG")
197
+ return base64.b64encode(buf.getvalue()).decode()
198
+
199
+
200
+ def taggable(p: dict) -> bool:
201
+ return (p["asset_count"] or 0) > 0 and p["kind"] not in ("engine", "archived", "other")
202
+
203
+
204
+ # --- the model server --------------------------------------------------------------------------
205
+
206
+ class Client:
207
+ """An OpenAI-compatible chat completions endpoint."""
208
+
209
+ def __init__(self, url: str, model: str):
210
+ self.url = url.rstrip("/")
211
+ self.model = model
212
+ self.key = os.environ.get(KEY_ENV)
213
+ self.schema_ok = True # cleared when the server rejects JSON schema output
214
+ self.effort_ok = True # cleared when the server rejects reasoning_effort
215
+
216
+ def _req(self, path: str, body: dict | None = None, timeout: float = 600):
217
+ headers = {"content-type": "application/json"}
218
+ if self.key:
219
+ headers["authorization"] = f"Bearer {self.key}"
220
+ data = json.dumps(body).encode() if body is not None else None
221
+ req = urllib.request.Request(self.url + path, data, headers)
222
+ try:
223
+ with urllib.request.urlopen(req, timeout=timeout) as r:
224
+ return json.loads(r.read())
225
+ except urllib.error.HTTPError as e:
226
+ raise AIError(f"{e.code} from {self.url}{path}: {e.read().decode(errors='replace')[:400]}") from e
227
+ except (urllib.error.URLError, ConnectionError, socket.timeout) as e:
228
+ raise AIError(f"can't reach a model server at {self.url} ({getattr(e, 'reason', e)}). "
229
+ "Start LM Studio's local server (Developer tab, or `lms server start`), "
230
+ "or point `ai_url` at yours: `assetcat config ai_url <url>`") from e
231
+
232
+ def models(self) -> list[str]:
233
+ return [m["id"] for m in self._req("/models", timeout=15).get("data", [])]
234
+
235
+ def check(self):
236
+ ids = self.models()
237
+ if ids and self.model not in ids:
238
+ raise AIError(f"model '{self.model}' isn't on {self.url}. Available: {', '.join(ids)}")
239
+
240
+ def tag(self, prompt: str, schema: dict, text: str, images: list[str]) -> tuple[dict, dict]:
241
+ """(parsed JSON answer, usage)."""
242
+ content = [{"type": "text", "text": prompt + "\n\n" + text}]
243
+ content += [{"type": "image_url", "image_url": {"url": "data:image/png;base64," + b}} for b in images]
244
+ body = {"model": self.model, "temperature": 0, "messages": [{"role": "user", "content": content}]}
245
+ if self.schema_ok:
246
+ try:
247
+ r = self._chat({**body, "max_tokens": 500, "response_format": {
248
+ "type": "json_schema", "json_schema": {"name": "pack_tags", "strict": True, "schema": schema}}})
249
+ return _json_from(r), r.get("usage", {})
250
+ except AIError as e:
251
+ if not str(e).startswith("4"):
252
+ raise
253
+ self.schema_ok = False # no (or partial) JSON schema support: ask in the prompt only
254
+ r = self._chat({**body, "max_tokens": 3000})
255
+ return _json_from(r), r.get("usage", {})
256
+
257
+ def _chat(self, body: dict) -> dict:
258
+ # Models that think before answering (Gemma 4, Qwen 3, ...) spend max_tokens on reasoning
259
+ # and can run out before the answer; tagging doesn't need it, so ask for none.
260
+ if self.effort_ok:
261
+ try:
262
+ return self._req("/chat/completions", {**body, "reasoning_effort": "none"})
263
+ except AIError as e:
264
+ if not (str(e).startswith("4") and "reasoning" in str(e).lower()):
265
+ raise
266
+ self.effort_ok = False
267
+ return self._req("/chat/completions", body)
268
+
269
+
270
+ def _json_from(r: dict) -> dict:
271
+ choice = (r.get("choices") or [{}])[0]
272
+ msg = choice.get("message", {}).get("content") or ""
273
+ msg = re.sub(r"<think>.*?</think>", "", msg, flags=re.S)
274
+ if not msg.strip() and choice.get("finish_reason") == "length":
275
+ raise AIError("the model ran out of tokens before answering, probably while reasoning. "
276
+ "Turn its reasoning (thinking) off in the model server's settings")
277
+ try:
278
+ return json.loads(msg[msg.index("{"): msg.rindex("}") + 1])
279
+ except ValueError as e:
280
+ raise AIError(f"the model didn't answer with JSON: {msg[:200]!r}") from e
281
+
282
+
283
+ # --- results in the profile --------------------------------------------------------------------
284
+
285
+ def model_slug(model: str) -> str:
286
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", model).strip("_") or "model"
287
+
288
+
289
+ def machine_name(uc: dict) -> str:
290
+ return re.sub(r"[^a-z0-9-]+", "-", str(uc.get("machine") or socket.gethostname()).lower().split(".")[0]).strip("-") or "machine"
291
+
292
+
293
+ def shard_path(cfg: Config, model: str, machine: str) -> Path:
294
+ return cfg.profile / "ai" / model_slug(model) / f"pack-tags-{machine}.jsonl"
295
+
296
+
297
+ def append_result(cfg: Config, model: str, machine: str, rec: dict):
298
+ p = cfg.writable(shard_path(cfg, model, machine))
299
+ p.parent.mkdir(parents=True, exist_ok=True)
300
+ with open(p, "a", encoding="utf-8", newline="\n") as f:
301
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
302
+
303
+
304
+ def load_results(cfg: Config, model: str | None = None) -> dict[str, dict]:
305
+ """Newest pack-tag result per pack id over every shard (of one model, if given)."""
306
+ root = cfg.profile / "ai"
307
+ out: dict[str, dict] = {}
308
+ if not root.is_dir():
309
+ return out
310
+ for p in sorted(root.glob("*/pack-tags-*.jsonl")):
311
+ if model and p.parent.name != model_slug(model):
312
+ continue
313
+ with open(p, encoding="utf-8") as f:
314
+ for line in f:
315
+ try:
316
+ rec = json.loads(line)
317
+ except ValueError:
318
+ continue # a line cut short by a sync conflict or a crash
319
+ pid = rec.get("pack")
320
+ if pid and (pid not in out or rec.get("at", "") >= out[pid].get("at", "")):
321
+ out[pid] = rec
322
+ return out
323
+
324
+
325
+ def is_fresh(rec: dict | None, p: dict, version: str) -> bool:
326
+ return bool(rec) and rec.get("prompt") == version and rec.get("files") == p["file_count"]
327
+
328
+
329
+ def now() -> str:
330
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
assetcat/classify.py ADDED
@@ -0,0 +1,171 @@
1
+ """File categories, asset types, variant keys and other naming rules. Pure functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _CATS = {
8
+ "image": "png gif jpg jpeg bmp tga webp",
9
+ "vector": "svg",
10
+ "source": "psd ai eps cdr aseprite ase pyxel xcf kra pdn sai clip ora afdesign afphoto sketch fla swf spine",
11
+ "audio": "wav ogg mp3 flac aif aiff m4a opus wma",
12
+ "model": "fbx obj glb gltf dae stl blend 3ds ply vox max ma mb",
13
+ "font": "ttf otf woff woff2",
14
+ "doc": "txt rtf pdf html htm md url webloc docx doc",
15
+ "data": "json xml tsx tmx csv plist atlas scml scon mtl bin fnt yaml yml",
16
+ "engine": (
17
+ "unitypackage prefab meta anim controller overridecontroller unity mat asset physicmaterial "
18
+ "uasset uproject umap gd tscn tres godot import yy yyp capx c3p"
19
+ ),
20
+ "archive": "zip rar 7z gz tar",
21
+ "video": "mp4 mov webm avi mkv",
22
+ }
23
+ EXT_CATEGORY = {ext: cat for cat, exts in _CATS.items() for ext in exts.split()}
24
+
25
+ # Asset media classes: files of these categories become assets.
26
+ MEDIA = {"image": "visual", "vector": "visual", "source": "visual", "audio": "audio", "model": "model", "font": "font"}
27
+
28
+ # Primary-variant preference (lower = better).
29
+ _PREF = {
30
+ "visual": "png gif webp jpg jpeg bmp tga svg psd aseprite ase ai eps pyxel kra xcf cdr",
31
+ "audio": "ogg wav flac mp3 aif aiff m4a opus wma",
32
+ "model": "glb gltf fbx obj dae blend 3ds stl ply vox",
33
+ "font": "ttf otf woff2 woff",
34
+ }
35
+ PREF_RANK = {m: {e: i for i, e in enumerate(s.split())} for m, s in _PREF.items()}
36
+ BROWSER_IMAGE = {"png", "gif", "jpg", "jpeg", "webp", "bmp", "svg"}
37
+
38
+ ENGINE_EXT = {
39
+ "unity": "unitypackage prefab meta anim controller overridecontroller unity mat asset physicmaterial",
40
+ "unreal": "uasset uproject umap",
41
+ "godot": "gd tscn tres godot import",
42
+ "gamemaker": "yy yyp",
43
+ "construct": "capx c3p",
44
+ }
45
+ EXT_ENGINE = {e: eng for eng, s in ENGINE_EXT.items() for e in s.split()}
46
+
47
+
48
+ def ext_of(name: str) -> str:
49
+ i = name.rfind(".")
50
+ return name[i + 1 :].lower() if i > 0 else ""
51
+
52
+
53
+ def stem_of(name: str) -> str:
54
+ i = name.rfind(".")
55
+ return name[:i] if i > 0 else name
56
+
57
+
58
+ def category(ext: str) -> str:
59
+ return EXT_CATEGORY.get(ext, "other")
60
+
61
+
62
+ def slugify(s: str) -> str:
63
+ s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
64
+ return s or "x"
65
+
66
+
67
+ _CAMEL = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[A-Za-z])(?=[0-9])|(?<=[0-9])(?=[A-Za-z])")
68
+
69
+
70
+ def words(s: str) -> list[str]:
71
+ """Split names into lowercase words: 'BatMonster_01-idle' -> bat monster 01 idle."""
72
+ s = _CAMEL.sub(" ", s)
73
+ return [w for w in re.split(r"[^A-Za-z0-9]+", s.lower()) if w]
74
+
75
+
76
+ # Folder names that only say which format is inside; dropped when grouping variants.
77
+ _FORMAT_FOLDER = re.compile(
78
+ r"^(png|pngs|wav|wavs|mp3|mp3s|ogg|oggs|flac|aiff?|psd|psds|ai|eps|svg|svgs|vector|vectors|jpe?g|gif|gifs|"
79
+ r"fbx|obj|glb|gltf|dae|stl|blend|ttf|otf|aseprite|pyxel|source|sources|source ?files|raw|"
80
+ r"(png|wav|mp3|ogg|fbx|obj|glb|gltf|dae|stl|svg|psd|vector) ?(format|files|version|\d+ ?k(hz)?|\d+ ?bit.*)|"
81
+ r"default ?size|retina|hd|sd|[1-4]x|x[1-4]|double|mono|stereo|\d+ ?bit|\d+ ?k(hz)?|\d+ ?bit[ _-]\d+ ?k(hz)?|"
82
+ r".+[_\- ](png|psd|ai|eps|svg|jpg|vector|wav|mp3|ogg)s?)$",
83
+ re.I,
84
+ )
85
+ FORMAT_FOLDER = _FORMAT_FOLDER
86
+
87
+
88
+ def variant_folder(folder: str) -> str:
89
+ """Folder path with format-only components removed (for grouping variants)."""
90
+ parts = [p for p in folder.split("/") if p and not _FORMAT_FOLDER.match(p.strip())]
91
+ return "/".join(p.lower() for p in parts)
92
+
93
+
94
+ _PREVIEW = re.compile(
95
+ r"^(preview|mockup|mock-up|sample|cover|screenshot|screen ?shot|promo|showcase|thumbnail|thumb|banner|"
96
+ r"overview|example|demo|presentation|poster|display)s?([ _\-\d].*)?$",
97
+ re.I,
98
+ )
99
+ PREVIEW_FOLDER = re.compile(r"^(previews?|screenshots?|mockups?|promo|showcase|examples?|samples?)$", re.I)
100
+
101
+
102
+ _PREVIEW_WORDS = {"preview", "previews", "mockup", "screenshot", "screenshots", "promo", "showcase", "example", "thumbnail", "banner"}
103
+
104
+
105
+ def is_preview_name(stem: str) -> bool:
106
+ return bool(_PREVIEW.match(stem)) or bool(set(words(stem)) & _PREVIEW_WORDS)
107
+
108
+
109
+ _TYPE_RULES: list[tuple[str, set[str]]] = [
110
+ ("ui", {"ui", "gui", "hud", "button", "buttons", "interface", "panel", "panels", "menu", "menus", "cursor", "cursors", "window", "windows", "dialog", "healthbar", "bars", "prompts", "prompt"}),
111
+ ("icon", {"icon", "icons", "badge", "badges"}),
112
+ ("tileset", {"tile", "tiles", "tileset", "tilesets", "tilesheet", "tilemap", "tilemaps", "terrain", "autotile"}),
113
+ ("background", {"background", "backgrounds", "bg", "bgs", "backdrop", "parallax", "sky", "skies", "wallpaper", "wallpapers"}),
114
+ ("portrait", {"portrait", "portraits", "avatar", "avatars", "face", "faces", "faceset", "bust"}),
115
+ ("vfx", {"fx", "vfx", "effect", "effects", "particle", "particles", "explosion", "explosions", "spell", "spells", "smoke", "fire", "impact"}),
116
+ ("character", {"character", "characters", "hero", "heroes", "player", "enemy", "enemies", "monster", "monsters", "npc", "npcs", "creature", "creatures", "mob", "mobs", "boss", "bosses"}),
117
+ ]
118
+
119
+
120
+ def visual_type(stem: str, folder: str, pack_name: str = "") -> str:
121
+ if is_preview_name(stem) or any(PREVIEW_FOLDER.match(p) for p in folder.split("/") if p):
122
+ return "preview"
123
+ ws = set(words(stem)) | set(words(folder))
124
+ for t, keys in _TYPE_RULES:
125
+ if ws & keys:
126
+ return t
127
+ pw = set(words(pack_name))
128
+ for t, keys in _TYPE_RULES:
129
+ if pw & keys:
130
+ return t
131
+ return "sprite"
132
+
133
+
134
+ _MUSIC = {"music", "bgm", "song", "songs", "soundtrack", "ost", "tune", "tunes"}
135
+ _MUSIC_WEAK = {"theme", "themes", "track", "tracks", "loop", "loops", "jingle", "jingles", "ambient", "ambience", "stinger", "stingers"}
136
+ _SFX_PACK = ("sfx", "soundfx", "sound fx", "soundeffect", "sound effect", "sound-effect", "sounds", "foley", "fx pack")
137
+ _MUSIC_PACK = ("music", "tune", "song", "soundtrack", "jingle")
138
+ _VOICE = {"voice", "voices", "vocal", "vocals", "vocalization", "vocalizations", "vo", "speech"}
139
+
140
+
141
+ def audio_type(stem: str, folder: str, pack_name: str, duration: float | None) -> str:
142
+ ws = set(words(stem)) | set(words(folder))
143
+ pn = pack_name.lower()
144
+ if ws & _VOICE:
145
+ return "voice"
146
+ if ws & _MUSIC:
147
+ return "music"
148
+ sfx_pack = any(k in pn for k in _SFX_PACK) and not any(k in pn for k in _MUSIC_PACK)
149
+ if duration is not None and duration >= 45:
150
+ return "music"
151
+ if sfx_pack:
152
+ return "sfx"
153
+ if ws & _MUSIC_WEAK and (duration is None or duration >= 8):
154
+ return "music"
155
+ if set(words(pack_name)) & _VOICE or "vocal" in pn:
156
+ return "voice"
157
+ if any(k in pn for k in _MUSIC_PACK) and (duration is None or duration >= 8):
158
+ return "music"
159
+ return "sfx"
160
+
161
+
162
+ _DOC_LICENSE = re.compile(r"(licen[cs]e|copyright|terms|eula|\btos\b|royalty)", re.I)
163
+ _DOC_README = re.compile(r"(read ?me|instructions|credits?|attribution|info|about|notes?|description)", re.I)
164
+
165
+
166
+ def doc_role(name: str) -> str | None:
167
+ if _DOC_LICENSE.search(name):
168
+ return "license"
169
+ if _DOC_README.search(name):
170
+ return "readme"
171
+ return None