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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|