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/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