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/link.py ADDED
@@ -0,0 +1,418 @@
1
+ """The link stages: tables that connect catalog keys, bundles, serialized files, scripts and objects.
2
+
3
+ link.scripts (nnnotes.scripts/1), one global task: every MonoScript of the censuses, by object id:
4
+ {"scripts": {"CAB-...:pathId": "assembly|namespace|class"}} (the assembly without `.dll`; an empty namespace
5
+ stays empty). A MonoBehaviour's class is the entry of its m_Script; `script_context` picks the entries a task
6
+ consumes.
7
+
8
+ link.addresses (nnnotes.addresses/1), one task per catalog pair: the catalog index and the censuses of its bundles
9
+ (by stable bundle name) ->
10
+ bundles {stable name: {name, files, assetBundleName, keys}}: the file name in this catalog version, its
11
+ serialized files, the AssetBundle name the census found and the keys located in it
12
+ files {serialized file: stable bundle name} (CAB -> bundle)
13
+ keys {primary key: [{location, type, internalId, bundle | raw, objects}]}: an asset location's bundle is the
14
+ dependency whose container holds its internal id (compared ignoring case); `objects` the object ids
15
+ the container path names (null when a dependency bundle has no census)
16
+ objects {stable address: {object, class, name, byteSize, typeHash}} of the objects a container path names
17
+ problems files named by two bundles, bundles whose census AssetBundle name is not the catalog's, keys whose
18
+ internal id no container holds
19
+
20
+ A stable address (contract.stable_address) finds an object again in another catalog version, where its object id
21
+ has changed with its bundle's content: "<bundle>/<container>" for the object that owns a container path (the class
22
+ its extension names, else the first by file and path id: the object the `original` layout puts at the path),
23
+ "<bundle>/<container>[<name>]" for another object under it with a name no other object under it has, else
24
+ "<bundle>:<pathId>". In a bundle of several serialized files (their path ids may coincide) the path id form names
25
+ the file after the bundle: the part of the file name after its CAB-<hex> prefix ("scene_x.sharedAssets:12").
26
+ Objects that no container path names are not listed; `address_of` gives any object's address.
27
+
28
+ link.artifacts (nnnotes.artifacts/1), one global task: the results of the stages that export objects (unity.export,
29
+ sprite.crop) -> where each object went:
30
+ objects {object id: [artifact ids]} of the objects with artifacts (exported, generic, unsupported with some)
31
+ contained {object id: the artifact it is part of}
32
+ artifacts {artifact id: {sha256, size, ext}}
33
+ Its context names the results it reads ({"results": {task id: key}}); results are functions of their keys, so the
34
+ table is a function of its key. With a layout manifest (its entries' ids) it gives every object's files. The task
35
+ reads the results one at a time and writes the table's text entry by entry (encode_artifacts), so its memory is the
36
+ table's entries, not the results nor the whole text.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+
43
+ from . import catalogdb, census, contract
44
+ from .contract import Cost, Input
45
+ from .layout import EXT_CLASSES
46
+ from .stages import Output, Pending, Stage
47
+
48
+ SCRIPTS = "nnnotes.scripts/1"
49
+ ADDRESSES = "nnnotes.addresses/1"
50
+ ARTIFACTS = "nnnotes.artifacts/1"
51
+ EXPORT_STAGES = ("unity.export", "sprite.crop")
52
+ OBJECT_FIELDS = ("object", "class", "name", "byteSize", "typeHash")
53
+ _CAB = re.compile(r"^CAB-[0-9a-f]+")
54
+
55
+
56
+ # ---------------------------------------------------------------- scripts
57
+ def script_entry(s: dict) -> str:
58
+ """"assembly|namespace|class" of a census MonoScript record."""
59
+ asm = s.get("assembly") or ""
60
+ if asm.endswith(".dll"):
61
+ asm = asm[:-4]
62
+ return f"{asm}|{s.get('namespace') or ''}|{s.get('class') or ''}"
63
+
64
+
65
+ def scripts(censuses) -> dict:
66
+ """The script table (nnnotes.scripts/1) of census documents."""
67
+ table = {}
68
+ for doc in censuses:
69
+ for s in doc["scripts"]:
70
+ table[contract.object_id(s["file"], s["pathId"])] = script_entry(s)
71
+ return {"schema": SCRIPTS, "scripts": dict(sorted(table.items()))}
72
+
73
+
74
+ def script_context(table: dict, refs) -> dict:
75
+ """The entries of a script table (the "scripts" of nnnotes.scripts/1) for the MonoScript object ids `refs`
76
+ (census.script_refs of a bundle): what a task that resolves those scripts consumes. Refs the table lacks are
77
+ left out."""
78
+ return {r: table[r] for r in sorted(set(refs)) if r in table}
79
+
80
+
81
+ # ---------------------------------------------------------------- addresses
82
+ def file_tags(files) -> dict[str, str]:
83
+ """{serialized file: the tag its path id addresses carry after the bundle name}: "" in a bundle of one file,
84
+ else the file name after its CAB-<hex> prefix ("" / ".sharedAssets"); "|<file>" where those coincide."""
85
+ files = list(files)
86
+ if len(files) <= 1:
87
+ return {f: "" for f in files}
88
+ tags = {f: _CAB.sub("", f) for f in files}
89
+ if len(set(tags.values())) < len(tags):
90
+ tags = {f: "|" + f for f in files}
91
+ return tags
92
+
93
+
94
+ def _path_address(stable: str, tags: dict, file: str, pid: int) -> str:
95
+ tag = tags.get(file, "|" + file) if tags else ""
96
+ return contract.stable_address(stable + tag, path_id=pid)
97
+
98
+
99
+ def _container_addresses(stable: str, entries: list[dict], info: dict, tags: dict) -> list[tuple[str, dict]]:
100
+ """[(address, container entry)] of the objects one container path names in a bundle."""
101
+ members = [e for e in entries if e["file"] is not None]
102
+ if not members:
103
+ return []
104
+ path = min(e["path"] for e in members)
105
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path.rsplit("/", 1)[-1] else ""
106
+ classes = EXT_CLASSES.get(ext, ())
107
+
108
+ def cls(e):
109
+ return (info.get((e["file"], e["pathId"])) or {}).get("class")
110
+
111
+ order = sorted({(e["file"], e["pathId"]): e for e in members}.values(),
112
+ key=lambda e: (cls(e) not in classes, e["file"], e["pathId"]))
113
+ out = [(contract.stable_address(stable, path), order[0])]
114
+ names: dict[str, list[dict]] = {}
115
+ for e in order[1:]:
116
+ name = (info.get((e["file"], e["pathId"])) or {}).get("name") or ""
117
+ names.setdefault(name.casefold(), []).append(e)
118
+ for folded, same in sorted(names.items()):
119
+ for e in same:
120
+ name = (info.get((e["file"], e["pathId"])) or {}).get("name") or ""
121
+ if name and len(same) == 1:
122
+ out.append((contract.stable_address(stable, path, name=name), e))
123
+ else:
124
+ out.append((_path_address(stable, tags, e["file"], e["pathId"]), e))
125
+ return out
126
+
127
+
128
+ def addresses(index: dict, censuses) -> dict:
129
+ """The address table (nnnotes.addresses/1) of a catalog index and the censuses of its bundles: {stable bundle
130
+ name: census document}, or (stable name, census document) pairs (read one at a time: only the container
131
+ entries and the objects they name are kept). Censuses of names the index does not list are ignored."""
132
+ files_of = catalogdb.file_refs(index)
133
+ listed = {b["stable"]: b for b in index["bundles"]}
134
+ bundles, owners, info, groups, tags_of = {}, {}, {}, {}, {}
135
+ problems = {"files": [], "bundleNames": [], "keys": []}
136
+ pairs = sorted(censuses.items()) if isinstance(censuses, dict) else censuses
137
+ for stable, doc in pairs:
138
+ b = listed.get(stable)
139
+ if b is None:
140
+ continue
141
+ names = [f["name"] for f in doc["files"]]
142
+ for n in names:
143
+ owners.setdefault(n, set()).add(stable)
144
+ found = [a["assetBundleName"] for a in doc["assetBundles"] if a["assetBundleName"]]
145
+ if b.get("bundleName") and found and b["bundleName"] + ".bundle" not in found:
146
+ problems["bundleNames"].append({"bundle": stable, "catalog": b["bundleName"] + ".bundle",
147
+ "census": found})
148
+ g: dict[str, list[dict]] = {}
149
+ for e in census.container(doc):
150
+ g.setdefault(e["path"].casefold(), []).append(e)
151
+ wanted = {(e["file"], e["pathId"]) for es in g.values() for e in es if e["file"] is not None}
152
+ for file, o in census.objects(doc):
153
+ if (file, o["pathId"]) in wanted:
154
+ info[(file, o["pathId"])] = o
155
+ groups[stable], tags_of[stable] = g, file_tags(names)
156
+ bundles[stable] = {"name": b["name"], "files": names, "assetBundleName": found[0] if found else None,
157
+ "keys": set()}
158
+ cab = {n: min(s) for n, s in owners.items()}
159
+ problems["files"] = [{"file": n, "bundles": sorted(s)} for n, s in sorted(owners.items()) if len(s) > 1]
160
+ problems["bundleNames"].sort(key=lambda p: p["bundle"])
161
+ objects = {}
162
+ for stable, g in groups.items():
163
+ for _, entries in sorted(g.items()):
164
+ for addr, e in _container_addresses(stable, entries, info, tags_of[stable]):
165
+ o = info.get((e["file"], e["pathId"])) or {}
166
+ objects[addr] = {"object": contract.object_id(e["file"], e["pathId"]), "class": o.get("class"),
167
+ "name": o.get("name"), "byteSize": o.get("byteSize"), "typeHash": o.get("typeHash")}
168
+ keys: dict[str, list[dict]] = {}
169
+ for loc in index["locations"]:
170
+ entry = {"location": loc["id"], "type": loc["type"], "internalId": loc["internalId"]}
171
+ if loc["id"] in files_of:
172
+ kind, stable = files_of[loc["id"]]
173
+ entry[kind] = stable
174
+ if stable in bundles:
175
+ bundles[stable]["keys"].add(loc["primaryKey"])
176
+ else:
177
+ deps = [files_of[d] for d in loc["dependencies"] if d in files_of]
178
+ in_bundles = [s for k, s in deps if k == "bundle"]
179
+ raws = [s for k, s in deps if k == "raw"]
180
+ folded = loc["internalId"].casefold()
181
+ home = next((s for s in in_bundles if folded in groups.get(s, {})), None)
182
+ if home is not None:
183
+ entry["bundle"] = home
184
+ entry["objects"] = [contract.object_id(e["file"], e["pathId"]) for e in groups[home][folded]
185
+ if e["file"] is not None]
186
+ bundles[home]["keys"].add(loc["primaryKey"])
187
+ else:
188
+ entry["bundle"] = in_bundles[0] if in_bundles else None
189
+ known = in_bundles and all(s in groups for s in in_bundles)
190
+ entry["objects"] = [] if known else None
191
+ if known and loc["kind"] == "asset" and not raws:
192
+ problems["keys"].append(loc["primaryKey"])
193
+ if raws:
194
+ entry["raw"] = raws
195
+ keys.setdefault(loc["primaryKey"], []).append(entry)
196
+ for b in bundles.values():
197
+ b["keys"] = sorted(b["keys"])
198
+ problems["keys"] = sorted(set(problems["keys"]))
199
+ cats = index["catalogs"]
200
+ return {"schema": ADDRESSES,
201
+ "catalog": {n: cats[n]["sha256"] if n in cats else None for n in catalogdb.CATALOG_NAMES},
202
+ "bundles": dict(sorted(bundles.items())), "files": dict(sorted(cab.items())),
203
+ "keys": dict(sorted(keys.items())),
204
+ "objects": dict(sorted(objects.items())), "problems": problems}
205
+
206
+
207
+ def address_of(doc: dict, oid: str, container_objects: dict | None = None) -> str | None:
208
+ """The stable address of an object id in an address table (None when its file is in no listed bundle).
209
+ `container_objects`: {object id: address} of the listed objects (reverse(doc)), for repeated calls."""
210
+ rev = container_objects if container_objects is not None else reverse(doc)
211
+ if oid in rev:
212
+ return rev[oid]
213
+ file, pid = contract.parse_object_id(oid)
214
+ stable = doc["files"].get(file)
215
+ if stable is None:
216
+ return None
217
+ return _path_address(stable, file_tags(doc["bundles"][stable]["files"]), file, pid)
218
+
219
+
220
+ def reverse(doc: dict) -> dict[str, str]:
221
+ """{object id: stable address} of the objects an address table lists (the first address of an object that
222
+ several container paths name)."""
223
+ out: dict[str, str] = {}
224
+ for addr, o in doc["objects"].items():
225
+ out.setdefault(o["object"], addr)
226
+ return out
227
+
228
+
229
+ def diff_addresses(old: dict, new: dict) -> dict:
230
+ """The objects of two address tables compared by stable address: added, removed, changed (class, name, size or
231
+ type hash differ), rebuilt (only the object id differs: its bundle changed), unchanged (a count)."""
232
+ a, b = old["objects"], new["objects"]
233
+ changed, rebuilt, same = [], [], 0
234
+ for addr in sorted(set(a) & set(b)):
235
+ fields = [f for f in OBJECT_FIELDS if a[addr].get(f) != b[addr].get(f)]
236
+ if not fields:
237
+ same += 1
238
+ elif fields == ["object"]:
239
+ rebuilt.append({"address": addr, "old": a[addr]["object"], "new": b[addr]["object"]})
240
+ else:
241
+ changed.append({"address": addr, "fields": fields, "old": a[addr], "new": b[addr]})
242
+ out = {"added": sorted(set(b) - set(a)), "removed": sorted(set(a) - set(b)), "changed": changed,
243
+ "rebuilt": rebuilt, "unchanged": same}
244
+ out["summary"] = {k: (v if isinstance(v, int) else len(v)) for k, v in out.items()}
245
+ return out
246
+
247
+
248
+ # ---------------------------------------------------------------- stages
249
+ def _census_input(env, tid: str) -> Input:
250
+ return env.input_of(tid, "census", as_role="census:" + contract.parse_task_id(tid, census.CensusStage.name)[1])
251
+
252
+
253
+ def _read(store, task, role: str) -> dict:
254
+ return contract.loads(store.input_bytes(task.input(role)))
255
+
256
+
257
+ class ScriptsStage(Stage):
258
+ """link.scripts: the censuses that hold MonoScripts -> the script table. One subject ("all"); inputs "census:<bundle
259
+ subject>". Waits for census tasks that have not run; skips failed ones. Artifact "link.scripts:all#scripts"."""
260
+ name = "link.scripts"
261
+ version = 1
262
+ after = ("unity.census",)
263
+
264
+ def subjects(self, env) -> list[str]:
265
+ return ["all"]
266
+
267
+ def inputs(self, subject: str, env) -> list[Input]:
268
+ pending = env.pending(census.CensusStage.name)
269
+ if pending:
270
+ raise Pending(pending[0])
271
+ return [_census_input(env, tid) for tid in env.tasks(census.CensusStage.name)
272
+ if env.artifact(tid, "census")["semantics"]["facts"].get("scripts")]
273
+
274
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
275
+ size = sum(i.size for i in inputs)
276
+ return Cost(0.05 + size / 50e6, (40 << 20) + 8 * size)
277
+
278
+ def run(self, task, store) -> Output:
279
+ doc = scripts(_read(store, task, i.role) for i in task.inputs)
280
+ content = store.add(contract.encode(doc), "json")
281
+ rec = contract.artifact(contract.artifact_id(task.id, "scripts"), content, contract.provenance(task),
282
+ {"kind": "link.scripts", "format": "json", "facts": {"scripts": len(doc["scripts"])}})
283
+ return Output([rec], [])
284
+
285
+
286
+ class AddressesStage(Stage):
287
+ """link.addresses: a catalog index (input "index", the result of catalog.index:<subject>) and the censuses of its
288
+ bundles (inputs "census:<stable name>") -> the address table. Subjects: those of catalog.index (the fact
289
+ "catalogs"). Waits for census tasks of its bundles that have not run; skips failed ones. Artifact
290
+ "link.addresses:<subject>#addresses"."""
291
+ name = "link.addresses"
292
+ version = 1
293
+ after = ("catalog.index", "unity.census")
294
+
295
+ def subjects(self, env) -> list[str]:
296
+ return sorted(env.fact("catalogs"))
297
+
298
+ def inputs(self, subject: str, env) -> list[Input]:
299
+ tid = contract.task_id(catalogdb.IndexStage.name, subject)
300
+ idx = env.input_of(tid, "index")
301
+ stables = {b["stable"] for b in env.store.document(idx.sha256)["bundles"]}
302
+ wanted = {contract.task_id(census.CensusStage.name, s) for s in stables}
303
+ pending = [t for t in env.pending(census.CensusStage.name) if t in wanted]
304
+ if pending:
305
+ raise Pending(pending[0])
306
+ return [idx] + [_census_input(env, t) for t in env.tasks(census.CensusStage.name) if t in wanted]
307
+
308
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
309
+ size = sum(i.size for i in inputs)
310
+ return Cost(0.5 + size / 50e6, (200 << 20) + size // 2)
311
+
312
+ def run(self, task, store) -> Output:
313
+ idx = _read(store, task, "index")
314
+ docs = ((i.role.split(":", 1)[1], _read(store, task, i.role)) for i in task.inputs
315
+ if i.role.startswith("census:"))
316
+ doc = addresses(idx, docs)
317
+ content = store.add(contract.encode(doc), "json")
318
+ facts = {"bundles": len(doc["bundles"]), "keys": len(doc["keys"]), "objects": len(doc["objects"]),
319
+ "problems": {k: len(v) for k, v in doc["problems"].items()}}
320
+ rec = contract.artifact(contract.artifact_id(task.id, "addresses"), content, contract.provenance(task),
321
+ {"kind": "link.addresses", "format": "json", "facts": facts})
322
+ return Output([rec], [])
323
+
324
+
325
+ def artifact_table(results) -> tuple[dict, dict, dict]:
326
+ """The parts of the artifact table of results [(task id, result)], each result read once and not kept:
327
+ ({object id: set of artifact ids}, {object id: the artifact it is part of}, {artifact id: {sha256, size, ext}}),
328
+ unsorted. The ids an object is contained in repeat (every part of a prefab names it): one string each."""
329
+ objects, contained, arts, same = {}, {}, {}, {}
330
+ for _, doc in results:
331
+ for a in doc["artifacts"]:
332
+ c = a["content"]
333
+ arts[a["id"]] = {"sha256": c["sha256"], "size": c["size"], "ext": c["ext"]}
334
+ for it in doc["items"]:
335
+ if it.get("artifacts"):
336
+ objects.setdefault(it["object"], set()).update(it["artifacts"])
337
+ elif "in" in it:
338
+ contained[it["object"]] = same.setdefault(it["in"], it["in"])
339
+ return objects, contained, arts
340
+
341
+
342
+ def artifacts(results) -> dict:
343
+ """The artifact table (nnnotes.artifacts/1) of results [(task id, result)]."""
344
+ objects, contained, arts = artifact_table(results)
345
+ return {"schema": ARTIFACTS, "objects": {o: sorted(v) for o, v in sorted(objects.items())},
346
+ "contained": dict(sorted(contained.items())), "artifacts": dict(sorted(arts.items()))}
347
+
348
+
349
+ def encode_artifacts(objects: dict, contained: dict, arts: dict, chunk: int = 1 << 20):
350
+ """contract.encode(artifacts(...)) of the parts artifact_table gives, as byte strings of about `chunk` bytes:
351
+ the canonical text (keys sorted, indent 1, non-ASCII as is, one trailing newline) written entry by entry, so the
352
+ whole document is never built."""
353
+ def text(v) -> str:
354
+ return json.dumps(v, indent=1, ensure_ascii=False, sort_keys=True)
355
+
356
+ buf, size = ["{"], 0
357
+ tables = (("artifacts", arts, arts.get), ("contained", contained, contained.get),
358
+ ("objects", objects, lambda k: sorted(objects[k])))
359
+ for i, (name, table, value) in enumerate(tables):
360
+ buf.append(("\n " if i == 0 else ",\n ") + text(name) + ": ")
361
+ if not table:
362
+ buf.append("{}")
363
+ continue
364
+ sep = "{\n "
365
+ for k in sorted(table):
366
+ s = sep + text(k) + ": " + text(value(k)).replace("\n", "\n ")
367
+ buf.append(s)
368
+ size += len(s)
369
+ sep = ",\n "
370
+ if size >= chunk:
371
+ yield "".join(buf).encode("utf-8")
372
+ buf, size = [], 0
373
+ buf.append("\n }")
374
+ buf.append(",\n " + text("schema") + ": " + text(ARTIFACTS) + "\n}\n")
375
+ yield "".join(buf).encode("utf-8")
376
+
377
+
378
+ class ArtifactsStage(Stage):
379
+ """link.artifacts: the results of the export stages (EXPORT_STAGES, those of the pipeline) -> the artifact table.
380
+ One subject ("all"); no inputs, context {"results": {task id: key}}. Waits for export tasks that have not run;
381
+ skips failed ones. Artifact "link.artifacts:all#artifacts"."""
382
+ name = "link.artifacts"
383
+ version = 1
384
+ after = ("unity.export",)
385
+
386
+ def subjects(self, env) -> list[str]:
387
+ return ["all"]
388
+
389
+ def _tasks(self, env) -> list[str]:
390
+ for stage in EXPORT_STAGES:
391
+ pending = env.pending(stage)
392
+ if pending:
393
+ raise Pending(pending[0])
394
+ return [t for stage in EXPORT_STAGES for t in env.tasks(stage)]
395
+
396
+ def depends(self, subject: str, env) -> list[str]:
397
+ return self._tasks(env)
398
+
399
+ def context(self, subject: str, env) -> dict:
400
+ return {"results": {t: env.key(t) for t in self._tasks(env)}}
401
+
402
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
403
+ n = len(self._tasks(env))
404
+ return Cost(0.05 + 0.002 * n, (60 << 20) + 200_000 * n)
405
+
406
+ def run(self, task, store) -> Output:
407
+ def results():
408
+ for tid, key in sorted(task.context.get("results", {}).items()):
409
+ doc = store.result(key)
410
+ if doc is None:
411
+ raise FileNotFoundError(f"result of {tid} ({key}) is not in the store")
412
+ yield tid, doc
413
+ objects, contained, arts = artifact_table(results())
414
+ content = store.add_chunks(encode_artifacts(objects, contained, arts), "json")
415
+ facts = {"objects": len(objects), "contained": len(contained), "artifacts": len(arts)}
416
+ rec = contract.artifact(contract.artifact_id(task.id, "artifacts"), content, contract.provenance(task),
417
+ {"kind": "link.artifacts", "format": "json", "facts": facts})
418
+ return Output([rec], [])
nnnotes/live.py ADDED
@@ -0,0 +1,115 @@
1
+ """One live (music + difficulty) -> a self-contained live directory, the data format ournotes-player reads.
2
+
3
+ <out>/score/ chart as shipped, converted runtime notes, master rows (score.py)
4
+ <out>/audio/<cueSheet>/ the live BGM cue sheet decoded per cue + cues.json + streams.json (score.py)
5
+ <out>/audio/live-audio.json the live's sounds (BGM, note SE, live SE) with their CRI routing, and the
6
+ note SE / live SE cue sheets decoded next to the BGM (liveaudio.py)
7
+ <out>/livescene/ scene graph, cameras, lane, LightWeight background, start timeline,
8
+ textures and shaders (livescene.py)
9
+ <out>/livenotes/ note / line / effect prefabs, skins, clips, particle systems,
10
+ textures and shaders (livenotes.py)
11
+ <out>/liveui/ the start canvas (music title, credits, difficulty) in the client language:
12
+ strings, text records or the game's fonts, sprites (liveui.py)
13
+ <out>/live.json index of the above
14
+
15
+ `options` (liveoptions.LiveOptions, from `--live-option`) adds the files of the option variants it offers: the
16
+ mirrored score (score.py, live.json `notesMirror`), the other note skins and effect sets, the bar line view and the
17
+ option tables (livenotes.py), the other note sound sets (liveaudio.py). Without it the directory holds what the
18
+ default options read.
19
+
20
+ The band of the LightWeight background and of the start timeline is the band of the player's deck centre
21
+ (`SelfMemberList[2]`: LightWeightBackgroundLoadStep.<LoadAsync>d__3 reads its BandID, LiveResourceBandResolver.Resolve
22
+ its MemberCard's Character.Band; MasterMemberCard.get_BandID = Character._bandID). The preview has no deck:
23
+ `resolve_band` takes it from `--leader-card` / `--band`, else from DEFAULT_BAND_RULE.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+
29
+ from .catalog import Catalog
30
+ from .cristages import FLAC_LEVEL
31
+ from .jsonio import write_json
32
+ from .liveoptions import LiveOptions
33
+ from .player import PlayerData
34
+ from .score import master_table
35
+ from . import liveaudio, livenotes, livescene, liveui, score
36
+
37
+ # Band of the preview when no deck centre is given: the band of the music's first vocal character
38
+ # (MasterLiveMusic._vocalCharacterIDs[0] -> MasterCharacter._bandID). The music's own fields are not the game's input.
39
+ DEFAULT_BAND_RULE = "firstVocalCharacter"
40
+ BAND_NOTE = ("the game takes the band from the player's deck centre (SelfMemberList[2]); the preview has no "
41
+ "deck, so the band is the preview's choice")
42
+ # keys the band selects (livescene.ASSET_KEYS / SPRITE_KEYS); a band without them cannot be previewed
43
+ BAND_KEYS = ("Band/{band}/live_stage/lightweight_background", "Band/{band}/timeline/live_start_playable_timeline")
44
+
45
+
46
+ def resolve_band(cat: Catalog, master: Path, music_id: int, band: int | None = None,
47
+ leader_card: int | None = None) -> dict:
48
+ """The preview's band: {"band", "source", "note"} (recorded in livescene/scene.json slice.bandChoice)."""
49
+ if band is not None and leader_card is not None:
50
+ raise ValueError("give either band or leader_card, not both")
51
+ chars = {r["_id"]: r for r in master_table(master, "MasterCharacter")}
52
+ if leader_card is not None:
53
+ cards = [r for r in master_table(master, "MasterMemberCard") if r["_id"] == leader_card]
54
+ if len(cards) != 1:
55
+ raise KeyError(f"MasterMemberCard {leader_card}: {len(cards)} rows")
56
+ ch = chars.get(cards[0]["_characterID"])
57
+ if ch is None: # get_BandID returns -1, LiveResourceBandResolver.Resolve throws: no preview
58
+ raise KeyError(f"MasterMemberCard {leader_card}: character {cards[0]['_characterID']} not in MasterCharacter")
59
+ b, source = ch["_bandID"], f"leader card {leader_card} (MasterMemberCard -> MasterCharacter "\
60
+ f"{ch['_id']}._bandID, MasterMemberCard.get_BandID)"
61
+ elif band is not None:
62
+ b, source = band, "band given"
63
+ else:
64
+ music = [r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id]
65
+ if len(music) != 1:
66
+ raise KeyError(f"MasterLiveMusic {music_id}: {len(music)} rows")
67
+ vocals = music[0]["_vocalCharacterIDs"]
68
+ if not vocals:
69
+ raise ValueError(f"music {music_id}: no vocal character; give --band or --leader-card")
70
+ if vocals[0] not in chars:
71
+ raise KeyError(f"music {music_id}: vocal character {vocals[0]} not in MasterCharacter")
72
+ b = chars[vocals[0]]["_bandID"]
73
+ source = (f"default ({DEFAULT_BAND_RULE}): MasterLiveMusic._vocalCharacterIDs[0] = {vocals[0]} -> "
74
+ f"MasterCharacter._bandID")
75
+ b = int(b)
76
+ missing = [k.format(band=b) for k in BAND_KEYS if not cat.has(k.format(band=b))]
77
+ if missing:
78
+ raise KeyError(f"band {b}: {', '.join(missing)} not in the catalog")
79
+ return {"band": b, "source": source, "note": BAND_NOTE}
80
+
81
+
82
+ def index_doc(music_id: int, difficulty: str, score_summary: dict, audio: dict, live_audio: str) -> dict:
83
+ """live.json: `score_summary` from score.extract (its `notesMirror` when the score was also converted
84
+ mirrored), `audio` its BGM entry, `live_audio` the path of the live sounds (liveaudio.extract)."""
85
+ s = score_summary
86
+ return {"musicId": music_id, "difficulty": difficulty, "chart": s["chart"], "notes": s["notes"],
87
+ **({"notesMirror": s["notesMirror"]} if "notesMirror" in s else {}),
88
+ "master": s["master"], "audio": audio, "liveAudio": live_audio, "scene": "livescene/scene.json",
89
+ "noteAssets": "livenotes/notes.json", "liveUi": "liveui/liveui.json"}
90
+
91
+
92
+ def build(cat: Catalog, master: Path, player: PlayerData, music_id: int, difficulty: str,
93
+ out_dir: Path, audio_format: str = "flac", band: int | None = None,
94
+ leader_card: int | None = None, *, language: str, fonts: str = "open",
95
+ options: LiveOptions = LiveOptions(), flac_level: int = FLAC_LEVEL) -> dict:
96
+ """One live directory. `language`: the client language of the start canvas (a languages.LANGUAGES code);
97
+ `fonts`: "open" or "game" (liveui.extract); `options`: the option variants whose files it carries as well
98
+ (liveoptions.resolve); `flac_level`: ffmpeg's compression level of the FLAC audio (cri.decode)."""
99
+ out_dir = Path(out_dir)
100
+ out_dir.mkdir(parents=True, exist_ok=True)
101
+ choice = resolve_band(cat, Path(master), music_id, band=band, leader_card=leader_card)
102
+ s = score.extract(cat, master, music_id, difficulty, out_dir, audio=True, audio_fmt=audio_format,
103
+ flac_level=flac_level, mirror=options.mirror)
104
+ la = liveaudio.extract(cat, Path(master), player, music_id, out_dir, fmt=audio_format, # reuses the BGM decode
105
+ options=options, flac_level=flac_level)
106
+ sc = livescene.extract(cat, player, out_dir, master=Path(master), music_id=music_id, band=choice["band"],
107
+ band_choice=choice)
108
+ lu = liveui.extract(cat, player, out_dir, master=Path(master), music_id=music_id, # reads livescene's shaders
109
+ difficulty=difficulty, language=language, fonts=fonts)
110
+ nt = livenotes.extract(cat, player, out_dir, master=Path(master), options=options)
111
+ index = index_doc(music_id, difficulty, s, s["audio"], la["index"])
112
+ write_json(out_dir / "live.json", index)
113
+ return {**index, "band": choice, "sceneSummary": sc, "noteAssetSummary": nt, "liveAudioSummary": la,
114
+ "liveUiSummary": lu, "judgementNoteCount": s["judgementNoteCount"], "fullComboCount": s["fullComboCount"]}
115
+