mntm-asset-packer 1.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.
@@ -0,0 +1,91 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: mntm-asset-packer
|
3
|
+
Version: 1.1.0
|
4
|
+
Summary: An improved asset packer script to make the process of creating and packing asset packs for the Momentum firmware easier.
|
5
|
+
Author-email: notnotnescap <97590612+nescapp@users.noreply.github.com>
|
6
|
+
Requires-Python: >=3.11
|
7
|
+
Requires-Dist: heatshrink2>=0.13.0
|
8
|
+
Requires-Dist: pillow>=11.2.1
|
9
|
+
Description-Content-Type: text/markdown
|
10
|
+
|
11
|
+
# mntm-asset-packer
|
12
|
+
|
13
|
+
An improved asset packer script to make the process of creating asset packs for the [Momentum firmware](https://momentum-fw.dev/) easier. This script is designed to be backwards compatible with the original packer while adding new features for a better user experience.
|
14
|
+
|
15
|
+
# Features
|
16
|
+
|
17
|
+
This improved packer adds several features over the original:
|
18
|
+
|
19
|
+
- **Pack specific asset packs**: No need to pack everything at once.
|
20
|
+
- **Create command**: Quickly scaffold the necessary file structure for a new asset pack.
|
21
|
+
- **Automatic file conversion**: Automatically convert and rename image frames for animations.
|
22
|
+
- **Asset pack recovery**: Recover PNGs and metadata from compiled asset packs. (Note: Font recovery is not yet implemented).
|
23
|
+
- **Backwards compatibility**: Works the same way as the original packer by default, so you can use it without changing your workflow.
|
24
|
+
|
25
|
+
# Setup
|
26
|
+
|
27
|
+
## Using [uv](https://docs.astral.sh/uv/) (recommended)
|
28
|
+
|
29
|
+
If you don't have `uv` installed, follow [these](https://docs.astral.sh/uv/getting-started/installation/) instructions.
|
30
|
+
|
31
|
+
You can quickly run the script with this command:
|
32
|
+
```sh
|
33
|
+
uvx mntm-asset-packer help
|
34
|
+
```
|
35
|
+
|
36
|
+
To install, use this command:
|
37
|
+
```sh
|
38
|
+
uv tool install mntm-asset-packer
|
39
|
+
mntm-asset-packer help
|
40
|
+
```
|
41
|
+
|
42
|
+
or using pip:
|
43
|
+
```sh
|
44
|
+
pip install mntm-asset-packer
|
45
|
+
mntm-asset-packer help
|
46
|
+
```
|
47
|
+
|
48
|
+
## Using venv
|
49
|
+
|
50
|
+
1. Clone this repository and navigate into its directory.
|
51
|
+
2. Create and activate a virtual environment:
|
52
|
+
```sh
|
53
|
+
python3 -m venv venv
|
54
|
+
source venv/bin/activate
|
55
|
+
```
|
56
|
+
3. Install the required dependencies from [`requirements.txt`](requirements.txt):
|
57
|
+
```sh
|
58
|
+
pip install -r requirements.txt
|
59
|
+
```
|
60
|
+
|
61
|
+
|
62
|
+
# Usage
|
63
|
+
|
64
|
+
If you run the script directly, replace `mntm-asset-packer` with `python3 mntm_asset_packer.py` in the commands below.
|
65
|
+
|
66
|
+
`mntm-asset-packer help`
|
67
|
+
: Displays a detailed help message with all available commands.
|
68
|
+
|
69
|
+
`mntm-asset-packer create <Asset Pack Name>`
|
70
|
+
: Creates a directory with the correct file structure to start a new asset pack.
|
71
|
+
|
72
|
+
`mntm-asset-packer pack <./path/to/AssetPack>`
|
73
|
+
: Packs a single, specified asset pack into the `./asset_packs/` directory.
|
74
|
+
|
75
|
+
`mntm-asset-packer pack all`
|
76
|
+
: Packs all valid asset pack folders found in the current directory into `./asset_packs/`. This is the default action if no command is provided.
|
77
|
+
|
78
|
+
`mntm-asset-packer recover <./asset_packs/AssetPack>`
|
79
|
+
: Recovers a compiled asset pack back to its source form (e.g., `.bmx` to `.png`). The recovered pack is saved in `./recovered/<AssetPackName>`.
|
80
|
+
|
81
|
+
`mntm-asset-packer recover all`
|
82
|
+
: Recovers all asset packs from the `./asset_packs/` directory into the `./recovered/` directory.
|
83
|
+
|
84
|
+
`mntm-asset-packer convert <./path/to/AssetPack>`
|
85
|
+
: Converts and renames all animation frames in an asset pack to the standard `frame_N.png` format.
|
86
|
+
|
87
|
+
# More Information
|
88
|
+
|
89
|
+
- **General Asset Info**: [https://github.com/Kuronons/FZ_graphics](https://github.com/Kuronons/FZ_graphics)
|
90
|
+
- **Animation `meta.txt` Guide**: [https://flipper.wiki/tutorials/Animation_guide_meta/Meta_settings_guide/](https://flipper.wiki/tutorials/Animation_guide_meta/Meta_settings_guide/)
|
91
|
+
- **Custom Fonts Guide**: [https://flipper.wiki/tutorials/f0_fonts_guide/guide/](https://flipper.wiki/tutorials/f0_fonts_guide/guide/)
|
@@ -0,0 +1,5 @@
|
|
1
|
+
mntm_asset_packer.py,sha256=1oRpiZI7-miamOx0POado6LBBjYo6mIEdfdBVpjltrg,26260
|
2
|
+
mntm_asset_packer-1.1.0.dist-info/METADATA,sha256=2OhlKcxQA2yzBICa4AX1Btedvizvd_HlIgPI5B-bW3c,3648
|
3
|
+
mntm_asset_packer-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
4
|
+
mntm_asset_packer-1.1.0.dist-info/entry_points.txt,sha256=CF05qVMVPPNhTroKeH_kkKqVgG-mJ-Q0mSAZHpfxkr0,61
|
5
|
+
mntm_asset_packer-1.1.0.dist-info/RECORD,,
|
mntm_asset_packer.py
ADDED
@@ -0,0 +1,681 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
An improved asset packer for the Momentum firmware.
|
4
|
+
This is a modification of the original asset_packer script by @Willy-JL
|
5
|
+
"""
|
6
|
+
|
7
|
+
import pathlib
|
8
|
+
import shutil
|
9
|
+
import struct
|
10
|
+
import typing
|
11
|
+
import time
|
12
|
+
import re
|
13
|
+
import io
|
14
|
+
import os
|
15
|
+
import sys
|
16
|
+
from PIL import Image, ImageOps
|
17
|
+
import heatshrink2
|
18
|
+
|
19
|
+
HELP_MESSAGE = """The asset packer converts animations with a specific structure to be efficient and compatible
|
20
|
+
with the asset pack system used in Momentum. More info: https://github.com/Kuronons/FZ_graphics
|
21
|
+
|
22
|
+
Usage :
|
23
|
+
\033[32mmntm-asset-packer \033[0;33;1mhelp\033[0m
|
24
|
+
\033[3mDisplays this message
|
25
|
+
\033[0m
|
26
|
+
\033[32mmntm-asset-packer \033[0;33;1mcreate <Asset Pack Name>\033[0m
|
27
|
+
\033[3mCreates a directory with the correct file structure that can be used
|
28
|
+
to prepare for the packing process.
|
29
|
+
\033[0m
|
30
|
+
\033[32mmntm-asset-packer \033[0;33;1mpack <./path/to/AssetPack>\033[0m
|
31
|
+
\033[3mPacks the specified asset pack into './asset_packs/AssetPack'
|
32
|
+
\033[0m
|
33
|
+
\033[32mmntm-asset-packer \033[0;33;1mpack all\033[0m
|
34
|
+
\033[3mPacks all asset packs in the current directory into './asset_packs/'
|
35
|
+
\033[0m
|
36
|
+
\033[32mpython3 mntm-asset-packer.py\033[0m
|
37
|
+
\033[3mSame as 'mntm-asset-packer pack all'
|
38
|
+
\033[0m
|
39
|
+
\033[32mmntm-asset-packer \033[0;33;1mrecover <./asset_packs/AssetPack>\033[0m
|
40
|
+
\033[3mRecovers the png frame(s) from a compiled assets for the specified asset pack
|
41
|
+
The recovered asset pack is saved in './recovered/AssetPack'
|
42
|
+
\033[0m
|
43
|
+
\033[32mmntm-asset-packer \033[0;33;1mrecover all\033[0m
|
44
|
+
\033[3mRecovers all asset packs in './asset_packs/' into './recovered/'
|
45
|
+
\033[0m
|
46
|
+
\033[32mmntm-asset-packer \033[0;33;1mconvert <./path/to/AssetPack>\033[0m
|
47
|
+
\033[3mConverts all anim frames to .png files and renames them to the correct format.
|
48
|
+
(requires numbers in filenames)
|
49
|
+
\033[0m
|
50
|
+
"""
|
51
|
+
|
52
|
+
EXAMPLE_MANIFEST = """Filetype: Flipper Animation Manifest
|
53
|
+
Version: 1
|
54
|
+
|
55
|
+
Name: example_anim
|
56
|
+
Min butthurt: 0
|
57
|
+
Max butthurt: 18
|
58
|
+
Min level: 1
|
59
|
+
Max level: 30
|
60
|
+
Weight: 8
|
61
|
+
"""
|
62
|
+
|
63
|
+
EXAMPLE_META = """Filetype: Flipper Animation
|
64
|
+
Version: 1
|
65
|
+
# More info on meta settings:
|
66
|
+
# https://flipper.wiki/tutorials/Animation_guide_meta/Meta_settings_guide/
|
67
|
+
|
68
|
+
Width: 128
|
69
|
+
Height: 64
|
70
|
+
Passive frames: 24
|
71
|
+
Active frames: 0
|
72
|
+
Frames order: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
73
|
+
Active cycles: 0
|
74
|
+
Frame rate: 2
|
75
|
+
Duration: 3600
|
76
|
+
Active cooldown: 0
|
77
|
+
|
78
|
+
Bubble slots: 0
|
79
|
+
"""
|
80
|
+
|
81
|
+
|
82
|
+
def convert_to_bm(img: "Image.Image | pathlib.Path") -> bytes:
|
83
|
+
"""Converts an image to a bitmap"""
|
84
|
+
if not isinstance(img, Image.Image):
|
85
|
+
img = Image.open(img)
|
86
|
+
|
87
|
+
with io.BytesIO() as output:
|
88
|
+
img = img.convert("1")
|
89
|
+
img = ImageOps.invert(img)
|
90
|
+
img.save(output, format="XBM")
|
91
|
+
xbm = output.getvalue()
|
92
|
+
|
93
|
+
f = io.StringIO(xbm.decode().strip())
|
94
|
+
data = f.read().strip().replace("\n", "").replace(" ", "").split("=")[1][:-1]
|
95
|
+
data_str = data[1:-1].replace(",", " ").replace("0x", "")
|
96
|
+
data_bin = bytearray.fromhex(data_str)
|
97
|
+
|
98
|
+
# compressing the image
|
99
|
+
data_encoded_str = heatshrink2.compress(data_bin, window_sz2=8, lookahead_sz2=4)
|
100
|
+
data_enc = bytearray(data_encoded_str)
|
101
|
+
data_enc = bytearray([len(data_enc) & 0xFF, len(data_enc) >> 8]) + data_enc
|
102
|
+
|
103
|
+
# marking the image as compressed
|
104
|
+
if len(data_enc) + 2 < len(data_bin) + 1:
|
105
|
+
return b"\x01\x00" + data_enc
|
106
|
+
return b"\x00" + data_bin
|
107
|
+
|
108
|
+
|
109
|
+
def convert_to_bmx(img: "Image.Image | pathlib.Path") -> bytes:
|
110
|
+
"""Converts an image to a bmx that contains image size info"""
|
111
|
+
if not isinstance(img, Image.Image):
|
112
|
+
img = Image.open(img)
|
113
|
+
|
114
|
+
data = struct.pack("<II", *img.size)
|
115
|
+
data += convert_to_bm(img)
|
116
|
+
return data
|
117
|
+
|
118
|
+
|
119
|
+
def recover_from_bm(bm: "bytes | pathlib.Path", width: int, height: int) -> Image.Image:
|
120
|
+
"""Converts a bitmap back to a png (same as convert_to_bm but in reverse) The resulting png
|
121
|
+
will not always be the same as the original image as some information is lost during the
|
122
|
+
conversion"""
|
123
|
+
if not isinstance(bm, bytes):
|
124
|
+
bm = bm.read_bytes()
|
125
|
+
|
126
|
+
# expected_length = (width * height + 7) // 8
|
127
|
+
|
128
|
+
if bm.startswith(b'\x01\x00'):
|
129
|
+
data_dec = heatshrink2.decompress(bm[4:], window_sz2=8, lookahead_sz2=4)
|
130
|
+
else:
|
131
|
+
data_dec = bm[1:]
|
132
|
+
|
133
|
+
img = Image.new("1", (width, height))
|
134
|
+
|
135
|
+
pixels = []
|
136
|
+
num_target_pixels = width * height
|
137
|
+
for byte_val in data_dec:
|
138
|
+
for i in range(8):
|
139
|
+
if len(pixels) < num_target_pixels:
|
140
|
+
pixels.append(1 - ((byte_val >> i) & 1))
|
141
|
+
else:
|
142
|
+
break
|
143
|
+
if len(pixels) >= num_target_pixels:
|
144
|
+
break
|
145
|
+
|
146
|
+
img.putdata(pixels)
|
147
|
+
|
148
|
+
return img
|
149
|
+
|
150
|
+
|
151
|
+
def recover_from_bmx(bmx: "bytes | pathlib.Path") -> Image.Image:
|
152
|
+
"""Converts a bmx back to a png (same as convert_to_bmx but in reverse)"""
|
153
|
+
if not isinstance(bmx, bytes):
|
154
|
+
bmx = bmx.read_bytes()
|
155
|
+
|
156
|
+
width, height = struct.unpack("<II", bmx[:8])
|
157
|
+
return recover_from_bm(bmx[8:], width, height)
|
158
|
+
|
159
|
+
|
160
|
+
def copy_file_as_lf(src: "pathlib.Path", dst: "pathlib.Path"):
|
161
|
+
"""Copy file but replace Windows Line Endings with Unix Line Endings"""
|
162
|
+
dst.write_bytes(src.read_bytes().replace(b"\r\n", b"\n"))
|
163
|
+
|
164
|
+
|
165
|
+
def pack_anim(src: pathlib.Path, dst: pathlib.Path):
|
166
|
+
"""Packs an anim"""
|
167
|
+
if not (src / "meta.txt").is_file():
|
168
|
+
print(f"\033[31mNo meta.txt found in \"{src.name}\" anim.\033[0m")
|
169
|
+
return
|
170
|
+
if not any(re.match(r"frame_\d+.(png|bm)", file.name) for file in src.iterdir()):
|
171
|
+
print(f"\033[31mNo frames with the required format found in \"{src.name}\" anim.\033[0m")
|
172
|
+
try:
|
173
|
+
input(
|
174
|
+
"Press [Enter] to convert and rename the frames or [Ctrl+C] to cancel\033[0m"
|
175
|
+
)
|
176
|
+
except KeyboardInterrupt:
|
177
|
+
sys.exit(0)
|
178
|
+
print()
|
179
|
+
convert_and_rename_frames(src, print)
|
180
|
+
|
181
|
+
dst.mkdir(parents=True, exist_ok=True)
|
182
|
+
for frame in src.iterdir():
|
183
|
+
if not frame.is_file():
|
184
|
+
continue
|
185
|
+
if frame.name == "meta.txt":
|
186
|
+
copy_file_as_lf(frame, dst / frame.name)
|
187
|
+
elif frame.name.startswith("frame_"):
|
188
|
+
if frame.suffix == ".png":
|
189
|
+
(dst / frame.with_suffix(".bm").name).write_bytes(convert_to_bm(frame))
|
190
|
+
elif frame.suffix == ".bm":
|
191
|
+
if not (dst / frame.name).is_file():
|
192
|
+
shutil.copyfile(frame, dst / frame.name)
|
193
|
+
|
194
|
+
|
195
|
+
def recover_anim(src: pathlib.Path, dst: pathlib.Path):
|
196
|
+
"""Converts a bitmap to a png"""
|
197
|
+
if not os.path.exists(src):
|
198
|
+
print(f"\033[31mError: \"{src}\" not found\033[0m")
|
199
|
+
return
|
200
|
+
if not any(re.match(r"frame_\d+.bm", file.name) for file in src.iterdir()):
|
201
|
+
print(f"\033[31mNo frames with the required format found in \"{src.name}\" anim.\033[0m")
|
202
|
+
return
|
203
|
+
|
204
|
+
dst.mkdir(parents=True, exist_ok=True)
|
205
|
+
|
206
|
+
width = 128
|
207
|
+
height = 64
|
208
|
+
meta = src / "meta.txt"
|
209
|
+
if os.path.exists(meta):
|
210
|
+
shutil.copyfile(meta, dst / meta.name)
|
211
|
+
with open(meta, "r", encoding="utf-8") as f:
|
212
|
+
for line in f:
|
213
|
+
if line.startswith("Width:"):
|
214
|
+
width = int(line.split(":")[1].strip())
|
215
|
+
elif line.startswith("Height:"):
|
216
|
+
height = int(line.split(":")[1].strip())
|
217
|
+
else:
|
218
|
+
print(f"meta.txt not found, assuming width={width}, height={height}")
|
219
|
+
|
220
|
+
for file in src.iterdir():
|
221
|
+
if file.is_file() and file.suffix == ".bm":
|
222
|
+
img = recover_from_bm(file, width, height)
|
223
|
+
img.save(dst / file.with_suffix(".png").name)
|
224
|
+
|
225
|
+
|
226
|
+
def pack_animated_icon(src: pathlib.Path, dst: pathlib.Path):
|
227
|
+
"""Packs an animated icon"""
|
228
|
+
if not (src / "frame_rate").is_file() and not (src / "meta").is_file():
|
229
|
+
return
|
230
|
+
dst.mkdir(parents=True, exist_ok=True)
|
231
|
+
frame_count = 0
|
232
|
+
frame_rate = None
|
233
|
+
size = None
|
234
|
+
files = [file for file in src.iterdir() if file.is_file()]
|
235
|
+
for frame in sorted(files, key=lambda x: x.name):
|
236
|
+
if not frame.is_file():
|
237
|
+
continue
|
238
|
+
if frame.name == "frame_rate":
|
239
|
+
frame_rate = int(frame.read_text().strip())
|
240
|
+
elif frame.name == "meta":
|
241
|
+
shutil.copyfile(frame, dst / frame.name)
|
242
|
+
else:
|
243
|
+
dst_frame = dst / f"frame_{frame_count:02}.bm"
|
244
|
+
if frame.suffix == ".png":
|
245
|
+
if not size:
|
246
|
+
size = Image.open(frame).size
|
247
|
+
dst_frame.write_bytes(convert_to_bm(frame))
|
248
|
+
frame_count += 1
|
249
|
+
elif frame.suffix == ".bm":
|
250
|
+
if frame.with_suffix(".png") not in files:
|
251
|
+
shutil.copyfile(frame, dst_frame)
|
252
|
+
frame_count += 1
|
253
|
+
if size is not None and frame_rate is not None:
|
254
|
+
(dst / "meta").write_bytes(struct.pack("<IIII", *size, frame_rate, frame_count))
|
255
|
+
|
256
|
+
|
257
|
+
def recover_animated_icon(src: pathlib.Path, dst: pathlib.Path):
|
258
|
+
"""Recovers an animated icon"""
|
259
|
+
meta_file_path = src / "meta"
|
260
|
+
|
261
|
+
if not meta_file_path.is_file():
|
262
|
+
return
|
263
|
+
|
264
|
+
unpacked_meta_data = None
|
265
|
+
try:
|
266
|
+
with open(meta_file_path, "rb") as f:
|
267
|
+
expected_bytes_count = struct.calcsize("<IIII")
|
268
|
+
data_bytes = f.read(expected_bytes_count)
|
269
|
+
if len(data_bytes) < expected_bytes_count:
|
270
|
+
print(f"Error: Meta file '{meta_file_path}' is too short or corrupted.")
|
271
|
+
return
|
272
|
+
unpacked_meta_data = struct.unpack("<IIII", data_bytes)
|
273
|
+
except struct.error:
|
274
|
+
print(f"Error: Failed to unpack meta file '{meta_file_path}'. It might be corrupted.")
|
275
|
+
return
|
276
|
+
except Exception as e: # Catch other potential IO errors
|
277
|
+
print(f"Error reading meta file '{meta_file_path}': {e}")
|
278
|
+
return
|
279
|
+
|
280
|
+
# unpacked_meta_data should be (width, height, frame_rate, frame_count)
|
281
|
+
image_width = unpacked_meta_data[0]
|
282
|
+
image_height = unpacked_meta_data[1]
|
283
|
+
frame_rate_value = unpacked_meta_data[2]
|
284
|
+
number_of_frames = unpacked_meta_data[3]
|
285
|
+
|
286
|
+
dst.mkdir(parents=True, exist_ok=True)
|
287
|
+
for i in range(number_of_frames):
|
288
|
+
frame_bm_file_path = src / f"frame_{i:02}.bm"
|
289
|
+
if not frame_bm_file_path.is_file():
|
290
|
+
print(f"Warning: Frame file '{frame_bm_file_path}' not found. Skipping.")
|
291
|
+
continue # skip this frame if the .bm file is missing
|
292
|
+
|
293
|
+
try:
|
294
|
+
frame = recover_from_bm(frame_bm_file_path, image_width, image_height)
|
295
|
+
frame.save(dst / f"frame_{i:02}.png")
|
296
|
+
except Exception as e:
|
297
|
+
print(f"Error recovering or saving frame '{frame_bm_file_path}': {e}")
|
298
|
+
continue # skip to the next frame if an error occurs
|
299
|
+
|
300
|
+
(dst / "frame_rate").write_text(str(frame_rate_value))
|
301
|
+
|
302
|
+
|
303
|
+
def pack_static_icon(src: pathlib.Path, dst: pathlib.Path):
|
304
|
+
"""Packs a static icon"""
|
305
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
306
|
+
if src.suffix == ".png":
|
307
|
+
dst.with_suffix(".bmx").write_bytes(convert_to_bmx(src))
|
308
|
+
elif src.suffix == ".bmx":
|
309
|
+
if not dst.is_file():
|
310
|
+
shutil.copyfile(src, dst)
|
311
|
+
|
312
|
+
|
313
|
+
def recover_static_icon(src: pathlib.Path, dst: pathlib.Path):
|
314
|
+
"""Recovers a static icon"""
|
315
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
316
|
+
if src.suffix == ".bmx":
|
317
|
+
recover_from_bmx(src).save(dst.with_suffix(".png"))
|
318
|
+
|
319
|
+
|
320
|
+
def pack_font(src: pathlib.Path, dst: pathlib.Path):
|
321
|
+
"""Packs a font"""
|
322
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
323
|
+
if src.suffix == ".c":
|
324
|
+
code = (
|
325
|
+
src.read_bytes().split(b' U8G2_FONT_SECTION("')[1].split(b'") =')[1].strip()
|
326
|
+
)
|
327
|
+
font = b""
|
328
|
+
for line in code.splitlines():
|
329
|
+
if line.count(b'"') == 2:
|
330
|
+
font += (
|
331
|
+
line[line.find(b'"') + 1 : line.rfind(b'"')]
|
332
|
+
.decode("unicode_escape")
|
333
|
+
.encode("latin_1")
|
334
|
+
)
|
335
|
+
font += b"\0"
|
336
|
+
dst.with_suffix(".u8f").write_bytes(font)
|
337
|
+
elif src.suffix == ".u8f":
|
338
|
+
if not dst.is_file():
|
339
|
+
shutil.copyfile(src, dst)
|
340
|
+
|
341
|
+
|
342
|
+
# recover font is not implemented
|
343
|
+
|
344
|
+
|
345
|
+
def convert_and_rename_frames(directory: "str | pathlib.Path", logger: typing.Callable):
|
346
|
+
"""Converts all frames to png and renames them "frame_N.png"
|
347
|
+
(requires the image name to contain the frame number)"""
|
348
|
+
already_formatted = True
|
349
|
+
for file in directory.iterdir():
|
350
|
+
if file.is_file() and file.suffix in (".jpg", ".jpeg", ".png"):
|
351
|
+
if not re.match(r"frame_\d+.png", file.name):
|
352
|
+
already_formatted = False
|
353
|
+
break
|
354
|
+
if already_formatted:
|
355
|
+
logger(f"\"{directory.name}\" anim is formatted")
|
356
|
+
return
|
357
|
+
|
358
|
+
try:
|
359
|
+
print(
|
360
|
+
f"\033[31mThis will convert all frames for the \"{directory.name}\" anim to png and rename them.\n"
|
361
|
+
"This action is irreversible, make sure to back up your files if needed.\n\033[0m"
|
362
|
+
)
|
363
|
+
input(
|
364
|
+
"Press [Enter] if you wish to continue or [Ctrl+C] to cancel"
|
365
|
+
)
|
366
|
+
except KeyboardInterrupt:
|
367
|
+
sys.exit(0)
|
368
|
+
print()
|
369
|
+
index = 1
|
370
|
+
|
371
|
+
for file in sorted(directory.iterdir(), key=lambda x: x.name):
|
372
|
+
if file.is_file() and file.suffix in (".jpg", ".jpeg", ".png"):
|
373
|
+
filename = file.stem
|
374
|
+
if re.search(r"\d+", filename):
|
375
|
+
filename = f"frame_{index}.png"
|
376
|
+
index += 1
|
377
|
+
else:
|
378
|
+
filename = f"{filename}.png"
|
379
|
+
|
380
|
+
img = Image.open(file)
|
381
|
+
img.save(directory / filename)
|
382
|
+
file.unlink()
|
383
|
+
|
384
|
+
|
385
|
+
def convert_and_rename_frames_for_all_anims(directory_for_anims: "str | pathlib.Path", logger: typing.Callable):
|
386
|
+
"""Converts all frames to png and renames them "frame_N.png for all anims in the given anim folder.
|
387
|
+
(requires the image name to contain the frame number)"""
|
388
|
+
for anim in directory_for_anims.iterdir():
|
389
|
+
if anim.is_dir():
|
390
|
+
convert_and_rename_frames(anim, logger)
|
391
|
+
|
392
|
+
|
393
|
+
def pack_specific(asset_pack_path: "str | pathlib.Path", output_directory: "str | pathlib.Path", logger: typing.Callable):
|
394
|
+
"""Packs a specific asset pack"""
|
395
|
+
asset_pack_path = pathlib.Path(asset_pack_path)
|
396
|
+
output_directory = pathlib.Path(output_directory)
|
397
|
+
logger(f"Packing '\033[3m{asset_pack_path.name}\033[0m'")
|
398
|
+
|
399
|
+
if not asset_pack_path.is_dir():
|
400
|
+
logger(f"\033[31mError: '{asset_pack_path}' is not a directory\033[0m")
|
401
|
+
return
|
402
|
+
|
403
|
+
packed = output_directory / asset_pack_path.name
|
404
|
+
|
405
|
+
if packed.exists():
|
406
|
+
try:
|
407
|
+
if packed.is_dir():
|
408
|
+
shutil.rmtree(packed, ignore_errors=True)
|
409
|
+
else:
|
410
|
+
packed.unlink()
|
411
|
+
except (OSError, shutil.Error):
|
412
|
+
logger(f"\033[31mError: Failed to remove existing pack: '{packed}'\033[0m")
|
413
|
+
|
414
|
+
# packing anims
|
415
|
+
if (asset_pack_path / "Anims/manifest.txt").exists():
|
416
|
+
(packed / "Anims").mkdir(parents=True, exist_ok=True) # ensure that the "Anims" directory exists
|
417
|
+
copy_file_as_lf(asset_pack_path / "Anims/manifest.txt", packed / "Anims/manifest.txt")
|
418
|
+
manifest = (asset_pack_path / "Anims/manifest.txt").read_bytes()
|
419
|
+
|
420
|
+
# Find all the anims in the manifest
|
421
|
+
for anim in re.finditer(rb"Name: (.*)", manifest):
|
422
|
+
anim = (
|
423
|
+
anim.group(1)
|
424
|
+
.decode()
|
425
|
+
.replace("\\", "/")
|
426
|
+
.replace("/", os.sep)
|
427
|
+
.replace("\r", "\n")
|
428
|
+
.strip()
|
429
|
+
)
|
430
|
+
logger(f"Compiling anim '\033[3m{anim}\033[0m' for '\033[3m{asset_pack_path.name}\033[0m'")
|
431
|
+
pack_anim(asset_pack_path / "Anims" / anim, packed / "Anims" / anim)
|
432
|
+
|
433
|
+
# packing icons
|
434
|
+
if (asset_pack_path / "Icons").is_dir():
|
435
|
+
for icons in (asset_pack_path / "Icons").iterdir():
|
436
|
+
if not icons.is_dir() or icons.name.startswith("."):
|
437
|
+
continue
|
438
|
+
for icon in icons.iterdir():
|
439
|
+
if icon.name.startswith("."):
|
440
|
+
continue
|
441
|
+
if icon.is_dir():
|
442
|
+
logger(f"Compiling icon for pack '{asset_pack_path.name}': {icons.name}/{icon.name}")
|
443
|
+
pack_animated_icon(icon, packed / "Icons" / icons.name / icon.name)
|
444
|
+
elif icon.is_file() and icon.suffix in (".png", ".bmx"):
|
445
|
+
logger(f"Compiling icon for pack '{asset_pack_path.name}': {icons.name}/{icon.name}")
|
446
|
+
pack_static_icon(icon, packed / "Icons" / icons.name / icon.name)
|
447
|
+
|
448
|
+
# packing fonts
|
449
|
+
if (asset_pack_path / "Fonts").is_dir():
|
450
|
+
for font in (asset_pack_path / "Fonts").iterdir():
|
451
|
+
if (
|
452
|
+
not font.is_file()
|
453
|
+
or font.name.startswith(".")
|
454
|
+
or font.suffix not in (".c", ".u8f")
|
455
|
+
):
|
456
|
+
continue
|
457
|
+
logger(f"Compiling font for pack '{asset_pack_path.name}': {font.name}")
|
458
|
+
pack_font(font, packed / "Fonts" / font.name)
|
459
|
+
|
460
|
+
logger(f"\033[32mFinished packing '\033[3m{asset_pack_path.name}\033[23m'\033[0m")
|
461
|
+
logger(f"Saved to: '\033[33m{packed}\033[0m'")
|
462
|
+
|
463
|
+
|
464
|
+
def recover_specific(asset_pack_path: "str | pathlib.Path", output_directory: "str | pathlib.Path", logger: typing.Callable):
|
465
|
+
"""Recovers a specific asset pack"""
|
466
|
+
asset_pack_path = pathlib.Path(asset_pack_path)
|
467
|
+
output_directory = pathlib.Path(output_directory)
|
468
|
+
logger(f"Recovering '\033[3m{asset_pack_path.name}\033[0m'")
|
469
|
+
|
470
|
+
if not asset_pack_path.is_dir():
|
471
|
+
logger(f"\033[31mError: '{asset_pack_path}' is not a directory\033[0m")
|
472
|
+
return
|
473
|
+
|
474
|
+
recovered = output_directory / asset_pack_path.name
|
475
|
+
|
476
|
+
if recovered.exists():
|
477
|
+
try:
|
478
|
+
if recovered.is_dir():
|
479
|
+
shutil.rmtree(recovered, ignore_errors=True)
|
480
|
+
else:
|
481
|
+
recovered.unlink()
|
482
|
+
except (OSError, shutil.Error):
|
483
|
+
logger(f"\033[31mError: Failed to remove existing pack: '{recovered}'\033[0m")
|
484
|
+
|
485
|
+
# recovering anims
|
486
|
+
if (asset_pack_path / "Anims").is_dir():
|
487
|
+
(recovered / "Anims").mkdir(parents=True, exist_ok=True) # ensure that the "Anims" directory exists
|
488
|
+
|
489
|
+
# copy the manifest if it exists
|
490
|
+
if (asset_pack_path / "Anims/manifest.txt").exists():
|
491
|
+
shutil.copyfile(asset_pack_path / "Anims/manifest.txt", recovered / "Anims/manifest.txt")
|
492
|
+
|
493
|
+
# recover all the anims in the Anims directory
|
494
|
+
for anim in (asset_pack_path / "Anims").iterdir():
|
495
|
+
if not anim.is_dir() or anim.name.startswith("."):
|
496
|
+
continue
|
497
|
+
logger(f"Recovering anim '\033[3m{anim}\033[0m' for '\033[3m{asset_pack_path.name}\033[0m'")
|
498
|
+
recover_anim(anim, recovered / "Anims" / anim.name)
|
499
|
+
|
500
|
+
# recovering icons
|
501
|
+
if (asset_pack_path / "Icons").is_dir():
|
502
|
+
for icons in (asset_pack_path / "Icons").iterdir():
|
503
|
+
if not icons.is_dir() or icons.name.startswith("."):
|
504
|
+
continue
|
505
|
+
for icon in icons.iterdir():
|
506
|
+
if icon.name.startswith("."):
|
507
|
+
continue
|
508
|
+
if icon.is_dir():
|
509
|
+
logger(f"Recovering icon for pack '{asset_pack_path.name}': {icons.name}/{icon.name}")
|
510
|
+
recover_animated_icon(icon, recovered / "Icons" / icons.name / icon.name)
|
511
|
+
elif icon.is_file() and icon.suffix == ".bmx":
|
512
|
+
logger(f"Recovering icon for pack '{asset_pack_path.name}': {icons.name}/{icon.name}")
|
513
|
+
recover_static_icon(icon, recovered / "Icons" / icons.name / icon.name)
|
514
|
+
|
515
|
+
# recovering fonts
|
516
|
+
if (asset_pack_path / "Fonts").is_dir():
|
517
|
+
# for font in (asset_pack_path / "Fonts").iterdir():
|
518
|
+
# if (
|
519
|
+
# not font.is_file()
|
520
|
+
# or font.name.startswith(".")
|
521
|
+
# or font.suffix not in (".c", ".u8f")
|
522
|
+
# ):
|
523
|
+
# continue
|
524
|
+
# logger(f"Compiling font for pack '{asset_pack_path.name}': {font.name}")
|
525
|
+
# pack_font(font, recovered / "Fonts" / font.name)
|
526
|
+
logger("Fonts recovery not implemented yet") #TODO: implement
|
527
|
+
|
528
|
+
logger(f"\033[32mFinished recovering '\033[3m{asset_pack_path.name}\033[23m'\033[0m")
|
529
|
+
logger(f"Saved to: '\033[33m{recovered}\033[0m'")
|
530
|
+
|
531
|
+
|
532
|
+
def pack_all_asset_packs(source_directory: "str | pathlib.Path", output_directory: "str | pathlib.Path", logger: typing.Callable):
|
533
|
+
"""Packs all asset packs in the source directory"""
|
534
|
+
try:
|
535
|
+
print(
|
536
|
+
"This will pack all asset packs in the current directory.\n"
|
537
|
+
"The resulting asset packs will be saved to './asset_packs'\n"
|
538
|
+
)
|
539
|
+
input(
|
540
|
+
"Press [Enter] if you wish to continue or [Ctrl+C] to cancel"
|
541
|
+
)
|
542
|
+
except KeyboardInterrupt:
|
543
|
+
sys.exit(0)
|
544
|
+
print()
|
545
|
+
|
546
|
+
source_directory = pathlib.Path(source_directory)
|
547
|
+
output_directory = pathlib.Path(output_directory)
|
548
|
+
|
549
|
+
for source in source_directory.iterdir():
|
550
|
+
# Skip folders that are definitely not meant to be packed
|
551
|
+
if source == output_directory:
|
552
|
+
continue
|
553
|
+
if not source.is_dir() or source.name.startswith(".") or source.name in ("venv", "recovered") :
|
554
|
+
continue
|
555
|
+
|
556
|
+
pack_specific(source, output_directory, logger)
|
557
|
+
|
558
|
+
|
559
|
+
def recover_all_asset_packs(source_directory: "str | pathlib.Path", output_directory: "str | pathlib.Path", logger: typing.Callable):
|
560
|
+
"""Recovers all asset packs in the source directory"""
|
561
|
+
try:
|
562
|
+
print(
|
563
|
+
"This will recover all asset packs in the current directory.\n"
|
564
|
+
"The resulting asset packs will be saved to './recovered'\n"
|
565
|
+
)
|
566
|
+
input(
|
567
|
+
"Press [Enter] if you wish to continue or [Ctrl+C] to cancel"
|
568
|
+
)
|
569
|
+
except KeyboardInterrupt:
|
570
|
+
sys.exit(0)
|
571
|
+
print()
|
572
|
+
|
573
|
+
source_directory = pathlib.Path(source_directory)
|
574
|
+
output_directory = pathlib.Path(output_directory)
|
575
|
+
|
576
|
+
for source in source_directory.iterdir():
|
577
|
+
# Skip folders that are definitely not meant to be recovered
|
578
|
+
if source == output_directory:
|
579
|
+
continue
|
580
|
+
if not source.is_dir() or source.name.startswith(".") or source.name in ("venv", "recovered") :
|
581
|
+
continue
|
582
|
+
|
583
|
+
recover_specific(source, output_directory, logger)
|
584
|
+
|
585
|
+
|
586
|
+
def create_asset_pack(asset_pack_name: str, output_directory: "str | pathlib.Path", logger: typing.Callable):
|
587
|
+
"""Creates the file structure for an asset pack"""
|
588
|
+
|
589
|
+
if not isinstance(output_directory, pathlib.Path):
|
590
|
+
output_directory = pathlib.Path(output_directory)
|
591
|
+
|
592
|
+
# check for illegal characters
|
593
|
+
if not re.match(r"^[a-zA-Z0-9_\- ]+$", asset_pack_name):
|
594
|
+
logger(f"\033[31mError: '{asset_pack_name}' contains illegal characters\033[0m")
|
595
|
+
return
|
596
|
+
|
597
|
+
if (output_directory / asset_pack_name).exists():
|
598
|
+
logger(f"\033[31mError: {output_directory / asset_pack_name} already exists\033[0m")
|
599
|
+
return
|
600
|
+
|
601
|
+
generate_example_files = input("Create example for anim structure? (y/N) : ").lower() == "y"
|
602
|
+
|
603
|
+
(output_directory / asset_pack_name / "Anims").mkdir(parents=True)
|
604
|
+
(output_directory / asset_pack_name / "Icons").mkdir(parents=True)
|
605
|
+
(output_directory / asset_pack_name / "Fonts").mkdir(parents=True)
|
606
|
+
(output_directory / asset_pack_name / "Passport").mkdir(parents=True)
|
607
|
+
# creating "manifest.txt" file
|
608
|
+
if generate_example_files:
|
609
|
+
(output_directory / asset_pack_name / "Anims" / "manifest.txt").touch()
|
610
|
+
with open(output_directory / asset_pack_name / "Anims" / "manifest.txt", "w", encoding="utf-8") as f:
|
611
|
+
f.write(EXAMPLE_MANIFEST)
|
612
|
+
(output_directory / asset_pack_name / "Anims" / "example_anim").mkdir(parents=True)
|
613
|
+
(output_directory / asset_pack_name / "Anims" / "example_anim" / "meta.txt").touch()
|
614
|
+
with open(output_directory / asset_pack_name / "Anims" / "example_anim" / "meta.txt", "w", encoding="utf-8") as f:
|
615
|
+
f.write(EXAMPLE_META)
|
616
|
+
|
617
|
+
logger(f"Created asset pack '{asset_pack_name}' in '{output_directory}'")
|
618
|
+
|
619
|
+
|
620
|
+
def main():
|
621
|
+
"""Main function"""
|
622
|
+
if len(sys.argv) > 1:
|
623
|
+
match sys.argv[1]:
|
624
|
+
case "help" | "-h" | "--help":
|
625
|
+
print(HELP_MESSAGE)
|
626
|
+
|
627
|
+
case "create":
|
628
|
+
if len(sys.argv) >= 3:
|
629
|
+
NAME = " ".join(sys.argv[2:])
|
630
|
+
create_asset_pack(NAME, pathlib.Path.cwd(), logger=print)
|
631
|
+
|
632
|
+
else:
|
633
|
+
print(HELP_MESSAGE)
|
634
|
+
|
635
|
+
case "pack":
|
636
|
+
if len(sys.argv) == 3:
|
637
|
+
here = pathlib.Path(__file__).absolute().parent
|
638
|
+
start = time.perf_counter()
|
639
|
+
|
640
|
+
if sys.argv[2] == "all":
|
641
|
+
pack_all_asset_packs(here, here / "asset_packs", logger=print)
|
642
|
+
else:
|
643
|
+
pack_specific(sys.argv[2], pathlib.Path.cwd() / "asset_packs", logger=print)
|
644
|
+
|
645
|
+
end = time.perf_counter()
|
646
|
+
print(f"\nFinished in {round(end - start, 2)}s\n")
|
647
|
+
else:
|
648
|
+
print(HELP_MESSAGE)
|
649
|
+
|
650
|
+
case "recover":
|
651
|
+
if len(sys.argv) == 3:
|
652
|
+
here = pathlib.Path(__file__).absolute().parent
|
653
|
+
start = time.perf_counter()
|
654
|
+
|
655
|
+
if sys.argv[2] == "all":
|
656
|
+
recover_all_asset_packs(here / "asset_packs", here / "recovered", logger=print)
|
657
|
+
else:
|
658
|
+
recover_specific(sys.argv[2], pathlib.Path.cwd() / "recovered", logger=print)
|
659
|
+
|
660
|
+
# recover_anim(pathlib.Path(sys.argv[2]), pathlib.Path.cwd() / "recovered")
|
661
|
+
end = time.perf_counter()
|
662
|
+
print(f"Finished in {round(end - start, 2)}s")
|
663
|
+
|
664
|
+
case "convert":
|
665
|
+
if len(sys.argv) == 3:
|
666
|
+
convert_and_rename_frames_for_all_anims(pathlib.Path(sys.argv[2]) / "Anims", logger=print)
|
667
|
+
else:
|
668
|
+
print(HELP_MESSAGE)
|
669
|
+
|
670
|
+
case _:
|
671
|
+
print(HELP_MESSAGE)
|
672
|
+
else:
|
673
|
+
here = pathlib.Path(__file__).absolute().parent
|
674
|
+
start = time.perf_counter()
|
675
|
+
pack_all_asset_packs(here, here / "asset_packs", logger=print)
|
676
|
+
end = time.perf_counter()
|
677
|
+
print(f"\nFinished in {round(end - start, 2)}s\n")
|
678
|
+
|
679
|
+
|
680
|
+
if __name__ == "__main__":
|
681
|
+
main()
|