mntm-asset-packer 1.1.0__tar.gz

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,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,144 @@
1
+ # upload the python package to PyPI when a release is created
2
+ name: release
3
+
4
+ on:
5
+ push:
6
+ tags:
7
+ - "[0-9]+.[0-9]+.[0-9]+"
8
+ - "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
9
+ - "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
10
+ - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
11
+
12
+ env:
13
+ PACKAGE_NAME: "mntm-asset-packer"
14
+ OWNER: "notnotnescap"
15
+
16
+ jobs:
17
+ details:
18
+ runs-on: ubuntu-latest
19
+ outputs:
20
+ new_version: ${{ steps.release.outputs.new_version }}
21
+ suffix: ${{ steps.release.outputs.suffix }}
22
+ tag_name: ${{ steps.release.outputs.tag_name }}
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Extract tag and Details
27
+ id: release
28
+ run: |
29
+ if [ "${{ github.ref_type }}" = "tag" ]; then
30
+ TAG_NAME=${GITHUB_REF#refs/tags/}
31
+ NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}')
32
+ SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "")
33
+ echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
34
+ echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT"
35
+ echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
36
+ echo "Version is $NEW_VERSION"
37
+ echo "Suffix is $SUFFIX"
38
+ echo "Tag name is $TAG_NAME"
39
+ else
40
+ echo "No tag found"
41
+ exit 1
42
+ fi
43
+
44
+ check_pypi:
45
+ needs: details
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - name: Fetch information from PyPI
49
+ run: |
50
+ response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}")
51
+ latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last")
52
+ if [ -z "$latest_previous_version" ]; then
53
+ echo "Package not found on PyPI."
54
+ latest_previous_version="0.0.0"
55
+ fi
56
+ echo "Latest version on PyPI: $latest_previous_version"
57
+ echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV
58
+
59
+ - name: Compare versions and exit if not newer
60
+ run: |
61
+ NEW_VERSION=${{ needs.details.outputs.new_version }}
62
+ LATEST_VERSION=$latest_previous_version
63
+ if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then
64
+ echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI."
65
+ exit 1
66
+ else
67
+ echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI."
68
+ fi
69
+
70
+ setup_and_build:
71
+ needs: [details, check_pypi]
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+
76
+ - name: Set up Python
77
+ uses: actions/setup-python@v5
78
+ with:
79
+ python-version: "3.11"
80
+
81
+ - name: Install uv
82
+ run: |
83
+ curl -LsSf https://astral.sh/uv/install.sh | sh
84
+ source "$HOME/.cargo/env"
85
+
86
+ - name: Set project version
87
+ run: |
88
+ uv version ${{ needs.details.outputs.new_version }}
89
+
90
+ - name: Install dependencies
91
+ run: uv sync
92
+
93
+ - name: Build source and wheel distribution
94
+ run: |
95
+ uv build
96
+
97
+ - name: Upload artifacts
98
+ uses: actions/upload-artifact@v4
99
+ with:
100
+ name: dist
101
+ path: dist/
102
+
103
+ pypi_publish:
104
+ name: Upload release to PyPI
105
+ needs: [setup_and_build, details]
106
+ runs-on: ubuntu-latest
107
+ environment:
108
+ name: release
109
+ permissions:
110
+ id-token: write
111
+ steps:
112
+ - name: Download artifacts
113
+ uses: actions/download-artifact@v4
114
+ with:
115
+ name: dist
116
+ path: dist/
117
+
118
+ - name: Publish distribution to PyPI
119
+ uses: pypa/gh-action-pypi-publish@release/v1
120
+
121
+ github_release:
122
+ name: Create GitHub Release
123
+ needs: [setup_and_build, details]
124
+ runs-on: ubuntu-latest
125
+ permissions:
126
+ contents: write
127
+ steps:
128
+ - name: Checkout Code
129
+ uses: actions/checkout@v4
130
+ with:
131
+ fetch-depth: 0
132
+
133
+ - name: Download artifacts
134
+ uses: actions/download-artifact@v4
135
+ with:
136
+ name: dist
137
+ path: dist/
138
+
139
+ - name: Create GitHub Release
140
+ id: create_release
141
+ env:
142
+ GH_TOKEN: ${{ github.token }}
143
+ run: |
144
+ gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes
@@ -0,0 +1,16 @@
1
+ # General
2
+ .DS_Store
3
+ .idea
4
+ .vscode
5
+
6
+ # test files
7
+ test
8
+ asset_packs
9
+ recovered
10
+
11
+ # Python
12
+ __pycache__/
13
+ *.pyc
14
+ venv
15
+ .venv
16
+ dist
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,158 @@
1
+ <!-- This was taken from the Momentum Firmware Documentation https://github.com/Next-Flip/Momentum-Firmware/blob/dev/documentation/file_formats/AssetPacks.md and slightly modified-->
2
+ ## Intro
3
+
4
+ Asset Packs are a feature exclusive to Momentum Firmware (and its predecessor Xtreme Firmware) that allows you to load custom Animation and Icon sets without recompiling the firmware or messing with manifest.txt files (as a user). Here you can find info on how to install Asset Packs and also how to make your own.
5
+
6
+ <br>
7
+
8
+ ## How to install Asset Packs?
9
+
10
+ Installing Asset Packs is quite easy and straightforward. First, find some packs to install (https://momentum-fw.dev/asset-packs/ or #asset-packs in discord). Once you have some packs to install:
11
+
12
+ - Open qFlipper and navigate to `SD Card` and into `asset_packs`; if you do not see this folder, try reinstalling the firmware, or create it yourself.
13
+
14
+ - Here (`SD/asset_packs`) is where all Asset Packs are stored. Simply unzip your packs and upload the folders here.
15
+
16
+ If you did this correctly, you should see `SD/asset_packs/PackName/Anims` and/or `SD/asset_packs/PackName/Icons`.
17
+
18
+ - Now simply open the Momentum Settings app (from the home screen press `Arrow UP` and then `Momentum Settings`) and select the asset pack you want. When you back out, Flipper will restart and your animations and icons will use the ones from the selected pack!
19
+
20
+ <br>
21
+
22
+ <br>
23
+
24
+ ## How do I make an Asset Pack?
25
+
26
+ Before we begin, it's better to understand a little on how they work. Asset Packs are made of 2 parts: Anims and Icons.
27
+
28
+ <br>
29
+
30
+ ### Animations
31
+
32
+ Animations use the standard and already well documented animation format, so this will be just a quick recap with the key differences mentioned.
33
+
34
+ The basic animation structure is:
35
+ ```
36
+ SD/
37
+ |-asset_packs/
38
+ |-PackName/
39
+ |-Icons/
40
+ |...
41
+ |-Anims/
42
+ |-ExampleAnim/
43
+ |-frame_0.bm
44
+ |-frame_1.bm
45
+ |...
46
+ |-meta.txt
47
+ |-AlsoExample/
48
+ |-frame_0.bm
49
+ |-frame_1.bm
50
+ |...
51
+ |-meta.txt
52
+ |...
53
+ |-manifest.txt
54
+ ```
55
+ `ExampleAnim` and `AlsoExample` are the individual animations, they contain the animation frames compiled as `frame_x.bm` (this is a special format Flipper uses, it can't understand `.png` but only raw pixel data which is what `.bm` is). Each animation has its own `meta.txt`, which contains information such as image width and height, frame rate, duration and so on. Next to all the animations you have `manifest.txt` which tells Flipper when and how to show each animation with values like level and butthurt (mood) constraints and weight (random chance weight).
56
+
57
+ Again, this is all fairly standard Flipper animation stuff, there are plenty of tutorials on YouTube. The key differences with the Asset Pack animation system are:
58
+
59
+ - They go in `SD/asset_packs/PackName/Anims` instead of `SD/dolphin`.
60
+ - Momentum has up to level 30, so make sure to update your manifest.txt accordingly!
61
+
62
+ <br>
63
+
64
+ ### Icons
65
+
66
+ With icons there are quite a few differences and issues we had to solve. In particular, they are usually compiled along with the firmware, so loading them dynamically required a special system. Also, for the same reason, some metadata for the icons now has to be stored along with them, since it's not in the firmware itself. And finally, icons can both be static and animated, both with different solutions to the above problems.
67
+
68
+ #### Static icons
69
+
70
+ The `.bm` format does not include image width and height, with animations that is stored in `meta.txt`, so for static icons we made a special format: `.bmx`, which is `[ int32 width ] + [ int32 height ] + [ standard .bm pixel data ]`, but this is handled by the packer (see below) so don't worry about it.
71
+
72
+ #### Animated icons
73
+
74
+ Animated icons are structured similarly to animations, but are used like icons. They live next to other static icons, but are stored as `.bm` sequences. To avoid storing redundant data with `.bmx`, we kept the frames as `.bm` and instead opted for a `meta` file (no extension), which consists of `[ int32 width ] + [ int32 height ] + [ int32 frame_rate ] + [ int32 frame_count ]`, but once again don't fret as this is handled by the packer (see below).
75
+
76
+ #### Structure
77
+
78
+ Other than those few differences above, we kept the same icon naming scheme and structure, so this should look familiar otherwise.
79
+
80
+ The basic icon structure is:
81
+ ```
82
+ SD/
83
+ |-asset_packs/
84
+ |-PackName/
85
+ |-Anims/
86
+ |...
87
+ |-Icons/
88
+ |-Animations/
89
+ |-Levelup_128x64/
90
+ |-frame_0.bm
91
+ |-frame_1.bm
92
+ |...
93
+ |-meta
94
+ |...
95
+ |-Passport/
96
+ |-passport_happy_46x49.bmx
97
+ |-passport_128x64.bmx
98
+ |...
99
+ |-RFID/
100
+ |-RFIDDolphinReceive_97x61.bmx
101
+ |-RFIDDolphinSend_97x61.bmx
102
+ |...
103
+ |...
104
+ ```
105
+ Which is the same you can find in the firmware source code, in `assets/icons`. The key differences/things to remember with the Asset Pack icon system are:
106
+
107
+ - Not all icons are supported (see below).
108
+ - The pixel numbers in the filename are ignored, they are there purely because of the original Flipper icon names and for a hint as to how you should size your icons, but they are not enforced.
109
+ - We kept the original naming scheme and file structure for compatibility, but the original setup is quite bad, so bear with us. Some icons in subfolders (like `SubGhz/Scanning_123x52`) are used in other unrelated apps/places.
110
+ - Some icons in the official firmware have different versions with different numbers to indicate the flipper level they target. Since our system has so many levels, we decided to keep it simple and remove the level progression from icons. For example `Passport/passport_happy1_46x49` becomes `Passport/passport_happy_46x49` and `Animations/Levelup1_128x64` becomes `Animations/Levelup_128x64`.
111
+
112
+ This system supports **all** internal assets!
113
+
114
+ <br>
115
+
116
+ <br>
117
+
118
+ ### Cool, I read all that, but how do I make one???
119
+
120
+ All the .bm and .bmx struggles are dealt with by the packer system, which is in `scripts/asset_packer.py`; when making your Asset Pack you will only be working with .png images and meta.txt/manifest.txt/frame_rate files. As explained above, packs are made of 2 parts, Anims and Icons, but you don't have to include both - any icon/animation that is not included in the asset pack will use the default icon/animation. You have 2 options: make standalone Asset Packs (recommended), or build them along with the firmware.
121
+
122
+ #### Standalone Asset Packs
123
+
124
+ - (First time only) Install Python (3.10 recommended, but not required), then open a terminal/console and run `pip3 install Pillow heatshrink2`.
125
+
126
+ - Make a NEW folder anywhere on your system where you'll put all your source asset packs. If you're not sure, the Desktop is always a good place, so make the NEW folder there.
127
+
128
+ - Inside the NEW folder make ANOTHER folder with the name of your Asset Pack (less than 32 characters). Inside THAT one, make the Anims and/or Icons folders.
129
+
130
+ If you used the desktop, you should have `Desktop/AssetPacks/PackName/Anims` and/or `Desktop/AssetPacks/PackName/Icons`.
131
+
132
+ - Fill those folders appropriately, referring to the information and structure above, BUT:
133
+ - Images should ALL be `.png`.
134
+ - For animations you add `manifest.txt` and `meta.txt` files.
135
+ - For animated icons you add `frame_rate` files.
136
+ - Static icons don't need extra configuration.
137
+ - NOTE THAT THIS IS ALL JUST LIKE IN OFFICIAL FIRMWARE, YOU'RE JUST DOING IT IN ANOTHER FOLDER.
138
+
139
+ Here is an example of what it should look like:
140
+
141
+ ![image](https://user-images.githubusercontent.com/49810075/218661220-cdc750bf-1eee-488e-a194-47371529112c.png)
142
+
143
+ - Copy the `scripts/asset_packer.py` file into your source packs folder, right next to your asset packs.
144
+
145
+ - Run the asset_packer.py script. You might be able to double click it, if not run in console with `python asset_packer.py`.
146
+
147
+ - It will explain and ask for confirmation, so press Enter.
148
+
149
+ - When it's done (it's usually quite quick) you will have a `asset_packs` folder right next to your source packs. Inside it you will see your Asset Pack, but in compiled form (.png images swapped for .bm and .bmx).
150
+
151
+ - Now upload the packed packs from that folder onto your flipper in `SD/asset_packs`.
152
+
153
+ - Done! Just select it from the Momentum Settings app now. And if you're generous share your (packed) asset pack in #asset-packs on discord.
154
+
155
+ #### Building with Firmware
156
+
157
+ - Follow the steps above, but use `assets/dolphin/custom` as your source packs folder.
158
+ - Packing is integrated with fbt, so just run `./fbt flash_usb_full` or `./fbt updater_package` to compile the firmware, pack the packs and update your Flipper.
@@ -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,81 @@
1
+ # mntm-asset-packer
2
+
3
+ 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.
4
+
5
+ # Features
6
+
7
+ This improved packer adds several features over the original:
8
+
9
+ - **Pack specific asset packs**: No need to pack everything at once.
10
+ - **Create command**: Quickly scaffold the necessary file structure for a new asset pack.
11
+ - **Automatic file conversion**: Automatically convert and rename image frames for animations.
12
+ - **Asset pack recovery**: Recover PNGs and metadata from compiled asset packs. (Note: Font recovery is not yet implemented).
13
+ - **Backwards compatibility**: Works the same way as the original packer by default, so you can use it without changing your workflow.
14
+
15
+ # Setup
16
+
17
+ ## Using [uv](https://docs.astral.sh/uv/) (recommended)
18
+
19
+ If you don't have `uv` installed, follow [these](https://docs.astral.sh/uv/getting-started/installation/) instructions.
20
+
21
+ You can quickly run the script with this command:
22
+ ```sh
23
+ uvx mntm-asset-packer help
24
+ ```
25
+
26
+ To install, use this command:
27
+ ```sh
28
+ uv tool install mntm-asset-packer
29
+ mntm-asset-packer help
30
+ ```
31
+
32
+ or using pip:
33
+ ```sh
34
+ pip install mntm-asset-packer
35
+ mntm-asset-packer help
36
+ ```
37
+
38
+ ## Using venv
39
+
40
+ 1. Clone this repository and navigate into its directory.
41
+ 2. Create and activate a virtual environment:
42
+ ```sh
43
+ python3 -m venv venv
44
+ source venv/bin/activate
45
+ ```
46
+ 3. Install the required dependencies from [`requirements.txt`](requirements.txt):
47
+ ```sh
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+
52
+ # Usage
53
+
54
+ If you run the script directly, replace `mntm-asset-packer` with `python3 mntm_asset_packer.py` in the commands below.
55
+
56
+ `mntm-asset-packer help`
57
+ : Displays a detailed help message with all available commands.
58
+
59
+ `mntm-asset-packer create <Asset Pack Name>`
60
+ : Creates a directory with the correct file structure to start a new asset pack.
61
+
62
+ `mntm-asset-packer pack <./path/to/AssetPack>`
63
+ : Packs a single, specified asset pack into the `./asset_packs/` directory.
64
+
65
+ `mntm-asset-packer pack all`
66
+ : Packs all valid asset pack folders found in the current directory into `./asset_packs/`. This is the default action if no command is provided.
67
+
68
+ `mntm-asset-packer recover <./asset_packs/AssetPack>`
69
+ : Recovers a compiled asset pack back to its source form (e.g., `.bmx` to `.png`). The recovered pack is saved in `./recovered/<AssetPackName>`.
70
+
71
+ `mntm-asset-packer recover all`
72
+ : Recovers all asset packs from the `./asset_packs/` directory into the `./recovered/` directory.
73
+
74
+ `mntm-asset-packer convert <./path/to/AssetPack>`
75
+ : Converts and renames all animation frames in an asset pack to the standard `frame_N.png` format.
76
+
77
+ # More Information
78
+
79
+ - **General Asset Info**: [https://github.com/Kuronons/FZ_graphics](https://github.com/Kuronons/FZ_graphics)
80
+ - **Animation `meta.txt` Guide**: [https://flipper.wiki/tutorials/Animation_guide_meta/Meta_settings_guide/](https://flipper.wiki/tutorials/Animation_guide_meta/Meta_settings_guide/)
81
+ - **Custom Fonts Guide**: [https://flipper.wiki/tutorials/f0_fonts_guide/guide/](https://flipper.wiki/tutorials/f0_fonts_guide/guide/)
@@ -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()
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "mntm-asset-packer"
3
+ version = "1.1.0"
4
+ description = "An improved asset packer script to make the process of creating and packing asset packs for the Momentum firmware easier."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "notnotnescap", email = "97590612+nescapp@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "heatshrink2>=0.13.0",
12
+ "pillow>=11.2.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ mntm-asset-packer = "mntm_asset_packer:main"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ force-include = { "mntm_asset_packer.py" = "mntm_asset_packer.py" }
@@ -0,0 +1,2 @@
1
+ Pillow
2
+ heatshrink2
@@ -0,0 +1,89 @@
1
+ version = 1
2
+ revision = 2
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "heatshrink2"
7
+ version = "0.13.0"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/22/53/5a46650f76811bfc174df261553f621ccc921ef84628cabf0261810ce140/heatshrink2-0.13.0.tar.gz", hash = "sha256:5aa93c102ba9c4e6e4fb01974cb5b03194e10fa01bfda8bda233c632b22d3b4a", size = 142262, upload-time = "2024-02-07T18:07:30.081Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/4f/cd/37429614a7dd0b6aba30d18264b5892b726d40151388c7b7f6778266a68b/heatshrink2-0.13.0-cp311-cp311-win32.whl", hash = "sha256:6820afcef084cbaedfbaa7f57739c01a70e35bc862e0bac26281457f0afa79e5", size = 56460, upload-time = "2024-02-07T18:23:44.823Z" },
12
+ { url = "https://files.pythonhosted.org/packages/30/60/675088369238a54e1217a9007066727d032921e51292816ea5d197b6c018/heatshrink2-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:55dcc79c2da58507a8d90eb55032aa4a2b6c14a2ad28b700a3f76d461a9583ed", size = 63265, upload-time = "2024-02-07T18:24:44.252Z" },
13
+ { url = "https://files.pythonhosted.org/packages/98/35/4cea898af2b8e26cd1eba99894a8eba325461f84cdf3fe72708913d0e20e/heatshrink2-0.13.0-cp312-cp312-win32.whl", hash = "sha256:2d30f1b8e5173f3877db178668d770fbf11226b4bd1410464f7784590208c914", size = 55699, upload-time = "2024-02-07T18:25:54.969Z" },
14
+ { url = "https://files.pythonhosted.org/packages/1a/27/a04c0ada1426fc9c6ff2714cda9319e1aaeb43b6563e402b8de54d71a5b0/heatshrink2-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:154da5a23deef5bc36e4b67ea6efb28e716a5dd89254e64dd4a3693a5e6a0e68", size = 61931, upload-time = "2024-02-07T18:27:05.648Z" },
15
+ ]
16
+
17
+ [[package]]
18
+ name = "mntm-asset-packer"
19
+ version = "1.1.0"
20
+ source = { editable = "." }
21
+ dependencies = [
22
+ { name = "heatshrink2" },
23
+ { name = "pillow" },
24
+ ]
25
+
26
+ [package.metadata]
27
+ requires-dist = [
28
+ { name = "heatshrink2", specifier = ">=0.13.0" },
29
+ { name = "pillow", specifier = ">=11.2.1" },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pillow"
34
+ version = "11.2.1"
35
+ source = { registry = "https://pypi.org/simple" }
36
+ sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload-time = "2025-04-12T17:47:37.135Z" },
39
+ { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload-time = "2025-04-12T17:47:39.345Z" },
40
+ { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload-time = "2025-04-12T17:47:41.128Z" },
41
+ { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload-time = "2025-04-12T17:47:42.912Z" },
42
+ { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload-time = "2025-04-12T17:47:44.611Z" },
43
+ { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload-time = "2025-04-12T17:47:46.46Z" },
44
+ { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload-time = "2025-04-12T17:47:49.255Z" },
45
+ { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload-time = "2025-04-12T17:47:51.067Z" },
46
+ { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload-time = "2025-04-12T17:47:54.425Z" },
47
+ { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload-time = "2025-04-12T17:47:56.535Z" },
48
+ { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload-time = "2025-04-12T17:47:58.217Z" },
49
+ { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload-time = "2025-04-12T17:48:00.417Z" },
50
+ { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload-time = "2025-04-12T17:48:02.391Z" },
51
+ { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload-time = "2025-04-12T17:48:04.554Z" },
52
+ { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload-time = "2025-04-12T17:48:06.831Z" },
53
+ { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload-time = "2025-04-12T17:48:09.229Z" },
54
+ { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload-time = "2025-04-12T17:48:11.631Z" },
55
+ { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload-time = "2025-04-12T17:48:13.592Z" },
56
+ { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload-time = "2025-04-12T17:48:15.938Z" },
57
+ { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" },
58
+ { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" },
59
+ { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" },
60
+ { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" },
61
+ { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload-time = "2025-04-12T17:48:25.738Z" },
62
+ { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload-time = "2025-04-12T17:48:27.908Z" },
63
+ { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload-time = "2025-04-12T17:48:29.888Z" },
64
+ { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload-time = "2025-04-12T17:48:31.874Z" },
65
+ { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload-time = "2025-04-12T17:48:34.422Z" },
66
+ { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload-time = "2025-04-12T17:48:37.641Z" },
67
+ { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload-time = "2025-04-12T17:48:39.652Z" },
68
+ { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload-time = "2025-04-12T17:48:41.765Z" },
69
+ { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload-time = "2025-04-12T17:48:43.625Z" },
70
+ { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload-time = "2025-04-12T17:48:45.475Z" },
71
+ { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload-time = "2025-04-12T17:48:47.866Z" },
72
+ { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload-time = "2025-04-12T17:48:50.189Z" },
73
+ { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload-time = "2025-04-12T17:48:52.346Z" },
74
+ { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload-time = "2025-04-12T17:48:54.403Z" },
75
+ { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload-time = "2025-04-12T17:48:56.383Z" },
76
+ { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload-time = "2025-04-12T17:48:58.782Z" },
77
+ { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload-time = "2025-04-12T17:49:00.709Z" },
78
+ { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload-time = "2025-04-12T17:49:02.946Z" },
79
+ { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload-time = "2025-04-12T17:49:04.889Z" },
80
+ { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload-time = "2025-04-12T17:49:06.635Z" },
81
+ { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" },
82
+ { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload-time = "2025-04-12T17:49:46.789Z" },
83
+ { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload-time = "2025-04-12T17:49:48.812Z" },
84
+ { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload-time = "2025-04-12T17:49:50.831Z" },
85
+ { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload-time = "2025-04-12T17:49:53.278Z" },
86
+ { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload-time = "2025-04-12T17:49:55.164Z" },
87
+ { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload-time = "2025-04-12T17:49:57.171Z" },
88
+ { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" },
89
+ ]