webp-convert 1.0.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.
- webp_convert-1.0.0/MANIFEST.in +3 -0
- webp_convert-1.0.0/PKG-INFO +148 -0
- webp_convert-1.0.0/README.md +133 -0
- webp_convert-1.0.0/pyproject.toml +27 -0
- webp_convert-1.0.0/setup.cfg +4 -0
- webp_convert-1.0.0/webp_convert/__init__.py +10 -0
- webp_convert-1.0.0/webp_convert/cli.py +89 -0
- webp_convert-1.0.0/webp_convert/converter.py +206 -0
- webp_convert-1.0.0/webp_convert/watcher.py +102 -0
- webp_convert-1.0.0/webp_convert.egg-info/PKG-INFO +148 -0
- webp_convert-1.0.0/webp_convert.egg-info/SOURCES.txt +13 -0
- webp_convert-1.0.0/webp_convert.egg-info/dependency_links.txt +1 -0
- webp_convert-1.0.0/webp_convert.egg-info/entry_points.txt +2 -0
- webp_convert-1.0.0/webp_convert.egg-info/requires.txt +8 -0
- webp_convert-1.0.0/webp_convert.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webp-convert
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Automatically convert PNG/JPG images to WebP with responsive sizes. CLI, file watcher, and Python API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: Pillow>=10.0.0
|
|
9
|
+
Requires-Dist: watchdog>=4.0.0
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# webp-convert (Python)
|
|
17
|
+
|
|
18
|
+
> Automatically convert PNG/JPG/GIF/TIFF images to WebP with responsive size variants.
|
|
19
|
+
> Zero-config CLI, file watcher, and programmatic Python API.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip3 install webp-convert
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Python 3.8+. Uses [Pillow](https://pillow.readthedocs.io/) — no external binaries required.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Convert everything in assets/images (default)
|
|
37
|
+
webp-convert
|
|
38
|
+
|
|
39
|
+
# Custom dir + quality
|
|
40
|
+
webp-convert src/img -q 90
|
|
41
|
+
|
|
42
|
+
# Watch mode — auto-converts on file change
|
|
43
|
+
webp-convert src/img --watch
|
|
44
|
+
|
|
45
|
+
# Custom responsive sizes
|
|
46
|
+
webp-convert src/img --sizes 1200,800,400
|
|
47
|
+
|
|
48
|
+
# Write outputs to a separate directory
|
|
49
|
+
webp-convert src/img -o dist/img
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## CLI reference
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Usage: webp-convert [INPUT_DIR] [OPTIONS]
|
|
58
|
+
|
|
59
|
+
Convert PNG/JPG images to WebP with responsive size variants.
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
-o, --output TEXT Output directory (default: same as source)
|
|
63
|
+
-q, --quality INT WebP quality 0-100 [default: 80]
|
|
64
|
+
--lossless Lossless encoding
|
|
65
|
+
--sizes W,W,... Responsive widths e.g. 1200,800,400 (0=full)
|
|
66
|
+
--no-full Skip full-size, only responsive variants
|
|
67
|
+
-r, --recursive Recurse into subdirectories
|
|
68
|
+
--no-overwrite Skip files that already have a .webp counterpart
|
|
69
|
+
-w, --watch Watch mode
|
|
70
|
+
--silent Suppress all output
|
|
71
|
+
-h, --help Show this message and exit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Programmatic API
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from webp_convert import convert, watch, convert_file, ConvertConfig, SizeSpec
|
|
80
|
+
|
|
81
|
+
# --- Convert a directory (keyword args shortcut) ---
|
|
82
|
+
results = convert(input="src/images", quality=85)
|
|
83
|
+
|
|
84
|
+
# --- Convert with a config object ---
|
|
85
|
+
cfg = ConvertConfig(
|
|
86
|
+
input="src/images",
|
|
87
|
+
output="public/images", # omit to write next to originals
|
|
88
|
+
quality=85,
|
|
89
|
+
lossless=False,
|
|
90
|
+
recursive=True,
|
|
91
|
+
overwrite=True,
|
|
92
|
+
sizes=[
|
|
93
|
+
SizeSpec(width=0, suffix=""), # full size
|
|
94
|
+
SizeSpec(width=1200, suffix="-1200"),
|
|
95
|
+
SizeSpec(width=800, suffix="-800"),
|
|
96
|
+
SizeSpec(width=400, suffix="-400"),
|
|
97
|
+
],
|
|
98
|
+
on_file=lambda r: print(f"{r.src} → {r.dest} ({r.size_bytes} bytes)"),
|
|
99
|
+
)
|
|
100
|
+
results = convert(cfg)
|
|
101
|
+
|
|
102
|
+
# --- Convert a single file ---
|
|
103
|
+
results = convert_file("hero.png", cfg)
|
|
104
|
+
|
|
105
|
+
# --- Watch mode (context manager) ---
|
|
106
|
+
import time
|
|
107
|
+
watcher = watch(ConvertConfig(input="src/images")).start()
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
time.sleep(1)
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
watcher.stop()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Output structure
|
|
118
|
+
|
|
119
|
+
Given `hero.jpg` in `src/images/`:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
src/images/
|
|
123
|
+
├── hero.jpg (original, kept by default)
|
|
124
|
+
├── hero.webp (full size)
|
|
125
|
+
├── hero-800.webp (800px wide, aspect-ratio preserved)
|
|
126
|
+
└── hero-400.webp (400px wide)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## HTML usage
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<picture>
|
|
135
|
+
<source
|
|
136
|
+
type="image/webp"
|
|
137
|
+
srcset="hero-400.webp 400w, hero-800.webp 800w, hero.webp 1600w"
|
|
138
|
+
sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1600px"
|
|
139
|
+
/>
|
|
140
|
+
<img src="hero.jpg" alt="Hero image" />
|
|
141
|
+
</picture>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# webp-convert (Python)
|
|
2
|
+
|
|
3
|
+
> Automatically convert PNG/JPG/GIF/TIFF images to WebP with responsive size variants.
|
|
4
|
+
> Zero-config CLI, file watcher, and programmatic Python API.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip3 install webp-convert
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.8+. Uses [Pillow](https://pillow.readthedocs.io/) — no external binaries required.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Convert everything in assets/images (default)
|
|
22
|
+
webp-convert
|
|
23
|
+
|
|
24
|
+
# Custom dir + quality
|
|
25
|
+
webp-convert src/img -q 90
|
|
26
|
+
|
|
27
|
+
# Watch mode — auto-converts on file change
|
|
28
|
+
webp-convert src/img --watch
|
|
29
|
+
|
|
30
|
+
# Custom responsive sizes
|
|
31
|
+
webp-convert src/img --sizes 1200,800,400
|
|
32
|
+
|
|
33
|
+
# Write outputs to a separate directory
|
|
34
|
+
webp-convert src/img -o dist/img
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## CLI reference
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Usage: webp-convert [INPUT_DIR] [OPTIONS]
|
|
43
|
+
|
|
44
|
+
Convert PNG/JPG images to WebP with responsive size variants.
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-o, --output TEXT Output directory (default: same as source)
|
|
48
|
+
-q, --quality INT WebP quality 0-100 [default: 80]
|
|
49
|
+
--lossless Lossless encoding
|
|
50
|
+
--sizes W,W,... Responsive widths e.g. 1200,800,400 (0=full)
|
|
51
|
+
--no-full Skip full-size, only responsive variants
|
|
52
|
+
-r, --recursive Recurse into subdirectories
|
|
53
|
+
--no-overwrite Skip files that already have a .webp counterpart
|
|
54
|
+
-w, --watch Watch mode
|
|
55
|
+
--silent Suppress all output
|
|
56
|
+
-h, --help Show this message and exit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Programmatic API
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from webp_convert import convert, watch, convert_file, ConvertConfig, SizeSpec
|
|
65
|
+
|
|
66
|
+
# --- Convert a directory (keyword args shortcut) ---
|
|
67
|
+
results = convert(input="src/images", quality=85)
|
|
68
|
+
|
|
69
|
+
# --- Convert with a config object ---
|
|
70
|
+
cfg = ConvertConfig(
|
|
71
|
+
input="src/images",
|
|
72
|
+
output="public/images", # omit to write next to originals
|
|
73
|
+
quality=85,
|
|
74
|
+
lossless=False,
|
|
75
|
+
recursive=True,
|
|
76
|
+
overwrite=True,
|
|
77
|
+
sizes=[
|
|
78
|
+
SizeSpec(width=0, suffix=""), # full size
|
|
79
|
+
SizeSpec(width=1200, suffix="-1200"),
|
|
80
|
+
SizeSpec(width=800, suffix="-800"),
|
|
81
|
+
SizeSpec(width=400, suffix="-400"),
|
|
82
|
+
],
|
|
83
|
+
on_file=lambda r: print(f"{r.src} → {r.dest} ({r.size_bytes} bytes)"),
|
|
84
|
+
)
|
|
85
|
+
results = convert(cfg)
|
|
86
|
+
|
|
87
|
+
# --- Convert a single file ---
|
|
88
|
+
results = convert_file("hero.png", cfg)
|
|
89
|
+
|
|
90
|
+
# --- Watch mode (context manager) ---
|
|
91
|
+
import time
|
|
92
|
+
watcher = watch(ConvertConfig(input="src/images")).start()
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
time.sleep(1)
|
|
96
|
+
except KeyboardInterrupt:
|
|
97
|
+
watcher.stop()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Output structure
|
|
103
|
+
|
|
104
|
+
Given `hero.jpg` in `src/images/`:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
src/images/
|
|
108
|
+
├── hero.jpg (original, kept by default)
|
|
109
|
+
├── hero.webp (full size)
|
|
110
|
+
├── hero-800.webp (800px wide, aspect-ratio preserved)
|
|
111
|
+
└── hero-400.webp (400px wide)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## HTML usage
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<picture>
|
|
120
|
+
<source
|
|
121
|
+
type="image/webp"
|
|
122
|
+
srcset="hero-400.webp 400w, hero-800.webp 800w, hero.webp 1600w"
|
|
123
|
+
sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1600px"
|
|
124
|
+
/>
|
|
125
|
+
<img src="hero.jpg" alt="Hero image" />
|
|
126
|
+
</picture>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webp-convert"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Automatically convert PNG/JPG images to WebP with responsive sizes. CLI, file watcher, and Python API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"Pillow>=10.0.0",
|
|
14
|
+
"watchdog>=4.0.0",
|
|
15
|
+
"click>=8.1.0",
|
|
16
|
+
"rich>=13.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
webp-convert = "webp_convert.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["."]
|
|
27
|
+
include = ["webp_convert*"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webp-convert — Python package
|
|
3
|
+
Convert PNG/JPG/GIF/TIFF images to WebP with responsive size variants.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .converter import convert, convert_file, convert_dir, DEFAULT_CONFIG
|
|
7
|
+
from .watcher import watch
|
|
8
|
+
|
|
9
|
+
__all__ = ["convert", "convert_file", "convert_dir", "watch", "DEFAULT_CONFIG"]
|
|
10
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webp_convert.cli
|
|
3
|
+
Click-based CLI entry point.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
webp-convert [INPUT_DIR] [options]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import click
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from .converter import ConvertConfig, SizeSpec, convert
|
|
14
|
+
from .watcher import watch as do_watch
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_sizes(sizes_str: str):
|
|
18
|
+
"""Parse '1200,800,400' → list of SizeSpec."""
|
|
19
|
+
specs = []
|
|
20
|
+
for part in sizes_str.split(","):
|
|
21
|
+
w = int(part.strip())
|
|
22
|
+
specs.append(SizeSpec(width=w, suffix="" if w == 0 else f"-{w}"))
|
|
23
|
+
return specs
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
27
|
+
@click.argument("input_dir", default="assets/images", metavar="[INPUT_DIR]")
|
|
28
|
+
@click.option("-o", "--output", default=None, help="Output directory (default: same as source)")
|
|
29
|
+
@click.option("-q", "--quality", default=80, show_default=True, help="WebP quality 0-100")
|
|
30
|
+
@click.option("--lossless", is_flag=True, help="Lossless encoding")
|
|
31
|
+
@click.option(
|
|
32
|
+
"--sizes", "sizes_str",
|
|
33
|
+
default=None,
|
|
34
|
+
metavar="W,W,...",
|
|
35
|
+
help="Responsive widths e.g. 1200,800,400 (0=full). Default: 0,800,400",
|
|
36
|
+
)
|
|
37
|
+
@click.option("--no-full", is_flag=True, help="Skip full-size, only responsive variants")
|
|
38
|
+
@click.option("-r", "--recursive", is_flag=True, help="Recurse into subdirectories")
|
|
39
|
+
@click.option("--no-overwrite", is_flag=True, help="Skip files that already have a .webp")
|
|
40
|
+
@click.option("-w", "--watch", "watch_mode", is_flag=True, help="Watch mode")
|
|
41
|
+
@click.option("--silent", is_flag=True, help="Suppress all output")
|
|
42
|
+
def main(input_dir, output, quality, lossless, sizes_str, no_full, recursive,
|
|
43
|
+
no_overwrite, watch_mode, silent):
|
|
44
|
+
"""
|
|
45
|
+
Convert PNG/JPG images to WebP with responsive size variants.
|
|
46
|
+
|
|
47
|
+
\b
|
|
48
|
+
Examples:
|
|
49
|
+
webp-convert
|
|
50
|
+
webp-convert src/img -q 90 -o dist/img
|
|
51
|
+
webp-convert src/img --sizes 1200,800,400
|
|
52
|
+
webp-convert src/img --watch
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Build sizes list
|
|
56
|
+
if sizes_str:
|
|
57
|
+
sizes = _parse_sizes(sizes_str)
|
|
58
|
+
if not no_full and not any(s.width == 0 for s in sizes):
|
|
59
|
+
sizes.insert(0, SizeSpec(width=0, suffix=""))
|
|
60
|
+
else:
|
|
61
|
+
sizes = [SizeSpec(0, ""), SizeSpec(800, "-800"), SizeSpec(400, "-400")]
|
|
62
|
+
if no_full:
|
|
63
|
+
sizes = [s for s in sizes if s.width != 0]
|
|
64
|
+
|
|
65
|
+
cfg = ConvertConfig(
|
|
66
|
+
input=input_dir,
|
|
67
|
+
output=output,
|
|
68
|
+
quality=quality,
|
|
69
|
+
lossless=lossless,
|
|
70
|
+
sizes=sizes,
|
|
71
|
+
overwrite=not no_overwrite,
|
|
72
|
+
recursive=recursive,
|
|
73
|
+
silent=silent,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if watch_mode:
|
|
77
|
+
watcher = do_watch(cfg).start()
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
time.sleep(1)
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
click.echo("\n stopping watcher...")
|
|
83
|
+
watcher.stop()
|
|
84
|
+
else:
|
|
85
|
+
convert(cfg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webp_convert.converter
|
|
3
|
+
Core image conversion logic using Pillow.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
# ─── Config ───────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SizeSpec:
|
|
19
|
+
width: int # 0 = full size (no resize)
|
|
20
|
+
suffix: str # e.g. "-800", "" for full-size
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_SIZES = [
|
|
24
|
+
SizeSpec(width=0, suffix=""),
|
|
25
|
+
SizeSpec(width=800, suffix="-800"),
|
|
26
|
+
SizeSpec(width=400, suffix="-400"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DEFAULT_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".tiff", ".bmp", ".webp"}
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ConvertConfig:
|
|
33
|
+
input: Union[str, List[str]] = "assets/images"
|
|
34
|
+
output: Optional[str] = None # None = same folder as source
|
|
35
|
+
quality: int = 80
|
|
36
|
+
lossless: bool = False
|
|
37
|
+
sizes: List[SizeSpec] = field(default_factory=lambda: list(DEFAULT_SIZES))
|
|
38
|
+
keep_originals: bool = True
|
|
39
|
+
overwrite: bool = True
|
|
40
|
+
recursive: bool = False
|
|
41
|
+
extensions: set = field(default_factory=lambda: set(DEFAULT_EXTENSIONS))
|
|
42
|
+
on_file: Optional[Callable] = None # called with ConvertResult per output
|
|
43
|
+
on_done: Optional[Callable] = None # called with list[ConvertResult] when batch finishes
|
|
44
|
+
silent: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DEFAULT_CONFIG = ConvertConfig()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─── Result ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ConvertResult:
|
|
54
|
+
src: Path
|
|
55
|
+
dest: Path
|
|
56
|
+
width: Union[int, str] # int or "original"
|
|
57
|
+
size_bytes: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _get_output_path(src: Path, suffix: str, cfg: ConvertConfig) -> Path:
|
|
63
|
+
if cfg.output:
|
|
64
|
+
base_dir = Path(cfg.output)
|
|
65
|
+
else:
|
|
66
|
+
base_dir = src.parent
|
|
67
|
+
return base_dir / f"{src.stem}{suffix}.webp"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _log(cfg: ConvertConfig, *args):
|
|
71
|
+
if not cfg.silent:
|
|
72
|
+
print(*args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ─── Single-file conversion ───────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def convert_file(src: Union[str, Path], cfg: Optional[ConvertConfig] = None) -> List[ConvertResult]:
|
|
78
|
+
"""
|
|
79
|
+
Convert a single image file to all configured WebP sizes.
|
|
80
|
+
Returns a list of ConvertResult objects.
|
|
81
|
+
|
|
82
|
+
Example::
|
|
83
|
+
|
|
84
|
+
from webp_convert import convert_file, ConvertConfig
|
|
85
|
+
results = convert_file("hero.jpg", ConvertConfig(quality=90))
|
|
86
|
+
"""
|
|
87
|
+
cfg = cfg or ConvertConfig()
|
|
88
|
+
src = Path(src)
|
|
89
|
+
results = []
|
|
90
|
+
|
|
91
|
+
if cfg.output:
|
|
92
|
+
Path(cfg.output).mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
with Image.open(src) as img:
|
|
95
|
+
# Ensure RGB(A) for proper WebP encoding
|
|
96
|
+
if img.mode not in ("RGB", "RGBA"):
|
|
97
|
+
img = img.convert("RGBA" if img.mode == "P" and "transparency" in img.info else "RGB")
|
|
98
|
+
|
|
99
|
+
for spec in cfg.sizes:
|
|
100
|
+
dest = _get_output_path(src, spec.suffix, cfg)
|
|
101
|
+
|
|
102
|
+
if not cfg.overwrite and dest.exists():
|
|
103
|
+
_log(cfg, f" skip {dest} (exists)")
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if spec.width > 0:
|
|
107
|
+
ratio = spec.width / img.width
|
|
108
|
+
# Don't enlarge
|
|
109
|
+
if ratio < 1.0:
|
|
110
|
+
new_h = int(img.height * ratio)
|
|
111
|
+
resized = img.resize((spec.width, new_h), Image.LANCZOS)
|
|
112
|
+
else:
|
|
113
|
+
resized = img
|
|
114
|
+
else:
|
|
115
|
+
resized = img
|
|
116
|
+
|
|
117
|
+
save_kwargs = {"format": "WEBP", "quality": cfg.quality}
|
|
118
|
+
if cfg.lossless:
|
|
119
|
+
save_kwargs["lossless"] = True
|
|
120
|
+
|
|
121
|
+
resized.save(dest, **save_kwargs)
|
|
122
|
+
size_bytes = dest.stat().st_size
|
|
123
|
+
result = ConvertResult(
|
|
124
|
+
src=src,
|
|
125
|
+
dest=dest,
|
|
126
|
+
width=spec.width if spec.width > 0 else "original",
|
|
127
|
+
size_bytes=size_bytes,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_log(cfg, f" → {dest} ({size_bytes / 1024:.1f} KB)")
|
|
131
|
+
|
|
132
|
+
if cfg.on_file:
|
|
133
|
+
cfg.on_file(result)
|
|
134
|
+
|
|
135
|
+
results.append(result)
|
|
136
|
+
|
|
137
|
+
return results
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ─── Directory conversion ─────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def convert_dir(input_dir: Union[str, Path], cfg: Optional[ConvertConfig] = None) -> List[ConvertResult]:
|
|
143
|
+
"""
|
|
144
|
+
Convert all matching images in a directory.
|
|
145
|
+
"""
|
|
146
|
+
cfg = cfg or ConvertConfig()
|
|
147
|
+
input_dir = Path(input_dir)
|
|
148
|
+
all_results: List[ConvertResult] = []
|
|
149
|
+
|
|
150
|
+
pattern = "**/*" if cfg.recursive else "*"
|
|
151
|
+
for path in input_dir.glob(pattern):
|
|
152
|
+
if not path.is_file():
|
|
153
|
+
continue
|
|
154
|
+
if path.suffix.lower() not in cfg.extensions:
|
|
155
|
+
continue
|
|
156
|
+
if path.suffix.lower() == ".webp":
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
_log(cfg, f"processing {path}")
|
|
160
|
+
try:
|
|
161
|
+
results = convert_file(path, cfg)
|
|
162
|
+
all_results.extend(results)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
print(f" error: {path}: {exc}")
|
|
165
|
+
|
|
166
|
+
return all_results
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─── Batch (multi-dir) ────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def convert(cfg: Optional[ConvertConfig] = None, **kwargs) -> List[ConvertResult]:
|
|
172
|
+
"""
|
|
173
|
+
Main entry point. Convert all images in one or more input directories.
|
|
174
|
+
|
|
175
|
+
Can be called with a ConvertConfig object or keyword arguments::
|
|
176
|
+
|
|
177
|
+
# Using a config object
|
|
178
|
+
from webp_convert import convert, ConvertConfig
|
|
179
|
+
results = convert(ConvertConfig(input="src/images", quality=85))
|
|
180
|
+
|
|
181
|
+
# Using keyword args (creates a ConvertConfig internally)
|
|
182
|
+
results = convert(input="src/images", quality=85)
|
|
183
|
+
"""
|
|
184
|
+
if cfg is None:
|
|
185
|
+
cfg = ConvertConfig(**kwargs)
|
|
186
|
+
elif kwargs:
|
|
187
|
+
# Merge kwargs into a copy of cfg
|
|
188
|
+
cfg_dict = cfg.__dict__.copy()
|
|
189
|
+
cfg_dict.update(kwargs)
|
|
190
|
+
cfg = ConvertConfig(**cfg_dict)
|
|
191
|
+
|
|
192
|
+
inputs = cfg.input if isinstance(cfg.input, list) else [cfg.input]
|
|
193
|
+
all_results: List[ConvertResult] = []
|
|
194
|
+
|
|
195
|
+
_log(cfg, "\n webp-convert — starting...\n")
|
|
196
|
+
|
|
197
|
+
for d in inputs:
|
|
198
|
+
results = convert_dir(d, cfg)
|
|
199
|
+
all_results.extend(results)
|
|
200
|
+
|
|
201
|
+
_log(cfg, f"\n ✓ done — converted {len(all_results)} file(s)")
|
|
202
|
+
|
|
203
|
+
if cfg.on_done:
|
|
204
|
+
cfg.on_done(all_results)
|
|
205
|
+
|
|
206
|
+
return all_results
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webp_convert.watcher
|
|
3
|
+
File watching support using watchdog.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Union, List
|
|
11
|
+
|
|
12
|
+
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
|
|
13
|
+
from watchdog.observers import Observer
|
|
14
|
+
|
|
15
|
+
from .converter import ConvertConfig, convert_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _ImageEventHandler(FileSystemEventHandler):
|
|
19
|
+
def __init__(self, cfg: ConvertConfig):
|
|
20
|
+
self.cfg = cfg
|
|
21
|
+
|
|
22
|
+
def _handle(self, src_path: str):
|
|
23
|
+
path = Path(src_path)
|
|
24
|
+
if path.suffix.lower() not in self.cfg.extensions:
|
|
25
|
+
return
|
|
26
|
+
if path.suffix.lower() == ".webp":
|
|
27
|
+
return
|
|
28
|
+
if not self.cfg.silent:
|
|
29
|
+
print(f"\n changed: {path}")
|
|
30
|
+
try:
|
|
31
|
+
convert_file(path, self.cfg)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
print(f" error: {exc}")
|
|
34
|
+
|
|
35
|
+
def on_created(self, event):
|
|
36
|
+
if not event.is_directory:
|
|
37
|
+
self._handle(event.src_path)
|
|
38
|
+
|
|
39
|
+
def on_modified(self, event):
|
|
40
|
+
if not event.is_directory:
|
|
41
|
+
self._handle(event.src_path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Watcher:
|
|
45
|
+
"""
|
|
46
|
+
Wraps a watchdog Observer. Call .start() to begin watching, .stop() to end.
|
|
47
|
+
The context manager interface handles start/stop automatically.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, cfg: ConvertConfig):
|
|
51
|
+
self.cfg = cfg
|
|
52
|
+
self._observer = Observer()
|
|
53
|
+
inputs = cfg.input if isinstance(cfg.input, list) else [cfg.input]
|
|
54
|
+
handler = _ImageEventHandler(cfg)
|
|
55
|
+
for d in inputs:
|
|
56
|
+
self._observer.schedule(handler, str(d), recursive=cfg.recursive)
|
|
57
|
+
|
|
58
|
+
def start(self):
|
|
59
|
+
self._observer.start()
|
|
60
|
+
if not self.cfg.silent:
|
|
61
|
+
inputs = self.cfg.input if isinstance(self.cfg.input, list) else [self.cfg.input]
|
|
62
|
+
print("\n webp-convert — watching...")
|
|
63
|
+
for d in inputs:
|
|
64
|
+
print(f" watching: {d}")
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def stop(self):
|
|
68
|
+
self._observer.stop()
|
|
69
|
+
self._observer.join()
|
|
70
|
+
|
|
71
|
+
def __enter__(self):
|
|
72
|
+
return self.start()
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *_):
|
|
75
|
+
self.stop()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def watch(cfg: Optional[ConvertConfig] = None, **kwargs) -> Watcher:
|
|
79
|
+
"""
|
|
80
|
+
Watch input directories and auto-convert images on change.
|
|
81
|
+
Returns a Watcher instance. Call .start() to begin (or use as context manager).
|
|
82
|
+
|
|
83
|
+
Example::
|
|
84
|
+
|
|
85
|
+
from webp_convert import watch, ConvertConfig
|
|
86
|
+
|
|
87
|
+
# Context manager (blocks until Ctrl-C)
|
|
88
|
+
with watch(ConvertConfig(input="src/images")).start():
|
|
89
|
+
try:
|
|
90
|
+
while True:
|
|
91
|
+
import time; time.sleep(1)
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# Manual control
|
|
96
|
+
watcher = watch(input="src/images").start()
|
|
97
|
+
# ... later:
|
|
98
|
+
watcher.stop()
|
|
99
|
+
"""
|
|
100
|
+
if cfg is None:
|
|
101
|
+
cfg = ConvertConfig(**kwargs)
|
|
102
|
+
return Watcher(cfg)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webp-convert
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Automatically convert PNG/JPG images to WebP with responsive sizes. CLI, file watcher, and Python API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: Pillow>=10.0.0
|
|
9
|
+
Requires-Dist: watchdog>=4.0.0
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# webp-convert (Python)
|
|
17
|
+
|
|
18
|
+
> Automatically convert PNG/JPG/GIF/TIFF images to WebP with responsive size variants.
|
|
19
|
+
> Zero-config CLI, file watcher, and programmatic Python API.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip3 install webp-convert
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Python 3.8+. Uses [Pillow](https://pillow.readthedocs.io/) — no external binaries required.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Convert everything in assets/images (default)
|
|
37
|
+
webp-convert
|
|
38
|
+
|
|
39
|
+
# Custom dir + quality
|
|
40
|
+
webp-convert src/img -q 90
|
|
41
|
+
|
|
42
|
+
# Watch mode — auto-converts on file change
|
|
43
|
+
webp-convert src/img --watch
|
|
44
|
+
|
|
45
|
+
# Custom responsive sizes
|
|
46
|
+
webp-convert src/img --sizes 1200,800,400
|
|
47
|
+
|
|
48
|
+
# Write outputs to a separate directory
|
|
49
|
+
webp-convert src/img -o dist/img
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## CLI reference
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Usage: webp-convert [INPUT_DIR] [OPTIONS]
|
|
58
|
+
|
|
59
|
+
Convert PNG/JPG images to WebP with responsive size variants.
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
-o, --output TEXT Output directory (default: same as source)
|
|
63
|
+
-q, --quality INT WebP quality 0-100 [default: 80]
|
|
64
|
+
--lossless Lossless encoding
|
|
65
|
+
--sizes W,W,... Responsive widths e.g. 1200,800,400 (0=full)
|
|
66
|
+
--no-full Skip full-size, only responsive variants
|
|
67
|
+
-r, --recursive Recurse into subdirectories
|
|
68
|
+
--no-overwrite Skip files that already have a .webp counterpart
|
|
69
|
+
-w, --watch Watch mode
|
|
70
|
+
--silent Suppress all output
|
|
71
|
+
-h, --help Show this message and exit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Programmatic API
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from webp_convert import convert, watch, convert_file, ConvertConfig, SizeSpec
|
|
80
|
+
|
|
81
|
+
# --- Convert a directory (keyword args shortcut) ---
|
|
82
|
+
results = convert(input="src/images", quality=85)
|
|
83
|
+
|
|
84
|
+
# --- Convert with a config object ---
|
|
85
|
+
cfg = ConvertConfig(
|
|
86
|
+
input="src/images",
|
|
87
|
+
output="public/images", # omit to write next to originals
|
|
88
|
+
quality=85,
|
|
89
|
+
lossless=False,
|
|
90
|
+
recursive=True,
|
|
91
|
+
overwrite=True,
|
|
92
|
+
sizes=[
|
|
93
|
+
SizeSpec(width=0, suffix=""), # full size
|
|
94
|
+
SizeSpec(width=1200, suffix="-1200"),
|
|
95
|
+
SizeSpec(width=800, suffix="-800"),
|
|
96
|
+
SizeSpec(width=400, suffix="-400"),
|
|
97
|
+
],
|
|
98
|
+
on_file=lambda r: print(f"{r.src} → {r.dest} ({r.size_bytes} bytes)"),
|
|
99
|
+
)
|
|
100
|
+
results = convert(cfg)
|
|
101
|
+
|
|
102
|
+
# --- Convert a single file ---
|
|
103
|
+
results = convert_file("hero.png", cfg)
|
|
104
|
+
|
|
105
|
+
# --- Watch mode (context manager) ---
|
|
106
|
+
import time
|
|
107
|
+
watcher = watch(ConvertConfig(input="src/images")).start()
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
time.sleep(1)
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
watcher.stop()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Output structure
|
|
118
|
+
|
|
119
|
+
Given `hero.jpg` in `src/images/`:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
src/images/
|
|
123
|
+
├── hero.jpg (original, kept by default)
|
|
124
|
+
├── hero.webp (full size)
|
|
125
|
+
├── hero-800.webp (800px wide, aspect-ratio preserved)
|
|
126
|
+
└── hero-400.webp (400px wide)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## HTML usage
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<picture>
|
|
135
|
+
<source
|
|
136
|
+
type="image/webp"
|
|
137
|
+
srcset="hero-400.webp 400w, hero-800.webp 800w, hero.webp 1600w"
|
|
138
|
+
sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1600px"
|
|
139
|
+
/>
|
|
140
|
+
<img src="hero.jpg" alt="Hero image" />
|
|
141
|
+
</picture>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
webp_convert/__init__.py
|
|
5
|
+
webp_convert/cli.py
|
|
6
|
+
webp_convert/converter.py
|
|
7
|
+
webp_convert/watcher.py
|
|
8
|
+
webp_convert.egg-info/PKG-INFO
|
|
9
|
+
webp_convert.egg-info/SOURCES.txt
|
|
10
|
+
webp_convert.egg-info/dependency_links.txt
|
|
11
|
+
webp_convert.egg-info/entry_points.txt
|
|
12
|
+
webp_convert.egg-info/requires.txt
|
|
13
|
+
webp_convert.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
webp_convert
|