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/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({"&": "&", "<": "<", ">": ">"})
|
|
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
|