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/cristages.py ADDED
@@ -0,0 +1,558 @@
1
+ """Stages `cri.audio` (CRI cue sheets -> FLAC per stream) and `cri.movie` (CRI movies -> their streams and a Matroska
2
+ file), one task per content.
3
+
4
+ cri.audio subject: the sha256 of an ACB ("cri.audio:<sha256>"). The ACB is a raw file of the catalog (magic
5
+ `@UTF`, fact "raw") or held in a bundle: the `cri.acb` artifacts of unity.export (a SplitAcbData's joined
6
+ chunks, a CriSerializedBytesAssetImpl's bytes), so a cue sheet stored in both forms, or in several
7
+ bundles, is decoded once. Inputs: "acb"; "awb" when the catalog pairs a raw ACB with an AWB (`AFS2`: its
8
+ streamed waveforms, which the decoder does not read: `unsupported.cri.awb_external`); "boot", the game's
9
+ boot data (fact "boot", see boot_input), from which the HCA keycode is read when the task runs: the key
10
+ itself never enters a key, a record or a log. Atoms: `hca.decode` (vgmstream) and `flac.encode` (ffmpeg),
11
+ each named by the version the tool reports and the sha256 of its executable. Artifacts
12
+ "cri.audio:<sha256>#<file>": one FLAC per vgmstream stream, `cues.json` and `streams.json`, the bytes
13
+ cri.decode writes for the sheet. Parameter `flac.level` (ffmpeg's FLAC compression level; every level
14
+ decodes to the same samples).
15
+ cri.movie subject: the sha256 of a USM: a raw file of the catalog (fact "raw", magic `CRID`) or held in a bundle
16
+ (the `cri.usm` artifacts of unity.export: a movie asset's CriSerializedBytesAssetImpl bytes), so a movie
17
+ stored in several places is converted once. Inputs "usm", "boot" (the USM masks and the HCA cipher use
18
+ the same key). Artifacts, per stream as stored and unmasked (advvideo.demux), named by its kind and, past
19
+ channel 0, its channel: `video.<ext>` / `alpha.<ext>` (`ivf` VP9, `m1v` MPEG-1, `h264` H.264),
20
+ `audio.<ext>` (`adx`, `hca`), and for subtitles `subtitle.json` (the records as stored) with
21
+ `subtitle.srt` and `subtitle.vtt`; then `movie.mkv`: every video and alpha stream copied, every audio
22
+ stream as FLAC (ADX through ffmpeg, HCA through vgmstream), every subtitle channel as a SubRip track.
23
+ Parameters `flac.level`, and `format` "webm", which makes `movie.webm` instead (advvideo.write_webm, the
24
+ story videos' form).
25
+ Atoms `usm.demux` (nnnotes), `movie.mux` (ffmpeg), `hca.decode` (vgmstream).
26
+
27
+ Each task has one item: object its task id, class "ACB" / "USM", status `exported` (its artifacts) or `unsupported`
28
+ (a reason code of docs/stages.md; a movie whose subtitles cannot be read keeps its other artifacts). An artifact names no object: layouts place it by its task (names(env) gives the
29
+ catalog keys and cue sheet names of each subject, for placing it under them).
30
+
31
+ Neither stage imports UnityPy, numpy or the decoders before a task runs.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import subprocess
38
+ import tempfile
39
+ import threading
40
+ import zipfile
41
+ from collections import defaultdict
42
+ from pathlib import Path
43
+
44
+ from . import contract
45
+ from .atoms import impl_id
46
+ from .contract import Cost, IncompatibleTask, Input
47
+ from .stages import Output, Pending, Stage
48
+
49
+ AUDIO, MOVIE, EXPORT = "cri.audio", "cri.movie", "unity.export"
50
+ ACB_MAGIC, AWB_MAGIC, USM_MAGIC = b"@UTF", b"AFS2", b"CRID"
51
+ ACB_KIND = "cri.acb" # semantics kind of the ACB artifacts of unity.export
52
+ USM_KIND = "cri.usm" # semantics kind of the USM artifacts of unity.export
53
+ BOOT_IN_APK = "assets/bin/Data/data.unity3d"
54
+ ACB_NAME = "sheet" # the stem of the ACB file vgmstream reads (names come from its cues)
55
+ TOOL_REVISION = 1
56
+ FORMATS = ("mkv", "webm")
57
+ FLAC_LEVEL = 8 # ffmpeg's FLAC compression level by default (here and in cri.decode)
58
+ REASONS = ("unsupported.cri.awb_external", "unsupported.usm.codec", "unsupported.usm.stream",
59
+ "unsupported.usm.subtitle")
60
+ _lock = threading.Lock()
61
+ _magic: dict[str, bytes] = {} # content id -> first 4 bytes
62
+ _tools: dict[tuple, str] = {} # (executable, size, mtime) -> tool id
63
+ _keys: dict[str, int] = {} # boot data content id -> HCA keycode
64
+
65
+
66
+ # ---------------------------------------------------------------- inputs shared by the stages
67
+ def boot_input(store, apk) -> Input | None:
68
+ """The input "boot" of the CRI stages: the boot data of an APK (`assets/bin/Data/data.unity3d`, where the HCA
69
+ keycode is read), stored in `store` once; the APK is hashed once (store.identify memo) and its boot data is
70
+ remembered by the APK's content id. A path that is not an .apk / .zip is the boot data itself. None without an
71
+ APK."""
72
+ if apk is None:
73
+ return None
74
+ apk = Path(apk)
75
+ if apk.suffix.lower() not in (".apk", ".zip"):
76
+ sha, size = store.identify(apk)
77
+ return Input("boot", sha, size, apk.name, ({"kind": "file", "path": str(apk.resolve())},))
78
+ apk_sha, _ = store.identify(apk)
79
+ name = f"boot:{apk_sha}"
80
+ known = store.named("files", name)
81
+ if known is None or not store.has(*known):
82
+ with zipfile.ZipFile(apk) as z:
83
+ data = z.read(BOOT_IN_APK)
84
+ known = store.identify(store.path(store.put(data)), "files", name)
85
+ return Input("boot", known[0], known[1], "data.unity3d", ({"kind": "store"},))
86
+
87
+
88
+ def _boot(env) -> Input:
89
+ inp = env.facts.get("boot")
90
+ if inp is None:
91
+ raise ValueError("the HCA key is read from the game's boot data: set [paths] apk")
92
+ return inp
93
+
94
+
95
+ def hca_key(store, inp: Input) -> int:
96
+ """The HCA keycode in the boot data `inp` (crikey.find_key), read once per content and process."""
97
+ with _lock:
98
+ if inp.sha256 in _keys:
99
+ return _keys[inp.sha256]
100
+ from . import crikey
101
+ key = crikey.find_key(store.open_input(inp))
102
+ with _lock:
103
+ _keys[inp.sha256] = key
104
+ return key
105
+
106
+
107
+ def tool_version(which: str, exe: str) -> str:
108
+ """The version an external tool reports: vgmstream-cli -V (its JSON "version"), ffmpeg -version (the word after
109
+ "version"); "unknown" when it reports none."""
110
+ if which == "vgmstream":
111
+ r = subprocess.run([exe, "-V"], capture_output=True, text=True)
112
+ try:
113
+ return str(json.loads(r.stdout)["version"])
114
+ except (ValueError, KeyError, TypeError):
115
+ return "unknown"
116
+ r = subprocess.run([exe, "-version"], capture_output=True, text=True)
117
+ words = (r.stdout.splitlines() or [""])[0].split()
118
+ return words[2] if len(words) > 2 and words[1] == "version" else "unknown"
119
+
120
+
121
+ def tool_id(which: str, exe: str) -> str:
122
+ """The implementation id of an external tool: `<tool>-<version>+sha256.<sha256 of the executable>/<revision>`,
123
+ computed once per executable file and process."""
124
+ p = Path(exe).resolve()
125
+ st = p.stat()
126
+ k = (str(p), st.st_size, st.st_mtime_ns)
127
+ with _lock:
128
+ hit = _tools.get(k)
129
+ if hit is None:
130
+ with open(p, "rb") as f:
131
+ sha = hashlib.file_digest(f, "sha256").hexdigest()
132
+ hit = f"{which}-{tool_version(which, str(p))}+sha256.{sha}/{TOOL_REVISION}"
133
+ with _lock:
134
+ _tools[k] = hit
135
+ return hit
136
+
137
+
138
+ def _tool(which: str) -> str:
139
+ from .config import tool
140
+ return tool(which, "vgmstream-cli" if which == "vgmstream" else "ffmpeg")
141
+
142
+
143
+ def _magic_of(store, inp: Input, name: str) -> bytes:
144
+ """The first 4 bytes of a raw file, from a local copy (the store, the cache, a file); one that is not fetched
145
+ yet is Pending("fetch:<name>")."""
146
+ with _lock:
147
+ m = _magic.get(inp.sha256)
148
+ if m is None:
149
+ from .store import InputMissing
150
+ local = Input(inp.role, inp.sha256, inp.size, inp.name,
151
+ tuple(loc for loc in inp.locators if loc.get("kind") != "catalog"))
152
+ try:
153
+ path = store.open_input(local)
154
+ except InputMissing:
155
+ raise Pending(f"fetch:{inp.name or name}") from None
156
+ with open(path, "rb") as f:
157
+ m = f.read(4)
158
+ with _lock:
159
+ _magic[inp.sha256] = m
160
+ return m
161
+
162
+
163
+ def _raw(env) -> dict[str, tuple[Input, bytes]]:
164
+ """{stable name: (Input, magic)} of the raw files of the fact "raw"; files not fetched yet: Pending (the first by
165
+ name, after all were looked at)."""
166
+ raw = env.facts.get("raw") or {}
167
+ out, waiting = {}, []
168
+ for stable in sorted(raw):
169
+ try:
170
+ inp = raw[stable]
171
+ if inp is None:
172
+ raise Pending(f"fetch:{stable}")
173
+ if isinstance(inp, Exception):
174
+ raise inp
175
+ out[stable] = (inp, _magic_of(env.store, inp, stable))
176
+ except Pending as p:
177
+ waiting.append(p)
178
+ if waiting:
179
+ raise waiting[0]
180
+ return out
181
+
182
+
183
+ class _Catalog:
184
+ """What the catalog index says about raw files: the keys that load each (their dependency lists name it) and the
185
+ groups of raw files one key loads together (an ACB with its AWB); and the keys whose dependency lists name each
186
+ bundle."""
187
+
188
+ def __init__(self, index: dict | None):
189
+ self.keys: dict[str, set] = defaultdict(set)
190
+ self.groups: dict[str, set] = defaultdict(set)
191
+ self.bundle_keys: dict[str, set] = defaultdict(set)
192
+ self.primary: set = set()
193
+ if not index:
194
+ return
195
+ raw_of = {lid: f["stable"] for f in index.get("rawFiles", ()) for lid in f.get("locations", ())}
196
+ bundle_of = {lid: f["stable"] for f in index.get("bundles", ()) for lid in f.get("locations", ())}
197
+ for loc in index.get("locations", ()):
198
+ self.primary.add(loc["primaryKey"])
199
+ if loc.get("kind") in ("bundle", "raw"):
200
+ continue
201
+ deps = {raw_of[d] for d in loc.get("dependencies", ()) if d in raw_of}
202
+ for s in deps:
203
+ self.keys[s].add(loc["primaryKey"])
204
+ self.groups[s] |= deps - {s}
205
+ for d in loc.get("dependencies", ()):
206
+ if d in bundle_of:
207
+ self.bundle_keys[bundle_of[d]].add(loc["primaryKey"])
208
+
209
+
210
+ class _CriStage(Stage):
211
+ """What the two stages share: subjects by content from an index built once per planning state."""
212
+ CLASS = ""
213
+
214
+ def __init__(self):
215
+ self._memo = None
216
+ self._catalog = (None, _Catalog(None))
217
+
218
+ def __getstate__(self):
219
+ return {}
220
+
221
+ def __setstate__(self, state):
222
+ self.__init__()
223
+
224
+ def catalog(self, env) -> _Catalog:
225
+ index = env.facts.get("index")
226
+ if self._catalog[0] is not index:
227
+ self._catalog = (index, _Catalog(index))
228
+ return self._catalog[1]
229
+
230
+ def index(self, env) -> dict:
231
+ raise NotImplementedError
232
+
233
+ def subjects(self, env) -> list[str]:
234
+ return sorted(self.index(env))
235
+
236
+ def names(self, env) -> dict[str, list[str]]:
237
+ """{subject: the catalog keys that load its content, or the names it is known by}, for placing a subject's
238
+ artifacts under them (layout time only: names never enter a key)."""
239
+ return {s: sorted(e["names"]) for s, e in sorted(self.index(env).items())}
240
+
241
+ def _entry(self, subject: str, env) -> dict:
242
+ idx = self.index(env)
243
+ if subject not in idx:
244
+ raise ValueError(f"{self.name}: no input has the content {subject}")
245
+ return idx[subject]
246
+
247
+ def depends(self, subject: str, env) -> list[str]:
248
+ return sorted(self._entry(subject, env)["depends"])
249
+
250
+ def _item(self, task, artifacts=(), why=None) -> Output:
251
+ status = "exported" if why is None else "unsupported"
252
+ return Output(list(artifacts), [contract.item(task.id, status, artifacts=[a["id"] for a in artifacts],
253
+ why=why, cls=self.CLASS)])
254
+
255
+ def _check_atoms(self, task, want: dict) -> None:
256
+ if task.atoms != want:
257
+ diff = sorted(k for k in set(task.atoms) | set(want) if task.atoms.get(k) != want.get(k))
258
+ raise IncompatibleTask(f"task {task.id}: {', '.join(diff)} differ here: "
259
+ + "; ".join(f"{k} {want.get(k)} (task: {task.atoms.get(k)})" for k in diff))
260
+
261
+ def _artifact(self, task, store, role: str, data: bytes, ext: str, kind: str, fmt: str, facts: dict,
262
+ media: str | None = None) -> dict:
263
+ return contract.artifact(contract.artifact_id(task.id, role), store.add(data, ext, media),
264
+ contract.provenance(task), {"kind": kind, "format": fmt, "facts": facts})
265
+
266
+
267
+ def _flac_params(p) -> dict:
268
+ flac = dict(p or {})
269
+ unknown = sorted(set(flac) - {"level"})
270
+ if unknown:
271
+ raise ValueError(f"unknown flac parameters {', '.join(unknown)}")
272
+ level = flac.get("level", FLAC_LEVEL)
273
+ if not isinstance(level, int) or isinstance(level, bool) or not 0 <= level <= 12:
274
+ raise ValueError(f"flac level {level!r}: expected 0-12")
275
+ return {"level": level}
276
+
277
+
278
+ # ---------------------------------------------------------------- cri.audio
279
+ class AudioStage(_CriStage):
280
+ """cri.audio (module documentation)."""
281
+ name = AUDIO
282
+ version = 1
283
+ after = (EXPORT,)
284
+ CLASS = "ACB"
285
+ PARAMS = {"flac": {"level": FLAC_LEVEL}}
286
+
287
+ @property
288
+ def ATOMS(self) -> dict:
289
+ return {"hca.decode": tool_id("vgmstream", _tool("vgmstream")), "flac.encode": tool_id("ffmpeg", _tool("ffmpeg"))}
290
+
291
+ def normalize(self, params: dict | None) -> dict:
292
+ p = dict(params or {})
293
+ unknown = sorted(set(p) - set(self.PARAMS))
294
+ if unknown:
295
+ raise ValueError(f"stage {self.name}: unknown parameters {', '.join(unknown)}")
296
+ return {"flac": _flac_params(p.get("flac"))}
297
+
298
+ def index(self, env) -> dict:
299
+ """{content id: {acb, awb, depends, names}} of the raw ACBs and the ACB artifacts of the unity.export
300
+ results."""
301
+ pending = env.pending(EXPORT)
302
+ if pending:
303
+ raise Pending(pending[0])
304
+ tids = env.tasks(EXPORT)
305
+ raw = _raw(env)
306
+ cat = self.catalog(env)
307
+ sig = (tuple((s, i.sha256) for s, (i, _m) in raw.items()), tuple(env.key(t) for t in tids), id(cat))
308
+ if self._memo is not None and self._memo[0] == sig:
309
+ return self._memo[1]
310
+ out: dict[str, dict] = {}
311
+
312
+ def entry(sha: str) -> dict:
313
+ return out.setdefault(sha, {"acb": None, "awb": None, "depends": set(), "names": set()})
314
+
315
+ for stable, (inp, magic) in raw.items():
316
+ if magic != ACB_MAGIC:
317
+ continue
318
+ e = entry(inp.sha256)
319
+ if e["acb"] is None:
320
+ e["acb"] = Input("acb", inp.sha256, inp.size, inp.name, inp.locators)
321
+ e["names"] |= cat.keys.get(stable) or {stable}
322
+ for other in sorted(cat.groups.get(stable, ())):
323
+ if other in raw and raw[other][1] == AWB_MAGIC and e["awb"] is None:
324
+ a = raw[other][0]
325
+ e["awb"] = Input("awb", a.sha256, a.size, a.name, a.locators)
326
+ for tid in tids:
327
+ for a in _export_artifacts(env.store, env.key(tid), ACB_KIND):
328
+ c = a["content"]
329
+ e = entry(c["sha256"])
330
+ if e["acb"] is None:
331
+ e["acb"] = Input("acb", c["sha256"], c["size"], None, ({"kind": "store"},))
332
+ e["depends"].add(tid)
333
+ sheet = (a["semantics"].get("facts") or {}).get("cueSheet")
334
+ if sheet:
335
+ key = f"Cri/Sound/{sheet}"
336
+ e["names"].add(key if key in cat.primary else sheet)
337
+ self._memo = (sig, out)
338
+ return out
339
+
340
+ def inputs(self, subject: str, env) -> list[Input]:
341
+ e = self._entry(subject, env)
342
+ return [e["acb"], *([e["awb"]] if e["awb"] is not None else []), _boot(env)]
343
+
344
+ def uses(self, subject: str, env) -> dict:
345
+ return self.ATOMS
346
+
347
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
348
+ """From the ACB's size (a whole catalog: about 2 CPU seconds per MB, the tools' processes included)."""
349
+ size = sum(i.size for i in inputs if i.role == "acb")
350
+ return Cost(0.3 + size * 2.0e-6, (64 << 20) + 8 * size)
351
+
352
+ def run(self, task, store) -> Output:
353
+ from . import cri
354
+ vgm, ff = _tool("vgmstream"), _tool("ffmpeg")
355
+ self._check_atoms(task, {"hca.decode": tool_id("vgmstream", vgm), "flac.encode": tool_id("ffmpeg", ff)})
356
+ if any(i.role == "awb" for i in task.inputs):
357
+ return self._item(task, why=contract.reason(
358
+ "unsupported.cri.awb_external", "the cue sheet's waveforms are streamed from an external AWB"))
359
+ acb = store.input_bytes(task.input("acb"))
360
+ key = hca_key(store, task.input("boot"))
361
+ level = int(task.params["flac"]["level"])
362
+ with tempfile.TemporaryDirectory(prefix="nnnotes-cri-") as tmp:
363
+ out = Path(tmp)
364
+ d = cri.decode_acb_bytes(acb, None, key, "flac", out, name=ACB_NAME, flac_level=level,
365
+ workers=cri.default_workers(), vgmstream=vgm, ffmpeg=ff)
366
+ arts = [self._artifact(task, store, s["file"], (out / s["file"]).read_bytes(), "flac", "audio.stream",
367
+ "flac", {k: v for k, v in s.items() if k != "file"}) for s in d.streams]
368
+ arts.append(self._artifact(task, store, "cues.json", (out / "cues.json").read_bytes(), "json",
369
+ "cri.cues", "json", {"cues": len(d.cues)}))
370
+ arts.append(self._artifact(task, store, "streams.json", (out / "streams.json").read_bytes(), "json",
371
+ "cri.streams", "json", {"streams": len(d.streams)}))
372
+ return self._item(task, arts)
373
+
374
+
375
+ def _export_artifacts(store, key: str, kind: str) -> list[dict]:
376
+ """The artifact records of semantics kind `kind` (cri.acb, cri.usm) of a unity.export result (a result without the
377
+ kind is not parsed)."""
378
+ try:
379
+ data = store.result_path(key).read_bytes()
380
+ except OSError:
381
+ return []
382
+ if f'"{kind}"'.encode() not in data:
383
+ return []
384
+ return [a for a in contract.loads(data)["artifacts"] if a["semantics"].get("kind") == kind]
385
+
386
+
387
+ # ---------------------------------------------------------------- cri.movie
388
+ USM_DEMUX_REVISION = 2
389
+ # stream codec -> (semantics format, media type) of its artifact
390
+ STREAM_FORMATS = {"vp9": ("ivf", "video/x-ivf"), "mpeg1": ("mpeg1video", "video/mpeg"), "h264": ("h264", "video/h264"),
391
+ "adx": ("adx", "audio/x-adx"), "hca": ("hca", "application/octet-stream")}
392
+ MOVIE_FORMATS = {"mkv": ("matroska", "video/x-matroska"), "webm": ("webm", "video/webm")}
393
+
394
+
395
+ class MovieStage(_CriStage):
396
+ """cri.movie (module documentation)."""
397
+ name = MOVIE
398
+ version = 2
399
+ after = (EXPORT,)
400
+ CLASS = "USM"
401
+ PARAMS = {"format": "mkv", "flac": {"level": FLAC_LEVEL}}
402
+
403
+ @property
404
+ def ATOMS(self) -> dict:
405
+ return movie_atoms(_tool("ffmpeg"), _tool("vgmstream"))
406
+
407
+ def normalize(self, params: dict | None) -> dict:
408
+ p = dict(params or {})
409
+ unknown = sorted(set(p) - set(self.PARAMS))
410
+ if unknown:
411
+ raise ValueError(f"stage {self.name}: unknown parameters {', '.join(unknown)}")
412
+ fmt = p.get("format", "mkv")
413
+ if fmt not in FORMATS:
414
+ raise ValueError(f"stage {self.name}: format {fmt!r}: expected {' or '.join(FORMATS)}")
415
+ if fmt == "webm": # Opus audio: the FLAC level does not apply
416
+ if "flac" in p:
417
+ _flac_params(p["flac"])
418
+ return {"format": "webm"}
419
+ return {"format": fmt, "flac": _flac_params(p.get("flac"))}
420
+
421
+ def index(self, env) -> dict:
422
+ """{content id: {usm, depends, names}} of the raw USMs and the USM artifacts of the unity.export results."""
423
+ pending = env.pending(EXPORT)
424
+ if pending:
425
+ raise Pending(pending[0])
426
+ tids = env.tasks(EXPORT)
427
+ raw = _raw(env)
428
+ cat = self.catalog(env)
429
+ sig = (tuple((s, i.sha256) for s, (i, _m) in raw.items()), tuple(env.key(t) for t in tids), id(cat))
430
+ if self._memo is not None and self._memo[0] == sig:
431
+ return self._memo[1]
432
+ out: dict[str, dict] = {}
433
+
434
+ def entry(inp: Input) -> dict:
435
+ return out.setdefault(inp.sha256, {"usm": inp, "depends": set(), "names": set()})
436
+
437
+ for stable, (inp, magic) in raw.items():
438
+ if magic == USM_MAGIC:
439
+ e = entry(Input("usm", inp.sha256, inp.size, inp.name, inp.locators))
440
+ e["names"] |= cat.keys.get(stable) or {stable}
441
+ for tid in tids:
442
+ bundle = contract.parse_task_id(tid, EXPORT)[1]
443
+ for a in _export_artifacts(env.store, env.key(tid), USM_KIND):
444
+ c = a["content"]
445
+ e = entry(Input("usm", c["sha256"], c["size"], None, ({"kind": "store"},)))
446
+ e["depends"].add(tid)
447
+ movie = (a["semantics"].get("facts") or {}).get("movie")
448
+ keys = {k for k in cat.bundle_keys.get(bundle, ()) if movie and k.rsplit("/", 1)[-1] == movie}
449
+ e["names"] |= keys or {movie or a["id"]}
450
+ self._memo = (sig, out)
451
+ return out
452
+
453
+ def inputs(self, subject: str, env) -> list[Input]:
454
+ return [self._entry(subject, env)["usm"], _boot(env)]
455
+
456
+ def uses(self, subject: str, env) -> dict:
457
+ return self.ATOMS
458
+
459
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
460
+ """From the USM's size (a whole catalog: about 0.1 CPU seconds per MB, ffmpeg included)."""
461
+ size = sum(i.size for i in inputs if i.role == "usm")
462
+ return Cost(0.3 + size * 1.1e-7, (96 << 20) + 12 * size)
463
+
464
+ def run(self, task, store) -> Output:
465
+ from . import advvideo
466
+ ff, vgm = _tool("ffmpeg"), _tool("vgmstream")
467
+ self._check_atoms(task, movie_atoms(ff, vgm))
468
+ usm = store.input_bytes(task.input("usm"))
469
+ key = hca_key(store, task.input("boot"))
470
+ try:
471
+ streams = advvideo.demux(usm, key)
472
+ except advvideo.Unsupported as e:
473
+ return self._item(task, why=contract.reason(e.code, str(e)))
474
+ arts, why, tracks = [], None, []
475
+ for s in streams:
476
+ if s.kind == "subtitle":
477
+ made, problem = self._subtitle(task, store, s)
478
+ arts += made
479
+ if problem is None:
480
+ tracks.append(s)
481
+ elif why is None:
482
+ why = contract.reason("unsupported.usm.subtitle", problem)
483
+ continue
484
+ ext = advvideo.EXTENSIONS[s.codec]
485
+ fmt, media = STREAM_FORMATS[s.codec]
486
+ if s.kind == "audio":
487
+ try:
488
+ facts = advvideo.adx_info(s.data) if s.codec == "adx" else advvideo.hca_info(s.data)
489
+ except (NotImplementedError, ValueError) as e:
490
+ return self._item(task, why=contract.reason("unsupported.usm.codec", f"{s.name}: {e}"))
491
+ kind = "audio.stream"
492
+ else:
493
+ facts, kind = advvideo.video_facts(s), "video.stream" if s.kind == "video" else "video.alpha"
494
+ arts.append(self._artifact(task, store, f"{s.name}.{ext}", s.data, ext, kind, fmt, facts, media))
495
+ tracks.append(s)
496
+ out_fmt = task.params["format"]
497
+ with tempfile.TemporaryDirectory(prefix="nnnotes-usm-") as tmp:
498
+ work = Path(tmp)
499
+ dst = work / f"movie.{out_fmt}"
500
+ try:
501
+ if out_fmt == "mkv":
502
+ inputs = {s.name: advvideo.stream_input(s, work / "in", key, vgm) for s in tracks}
503
+ advvideo.run_ffmpeg(ff, advvideo.mkv_args(tracks, inputs, dst, task.params["flac"]["level"]),
504
+ "mkv")
505
+ else:
506
+ tracks = advvideo.write_webm(streams, dst, work / "in", ff, key, vgm)
507
+ except advvideo.Unsupported as e:
508
+ return self._item(task, why=contract.reason(e.code, str(e)))
509
+ reencoded = out_fmt == "webm" and advvideo.webm_reencoded(tracks)
510
+ facts = {"tracks": [_track(s, out_fmt, reencoded) for s in tracks]}
511
+ fmt, media = MOVIE_FORMATS[out_fmt]
512
+ arts.append(self._artifact(task, store, dst.name, dst.read_bytes(), out_fmt, "video.movie", fmt, facts,
513
+ media))
514
+ return self._item(task, arts, why)
515
+
516
+ def _subtitle(self, task, store, s) -> tuple[list[dict], str | None]:
517
+ """The artifacts of a subtitle channel and why it is not a track (None when it is): the records as JSON, and
518
+ as SubRip and WebVTT when every text is UTF-8; the bytes as stored (`.sbt`) when they are not records."""
519
+ from . import advvideo
520
+ from .jsonio import dumps
521
+ try:
522
+ records = advvideo.subtitle_records(s.data)
523
+ except ValueError as e:
524
+ return [self._artifact(task, store, f"{s.name}.sbt", s.data, "sbt", "subtitle.stream", "sbt",
525
+ {"channel": s.channel})], f"{s.name}: {e}"
526
+ facts = {"channel": s.channel, "records": len(records)}
527
+ doc = dumps(advvideo.subtitle_json(s.channel, records), indent=1, ensure_ascii=False).encode()
528
+ arts = [self._artifact(task, store, f"{s.name}.json", doc, "json", "subtitle.records", "json", facts)]
529
+ try:
530
+ srt, vtt = advvideo.subtitle_srt(records), advvideo.subtitle_webvtt(records)
531
+ except UnicodeDecodeError:
532
+ return arts, f"{s.name}: a text that is not UTF-8"
533
+ arts.append(self._artifact(task, store, f"{s.name}.srt", srt.encode(), "srt", "subtitle.track", "srt", facts,
534
+ "application/x-subrip"))
535
+ arts.append(self._artifact(task, store, f"{s.name}.vtt", vtt.encode(), "vtt", "subtitle.track", "webvtt",
536
+ facts, "text/vtt"))
537
+ return arts, None
538
+
539
+
540
+ def movie_atoms(ffmpeg: str, vgmstream: str) -> dict:
541
+ """The atoms of a cri.movie task: the demuxer, ffmpeg (the mux, ADX decoding, FLAC / Opus / VP9 encoding) and
542
+ vgmstream (HCA decoding)."""
543
+ return {"usm.demux": impl_id(("numpy",), USM_DEMUX_REVISION), "movie.mux": tool_id("ffmpeg", ffmpeg),
544
+ "hca.decode": tool_id("vgmstream", vgmstream)}
545
+
546
+
547
+ def _track(s, fmt: str, reencoded: bool) -> dict:
548
+ """A track of the movie file: kind, channel, codec, and `source` (the stream's codec) when it was converted."""
549
+ if s.kind in ("video", "alpha"):
550
+ if reencoded:
551
+ return {"kind": s.kind, "channel": s.channel, "codec": "vp9", "source": s.codec}
552
+ return {"kind": s.kind, "channel": s.channel, "codec": s.codec}
553
+ if s.kind == "audio":
554
+ return {"kind": "audio", "channel": s.channel, "codec": "flac" if fmt == "mkv" else "opus", "source": s.codec}
555
+ return {"kind": "subtitle", "channel": s.channel, "codec": "srt"}
556
+
557
+
558
+ STAGES = (AudioStage, MovieStage)