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/advvideo.py ADDED
@@ -0,0 +1,684 @@
1
+ """CRI Sofdec2 movies (USM): demux, masks, subtitles; ADV videos: Movie / Clip VideoID -> the episode's -Video row ->
2
+ `Cri/Video/<_assetName>` (a USM) -> <story>/videos/<name>.webm + videos/videos.json.
3
+
4
+ An addressable key `Cri/Video/<assetName>` depends on a small bundle holding the CriWare.Assets MonoBehaviour of the
5
+ movie (`movieInfo`: size, frame rate, frame count, codec, alpha / audio / subtitle stream counts; `assetInfo`) and on
6
+ the USM itself, stored as-is on the CDN like the cue sheets' raw data (VideoManager.Prepare hands it to CRI Mana). A
7
+ movie asset may instead hold the USM bytes itself (a CriSerializedBytesAssetImpl).
8
+
9
+ USM: a sequence of chunks `<signature><size:be32>` + a header (payload offset, padding size, channel, data type, frame
10
+ time, frame rate) + payload. Streams by signature, each in one or more channels: `@SFV` video, `@ALP` alpha (a second
11
+ video stream whose luma is the opacity of the video's frames, decoded in step with them), `@SFA` audio, `@SBT`
12
+ subtitles. Data type 0 = stream data, 1 = the stream's header table (a CRI @UTF table: VIDEO_HDRINFO,
13
+ AUDIO_HDRINFO, ...), 2 = metadata / end markers, 3 = seek table.
14
+
15
+ Codecs: video and alpha by the header's `mpeg_codec`: 1 MPEG-1 video (CRI's "Sofdec.Prime": an MPEG-1 elementary
16
+ stream whose user data asks for an 11-bit intra DC precision, the TMPGEnc extension FFmpeg's MPEG-1 decoder reads),
17
+ 5 H.264 (an Annex B elementary stream), 9 VP9 (in an IVF container); audio by the header's `audio_codec`: 2 ADX,
18
+ 4 HCA. Without a header the codec is recognised from the stream's first bytes.
19
+
20
+ Masks: 32-byte masks derived from the 64-bit CRI key -- the same key the HCA audio uses, read from the game's boot
21
+ data by crikey.py. Video and alpha payloads are masked from byte 0x40 on (a rolling mask over bytes 0x100.., then a
22
+ mask chained through them over the first 0x100), ADX payloads from byte 0x140 on (a fixed mask); HCA payloads and
23
+ subtitles are stored plain (an HCA stream carries its own cipher, which the decoder undoes with the same key). With
24
+ key 0 (decryption disabled) every stream is plain. A catalog may hold movies stored plain beside masked ones: a video,
25
+ alpha or ADX stream is kept as stored when only that form shows the stream's structure past byte 0x40 (MPEG-1 slice
26
+ start codes, H.264 start codes, VP9 superframe indexes whose frame sizes add up, ADX frames whose scale word has its
27
+ top bit clear), else unmasked.
28
+
29
+ Subtitles: a stream of records, each five little-endian u32 (language id, time unit in ticks per second, start,
30
+ duration, text size) followed by the text bytes, which end in up to two NUL bytes. The text is kept as stored;
31
+ subtitle_srt / subtitle_webvtt write the records of one channel whose text is UTF-8.
32
+
33
+ Movie files (ffmpeg: `[paths] ffmpeg`, else found on PATH): the Matroska file of cri.movie carries every stream
34
+ (mkv_args); the WebM of the story and of cri.movie's `format: webm` (write_webm) keeps a VP9 stream as is and
35
+ re-encodes any other video, and a video with an alpha stream, as VP9 (libvpx at a constant quality, with a straight
36
+ alpha channel from the alpha stream's luma); its audio is the first audio stream as Opus (libopus). Every encoder runs
37
+ in its bit-exact mode, so the files are reproducible. An MPEG-1 or H.264 elementary stream carries no timestamps:
38
+ ffmpeg derives them (`-fflags +genpts`), from the frame rate of the stream's header for H.264.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import re
43
+ import shutil
44
+ import struct
45
+ import subprocess
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import TYPE_CHECKING
49
+
50
+ import numpy as np
51
+
52
+ from .jsonio import write_json
53
+
54
+ if TYPE_CHECKING:
55
+ from .catalog import Catalog
56
+
57
+ VIDEO_DIR = "videos"
58
+ INDEX = "videos/videos.json"
59
+ AUDIO_BITRATE = "192k"
60
+ # movieInfo.codecType (CriMana.CodecType)
61
+ CODECS = {1: "sofdec.prime", 5: "h264", 9: "vp9"}
62
+ ADX_SIGNATURE = b"\x80\x00"
63
+ ADX_FRAME = 18 # bytes of one ADX frame: 32 samples of one channel
64
+ ADX_END = b"\x80\x01" # the end-of-stream block after the last frame: 0x8001, its size, zero padding
65
+ HCA_SIGNATURES = (b"HCA\x00", b"\xc8\xc3\xc1\x00") # plain, and with the high bits of an encrypted header
66
+ KINDS = {b"@SFV": "video", b"@ALP": "alpha", b"@SFA": "audio", b"@SBT": "subtitle"}
67
+ KIND_ORDER = ("video", "alpha", "audio", "subtitle")
68
+ VIDEO_CODECS = {1: "mpeg1", 5: "h264", 9: "vp9"} # VIDEO_HDRINFO mpeg_codec
69
+ AUDIO_CODECS = {2: "adx", 4: "hca"} # AUDIO_HDRINFO audio_codec
70
+ EXTENSIONS = {"vp9": "ivf", "mpeg1": "m1v", "h264": "h264", "adx": "adx", "hca": "hca"}
71
+ SBT_RECORD = struct.Struct("<5I")
72
+ CODEC_REASON, STREAM_REASON = "unsupported.usm.codec", "unsupported.usm.stream"
73
+
74
+
75
+ class Unsupported(ValueError):
76
+ """A USM stream nnnotes does not convert; `code` is its reason code (docs/stages.md)."""
77
+
78
+ def __init__(self, code: str, message: str):
79
+ super().__init__(message)
80
+ self.code = code
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Stream:
85
+ """One stream of a USM: `kind` video / alpha / audio / subtitle, its channel, its codec (vp9, h264, mpeg1; adx,
86
+ hca; sbt), the first row of its header table ({} without one) and its bytes as stored, unmasked."""
87
+ kind: str
88
+ channel: int
89
+ codec: str
90
+ data: bytes = field(repr=False)
91
+ header: dict = field(default_factory=dict)
92
+
93
+ @property
94
+ def name(self) -> str:
95
+ """`<kind>` for channel 0, else `<kind>_<channel>`."""
96
+ return self.kind if self.channel == 0 else f"{self.kind}_{self.channel}"
97
+
98
+
99
+ # --- USM ------------------------------------------------------------------------------
100
+ def masks(key: int) -> tuple[bytes, bytes, bytes]:
101
+ """(video mask 1, video mask 2, audio mask) of a 64-bit CRI key."""
102
+ k = int(key).to_bytes(8, "little")
103
+ t = [0] * 0x20
104
+ t[0x00] = k[0]
105
+ t[0x01] = k[1]
106
+ t[0x02] = k[2]
107
+ t[0x03] = (k[3] - 0x34) & 0xFF
108
+ t[0x04] = (k[4] + 0xF9) & 0xFF
109
+ t[0x05] = k[5] ^ 0x13
110
+ t[0x06] = (k[6] + 0x61) & 0xFF
111
+ t[0x07] = t[0x00] ^ 0xFF
112
+ t[0x08] = (t[0x02] + t[0x01]) & 0xFF
113
+ t[0x09] = (t[0x01] - t[0x07]) & 0xFF
114
+ t[0x0A] = t[0x02] ^ 0xFF
115
+ t[0x0B] = t[0x01] ^ 0xFF
116
+ t[0x0C] = (t[0x0B] + t[0x09]) & 0xFF
117
+ t[0x0D] = (t[0x08] - t[0x03]) & 0xFF
118
+ t[0x0E] = t[0x0D] ^ 0xFF
119
+ t[0x0F] = (t[0x0A] - t[0x0B]) & 0xFF
120
+ t[0x10] = (t[0x08] - t[0x0F]) & 0xFF
121
+ t[0x11] = t[0x10] ^ t[0x07]
122
+ t[0x12] = t[0x0F] ^ 0xFF
123
+ t[0x13] = t[0x03] ^ 0x10
124
+ t[0x14] = (t[0x04] - 0x32) & 0xFF
125
+ t[0x15] = (t[0x05] + 0xED) & 0xFF
126
+ t[0x16] = t[0x06] ^ 0xF3
127
+ t[0x17] = (t[0x13] - t[0x0F]) & 0xFF
128
+ t[0x18] = (t[0x15] + t[0x07]) & 0xFF
129
+ t[0x19] = (0x21 - t[0x13]) & 0xFF
130
+ t[0x1A] = t[0x14] ^ t[0x17]
131
+ t[0x1B] = (t[0x16] + t[0x16]) & 0xFF
132
+ t[0x1C] = (t[0x17] + 0x44) & 0xFF
133
+ t[0x1D] = (t[0x03] + t[0x04]) & 0xFF
134
+ t[0x1E] = (t[0x05] - t[0x16]) & 0xFF
135
+ t[0x1F] = t[0x1D] ^ t[0x13]
136
+ audio = b"URUC"
137
+ return (bytes(t), bytes(x ^ 0xFF for x in t),
138
+ bytes(audio[(i >> 1) & 3] if i & 1 else t[i] ^ 0xFF for i in range(0x20)))
139
+
140
+
141
+ def unmask_video(payload: bytes, mask1: bytes, mask2: bytes) -> bytes:
142
+ """Undo the video mask of one @SFV / @ALP data payload (payloads shorter than 0x240 bytes are not masked).
143
+
144
+ Bytes 0x140.. (0x100.. of the masked part): out[i] = in[i] ^ mask2[i % 32] ^ out[i - 32] (seeded with in ^ mask2
145
+ for the first 32); then the first 0x100: out[i] = in[i] ^ mask1[i % 32] ^ (xor of out[0x100 + j], j <= i,
146
+ j = i mod 32)."""
147
+ body = np.frombuffer(payload, np.uint8)[0x40:]
148
+ if len(body) < 0x200:
149
+ return payload
150
+ tail = body[0x100:]
151
+ n = len(tail)
152
+ x = np.zeros(-(-n // 32) * 32, np.uint8)
153
+ x[:n] = tail ^ np.resize(np.frombuffer(mask2, np.uint8), n)
154
+ tail = np.bitwise_xor.accumulate(x.reshape(-1, 32), axis=0).reshape(-1)[:n]
155
+ chain = np.bitwise_xor.accumulate(tail[:0x100].reshape(8, 32), axis=0) ^ np.frombuffer(mask1, np.uint8)
156
+ head = body[:0x100] ^ chain.reshape(-1)
157
+ return payload[:0x40] + head.tobytes() + tail.tobytes()
158
+
159
+
160
+ def unmask_audio(payload: bytes, mask: bytes) -> bytes:
161
+ """Undo the audio mask of one ADX @SFA data payload (bytes 0x140.. XOR mask[i % 32])."""
162
+ if len(payload) <= 0x140:
163
+ return payload
164
+ body = np.frombuffer(payload, np.uint8)[0x140:]
165
+ return payload[:0x140] + (body ^ np.resize(np.frombuffer(mask, np.uint8), len(body))).tobytes()
166
+
167
+
168
+ def chunks(data: bytes):
169
+ """(signature, channel, data type, payload) of every USM chunk."""
170
+ i = 0
171
+ while i < len(data):
172
+ if len(data) - i < 0x20:
173
+ raise ValueError(f"USM: truncated chunk at {i:#x}")
174
+ sig = data[i:i + 4]
175
+ size = struct.unpack_from(">I", data, i + 4)[0]
176
+ offset, padding = data[i + 9], struct.unpack_from(">H", data, i + 10)[0]
177
+ channel, dtype = data[i + 12], data[i + 15]
178
+ end = i + 8 + size
179
+ if end > len(data) or 8 + offset > size + 8 - padding:
180
+ raise ValueError(f"USM: chunk {sig!r} at {i:#x} overruns the file")
181
+ yield sig, channel, dtype, data[i + 8 + offset:end - padding]
182
+ i = end
183
+
184
+
185
+ def _header(payload: bytes) -> dict:
186
+ """The first row of a stream's @UTF header table (its scalar and string columns)."""
187
+ from .acb import utf_table
188
+ rows = utf_table(payload)
189
+ return {k: v for k, v in rows[0].items() if isinstance(k, str) and not isinstance(v, bytes)} if rows else {}
190
+
191
+
192
+ def _video_codec(header: dict, data: bytes, what: str) -> str:
193
+ n = header.get("mpeg_codec")
194
+ if n is not None:
195
+ codec = VIDEO_CODECS.get(n)
196
+ if codec is None:
197
+ raise Unsupported(CODEC_REASON, f"{what}: codec {n} (mpeg_codec)")
198
+ else:
199
+ codec = _sniff_video(data)
200
+ if codec is None:
201
+ raise Unsupported(CODEC_REASON, f"{what}: no header and an unknown stream start {data[:8].hex()}")
202
+ if codec == "vp9" and not (data[:4] == b"DKIF" and data[8:12] == b"VP90"):
203
+ raise Unsupported(CODEC_REASON, f"{what}: the VP9 stream is not in IVF: {data[:4].hex()} {data[8:12].hex()}")
204
+ return codec
205
+
206
+
207
+ def _sniff_video(data: bytes) -> str | None:
208
+ if data[:4] == b"DKIF":
209
+ return "vp9" if data[8:12] == b"VP90" else None
210
+ head = data[:4096]
211
+ body = head.lstrip(b"\0")
212
+ if len(head) - len(body) < 2 or body[:1] != b"\x01" or len(body) < 2:
213
+ return None
214
+ if body[1] == 0xB3: # MPEG sequence header
215
+ return "mpeg1"
216
+ return "h264" if body[1] & 0x80 == 0 else None # an H.264 NAL unit header
217
+
218
+
219
+ def _audio_codec(header: dict, first: bytes, what: str) -> str:
220
+ n = header.get("audio_codec")
221
+ if n is not None:
222
+ codec = AUDIO_CODECS.get(n)
223
+ if codec is None:
224
+ raise Unsupported(CODEC_REASON, f"{what}: codec {n} (audio_codec)")
225
+ return codec
226
+ if first[:2] == ADX_SIGNATURE:
227
+ return "adx"
228
+ if first[:4] in HCA_SIGNATURES:
229
+ return "hca"
230
+ raise Unsupported(CODEC_REASON, f"{what}: no header and an unknown stream start {first[:8].hex()}")
231
+
232
+
233
+ def demux(data: bytes, key: int) -> list[Stream]:
234
+ """Every stream of a USM, unmasked, ordered video, alpha, audio, subtitle and by channel. Unsupported (with its
235
+ reason code) for a stream of another kind or codec."""
236
+ if data[:4] != b"CRID":
237
+ raise ValueError(f"not a USM (CRID): {data[:4]!r}")
238
+ m1, m2, am = masks(key)
239
+ headers: dict[tuple[str, int], dict] = {}
240
+ parts: dict[tuple[str, int], list[bytes]] = {}
241
+ for sig, channel, dtype, payload in chunks(data):
242
+ if sig == b"CRID":
243
+ continue
244
+ kind = KINDS.get(sig)
245
+ if dtype == 1 and kind is not None and payload[:4] == b"@UTF":
246
+ headers[(kind, channel)] = _header(payload)
247
+ elif dtype == 0:
248
+ if kind is None:
249
+ raise Unsupported(STREAM_REASON, f"USM stream {sig.decode('latin-1')} channel {channel}")
250
+ parts.setdefault((kind, channel), []).append(payload)
251
+ out = []
252
+ for kind, channel in sorted(parts, key=lambda k: (KIND_ORDER.index(k[0]), k[1])):
253
+ ps, header, what = parts[(kind, channel)], headers.get((kind, channel), {}), f"{kind} channel {channel}"
254
+ if kind in ("video", "alpha"):
255
+ codec = _video_codec(header, ps[0][:0x40], what) # a payload's first 0x40 bytes are never masked
256
+ if key:
257
+ ps = _form(codec, ps, lambda p: unmask_video(p, m1, m2))
258
+ elif kind == "audio":
259
+ codec = _audio_codec(header, ps[0], what)
260
+ if key and codec == "adx":
261
+ ps = _form(codec, ps, lambda p: unmask_audio(p, am))
262
+ else:
263
+ codec = "sbt"
264
+ out.append(Stream(kind, channel, codec, b"".join(ps), header))
265
+ return out
266
+
267
+
268
+ _START = re.compile(rb"\x00\x00\x01")
269
+ _MPEG_SLICE = re.compile(rb"\x00\x00\x01[\x01-\xaf]")
270
+
271
+
272
+ def _form(codec: str, payloads: list[bytes], unmask) -> list[bytes]:
273
+ """The payloads unmasked, or as stored when only that form shows the stream's structure (_structure)."""
274
+ unmasked = [unmask(p) for p in payloads]
275
+ return payloads if _structure(codec, payloads) > _structure(codec, unmasked) else unmasked
276
+
277
+
278
+ def _structure(codec: str, payloads: list[bytes]) -> int:
279
+ """How often a stream's structure shows in the masked part of its payloads (past byte 0x40): MPEG-1 slice start
280
+ codes, H.264 start codes, VP9 frames ending in a superframe index whose frame sizes add up, ADX frames whose
281
+ scale word has its top bit clear."""
282
+ if codec == "mpeg1":
283
+ return sum(len(_MPEG_SLICE.findall(p, 0x40)) for p in payloads)
284
+ if codec == "h264":
285
+ return sum(len(_START.findall(p, 0x40)) for p in payloads)
286
+ if codec == "vp9":
287
+ return sum(_superframe(p) for p in payloads)
288
+ data = b"".join(payloads)
289
+ start = struct.unpack_from(">H", data, 2)[0] + 4
290
+ return int(np.count_nonzero(np.frombuffer(data, np.uint8)[start::ADX_FRAME] < 0x80))
291
+
292
+
293
+ def _superframe(p: bytes) -> int:
294
+ """1 when the VP9 frame of an IVF payload (its 12-byte frame header, after the file header in the first one) ends
295
+ in a superframe index whose frame sizes add up to the frame, else 0."""
296
+ at = 32 if p[:4] == b"DKIF" else 0
297
+ if len(p) < at + 12:
298
+ return 0
299
+ size = int.from_bytes(p[at:at + 4], "little")
300
+ d = p[at + 12:at + 12 + size]
301
+ if not d or len(d) != size or d[-1] & 0xE0 != 0xC0:
302
+ return 0
303
+ marker = d[-1]
304
+ frames, width = (marker & 7) + 1, ((marker >> 3) & 3) + 1
305
+ index = 2 + frames * width
306
+ if len(d) < index or d[-index] != marker:
307
+ return 0
308
+ sizes = sum(int.from_bytes(d[len(d) - index + 1 + i * width:len(d) - index + 1 + (i + 1) * width], "little")
309
+ for i in range(frames))
310
+ return int(sizes + index == len(d))
311
+
312
+
313
+ def video_facts(s: Stream) -> dict:
314
+ """The facts of a video or alpha stream: its codec and what its header table says (coded and display size,
315
+ frame rate, frames; an alpha stream's alpha type); a VP9 stream without a header: its IVF header's."""
316
+ h = s.header
317
+ if not h and s.codec == "vp9":
318
+ w, ht, rate, scale, frames = struct.unpack_from("<HHIII", s.data, 12)
319
+ return {"codec": "vp9", "width": w, "height": ht, "frameRate": [rate, scale], "frames": frames}
320
+ facts = {"codec": s.codec}
321
+ for k, name in (("width", "width"), ("height", "height"), ("disp_width", "displayWidth"),
322
+ ("disp_height", "displayHeight"), ("total_frames", "frames")):
323
+ if h.get(k) is not None:
324
+ facts[name] = h[k]
325
+ if h.get("framerate_n") and h.get("framerate_d"):
326
+ facts["frameRate"] = [h["framerate_n"], h["framerate_d"]]
327
+ if s.kind == "alpha" and h.get("alpha_type") is not None:
328
+ facts["alphaType"] = h["alpha_type"]
329
+ return facts
330
+
331
+
332
+ def display_size(video: Stream) -> tuple[int, int] | None:
333
+ """The display size of a video stream (its header's disp_width x disp_height), None when not known."""
334
+ w, h = video.header.get("disp_width"), video.header.get("disp_height")
335
+ return (w, h) if w and h else None
336
+
337
+
338
+ # --- audio ------------------------------------------------------------------------------
339
+ def adx_info(adx: bytes) -> dict:
340
+ """ADX header: sample rate, channels, samples (big-endian fields after the 0x8000 signature)."""
341
+ if adx[:2] != ADX_SIGNATURE:
342
+ raise NotImplementedError(f"USM audio is not ADX: {adx[:4]!r}")
343
+ data_at = struct.unpack_from(">H", adx, 2)[0] + 4
344
+ if adx[data_at - 6:data_at] != b"(c)CRI":
345
+ raise ValueError("ADX header without the (c)CRI mark")
346
+ return {"codec": "adx", "channels": adx[7], "sampleRate": struct.unpack_from(">I", adx, 8)[0],
347
+ "samples": struct.unpack_from(">I", adx, 12)[0]}
348
+
349
+
350
+ def adx_frames(adx: bytes) -> bytes:
351
+ """The ADX stream up to the last frame its header counts (ceil(samples / 32) frames per channel after the
352
+ header), without the end-of-stream block the USM streams carry after it (0x8001, the block size, zeros). ffmpeg's
353
+ ADX demuxer reports a final read shorter than one frame of every channel as an input/output error; the decoded
354
+ samples are the same without the block."""
355
+ info = adx_info(adx)
356
+ end = struct.unpack_from(">H", adx, 2)[0] + 4 + -(-info["samples"] // 32) * ADX_FRAME * info["channels"]
357
+ if len(adx) < end:
358
+ raise ValueError(f"ADX: {len(adx)} bytes, the header counts frames up to byte {end}")
359
+ tail = adx[end:]
360
+ if tail and not (tail[:2] == ADX_END and len(tail) >= 4 and len(tail) == 4 + struct.unpack_from(">H", tail, 2)[0]
361
+ and not tail[4:].strip(bytes(1))):
362
+ raise ValueError(f"ADX: the {len(tail)} bytes after the last frame are not an end-of-stream block")
363
+ return adx[:end]
364
+
365
+
366
+ def hca_info(hca: bytes) -> dict:
367
+ """HCA header: channels, sample rate, blocks, block size, cipher type (the header's chunk names may carry the
368
+ high bit of an encrypted header)."""
369
+ if hca[:4] not in HCA_SIGNATURES:
370
+ raise ValueError(f"not an HCA stream: {hca[:4].hex()}")
371
+ size = struct.unpack_from(">H", hca, 6)[0]
372
+ head = bytes(b & 0x7F for b in hca[:size])
373
+ info = {"codec": "hca"}
374
+ i = head.find(b"fmt\x00")
375
+ if i < 0:
376
+ raise ValueError("HCA header without fmt")
377
+ info["channels"], info["sampleRate"] = hca[i + 4], int.from_bytes(hca[i + 5:i + 8], "big")
378
+ info["blocks"] = struct.unpack_from(">I", hca, i + 8)[0]
379
+ for tag in (b"comp", b"dec\x00"):
380
+ j = head.find(tag)
381
+ if j >= 0:
382
+ info["blockSize"] = struct.unpack_from(">H", hca, j + 4)[0]
383
+ break
384
+ j = head.find(b"ciph")
385
+ info["cipher"] = struct.unpack_from(">H", hca, j + 4)[0] if j >= 0 else 0
386
+ return info
387
+
388
+
389
+ # --- subtitles --------------------------------------------------------------------------
390
+ def subtitle_records(data: bytes) -> list[dict]:
391
+ """The records of a subtitle stream: language, timeUnit (ticks per second), start, duration, text (the stored
392
+ bytes without their NUL terminators), terminator (their count). ValueError when the bytes are not a sequence of
393
+ records."""
394
+ out, i = [], 0
395
+ while i < len(data):
396
+ if len(data) - i < SBT_RECORD.size:
397
+ raise ValueError(f"subtitle record at byte {i}: {len(data) - i} bytes, a record header has 20")
398
+ language, unit, start, duration, size = SBT_RECORD.unpack_from(data, i)
399
+ i += SBT_RECORD.size
400
+ if unit == 0:
401
+ raise ValueError(f"subtitle record at byte {i - SBT_RECORD.size}: time unit 0")
402
+ if len(data) - i < size:
403
+ raise ValueError(f"subtitle record at byte {i - SBT_RECORD.size}: text of {size} bytes, {len(data) - i} "
404
+ f"left")
405
+ text = data[i:i + size]
406
+ i += size
407
+ stripped = text.rstrip(b"\0")
408
+ out.append({"language": language, "timeUnit": unit, "start": start, "duration": duration, "text": stripped,
409
+ "terminator": len(text) - len(stripped)})
410
+ return out
411
+
412
+
413
+ def _ms(ticks: int, unit: int) -> int:
414
+ return (ticks * 1000 + unit // 2) // unit
415
+
416
+
417
+ def _clock(ms: int, sep: str) -> str:
418
+ h, rest = divmod(ms, 3_600_000)
419
+ m, rest = divmod(rest, 60_000)
420
+ s, ms = divmod(rest, 1000)
421
+ return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
422
+
423
+
424
+ def _cues(records: list[dict]) -> list[tuple[int, int, list[str]]]:
425
+ """(start ms, end ms, text lines) in start order (stored order among equal starts); the text decoded as UTF-8,
426
+ line breaks normalised to LF, an empty line (which would end the cue) as a single space."""
427
+ cues = []
428
+ for r in records:
429
+ text = r["text"].decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
430
+ start = _ms(r["start"], r["timeUnit"])
431
+ cues.append((start, _ms(r["start"] + r["duration"], r["timeUnit"]), [ln or " " for ln in text.split("\n")]))
432
+ return sorted(cues, key=lambda c: c[0])
433
+
434
+
435
+ def subtitle_srt(records: list[dict]) -> str:
436
+ """SubRip of one channel's records (UnicodeDecodeError when a text is not UTF-8)."""
437
+ return "".join(f"{n}\n{_clock(a, ',')} --> {_clock(b, ',')}\n" + "\n".join(lines) + "\n\n"
438
+ for n, (a, b, lines) in enumerate(_cues(records), 1))
439
+
440
+
441
+ def subtitle_webvtt(records: list[dict]) -> str:
442
+ """WebVTT of one channel's records, `&`, `<`, `>` escaped (UnicodeDecodeError when a text is not UTF-8)."""
443
+ esc = str.maketrans({"&": "&amp;", "<": "&lt;", ">": "&gt;"})
444
+ return "WEBVTT\n\n" + "".join(f"{_clock(a, '.')} --> {_clock(b, '.')}\n" + "\n".join(ln.translate(esc) for ln in lines)
445
+ + "\n\n" for a, b, lines in _cues(records))
446
+
447
+
448
+ def subtitle_json(channel: int, records: list[dict]) -> dict:
449
+ """One channel's records as stored: language, timeUnit, start, duration (ticks of 1/timeUnit s), terminator,
450
+ and `text` (the bytes decoded as UTF-8) or, when they are not UTF-8, `textHex`."""
451
+ out = []
452
+ for r in records:
453
+ rec = {k: r[k] for k in ("language", "timeUnit", "start", "duration")}
454
+ try:
455
+ rec["text"] = r["text"].decode("utf-8")
456
+ except UnicodeDecodeError:
457
+ rec["textHex"] = r["text"].hex()
458
+ rec["terminator"] = r["terminator"]
459
+ out.append(rec)
460
+ return {"channel": channel, "records": out}
461
+
462
+
463
+ # --- movie files ------------------------------------------------------------------------
464
+ # ffmpeg's input options per codec: an MPEG-1 or H.264 elementary stream has no timestamps (+genpts); an H.264 one
465
+ # no frame rate either (the header's)
466
+ INPUT_FORMATS = {"vp9": ("-f", "ivf"), "mpeg1": ("-fflags", "+genpts", "-f", "mpegvideo"),
467
+ "h264": ("-fflags", "+genpts", "-f", "h264")}
468
+ # the WebM re-encode of a stream that is not VP9 or has an alpha stream: libvpx VP9 at a constant quality, one
469
+ # thread layout (the bytes do not depend on the machine's cores)
470
+ VP9_ENCODE = ("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "20", "-row-mt", "0", "-threads", "8", "-flags:v", "+bitexact")
471
+ VP9_ALPHA = ("-auto-alt-ref", "0")
472
+ OPUS_ENCODE = ("-c:a", "libopus", "-b:a", AUDIO_BITRATE, "-flags:a", "+bitexact")
473
+ TRACK_TYPES = {"video": "v", "alpha": "v", "audio": "a", "subtitle": "s"}
474
+
475
+
476
+ def stream_input(s: Stream, work: Path, key: int = 0, vgmstream: str | None = None) -> list[str]:
477
+ """Write the file ffmpeg reads for stream `s` into `work`; its ffmpeg input options. Video and alpha: the stream
478
+ as stored; ADX: its frames (adx_frames); HCA: the samples vgmstream decodes with the key (`.hcakey` beside it);
479
+ subtitles: SubRip (subtitle_srt)."""
480
+ work.mkdir(parents=True, exist_ok=True)
481
+ if s.kind in ("video", "alpha"):
482
+ p = work / f"{s.name}.{EXTENSIONS[s.codec]}"
483
+ p.write_bytes(s.data)
484
+ opts = list(INPUT_FORMATS[s.codec])
485
+ if s.codec == "h264":
486
+ n, d = s.header.get("framerate_n"), s.header.get("framerate_d")
487
+ if not (n and d):
488
+ raise Unsupported(CODEC_REASON, f"{s.name}: an H.264 stream without a frame rate in its header")
489
+ opts += ["-framerate", f"{n}/{d}"]
490
+ return [*opts, "-i", str(p)]
491
+ if s.kind == "audio" and s.codec == "adx":
492
+ p = work / f"{s.name}.adx"
493
+ p.write_bytes(adx_frames(s.data))
494
+ return ["-f", "adx", "-i", str(p)]
495
+ if s.kind == "audio":
496
+ from . import crikey
497
+ if vgmstream is None:
498
+ raise ValueError(f"{s.name}: an HCA stream needs vgmstream")
499
+ p, wav = work / f"{s.name}.hca", work / f"{s.name}.wav"
500
+ p.write_bytes(s.data)
501
+ crikey.write_hcakey(key, work)
502
+ subprocess.run([vgmstream, "-i", "-o", str(wav), str(p)], check=True, capture_output=True)
503
+ return ["-f", "wav", "-i", str(wav)]
504
+ p = work / f"{s.name}.srt"
505
+ p.write_text(subtitle_srt(subtitle_records(s.data)), encoding="utf-8", newline="\n")
506
+ return ["-f", "srt", "-i", str(p)]
507
+
508
+
509
+ def mkv_args(streams: list[Stream], inputs: dict[str, list[str]], dst: Path, flac_level: int) -> list[str]:
510
+ """ffmpeg arguments (after the executable) of the Matroska file of `streams`: video and alpha copied (an alpha
511
+ track titled "alpha", not a default track), each audio stream as FLAC (`flac_level`), each subtitle channel as a
512
+ SubRip track; no metadata, bit-exact container and codec flags. `inputs`: stream_input's options by stream
513
+ name."""
514
+ args, maps, count = ["-hide_banner", "-y", "-loglevel", "error"], [], {"v": 0, "a": 0, "s": 0}
515
+ for i, s in enumerate(streams):
516
+ t = TRACK_TYPES[s.kind]
517
+ args += inputs[s.name]
518
+ maps += ["-map", f"{i}:{t}:0"]
519
+ if s.kind == "alpha":
520
+ maps += [f"-metadata:s:v:{count['v']}", "title=alpha", f"-disposition:v:{count['v']}", "0"]
521
+ count[t] += 1
522
+ codecs = ["-c:v", "copy"]
523
+ if count["a"]:
524
+ codecs += ["-c:a", "flac", "-compression_level", str(int(flac_level)), "-flags:a", "+bitexact"]
525
+ if count["s"]:
526
+ codecs += ["-c:s", "srt"]
527
+ return [*args, *maps, *codecs, "-map_metadata", "-1", "-fflags", "+bitexact", "-f", "matroska", str(dst)]
528
+
529
+
530
+ def webm_streams(streams: list[Stream]) -> list[Stream]:
531
+ """The streams of the WebM: the first video stream, the alpha stream of its channel, the first audio stream (the
532
+ lowest channel: the track the game plays); no subtitles."""
533
+ video = next((s for s in streams if s.kind == "video"), None)
534
+ alpha = next((s for s in streams if video is not None and s.kind == "alpha" and s.channel == video.channel), None)
535
+ audio = next((s for s in streams if s.kind == "audio"), None)
536
+ return [s for s in (video, alpha, audio) if s is not None]
537
+
538
+
539
+ def webm_reencoded(streams: list[Stream]) -> bool:
540
+ """Whether the WebM of these streams (webm_streams) re-encodes the video: a video that is not VP9, or an alpha
541
+ stream."""
542
+ return any(s.kind == "alpha" or (s.kind == "video" and s.codec != "vp9") for s in streams)
543
+
544
+
545
+ def webm_args(streams: list[Stream], inputs: dict[str, list[str]], dst: Path) -> list[str]:
546
+ """ffmpeg arguments (after the executable) of the WebM of `streams` (webm_streams): a VP9 video without alpha
547
+ copied; any other video, and a video with an alpha stream, re-encoded as VP9 (VP9_ENCODE) from its frames in
548
+ decoding order, cropped to its display size, the alpha stream's luma as a straight alpha channel (yuva420p); the
549
+ audio as Opus; no metadata, bit-exact container and codec flags."""
550
+ args, maps = ["-hide_banner", "-y", "-loglevel", "error"], []
551
+ for s in streams:
552
+ args += inputs[s.name]
553
+ video = next((s for s in streams if s.kind == "video"), None)
554
+ alpha = next((s for s in streams if s.kind == "alpha"), None)
555
+ audio_at = next((i for i, s in enumerate(streams) if s.kind == "audio"), None)
556
+ codecs: list[str] = []
557
+ if video is not None and not webm_reencoded(streams):
558
+ maps += ["-map", "0:v:0"]
559
+ codecs = ["-c:v", "copy"]
560
+ elif video is not None:
561
+ size = display_size(video)
562
+ crop = f",crop={size[0]}:{size[1]}:0:0" if size else ""
563
+ graph = f"[0:v]setpts=N/FRAME_RATE/TB{crop}[c];"
564
+ graph += (f"[1:v]setpts=N/FRAME_RATE/TB{crop},format=gray[a];[c][a]alphamerge,format=yuva420p[v]" if alpha
565
+ else "[c]format=yuv420p[v]")
566
+ args += ["-filter_complex", graph]
567
+ maps += ["-map", "[v]"]
568
+ codecs = [*VP9_ENCODE, *(VP9_ALPHA if alpha else ())]
569
+ if audio_at is not None:
570
+ maps += ["-map", f"{audio_at}:a:0", *OPUS_ENCODE]
571
+ return [*args, *maps, *codecs, "-map_metadata", "-1", "-fflags", "+bitexact", "-f", "webm", str(dst)]
572
+
573
+
574
+ def run_ffmpeg(ffmpeg: str, args: list[str], what: str) -> None:
575
+ r = subprocess.run([ffmpeg, *args], capture_output=True, text=True)
576
+ if r.returncode != 0:
577
+ raise RuntimeError(f"ffmpeg {what} failed: {r.stderr[-2000:]}")
578
+
579
+
580
+ def write_webm(streams: list[Stream], dst: Path, work: Path, ffmpeg: str, key: int = 0,
581
+ vgmstream: str | None = None) -> list[Stream]:
582
+ """The WebM of a movie's streams (webm_streams, webm_args) -> dst, through files in `work` (removed after).
583
+ Returns the streams it carries."""
584
+ picked = webm_streams(streams)
585
+ try:
586
+ inputs = {s.name: stream_input(s, work, key, vgmstream) for s in picked}
587
+ dst.parent.mkdir(parents=True, exist_ok=True)
588
+ run_ffmpeg(ffmpeg, webm_args(picked, inputs, dst), "webm")
589
+ finally:
590
+ shutil.rmtree(work, ignore_errors=True)
591
+ return picked
592
+
593
+
594
+ # --- catalog ----------------------------------------------------------------------------
595
+ def usm_asset(cat: "Catalog", key: str) -> tuple[dict, Path]:
596
+ """(the movie's CriWare.Assets MonoBehaviour typetree, the USM file) of a `Cri/Video/...` key."""
597
+ import UnityPy
598
+ from .addressables import remote_path
599
+ info, raw = None, None
600
+ for off in cat._entry(key)["dependencies"]:
601
+ e = cat._by_off.get(off)
602
+ if e is None:
603
+ continue
604
+ iid = e["internal_id"]
605
+ if remote_path(iid) is None:
606
+ continue
607
+ if iid.endswith(".bundle"):
608
+ env = UnityPy.load(str(cat.fetch(cat._as_bundle(e))))
609
+ for o in env.objects:
610
+ if o.type.name == "MonoBehaviour":
611
+ tt = o.read_typetree()
612
+ if "movieInfo" in tt:
613
+ info = tt
614
+ else:
615
+ raw = cat.fetch_raw(e)
616
+ if info is None or raw is None:
617
+ raise RuntimeError(f"{key}: movie asset or USM data not among the dependencies")
618
+ return info, raw
619
+
620
+
621
+ def export(cat: "Catalog", key: str, dst: Path, cri_key: int, work: Path) -> dict:
622
+ """One USM -> dst (.webm, write_webm: VP9 copied, or re-encoded when the movie's video is not VP9 or has an alpha
623
+ stream; the first audio stream as Opus). Returns its record (movie info, the audio's source; `encode` when the
624
+ video was re-encoded)."""
625
+ from .config import tool
626
+ info, raw = usm_asset(cat, key)
627
+ mi = info["movieInfo"]
628
+ codec = CODECS.get(mi["codecType"], f"codecType {mi['codecType']}")
629
+ streams = demux(raw.read_bytes(), cri_key)
630
+ if mi["numAudioStreams"] and not any(s.kind == "audio" for s in streams):
631
+ raise RuntimeError(f"{key}: movie info lists an audio stream the USM does not carry")
632
+ hca = any(s.kind == "audio" and s.codec == "hca" for s in webm_streams(streams))
633
+ picked = write_webm(streams, dst, work, tool("ffmpeg", "ffmpeg"), cri_key,
634
+ tool("vgmstream", "vgmstream-cli") if hca else None)
635
+ rec = {"key": key, "codec": codec, "width": mi["width"], "height": mi["height"],
636
+ "displayWidth": mi["dispWidth"], "displayHeight": mi["dispHeight"],
637
+ "frameRate": [mi["framerateN"], mi["framerateD"]], "frames": mi["totalFrames"],
638
+ "assetInfo": info.get("assetInfo")}
639
+ if webm_reencoded(picked):
640
+ rec["encode"] = {"codec": "vp9", "crf": int(VP9_ENCODE[VP9_ENCODE.index("-crf") + 1]),
641
+ "alpha": any(s.kind == "alpha" for s in picked)}
642
+ audio = next((s for s in picked if s.kind == "audio"), None)
643
+ if audio is not None:
644
+ rec["audio"] = {"codec": "opus", "bitrate": AUDIO_BITRATE,
645
+ "source": adx_info(audio.data) if audio.codec == "adx" else hca_info(audio.data)}
646
+ return rec
647
+
648
+
649
+ def extract(cat: "Catalog", episode: dict, out_dir: Path, cri_key: int | None = None) -> dict | None:
650
+ """Every video of the episode's closure -> videos/<name>.webm; videos/videos.json maps each VideoID of the
651
+ Movie / Clip rows to its file, its -Video row (`master`: size, autoStop, hasAudio) and the stream facts.
652
+ Returns the index, or None when the episode has no video."""
653
+ from . import adv, cri
654
+ keys = sorted(r["address"] for r in episode["resources"] if r["kind"] == "video")
655
+ if not keys:
656
+ return None
657
+ if cri_key is None:
658
+ if cat.apk is None:
659
+ raise RuntimeError("USM key: pass cri_key= or open the Catalog with apk=")
660
+ cri_key = cri.hca_key(cat.apk)
661
+ out_dir = Path(out_dir)
662
+ files: dict[str, dict] = {}
663
+ names: dict[str, str] = {}
664
+ for key in keys:
665
+ name = key.rsplit("/", 1)[-1]
666
+ if name in names:
667
+ raise RuntimeError(f"videos {names[name]} and {key} share the file name {name}")
668
+ names[name] = key
669
+ rel = f"{VIDEO_DIR}/{name}.webm"
670
+ files[key] = {"file": rel, **export(cat, key, out_dir / rel, cri_key, out_dir / VIDEO_DIR / "_work")}
671
+ videos = {}
672
+ for c in episode["commands"]:
673
+ vid = c.get("VideoID")
674
+ if c["cmd"] not in adv.VIDEO_COMMANDS or not vid or str(vid) in videos:
675
+ continue
676
+ row = episode["videos"].get(vid) or episode["videos"].get(str(vid))
677
+ if not row:
678
+ continue
679
+ key = adv.VIDEO_ADDRESS.format(row["_assetName"])
680
+ if key in files:
681
+ videos[str(vid)] = {"master": row, **files[key]}
682
+ index = {"videos": videos, "format": "WebM: VP9 as stored in the USM, audio as Opus"}
683
+ write_json(out_dir / INDEX, index)
684
+ return index