nnnotes 0.1.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 (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/webaudio.py ADDED
@@ -0,0 +1,14 @@
1
+ """The audio formats of a web build (`web --format`): per format the file suffix and the ffmpeg encoder flags (None:
2
+ the FLAC of the decode itself). Imports nothing, so the command line lists the formats without loading the
3
+ exporters."""
4
+
5
+ # BGM encodings for the web (ffmpeg). Chromium's decodeAudioData decodes each sample-aligned with the FLAC (encoder
6
+ # delay / pre-skip removed). Default AAC-LC 160 kbit/s in MP4: decodes in Safari / iOS as well.
7
+ WEB_AUDIO = {
8
+ "aac": (".m4a", ["-c:a", "aac", "-b:a", "160k"]),
9
+ "opus": (".opus", ["-c:a", "libopus", "-b:a", "128k"]),
10
+ "vorbis": (".ogg", ["-c:a", "libvorbis", "-q:a", "5"]),
11
+ "mp3": (".mp3", ["-c:a", "libmp3lame", "-b:a", "192k"]),
12
+ "flac": (".flac", None),
13
+ }
14
+ DEFAULT_AUDIO_FORMAT = "aac"
nnnotes/webmodel.py ADDED
@@ -0,0 +1,397 @@
1
+ """Live2D models -> the model part of an ournotes-player site (web.py builds the charts of the same site).
2
+
3
+ <site>/models.json model index: id, manifest path, key, listing facts, sizes
4
+ <site>/models/<id>.json model manifest: every path of the model's files -> {asset, size}, or, for a large
5
+ JSON object, {parts: [[key, asset, size], ...], size} (as a chart manifest)
6
+ <site>/assets/<sha256>.<ext> content-addressed files, shared with the charts (the models share their shaders)
7
+
8
+ The files of one model (the paths in its manifest), only those the player reads (read_files):
9
+
10
+ model.json index: format (MODEL_FORMAT), name, key, moc3, prefab, textures, canvas, shaders,
11
+ resources
12
+ <name>.moc3 the moc3 (as live2d.extract_runtime writes it)
13
+ <name>.prefab.json the whole prefab, clips, expressions and controllers inlined (the same)
14
+ textures/*.png the atlas pages the drawables draw with (the same)
15
+ shaders/shaders.json the shader index (shader.py layout, filtered as in the chart sites: web.collect),
16
+ shaders/<shader>.json the parsed forms and the GLES3 programs (GLSL ES 3.00) the drawables' materials
17
+ shaders/<shader>/gles3/*.glsl select by their keywords; the mask shader's only when a drawable is masked
18
+
19
+ `resources` in model.json: the materials the Cubism mask pass draws with (the Resources.Load paths of
20
+ advscene.RESOURCES, read from the APK's boot data), exported as the story scene exports them.
21
+
22
+ Models are the catalog keys Character/Live2D/<group>/<name>/model/<name>; a model's id is <name> (unique in the
23
+ catalog, URL-safe). Each model is exported into a temporary directory, stored, and the directory deleted; models run in
24
+ parallel worker processes (catalog cache writes serialized by a lock). A model whose manifest exists is skipped unless
25
+ `force`. Same inputs and library versions give byte-identical outputs. A model's files read no master data and the
26
+ regions serve the same catalog, so one build serves every region of a site (the bundles are fetched from the CDN of
27
+ `region`).
28
+
29
+ Names (optional): when master data is configured for `region`, the listing of each model the master data maps to a
30
+ character (model_names: MasterCharacterCostume -> MasterCharacter -> MasterText) gets `character`, `names` and
31
+ `label`; the manifests of skipped models get the same fields of this build. Without master data the fields are not
32
+ written (and skipped manifests keep theirs).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import multiprocessing as mp
38
+ import re
39
+ import shutil
40
+ import tempfile
41
+ import time
42
+ import traceback
43
+ from pathlib import Path
44
+
45
+ from . import advscene, jsonio, languages, live2d, master
46
+ from . import shader as shader_mod
47
+ from .config import Config, usable_cpus, use
48
+ from .export import Exporter
49
+ from .web import (MODELS_DIR, MODELS_INDEX, SHADER_PLATFORM, SHADER_TYPE, SITE_FORMAT, Store, _dump, _lock_fetches,
50
+ _log, check_player, collect, entry_assets, text_asset, write_index, write_player)
51
+
52
+ LIVE2D_PREFIX = "Character/Live2D/"
53
+ MODEL_KEY = re.compile(r"Character/Live2D/(?P<group>[^/]+)/(?P<name>[^/]+)/model/(?P=name)")
54
+ MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
55
+ MODEL_INDEX = "model.json"
56
+ MODEL_FORMAT = 1 # model.json "format"
57
+ SHADER_DIR = "shaders"
58
+ MASK_KEYWORD = "CUBISM_MASK_ON" # a drawable material's keyword: the drawable is masked
59
+ COSTUME_TABLE = "MasterCharacterCostume" # _live2dPath (the key after LIVE2D_PREFIX) -> _characterID
60
+ NAME_FIELDS = ("character", "names", "label") # listing fields from the master data (model_names)
61
+
62
+
63
+ # ---------------------------------------------------------------- models and ids
64
+ def model_id(key: str) -> str:
65
+ """The id of a model key: its <name>."""
66
+ m = MODEL_KEY.fullmatch(key)
67
+ if not m:
68
+ raise ValueError(f"{key}: not a Live2D model key (Character/Live2D/<group>/<name>/model/<name>)")
69
+ if not MODEL_ID.fullmatch(m["name"]):
70
+ raise ValueError(f"{key}: model name {m['name']!r} is not URL-safe")
71
+ return m["name"]
72
+
73
+
74
+ def model_keys(keys) -> dict[str, str]:
75
+ """{id: key} of the model keys among `keys`, in id order; two keys with the same id raise."""
76
+ out: dict[str, str] = {}
77
+ for k in keys:
78
+ if MODEL_KEY.fullmatch(k):
79
+ i = model_id(k)
80
+ if out.get(i, k) != k:
81
+ raise ValueError(f"model id {i}: keys {out[i]} and {k}")
82
+ out[i] = k
83
+ return dict(sorted(out.items()))
84
+
85
+
86
+ def select(specs, models: dict[str, str]) -> dict[str, str]:
87
+ """The models `specs` names (ids or keys; None: all) as {id: key} in id order, from `models` ({id: key})."""
88
+ if specs is None:
89
+ return dict(models)
90
+ by_key = {k: i for i, k in models.items()}
91
+ out, unknown = {}, []
92
+ for s in specs:
93
+ i = s if s in models else by_key.get(s)
94
+ if i is None:
95
+ unknown.append(s)
96
+ else:
97
+ out[i] = models[i]
98
+ if unknown:
99
+ raise ValueError(f"not a Live2D model of the catalog: {', '.join(unknown)} "
100
+ f"(give a model id or a key Character/Live2D/<group>/<name>/model/<name>)")
101
+ return dict(sorted(out.items()))
102
+
103
+
104
+ def catalog_models(cat, specs=None) -> dict[str, str]:
105
+ """`select` over the model keys of the catalog `cat`."""
106
+ return select(specs, model_keys(cat.keys(LIVE2D_PREFIX)))
107
+
108
+
109
+ # ---------------------------------------------------------------- names from the master data
110
+ def model_names(master_dir: Path, language: str | None = None) -> dict[str, dict]:
111
+ """{model key: {"character", "names", "label"}} of the models the master data maps to one character: every
112
+ MasterCharacterCostume row maps the key LIVE2D_PREFIX + `_live2dPath` to `_characterID` (the MasterCharacter id,
113
+ `character`); `names`: {language code: the MasterText of the character's `_nameTextID`}, the languages with a
114
+ text; `label`: the name in `language` (left out without `language` or a text in it). A key the rows map to two
115
+ characters, or a character without a name text, is left out."""
116
+ chars_of: dict[str, set] = {}
117
+ for r in master.table(master_dir, COSTUME_TABLE):
118
+ if r.get("_live2dPath") and r.get("_characterID") is not None:
119
+ chars_of.setdefault(LIVE2D_PREFIX + r["_live2dPath"], set()).add(r["_characterID"])
120
+ chars = {r["_id"]: r for r in master.table(master_dir, "MasterCharacter")}
121
+ texts = {r["_id"]: r for r in master.table(master_dir, "MasterText")}
122
+ out = {}
123
+ for key, ids in sorted(chars_of.items()):
124
+ if len(ids) != 1:
125
+ continue
126
+ (cid,) = ids
127
+ row = texts.get((chars.get(cid) or {}).get("_nameTextID"))
128
+ names = {c: t for c, t in languages.texts(row).items() if isinstance(t, str) and t} if row else {}
129
+ if not names:
130
+ continue
131
+ entry = {"character": cid, "names": names}
132
+ if language in names:
133
+ entry["label"] = names[language]
134
+ out[key] = entry
135
+ return out
136
+
137
+
138
+ def master_names(cfg: Config, region: str | None = None, log=None) -> dict[str, dict] | None:
139
+ """model_names of the master data of `region` (default: [catalog] region; the --master flag, else
140
+ [servers.<region>] master, else [paths] master), labels in [catalog] language; None when no master data is
141
+ configured or it lacks a table model_names reads (logged)."""
142
+ section, key = cfg.master(region or cfg.get("catalog", "region"))
143
+ if cfg.path(section, key) is None:
144
+ return None
145
+ from .cli import master_dir
146
+ code = cfg.get("catalog", "language")
147
+ try:
148
+ return model_names(master_dir(cfg, region), languages.check(code) if code else None)
149
+ except FileNotFoundError as e:
150
+ (log or _log)(f"model names left out: {Path(e.filename).name if e.filename else e} not in the master data")
151
+ return None
152
+
153
+
154
+ def refresh_names(site: Path, mid: str, entry: dict | None) -> bool:
155
+ """Set the NAME_FIELDS of the model manifest of `mid` to those of `entry` (None: none); True when it changed."""
156
+ p = Path(site) / MODELS_DIR / f"{mid}.json"
157
+ man = json.loads(p.read_text(encoding="utf-8"))
158
+ model = {k: v for k, v in man.get("model", {}).items() if k not in NAME_FIELDS}
159
+ model.update({k: entry[k] for k in NAME_FIELDS if entry and k in entry})
160
+ if model == man.get("model"):
161
+ return False
162
+ man["model"] = model
163
+ p.write_bytes(_dump(man))
164
+ return True
165
+
166
+
167
+ # ---------------------------------------------------------------- one model
168
+ def shader_names(doc) -> set[str]:
169
+ """Names of the shaders an export references (its {"shader": <name>} objects)."""
170
+ out: set[str] = set()
171
+ stack = [doc]
172
+ while stack:
173
+ v = stack.pop()
174
+ if isinstance(v, dict):
175
+ s = v.get("shader")
176
+ if isinstance(s, str) and len(v) == 1:
177
+ out.add(s)
178
+ stack.extend(v.values())
179
+ elif isinstance(v, list):
180
+ stack.extend(v)
181
+ return out
182
+
183
+
184
+ def drawable_materials(prefab: dict) -> list[dict]:
185
+ """The material of every drawable: MeshRenderer.m_Materials[0] of the prefab's nodes."""
186
+ return [c["m_Materials"][0] for n in prefab["nodes"] for c in n["components"]
187
+ if c.get("type") == "MeshRenderer" and c.get("m_Materials") and c["m_Materials"][0]]
188
+
189
+
190
+ def drawable_textures(prefab: dict) -> list[str]:
191
+ """The atlas pages the drawables draw with (every distinct CubismRenderer._mainTexture), sorted."""
192
+ return sorted({c["_mainTexture"]["texture"] for n in prefab["nodes"] for c in n["components"]
193
+ if c.get("class") == "CubismRenderer" and c.get("_mainTexture")})
194
+
195
+
196
+ def shader_files(index: list, materials: list[dict]) -> list[str]:
197
+ """The files of a shader directory (paths relative to it) that `materials` draw with: per shader its parsed file
198
+ and, for each distinct keyword set of its materials, the GLES3 program of subshader 0 pass 0 whose keywords are
199
+ exactly the material's keywords among those the shader's programs there use (a global keyword such as
200
+ _ADDITIONAL_LIGHTS is never in a material's set, so its variants are never picked)."""
201
+ recs = {r["name"]: r for r in index}
202
+ files = set()
203
+ for name, kws in sorted({(m["shader"]["shader"], tuple(m["keywords"])) for m in materials}):
204
+ if name not in recs:
205
+ raise RuntimeError(f"shader {name}: not in the shader index")
206
+ variants = [v for v in recs[name]["variants"] if v["platform"] == SHADER_PLATFORM and v["type"] == SHADER_TYPE
207
+ and v["subShader"] == 0 and v["pass"] == 0]
208
+ want = set(kws) & {k for v in variants for k in v["keywords"]}
209
+ hits = [v for v in variants if set(v["keywords"]) == want]
210
+ if len(hits) != 1:
211
+ raise RuntimeError(f"shader {name}: {len(hits)} GLES3 programs with the keywords {sorted(want)}")
212
+ files |= {recs[name]["parsed"], hits[0]["file"]}
213
+ return sorted(files)
214
+
215
+
216
+ def read_files(doc: dict, prefab: dict, index: list) -> list[str]:
217
+ """The files of a model the player reads (model.json `doc`, its prefab, its shader index): model.json, the moc3,
218
+ the prefab, the drawables' atlas pages, the shader index and the shader files of the drawables' materials, plus
219
+ those of the mask materials when a drawable is masked (MASK_KEYWORD)."""
220
+ materials = drawable_materials(prefab)
221
+ if any(MASK_KEYWORD in m["keywords"] for m in materials):
222
+ materials += list(doc["resources"].values())
223
+ sdir = doc["shaders"].rpartition("/")[0]
224
+ return sorted({MODEL_INDEX, doc["moc3"], doc["prefab"], *doc["textures"], doc["shaders"],
225
+ *(f"{sdir}/{f}" if sdir else f for f in shader_files(index, materials))})
226
+
227
+
228
+ def export_model(cat, player, key: str, out_dir: Path) -> dict:
229
+ """The files of one model (module docstring) into `out_dir`; returns the runtime export summary with `canvas`,
230
+ `shaders` (names) and `files` (read_files: the paths to store)."""
231
+ out_dir = Path(out_dir)
232
+ rt = live2d.export_runtime(cat, key, out_dir)
233
+ res, ex = rt.summary, rt.exporter
234
+ prefab = json.loads((out_dir / res["prefab"]).read_text(encoding="utf-8"))
235
+ rex = Exporter(cat, out_dir, player=player)
236
+ resources = {name: rex.material(player.resource(path)) for name, path in advscene.RESOURCES.items()}
237
+ objs = {**rex.shaders, **ex.shaders}
238
+ names = sorted(shader_names(prefab) | shader_names(resources))
239
+ missing = [n for n in names if n not in objs]
240
+ if missing:
241
+ raise RuntimeError(f"{key}: shaders neither in the model's bundles nor in the player data: {missing}")
242
+ index: list = []
243
+ for n in names:
244
+ shader_mod.dump_objects([objs[n]], out_dir / SHADER_DIR, objs[n].assets_file.name, index)
245
+ shader_mod.write_index(index, out_dir / SHADER_DIR)
246
+ doc = {"format": MODEL_FORMAT, "name": res["name"], "key": key, "moc3": res["moc3"], "prefab": res["prefab"],
247
+ "textures": drawable_textures(prefab), "canvas": prefab["canvas"], "shaders": f"{SHADER_DIR}/shaders.json",
248
+ "resources": resources}
249
+ jsonio.write_json(out_dir / MODEL_INDEX, doc)
250
+ return {**res, "textures": doc["textures"], "canvas": prefab["canvas"], "shaders": names,
251
+ "files": read_files(doc, prefab, index)}
252
+
253
+
254
+ def model_facts(key: str, summary: dict, names: dict | None = None) -> dict:
255
+ """What a model listing needs, from the key and the export summary, plus the NAME_FIELDS of `names` (an entry
256
+ of model_names)."""
257
+ facts = {"group": MODEL_KEY.fullmatch(key)["group"], "canvas": summary["canvas"],
258
+ "textures": len(summary["textures"]), "nodes": summary["nodes"]}
259
+ facts.update({k: names[k] for k in NAME_FIELDS if names and k in names})
260
+ return facts
261
+
262
+
263
+ def ingest(store: Store, site: Path, mid: str, key: str, model_dir: Path, summary: dict,
264
+ names: dict | None = None) -> dict:
265
+ """One exported model's files (summary["files"]) into the store + its manifest (`names`: model_facts)."""
266
+ text, binary = collect(Path(model_dir), summary["files"])
267
+ entries = {p: store.put_file(p, text_asset(p, s)) for p, s in text.items()}
268
+ entries.update({p: store.put(p, b) for p, b in binary.items()})
269
+ manifest = {"format": SITE_FORMAT, "id": mid, "key": key, "model": model_facts(key, summary, names),
270
+ "files": dict(sorted(entries.items()))}
271
+ (Path(site) / MODELS_DIR / f"{mid}.json").write_bytes(_dump(manifest))
272
+ return {"id": mid, "ok": True, "files": len(entries), "bytes": sum(e["size"] for e in entries.values())}
273
+
274
+
275
+ # ---------------------------------------------------------------- index
276
+ def write_models_index(site: Path) -> tuple[int, set[str]]:
277
+ """site/models.json from every model manifest present (no models directory: no models.json); returns the number
278
+ of models and the assets their manifests reference."""
279
+ site = Path(site)
280
+ mdir = site / MODELS_DIR
281
+ if not mdir.is_dir():
282
+ (site / MODELS_INDEX).unlink(missing_ok=True)
283
+ return 0, set()
284
+ models, used = [], set()
285
+ for p in sorted(mdir.glob("*.json")):
286
+ man = json.loads(p.read_text(encoding="utf-8"))
287
+ for e in man["files"].values():
288
+ used.update(entry_assets(e))
289
+ models.append({"id": p.stem, "manifest": f"{MODELS_DIR}/{p.name}", "key": man["key"],
290
+ "files": len(man["files"]),
291
+ "bytes": sum(f["size"] for f in man["files"].values()), **man["model"]})
292
+ (site / MODELS_INDEX).write_bytes(_dump({"format": SITE_FORMAT, "models": models}))
293
+ return len(models), used
294
+
295
+
296
+ # ---------------------------------------------------------------- per model (in a worker or in this process)
297
+ _W: dict = {}
298
+
299
+
300
+ def _open(cfg: Config, region: str | None = None):
301
+ from .cli import open_catalog, player_data
302
+ return open_catalog(cfg, region=region), player_data(cfg)
303
+
304
+
305
+ def _worker_init(cfg: Config, lock, job: dict) -> None:
306
+ use(cfg)
307
+ cat, player = _open(cfg, job.get("region"))
308
+ _lock_fetches(cat, lock)
309
+ _W.update(cat=cat, player=player, job=job)
310
+
311
+
312
+ def model_task(mid: str, key: str, job: dict | None = None, data=None) -> dict:
313
+ """One model: export into a temporary directory, ingest; the directory is deleted."""
314
+ cat, player = data or (_W["cat"], _W["player"])
315
+ job = job or _W["job"]
316
+ site = Path(job["site"])
317
+ work = Path(tempfile.mkdtemp(prefix=f"{mid}-", dir=job["tmp"]))
318
+ t0, stage = time.time(), "export"
319
+ try:
320
+ summary = export_model(cat, player, key, work)
321
+ stage = "ingest"
322
+ r = ingest(Store(site), site, mid, key, work, summary, (job.get("names") or {}).get(key))
323
+ return {**r, "seconds": round(time.time() - t0, 1)}
324
+ except Exception as e:
325
+ cause = f"{type(e).__name__}: {e}"
326
+ _log(f"{mid}: {stage} failed: {cause[:300]}")
327
+ return {"id": mid, "ok": False, "stage": stage, "error": cause[:2000], "trace": traceback.format_exc()[-3000:]}
328
+ finally:
329
+ shutil.rmtree(work, ignore_errors=True)
330
+
331
+
332
+ def _pool_task(args):
333
+ return model_task(*args)
334
+
335
+
336
+ # ---------------------------------------------------------------- build
337
+ def build(out_dir, models: dict[str, str] | None, cfg: Config, player_dir, force: bool = False, *, tmp_dir=None,
338
+ log=None, workers: int | None = None, region: str | None = None) -> dict:
339
+ """Add the Live2D models `models` ({id: key}, see `catalog_models`; None: every model of the catalog) to the site
340
+ at `out_dir`, with the player of the ournotes-player checkout or package at `player_dir` (its page files are
341
+ written, models.json and charts.json rebuilt). The data comes from the settings `cfg` (each worker process opens
342
+ its own), bundles from the CDN of `region` (default: [catalog] region), names from the master data of `region`
343
+ when it is configured (master_names). `workers`: parallel model processes (default up to 4)."""
344
+ player_dir = check_player(player_dir)
345
+ site = Path(out_dir).resolve()
346
+ (site / MODELS_DIR).mkdir(parents=True, exist_ok=True)
347
+ Store(site)
348
+ tmp_root = Path(tmp_dir).resolve() if tmp_dir else site.parent / f"{site.name}.tmp"
349
+ tmp_root.mkdir(parents=True, exist_ok=True)
350
+ log = log or _log
351
+ t0 = time.time()
352
+ if models is None:
353
+ from .cli import open_catalog
354
+ models = catalog_models(open_catalog(cfg, bundles=False, region=region))
355
+ todo, skipped = [], []
356
+ for mid, key in sorted(models.items()):
357
+ if model_id(key) != mid:
358
+ raise ValueError(f"model {mid}: key {key} has the id {model_id(key)}")
359
+ if (site / MODELS_DIR / f"{mid}.json").exists() and not force:
360
+ skipped.append(mid)
361
+ else:
362
+ todo.append((mid, key))
363
+ if workers is None:
364
+ workers = max(1, min(4, len(todo), usable_cpus() // 2))
365
+ names = master_names(cfg, region, log)
366
+ if names is not None:
367
+ for mid in skipped:
368
+ refresh_names(site, mid, names.get(models[mid]))
369
+ job = {"site": str(site), "tmp": str(tmp_root), "region": region, "names": names or {}}
370
+ results = []
371
+ if todo:
372
+ log(f"{len(todo)} models, {workers} worker(s)")
373
+ if workers <= 1:
374
+ data = _open(cfg) if region is None else _open(cfg, region)
375
+ for mid, key in todo:
376
+ results.append(model_task(mid, key, job, data=data))
377
+ else:
378
+ ctx = mp.get_context("spawn")
379
+ with ctx.Manager() as mgr:
380
+ lock = mgr.Lock()
381
+ with ctx.Pool(workers, initializer=_worker_init, initargs=(cfg, lock, job)) as pool:
382
+ for r in pool.imap_unordered(_pool_task, [(mid, key) for mid, key in todo]):
383
+ results.append(r)
384
+ if len(results) % 20 == 0:
385
+ log(f"{len(results)}/{len(todo)} models")
386
+ v = write_player(site, player_dir)
387
+ idx = write_index(site)
388
+ results.sort(key=lambda r: r["id"])
389
+ failed = [r for r in results if not r["ok"]]
390
+ if failed:
391
+ (site.parent / f"{site.name}.model-failures.json").write_bytes(_dump(failed))
392
+ return {"site": str(site),
393
+ "modelsBuilt": [{k: r[k] for k in ("id", "files", "bytes")} for r in results if r["ok"]],
394
+ "modelsFailed": [{k: r.get(k) for k in ("id", "stage", "error")} for r in failed],
395
+ "modelsSkipped": skipped, "modelSeconds": round(time.time() - t0, 1), "modelWorkers": workers,
396
+ "modelNames": None if names is None else sum(1 for k in models.values() if k in names),
397
+ **v, **idx}
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: nnnotes
3
+ Version: 0.1.0
4
+ Summary: Offline data toolkit for BanG Dream! Our Notes game files: catalogs, bundles, master data, stories, Live2D, audio and live charts
5
+ Author: MetaMiku, emptysekai
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/MetaSekaiLab/nnnotes
8
+ Project-URL: Source, https://github.com/MetaSekaiLab/nnnotes
9
+ Project-URL: Issues, https://github.com/MetaSekaiLab/nnnotes/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Multimedia
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: UnityPy>=1.25
17
+ Requires-Dist: numpy>=2.0
18
+ Requires-Dist: Pillow>=10.0
19
+ Requires-Dist: pycryptodome>=3.20
20
+ Requires-Dist: grpcio>=1.49.1
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+ Requires-Dist: pyflakes>=3.2; extra == "test"
24
+ Requires-Dist: jsonschema>=4.18; extra == "test"
25
+ Provides-Extra: fonts
26
+ Requires-Dist: fonttools>=4.50; extra == "fonts"
27
+ Requires-Dist: freetype-py>=2.5; extra == "fonts"
28
+ Requires-Dist: scipy>=1.13; extra == "fonts"
29
+ Dynamic: license-file
30
+
31
+ # nnnotes?!
32
+
33
+ [简体中文](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.md) | [English](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.en.md)
34
+
35
+ nnnotes is an offline data toolkit for the game files of BanG Dream! Our Notes: it reads Addressables catalogs,
36
+ downloads and decrypts asset bundles, downloads and decodes master data, and exports stories, Live2D models, spots,
37
+ shaders, CRI audio and live charts as structured JSON and common file formats. The chart export is the data that
38
+ [ournotes-player](https://github.com/empty-sekai/ournotes-player) reads. The naming was inspired by [mos9527/sssekai](https://github.com/mos9527/sssekai).
39
+
40
+ This is an unofficial fan project, not affiliated with the game's developer or operator. The repository contains no
41
+ game assets, keys or server addresses: users supply the game files, the keys needed for decryption and the server
42
+ addresses in their own configuration, and the exports stay in local directories the user chooses.
43
+
44
+ ## Features
45
+
46
+ | Command | Input | Output |
47
+ |---|---|---|
48
+ | `catalog` | the region's catalog | addressable keys (optionally by prefix) |
49
+ | `browse` | the configured regions' catalogs | a local web page to browse catalogs and bundles (`--host` / `--port`) |
50
+ | `pull` | addressable keys | every bundle of each key's dependency closure, decrypted into the local cache |
51
+ | `servers` | the bootstrap API root `[bootstrap] api` | the game API's server list: each region's name, area id and CDN / API roots (the roots only with `--show-hosts`) |
52
+ | `master version` | region | the master data version and resource version the region serves now (anonymous game API call) |
53
+ | `master download` | master data version, or `--latest` (the region's current one) | that version's `MasterManifest.json` and every `.bin` file (SHA-256 checked) |
54
+ | `master decode` | master data `.bin` files or directories | one JSON per table (Rijndael-256 CBC decryption + gzip) |
55
+ | `adv` | episode ID | `episode.json`: command list, lines in five languages, voice / sound / video index |
56
+ | `story` | episode ID | a full story directory: episode, every Live2D model, audio, stage scene and shaders, story UI, frames / particle effects / post effects / stills / talk windows / chat assets, videos (WebM) |
57
+ | `live2d` | model key or model id | a Live2D (Cubism) runtime directory: moc3, textures, motion3, physics, expressions, prefab parameters |
58
+ | `spot` | spot ID | `spot.json` + Spine characters + the room as `room.glb` + shaders |
59
+ | `room` | background prefab key | the room model (binary glTF) |
60
+ | `shader` | key or APK bundles | every platform variant of the shaders (GLSL ES and others) with an index |
61
+ | `audio` | CRI cue sheet | one audio file per cue (FLAC / Ogg / WAV) + cue metadata |
62
+ | `crikey` | APK | the CRI HCA keycode from the game's boot data (shows whether it was found; can write a `.hcakey`) |
63
+ | `player` | APK | render-related global settings (color space, quality levels, renderers) as JSON |
64
+ | `live` | music ID + difficulty | a full chart directory: chart and runtime notes, 3D scene, note and effect assets, BGM and sounds, sound routing |
65
+ | `web` | `--pair music:difficulty` (repeatable) or `--all`; `--live2d model` (repeatable) or `--all-live2d`; `--story episode` (repeatable) or `--all-stories`; `--region region` (repeatable) or `--all-regions` | an ournotes-player static site: shared player + per-chart / per-model / per-episode manifests + content-addressed assets; one site can serve several regions, with listing texts in five languages; a story's interface texts are grouped by language, with TextMeshPro font assets generated from open fonts (the game's fonts with `--fonts game`) |
66
+ | `deck-data` | master data files (`--master-files` directory or `--apk-master`) | one JSON file for deck-building tools: every chart's runtime notes, skill events and fever ranges, and the master data tables about cards, skills, bonuses, scores and events ([format](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/deck-data.md)) |
67
+
68
+ Export conventions:
69
+
70
+ - Commands that write files take the output path with `-o` (required); `web` takes the site directory as a
71
+ positional argument.
72
+ - JSON is always UTF-8 with LF line endings; infinity is written as `1e999`.
73
+ - Exports are deterministic for a given installation: the same input with the same versions of nnnotes, its Python
74
+ dependencies (UnityPy, Pillow, numpy, ...) and the external tools (vgmstream, FFmpeg) gives byte-identical files.
75
+ Other versions can encode the same content into other bytes: PNG files written by another Pillow version can
76
+ differ in their bytes while their pixels are equal.
77
+ - Values keep Unity's serialized values and field names (`m_LocalPosition`, `_bandIDs`, ...), so they can be
78
+ compared with the game data.
79
+ - Textures are exported as PNG, shaders keep the game's own compiled programs, audio is decoded from the CRI formats
80
+ to common formats.
81
+
82
+ ## Status
83
+
84
+ Based on all data of the Taiwan server, version 1.0.1 (zh-Hant):
85
+
86
+ | Part | Status |
87
+ |---|---|
88
+ | catalog / bundle decryption / dependency closure | working |
89
+ | master data decoding | working |
90
+ | stories, `adv` | 946 / 946 episodes |
91
+ | stories, `story` (full directory) | 946 / 946 episodes have all their resources in the catalog and of supported kinds (the resource closure equals the game's own per-episode download list); 759 exported one by one, the other 187 (with frames, effects, post effects, stills and the like) not yet one by one |
92
+ | Live2D models | 239 / 239 (every model of the catalog; episodes use 185 of them) |
93
+ | CRI audio | 681 / 681 cue sheets |
94
+ | charts, `live` | 336 / 336 (music, difficulty) pairs |
95
+ | web site, `web` | 336 / 336 charts, 239 / 239 Live2D models, 946 / 946 story episodes (two full builds in English without audio are byte-identical and 946 / 946 pass the data validation; a 27-episode sample in five languages with AAC audio is likewise identical across two builds and valid) |
96
+ | spots, `spot` / `room` | one spot verified, the others not individually checked |
97
+ | other regions (en / kr) and languages | checked: the regions serve the same catalog for a language and the same bundles, and the keys are shared; the master tables the charts use are the same in the three regions, and the text tables have all five languages; the chart exports checked match the Taiwan server's. A full multi-region site build is not verified yet |
98
+
99
+ ## Requirements
100
+
101
+ - Python 3.11+, 3.13 recommended (a `web` build reads and writes a lot of JSON, and the standard library encodes
102
+ JSON faster on 3.13); `pip install nnnotes` (for development `pip install -e .` in the repository)
103
+ - the game's `base.apk`: APK-local bundles, the CRI keycode, boot settings. `player`, `story`, `live` and `web` read
104
+ MonoBehaviours of its boot data with type trees that ship with nnnotes; supported now: game version 1.0.1
105
+ (Unity 6000.3.12f1). With an APK of another version whose classes do not match, these commands stop with an
106
+ error naming the class, the game version and the Unity version
107
+ - a decoded master data directory (from `master download` + `master decode`): needed by `adv`, `story`, `spot`,
108
+ `live` and the charts of `web`; the Live2D models of `web` use it only for their character names (optional)
109
+ - external tools: [vgmstream](https://vgmstream.org/) (CRI HCA decoding), [FFmpeg](https://ffmpeg.org/)
110
+ (transcoding, including the WebM muxing and Opus audio of story videos); `web` also needs Node.js 20+ and a built
111
+ ournotes-player
112
+
113
+ ## Configuration
114
+
115
+ The code contains no keys, server addresses or default paths. Settings are read in this order, later sources
116
+ overriding earlier ones:
117
+
118
+ 1. config file: `--config <file>`, else `NNNOTES_CONFIG`, else `nnnotes.toml` in the working directory
119
+ 2. environment variables: `NNNOTES_<SECTION>_<KEY>` (e.g. `NNNOTES_BUNDLE_KEY`, `NNNOTES_SERVERS_TW_CDN`)
120
+ 3. command-line flags: `--region`, `--language`, `--catalog`, `--cache`, `--master`, `--apk`, `--ffmpeg`,
121
+ `--vgmstream`, `--node` go before the command name; `--player` is an option of `web`
122
+
123
+ Copy [`nnnotes.example.toml`](https://github.com/MetaSekaiLab/nnnotes/blob/main/nnnotes.example.toml) to `nnnotes.toml` and fill it in. The settings are: the bundle
124
+ key and nonce seed, the master data key and IV, the region in use (`[catalog] region`) and the catalog language
125
+ (`[catalog] language`), the CDN base and API root of each region (`cdn`, `api` of `[servers.<region>]`), the client
126
+ version (`[client] version`; unset: the APK's versionName), optionally the bootstrap API root (`[bootstrap] api`, for
127
+ `servers`), and the paths of the cache, the APK, the master data directory, ournotes-player and vgmstream / FFmpeg /
128
+ Node.js (the three tools are looked up on `PATH` when unset). All values come from the game client you own.
129
+
130
+ A missing or malformed setting stops the command with exit status 2 and one line naming the TOML key, the
131
+ environment variable and the flag; no setting value is printed. `nnnotes.toml` is in `.gitignore`; do not commit it.
132
+
133
+ See [docs/configuration.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/configuration.md) for the full reference.
134
+
135
+ ## Examples
136
+
137
+ ```bash
138
+ nnnotes catalog --prefix Live/MusicScore/ --limit 20
139
+ nnnotes browse --port 8000
140
+ nnnotes pull Live/MusicScore/0001/0001_03
141
+ nnnotes master version
142
+ nnnotes master download --latest -o work/master-bin
143
+ nnnotes master decode work/master-bin -o work/master
144
+ nnnotes --master work/master adv 10462 -o out/adv_10462.json
145
+ nnnotes story 10462 -o out/story_10462
146
+ nnnotes live 100001 --difficulty expert -o out/live_100001
147
+ nnnotes web out/site --all --player <ournotes-player dir> --workers 5
148
+ nnnotes web out/site --pair 100001:expert --pair 100001:hard --player <ournotes-player dir>
149
+ nnnotes web out/site --all --region tw --region en --region kr --player <ournotes-player dir>
150
+ nnnotes web out/site --live2d adv_live2d_rana_003_casual_spring_01 --player <ournotes-player dir>
151
+ ```
152
+
153
+ `web` builds incrementally: charts and models whose manifest exists are skipped (`--force` rebuilds them) and assets
154
+ no longer referenced are removed. Common options:
155
+
156
+ - `--format aac|opus|vorbis|mp3|flac`: BGM format, default AAC; `--no-audio`: no audio files
157
+ - `--band` / `--leader-card`: the band of the LightWeight background and the start timeline; default: the band of
158
+ the music's first vocal character
159
+ - `--workers`: parallel music processes (default a quarter of the CPUs, up to 8) and model processes (default up
160
+ to 4); `--read-workers`: chart read sets at a time (Node.js processes; default half the CPUs, up to 16); `--tmp`:
161
+ temporary build directory (default `<site>.tmp`)
162
+ - `--live2d MODEL` (model id or key, repeatable) / `--all-live2d`: add Live2D models (every model of the catalog);
163
+ charts and models can be added in the same run
164
+ - `--region REGION` (repeatable) / `--all-regions`: the regions the site serves (default: `[catalog] region`);
165
+ each region's master data is `[servers.<region>] master`. Regions with the same chart data share one manifest;
166
+ the chart list page switches region and language with `?region=&lang=`
167
+ - `--player-only`: rewrite the player files, `charts.json` and `models.json` only; `--reingest-json`: store every
168
+ chart's and model's JSON files again under the current rules
169
+
170
+ See [docs/commands.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/commands.md) for every command's options and output layout.
171
+
172
+ ## Architecture
173
+
174
+ ```
175
+ settings (TOML / environment / flags)
176
+ └─ access addressables (catalog parsing, bundle decryption, local browser)
177
+ catalog (dependency closures, remote and APK bundles, local cache)
178
+ master (master data download and decoding)
179
+ gameapi (anonymous game API calls: current master data version, server list)
180
+ └─ reading unity (UnityPy type trees and TextAssets)
181
+ export (prefabs / components / references resolved to JSON, textures and shaders alongside)
182
+ shader, textstyle (text layout and style), tmpfont (TextMesh Pro fonts), player (boot settings), cri + crikey (CRI audio)
183
+ └─ content stories: adv, advscene, advmedia (frames / effects / post effects / stills / chat),
184
+ advvideo (USM videos), advui, story
185
+ Live2D: live2d, motion
186
+ spots: spot, room
187
+ charts: score (chart parsing and a reimplementation of the game's chart converter),
188
+ livescene, livenotes, liveui, liveaudio, live
189
+ site: web, webmodel (ournotes-player data)
190
+ ```
191
+
192
+ Every JSON file is written by one writer (`jsonio`), so encoding, line endings and number formatting are the same
193
+ everywhere.
194
+
195
+ See [docs/performance.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/performance.md) for where a
196
+ `web` build spends its time and which parts of it run in compiled code.
197
+
198
+ ## License
199
+
200
+ MIT, see [LICENSE](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE).