image-to-webp 0.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.
- image_to_webp-0.1.0/.gitignore +23 -0
- image_to_webp-0.1.0/CHANGELOG.md +8 -0
- image_to_webp-0.1.0/LICENSE +21 -0
- image_to_webp-0.1.0/PKG-INFO +171 -0
- image_to_webp-0.1.0/README.md +123 -0
- image_to_webp-0.1.0/pyproject.toml +60 -0
- image_to_webp-0.1.0/src/image_to_webp/__init__.py +38 -0
- image_to_webp-0.1.0/src/image_to_webp/__main__.py +6 -0
- image_to_webp-0.1.0/src/image_to_webp/batch.py +194 -0
- image_to_webp-0.1.0/src/image_to_webp/cli.py +151 -0
- image_to_webp-0.1.0/src/image_to_webp/converter.py +154 -0
- image_to_webp-0.1.0/src/image_to_webp/errors.py +13 -0
- image_to_webp-0.1.0/src/image_to_webp/loaders.py +186 -0
- image_to_webp-0.1.0/src/image_to_webp/options.py +51 -0
- image_to_webp-0.1.0/src/image_to_webp/py.typed +0 -0
- image_to_webp-0.1.0/tests/__init__.py +0 -0
- image_to_webp-0.1.0/tests/conftest.py +47 -0
- image_to_webp-0.1.0/tests/test_api.py +138 -0
- image_to_webp-0.1.0/tests/test_batch.py +66 -0
- image_to_webp-0.1.0/tests/test_cli.py +44 -0
- image_to_webp-0.1.0/tests/test_formats.py +73 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Virtual environment
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
env/
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
|
|
16
|
+
# Partial conversion output
|
|
17
|
+
*.webp.part
|
|
18
|
+
|
|
19
|
+
# OS / editors
|
|
20
|
+
.DS_Store
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — unreleased
|
|
4
|
+
|
|
5
|
+
- First release as a Python library (`import image_to_webp`).
|
|
6
|
+
- Python API: `convert_file`, `convert_bytes`, `convert_image`, `convert_many`.
|
|
7
|
+
- `image-to-webp` command (also `python -m image_to_webp`).
|
|
8
|
+
- Optional installs for more formats: `[heif]`, `[raw]`, `[svg]`, `[all]`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stakmi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: image-to-webp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert images in almost any format (PNG, JPEG, GIF, TIFF, HEIC, AVIF, RAW, SVG, ...) to WebP.
|
|
5
|
+
Project-URL: Homepage, https://github.com/stakmi/image-to-webp
|
|
6
|
+
Project-URL: Repository, https://github.com/stakmi/image-to-webp
|
|
7
|
+
Project-URL: Issues, https://github.com/stakmi/image-to-webp/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/stakmi/image-to-webp/blob/main/CHANGELOG.md
|
|
9
|
+
Author: stakmi
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: convert,converter,heic,image,pillow,raw,svg,webp
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: pillow>=10.0
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: cairosvg>=2.7; extra == 'all'
|
|
30
|
+
Requires-Dist: pillow-heif>=0.16; extra == 'all'
|
|
31
|
+
Requires-Dist: rawpy>=0.19; extra == 'all'
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: build; extra == 'dev'
|
|
34
|
+
Requires-Dist: cairosvg>=2.7; extra == 'dev'
|
|
35
|
+
Requires-Dist: pillow-heif>=0.16; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
37
|
+
Requires-Dist: rawpy>=0.19; extra == 'dev'
|
|
38
|
+
Requires-Dist: twine; extra == 'dev'
|
|
39
|
+
Provides-Extra: heif
|
|
40
|
+
Requires-Dist: pillow-heif>=0.16; extra == 'heif'
|
|
41
|
+
Provides-Extra: raw
|
|
42
|
+
Requires-Dist: rawpy>=0.19; extra == 'raw'
|
|
43
|
+
Provides-Extra: svg
|
|
44
|
+
Requires-Dist: cairosvg>=2.7; extra == 'svg'
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# image-to-webp
|
|
50
|
+
|
|
51
|
+
Convert images in almost any format to [WebP](https://developers.google.com/speed/webp), from Python or the command line.
|
|
52
|
+
|
|
53
|
+
- Over 70 input formats with Pillow alone: PNG, JPEG, GIF, BMP, TIFF, ICO/ICNS, TGA, PSD, JPEG 2000, PCX, PPM/PGM/PBM, DDS, SGI, QOI, WebP, …
|
|
54
|
+
- More formats through optional installs: HEIC/HEIF/AVIF, camera RAW (CR2, CR3, NEF, ARW, DNG, …) and SVG
|
|
55
|
+
- Applies EXIF rotation, keeps transparency, and turns animated GIF/APNG into animated WebP
|
|
56
|
+
- Converts files in parallel and writes each output in one step, so a failed run never leaves a half-written file
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install image-to-webp # core formats (Pillow only)
|
|
62
|
+
pip install "image-to-webp[all]" # + HEIC/AVIF, RAW and SVG
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Optional install | Adds | Package |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `[heif]` | HEIC / HEIF / AVIF | `pillow-heif` |
|
|
68
|
+
| `[raw]` | Camera RAW | `rawpy` |
|
|
69
|
+
| `[svg]` | SVG / SVGZ | `cairosvg` (also needs the system cairo library: `brew install cairo` or `apt install libcairo2`) |
|
|
70
|
+
|
|
71
|
+
Requires Python 3.9+.
|
|
72
|
+
|
|
73
|
+
## Library usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from image_to_webp import convert_file, convert_bytes, convert_image, convert_many, ConversionOptions
|
|
77
|
+
|
|
78
|
+
# One file -> photo.webp next to it (returns the output Path)
|
|
79
|
+
convert_file("photo.heic")
|
|
80
|
+
convert_file("photo.png", "out/photo.webp", quality=90)
|
|
81
|
+
|
|
82
|
+
# In memory
|
|
83
|
+
webp_bytes = convert_bytes(png_bytes, lossless=True)
|
|
84
|
+
webp_bytes = convert_bytes(open("img.cr2", "rb"), filename="img.cr2") # filename hints RAW decoding
|
|
85
|
+
webp_bytes = convert_image(pil_image, quality=75)
|
|
86
|
+
|
|
87
|
+
# Reusable settings
|
|
88
|
+
opts = ConversionOptions(quality=85, method=6, max_size=(2560, 2560), keep_metadata=True)
|
|
89
|
+
convert_file("big.tif", options=opts)
|
|
90
|
+
|
|
91
|
+
# Batch: files and/or directories, in parallel
|
|
92
|
+
summary = convert_many(["images/", "extra.gif"], output_dir="webp", recursive=True,
|
|
93
|
+
jobs=4, on_result=lambda r: print(r.status, r.src))
|
|
94
|
+
print(summary.converted, summary.skipped, summary.failed, summary.bytes_saved)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### API reference
|
|
98
|
+
|
|
99
|
+
| Name | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `convert_file(src, dst=None, options=None, *, overwrite=True, **opts) -> Path` | Convert one file. `dst` defaults to `src` with a `.webp` extension. |
|
|
102
|
+
| `convert_bytes(data, options=None, *, filename=None, **opts) -> bytes` | Convert bytes or a binary file object. |
|
|
103
|
+
| `convert_image(image, options=None, **opts) -> bytes` | Encode a `PIL.Image.Image`. |
|
|
104
|
+
| `convert_many(inputs, output_dir=None, options=None, *, recursive, overwrite=False, delete_original, jobs, on_result, **opts) -> BatchSummary` | Convert many files. One failed file doesn't stop the run. |
|
|
105
|
+
| `plan_conversions(inputs, output_dir=None, *, recursive, overwrite)` | Preview what `convert_many` would do (dry run). |
|
|
106
|
+
| `ConversionOptions(quality=80, lossless=False, method=4, max_size=None, keep_metadata=False)` | Encoder settings, checked when created. |
|
|
107
|
+
| `ConversionResult` / `BatchSummary` | Results for each file and for the whole batch. |
|
|
108
|
+
| `supported_extensions()`, `is_supported(path)`, `plugin_status()` | See which formats work in the current environment. |
|
|
109
|
+
| `open_image(source, filename=None)` | Open any supported input as a PIL image. |
|
|
110
|
+
| `ImageToWebPError` → `UnsupportedFormatError`, `ConversionError` | Exceptions. |
|
|
111
|
+
|
|
112
|
+
`**opts` accepts the same keywords as `ConversionOptions` (`quality`, `lossless`, `method`, `max_size`, `keep_metadata`).
|
|
113
|
+
The library never prints. Messages go to Python's `logging` under the `image_to_webp` logger.
|
|
114
|
+
|
|
115
|
+
## Command line
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
image-to-webp photo.jpg # -> photo.webp next to source
|
|
119
|
+
image-to-webp ./images -r -o ./webp # recursive, mirror tree into ./webp
|
|
120
|
+
image-to-webp *.png --lossless
|
|
121
|
+
image-to-webp ./raw -q 85 --max-size 2560x2560 -j 8
|
|
122
|
+
image-to-webp ./images -r --dry-run
|
|
123
|
+
image-to-webp --list-formats # formats + optional plugin status
|
|
124
|
+
python -m image_to_webp --help # same tool
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Option | Description |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `-r, --recursive` | Search directories recursively |
|
|
130
|
+
| `-o, --output DIR` | Output directory (default: next to source) |
|
|
131
|
+
| `-q, --quality N` | Quality 0–100 (default 80) |
|
|
132
|
+
| `--lossless` | Lossless encoding |
|
|
133
|
+
| `-m, --method N` | Compression effort 0–6 (default 4) |
|
|
134
|
+
| `--max-size WxH` | Downscale to fit, keeping aspect ratio |
|
|
135
|
+
| `--keep-metadata` | Keep EXIF and ICC profile |
|
|
136
|
+
| `--overwrite` | Overwrite existing `.webp` (default: skip) |
|
|
137
|
+
| `--delete-original` | Delete source after successful conversion |
|
|
138
|
+
| `-j, --jobs N` | Parallel workers (default: CPU count) |
|
|
139
|
+
| `--dry-run` | Show what would be converted |
|
|
140
|
+
| `-v, --verbose` | Print each file |
|
|
141
|
+
| `--list-formats` | Show supported formats and plugin status |
|
|
142
|
+
|
|
143
|
+
Exit code is `0` on success, `1` if any file failed, `2` for invalid arguments.
|
|
144
|
+
|
|
145
|
+
> The command is called `image-to-webp`, not `img2webp`, because libwebp already ships a tool named `img2webp`.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
./install.sh # .venv + editable install with all optional formats + link command to /usr/local/bin (macOS) or /usr/bin
|
|
151
|
+
./install.sh --uninstall # remove the linked command
|
|
152
|
+
.venv/bin/pip install -e ".[dev]"
|
|
153
|
+
.venv/bin/python -m pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`install.sh` uses `sudo` only when the target directory isn't writable. Override with `BIN_DIR=~/.local/bin` or `CMD_NAME=...`.
|
|
157
|
+
|
|
158
|
+
## Releasing
|
|
159
|
+
|
|
160
|
+
1. Update `__version__` in `src/image_to_webp/__init__.py` and `CHANGELOG.md`.
|
|
161
|
+
2. Check the build locally:
|
|
162
|
+
```bash
|
|
163
|
+
python -m build && python -m twine check --strict dist/*
|
|
164
|
+
```
|
|
165
|
+
3. Publish:
|
|
166
|
+
- **Automated (recommended):** set up PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) for `stakmi/image-to-webp` with workflow `publish.yml` and environment `pypi`, then create a GitHub release tagged `v<version>`.
|
|
167
|
+
- **Manual:** `python -m twine upload dist/*`
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# image-to-webp
|
|
2
|
+
|
|
3
|
+
Convert images in almost any format to [WebP](https://developers.google.com/speed/webp), from Python or the command line.
|
|
4
|
+
|
|
5
|
+
- Over 70 input formats with Pillow alone: PNG, JPEG, GIF, BMP, TIFF, ICO/ICNS, TGA, PSD, JPEG 2000, PCX, PPM/PGM/PBM, DDS, SGI, QOI, WebP, …
|
|
6
|
+
- More formats through optional installs: HEIC/HEIF/AVIF, camera RAW (CR2, CR3, NEF, ARW, DNG, …) and SVG
|
|
7
|
+
- Applies EXIF rotation, keeps transparency, and turns animated GIF/APNG into animated WebP
|
|
8
|
+
- Converts files in parallel and writes each output in one step, so a failed run never leaves a half-written file
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install image-to-webp # core formats (Pillow only)
|
|
14
|
+
pip install "image-to-webp[all]" # + HEIC/AVIF, RAW and SVG
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Optional install | Adds | Package |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `[heif]` | HEIC / HEIF / AVIF | `pillow-heif` |
|
|
20
|
+
| `[raw]` | Camera RAW | `rawpy` |
|
|
21
|
+
| `[svg]` | SVG / SVGZ | `cairosvg` (also needs the system cairo library: `brew install cairo` or `apt install libcairo2`) |
|
|
22
|
+
|
|
23
|
+
Requires Python 3.9+.
|
|
24
|
+
|
|
25
|
+
## Library usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from image_to_webp import convert_file, convert_bytes, convert_image, convert_many, ConversionOptions
|
|
29
|
+
|
|
30
|
+
# One file -> photo.webp next to it (returns the output Path)
|
|
31
|
+
convert_file("photo.heic")
|
|
32
|
+
convert_file("photo.png", "out/photo.webp", quality=90)
|
|
33
|
+
|
|
34
|
+
# In memory
|
|
35
|
+
webp_bytes = convert_bytes(png_bytes, lossless=True)
|
|
36
|
+
webp_bytes = convert_bytes(open("img.cr2", "rb"), filename="img.cr2") # filename hints RAW decoding
|
|
37
|
+
webp_bytes = convert_image(pil_image, quality=75)
|
|
38
|
+
|
|
39
|
+
# Reusable settings
|
|
40
|
+
opts = ConversionOptions(quality=85, method=6, max_size=(2560, 2560), keep_metadata=True)
|
|
41
|
+
convert_file("big.tif", options=opts)
|
|
42
|
+
|
|
43
|
+
# Batch: files and/or directories, in parallel
|
|
44
|
+
summary = convert_many(["images/", "extra.gif"], output_dir="webp", recursive=True,
|
|
45
|
+
jobs=4, on_result=lambda r: print(r.status, r.src))
|
|
46
|
+
print(summary.converted, summary.skipped, summary.failed, summary.bytes_saved)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### API reference
|
|
50
|
+
|
|
51
|
+
| Name | Description |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `convert_file(src, dst=None, options=None, *, overwrite=True, **opts) -> Path` | Convert one file. `dst` defaults to `src` with a `.webp` extension. |
|
|
54
|
+
| `convert_bytes(data, options=None, *, filename=None, **opts) -> bytes` | Convert bytes or a binary file object. |
|
|
55
|
+
| `convert_image(image, options=None, **opts) -> bytes` | Encode a `PIL.Image.Image`. |
|
|
56
|
+
| `convert_many(inputs, output_dir=None, options=None, *, recursive, overwrite=False, delete_original, jobs, on_result, **opts) -> BatchSummary` | Convert many files. One failed file doesn't stop the run. |
|
|
57
|
+
| `plan_conversions(inputs, output_dir=None, *, recursive, overwrite)` | Preview what `convert_many` would do (dry run). |
|
|
58
|
+
| `ConversionOptions(quality=80, lossless=False, method=4, max_size=None, keep_metadata=False)` | Encoder settings, checked when created. |
|
|
59
|
+
| `ConversionResult` / `BatchSummary` | Results for each file and for the whole batch. |
|
|
60
|
+
| `supported_extensions()`, `is_supported(path)`, `plugin_status()` | See which formats work in the current environment. |
|
|
61
|
+
| `open_image(source, filename=None)` | Open any supported input as a PIL image. |
|
|
62
|
+
| `ImageToWebPError` → `UnsupportedFormatError`, `ConversionError` | Exceptions. |
|
|
63
|
+
|
|
64
|
+
`**opts` accepts the same keywords as `ConversionOptions` (`quality`, `lossless`, `method`, `max_size`, `keep_metadata`).
|
|
65
|
+
The library never prints. Messages go to Python's `logging` under the `image_to_webp` logger.
|
|
66
|
+
|
|
67
|
+
## Command line
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
image-to-webp photo.jpg # -> photo.webp next to source
|
|
71
|
+
image-to-webp ./images -r -o ./webp # recursive, mirror tree into ./webp
|
|
72
|
+
image-to-webp *.png --lossless
|
|
73
|
+
image-to-webp ./raw -q 85 --max-size 2560x2560 -j 8
|
|
74
|
+
image-to-webp ./images -r --dry-run
|
|
75
|
+
image-to-webp --list-formats # formats + optional plugin status
|
|
76
|
+
python -m image_to_webp --help # same tool
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Option | Description |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `-r, --recursive` | Search directories recursively |
|
|
82
|
+
| `-o, --output DIR` | Output directory (default: next to source) |
|
|
83
|
+
| `-q, --quality N` | Quality 0–100 (default 80) |
|
|
84
|
+
| `--lossless` | Lossless encoding |
|
|
85
|
+
| `-m, --method N` | Compression effort 0–6 (default 4) |
|
|
86
|
+
| `--max-size WxH` | Downscale to fit, keeping aspect ratio |
|
|
87
|
+
| `--keep-metadata` | Keep EXIF and ICC profile |
|
|
88
|
+
| `--overwrite` | Overwrite existing `.webp` (default: skip) |
|
|
89
|
+
| `--delete-original` | Delete source after successful conversion |
|
|
90
|
+
| `-j, --jobs N` | Parallel workers (default: CPU count) |
|
|
91
|
+
| `--dry-run` | Show what would be converted |
|
|
92
|
+
| `-v, --verbose` | Print each file |
|
|
93
|
+
| `--list-formats` | Show supported formats and plugin status |
|
|
94
|
+
|
|
95
|
+
Exit code is `0` on success, `1` if any file failed, `2` for invalid arguments.
|
|
96
|
+
|
|
97
|
+
> The command is called `image-to-webp`, not `img2webp`, because libwebp already ships a tool named `img2webp`.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
./install.sh # .venv + editable install with all optional formats + link command to /usr/local/bin (macOS) or /usr/bin
|
|
103
|
+
./install.sh --uninstall # remove the linked command
|
|
104
|
+
.venv/bin/pip install -e ".[dev]"
|
|
105
|
+
.venv/bin/python -m pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`install.sh` uses `sudo` only when the target directory isn't writable. Override with `BIN_DIR=~/.local/bin` or `CMD_NAME=...`.
|
|
109
|
+
|
|
110
|
+
## Releasing
|
|
111
|
+
|
|
112
|
+
1. Update `__version__` in `src/image_to_webp/__init__.py` and `CHANGELOG.md`.
|
|
113
|
+
2. Check the build locally:
|
|
114
|
+
```bash
|
|
115
|
+
python -m build && python -m twine check --strict dist/*
|
|
116
|
+
```
|
|
117
|
+
3. Publish:
|
|
118
|
+
- **Automated (recommended):** set up PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) for `stakmi/image-to-webp` with workflow `publish.yml` and environment `pypi`, then create a GitHub release tagged `v<version>`.
|
|
119
|
+
- **Manual:** `python -m twine upload dist/*`
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "image-to-webp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Convert images in almost any format (PNG, JPEG, GIF, TIFF, HEIC, AVIF, RAW, SVG, ...) to WebP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "stakmi" }]
|
|
14
|
+
keywords = ["webp", "image", "convert", "converter", "heic", "raw", "svg", "pillow"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = ["Pillow>=10.0"]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
heif = ["pillow-heif>=0.16"]
|
|
34
|
+
raw = ["rawpy>=0.19"]
|
|
35
|
+
svg = ["cairosvg>=2.7"]
|
|
36
|
+
all = ["image-to-webp[heif,raw,svg]"]
|
|
37
|
+
test = ["pytest>=7"]
|
|
38
|
+
dev = ["image-to-webp[all,test]", "build", "twine"]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
image-to-webp = "image_to_webp.cli:main"
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/stakmi/image-to-webp"
|
|
45
|
+
Repository = "https://github.com/stakmi/image-to-webp"
|
|
46
|
+
Issues = "https://github.com/stakmi/image-to-webp/issues"
|
|
47
|
+
Changelog = "https://github.com/stakmi/image-to-webp/blob/main/CHANGELOG.md"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version]
|
|
50
|
+
path = "src/image_to_webp/__init__.py"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/image_to_webp"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.sdist]
|
|
56
|
+
include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Convert images in almost any format to WebP.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from image_to_webp import convert_file, convert_bytes, convert_many
|
|
6
|
+
|
|
7
|
+
convert_file("photo.heic") # -> photo.webp
|
|
8
|
+
webp = convert_bytes(png_bytes, quality=90) # in memory
|
|
9
|
+
summary = convert_many("images/", recursive=True, output_dir="webp")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .batch import BatchSummary, ConversionResult, convert_many, plan_conversions
|
|
13
|
+
from .converter import convert_bytes, convert_file, convert_image, default_output_path
|
|
14
|
+
from .errors import ConversionError, ImageToWebPError, UnsupportedFormatError
|
|
15
|
+
from .loaders import is_supported, open_image, plugin_status, supported_extensions
|
|
16
|
+
from .options import ConversionOptions
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BatchSummary",
|
|
22
|
+
"ConversionError",
|
|
23
|
+
"ConversionOptions",
|
|
24
|
+
"ConversionResult",
|
|
25
|
+
"ImageToWebPError",
|
|
26
|
+
"UnsupportedFormatError",
|
|
27
|
+
"__version__",
|
|
28
|
+
"convert_bytes",
|
|
29
|
+
"convert_file",
|
|
30
|
+
"convert_image",
|
|
31
|
+
"convert_many",
|
|
32
|
+
"default_output_path",
|
|
33
|
+
"is_supported",
|
|
34
|
+
"open_image",
|
|
35
|
+
"plan_conversions",
|
|
36
|
+
"plugin_status",
|
|
37
|
+
"supported_extensions",
|
|
38
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Convert many files, optionally in parallel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Iterable, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .converter import PathLike, convert_file, default_output_path
|
|
12
|
+
from .loaders import supported_extensions
|
|
13
|
+
from .options import ConversionOptions, resolve_options
|
|
14
|
+
|
|
15
|
+
PENDING = "pending"
|
|
16
|
+
CONVERTED = "converted"
|
|
17
|
+
SKIPPED = "skipped"
|
|
18
|
+
FAILED = "failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConversionResult:
|
|
23
|
+
"""Outcome for one input file.
|
|
24
|
+
|
|
25
|
+
``status`` is one of ``"pending"`` (planned only), ``"converted"``, ``"skipped"`` or ``"failed"``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
src: Path
|
|
29
|
+
dst: Optional[Path]
|
|
30
|
+
status: str = PENDING
|
|
31
|
+
in_size: int = 0
|
|
32
|
+
out_size: int = 0
|
|
33
|
+
error: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ok(self) -> bool:
|
|
37
|
+
return self.status in (CONVERTED, SKIPPED)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BatchSummary:
|
|
42
|
+
"""Aggregate result of :func:`convert_many`."""
|
|
43
|
+
|
|
44
|
+
results: List[ConversionResult] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def _count(self, status: str) -> int:
|
|
47
|
+
return sum(r.status == status for r in self.results)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def converted(self) -> int:
|
|
51
|
+
return self._count(CONVERTED)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def skipped(self) -> int:
|
|
55
|
+
return self._count(SKIPPED)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def failed(self) -> int:
|
|
59
|
+
return self._count(FAILED)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def bytes_in(self) -> int:
|
|
63
|
+
return sum(r.in_size for r in self.results if r.status == CONVERTED)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def bytes_out(self) -> int:
|
|
67
|
+
return sum(r.out_size for r in self.results if r.status == CONVERTED)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def bytes_saved(self) -> int:
|
|
71
|
+
return self.bytes_in - self.bytes_out
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def ok(self) -> bool:
|
|
75
|
+
return self.failed == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _as_list(inputs: Union[PathLike, Iterable[PathLike]]) -> List[Path]:
|
|
79
|
+
if isinstance(inputs, (str, os.PathLike)):
|
|
80
|
+
return [Path(inputs)]
|
|
81
|
+
return [Path(p) for p in inputs]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def plan_conversions(
|
|
85
|
+
inputs: Union[PathLike, Iterable[PathLike]],
|
|
86
|
+
output_dir: Optional[PathLike] = None,
|
|
87
|
+
*,
|
|
88
|
+
recursive: bool = False,
|
|
89
|
+
overwrite: bool = False,
|
|
90
|
+
) -> List[ConversionResult]:
|
|
91
|
+
"""Work out what :func:`convert_many` would do, without converting anything.
|
|
92
|
+
|
|
93
|
+
Files given explicitly are always included; files found in directories are
|
|
94
|
+
included only if their extension is supported (and not already ``.webp``).
|
|
95
|
+
Output paths mirror the directory tree under ``output_dir`` when given.
|
|
96
|
+
"""
|
|
97
|
+
exts = supported_extensions()
|
|
98
|
+
out_root = Path(output_dir) if output_dir is not None else None
|
|
99
|
+
results: List[ConversionResult] = []
|
|
100
|
+
seen: set = set()
|
|
101
|
+
|
|
102
|
+
def add(src: Path, dst: Path) -> None:
|
|
103
|
+
key = os.path.normcase(os.path.abspath(dst))
|
|
104
|
+
if key in seen:
|
|
105
|
+
results.append(ConversionResult(src, dst, SKIPPED, error="duplicate output name"))
|
|
106
|
+
elif dst.exists() and not overwrite:
|
|
107
|
+
results.append(ConversionResult(src, dst, SKIPPED, error="output exists"))
|
|
108
|
+
else:
|
|
109
|
+
results.append(ConversionResult(src, dst))
|
|
110
|
+
seen.add(key)
|
|
111
|
+
|
|
112
|
+
for item in _as_list(inputs):
|
|
113
|
+
if item.is_file():
|
|
114
|
+
add(item, (out_root / f"{item.stem}.webp") if out_root else default_output_path(item))
|
|
115
|
+
elif item.is_dir():
|
|
116
|
+
files = item.rglob("*") if recursive else item.iterdir()
|
|
117
|
+
for f in sorted(files):
|
|
118
|
+
ext = f.suffix.lower()
|
|
119
|
+
if ext == ".webp" or ext not in exts or not f.is_file():
|
|
120
|
+
continue
|
|
121
|
+
dst_dir = out_root / f.parent.relative_to(item) if out_root else f.parent
|
|
122
|
+
add(f, dst_dir / f"{f.stem}.webp")
|
|
123
|
+
else:
|
|
124
|
+
results.append(ConversionResult(item, None, FAILED, error="no such file or directory"))
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_one(src: Path, dst: Path, opts: ConversionOptions, delete_original: bool) -> ConversionResult:
|
|
129
|
+
try:
|
|
130
|
+
in_size = src.stat().st_size
|
|
131
|
+
convert_file(src, dst, opts)
|
|
132
|
+
if delete_original and src.resolve() != dst.resolve():
|
|
133
|
+
src.unlink()
|
|
134
|
+
return ConversionResult(src, dst, CONVERTED, in_size, dst.stat().st_size)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
return ConversionResult(src, dst, FAILED, error=str(exc) or type(exc).__name__)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def convert_many(
|
|
140
|
+
inputs: Union[PathLike, Iterable[PathLike]],
|
|
141
|
+
output_dir: Optional[PathLike] = None,
|
|
142
|
+
options: Optional[ConversionOptions] = None,
|
|
143
|
+
*,
|
|
144
|
+
recursive: bool = False,
|
|
145
|
+
overwrite: bool = False,
|
|
146
|
+
delete_original: bool = False,
|
|
147
|
+
jobs: Optional[int] = None,
|
|
148
|
+
on_result: Optional[Callable[[ConversionResult], None]] = None,
|
|
149
|
+
**kwargs: Any,
|
|
150
|
+
) -> BatchSummary:
|
|
151
|
+
"""Convert files and/or directories to WebP.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
inputs: a path or iterable of paths (files or directories).
|
|
155
|
+
output_dir: where to write output; default is next to each source.
|
|
156
|
+
options: :class:`ConversionOptions`; keyword overrides (``quality=...``) are also accepted.
|
|
157
|
+
recursive: descend into sub-directories.
|
|
158
|
+
overwrite: replace existing ``.webp`` files (default: skip them).
|
|
159
|
+
delete_original: remove each source after it converts successfully.
|
|
160
|
+
jobs: worker processes (default: CPU count; 1 = run in this process).
|
|
161
|
+
on_result: called with each :class:`ConversionResult` as it completes (in the calling process).
|
|
162
|
+
|
|
163
|
+
Individual failures never raise; check :attr:`BatchSummary.failed`.
|
|
164
|
+
"""
|
|
165
|
+
opts = resolve_options(options, kwargs)
|
|
166
|
+
planned = plan_conversions(inputs, output_dir, recursive=recursive, overwrite=overwrite)
|
|
167
|
+
summary = BatchSummary()
|
|
168
|
+
|
|
169
|
+
def done(result: ConversionResult) -> None:
|
|
170
|
+
summary.results.append(result)
|
|
171
|
+
if on_result is not None:
|
|
172
|
+
on_result(result)
|
|
173
|
+
|
|
174
|
+
todo = []
|
|
175
|
+
for r in planned:
|
|
176
|
+
if r.status == PENDING:
|
|
177
|
+
todo.append(r)
|
|
178
|
+
else:
|
|
179
|
+
done(r)
|
|
180
|
+
|
|
181
|
+
workers = max(1, min(jobs or os.cpu_count() or 1, len(todo) or 1))
|
|
182
|
+
if workers == 1:
|
|
183
|
+
for r in todo:
|
|
184
|
+
done(_run_one(r.src, r.dst, opts, delete_original)) # type: ignore[arg-type]
|
|
185
|
+
else:
|
|
186
|
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
187
|
+
futures = {pool.submit(_run_one, r.src, r.dst, opts, delete_original): r for r in todo}
|
|
188
|
+
for fut in as_completed(futures):
|
|
189
|
+
r = futures[fut]
|
|
190
|
+
try:
|
|
191
|
+
done(fut.result())
|
|
192
|
+
except Exception as exc: # worker crashed
|
|
193
|
+
done(ConversionResult(r.src, r.dst, FAILED, error=str(exc) or type(exc).__name__))
|
|
194
|
+
return summary
|