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