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