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/store.py ADDED
@@ -0,0 +1,518 @@
1
+ """The content-addressed store of the asset stages: a documented format, and the cache of their work.
2
+
3
+ <store>/cas/sha256/ab/<sha> bytes by content id, write-once (no extension: the records carry it)
4
+ <store>/ac/ab/<key>.json task results (nnnotes.result/1) by task key; written after every byte they
5
+ name is in cas/, so a result present is a complete result (the commit)
6
+ <store>/inputs/<kind>.jsonl input identities: name -> sha256, size, with the (path, size, mtime) of the
7
+ file that was hashed (a memo: a file seen unchanged is not hashed again);
8
+ bundles.jsonl, raw.jsonl, files.jsonl (files verified by path only)
9
+ <store>/catalogs/ versioned catalogs (catalogdb)
10
+ <store>/runs/<run>.json run manifests (nnnotes.run/1), deterministic
11
+ <store>/runs/<run>.log.jsonl the log of the run's last execution (timings, workers, errors)
12
+ <store>/runs/selections/<digest> the id of the latest run of a selection (digest of its selection)
13
+ <store>/costs/<key>.json the measured cost of a task's last run (nnnotes.cost/1)
14
+
15
+ Every file is written through a temporary file and a rename, so concurrent writers (threads, processes, machines on
16
+ a shared file system) never see or leave a partial file, and a crash before a result's entry leaves no false hit.
17
+ The files of cas/ are read-only (a layout may hard-link them: an edit of such a file fails instead of changing the
18
+ store).
19
+ Only costs/, runs/ and inputs/ hold non-deterministic data; cas/ and ac/ are a function of the tasks run.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import threading
27
+ import time
28
+ from pathlib import Path
29
+
30
+ from . import contract
31
+ from .cache import temp_path
32
+ from .contract import Input
33
+
34
+ INPUT_KINDS = ("bundles", "raw", "files")
35
+
36
+
37
+ class InputMissing(FileNotFoundError):
38
+ """No locator of an input gives a file with its content id."""
39
+
40
+
41
+ class StoreConflict(RuntimeError):
42
+ """A result already in the store differs from the one being committed for the same key: the stage's output is
43
+ not a function of its key (a nondeterministic stage, or a change without a version bump)."""
44
+
45
+
46
+ def _replace(tmp: Path, dst: Path, same) -> bool:
47
+ """Rename `tmp` onto `dst`; False when another writer put `dst` first (`same(dst)` true) and `tmp` was dropped.
48
+ On some systems a rename onto (or a read of) a file another process is replacing or reading fails for a
49
+ moment: retried, as web.Store does."""
50
+ try:
51
+ for i in range(20):
52
+ try:
53
+ os.replace(tmp, dst)
54
+ return True
55
+ except PermissionError:
56
+ try:
57
+ if dst.exists() and same(dst):
58
+ _drop(tmp)
59
+ return False
60
+ except PermissionError:
61
+ pass
62
+ time.sleep(0.1 * (i + 1))
63
+ raise RuntimeError(f"could not store {dst}")
64
+ except BaseException:
65
+ _drop(tmp)
66
+ raise
67
+
68
+
69
+ def _drop(tmp: Path) -> None:
70
+ """Remove a temporary file, read-only or not (Windows refuses to delete a read-only file)."""
71
+ try:
72
+ tmp.unlink(missing_ok=True)
73
+ except PermissionError:
74
+ os.chmod(tmp, 0o666)
75
+ tmp.unlink(missing_ok=True)
76
+
77
+
78
+ def _read(p: Path) -> bytes | None:
79
+ """A file's bytes, None when it does not exist (retried while another process is replacing it)."""
80
+ for i in range(20):
81
+ try:
82
+ return p.read_bytes()
83
+ except FileNotFoundError:
84
+ return None
85
+ except PermissionError:
86
+ time.sleep(0.05 * (i + 1))
87
+ return p.read_bytes()
88
+
89
+
90
+ def _write(dst: Path, data: bytes, same=None, read_only: bool = False) -> bool:
91
+ """Write `data` to `dst` atomically (creating the directory); with `same`, keep an existing `dst` for which
92
+ same(dst) holds; `read_only`: the file is read-only (store objects). True when this call wrote the file."""
93
+ dst.parent.mkdir(parents=True, exist_ok=True)
94
+ tmp = temp_path(dst)
95
+ try:
96
+ with open(tmp, "xb") as f:
97
+ f.write(data)
98
+ if read_only:
99
+ os.chmod(tmp, 0o444)
100
+ except BaseException:
101
+ _drop(tmp)
102
+ raise
103
+ return _replace(tmp, dst, same or (lambda p: False))
104
+
105
+
106
+ def write_file(dst, data: bytes) -> None:
107
+ """Write (or overwrite) a file atomically."""
108
+ _write(Path(dst), data)
109
+
110
+
111
+ def write_doc(dst, doc) -> str:
112
+ """Write a document's canonical bytes (contract.encode) atomically, encoded as they are written; their sha256."""
113
+ dst = Path(dst)
114
+ dst.parent.mkdir(parents=True, exist_ok=True)
115
+ tmp = temp_path(dst)
116
+ try:
117
+ with open(tmp, "xb") as f:
118
+ h = hashlib.sha256()
119
+ try:
120
+ for b in contract.encode_chunks(doc):
121
+ h.update(b)
122
+ f.write(b)
123
+ except ValueError: # a non-finite number: encode() writes those
124
+ f.seek(0)
125
+ f.truncate()
126
+ data = contract.encode(doc)
127
+ h = hashlib.sha256(data)
128
+ f.write(data)
129
+ _replace(tmp, dst, lambda p: False)
130
+ except BaseException:
131
+ tmp.unlink(missing_ok=True)
132
+ raise
133
+ return h.hexdigest()
134
+
135
+
136
+ def _append(path: Path, line: str) -> None:
137
+ """Append one line with one write (O_APPEND): lines of concurrent writers do not interleave."""
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o666)
140
+ try:
141
+ os.write(fd, line.encode("utf-8"))
142
+ finally:
143
+ os.close(fd)
144
+
145
+
146
+ def _lines(path: Path):
147
+ """The JSON lines of a file; a torn or malformed line (a writer that died mid-line) is skipped."""
148
+ try:
149
+ text = path.read_text(encoding="utf-8")
150
+ except OSError:
151
+ return
152
+ for line in text.splitlines():
153
+ try:
154
+ yield json.loads(line)
155
+ except ValueError:
156
+ continue
157
+
158
+
159
+ class Store:
160
+ """A store directory. `cache`: the directory "cache" locators are relative to; `fetch(input, locator) -> Path`:
161
+ how a "catalog" locator is fetched (none: such locators are skipped)."""
162
+
163
+ def __init__(self, root, *, cache=None, fetch=None):
164
+ self.root = Path(root)
165
+ self.cache = Path(cache) if cache else None
166
+ self.fetch = fetch
167
+ self.written = self.reused = 0
168
+ self._have: set[str] = set() # content ids this store object wrote or found
169
+ self._memo: dict | None = None # (path, size, mtime_ns) -> (sha256, size)
170
+ self._names: dict[str, dict] = {} # kind -> {name: (sha256, size)}
171
+ self._docs: dict[str, object] = {} # the documents document() decoded last
172
+ self._lock = threading.Lock()
173
+
174
+ def __getstate__(self): # a store crosses to worker processes as its settings
175
+ return {"root": self.root, "cache": self.cache, "fetch": self.fetch}
176
+
177
+ def __setstate__(self, state):
178
+ self.__init__(state["root"], cache=state["cache"], fetch=state["fetch"])
179
+
180
+ def __repr__(self) -> str:
181
+ return f"Store({str(self.root)!r})"
182
+
183
+ @property
184
+ def catalogs(self) -> Path:
185
+ """The directory of versioned catalogs (catalogdb)."""
186
+ return self.root / "catalogs"
187
+
188
+ # ---------------------------------------------------------------- content
189
+ def path(self, sha: str) -> Path:
190
+ if not contract.is_sha256(sha):
191
+ raise ValueError(f"not a sha256: {sha!r}")
192
+ return self.root / "cas" / "sha256" / sha[:2] / sha
193
+
194
+ def has(self, sha: str, size: int | None = None) -> bool:
195
+ if sha in self._have:
196
+ return True
197
+ p = self.path(sha)
198
+ try:
199
+ n = p.stat().st_size
200
+ except OSError:
201
+ return False
202
+ if size is not None and n != size:
203
+ return False
204
+ self._have.add(sha)
205
+ return True
206
+
207
+ def put(self, data) -> str:
208
+ """Store bytes; their content id. Write-once: bytes already stored are not written again."""
209
+ data = bytes(data)
210
+ sha = contract.sha256(data)
211
+ if self.has(sha, len(data)):
212
+ self.reused += 1
213
+ return sha
214
+ if _write(self.path(sha), data, lambda p: p.stat().st_size == len(data), read_only=True):
215
+ self.written += 1
216
+ else:
217
+ self.reused += 1
218
+ self._have.add(sha)
219
+ return sha
220
+
221
+ def put_file(self, src) -> tuple[str, int]:
222
+ """Store a file's bytes (hashed and copied in blocks); (content id, size)."""
223
+ src = Path(src)
224
+ sha, size = self.identify(src)
225
+ if self.has(sha, size):
226
+ self.reused += 1
227
+ return sha, size
228
+ dst = self.path(sha)
229
+ dst.parent.mkdir(parents=True, exist_ok=True)
230
+ tmp = temp_path(dst)
231
+ h = hashlib.sha256()
232
+ try:
233
+ with open(src, "rb") as f, open(tmp, "xb") as g:
234
+ while block := f.read(1 << 20):
235
+ h.update(block)
236
+ g.write(block)
237
+ except BaseException:
238
+ tmp.unlink(missing_ok=True)
239
+ raise
240
+ if h.hexdigest() != sha:
241
+ tmp.unlink(missing_ok=True)
242
+ raise RuntimeError(f"{src} changed while it was stored")
243
+ os.chmod(tmp, 0o444)
244
+ if _replace(tmp, dst, lambda p: p.stat().st_size == size):
245
+ self.written += 1
246
+ self._have.add(sha)
247
+ return sha, size
248
+
249
+ def add(self, data, ext: str, media: str | None = None) -> dict:
250
+ """Store bytes; the content part of their artifact record."""
251
+ c = contract.content(data, ext, media)
252
+ self.put(data)
253
+ return c
254
+
255
+ def add_chunks(self, chunks, ext: str, media: str | None = None) -> dict:
256
+ """Store the bytes of a sequence of byte strings, written and hashed as they come (never held whole); the
257
+ content part of their artifact record, as add() gives it for the joined bytes."""
258
+ tmp = temp_path(self.root / "cas" / "sha256" / "chunks") # the content id is known at the end
259
+ tmp.parent.mkdir(parents=True, exist_ok=True)
260
+ h, size = hashlib.sha256(), 0
261
+ try:
262
+ with open(tmp, "xb") as f:
263
+ for b in chunks:
264
+ h.update(b)
265
+ f.write(b)
266
+ size += len(b)
267
+ except BaseException:
268
+ tmp.unlink(missing_ok=True)
269
+ raise
270
+ sha = h.hexdigest()
271
+ if self.has(sha, size):
272
+ tmp.unlink(missing_ok=True)
273
+ self.reused += 1
274
+ else:
275
+ dst = self.path(sha)
276
+ dst.parent.mkdir(parents=True, exist_ok=True)
277
+ os.chmod(tmp, 0o444)
278
+ if _replace(tmp, dst, lambda p: p.stat().st_size == size):
279
+ self.written += 1
280
+ else:
281
+ self.reused += 1
282
+ self._have.add(sha)
283
+ ext = ext.lower().lstrip(".")
284
+ return {"sha256": sha, "size": size, "mediaType": media or contract.media_type(ext), "ext": ext}
285
+
286
+ def read(self, sha: str) -> bytes:
287
+ return self.path(sha).read_bytes()
288
+
289
+ def document(self, sha: str):
290
+ """The JSON document stored as `sha`, decoded; the last two decoded are kept (a catalog index is read at
291
+ planning and again by the stages it describes) and shared: callers must not change them."""
292
+ with self._lock:
293
+ doc = self._docs.get(sha)
294
+ if doc is None:
295
+ doc = contract.loads(self.read(sha))
296
+ with self._lock:
297
+ self._docs[sha] = doc
298
+ while len(self._docs) > 2:
299
+ del self._docs[next(iter(self._docs))]
300
+ return doc
301
+
302
+ # ---------------------------------------------------------------- results (the action cache)
303
+ def result_path(self, key: str) -> Path:
304
+ if not contract.is_sha256(key):
305
+ raise ValueError(f"not a task key: {key!r}")
306
+ return self.root / "ac" / key[:2] / f"{key}.json"
307
+
308
+ def result(self, key: str) -> dict | None:
309
+ """The committed result of a task key, or None."""
310
+ data = _read(self.result_path(key))
311
+ return None if data is None else contract.loads(data)
312
+
313
+ def has_result(self, key: str) -> bool:
314
+ return self.result_path(key).is_file()
315
+
316
+ def commit(self, doc: dict) -> bool:
317
+ """Write a result entry, after checking that the bytes of every artifact it names are stored. Write-once: an
318
+ entry already there must be identical (else StoreConflict). True when this call wrote it."""
319
+ if doc.get("schema") != contract.RESULT:
320
+ raise ValueError("not a result document")
321
+ for a in doc["artifacts"]:
322
+ c = a["content"]
323
+ if not self.has(c["sha256"], c["size"]):
324
+ raise RuntimeError(f"result {doc['key']}: bytes of {a['id']} ({c['sha256']}) are not stored")
325
+ data = contract.encode(doc)
326
+ dst = self.result_path(doc["key"])
327
+
328
+ def same(p: Path) -> bool:
329
+ old = _read(p)
330
+ if old is None:
331
+ return False
332
+ if old != data:
333
+ raise StoreConflict(f"result {doc['key']} differs from the one in the store ({dst})")
334
+ return True
335
+
336
+ if same(dst):
337
+ return False
338
+ return _write(dst, data, same)
339
+
340
+ # ---------------------------------------------------------------- inputs
341
+ def _load_memo(self) -> dict:
342
+ if self._memo is None:
343
+ memo: dict = {}
344
+ for kind in INPUT_KINDS:
345
+ names = self._names.setdefault(kind, {})
346
+ for d in _lines(self.root / "inputs" / f"{kind}.jsonl"):
347
+ try:
348
+ entry = (d["sha256"], int(d["size"]))
349
+ if d.get("path") is not None:
350
+ memo[(d["path"], int(d["fileSize"]), int(d["mtimeNs"]))] = entry
351
+ if d.get("name") is not None:
352
+ names[d["name"]] = entry
353
+ except (KeyError, TypeError, ValueError):
354
+ continue
355
+ self._memo = memo
356
+ return self._memo
357
+
358
+ def identify(self, path, kind: str = "files", name: str | None = None) -> tuple[str, int]:
359
+ """(sha256, size) of a file: from the memo when the file's path, size and mtime are unchanged, else hashed
360
+ and remembered (inputs/<kind>.jsonl, with `name`)."""
361
+ if kind not in INPUT_KINDS:
362
+ raise ValueError(f"unknown input kind {kind!r}")
363
+ p = Path(path).resolve()
364
+ st = p.stat()
365
+ memo_key = (str(p), st.st_size, st.st_mtime_ns)
366
+ with self._lock:
367
+ memo = self._load_memo()
368
+ hit = memo.get(memo_key)
369
+ if hit is not None and (name is None or self._names[kind].get(name) == hit):
370
+ return hit
371
+ if hit is None:
372
+ with open(p, "rb") as f:
373
+ hit = (hashlib.file_digest(f, "sha256").hexdigest(), st.st_size)
374
+ line = {"name": name, "sha256": hit[0], "size": hit[1], "path": str(p), "fileSize": st.st_size,
375
+ "mtimeNs": st.st_mtime_ns}
376
+ _append(self.root / "inputs" / f"{kind}.jsonl", json.dumps(line, ensure_ascii=False, sort_keys=True) + "\n")
377
+ with self._lock:
378
+ self._memo[memo_key] = hit
379
+ if name is not None:
380
+ self._names[kind][name] = hit
381
+ return hit
382
+
383
+ def named(self, kind: str, name: str) -> tuple[str, int] | None:
384
+ """The last identity recorded for an input name, or None."""
385
+ with self._lock:
386
+ self._load_memo()
387
+ return self._names.get(kind, {}).get(name)
388
+
389
+ def open_input(self, inp: Input) -> Path:
390
+ """A file holding the bytes of `inp`: its locators in order (none: the store), each checked against the
391
+ content id (store: by name and size; files: hashed, memoized). Raises InputMissing."""
392
+ tried = []
393
+ for loc in inp.locators or ({"kind": "store"},):
394
+ kind = loc["kind"]
395
+ try:
396
+ if kind == "store":
397
+ if self.has(inp.sha256, inp.size):
398
+ return self.path(inp.sha256)
399
+ tried.append("store: absent")
400
+ continue
401
+ if kind == "catalog":
402
+ if self.fetch is None:
403
+ tried.append("catalog: no fetcher")
404
+ continue
405
+ p = Path(self.fetch(inp, loc))
406
+ elif kind == "cache":
407
+ if self.cache is None:
408
+ tried.append("cache: no cache directory")
409
+ continue
410
+ p = self.cache / loc["path"]
411
+ else:
412
+ p = Path(loc["path"])
413
+ if not p.is_file() or p.stat().st_size != inp.size:
414
+ tried.append(f"{kind}: absent")
415
+ continue
416
+ if self.identify(p)[0] != inp.sha256:
417
+ tried.append(f"{kind}: other content")
418
+ continue
419
+ return p
420
+ except OSError as e:
421
+ tried.append(f"{kind}: {type(e).__name__}")
422
+ raise InputMissing(f"input {inp.role} ({inp.name or inp.sha256}): {'; '.join(tried)}")
423
+
424
+ def input_bytes(self, inp: Input) -> bytes:
425
+ return self.open_input(inp).read_bytes()
426
+
427
+ # ---------------------------------------------------------------- verification
428
+ def verify(self, *, quick: bool = False, workers: int = 8, limit: int = 100) -> dict:
429
+ """Check the store: every cas/ file has the content its name says (`quick`: only that it is a file of a
430
+ well-formed name; its size is checked against the results naming it), every ac/ entry is a result whose
431
+ name is its key, whose key is the key of its parts and whose artifacts' bytes are stored with their size.
432
+ -> {"contents", "results", "problems": [{path, problem}] (the first `limit`, sorted), "problemCount"}."""
433
+ from concurrent.futures import ThreadPoolExecutor
434
+ problems = []
435
+ top = self.root / "cas" / "sha256"
436
+ cas = sorted([*top.glob("*/*"), *top.glob("*.part")]) # add_chunks writes its temporary file in top
437
+
438
+ def content(p: Path):
439
+ name = p.name
440
+ if not contract.is_sha256(name) or p.parent.name != name[:2]:
441
+ return p, "not a content id" if not name.endswith(".part") else "temporary file left"
442
+ if not quick:
443
+ with open(p, "rb") as f:
444
+ if hashlib.file_digest(f, "sha256").hexdigest() != name:
445
+ return p, "content differs from its id"
446
+ return None
447
+
448
+ with ThreadPoolExecutor(max(1, workers)) as pool:
449
+ problems += [r for r in pool.map(content, cas) if r is not None]
450
+ sizes = {}
451
+ results = sorted((self.root / "ac").glob("*/*.json"))
452
+ for p in results:
453
+ try:
454
+ doc = contract.loads(p.read_bytes())
455
+ except (OSError, ValueError) as e:
456
+ problems.append((p, f"unreadable: {type(e).__name__}"))
457
+ continue
458
+ key = p.name[:-len(".json")]
459
+ if doc.get("schema") != contract.RESULT or doc.get("key") != key:
460
+ problems.append((p, "not the result of its key"))
461
+ continue
462
+ if contract.digest(doc.get("keyParts")) != key:
463
+ problems.append((p, "key is not the key of its parts"))
464
+ for a in doc.get("artifacts", ()):
465
+ c = a["content"]
466
+ if c["sha256"] not in sizes:
467
+ try:
468
+ sizes[c["sha256"]] = self.path(c["sha256"]).stat().st_size
469
+ except (OSError, ValueError):
470
+ sizes[c["sha256"]] = None
471
+ if sizes[c["sha256"]] != c["size"]:
472
+ problems.append((p, f"artifact {a['id']}: bytes "
473
+ f"{'missing' if sizes[c['sha256']] is None else 'of another size'}"))
474
+ rows = sorted({(p.relative_to(self.root).as_posix(), m) for p, m in problems})
475
+ return {"contents": len(cas), "results": len(results), "problemCount": len(rows),
476
+ "problems": [{"path": a, "problem": b} for a, b in rows[:limit]]}
477
+
478
+ # ---------------------------------------------------------------- runs and costs
479
+ def run_path(self, run_id: str) -> Path:
480
+ return self.root / "runs" / f"{run_id}.json"
481
+
482
+ def write_run(self, doc: dict, log: list[dict] | None = None) -> Path:
483
+ """Write a run manifest (and the log of this execution), and make it the latest run of its selection."""
484
+ if doc.get("schema") != contract.RUN:
485
+ raise ValueError("not a run document")
486
+ p = self.run_path(doc["id"])
487
+ write_file(p, contract.encode(doc))
488
+ if log is not None:
489
+ text = "".join(json.dumps(line, ensure_ascii=False, sort_keys=True) + "\n" for line in log)
490
+ write_file(p.with_name(f"{doc['id']}.log.jsonl"), text.encode("utf-8"))
491
+ write_file(self._selection_path(doc["selection"]), (doc["id"] + "\n").encode("ascii"))
492
+ return p
493
+
494
+ def _selection_path(self, selection) -> Path:
495
+ return self.root / "runs" / "selections" / contract.digest(list(selection))
496
+
497
+ def run(self, run_id: str) -> dict | None:
498
+ try:
499
+ return contract.loads(self.run_path(run_id).read_bytes())
500
+ except OSError:
501
+ return None
502
+
503
+ def latest_run(self, selection) -> dict | None:
504
+ """The latest run manifest written for `selection`, or None."""
505
+ try:
506
+ rid = self._selection_path(selection).read_text(encoding="ascii").strip()
507
+ except OSError:
508
+ return None
509
+ return self.run(rid)
510
+
511
+ def write_cost(self, key: str, cost: dict) -> None:
512
+ write_file(self.root / "costs" / f"{key}.json", contract.encode(cost))
513
+
514
+ def cost(self, key: str) -> dict | None:
515
+ try:
516
+ return contract.loads((self.root / "costs" / f"{key}.json").read_bytes())
517
+ except OSError:
518
+ return None
nnnotes/story.py ADDED
@@ -0,0 +1,142 @@
1
+ """One ADV episode -> a self-contained story directory that a player can load without game code.
2
+
3
+ <out>/episode.json command list + text/sound/video shards + resource closure (adv.py)
4
+ <out>/live2d/<model>/ every Live2D model the episode uses: moc3, atlas
5
+ pages, full prefab (live2d.extract_runtime)
6
+ <out>/audio/<cueSheet>/ every cue sheet in the episode's -SoundCueSheet
7
+ shard, decoded per cue + cues.json (cri.py)
8
+ <out>/scene.json, textures/, shaders/
9
+ player graphics, cameras, ADV fields, volumes,
10
+ settings and stages (advscene.py); textures/ and
11
+ shaders/ also hold those of the kind files below
12
+ <out>/frames.json, effects.json, posteffects.json, stills.json, talkwindows.json, chat.json
13
+ the episode's frames, particle effects, post-effect
14
+ profiles, stills, talk windows and chat assets
15
+ (advmedia.py; each written only when used)
16
+ <out>/videos/ Movie / Clip videos as WebM + videos.json (advvideo.py)
17
+ <out>/ui/ ADV front canvas UI: ui.json, packed textures,
18
+ UI shaders; also the rule transitions (advui.py)
19
+ <out>/crilips/ crilips.json + crilips.bin: the CRI Lips analysis data read from the
20
+ APK (crilips.py), when a voice reaches the CRI Lips path (needs_cri_lips)
21
+ <out>/story.json index of the above (a kind file the episode does not use is null)
22
+
23
+ Every resource kind of the closure has an exporter (KIND_OUTPUT); the build stops before writing anything when a
24
+ resource is missing from the catalog or of a kind without one.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+
31
+ from .catalog import Catalog
32
+ from .jsonio import write_json
33
+ from .player import PlayerData
34
+ from . import adv, advmedia, advscene, advui, advvideo, cri, crilips, live2d
35
+
36
+ # resource kind -> where the story directory holds it
37
+ KIND_OUTPUT = {
38
+ "live2d": "live2d/<model>/",
39
+ "stage": "scene.json stages",
40
+ "transition": "ui/ui.json transitions",
41
+ "video": advvideo.INDEX,
42
+ **{k: advmedia.FILES[k][1] for k in advmedia.FILES},
43
+ **{k: advmedia.CHAT[1] for k in advmedia.CHAT_KINDS},
44
+ }
45
+
46
+
47
+ CRILIPS_DIR = "crilips"
48
+ # a model's MotionSync path (the player's CubismMotionSyncController.fromPrefab): both components on the prefab's root
49
+ MOTION_SYNC_COMPONENTS = ("CubismMotionSyncController", "Live2DMotionSyncCriAudioInput")
50
+ AIR_LIP_SYNC = ("airlipsync", "airlipsync_holdopen") # Talk Parameter1 modes without a voice analysis
51
+
52
+
53
+ def has_motion_sync(prefab: dict) -> bool:
54
+ """The exported prefab (live2d.extract_runtime) has a MotionSync controller with its CRI audio input on its root
55
+ node; a model without one takes its mouth from the voice's CRI Lips analysis."""
56
+ classes = {c.get("class") or c.get("type") for n in prefab["nodes"] if "/" not in n["path"]
57
+ for c in n["components"]}
58
+ return all(k in classes for k in MOTION_SYNC_COMPONENTS)
59
+
60
+
61
+ def needs_cri_lips(episode: dict, split_key: str, motion_sync: list[bool]) -> bool:
62
+ """A voice of the episode reaches the CRI Lips analysis: a lip-synced voice row (Talk or Voice with TargetName and
63
+ VoiceIDs; IgnoreLipSync and IgnoreData not set) when a model of the episode has no MotionSync controller
64
+ (`motion_sync`: per model), or a Talk row whose voice also drives other speakers, which use the analysis whatever
65
+ their model (StartSharedAnalyzerLipSync: the EveryoneLipSync mode, or more speakers than voices)."""
66
+ for c in episode["commands"]:
67
+ if c["cmd"] not in ("Talk", "Voice") or c.get("IgnoreData") or c.get("IgnoreLipSync"):
68
+ continue
69
+ if not c.get("TargetName") or not c.get("VoiceIDs"):
70
+ continue
71
+ if not all(motion_sync):
72
+ return True
73
+ if c["cmd"] == "Talk":
74
+ mode = (c.get("Parameter1") or "").strip().lower()
75
+ if mode == "everyonelipsync":
76
+ return True
77
+ if mode not in AIR_LIP_SYNC and len(c["TargetName"].split(split_key)) > len(c["VoiceIDs"]):
78
+ return True
79
+ return False
80
+
81
+
82
+ def check_kinds(resources: list[dict]) -> None:
83
+ """Raise for the first resource missing from the catalog or of a kind the story export does not handle."""
84
+ missing = [r["address"] for r in resources if not r["present"]]
85
+ if missing:
86
+ raise RuntimeError(f"resources not in catalog: {missing}")
87
+ for r in resources:
88
+ if r["kind"] not in KIND_OUTPUT:
89
+ raise NotImplementedError(f"resource kind {r['kind']}: {r['address']}")
90
+
91
+
92
+ def build(cat: Catalog, master: Path, player: PlayerData, adv_id: int, out_dir: Path,
93
+ audio_format: str = "flac", audio: bool = True, fonts: str = "open", *, ui: bool = True,
94
+ audio_options: dict | None = None) -> dict:
95
+ """Build the story directory. `audio=False` leaves the cue sheets undecoded (story.json `audio` is empty);
96
+ `fonts`: "open" exports no font data of the game, "game" exports it (advmedia, advui). `ui=False` writes no
97
+ ui/ (story.json still names ui/ui.json: the caller writes it, e.g. per language as storysite does);
98
+ `audio_options`: further cri.decode options of every cue sheet (flac_level, also). With audio, the CRI Lips data
99
+ is read from the catalog's APK when a voice reaches it (needs_cri_lips; story.json `crilips`, else null)."""
100
+ out_dir = Path(out_dir)
101
+ out_dir.mkdir(parents=True, exist_ok=True)
102
+ episode = adv.to_json(adv.extract(cat, master, adv_id))
103
+ check_kinds(episode["resources"])
104
+ write_json(out_dir / "episode.json", episode)
105
+
106
+ models = {}
107
+ for r in episode["resources"]:
108
+ if r["kind"] == "live2d":
109
+ name = r["address"].rsplit("/", 1)[-1]
110
+ res = live2d.extract_runtime(cat, r["address"], out_dir / "live2d" / name)
111
+ models[r["address"]] = {"dir": f"live2d/{name}", "moc3": res["moc3"],
112
+ "prefab": res["prefab"]}
113
+
114
+ sheets = {}
115
+ if audio:
116
+ for sheet in sorted({c["_cueSheetName"] for c in episode["cuesheets"].values()}):
117
+ cri.decode(cat, sheet, out_dir / "audio" / sheet, fmt=audio_format, **(audio_options or {}))
118
+ sheets[sheet] = f"audio/{sheet}"
119
+
120
+ media = advmedia.extract(cat, player, master, episode, out_dir, fonts=fonts)
121
+ scene = advscene.extract(cat, player, episode, out_dir, shaders=media["shaders"])
122
+ ui = advui.extract(cat, player, episode, out_dir, fonts=fonts, master=master) if ui else None
123
+ videos = advvideo.extract(cat, episode, out_dir)
124
+ lips = None
125
+ if audio:
126
+ split = json.loads((out_dir / "scene.json").read_text(encoding="utf-8"))["settings"]["playerSettings"][
127
+ "_targetNameSplitKey"]
128
+ ms = [has_motion_sync(json.loads((out_dir / m["dir"] / m["prefab"]).read_text(encoding="utf-8")))
129
+ for m in models.values()]
130
+ if needs_cri_lips(episode, split, ms):
131
+ if cat.apk is None:
132
+ raise crilips.CriLipsError("the CRI Lips data is read from the APK: set [paths] apk")
133
+ crilips.write_data(cat.apk, out_dir / CRILIPS_DIR)
134
+ lips = {"descriptor": f"{CRILIPS_DIR}/crilips.json", "data": f"{CRILIPS_DIR}/crilips.bin"}
135
+ index = {"advId": adv_id, "episode": "episode.json", "scene": "scene.json", "ui": "ui/ui.json",
136
+ "models": models, "audio": sheets, **media["files"],
137
+ "videos": advvideo.INDEX if videos else None, "crilips": lips}
138
+ write_json(out_dir / "story.json", index)
139
+ return {**index, "stages": scene["stages"], "shaders": scene["count"],
140
+ "textures": scene["textures"], "uiSummary": ui,
141
+ "media": {"entries": media["counts"], "textures": media["textures"]},
142
+ "videoCount": len(videos["videos"]) if videos else 0}