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.
- mntm_asset_packer-1.1.0/.gitattributes +2 -0
- mntm_asset_packer-1.1.0/.github/workflows/python-publish.yml +144 -0
- mntm_asset_packer-1.1.0/.gitignore +16 -0
- mntm_asset_packer-1.1.0/.python-version +1 -0
- mntm_asset_packer-1.1.0/Asset Pack File Format.md +158 -0
- mntm_asset_packer-1.1.0/PKG-INFO +91 -0
- mntm_asset_packer-1.1.0/README.md +81 -0
- mntm_asset_packer-1.1.0/mntm_asset_packer.py +681 -0
- mntm_asset_packer-1.1.0/pyproject.toml +23 -0
- mntm_asset_packer-1.1.0/requirements.txt +2 -0
- mntm_asset_packer-1.1.0/uv.lock +89 -0
@@ -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 @@
|
|
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
|
+

|
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,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
|
+
]
|