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