fotolab 0.29.2__tar.gz → 0.31.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.
- {fotolab-0.29.2 → fotolab-0.31.0}/.gitignore +1 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/.pre-commit-config.yaml +3 -3
- {fotolab-0.29.2 → fotolab-0.31.0}/CHANGELOG.md +29 -11
- {fotolab-0.29.2 → fotolab-0.31.0}/PKG-INFO +10 -3
- {fotolab-0.29.2 → fotolab-0.31.0}/README.md +9 -2
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/__init__.py +7 -8
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/cli.py +4 -4
- fotolab-0.31.0/fotolab/subcommands/animate.py +193 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/border.py +7 -7
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/contrast.py +3 -1
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/watermark.py +37 -6
- {fotolab-0.29.2 → fotolab-0.31.0}/noxfile.py +1 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/tests/conftest.py +11 -0
- fotolab-0.31.0/tests/test_animate_subcommand.py +7 -0
- fotolab-0.31.0/tests/test_auto_subcommand.py +7 -0
- fotolab-0.31.0/tests/test_border_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_contrast_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_env_subcommand.py +23 -0
- fotolab-0.31.0/tests/test_halftone_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_info_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_montage_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_resize_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_rotate_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_sharpen_subcommand.py +6 -0
- fotolab-0.31.0/tests/test_watermark_subcommand.py +6 -0
- fotolab-0.29.2/fotolab/subcommands/animate.py +0 -131
- fotolab-0.29.2/tests/test_env_subcommand.py +0 -10
- {fotolab-0.29.2 → fotolab-0.31.0}/.coveragerc +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/.python-version +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/CONTRIBUTING.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/LICENSE.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/Pipfile +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/Pipfile.lock +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/Makefile +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/make.bat +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/CHANGELOG.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/CONTRIBUTING.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/LICENSE.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/README.md +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/_static/logo.jpg +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/conf.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/index.rst +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/__main__.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/__init__.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/auto.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/env.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/halftone.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/info.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/montage.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/resize.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/rotate.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/sharpen.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/generate +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/pyproject.toml +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/tests/__init__.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/tests/test_help_flag.py +0 -0
- {fotolab-0.29.2 → fotolab-0.31.0}/tests/test_quiet_flag.py +0 -0
@@ -48,7 +48,7 @@ repos:
|
|
48
48
|
rev: 25.1.0
|
49
49
|
hooks:
|
50
50
|
- id: black
|
51
|
-
language_version: python3.
|
51
|
+
language_version: python3.13
|
52
52
|
args:
|
53
53
|
- --line-length=79
|
54
54
|
- --target-version=py38
|
@@ -74,13 +74,13 @@ repos:
|
|
74
74
|
- --remove-all-unused-imports
|
75
75
|
language: python
|
76
76
|
files: \.py$
|
77
|
-
language_version: python3.
|
77
|
+
language_version: python3.13
|
78
78
|
|
79
79
|
- repo: https://github.com/PyCQA/flake8
|
80
80
|
rev: 7.2.0
|
81
81
|
hooks:
|
82
82
|
- id: flake8
|
83
|
-
language_version: python3.
|
83
|
+
language_version: python3.13
|
84
84
|
additional_dependencies:
|
85
85
|
- flake8-docstrings
|
86
86
|
- flake8-pytest-style
|
@@ -7,12 +7,30 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## v0.31.0 (2025-05-25)
|
11
|
+
|
12
|
+
- Add basic tests for several CLI subcommands
|
13
|
+
- Add test for auto subcommand and image fixture
|
14
|
+
- Add watermark transparency option and improve error handling
|
15
|
+
- Fix markdown in changelog
|
16
|
+
- Refine `env` subcommand test assertions
|
17
|
+
- Standardize border dimension return as a tuple
|
18
|
+
|
19
|
+
## v0.30.0 (2025-05-18)
|
20
|
+
|
21
|
+
- Add WEBP `quality`, `lossless`, and `method` args
|
22
|
+
- Bump Python version for `pre-commit` check
|
23
|
+
- Close image files after creating animation
|
24
|
+
- Update help message generated by latest Python to readme
|
25
|
+
- Update help message in readme
|
26
|
+
- Validate duration for `animate` subcommand
|
27
|
+
|
10
28
|
## v0.29.2 (2025-05-11)
|
11
29
|
|
12
30
|
- Bump deps
|
13
31
|
- Code format
|
14
32
|
- Improve info subcommand by adding error handling for missing keys
|
15
|
-
- Remove default value for image filename argument in info subcommand
|
33
|
+
- Remove default value for image filename argument in `info` subcommand
|
16
34
|
- Use context manager to open image and remove redundant image close.
|
17
35
|
|
18
36
|
## v0.29.1 (2025-05-04)
|
@@ -25,8 +43,8 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
25
43
|
|
26
44
|
## v0.29.0 (2025-04-27)
|
27
45
|
|
28
|
-
- Add `cells` argument to halftone subcommand and function
|
29
|
-
- Add grayscale option to halftone subcommand for grayscale conversion
|
46
|
+
- Add `cells` argument to `halftone` subcommand and function
|
47
|
+
- Add `grayscale` option to `halftone` subcommand for grayscale conversion
|
30
48
|
- Bump deps
|
31
49
|
- Calculate dot radius relative to cell size based on brightness.
|
32
50
|
- Refactor CLI to handle missing subcommand execution function gracefully
|
@@ -42,22 +60,22 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
42
60
|
## v0.28.5 (2025-04-13)
|
43
61
|
|
44
62
|
- Group build and publish package commands together
|
45
|
-
- Improve contrast subcommand and add pylint disable
|
46
|
-
- Prompt to publish package in release nox job
|
47
|
-
- Resolve pylint raised issue
|
48
|
-
- Validate cutoff value is between 0 and 50 in contrast subcommand
|
63
|
+
- Improve `contrast` subcommand and add `pylint` disable
|
64
|
+
- Prompt to publish package in release `nox` job
|
65
|
+
- Resolve `pylint` raised issue
|
66
|
+
- Validate `cutoff` value is between 0 and 50 in `contrast` subcommand
|
49
67
|
|
50
68
|
## v0.28.4 (2025-04-06)
|
51
69
|
|
52
70
|
- Build the release after release `nox` job
|
53
71
|
- Bump `pre-commit` for `flake8`
|
54
72
|
- Fix incorrect git options
|
55
|
-
- Install flit as deps for dev environment
|
73
|
+
- Install `flit` as deps for dev environment
|
56
74
|
- Update help message in readme
|
57
75
|
|
58
76
|
## v0.28.3 (2025-03-30)
|
59
77
|
|
60
|
-
- Bump `pre-commit` hook for validate-project
|
78
|
+
- Bump `pre-commit` hook for `validate-project`
|
61
79
|
- Commit changes after bump release
|
62
80
|
- Remove exception handling as it's handled in parent calling function
|
63
81
|
- Resolve W0718: Catching too general exception Exception
|
@@ -67,7 +85,7 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
67
85
|
## v0.28.2 (2025-03-23)
|
68
86
|
|
69
87
|
- Accept width in integer instead of string
|
70
|
-
- Bump pre-commit hook for validate-project
|
88
|
+
- Bump `pre-commit` hook for `validate-project`
|
71
89
|
- Handle file not found and other exceptions when opening/processing images
|
72
90
|
- Remove all unnecessary exceptions handling
|
73
91
|
- Use Union for border width return type to allow int or tuple
|
@@ -88,7 +106,7 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
88
106
|
- Refactor getting output filename when saving image
|
89
107
|
- Remove `--output-dir` and `--open` args from main cli
|
90
108
|
- Update help message in readme
|
91
|
-
- Use single `save_gif_image` function in sharpen and halftone subcommand
|
109
|
+
- Use single `save_gif_image` function in sharpen and `halftone` subcommand
|
92
110
|
|
93
111
|
## v0.27.2 (2025-03-02)
|
94
112
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fotolab
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.31.0
|
4
4
|
Summary: A console program that manipulate images.
|
5
5
|
Keywords: photography,photo
|
6
6
|
Author-email: Kian-Meng Ang <kianmeng@cpan.org>
|
@@ -106,7 +106,8 @@ fotolab animate -h
|
|
106
106
|
|
107
107
|
```console
|
108
108
|
usage: fotolab animate [-h] [-f FORMAT] [-d DURATION] [-l LOOP] [-op]
|
109
|
-
[-
|
109
|
+
[--webp-quality QUALITY] [--webp-lossless]
|
110
|
+
[--webp-method METHOD] [-od OUTPUT_DIR]
|
110
111
|
IMAGE_FILENAMES [IMAGE_FILENAMES ...]
|
111
112
|
|
112
113
|
positional arguments:
|
@@ -116,10 +117,16 @@ options:
|
|
116
117
|
-h, --help show this help message and exit
|
117
118
|
-f, --format FORMAT set the image format (default: 'gif')
|
118
119
|
-d, --duration DURATION
|
119
|
-
set the duration in milliseconds (
|
120
|
+
set the duration in milliseconds (must be a positive
|
121
|
+
integer, default: '2500')
|
120
122
|
-l, --loop LOOP set the loop cycle (default: '0')
|
121
123
|
-op, --open open the image using default program (default:
|
122
124
|
'False')
|
125
|
+
--webp-quality QUALITY
|
126
|
+
set WEBP quality (0-100, default: '80')
|
127
|
+
--webp-lossless enable WEBP lossless compression (default: 'False')
|
128
|
+
--webp-method METHOD set WEBP encoding method (0=fast, 6=slow/best,
|
129
|
+
default: '4')
|
123
130
|
-od, --output-dir OUTPUT_DIR
|
124
131
|
set default output folder (default: 'output')
|
125
132
|
```
|
@@ -83,7 +83,8 @@ fotolab animate -h
|
|
83
83
|
|
84
84
|
```console
|
85
85
|
usage: fotolab animate [-h] [-f FORMAT] [-d DURATION] [-l LOOP] [-op]
|
86
|
-
[-
|
86
|
+
[--webp-quality QUALITY] [--webp-lossless]
|
87
|
+
[--webp-method METHOD] [-od OUTPUT_DIR]
|
87
88
|
IMAGE_FILENAMES [IMAGE_FILENAMES ...]
|
88
89
|
|
89
90
|
positional arguments:
|
@@ -93,10 +94,16 @@ options:
|
|
93
94
|
-h, --help show this help message and exit
|
94
95
|
-f, --format FORMAT set the image format (default: 'gif')
|
95
96
|
-d, --duration DURATION
|
96
|
-
set the duration in milliseconds (
|
97
|
+
set the duration in milliseconds (must be a positive
|
98
|
+
integer, default: '2500')
|
97
99
|
-l, --loop LOOP set the loop cycle (default: '0')
|
98
100
|
-op, --open open the image using default program (default:
|
99
101
|
'False')
|
102
|
+
--webp-quality QUALITY
|
103
|
+
set WEBP quality (0-100, default: '80')
|
104
|
+
--webp-lossless enable WEBP lossless compression (default: 'False')
|
105
|
+
--webp-method METHOD set WEBP encoding method (0=fast, 6=slow/best,
|
106
|
+
default: '4')
|
100
107
|
-od, --output-dir OUTPUT_DIR
|
101
108
|
set default output folder (default: 'output')
|
102
109
|
```
|
@@ -24,7 +24,7 @@ from pathlib import Path
|
|
24
24
|
|
25
25
|
from PIL import Image
|
26
26
|
|
27
|
-
__version__ = "0.
|
27
|
+
__version__ = "0.31.0"
|
28
28
|
|
29
29
|
log = logging.getLogger(__name__)
|
30
30
|
|
@@ -59,15 +59,14 @@ def save_gif_image(
|
|
59
59
|
_open_image(new_filename)
|
60
60
|
|
61
61
|
|
62
|
-
def save_image(
|
62
|
+
def save_image(
|
63
|
+
args: argparse.Namespace,
|
64
|
+
new_image: Image.Image,
|
65
|
+
output_filename: str,
|
66
|
+
subcommand: str,
|
67
|
+
) -> None:
|
63
68
|
"""Save image after image operation.
|
64
69
|
|
65
|
-
Args:
|
66
|
-
args (argparse.Namespace): Config from command line arguments
|
67
|
-
new_image(PIL.Image.Image): Modified image
|
68
|
-
output_filename(str): Save filename image
|
69
|
-
subcommand(str): Subcommand used to call this function
|
70
|
-
|
71
70
|
Returns:
|
72
71
|
None
|
73
72
|
"""
|
@@ -15,9 +15,9 @@
|
|
15
15
|
|
16
16
|
"""A console program to manipulate photos.
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
18
|
+
website: https://github.com/kianmeng/fotolab
|
19
|
+
changelog: https://github.com/kianmeng/fotolab/blob/master/CHANGELOG.md
|
20
|
+
issues: https://github.com/kianmeng/fotolab/issues
|
21
21
|
"""
|
22
22
|
|
23
23
|
import argparse
|
@@ -143,7 +143,7 @@ def main(args: Optional[Sequence[str]] = None) -> None:
|
|
143
143
|
# correctly
|
144
144
|
log.error(
|
145
145
|
"subcommand '%s' is missing its execution function.",
|
146
|
-
parsed_args.command
|
146
|
+
parsed_args.command,
|
147
147
|
)
|
148
148
|
parser.print_help(sys.stderr)
|
149
149
|
raise SystemExit(1)
|
@@ -0,0 +1,193 @@
|
|
1
|
+
# Copyright (C) 2024,2025 Kian-Meng Ang
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify it under
|
4
|
+
# the terms of the GNU Affero General Public License as published by the Free
|
5
|
+
# Software Foundation, either version 3 of the License, or (at your option) any
|
6
|
+
# later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
9
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
10
|
+
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
11
|
+
# details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU Affero General Public License
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
"""Animate subcommand."""
|
17
|
+
|
18
|
+
import argparse
|
19
|
+
import logging
|
20
|
+
from pathlib import Path
|
21
|
+
|
22
|
+
from PIL import Image
|
23
|
+
|
24
|
+
from fotolab import _open_image
|
25
|
+
|
26
|
+
log = logging.getLogger(__name__)
|
27
|
+
|
28
|
+
|
29
|
+
def _validate_duration(value: str) -> int:
|
30
|
+
"""Validate that the duration is a positive integer."""
|
31
|
+
try:
|
32
|
+
ivalue = int(value)
|
33
|
+
if ivalue <= 0:
|
34
|
+
raise argparse.ArgumentTypeError(
|
35
|
+
f"duration must be a positive integer, but got {value}"
|
36
|
+
)
|
37
|
+
return ivalue
|
38
|
+
except ValueError as e:
|
39
|
+
raise argparse.ArgumentTypeError(
|
40
|
+
f"duration must be an integer, but got '{value}'"
|
41
|
+
) from e
|
42
|
+
|
43
|
+
|
44
|
+
def build_subparser(subparsers) -> None:
|
45
|
+
"""Build the subparser."""
|
46
|
+
animate_parser = subparsers.add_parser("animate", help="animate an image")
|
47
|
+
|
48
|
+
animate_parser.set_defaults(func=run)
|
49
|
+
|
50
|
+
animate_parser.add_argument(
|
51
|
+
dest="image_filenames",
|
52
|
+
help="set the image filenames",
|
53
|
+
nargs="+",
|
54
|
+
type=str,
|
55
|
+
default=None,
|
56
|
+
metavar="IMAGE_FILENAMES",
|
57
|
+
)
|
58
|
+
|
59
|
+
animate_parser.add_argument(
|
60
|
+
"-f",
|
61
|
+
"--format",
|
62
|
+
dest="format",
|
63
|
+
type=str,
|
64
|
+
choices=["gif", "webp"],
|
65
|
+
default="gif",
|
66
|
+
help="set the image format (default: '%(default)s')",
|
67
|
+
metavar="FORMAT",
|
68
|
+
)
|
69
|
+
|
70
|
+
animate_parser.add_argument(
|
71
|
+
"-d",
|
72
|
+
"--duration",
|
73
|
+
dest="duration",
|
74
|
+
type=_validate_duration,
|
75
|
+
default=2500,
|
76
|
+
help="set the duration in milliseconds (must be a positive integer, default: '%(default)s')",
|
77
|
+
metavar="DURATION",
|
78
|
+
)
|
79
|
+
|
80
|
+
animate_parser.add_argument(
|
81
|
+
"-l",
|
82
|
+
"--loop",
|
83
|
+
dest="loop",
|
84
|
+
type=int,
|
85
|
+
default=0,
|
86
|
+
help="set the loop cycle (default: '%(default)s')",
|
87
|
+
metavar="LOOP",
|
88
|
+
)
|
89
|
+
|
90
|
+
animate_parser.add_argument(
|
91
|
+
"-op",
|
92
|
+
"--open",
|
93
|
+
default=False,
|
94
|
+
action="store_true",
|
95
|
+
dest="open",
|
96
|
+
help="open the image using default program (default: '%(default)s')",
|
97
|
+
)
|
98
|
+
|
99
|
+
animate_parser.add_argument(
|
100
|
+
"--webp-quality",
|
101
|
+
dest="webp_quality",
|
102
|
+
type=int,
|
103
|
+
default=80,
|
104
|
+
choices=range(0, 101),
|
105
|
+
help="set WEBP quality (0-100, default: '%(default)s')",
|
106
|
+
metavar="QUALITY",
|
107
|
+
)
|
108
|
+
|
109
|
+
animate_parser.add_argument(
|
110
|
+
"--webp-lossless",
|
111
|
+
dest="webp_lossless",
|
112
|
+
default=False,
|
113
|
+
action="store_true",
|
114
|
+
help="enable WEBP lossless compression (default: '%(default)s')",
|
115
|
+
)
|
116
|
+
|
117
|
+
animate_parser.add_argument(
|
118
|
+
"--webp-method",
|
119
|
+
dest="webp_method",
|
120
|
+
type=int,
|
121
|
+
default=4,
|
122
|
+
choices=range(0, 7),
|
123
|
+
help="set WEBP encoding method (0=fast, 6=slow/best, default: '%(default)s')",
|
124
|
+
metavar="METHOD",
|
125
|
+
)
|
126
|
+
|
127
|
+
animate_parser.add_argument(
|
128
|
+
"-od",
|
129
|
+
"--output-dir",
|
130
|
+
dest="output_dir",
|
131
|
+
default="output",
|
132
|
+
help="set default output folder (default: '%(default)s')",
|
133
|
+
)
|
134
|
+
|
135
|
+
|
136
|
+
def run(args: argparse.Namespace) -> None:
|
137
|
+
"""Run animate subcommand.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
args (argparse.Namespace): Config from command line arguments
|
141
|
+
|
142
|
+
Returns:
|
143
|
+
None
|
144
|
+
"""
|
145
|
+
log.debug(args)
|
146
|
+
|
147
|
+
first_image_filepath = args.image_filenames[0]
|
148
|
+
main_frame = None
|
149
|
+
other_frames = []
|
150
|
+
|
151
|
+
try:
|
152
|
+
main_frame = Image.open(first_image_filepath)
|
153
|
+
|
154
|
+
for image_filename in args.image_filenames[1:]:
|
155
|
+
img = Image.open(image_filename)
|
156
|
+
other_frames.append(img)
|
157
|
+
|
158
|
+
image_file = Path(first_image_filepath)
|
159
|
+
new_filename = Path(
|
160
|
+
args.output_dir,
|
161
|
+
image_file.with_name(f"animate_{image_file.stem}.{args.format}"),
|
162
|
+
)
|
163
|
+
new_filename.parent.mkdir(parents=True, exist_ok=True)
|
164
|
+
|
165
|
+
log.info("animate image: %s", new_filename)
|
166
|
+
|
167
|
+
save_kwargs = {
|
168
|
+
"format": args.format,
|
169
|
+
"append_images": other_frames,
|
170
|
+
"save_all": True,
|
171
|
+
"duration": args.duration,
|
172
|
+
"loop": args.loop,
|
173
|
+
"optimize": True, # General optimization, good for GIF
|
174
|
+
}
|
175
|
+
|
176
|
+
if args.format == "webp":
|
177
|
+
save_kwargs["quality"] = args.webp_quality
|
178
|
+
save_kwargs["lossless"] = args.webp_lossless
|
179
|
+
save_kwargs["method"] = args.webp_method
|
180
|
+
# Pillow's WEBP save doesn't use a general 'optimize' like GIF.
|
181
|
+
# Specific WEBP params like 'method' and 'quality' control this.
|
182
|
+
# We can remove 'optimize' if it causes issues or is ignored for WEBP.
|
183
|
+
# For now, let's keep it, Pillow might handle it or ignore it.
|
184
|
+
|
185
|
+
main_frame.save(new_filename, **save_kwargs)
|
186
|
+
finally:
|
187
|
+
if main_frame:
|
188
|
+
main_frame.close()
|
189
|
+
for frame in other_frames:
|
190
|
+
frame.close()
|
191
|
+
|
192
|
+
if args.open:
|
193
|
+
_open_image(new_filename)
|
@@ -26,7 +26,7 @@ from fotolab import save_image
|
|
26
26
|
log = logging.getLogger(__name__)
|
27
27
|
|
28
28
|
|
29
|
-
def build_subparser(subparsers) -> None:
|
29
|
+
def build_subparser(subparsers: argparse._SubParsersAction) -> None:
|
30
30
|
"""Build the subparser."""
|
31
31
|
border_parser = subparsers.add_parser("border", help="add border to image")
|
32
32
|
|
@@ -150,17 +150,16 @@ def run(args: argparse.Namespace) -> None:
|
|
150
150
|
|
151
151
|
def get_border(
|
152
152
|
args: argparse.Namespace,
|
153
|
-
) ->
|
153
|
+
) -> Tuple[int, int, int, int]:
|
154
154
|
"""Calculate the border dimensions.
|
155
155
|
|
156
156
|
Args:
|
157
157
|
args (argparse.Namespace): Command line arguments
|
158
158
|
|
159
159
|
Returns:
|
160
|
-
|
161
|
-
If individual widths are specified,
|
162
|
-
|
163
|
-
sides.
|
160
|
+
Tuple[int, int, int, int]: Border dimensions in pixels as (left, top,
|
161
|
+
right, bottom) widths. If individual widths are not specified,
|
162
|
+
a uniform width is returned for all sides.
|
164
163
|
"""
|
165
164
|
if any(
|
166
165
|
[
|
@@ -176,4 +175,5 @@ def get_border(
|
|
176
175
|
args.width_right,
|
177
176
|
args.width_bottom,
|
178
177
|
)
|
179
|
-
|
178
|
+
# If no individual widths are specified, use the general width for all sides
|
179
|
+
return (args.width, args.width, args.width, args.width)
|
@@ -31,7 +31,9 @@ def _validate_cutoff(value: str) -> float:
|
|
31
31
|
try:
|
32
32
|
f_value = float(value)
|
33
33
|
except ValueError as e:
|
34
|
-
raise argparse.ArgumentTypeError(
|
34
|
+
raise argparse.ArgumentTypeError(
|
35
|
+
f"invalid float value: '{value}'"
|
36
|
+
) from e
|
35
37
|
if not 0 <= f_value <= 50:
|
36
38
|
raise argparse.ArgumentTypeError(
|
37
39
|
f"cutoff value {f_value} must be between 0 and 50"
|
@@ -129,6 +129,21 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
|
|
129
129
|
metavar="OUTLINE_COLOR",
|
130
130
|
)
|
131
131
|
|
132
|
+
watermark_parser.add_argument(
|
133
|
+
"-a",
|
134
|
+
"--alpha",
|
135
|
+
dest="alpha",
|
136
|
+
type=int,
|
137
|
+
default=128,
|
138
|
+
choices=range(0, 256),
|
139
|
+
metavar="ALPHA_VALUE",
|
140
|
+
help=(
|
141
|
+
"set the transparency of the watermark text (0-255, "
|
142
|
+
"where 0 is fully transparent and 255 is fully opaque; "
|
143
|
+
"default: '%(default)s')"
|
144
|
+
),
|
145
|
+
)
|
146
|
+
|
132
147
|
watermark_parser.add_argument(
|
133
148
|
"--camera",
|
134
149
|
default=False,
|
@@ -176,7 +191,15 @@ def run(args: argparse.Namespace) -> None:
|
|
176
191
|
log.debug(args)
|
177
192
|
|
178
193
|
for image_filename in args.image_filenames:
|
179
|
-
|
194
|
+
try:
|
195
|
+
image: Image.Image = Image.open(image_filename)
|
196
|
+
except FileNotFoundError:
|
197
|
+
log.error("Image file not found: %s", image_filename)
|
198
|
+
continue
|
199
|
+
except Exception as e:
|
200
|
+
log.error("Could not open image %s: %s", image_filename, e)
|
201
|
+
continue
|
202
|
+
|
180
203
|
if image.format == "GIF":
|
181
204
|
watermark_gif_image(image, image_filename, args)
|
182
205
|
else:
|
@@ -202,7 +225,7 @@ def watermark_gif_image(
|
|
202
225
|
frames: list[Image.Image] = []
|
203
226
|
for frame in ImageSequence.Iterator(original_image):
|
204
227
|
watermarked_frame: Image.Image = watermark_image(
|
205
|
-
args, frame.convert("RGBA")
|
228
|
+
args, frame.convert("RGBA"), args.alpha
|
206
229
|
)
|
207
230
|
frames.append(watermarked_frame)
|
208
231
|
|
@@ -241,11 +264,11 @@ def watermark_non_gif_image(
|
|
241
264
|
Returns:
|
242
265
|
Image.Image: The watermarked image
|
243
266
|
"""
|
244
|
-
return watermark_image(args, original_image)
|
267
|
+
return watermark_image(args, original_image, args.alpha)
|
245
268
|
|
246
269
|
|
247
270
|
def watermark_image(
|
248
|
-
args: argparse.Namespace, original_image: Image.Image
|
271
|
+
args: argparse.Namespace, original_image: Image.Image, alpha: int
|
249
272
|
) -> Image.Image:
|
250
273
|
"""Watermark an image."""
|
251
274
|
watermarked_image: Image.Image = original_image.copy()
|
@@ -268,13 +291,21 @@ def watermark_image(
|
|
268
291
|
calc_padding(original_image, args),
|
269
292
|
)
|
270
293
|
|
294
|
+
try:
|
295
|
+
font_fill_color = ImageColor.getrgb(args.font_color)
|
296
|
+
stroke_fill_color = ImageColor.getrgb(args.outline_color)
|
297
|
+
except ValueError:
|
298
|
+
log.error("Invalid font or outline color specified. Using defaults.")
|
299
|
+
font_fill_color = ImageColor.getrgb("white")
|
300
|
+
stroke_fill_color = ImageColor.getrgb("black")
|
301
|
+
|
271
302
|
draw.text(
|
272
303
|
(position_x, position_y),
|
273
304
|
text,
|
274
305
|
font=font,
|
275
|
-
fill=(*
|
306
|
+
fill=(*font_fill_color, alpha),
|
276
307
|
stroke_width=calc_font_outline_width(original_image, args),
|
277
|
-
stroke_fill=(*
|
308
|
+
stroke_fill=(*stroke_fill_color, alpha),
|
278
309
|
)
|
279
310
|
return watermarked_image
|
280
311
|
|
@@ -27,6 +27,17 @@ def fixture_csv_file(tmpdir):
|
|
27
27
|
return csv_file
|
28
28
|
|
29
29
|
|
30
|
+
@pytest.fixture(autouse=True, name="image_file")
|
31
|
+
def fixture_image_file(tmpdir):
|
32
|
+
def image_file(filename):
|
33
|
+
src = Path(FIXTURE_PATH, filename)
|
34
|
+
des = Path(tmpdir, src.name)
|
35
|
+
copyfile(src, des)
|
36
|
+
return des
|
37
|
+
|
38
|
+
return image_file
|
39
|
+
|
40
|
+
|
30
41
|
@pytest.fixture(autouse=True, name="cli_runner")
|
31
42
|
def fixture_cli_runner(tmpdir):
|
32
43
|
def cli_runner(*args, **kwargs):
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# pylint: disable=C0114,C0116
|
2
|
+
|
3
|
+
import platform
|
4
|
+
import sys
|
5
|
+
|
6
|
+
from fotolab import __version__
|
7
|
+
|
8
|
+
|
9
|
+
def test_env_output(cli_runner):
|
10
|
+
ret = cli_runner("env")
|
11
|
+
|
12
|
+
actual_sys_version = sys.version.replace("\n", "")
|
13
|
+
actual_platform = platform.platform()
|
14
|
+
|
15
|
+
expected_output = (
|
16
|
+
f"fotolab: {__version__}\n"
|
17
|
+
f"python: {actual_sys_version}\n"
|
18
|
+
f"platform: {actual_platform}\n"
|
19
|
+
)
|
20
|
+
|
21
|
+
assert ret.stdout == expected_output
|
22
|
+
assert ret.stderr == ""
|
23
|
+
assert ret.returncode == 0
|
@@ -1,131 +0,0 @@
|
|
1
|
-
# Copyright (C) 2024,2025 Kian-Meng Ang
|
2
|
-
#
|
3
|
-
# This program is free software: you can redistribute it and/or modify it under
|
4
|
-
# the terms of the GNU Affero General Public License as published by the Free
|
5
|
-
# Software Foundation, either version 3 of the License, or (at your option) any
|
6
|
-
# later version.
|
7
|
-
#
|
8
|
-
# This program is distributed in the hope that it will be useful, but WITHOUT
|
9
|
-
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
10
|
-
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
11
|
-
# details.
|
12
|
-
#
|
13
|
-
# You should have received a copy of the GNU Affero General Public License
|
14
|
-
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
-
|
16
|
-
"""Animate subcommand."""
|
17
|
-
|
18
|
-
import argparse
|
19
|
-
import logging
|
20
|
-
from pathlib import Path
|
21
|
-
|
22
|
-
from PIL import Image
|
23
|
-
|
24
|
-
from fotolab import _open_image
|
25
|
-
|
26
|
-
log = logging.getLogger(__name__)
|
27
|
-
|
28
|
-
|
29
|
-
def build_subparser(subparsers) -> None:
|
30
|
-
"""Build the subparser."""
|
31
|
-
animate_parser = subparsers.add_parser("animate", help="animate an image")
|
32
|
-
|
33
|
-
animate_parser.set_defaults(func=run)
|
34
|
-
|
35
|
-
animate_parser.add_argument(
|
36
|
-
dest="image_filenames",
|
37
|
-
help="set the image filenames",
|
38
|
-
nargs="+",
|
39
|
-
type=str,
|
40
|
-
default=None,
|
41
|
-
metavar="IMAGE_FILENAMES",
|
42
|
-
)
|
43
|
-
|
44
|
-
animate_parser.add_argument(
|
45
|
-
"-f",
|
46
|
-
"--format",
|
47
|
-
dest="format",
|
48
|
-
type=str,
|
49
|
-
choices=["gif", "webp"],
|
50
|
-
default="gif",
|
51
|
-
help="set the image format (default: '%(default)s')",
|
52
|
-
metavar="FORMAT",
|
53
|
-
)
|
54
|
-
|
55
|
-
animate_parser.add_argument(
|
56
|
-
"-d",
|
57
|
-
"--duration",
|
58
|
-
dest="duration",
|
59
|
-
type=int,
|
60
|
-
default=2500,
|
61
|
-
help="set the duration in milliseconds (default: '%(default)s')",
|
62
|
-
metavar="DURATION",
|
63
|
-
)
|
64
|
-
|
65
|
-
animate_parser.add_argument(
|
66
|
-
"-l",
|
67
|
-
"--loop",
|
68
|
-
dest="loop",
|
69
|
-
type=int,
|
70
|
-
default=0,
|
71
|
-
help="set the loop cycle (default: '%(default)s')",
|
72
|
-
metavar="LOOP",
|
73
|
-
)
|
74
|
-
|
75
|
-
animate_parser.add_argument(
|
76
|
-
"-op",
|
77
|
-
"--open",
|
78
|
-
default=False,
|
79
|
-
action="store_true",
|
80
|
-
dest="open",
|
81
|
-
help="open the image using default program (default: '%(default)s')",
|
82
|
-
)
|
83
|
-
|
84
|
-
animate_parser.add_argument(
|
85
|
-
"-od",
|
86
|
-
"--output-dir",
|
87
|
-
dest="output_dir",
|
88
|
-
default="output",
|
89
|
-
help="set default output folder (default: '%(default)s')",
|
90
|
-
)
|
91
|
-
|
92
|
-
|
93
|
-
def run(args: argparse.Namespace) -> None:
|
94
|
-
"""Run animate subcommand.
|
95
|
-
|
96
|
-
Args:
|
97
|
-
args (argparse.Namespace): Config from command line arguments
|
98
|
-
|
99
|
-
Returns:
|
100
|
-
None
|
101
|
-
"""
|
102
|
-
log.debug(args)
|
103
|
-
|
104
|
-
first_image = args.image_filenames[0]
|
105
|
-
animated_image = Image.open(first_image)
|
106
|
-
|
107
|
-
append_images = []
|
108
|
-
for image_filename in args.image_filenames[1:]:
|
109
|
-
append_images.append(Image.open(image_filename))
|
110
|
-
|
111
|
-
image_file = Path(first_image)
|
112
|
-
new_filename = Path(
|
113
|
-
args.output_dir,
|
114
|
-
image_file.with_name(f"animate_{image_file.stem}.{args.format}"),
|
115
|
-
)
|
116
|
-
new_filename.parent.mkdir(parents=True, exist_ok=True)
|
117
|
-
|
118
|
-
log.info("animate image: %s", new_filename)
|
119
|
-
|
120
|
-
animated_image.save(
|
121
|
-
new_filename,
|
122
|
-
format=args.format,
|
123
|
-
append_images=append_images,
|
124
|
-
save_all=True,
|
125
|
-
duration=args.duration,
|
126
|
-
loop=args.loop,
|
127
|
-
optimize=True,
|
128
|
-
)
|
129
|
-
|
130
|
-
if args.open:
|
131
|
-
_open_image(new_filename)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|