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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mntm-asset-packer = mntm_asset_packer:main
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()