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/modelstages.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""The model stages: Live2D models and Spine skeletons, written by the extractors of `nnnotes live2d` and
|
|
2
|
+
`nnnotes spot` (unchanged) from the bundles of their tasks.
|
|
3
|
+
|
|
4
|
+
live2d.model, one task per Live2D model key (Character/Live2D/<group>/<name>/model/<name>, the keys `nnnotes live2d`
|
|
5
|
+
takes) whose own bundle (the first dependency of the key's location) is selected:
|
|
6
|
+
inputs "bundle:<n>": the bundles of the key's dependency closure in the order the catalog gives them
|
|
7
|
+
(Catalog.resolve; n is the position: the extractor loads them in this order, so the order is part of
|
|
8
|
+
the key)
|
|
9
|
+
context {"internalId": the internal id of the key's location} (the container path the model is read from)
|
|
10
|
+
artifacts "live2d.model:<key>#<path>": every file live2d.extract_model writes, <path> relative to the model's
|
|
11
|
+
directory (<name>.moc3, <name>.prefab.json, textures/*.png, <name>.model3.json, *.exp3.json,
|
|
12
|
+
<name>.physics3.json, motions/*.motion3.json, motions/_fades.json), with its bytes
|
|
13
|
+
The extractor runs through TaskCatalog, which answers what it asks of a catalog from the task. It reads nothing of
|
|
14
|
+
the APK but the APK's bundles of the closure, which are inputs like the others.
|
|
15
|
+
|
|
16
|
+
spine.skeleton, one task per SkeletonDataAsset (a MonoBehaviour whose script the script table names
|
|
17
|
+
SkeletonDataAsset) of a selected bundle; its subject is the asset's stable address by path id,
|
|
18
|
+
"<bundle><file tag>:<pathId>" (link.file_tags):
|
|
19
|
+
inputs "bundle:<stable name>": the bundles holding the serialized files the asset's file reaches through
|
|
20
|
+
externals, transitively (every object a reference chain from the asset can reach); externals no
|
|
21
|
+
census holds (the built-in files) are left out
|
|
22
|
+
context {"object": the asset's object id}
|
|
23
|
+
artifacts "spine.skeleton:<subject>#<file>": the skeleton (.json or .skel), each of its atlases (.atlas) and
|
|
24
|
+
the atlas pages (PNG), as spot.write_skeleton writes them for `nnnotes spot`
|
|
25
|
+
item the asset: exported (its artifacts), or failed with the writer's error
|
|
26
|
+
|
|
27
|
+
Both tasks name their extractor as their one atom ("live2d.extract_model", "spot.write_skeleton"), whose id carries
|
|
28
|
+
the versions of the libraries the files depend on; this module imports UnityPy only when a task runs.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
import tempfile
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from . import catalogdb, contract, link
|
|
37
|
+
from .atoms import impl_id
|
|
38
|
+
from .catalog import Bundle
|
|
39
|
+
from .contract import Cost, IncompatibleTask, Input
|
|
40
|
+
from .stages import Output, Pending, Stage
|
|
41
|
+
|
|
42
|
+
LIVE2D = "live2d.model"
|
|
43
|
+
SPINE = "spine.skeleton"
|
|
44
|
+
CENSUS = "unity.census"
|
|
45
|
+
SCRIPTS = "link.scripts"
|
|
46
|
+
SCRIPTS_TASK = contract.task_id(SCRIPTS, "all")
|
|
47
|
+
# the model keys of `nnnotes live2d` (webmodel.MODEL_KEY; webmodel imports UnityPy)
|
|
48
|
+
MODEL_KEY = re.compile(r"Character/Live2D/(?P<group>[^/]+)/(?P<name>[^/]+)/model/(?P=name)")
|
|
49
|
+
SKELETON_CLASS = "SkeletonDataAsset"
|
|
50
|
+
# the libraries the extractors' output depends on (export._LIBS, the salt of export's PNG cache)
|
|
51
|
+
LIBRARIES = ("UnityPy", "Pillow", "texture2ddecoder", "astc-encoder-py", "etcpak", "numpy")
|
|
52
|
+
_ASSET_PREFIXES = ("Assets/", "Packages/")
|
|
53
|
+
_ADDRESS = re.compile(r"0x[0-9a-fA-F]{6,}")
|
|
54
|
+
_EXT = re.compile(r"[0-9a-z]+")
|
|
55
|
+
# live2d.extract_model files -> artifact kinds (by suffix, the first that matches)
|
|
56
|
+
LIVE2D_KINDS = ((".model3.json", "live2d.model3"), (".prefab.json", "live2d.prefab"),
|
|
57
|
+
(".physics3.json", "live2d.physics3"), (".exp3.json", "live2d.expression"),
|
|
58
|
+
(".motion3.json", "live2d.motion"), ("motions/_fades.json", "live2d.fades"), (".moc3", "live2d.moc3"),
|
|
59
|
+
("textures/", "live2d.texture"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _message(e: BaseException) -> str:
|
|
63
|
+
return _ADDRESS.sub("0x?", f"{type(e).__name__}: {e}")[:500]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ext(name: str, default: str = "bin") -> str:
|
|
67
|
+
ext = name.rsplit("/", 1)[-1].rpartition(".")[2].lower() if "." in name.rsplit("/", 1)[-1] else ""
|
|
68
|
+
return ext if _EXT.fullmatch(ext) else default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_atoms(task, atoms: dict) -> None:
|
|
72
|
+
if task.atoms != atoms:
|
|
73
|
+
raise IncompatibleTask(f"task {task.id}: this installation runs {atoms}, the task names {task.atoms}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fact_input(bundles, stable: str, what: str) -> Input:
|
|
77
|
+
"""The Input of a bundle from the fact "bundles" (Pending while it is not fetched)."""
|
|
78
|
+
if stable not in bundles:
|
|
79
|
+
raise ValueError(f"{what}: bundle {stable} is not readable")
|
|
80
|
+
inp = bundles[stable]
|
|
81
|
+
if inp is None:
|
|
82
|
+
raise Pending(f"fetch:{stable}")
|
|
83
|
+
if isinstance(inp, Exception):
|
|
84
|
+
raise inp
|
|
85
|
+
if inp.role != "bundle":
|
|
86
|
+
raise ValueError(f"{what}: input role {inp.role!r}, expected 'bundle'")
|
|
87
|
+
return inp
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ordered(task, prefix: str = "bundle:") -> list[Input]:
|
|
91
|
+
"""The inputs "bundle:<n>" of a task by n."""
|
|
92
|
+
ins = [i for i in task.inputs if i.role.startswith(prefix)]
|
|
93
|
+
return sorted(ins, key=lambda i: int(i.role[len(prefix):]))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------- the catalog of a task
|
|
97
|
+
class Closures:
|
|
98
|
+
"""Key lookup and dependency closures of a catalog index, as Catalog._entry and Catalog.resolve give them on the
|
|
99
|
+
catalogs the index was made of: a key's location is its first one inside a bundle (internal id under Assets/ or
|
|
100
|
+
Packages/), else its first one (remote catalog before APK catalog, each by offset); its closure is every bundle
|
|
101
|
+
location its dependencies reach, depth first, in the order Catalog.resolve lists them."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, index: dict):
|
|
104
|
+
self.index = index
|
|
105
|
+
self.locations = catalogdb.by_id(index)
|
|
106
|
+
self.files = catalogdb.file_refs(index)
|
|
107
|
+
self.by_key: dict[str, list[dict]] = {}
|
|
108
|
+
for loc in index["locations"]:
|
|
109
|
+
self.by_key.setdefault(loc["primaryKey"], []).append(loc)
|
|
110
|
+
|
|
111
|
+
def keys(self) -> list[str]:
|
|
112
|
+
return sorted(self.by_key)
|
|
113
|
+
|
|
114
|
+
def entry(self, key: str) -> dict:
|
|
115
|
+
locs = self.by_key.get(key)
|
|
116
|
+
if not locs:
|
|
117
|
+
raise KeyError(key)
|
|
118
|
+
return next((loc for loc in locs if loc["internalId"].startswith(_ASSET_PREFIXES)), locs[0])
|
|
119
|
+
|
|
120
|
+
def own_bundle(self, key: str) -> str | None:
|
|
121
|
+
"""The stable name of the bundle a key's location names first (the bundle holding it), else None."""
|
|
122
|
+
deps = self.entry(key)["dependencies"]
|
|
123
|
+
ref = self.files.get(deps[0]) if deps else None
|
|
124
|
+
return ref[1] if ref is not None and ref[0] == "bundle" else None
|
|
125
|
+
|
|
126
|
+
def closure(self, key: str) -> list[str]:
|
|
127
|
+
"""The location ids of the bundles of a key's closure, in Catalog.resolve order."""
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
out: dict[str, None] = {}
|
|
130
|
+
stack = [self.entry(key)["id"]]
|
|
131
|
+
while stack:
|
|
132
|
+
lid = stack.pop()
|
|
133
|
+
if lid in seen:
|
|
134
|
+
continue
|
|
135
|
+
seen.add(lid)
|
|
136
|
+
loc = self.locations.get(lid)
|
|
137
|
+
if loc is None:
|
|
138
|
+
continue
|
|
139
|
+
if loc["kind"] == "bundle":
|
|
140
|
+
out.setdefault(lid, None)
|
|
141
|
+
for d in loc["dependencies"]:
|
|
142
|
+
if d not in seen:
|
|
143
|
+
stack.append(d)
|
|
144
|
+
return list(out)
|
|
145
|
+
|
|
146
|
+
def closure_bundles(self, key: str) -> list[str]:
|
|
147
|
+
"""The stable names of the bundles of a key's closure, in Catalog.resolve order (a file two locations name
|
|
148
|
+
is listed twice, as Catalog.fetch_key gives it twice)."""
|
|
149
|
+
return [self.files[lid][1] for lid in self.closure(key)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# the value of TaskCatalog.apk: not None, the APK's bundles of the closure are inputs of the task
|
|
153
|
+
APK_FROM_INPUTS = "<the task's inputs>"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TaskCatalog:
|
|
157
|
+
"""What the model extractors ask of a Catalog (live2d.extract_model through export.Exporter: fetch_key, _entry,
|
|
158
|
+
apk), answered from a task: its key, the internal id of the key's location and the bundles of the key's closure
|
|
159
|
+
in load order, each bundle read through the store (Store.open_input: its locators, checked against its content
|
|
160
|
+
id). Another key is not in it (KeyError). `apk` is not None: the extractors need the APK's bundles, and those of
|
|
161
|
+
the closure are inputs of the task like the others."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, key: str, internal_id: str, bundles: list[Input], store):
|
|
164
|
+
self.key, self.internal_id, self.bundles, self.store = key, internal_id, list(bundles), store
|
|
165
|
+
self.apk = APK_FROM_INPUTS
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_task(cls, task, store) -> "TaskCatalog":
|
|
169
|
+
"""The catalog of a live2d.model task: its subject, context internalId and inputs "bundle:<n>"."""
|
|
170
|
+
return cls(task.subject, task.context["internalId"], _ordered(task), store)
|
|
171
|
+
|
|
172
|
+
def _check(self, key: str) -> None:
|
|
173
|
+
if key != self.key:
|
|
174
|
+
raise KeyError(key)
|
|
175
|
+
|
|
176
|
+
def has(self, key: str) -> bool:
|
|
177
|
+
return key == self.key
|
|
178
|
+
|
|
179
|
+
def keys(self, prefix: str = "") -> list[str]:
|
|
180
|
+
return [self.key] if self.key.startswith(prefix) else []
|
|
181
|
+
|
|
182
|
+
def _entry(self, key: str) -> dict:
|
|
183
|
+
self._check(key)
|
|
184
|
+
return {"primary_key": key, "internal_id": self.internal_id}
|
|
185
|
+
|
|
186
|
+
def entries_for(self, key: str) -> list[dict]:
|
|
187
|
+
return [self._entry(key)] if key == self.key else []
|
|
188
|
+
|
|
189
|
+
def resolve(self, key: str) -> list[Bundle]:
|
|
190
|
+
"""The closure as Bundles in load order: offset = the position, name = the input's file name (its content
|
|
191
|
+
id when it has none); internal_id is empty and remote True (a task has a file's content, not its place in
|
|
192
|
+
a catalog; every bundle is read from the inputs)."""
|
|
193
|
+
self._check(key)
|
|
194
|
+
return [Bundle(i, "", inp.name or inp.sha256, True) for i, inp in enumerate(self.bundles)]
|
|
195
|
+
|
|
196
|
+
def fetch_key(self, key: str) -> list[Path]:
|
|
197
|
+
"""The files of the closure's bundles, in load order."""
|
|
198
|
+
self._check(key)
|
|
199
|
+
return [self.store.open_input(inp) for inp in self.bundles]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------- live2d.model
|
|
203
|
+
def live2d_kind(path: str) -> str:
|
|
204
|
+
"""The artifact kind of a file live2d.extract_model writes (LIVE2D_KINDS; else "live2d.file")."""
|
|
205
|
+
for suffix, kind in LIVE2D_KINDS:
|
|
206
|
+
if path.endswith(suffix) or (suffix.endswith("/") and path.startswith(suffix)):
|
|
207
|
+
return kind
|
|
208
|
+
return "live2d.file"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _tree(root: Path) -> list[tuple[str, Path]]:
|
|
212
|
+
"""(relative POSIX path, file) of every file under `root`, sorted by path."""
|
|
213
|
+
return sorted((p.relative_to(root).as_posix(), p) for p in root.rglob("*") if p.is_file())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Live2DStage(Stage):
|
|
217
|
+
"""live2d.model: a Live2D model key -> the files of `nnnotes live2d` (module documentation). Subjects and inputs
|
|
218
|
+
come from the facts "index" (the catalog index), "selected" and "bundles" ({stable name: Input "bundle"})."""
|
|
219
|
+
name = LIVE2D
|
|
220
|
+
version = 1
|
|
221
|
+
ATOMS = {"live2d.extract_model": impl_id(LIBRARIES, 1)}
|
|
222
|
+
LAYOUT_ROOT = "{subject}"
|
|
223
|
+
|
|
224
|
+
def __init__(self):
|
|
225
|
+
self._closures: Closures | None = None
|
|
226
|
+
|
|
227
|
+
def __getstate__(self):
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
def __setstate__(self, state):
|
|
231
|
+
self.__init__()
|
|
232
|
+
|
|
233
|
+
def closures(self, env) -> Closures:
|
|
234
|
+
index = env.fact("index")
|
|
235
|
+
if self._closures is None or self._closures.index is not index:
|
|
236
|
+
self._closures = Closures(index)
|
|
237
|
+
return self._closures
|
|
238
|
+
|
|
239
|
+
def subjects(self, env) -> list[str]:
|
|
240
|
+
c, selected = self.closures(env), set(env.fact("selected"))
|
|
241
|
+
return [k for k in c.keys() if MODEL_KEY.fullmatch(k) and c.own_bundle(k) in selected]
|
|
242
|
+
|
|
243
|
+
def inputs(self, subject: str, env) -> list[Input]:
|
|
244
|
+
bundles, what = env.fact("bundles"), f"{self.name} {subject}"
|
|
245
|
+
names = self.closures(env).closure_bundles(subject)
|
|
246
|
+
width = max(3, len(str(len(names) - 1)))
|
|
247
|
+
out = []
|
|
248
|
+
for i, stable in enumerate(names):
|
|
249
|
+
inp = _fact_input(bundles, stable, what)
|
|
250
|
+
out.append(Input(f"bundle:{i:0{width}d}", inp.sha256, inp.size, inp.name, inp.locators))
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
def context(self, subject: str, env) -> dict:
|
|
254
|
+
return {"internalId": self.closures(env).entry(subject)["internalId"]}
|
|
255
|
+
|
|
256
|
+
def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
|
|
257
|
+
"""From the closure's bytes (least squares over the models of a catalog: CPU seconds; peak resident bytes
|
|
258
|
+
of a worker process)."""
|
|
259
|
+
size = sum(i.size for i in inputs)
|
|
260
|
+
return Cost(0.9 + size * 2.2e-7, (420 << 20) + 10 * size)
|
|
261
|
+
|
|
262
|
+
def run(self, task, store) -> Output:
|
|
263
|
+
_check_atoms(task, self.ATOMS)
|
|
264
|
+
from . import live2d
|
|
265
|
+
cat = TaskCatalog.from_task(task, store)
|
|
266
|
+
arts = []
|
|
267
|
+
with tempfile.TemporaryDirectory(prefix="nnnotes-live2d-") as d:
|
|
268
|
+
summary = live2d.extract_model(cat, task.subject, Path(d))
|
|
269
|
+
for rel, p in _tree(Path(d)):
|
|
270
|
+
kind = live2d_kind(rel)
|
|
271
|
+
facts = {k: summary[k] for k in sorted(summary)} if kind == "live2d.model3" else {}
|
|
272
|
+
ext = _ext(rel)
|
|
273
|
+
arts.append(contract.artifact(contract.artifact_id(task.id, rel), store.add(p.read_bytes(), ext),
|
|
274
|
+
contract.provenance(task), {"kind": kind, "format": ext,
|
|
275
|
+
"facts": facts}))
|
|
276
|
+
return Output(arts, [])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------- spine.skeleton
|
|
280
|
+
def skeleton_index(env, selected) -> dict:
|
|
281
|
+
"""What spine.skeleton plans from, read from the census results and the script table: {"subjects": {subject:
|
|
282
|
+
(stable bundle name, file, pathId)} of the SkeletonDataAssets of the `selected` bundles, "files": {serialized
|
|
283
|
+
file (case-folded): stable bundle name} (a file two bundles hold: the first by name), "externals": {serialized
|
|
284
|
+
file (case-folded): [its external files]}}. Raises Pending while a census task has not run or the script table
|
|
285
|
+
is missing; failed censuses are left out."""
|
|
286
|
+
pending = env.pending(CENSUS)
|
|
287
|
+
if pending:
|
|
288
|
+
raise Pending(pending[0])
|
|
289
|
+
table = contract.loads(env.store.read(env.input_of(SCRIPTS_TASK, "scripts").sha256))["scripts"]
|
|
290
|
+
scripts = {oid for oid, entry in table.items() if entry.rsplit("|", 1)[-1] == SKELETON_CLASS}
|
|
291
|
+
files: dict[str, str] = {}
|
|
292
|
+
externals: dict[str, list[str]] = {}
|
|
293
|
+
subjects: dict[str, tuple] = {}
|
|
294
|
+
for tid in env.tasks(CENSUS):
|
|
295
|
+
stable = contract.parse_task_id(tid, CENSUS)[1]
|
|
296
|
+
doc = contract.loads(env.store.read(env.artifact(tid, "census")["content"]["sha256"]))
|
|
297
|
+
for f in doc["files"]:
|
|
298
|
+
files.setdefault(f["name"].casefold(), stable)
|
|
299
|
+
externals.setdefault(f["name"].casefold(), [x["file"] for x in f["externals"]])
|
|
300
|
+
if not scripts or stable not in selected or "MonoBehaviour" not in doc["classes"]:
|
|
301
|
+
continue
|
|
302
|
+
tags = link.file_tags(f["name"] for f in doc["files"])
|
|
303
|
+
for f in doc["files"]:
|
|
304
|
+
for o in f["objects"]:
|
|
305
|
+
s = o.get("script")
|
|
306
|
+
if o["class"] == "MonoBehaviour" and s and s.get("file") is not None and \
|
|
307
|
+
contract.object_id(s["file"], s["pathId"]) in scripts:
|
|
308
|
+
subject = contract.stable_address(stable + tags[f["name"]], path_id=o["pathId"])
|
|
309
|
+
subjects[subject] = (stable, f["name"], int(o["pathId"]))
|
|
310
|
+
return {"subjects": subjects, "files": files, "externals": externals}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def reach(index: dict, file: str) -> list[str]:
|
|
314
|
+
"""The stable names of the bundles holding `file` and every serialized file it reaches through externals,
|
|
315
|
+
sorted (skeleton_index document; files no census holds are left out)."""
|
|
316
|
+
start = file.casefold()
|
|
317
|
+
seen, stack, out = {start}, [start], set()
|
|
318
|
+
while stack:
|
|
319
|
+
f = stack.pop()
|
|
320
|
+
b = index["files"].get(f)
|
|
321
|
+
if b is None:
|
|
322
|
+
continue
|
|
323
|
+
out.add(b)
|
|
324
|
+
for x in index["externals"].get(f, ()):
|
|
325
|
+
xf = x.casefold()
|
|
326
|
+
if xf not in seen:
|
|
327
|
+
seen.add(xf)
|
|
328
|
+
stack.append(xf)
|
|
329
|
+
return sorted(out)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SpineStage(Stage):
|
|
333
|
+
"""spine.skeleton: a SkeletonDataAsset -> its Spine files as `nnnotes spot` writes them (module documentation).
|
|
334
|
+
Subjects from the census results, the script table (link.scripts:all) and the fact "selected"; inputs from the
|
|
335
|
+
fact "bundles". Waits for census tasks that have not run."""
|
|
336
|
+
name = SPINE
|
|
337
|
+
version = 1
|
|
338
|
+
after = (CENSUS, SCRIPTS)
|
|
339
|
+
ATOMS = {"spot.write_skeleton": impl_id(LIBRARIES, 2)}
|
|
340
|
+
|
|
341
|
+
def __init__(self):
|
|
342
|
+
self._memo = None
|
|
343
|
+
|
|
344
|
+
def __getstate__(self):
|
|
345
|
+
return {}
|
|
346
|
+
|
|
347
|
+
def __setstate__(self, state):
|
|
348
|
+
self.__init__()
|
|
349
|
+
|
|
350
|
+
def index(self, env) -> dict:
|
|
351
|
+
"""skeleton_index, kept while the census results, the script table and the selection stay the same."""
|
|
352
|
+
pending = env.pending(CENSUS)
|
|
353
|
+
if pending:
|
|
354
|
+
raise Pending(pending[0])
|
|
355
|
+
selected = tuple(sorted(env.fact("selected")))
|
|
356
|
+
sig = (selected, tuple(env.key(t) for t in env.tasks(CENSUS)), env.key(SCRIPTS_TASK))
|
|
357
|
+
if self._memo is None or self._memo[0] != sig:
|
|
358
|
+
self._memo = (sig, skeleton_index(env, set(selected)))
|
|
359
|
+
return self._memo[1]
|
|
360
|
+
|
|
361
|
+
def _subject(self, subject: str, env) -> tuple[dict, tuple]:
|
|
362
|
+
idx = self.index(env)
|
|
363
|
+
if subject not in idx["subjects"]:
|
|
364
|
+
raise ValueError(f"{self.name}: no SkeletonDataAsset {subject} in the selected bundles")
|
|
365
|
+
return idx, idx["subjects"][subject]
|
|
366
|
+
|
|
367
|
+
def subjects(self, env) -> list[str]:
|
|
368
|
+
return sorted(self.index(env)["subjects"])
|
|
369
|
+
|
|
370
|
+
def closure(self, subject: str, env) -> list[str]:
|
|
371
|
+
idx, (_stable, file, _pid) = self._subject(subject, env)
|
|
372
|
+
return reach(idx, file)
|
|
373
|
+
|
|
374
|
+
def inputs(self, subject: str, env) -> list[Input]:
|
|
375
|
+
bundles, what = env.fact("bundles"), f"{self.name} {subject}"
|
|
376
|
+
out = []
|
|
377
|
+
for stable in self.closure(subject, env):
|
|
378
|
+
inp = _fact_input(bundles, stable, what)
|
|
379
|
+
out.append(Input(f"bundle:{stable}", inp.sha256, inp.size, inp.name, inp.locators))
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
def context(self, subject: str, env) -> dict:
|
|
383
|
+
_idx, (_stable, file, pid) = self._subject(subject, env)
|
|
384
|
+
return {"object": contract.object_id(file, pid)}
|
|
385
|
+
|
|
386
|
+
def depends(self, subject: str, env) -> list[str]:
|
|
387
|
+
return sorted({*(contract.task_id(CENSUS, s) for s in self.closure(subject, env)), SCRIPTS_TASK})
|
|
388
|
+
|
|
389
|
+
def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
|
|
390
|
+
"""From the closure's bytes (least squares over the skeletons of a catalog, as Live2DStage.estimate)."""
|
|
391
|
+
size = sum(i.size for i in inputs)
|
|
392
|
+
return Cost(0.27 + size * 1.9e-7, (440 << 20) + 20 * size)
|
|
393
|
+
|
|
394
|
+
def run(self, task, store) -> Output:
|
|
395
|
+
_check_atoms(task, self.ATOMS)
|
|
396
|
+
from . import spot, unity
|
|
397
|
+
oid = task.context["object"]
|
|
398
|
+
paths = [store.open_input(i) for i in task.inputs if i.role.startswith("bundle:")]
|
|
399
|
+
env = unity.load_closure(paths)
|
|
400
|
+
file, pid = contract.parse_object_id(oid)
|
|
401
|
+
written: dict[str, bytes] = {}
|
|
402
|
+
try:
|
|
403
|
+
sf = env.get_cab(file)
|
|
404
|
+
o = sf.objects[pid] if sf is not None and pid in getattr(sf, "objects", {}) else None
|
|
405
|
+
if o is None:
|
|
406
|
+
raise KeyError(f"{oid} is not in the task's bundles")
|
|
407
|
+
record = spot.write_skeleton(o, written.__setitem__)
|
|
408
|
+
except (MemoryError, OSError):
|
|
409
|
+
raise
|
|
410
|
+
except Exception as e:
|
|
411
|
+
return Output([], [contract.item(oid, "failed", why=contract.reason("failed.export", _message(e)),
|
|
412
|
+
cls="MonoBehaviour")])
|
|
413
|
+
arts = []
|
|
414
|
+
for name in sorted(written):
|
|
415
|
+
data = written[name]
|
|
416
|
+
if name == record["skeleton"]:
|
|
417
|
+
ext, kind, facts = _ext(name), "spine.skeleton", record
|
|
418
|
+
elif name in record["atlases"]:
|
|
419
|
+
ext, kind, facts = _ext(name, "atlas"), "spine.atlas", {}
|
|
420
|
+
else:
|
|
421
|
+
ext, kind, facts = "png", "spine.page", {}
|
|
422
|
+
arts.append(contract.artifact(contract.artifact_id(task.id, name), store.add(data, ext),
|
|
423
|
+
contract.provenance(task), {"kind": kind, "format": ext, "facts": facts}))
|
|
424
|
+
return Output(arts, [contract.item(oid, "exported", artifacts=[a["id"] for a in arts], cls="MonoBehaviour")])
|
nnnotes/motion.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""CubismFadeMotionData (Unity) -> motion3.json.
|
|
2
|
+
|
|
3
|
+
Cubism-for-Unity keeps each motion's per-parameter curves, keyed by Cubism id,
|
|
4
|
+
in a CubismFadeMotionData MonoBehaviour. Unity AnimationCurve segments are cubic
|
|
5
|
+
Hermite; a Hermite segment is exactly a cubic Bezier with control points at 1/3
|
|
6
|
+
of the segment (or at the key weights when weighted tangents are used), so the
|
|
7
|
+
conversion is lossless. Stepped keys (infinite tangents) map to Cubism stepped
|
|
8
|
+
segments.
|
|
9
|
+
|
|
10
|
+
Looping is not a property of the data here: every imported AnimationClip is
|
|
11
|
+
flagged loopTime, and the game's motion controller decides looping per call.
|
|
12
|
+
Meta.Loop is therefore written false and left to the player.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .jsonio import write_json
|
|
20
|
+
|
|
21
|
+
SEG_LINEAR, SEG_BEZIER, SEG_STEPPED = 0, 1, 2
|
|
22
|
+
WEIGHT_IN, WEIGHT_OUT = 1, 2 # UnityEngine.WeightedMode flags
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _segments(kf: list[dict]):
|
|
26
|
+
t0, v0 = float(kf[0]["time"]), float(kf[0]["value"])
|
|
27
|
+
segs: list[float] = [t0, v0]
|
|
28
|
+
nseg = 0
|
|
29
|
+
npts = 1
|
|
30
|
+
restricted = True
|
|
31
|
+
for k0, k1 in zip(kf, kf[1:]):
|
|
32
|
+
a_t, a_v = float(k0["time"]), float(k0["value"])
|
|
33
|
+
b_t, b_v = float(k1["time"]), float(k1["value"])
|
|
34
|
+
out0, in1 = float(k0["outSlope"]), float(k1["inSlope"])
|
|
35
|
+
dt = b_t - a_t
|
|
36
|
+
nseg += 1
|
|
37
|
+
if math.isinf(out0) or math.isinf(in1):
|
|
38
|
+
segs += [SEG_STEPPED, b_t, b_v]
|
|
39
|
+
npts += 1
|
|
40
|
+
continue
|
|
41
|
+
w_out = float(k0["outWeight"]) if int(k0["weightedMode"]) & WEIGHT_OUT else 1.0 / 3.0
|
|
42
|
+
w_in = float(k1["inWeight"]) if int(k1["weightedMode"]) & WEIGHT_IN else 1.0 / 3.0
|
|
43
|
+
if w_out != 1.0 / 3.0 or w_in != 1.0 / 3.0:
|
|
44
|
+
restricted = False
|
|
45
|
+
segs += [SEG_BEZIER,
|
|
46
|
+
a_t + w_out * dt, a_v + out0 * w_out * dt,
|
|
47
|
+
b_t - w_in * dt, b_v - in1 * w_in * dt,
|
|
48
|
+
b_t, b_v]
|
|
49
|
+
npts += 3
|
|
50
|
+
return segs, nseg, npts, restricted
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _clip_rates(env) -> dict[str, float]:
|
|
54
|
+
return {o.read_typetree()["m_Name"]: float(o.read_typetree()["m_SampleRate"])
|
|
55
|
+
for o in env.objects if o.type.name == "AnimationClip"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def convert_fade(fade: dict, fps: float | None) -> dict:
|
|
59
|
+
curves = []
|
|
60
|
+
total_seg = total_pts = 0
|
|
61
|
+
restricted = True
|
|
62
|
+
ids, crv = fade["ParameterIds"], fade["ParameterCurves"]
|
|
63
|
+
fin, fout = fade["ParameterFadeInTimes"], fade["ParameterFadeOutTimes"]
|
|
64
|
+
if not len(ids) == len(crv) == len(fin) == len(fout):
|
|
65
|
+
raise ValueError(f"{fade['MotionName']}: parameter arrays differ in length")
|
|
66
|
+
for i, (pid, curve) in enumerate(zip(ids, crv)):
|
|
67
|
+
kf = curve["m_Curve"]
|
|
68
|
+
if not kf:
|
|
69
|
+
# an empty AnimationCurve evaluates to 0 in Unity; motion3 cannot say that
|
|
70
|
+
raise NotImplementedError(f"{fade['MotionName']}: empty curve for {pid}")
|
|
71
|
+
segs, nseg, npts, r = _segments(kf)
|
|
72
|
+
restricted = restricted and r
|
|
73
|
+
c = {"Target": "Parameter", "Id": pid, "Segments": segs}
|
|
74
|
+
if fin[i] >= 0:
|
|
75
|
+
c["FadeInTime"] = fin[i]
|
|
76
|
+
if fout[i] >= 0:
|
|
77
|
+
c["FadeOutTime"] = fout[i]
|
|
78
|
+
curves.append(c)
|
|
79
|
+
total_seg += nseg
|
|
80
|
+
total_pts += npts
|
|
81
|
+
meta = {
|
|
82
|
+
"Duration": float(fade["MotionLength"]),
|
|
83
|
+
"Loop": False,
|
|
84
|
+
"AreBeziersRestricted": restricted,
|
|
85
|
+
"CurveCount": len(curves),
|
|
86
|
+
"TotalSegmentCount": total_seg,
|
|
87
|
+
"TotalPointCount": total_pts,
|
|
88
|
+
"UserDataCount": 0,
|
|
89
|
+
"TotalUserDataSize": 0,
|
|
90
|
+
}
|
|
91
|
+
# Fps is informational for Cubism runtimes; written only when the
|
|
92
|
+
# motion's AnimationClip is present to state it.
|
|
93
|
+
if fps:
|
|
94
|
+
meta["Fps"] = fps
|
|
95
|
+
if fade["FadeInTime"] >= 0:
|
|
96
|
+
meta["FadeInTime"] = fade["FadeInTime"]
|
|
97
|
+
if fade["FadeOutTime"] >= 0:
|
|
98
|
+
meta["FadeOutTime"] = fade["FadeOutTime"]
|
|
99
|
+
return {"Version": 3, "Meta": meta, "Curves": curves}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def motion_name(fade: dict) -> str:
|
|
103
|
+
return Path(str(fade["MotionName"])).name.removesuffix(".motion3.json")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_motions(env, out_dir: Path) -> dict[str, str]:
|
|
107
|
+
"""Every CubismFadeMotionData in env -> <out_dir>/<motion>.motion3.json.
|
|
108
|
+
|
|
109
|
+
Returns {motionName: fileName}. Also writes `_fades.json` with the
|
|
110
|
+
model-level fade overrides (ModelFadeIn/OutTime, -1 = unset) the model3
|
|
111
|
+
motion entries need.
|
|
112
|
+
"""
|
|
113
|
+
out_dir = Path(out_dir)
|
|
114
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
rates = _clip_rates(env)
|
|
116
|
+
manifest: dict[str, str] = {}
|
|
117
|
+
model_fades: dict[str, dict] = {}
|
|
118
|
+
for o in env.objects:
|
|
119
|
+
if o.type.name != "MonoBehaviour":
|
|
120
|
+
continue
|
|
121
|
+
if o.read().m_Script.read().m_ClassName != "CubismFadeMotionData":
|
|
122
|
+
continue
|
|
123
|
+
tt = o.read_typetree()
|
|
124
|
+
name = motion_name(tt)
|
|
125
|
+
fn = f"{name}.motion3.json"
|
|
126
|
+
write_json(out_dir / fn, convert_fade(tt, rates.get(name)), indent=None)
|
|
127
|
+
manifest[name] = fn
|
|
128
|
+
model_fades[name] = {"FadeInTime": tt["ModelFadeInTime"],
|
|
129
|
+
"FadeOutTime": tt["ModelFadeOutTime"]}
|
|
130
|
+
write_json(out_dir / "_fades.json", model_fades, indent=None, ensure_ascii=True)
|
|
131
|
+
return manifest
|