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/atoms/sniff.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Atom `sniff`: what a byte string is, from its content.
|
|
2
|
+
|
|
3
|
+
`sniff(data, hint)` returns the format, the file extension and the media type. Binary formats are recognized by
|
|
4
|
+
their magic numbers (the table below); then JSON (strictly: UTF-8 text that parses as JSON, a Spine skeleton when it
|
|
5
|
+
has a `skeleton.spine` version), UTF-8 text, and otherwise `binary`. `hint`, a file name, only chooses among
|
|
6
|
+
equivalent answers: a text extension it ends with (`.csv`, `.atlas`, ...; a trailing `.bytes` is skipped) for text,
|
|
7
|
+
`.acf` for a CRI table. Magic numbers take precedence over the hint.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from . import estimate
|
|
16
|
+
from .. import contract
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Sniffed:
|
|
21
|
+
format: str
|
|
22
|
+
ext: str
|
|
23
|
+
media_type: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# (offset, magic, format, extension, media type); the first match wins
|
|
27
|
+
MAGIC: tuple = (
|
|
28
|
+
(0, b"\x89PNG\r\n\x1a\n", "png", "png", "image/png"),
|
|
29
|
+
(0, b"\xff\xd8\xff", "jpeg", "jpg", "image/jpeg"),
|
|
30
|
+
(0, b"GIF87a", "gif", "gif", "image/gif"),
|
|
31
|
+
(0, b"GIF89a", "gif", "gif", "image/gif"),
|
|
32
|
+
(0, b"\x13\xab\xa1\x5c", "astc", "astc", "image/astc"),
|
|
33
|
+
(0, b"DDS ", "dds", "dds", "image/vnd-ms.dds"),
|
|
34
|
+
(0, b"\xabKTX 11\xbb\r\n\x1a\n", "ktx", "ktx", "image/ktx"),
|
|
35
|
+
(0, b"\xabKTX 20\xbb\r\n\x1a\n", "ktx2", "ktx2", "image/ktx2"),
|
|
36
|
+
(0, b"OTTO", "otf", "otf", "font/otf"),
|
|
37
|
+
(0, b"\x00\x01\x00\x00", "ttf", "ttf", "font/ttf"),
|
|
38
|
+
(0, b"true", "ttf", "ttf", "font/ttf"),
|
|
39
|
+
(0, b"ttcf", "ttc", "ttc", "font/collection"),
|
|
40
|
+
(0, b"wOFF", "woff", "woff", "font/woff"),
|
|
41
|
+
(0, b"wOF2", "woff2", "woff2", "font/woff2"),
|
|
42
|
+
(0, b"@UTF", "cri-utf", "acb", "application/octet-stream"),
|
|
43
|
+
(0, b"AFS2", "afs2", "awb", "application/octet-stream"),
|
|
44
|
+
(0, b"CRID", "usm", "usm", "application/octet-stream"),
|
|
45
|
+
(0, b"HCA\x00", "hca", "hca", "application/octet-stream"),
|
|
46
|
+
(0, b"\xc8\xc3\xc1\x00", "hca", "hca", "application/octet-stream"), # HCA with masked chunk names
|
|
47
|
+
(0, b"UnityFS\x00", "unityfs", "bundle", "application/octet-stream"),
|
|
48
|
+
(0, b"UnityWeb\x00", "unityweb", "bundle", "application/octet-stream"),
|
|
49
|
+
(0, b"UnityRaw\x00", "unityraw", "bundle", "application/octet-stream"),
|
|
50
|
+
(0, b"UnityArchive", "unityarchive", "bundle", "application/octet-stream"),
|
|
51
|
+
(0, b"MOC3", "moc3", "moc3", "application/octet-stream"),
|
|
52
|
+
(0, b"glTF", "glb", "glb", "model/gltf-binary"),
|
|
53
|
+
(0, b"OggS", "ogg", "ogg", "audio/ogg"),
|
|
54
|
+
(0, b"fLaC", "flac", "flac", "audio/flac"),
|
|
55
|
+
(0, b"ID3", "mp3", "mp3", "audio/mpeg"),
|
|
56
|
+
(0, b"MThd", "midi", "mid", "audio/midi"),
|
|
57
|
+
(0, b"DKIF", "ivf", "ivf", "video/x-ivf"),
|
|
58
|
+
(0, b"\x1a\x45\xdf\xa3", "matroska", "mkv", "video/x-matroska"),
|
|
59
|
+
(4, b"ftyp", "mp4", "mp4", "video/mp4"),
|
|
60
|
+
(0, b"%PDF-", "pdf", "pdf", "application/pdf"),
|
|
61
|
+
(0, b"\x1f\x8b", "gzip", "gz", "application/gzip"),
|
|
62
|
+
(0, b"PK\x03\x04", "zip", "zip", "application/zip"),
|
|
63
|
+
(0, b"PK\x05\x06", "zip", "zip", "application/zip"),
|
|
64
|
+
(0, b"7z\xbc\xaf\x27\x1c", "7z", "7z", "application/x-7z-compressed"),
|
|
65
|
+
(0, b"\xfd7zXZ\x00", "xz", "xz", "application/x-xz"),
|
|
66
|
+
(0, b"\x28\xb5\x2f\xfd", "zstd", "zst", "application/zstd"),
|
|
67
|
+
(0, b"\x04\x22\x4d\x18", "lz4", "lz4", "application/octet-stream"),
|
|
68
|
+
)
|
|
69
|
+
# RIFF containers by form type
|
|
70
|
+
RIFF = {b"WAVE": ("wav", "wav", "audio/wav"), b"WEBP": ("webp", "webp", "image/webp"),
|
|
71
|
+
b"AVI ": ("avi", "avi", "video/x-msvideo")}
|
|
72
|
+
TEXT_EXTS = frozenset({"txt", "csv", "tsv", "xml", "yaml", "yml", "ini", "md", "html", "css", "js", "lua", "atlas",
|
|
73
|
+
"srt", "vtt", "svg", "plist"})
|
|
74
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0e-\x1f\x7f]") # not in text: C0 controls except \t \n \f \r, DEL
|
|
75
|
+
_SPINE_VERSION = re.compile(r"[34]\.\d+(\.\d+)*")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _media(ext: str, fallback: str = "application/octet-stream") -> str:
|
|
79
|
+
m = contract.media_type(ext)
|
|
80
|
+
return fallback if m == "application/octet-stream" else m
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hint_ext(hint: str | None) -> str:
|
|
84
|
+
if not hint:
|
|
85
|
+
return ""
|
|
86
|
+
parts = re.split(r"[\\/]", hint)[-1].lower().split(".")[1:]
|
|
87
|
+
if parts and parts[-1] == "bytes":
|
|
88
|
+
parts.pop()
|
|
89
|
+
return parts[-1] if parts else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _varint(data: bytes, pos: int) -> tuple[int, int]:
|
|
93
|
+
"""Spine's variable-length unsigned int (7 bits per byte, least significant first, at most 5 bytes)."""
|
|
94
|
+
value = shift = 0
|
|
95
|
+
for i in range(5):
|
|
96
|
+
if pos + i >= len(data):
|
|
97
|
+
raise ValueError
|
|
98
|
+
b = data[pos + i]
|
|
99
|
+
value |= (b & 0x7F) << shift
|
|
100
|
+
if not b & 0x80:
|
|
101
|
+
return value, pos + i + 1
|
|
102
|
+
shift += 7
|
|
103
|
+
raise ValueError
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _spine_string(data: bytes, pos: int) -> tuple[str, int]:
|
|
107
|
+
n, pos = _varint(data, pos)
|
|
108
|
+
if n == 0 or pos + n - 1 > len(data):
|
|
109
|
+
raise ValueError
|
|
110
|
+
return data[pos:pos + n - 1].decode("ascii"), pos + n - 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def spine_binary_version(data: bytes) -> str | None:
|
|
114
|
+
"""The Spine version a binary skeleton (.skel) states, None if `data` is not one: 4.x files start with an
|
|
115
|
+
8-byte hash then the version string, 3.x files with the hash string then the version string."""
|
|
116
|
+
for start in (8, None):
|
|
117
|
+
try:
|
|
118
|
+
pos = start if start is not None else _spine_string(data, 0)[1]
|
|
119
|
+
version = _spine_string(data, pos)[0]
|
|
120
|
+
except (ValueError, UnicodeDecodeError):
|
|
121
|
+
continue
|
|
122
|
+
if _SPINE_VERSION.fullmatch(version) and version[0] == ("4" if start is not None else "3"):
|
|
123
|
+
return version
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _sfnt(data: bytes) -> bool:
|
|
128
|
+
"""An OpenType / TrueType table directory: a table count and the search range that goes with it."""
|
|
129
|
+
if len(data) < 12:
|
|
130
|
+
return False
|
|
131
|
+
n, search = int.from_bytes(data[4:6], "big"), int.from_bytes(data[6:8], "big")
|
|
132
|
+
return 0 < n <= 512 and len(data) >= 12 + 16 * n and search == 16 << (n.bit_length() - 1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# checks beyond the magic, for magics short or common enough to begin other data
|
|
136
|
+
VALIDATE = {
|
|
137
|
+
"ttf": _sfnt, "otf": _sfnt,
|
|
138
|
+
"gzip": lambda d: d[2:3] == b"\x08",
|
|
139
|
+
"midi": lambda d: d[4:8] == b"\x00\x00\x00\x06",
|
|
140
|
+
"glb": lambda d: d[4:8] in (b"\x01\x00\x00\x00", b"\x02\x00\x00\x00"),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _adx(data: bytes) -> bool:
|
|
145
|
+
if len(data) < 6 or data[:2] != b"\x80\x00":
|
|
146
|
+
return False
|
|
147
|
+
off = int.from_bytes(data[2:4], "big")
|
|
148
|
+
return data[off - 2:off + 4] == b"(c)CRI"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sniff(data: bytes, hint: str | None = None) -> Sniffed:
|
|
152
|
+
data = bytes(data)
|
|
153
|
+
if not data:
|
|
154
|
+
return Sniffed("empty", "bin", "application/octet-stream")
|
|
155
|
+
head = data[:16]
|
|
156
|
+
for off, magic, fmt, ext, media in MAGIC:
|
|
157
|
+
if head[off:off + len(magic)] == magic and VALIDATE.get(fmt, bool)(data):
|
|
158
|
+
if fmt == "cri-utf" and _hint_ext(hint) == "acf":
|
|
159
|
+
ext = "acf"
|
|
160
|
+
return Sniffed(fmt, ext, media)
|
|
161
|
+
if head[:4] == b"RIFF" and head[8:12] in RIFF:
|
|
162
|
+
return Sniffed(*RIFF[head[8:12]])
|
|
163
|
+
if _adx(data):
|
|
164
|
+
return Sniffed("adx", "adx", "audio/x-adx")
|
|
165
|
+
version = spine_binary_version(data)
|
|
166
|
+
if version is not None:
|
|
167
|
+
return Sniffed("spine-skel", "skel", "application/octet-stream")
|
|
168
|
+
try:
|
|
169
|
+
text = data.decode("utf-8")
|
|
170
|
+
except UnicodeDecodeError:
|
|
171
|
+
return Sniffed("binary", "bin", "application/octet-stream")
|
|
172
|
+
if text.startswith(""):
|
|
173
|
+
text = text[1:]
|
|
174
|
+
if _CONTROL.search(text):
|
|
175
|
+
return Sniffed("binary", "bin", "application/octet-stream")
|
|
176
|
+
stripped = text.lstrip(" \t\r\n")
|
|
177
|
+
if stripped[:1] in ("{", "["):
|
|
178
|
+
try:
|
|
179
|
+
doc = json.loads(text)
|
|
180
|
+
except ValueError:
|
|
181
|
+
doc = None
|
|
182
|
+
else:
|
|
183
|
+
skeleton = doc.get("skeleton") if isinstance(doc, dict) else None
|
|
184
|
+
if isinstance(skeleton, dict) and isinstance(skeleton.get("spine"), str):
|
|
185
|
+
return Sniffed("spine-json", "json", "application/json")
|
|
186
|
+
return Sniffed("json", "json", "application/json")
|
|
187
|
+
ext = _hint_ext(hint)
|
|
188
|
+
if stripped.startswith("<?xml") and ext not in TEXT_EXTS:
|
|
189
|
+
ext = "xml"
|
|
190
|
+
if ext not in TEXT_EXTS:
|
|
191
|
+
ext = "txt"
|
|
192
|
+
return Sniffed("text", ext, _media(ext, "text/plain"))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cost(facts: dict) -> dict:
|
|
196
|
+
"""facts: size."""
|
|
197
|
+
n = int(facts.get("size", 0))
|
|
198
|
+
return estimate(1e-5 + n * 2e-9, n * 3)
|
nnnotes/atoms/sprite.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Atom `sprite.crop`: a Sprite's image from its texture's decoded image.
|
|
2
|
+
|
|
3
|
+
A port of UnityPy's `SpriteHelper.get_image_from_sprite` (MIT) that works on values instead of objects: the texture
|
|
4
|
+
rect is cut out of the texture (merged with the alpha texture's first channel when there is one), a packed sprite's
|
|
5
|
+
packing rotation or flip is undone, and a tightly packed sprite is masked with its mesh (or, when its mesh has
|
|
6
|
+
texture coordinates, drawn from them triangle by triangle); the result is top row first. The output equals UnityPy's
|
|
7
|
+
for the same sprite (tests compare the two).
|
|
8
|
+
|
|
9
|
+
The texture image is the one `texture.decode` returns (top row first); the rect is cut from it directly, so an atlas
|
|
10
|
+
texture is not copied once per sprite.
|
|
11
|
+
|
|
12
|
+
`canvas` places the result on the sprite's full rect (Sprite.m_Rect size, transparent elsewhere) at its
|
|
13
|
+
textureRectOffset -- where a tightly packed or trimmed sprite sits in its original bounds; a sprite drawn from its
|
|
14
|
+
mesh is placed by its mesh bounds and the pivot. Sizes are rounded; when rounding fractional rects leaves the canvas
|
|
15
|
+
one pixel narrower or lower than the cut, the canvas takes that pixel.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from . import Unsupported, estimate
|
|
22
|
+
|
|
23
|
+
TIGHT, RECTANGLE = 0, 1
|
|
24
|
+
ROTATIONS = {0: "none", 1: "flipHorizontal", 2: "flipVertical", 3: "rotate180", 4: "rotate90"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Settings:
|
|
29
|
+
packed: bool
|
|
30
|
+
packing_mode: int # 0 tight, 1 rectangle
|
|
31
|
+
packing_rotation: int # 0 none, 1 flip horizontal, 2 flip vertical, 3 rotate 180, 4 rotate 90
|
|
32
|
+
mesh_type: int # 0 full rect, 1 tight
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def settings(raw: int) -> Settings:
|
|
36
|
+
"""SpriteSettings bits of `settingsRaw`."""
|
|
37
|
+
raw = int(raw)
|
|
38
|
+
return Settings(bool(raw & 1), (raw >> 1) & 1, (raw >> 2) & 0xF, (raw >> 6) & 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mesh_values(mesh) -> dict:
|
|
42
|
+
"""The part of a sprite's MeshArrays sprite.crop reads, as plain values (for a document that carries a sprite
|
|
43
|
+
to a task without its bundle): positions, triangles per submesh, texture coordinates (or None). Floats are the
|
|
44
|
+
stored float32 values, so they read back exactly."""
|
|
45
|
+
uv = mesh.channels.get("uv0")
|
|
46
|
+
return {"positions": mesh.channels["position"].data.astype(float).tolist(),
|
|
47
|
+
"submeshes": [p.indices.tolist() for p in mesh.primitives
|
|
48
|
+
if p.topology == "triangles" and p.indices is not None],
|
|
49
|
+
"uv0": None if uv is None else uv.data.astype(float).tolist()}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mesh_from_values(values: dict):
|
|
53
|
+
"""The MeshArrays of mesh_values(...)."""
|
|
54
|
+
import numpy as np
|
|
55
|
+
from .mesh import Channel, MeshArrays, Primitive
|
|
56
|
+
pos = np.asarray(values["positions"], np.float32).reshape(-1, 3)
|
|
57
|
+
channels = {"position": Channel(pos)}
|
|
58
|
+
if values.get("uv0") is not None:
|
|
59
|
+
channels["uv0"] = Channel(np.asarray(values["uv0"], np.float32).reshape(len(pos), -1))
|
|
60
|
+
prims = [Primitive("triangles", np.asarray(t, np.int64).reshape(-1, 3), i)
|
|
61
|
+
for i, t in enumerate(values.get("submeshes") or [])]
|
|
62
|
+
return MeshArrays("", len(pos), channels, prims)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cut(image, rect: dict):
|
|
66
|
+
"""The texture rect of a top-row-first image as UnityPy cuts it from the bottom-row-first image (Pillow's crop
|
|
67
|
+
rounds the box and pads outside the image with zeros), still bottom row first."""
|
|
68
|
+
from PIL.Image import Transpose
|
|
69
|
+
x, y, w, h = rect["x"], rect["y"], rect["width"], rect["height"]
|
|
70
|
+
if x + w < x or y + h < y:
|
|
71
|
+
raise ValueError(f"texture rect {rect}: negative size")
|
|
72
|
+
x0, y0, x1, y1 = (int(round(v)) for v in (x, y, x + w, y + h))
|
|
73
|
+
top = image.height
|
|
74
|
+
return image.crop((x0, top - y1, x1, top - y0)).transpose(Transpose.FLIP_TOP_BOTTOM)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _merge(rgb, alpha):
|
|
78
|
+
from PIL import Image
|
|
79
|
+
return Image.merge("RGBA", (*rgb.split()[:3], alpha.split()[0]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _positions(mesh) -> list:
|
|
83
|
+
ch = mesh.channels.get("position")
|
|
84
|
+
if ch is None or ch.data.shape[1] != 3 or not len(ch.data):
|
|
85
|
+
shape = None if ch is None else ch.data.shape
|
|
86
|
+
raise Unsupported("unsupported.sprite.tight_mask", f"sprite mesh positions {shape}")
|
|
87
|
+
return [tuple(v) for v in ch.data.tolist()]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _triangles(mesh) -> list[list[tuple]]:
|
|
91
|
+
return [[tuple(t) for t in p.indices.tolist()] for p in mesh.primitives
|
|
92
|
+
if p.topology == "triangles" and p.indices is not None]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mask(mesh, image, ppu: float):
|
|
96
|
+
from PIL import Image, ImageDraw
|
|
97
|
+
positions = _positions(mesh)
|
|
98
|
+
mask = Image.new("1", image.size, color=0)
|
|
99
|
+
draw = ImageDraw.ImageDraw(mask)
|
|
100
|
+
min_x = min(x for x, _y, _z in positions)
|
|
101
|
+
min_y = min(y for _x, y, _z in positions)
|
|
102
|
+
flat = [((x - min_x) * ppu, (y - min_y) * ppu) for x, y, _z in positions]
|
|
103
|
+
for tris in _triangles(mesh):
|
|
104
|
+
for a, b, c in tris:
|
|
105
|
+
draw.polygon((flat[a], flat[b], flat[c]), fill=1)
|
|
106
|
+
if image.mode == "RGBA":
|
|
107
|
+
return Image.composite(image, Image.new(image.mode, image.size, color=0), mask)
|
|
108
|
+
image = image.copy()
|
|
109
|
+
image.putalpha(mask)
|
|
110
|
+
return image
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _copy_triangle(src, src_tri, dst, dst_tri) -> None:
|
|
114
|
+
import numpy as np
|
|
115
|
+
from PIL import Image, ImageDraw
|
|
116
|
+
from PIL.Image import Transform
|
|
117
|
+
src_off = ((src_tri[1][0] - src_tri[0][0], src_tri[1][1] - src_tri[0][1]),
|
|
118
|
+
(src_tri[2][0] - src_tri[0][0], src_tri[2][1] - src_tri[0][1]))
|
|
119
|
+
dst_off = ((dst_tri[1][0] - dst_tri[0][0], dst_tri[1][1] - dst_tri[0][1]),
|
|
120
|
+
(dst_tri[2][0] - dst_tri[0][0], dst_tri[2][1] - dst_tri[0][1]))
|
|
121
|
+
if src_off == dst_off: # same shape: copy the triangle's box through a triangle mask
|
|
122
|
+
upper_left = (min(p[0] for p in src_tri), min(p[1] for p in src_tri))
|
|
123
|
+
lower_right = (max(p[0] for p in src_tri), max(p[1] for p in src_tri))
|
|
124
|
+
part = src.crop((*upper_left, *lower_right))
|
|
125
|
+
mask = Image.new("1", part.size)
|
|
126
|
+
ImageDraw.Draw(mask).polygon([(x - upper_left[0], y - upper_left[1]) for x, y in src_tri], fill=255)
|
|
127
|
+
dst.paste(part, (int(min(p[0] for p in dst_tri)), int(min(p[1] for p in dst_tri))), mask=mask)
|
|
128
|
+
return
|
|
129
|
+
(x11, x12), (x21, x22), (x31, x32) = src_tri # the affine map from destination to source coordinates
|
|
130
|
+
(y11, y12), (y21, y22), (y31, y32) = dst_tri
|
|
131
|
+
m = [[y11, y12, 1, 0, 0, 0], [y21, y22, 1, 0, 0, 0], [y31, y32, 1, 0, 0, 0],
|
|
132
|
+
[0, 0, 0, y11, y12, 1], [0, 0, 0, y21, y22, 1], [0, 0, 0, y31, y32, 1]]
|
|
133
|
+
coeffs = np.linalg.solve(m, [x11, x21, x31, x12, x22, x32])
|
|
134
|
+
moved = src.transform(dst.size, Transform.AFFINE, coeffs)
|
|
135
|
+
mask = Image.new("1", dst.size)
|
|
136
|
+
ImageDraw.Draw(mask).polygon(dst_tri, fill=255)
|
|
137
|
+
dst.paste(moved, mask=mask)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _render(mesh, texture, ppu: float):
|
|
141
|
+
"""The sprite drawn from its first submesh's triangles through their texture coordinates (bottom row first),
|
|
142
|
+
and its mesh bounds' minimum corner in units."""
|
|
143
|
+
from PIL import Image
|
|
144
|
+
tris = _triangles(mesh)
|
|
145
|
+
positions = _positions(mesh)
|
|
146
|
+
uv = mesh.channels["uv0"].data
|
|
147
|
+
if not tris:
|
|
148
|
+
raise Unsupported("unsupported.sprite.tight_mask", "sprite mesh without triangles")
|
|
149
|
+
if uv.shape[1] != 2:
|
|
150
|
+
raise Unsupported("unsupported.sprite.tight_mask", f"sprite texture coordinates with {uv.shape[1]} components")
|
|
151
|
+
axes = [[p[i] for p in positions] for i in range(3)]
|
|
152
|
+
for i in range(2, -1, -1):
|
|
153
|
+
if len(set(axes[i])) == 1:
|
|
154
|
+
break
|
|
155
|
+
else:
|
|
156
|
+
raise Unsupported("unsupported.sprite.tight_mask", "sprite mesh is not flat")
|
|
157
|
+
axes = axes[:i] + axes[i + 1:]
|
|
158
|
+
x_min, y_min, x_max, y_max = min(axes[0]), min(axes[1]), max(axes[0]), max(axes[1])
|
|
159
|
+
pos = [(round((x - x_min) * ppu), round((y - y_min) * ppu)) for x, y in zip(*axes)]
|
|
160
|
+
width, height = texture.size
|
|
161
|
+
uv_abs = [(round(u * width), round(v * height)) for u, v in uv.tolist()]
|
|
162
|
+
sprite = Image.new(texture.mode, (round((x_max - x_min) * ppu), round((y_max - y_min) * ppu)))
|
|
163
|
+
for tri in tris[0]:
|
|
164
|
+
_copy_triangle(texture, tuple(uv_abs[k] for k in tri), sprite, tuple(pos[k] for k in tri))
|
|
165
|
+
return sprite, (x_min, y_min)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def crop(image, texture_rect: dict, settings_raw: int, mesh=None, pixels_to_units: float = 100.0, alpha=None, *,
|
|
169
|
+
downscale: float = 1.0, canvas: dict | None = None):
|
|
170
|
+
"""The sprite's PIL image, top row first.
|
|
171
|
+
|
|
172
|
+
image / alpha: the decoded texture and alpha texture (texture.decode); texture_rect: {x, y, width, height} in
|
|
173
|
+
texels from the bottom-left; mesh: the MeshArrays of the sprite's own render data (tight packing);
|
|
174
|
+
pixels_to_units: Sprite.m_PixelsToUnits; downscale: the atlas' downscaleMultiplier (only 1 is supported);
|
|
175
|
+
canvas: {rect: {width, height}, offset: textureRectOffset {x, y}, pivot: m_Pivot {x, y}}."""
|
|
176
|
+
from PIL.Image import Transpose
|
|
177
|
+
if downscale != 1.0:
|
|
178
|
+
raise Unsupported("unsupported.sprite.downscale", f"downscaleMultiplier {downscale}")
|
|
179
|
+
if alpha is not None and alpha.size != image.size:
|
|
180
|
+
raise ValueError(f"alpha texture {alpha.size} for texture {image.size}")
|
|
181
|
+
img = _cut(image, texture_rect)
|
|
182
|
+
if alpha is not None:
|
|
183
|
+
img = _merge(img, _cut(alpha, texture_rect))
|
|
184
|
+
s = settings(settings_raw)
|
|
185
|
+
if s.packed:
|
|
186
|
+
turn = {1: Transpose.FLIP_LEFT_RIGHT, 2: Transpose.FLIP_TOP_BOTTOM, 3: Transpose.ROTATE_180,
|
|
187
|
+
4: Transpose.ROTATE_270}.get(s.packing_rotation)
|
|
188
|
+
if turn is not None:
|
|
189
|
+
img = img.transpose(turn)
|
|
190
|
+
corner = None
|
|
191
|
+
if s.packing_mode == TIGHT:
|
|
192
|
+
if mesh is None:
|
|
193
|
+
raise ValueError("tightly packed sprite without its mesh")
|
|
194
|
+
uv = mesh.channels.get("uv0")
|
|
195
|
+
if uv is not None and len(uv.data) and bool((uv.data[:, :2] != 0).any()):
|
|
196
|
+
texture = image if alpha is None else _merge(image, alpha)
|
|
197
|
+
img, corner = _render(mesh, texture.transpose(Transpose.FLIP_TOP_BOTTOM), pixels_to_units)
|
|
198
|
+
else:
|
|
199
|
+
img = _mask(mesh, img, pixels_to_units)
|
|
200
|
+
img = img.transpose(Transpose.FLIP_TOP_BOTTOM)
|
|
201
|
+
if canvas is None:
|
|
202
|
+
return img
|
|
203
|
+
cut_rounded = any(float(texture_rect[k]) % 1 for k in ("x", "y", "width", "height"))
|
|
204
|
+
return _place(img, canvas, corner, pixels_to_units, cut_rounded)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _place(img, canvas: dict, corner, ppu: float, cut_rounded: bool = False):
|
|
208
|
+
from PIL import Image
|
|
209
|
+
rect, pivot = canvas["rect"], canvas.get("pivot") or {"x": 0.5, "y": 0.5}
|
|
210
|
+
w, h = int(round(rect["width"])), int(round(rect["height"]))
|
|
211
|
+
if corner is None:
|
|
212
|
+
ox, oy = canvas["offset"]["x"], canvas["offset"]["y"]
|
|
213
|
+
else:
|
|
214
|
+
ox, oy = corner[0] * ppu + pivot["x"] * rect["width"], corner[1] * ppu + pivot["y"] * rect["height"]
|
|
215
|
+
x, y = int(round(ox)), int(round(oy))
|
|
216
|
+
# fractional values: the texture rect is cut at its rounded edges and the canvas has the rounded size, so the cut
|
|
217
|
+
# can be one pixel wider or higher than the canvas; the canvas grows by that pixel
|
|
218
|
+
if cut_rounded or rect["width"] % 1 or ox % 1:
|
|
219
|
+
w = w + 1 if x + img.width == w + 1 else w
|
|
220
|
+
if cut_rounded or rect["height"] % 1 or oy % 1:
|
|
221
|
+
h = h + 1 if y + img.height == h + 1 else h
|
|
222
|
+
if x < 0 or y < 0 or x + img.width > w or y + img.height > h:
|
|
223
|
+
raise Unsupported("unsupported.sprite.canvas", f"{img.width}x{img.height} image at ({x}, {y}) outside the "
|
|
224
|
+
f"{w}x{h} rect")
|
|
225
|
+
if "A" not in img.getbands():
|
|
226
|
+
img = img.convert("RGBA")
|
|
227
|
+
out = Image.new(img.mode, (w, h))
|
|
228
|
+
out.paste(img, (x, h - y - img.height))
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cost(facts: dict) -> dict:
|
|
233
|
+
"""facts: width, height (texture rect), textureWidth, textureHeight, tight (bool)."""
|
|
234
|
+
px = int(facts.get("width", 0)) * int(facts.get("height", 0))
|
|
235
|
+
tex = int(facts.get("textureWidth", 0)) * int(facts.get("textureHeight", 0))
|
|
236
|
+
return estimate(5e-5 + px * 1.5e-8 + (tex * 4e-9 if facts.get("tight") else 0), px * 8 + tex * 4)
|
nnnotes/atoms/texture.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Atom `texture.decode`: the image of a Texture2D's first mip level from its stored bytes.
|
|
2
|
+
|
|
3
|
+
The reference implementation is UnityPy's `Texture2DConverter.parse_image_data` with the arguments UnityPy's
|
|
4
|
+
`Texture2D.image` passes (top row first), so the image, and its PNG encoding, equal the ones the other commands
|
|
5
|
+
write. HDR ASTC formats are refused: the decoder turns their blocks into its error colour (they are exported as
|
|
6
|
+
.astc, see `astc.container`).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from . import Unsupported, estimate
|
|
13
|
+
|
|
14
|
+
HDR_ASTC = frozenset(range(66, 72)) # TextureFormat ASTC_HDR_4x4 .. ASTC_HDR_12x12
|
|
15
|
+
# TextureFormat -> ASTC block size (width, height)
|
|
16
|
+
ASTC_BLOCKS = {
|
|
17
|
+
**{48 + i: b for i, b in enumerate(((4, 4), (5, 5), (6, 6), (8, 8), (10, 10), (12, 12)))}, # ASTC_RGB_*
|
|
18
|
+
**{54 + i: b for i, b in enumerate(((4, 4), (5, 5), (6, 6), (8, 8), (10, 10), (12, 12)))}, # ASTC_RGBA_*
|
|
19
|
+
**{66 + i: b for i, b in enumerate(((4, 4), (5, 5), (6, 6), (8, 8), (10, 10), (12, 12)))}, # ASTC_HDR_*
|
|
20
|
+
}
|
|
21
|
+
# formats stored uncompressed (bytes per texel is all their decode needs)
|
|
22
|
+
_RAW = frozenset({*range(1, 10), *range(13, 21), 62, 63, *range(72, 83)})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TextureInput:
|
|
27
|
+
"""What a Texture2D's image depends on: the stored image data (every mip level, as `get_image_data` returns
|
|
28
|
+
it), size, TextureFormat, the serialized file's Unity version and build target, and the platform blob."""
|
|
29
|
+
data: bytes
|
|
30
|
+
width: int
|
|
31
|
+
height: int
|
|
32
|
+
format: int
|
|
33
|
+
version: tuple = (0, 0, 0, 0)
|
|
34
|
+
platform: int = 0
|
|
35
|
+
platform_blob: bytes | None = None
|
|
36
|
+
|
|
37
|
+
def key(self) -> tuple:
|
|
38
|
+
"""The decode inputs as one tuple (the order export._decode_inputs uses)."""
|
|
39
|
+
return (self.data, self.width, self.height, self.format, tuple(self.version), self.platform,
|
|
40
|
+
self.platform_blob)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_hdr_astc(fmt: int) -> bool:
|
|
44
|
+
return int(fmt) in HDR_ASTC
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_name(fmt: int) -> str:
|
|
48
|
+
from UnityPy.enums import TextureFormat
|
|
49
|
+
try:
|
|
50
|
+
return TextureFormat(int(fmt)).name
|
|
51
|
+
except ValueError:
|
|
52
|
+
return str(int(fmt))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decode(inp: TextureInput):
|
|
56
|
+
"""PIL image of the first mip level, top row first. Unsupported: `empty.texture` for a texture without pixels
|
|
57
|
+
(0 wide or high), `unsupported.texture.format` for HDR ASTC and for formats the decoder does not implement."""
|
|
58
|
+
from UnityPy.export.Texture2DConverter import parse_image_data
|
|
59
|
+
fmt = int(inp.format)
|
|
60
|
+
if not inp.width or not inp.height:
|
|
61
|
+
raise Unsupported("empty.texture", f"{inp.width}x{inp.height} texture")
|
|
62
|
+
if fmt in HDR_ASTC:
|
|
63
|
+
raise Unsupported("unsupported.texture.format",
|
|
64
|
+
f"{format_name(fmt)} (HDR ASTC): the decoder returns its error colour for these blocks")
|
|
65
|
+
try:
|
|
66
|
+
return parse_image_data(inp.data, inp.width, inp.height, fmt, tuple(inp.version), inp.platform,
|
|
67
|
+
inp.platform_blob, True)
|
|
68
|
+
except NotImplementedError as e:
|
|
69
|
+
raise Unsupported("unsupported.texture.format", str(e)) from None
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
if "is not a valid TextureFormat" in str(e):
|
|
72
|
+
raise Unsupported("unsupported.texture.format", f"texture format {fmt}") from None
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cost(facts: dict) -> dict:
|
|
77
|
+
"""facts: width, height, format."""
|
|
78
|
+
px = int(facts.get("width", 0)) * int(facts.get("height", 0))
|
|
79
|
+
per_px = 4e-9 if int(facts.get("format", 4)) in _RAW else 2.5e-8
|
|
80
|
+
return estimate(1e-4 + px * per_px, px * 4 * 3)
|